应用开发基础之 - 并发编程
1:需求与目的
为什么需要并发编程
1:充分利用CPU
任务执行中有部分是IO操作,执行IO时线程阻塞,不在消耗CPU。可在IO处理时CPU执行其他任务。
多个涉及io的任务可并发执行。任务可拆分IO与非IO,并发执行。
计算机支持多核并行运行,充分利用多核。
2:与人交互的任务
执行任务时人需要需要响应, 体验更好。
耗比如时处理,显示处理进度,可中途取消处理。
3:效率差异匹配--人与计算机处理任务进度有差异,让人与计算机都忙起来不互相等待。
人下指令计算机处理,人的任务轻,执行快,计算机任务重,处理慢--人不等计算机,异步获取结果。
人响应慢,计算机处理快,计算机偷空做其他任务。
比如:
文档编辑,人远慢计算机快。人在编辑时,计算机可执行其他任务。
音乐,视频,计算机处理速度远快于人的感官处理。
视频画面-一秒几十个画面。语音几十赫兹。人就感觉是连续的,音频边下载边听。
拷贝文件-拷贝时间长中,人可继续写文档,拷贝完告知即可
4:网络应用同时响应多个请求
网络环境下,同时收到10个请求,不能按排队策略处理,特殊的处理慢的可以返回慢,处理快的返回快。否则有一个处理慢的请求会导致其他正常请求也慢。
饭馆来几座客人,先后到达,后厨安排也要交叉都给做,客人感觉后厨一直在做自己的菜。不断有菜端上来,边吃边上。 如果排队一座一座的做,客人就会干等。体验差,总体等待时间也长。
5:任务拆分为多个阶段或多组并发执行,提高执行效率
2:要解决的问题
设计并发程序的步骤与内容
2.1 任务拆分
如何拆到多个任务,任务如何设计组合
阿姆达尔定律
完成复杂工作可获得的加速比是有限的,取决于必须串行执行的部分 。
应最小化串行代码的粒度
比如刷屋子 , 5间房5个人刷,如果个人完成各个房间的刷效率最高可以不协作,5个并行。
但如果其中某一个房子大,其他人刷完后,过来帮忙,就需要协作。-这部分不能完全并行的部分决定程序的执行总时间的限制。
2.1.1 不直接相关的任务并行
合理划分任务与执行者线程(进程)执行就行。
优先级高的优先保障:不同种类任务隔离
比如后台程序有很多种服务,有些服务优先级别高,用专门线程池去处理。
或优先级低的,不稳定的用专门的线程隔离-不影响高优先级的。
2.1.2 交互任务
计算机进度慢人快
单独任务处理交互,响应用户
人提交后异步,执行完毕后反馈
人慢计算机进度快
分匹配到人的需求,剩余可开多个任务响应不同用户,或处理不同任务。
音视频只给满足人的感知频率就行。
2.1.3 任务拆分与并行
根据特点拆分
1>并行处理多个同类任务
多个线程并行处理多个同类大任务
比如任务中有IO请求,多个线程响应多个请求。web请求的服务。
2>任务按数据拆分
将任务要处理数据进行拆分用多个线程处理
3>任务拆分多个阶段,部分阶段可并行
将任务分为多个阶段不同阶段用不同线程数去处理。
部分阶段间可并行运行--充分利用多核,提高效率
部分阶段内可将数据拆分为几份并行运行。
4>三种拆法综合运用
5:任务合并由专门线程处理
多个任务都需要完成某个任务,这些任务可以合并,提高执行效率,将这部分任务合并由专门任务(线程)去处理。(拆法类似任务3)--参考后面的串行线程模式-比如UI事件就由单一线程执行
拆分后任务与线程关系
1>线程1….N -->任务1….N
2>任务(数据1-N)--->小任务1(数据1)….小任务N(数据N)-->线程1….N
3>任务:步骤1…..N--->任务1….M:->任务1-步骤1 ...任务M(步骤M)--->
多个线程池对应不同阶段(不认步骤单线程,部分步骤多线程)
4>1,2,3结合
2.2 任务的配合
任务拆分后协作通信
1:修改共享资源
对应共享资源的安全访问。
2:线程间配合
将任务交给其他执行者:对应任务与线程的创建,完成通知,销毁等
任务执行过程中协作:执行者(线程|进程)通信
2.3 执行资源管理
执行资源-线程的管理
java:自主创建线程,使用线程池(多个)
go: 程序负责创建协程(任务),底层线程由语言平台提供的调度器负责。
3:基础概念
3.1 进程与线程
线程进程都是操作系统进程的封装
进程:OS分配资源的最小单位
线程:cpu调用的最小单位
底层实现:将执行中需要的数据在存储中隔离,通过虚拟映射表控制各自可访问数据,
OS通过硬件异常处理程序,时间片轮动调度多个线程。
进程与线程比较
进程创建销毁切换开销大,但数据相互相互独立(OS保证),没有线程安全问题,但共享也比较麻烦。
线程正好相反:切换开销小,同一进程内线程间共享存储,共享方便,但要注意共享导致问题。
并发与并行
并行:硬件支持的最高同时运行线程数--可同时执行-不切换
并发:超过并行数,基于时间片轮换。
并发量
系统的最大并发量:是单位时间可以处理的并发数。
系统实际请求数可以大于系统的最大并发量,只是等待响应时间变长,如果持续大于最大值,将有部分无法在容忍时间内处理。短时间是没问题的--这也是异步处理的理由。
并发量理论极限
1:系统通过CPU时间片轮动分配给不同的线程。由于CPU轮动有开销。
实际活跃线程(非阻塞线程)等于核心数真实效率是最高的。
如果活跃线程过多在不阻塞的情况下不断轮动总效率是下降的。
线程本身也会占用系统的内存资源。
2:最优线程数:任务执行时间/(任务执行时间-IO等待时间)*CPU内核数
3.2 同步异步阻塞与非阻塞
3.2.1 场景
两个任务:任务A,任务B
两个线程:线程A-A任务的执行线程,线程B-B任务的执行线程
依赖关系:
依赖:任务A依赖任务B
不依赖:任务A不依赖B
触发关系:
任务B被任务A触发
任务B不是A触发
3.2.2 同步与异步
按依赖与触发两个维度任务间关系
同步:存在依赖关系,任务A依赖任务B的就属于同步。
一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成或继续执行,这是一种可靠的任务序列。
如果触发并依赖-一般是同步调用,(也可以调用后做其他事情,稍后查看调用结果-feature模式)
非触发的依赖:需要结果时可以轮询或阻塞等待。
异步:任务A通知任务B开始执行,但任务A不依赖任务B的完成。
是一种不可靠任务序列。
直接无关:任务A不依赖B,也不触发B开始执行,就是两个无直接关系的任务了。
不需依赖的依赖
任务没有依赖关系到有触发关系时,两者是异步,这里的不依赖不是业务上的不依赖,而是设计上的不依赖,如业务上A不依赖B,A触发B执行后,阻塞等待B执行完继续执行(不关注执行结果,但却等对方执行),就是设计上的依赖。应避免任务在业务上无依赖关系,但设计出依赖的情况。
3.2.3 阻塞与非阻塞
A依赖B的情况下,线程A在等待任务B完成状态通知时的状态差异
阻塞:线程A等待获取通知,获取任务B完成状态前挂起不执行
非阻塞:线程A在获取任务B完成状态是,可执行其他任务(不挂起),通过轮询或回调其他通知的方式获取。
阻塞就是依赖别人的时候蹲下不动等别人完成。
阻塞与触发关系
阻塞与线程间的触发关系不相干
1:同步-触发并阻塞
最常见在任务A的执行线程A触发任务B,(B是内核线程执行IO操作(IO操作都是内核处理的),调用后等待B的执行结果。最常见的就是B是内核线程执行IO操作(IO操作都是内核处理的)。
2:同步-触发非阻塞
线程A触发B后也可以非阻塞,继续执行其他任务,定期检查状态。JAVA的Feature接口就是这个逻辑。触发后可先执行其他任务,后续通过Featue获取执行结果。
3:触发异步
即A触发B,不依赖B
A触发B后不再需要跟B的消息通知,不关注B的执行结果。让B异步执行就行。
这种也不算非阻塞(不依赖B的也就不算非阻塞)
4:非触发阻塞或非阻塞
任务A的线程A,A任务依赖B任务,线程A阻塞(比如用join,或wait)等待任务B的完成,比如生产消费模式。线程也可以不阻塞执行其他任务
获取通知状态的方式:
轮询,通知,回调。
通知方式依赖被调用者的接口。
因此存在触发关系时,有时虽然业务上不存在依赖,可以异步,但被调用者提供的接口会导致被调用者挂起,被动变成了同步阻塞了。
实践方案
同步阻塞:最常用,最简单,效率不高
同步不阻塞
异步不阻塞
阻塞会导致上下文的切换,追求高性能的场景尽量不阻塞。
4:方案
如果实现设计目标--落地
4.1 任务执行者创建销毁
执行者可以使线程,进程。本文只介绍当前工作中普遍使用的多线程并发
4.1.1 目的
创建线程,将任务交给线程,线程不使用时释放
4.1.2 问题
任务如何封装
线程如何获取
4.1.2 方案
任务封装
创建线程是os创建一个独立的执行环境(栈,程序计数等)。
高级语言线程任务封装:提供一个方法,有时会附带任务的一些数据(方法上下文)
Java:传递的参数是个接口约束,接口对应实例里面可以包括上下文数据,接口的run方法就是任务入口。
go:传递方法调用。参数可以在方法调用里,方法的上下文也可通过闭包模式传递进去。
线程获取
1:用一次创建一次,用的时候新创建
2:重用,预先创建,用的时候从已经有的池中获取。
4.1.3 实践
1:任务量比较少,不需要线程一直活着等待执行,就用一次创建一次。
2:线程池解决的问题
任务量比较多的场景,频繁创建销毁线程消耗大,线程重用减少消耗。
过多的活跃线程是时间片轮询的,线程间频繁切换性能低,合理设置线程数能避免频繁切换。
线程池还能避免请求过多创建过多线程导致系统崩溃
线程池还可用来进行任务隔离,将不同类型的任务用不同的大小线程池处理,隔离相互的影响。其中一类任务出问题响应不过来,不影响另一类任务的处理。
3:部分语言或框架底层将线程的管理封装,上层只创建任务不创建线程。由框架保证合理的活跃线程数,保证高效与安全。
go的协程,AKKA框架 都将线程管理封装起来了。
不过封装后实现任务分类隔离反而复杂。
4:线程创建多少
线程创建本身有消耗,线程间切换也有消耗,真正执行任务的线程与核心匹配效率最高。
go 默认底层就这么做了,活跃线程数(非阻塞状态)与核心数相等
4.2 线程通信
线程间交互的各种手段都属于线程通信的范围。
通信的手段
基于信号通知
基于共享数据
基于消息通道
借助消息通道通过数据交换方式通信。
线程间不直接依赖, 都直接依赖消息通道。
不同于共享资源,通道里的消息一般是一份拷贝。
具体通用实现原理及api
基于信号:底层硬件的中断机制,线程间协调时常用的通信手段,包括锁(同步原语,notify,wait,condition,park,unpark等),线程状态变更(java thread :join,sleep)
共享数据:线程间不直接相互阻塞(进入临界区时除外),通过共享数据互通信息。共享数据要进行安全保护。
消息通道:通过数据拷贝模式实现,通过队列实现线程间通信。go 推荐这种方式的线程协作(chan)
通信内容
线程状态变化:等待,唤起,执行完毕,打断。
任务相关数据:共享数据,消息等
线程执行结果:包括执行成功的结果与执行异常的数据
4.2.1线程状态
从os角度存活线程状态包括:就绪,执行,阻塞
就绪:等待被调度的线程
执行:cpu执行中的线程
阻塞:线程由于各种原因被挂起,包括I/O阻塞,被动或主动挂起
对线程的启动,关闭,打断,阻塞,等待线程执行完毕(join)等都是基于线程状态的通信。
线程的阻塞与唤醒是后续基于信号量机制的锁等各种通信方式的基础。
java相关
join,wait,notify,sleep,park,unpark.interrupt
执行前设置,可捕获线程执行异常 Thread.setUncaughtExceptionHandler(UncaughtExceptiong )
4.2.3 锁与线程安全
线程安全与临界区:
对共享资源的操作,在同一时刻只能允许一个线程执行,否则会出现意料外的异常。
这些存在互斥关系的相同或不同代码段间需要通过机制保障其顺序执行。
冲突分类
并发会导致结果异常也可称为冲突,包括以下类型与原因
1:写写冲突
最常见的是读后写,写覆盖。
不能一次写完分别写一部分覆盖后导致数据不一致。
2:读写冲突
一次不能读完时如何保证读到的是正确的(原子性)
读的值正确
临界区内被读到的数据正确
缓存问题-要读到最新写的数据。
部分冲突的底层原因
原子性:读写时有些值占用的空间大,不能原子读写。
可见性:缓存导致某个线程写入后,其他线程读不到最新值。
有序性:代码中看到的代码顺序在执行时可能重排序。单个线程执行没问题,当多个线程访问共享资源时会出现不可预计的结果。
语言中有些指令可以保证可见性与多线程下指令重排序的约束,使指令前后满足串行性
比如java 同步关键字 锁前后满足串行性。voliatile 满足可见性
信号量
解决线程同步问题
信号量s 是具有非负整数,这孩子能由两种特殊操作来处理,两种操作分别称为P 和V
P(s):s 如果大于0,减1,并返回,如果s等于0,挂起执行线程。 等另一个V将s变为非0时重启改线程。
重启线程后s 减1,控制返回调用者
V(s): s 加1,如果有其他线程在P操作时等待s变为非0,重启这些线程中的一个。
P操作中的s的判断与减1是原子的不可打断。
保障临界区安全的方法
1:单线程模型执行
始终用一个线程执行,可避免临界区的线程安全问题
处理时间很短或不频繁或对并发比敏感的任务,用一个线程承担的起所有任务。尤其是异步的场景更适合,通过消息将任务传递给线程 ,线程执行完毕通过消息反馈,或调用方轮询执行结果。
整个任务用多个线程并发执行存在临界区,将临界区内按数据进行拆分为多个不同的任务,拆分后不同阶段的任务间不存在争用。 然后用单线程负责部分任务,线程间无共享资源,这样就可避免资源争用问题。
2:临界区通过信号量机制保障(加锁)
锁相关问题
无死锁:只有一个想使用时最终必定成功,两个都想使用时有一个可以成功。如果两个想使用都不能成功-就是死锁。
无饥饿:两个都多次使用时,不能一个永远没机会。
等待问题:其中一方占有后,去做其他事情,导致请求的另一方长时间等待。占有资源方长期没有响应-长期占有。
无死锁与无饥饿设计锁的应该满足,等待问题使用锁应该避免。
锁的实现
原子性的判断与更改:硬件CAS原语支持。
线程挂起与唤醒:操作系统支持
锁分类
多个角度锁分类及锁的应用:公平锁,非公平锁,可重入锁,不可重入锁。自选锁。独享|互斥,共享锁,读写锁。乐观锁,悲观锁,分段锁。
锁使用技巧:尽量用小锁,减低锁的时间,避免不必要的争用。
4.2.3 java锁
实现
cas:调用底层cas接口
线程挂起:park,unpark,notify,wait
具体锁
同步关键字:synchronized
靠CAS原语修改对象头(64bit)实现。
偏向锁-轻量级锁-重量级锁
偏向锁:第一个线程获取锁 --获取的是偏向锁
轻量级锁:已经是偏向锁时如果其他线程在争取,升级为轻量级锁,进行自旋。
重量级锁:轻量级锁自旋一定次数还获取不到就升级到重量级锁。线程进入阻塞队列。
java的锁应用
ReentrantLock ,ReentrantReadWriteLock
CountDownLatch,CyclicBarrier,Semaphore
线程安全容器
读写锁
分段锁:线程安全map
AbstractQueuedSynchronizer
java 各种锁实现的的模板,抽象锁的模板,包括等待队列,资源,线程唤醒等待。
很多应用级锁都是继承AbstractQueuedSynchronizer实现。
java 与多线程相关的api分类
1:线程状态,线程池
Thread 上的各种api:创建,状态转换(join,wait,打断,销毁-已经不推荐使用),线程名称
线程组
线程池
2:内存模型
volatile,数据可见性问题-线程缓存,cpu缓存。指令重排序等
3:原子操作
4:锁
同步原语,可重入锁,等基于AQS封装的高级锁
信号通信:条件,wait,notify,park,unpark,高级别同步计数器(CoundDownLatch(一次性栅栏),CyclicBarrier(循环栅栏),Semaphore(信号量限流)
5:线程安全的数据容器-集合类
6 :常见并发模式的支持
Fork/Join,Feature
两阶段终止:jvm的钩子模式
语言对并发API 的设计参见《并发API设计与比较java-go》-石墨文档
用好多线程,需要了解语言提供的并发相关的api,但这只是工具层面的技术,各种语言提供的api也大同小异,程序真正用好了并发,是在合适的场景使用并发,并设计好并发--第一章,第二章内容。
4.3 实践
提高并发性能的实践方法:线程池与线程托管,消息通信,异步,单线程模型等
1:线程池与线程托管
线程创建需要开销,线程过多影响频繁切换影响性能。
在高并发场景下,将活跃线程保持在合理的个数,效率最高
akka框架,go 协程 都将线程从应用代码分离,跟jvm管理堆对象一样管理线程的分配。
如果自己采用线程池,在高并发下要设计好池大小,超过处理能力时的处理方法(输入日志,丢弃最新请求,还是丢失最早未处理请求等),线程隔离。异常捕获等。
2: 减小或去除临界区
设计上去临界区,尽量通过消息通信。
临界区尽量小这样可以尽快处理完成,减少阻塞的时间与几率
分段锁就是一直减小临界区的方式。将冲突的几率减少。
3:并发模式
模式分类
任务划分:生产消费,流水线,主仆(主需要仆的结果),Featue模式(可在子任务完成前做点别的事)
竞争处理:保护性暂挂(执行条件为竞争资源),不可变(数据不变),本地存储(一个线程一份),串行模式(竞争的资源交给同一个线程去处理)
线程管理:线程池,主动对象模式
不可变模式:通过创建更多的对象,解决线程共享问题
保护性暂挂:任务执行需要等待某个条件满足---样板代码封装
两阶段终止模式:停止线程时需要其完成手头的事。
承诺模式:Feature 模式,提交任务获取凭据,继续其他的事,到时凭票据获取结果
, 减少不必要等待。
生产者消费者模式:生产,消费用不同线程,用队列缓存任务
解决:生产,消费方处理不均衡,或不希望彼此影响。
避免相互阻塞等待。
线程池模式:减少线程创建开销,并控制并发数。
线程本地存储模式:ThreadLoacl
串行模式封装:所有的同类任务单线程执行。客户端提交任务。eg:ui 线程的封装。
主动对象模式:任务提交与执行分离,ThreadPoolExecutor 就是一个实例。
主仆模式:任务分解为多个并行任务,由多个线程执行,执行完汇总结果。
半同步半异步模式:
流水线模式:任务拆分为几个阶段,在不同线程执行
语言实现:
java 参考 图解java多线程设计模式 与java多线程编程实战指南-设计模式篇。
go参考github上练习。
评论