抛砖引玉,同大家一起交流;可能会从两个大方向上来开始:一是来看看为什么并发编程难,包括涉及的一些基础硬件和操作系统相关的一点知识;二是一起来分析一下常同的并发模型,我们怎么通一个简单高效的并发模型来写出简单的并发实现(如线程与锁/actor/csp/);
并发与并行
并发:是逻辑上发生的同时;如一个处理器同时处理多个任务,CPU时间片发生切换
并行:是物理上发生的同时;是多核处理器同时处理多个任务;
系统硬件体系架构
这里做一个假设,如果CPU1在对一个变量执行一个CAS的操作,而该变量的缓存行是在CPU7的高速缓存里面,那么可能的执行顺序可能是:
CPU1检查本地高速缓存,没有找到缓存行;
请求转发到CPU0与CPU1的互联模块,检查CPU0的本地高速缓存,没有找到缓存行;
请求转发到系统互联模块,检查其它三个芯片,得知缓存行被CPU6与CPU7所在的芯片持有;
请求被转发到CPU6和CPU7的互联模块,检查这两个CPU的高速缓存,在CPU7的高速缓存中找到缓存行;
CPU6将缓存行发送给所属的互联模块,并刷新自己高速缓存中的缓存行;
CPU6和CPU7的互联模块将缓存行发送给系统互联模块;
系统互联模块将缓存行发送给CPU0和CPU1的互联模块;
CPU0和CPU1的互联模块将缓存行发送给CPU1的高速缓存;
CPU1对高速缓存中的变量执行CAS操作;
这是一个简化并忽略了某些复杂的事件序列,因为:
其它CPU可能试图在相同的缓存行上执行并发的CAS操作;
缓存行可能被只读复制到其它的CPU高速缓存中,这种情况下有必要刷新它们的缓存;
当请求到达时,CPU7可能已经在缓存上操作,这种情况下CPU7必须你保留这个请求,直到请求完成、
CPU7可能已经从缓存中排出它的缓存行,这样当请求到达时,缓存行已经写入内存中了;
在缓存行中可能发生一个可纠正的错误,因此需要在使用数据前纠正它;
...
CPU的缓存一致性极其复杂,所以高效率(榨干最后一滴CPU资源,每瓦特性能)且可靠的并行编程总是太不容易!
除了CPU高速缓存还有许多其它因素,如内存引用、原子操作、内存屏障、I/O操作等等。
内存引用
微处理器从内存里读一个值的时间,微处理器可以用这段时间执行成成百甚至上行知指令,虽然一直在极大的减少内存访问的延迟,但是仍只有高度可以预测的数据访问模式才能让缓存发挥最大效果;
原子操作
CPU会通过一条『流水线』来控制CPU内部的指令流(现在微处理器都可以支持多条流水线并行),这种架构使得CPU流水线的可以一次执行多个操作,而原子操作正与这种特性有冲突;比如一种常见的技巧是标出所有包含原子操作所需数据的流水线,保证CPU在操作时,这些流水线都属于正在执行原子操作的CPU;如果我们按CPU一个时钟周期执行一条指指令(约为0.6ns),一个最好情况下的CAS操作也需要40个时钟周期;
内存屏障
这个比较好理解,一般有Load Barrier 和 Store Barrier即读屏障和写屏障,如JAVA中的volatile关键字
寄存器在执行前,为了提高性能,会对指令重排,而内存屏障会禁止指令重排;
强制将CPU高速存中的数据写回内存,让缓存中相关的数据失效,这又涉及到内存总线 CPU与内存的读写等等的性能损耗;
JVM对内存屏障的详细内容可以参考一下<>,作者是阿里大神方腾飞,也是 并发编程网 博主,总之很牛逼。
I/O操作
如高速缓存未命中(CPU之间的I/O),如果涉及到网络,大容量存储(磁盘),这类操作对于性能的影响更是远远大于上面提到几种的开销;
所以并行编程变得复杂,除了与硬件的交互、还有任务分割、并行访问控制等;这些如线程、锁、屏障等,我们在实际工作中应尽量避免直接控制它们,因为它们都有怪脾气,处处是陷井;庆辛随着为并发设计编程语言的兴起(Elang,Scala,Golang等),在释放多核威力的同时,也一定程度降低了并发程序的难度。特别Golang也是Docker这类明星产品的实现语言。
Golang并发模式(CSP)并发内核
(注,该图引自网络)
其中M是一个内核线程,P是调度器,G是一个协程,灰色的G为挂起的协程;Go通过协程goroutine提供语言层面的调度器,实现高效的M:N(M个用户线程对应N个os内核线程)对应关系,使用goroutine做为并发实体,非常的轻量级,理论可以很轻松的创建上十万个goroutine。
P作为调度器,作用类似于CPU的核,每个工作线程都必须绑定一个有效的P才被允许执行,否则只能休眠等到有空闲的P时被唤醒;P还要为线程提供执行资源,如为对象分配内存 本地任务队更等;
而实际的执行体是M(OS内核线程),和P绑定 不停的获取执行G的并发任务。M通过修改寄存器,将执行栈指向G自带的栈内存;
P/M是执行的组合体,但是两者数量并非是一一对应,M由调度器按需创建,比如当M陷于一个IO操作长时间阻塞,P就会被监控线程抢回去去创建或唤醒一个M执行其它任务。
如果想深入了解,建议可以看看雨痕写的 <<源码解析>> 郁闷的点是有汇编和C的代码...
从一段简单的代码来看
主协程调用producer,创建了个生产者协程,并返回了一个通道,这里也可以理解为一个服务。
主协程的do_something执行业务逻辑。
JAVA的同学从简单理解就当chan就是JAVA中的队列(用通讯的方式共享内存)
先做抛砖引玉,后续再专门针对Go 和Actor做一些分享,交流。
有疑问加站长微信联系(非本文作者)