🚀 优质资源分享 🚀
学习路线指引(点击解锁) | 知识定位 | 人群定位 |
---|---|---|
🧡 Python实战微信订餐小程序 🧡 | 进阶级 | 本课程是python flask+微信小程序的完美结合,从项目搭建到腾讯云部署上线,打造一个全栈订餐系统。 |
💛Python量化交易实战💛 | 入门级 | 手把手带你打造一个易扩展、更安全、效率更高的量化交易系统 |
我不想卷,我是被逼的
在做了几年前端之后,发现互联网行情比想象的差,不如赶紧学点后端知识,被裁之后也可接个私活不至于饿死。学习两周Go,如盲人摸象般不知重点,那么重点谁知道呢?肯定是使用Go的后端工程师,那便利用业余时间找了几个老哥对练一下。其中一位问道在利用多个goroutine发送请求拿到结果之后如果进行销毁。是个好问题,研究了一下需要利用Context,而我一向喜欢研究源码,继续深挖发现细节非常多,于是乎有此这篇文章。
有句话叫做初出茅庐天下无敌,再练三年寸步难行。本着不服输精神回来研究了一下这个问题,很简单需要使用Go提供的Context,api使用起来也很简单,但是我一向喜欢刨根问底,于是乎研究Context源码发现互斥锁(Mutex)、原子操作(atomic),研究atomic发现CAS,研究CAS发现了java的自旋锁、偏向锁、轻量级锁、重量级锁,研究锁发现Disruptor,研究Disruptor发现CPU伪共享、MESI协议、内存屏障。
所以这篇文章会自下向上的讲解,先讲CPU硬件设计带来的优势以及带来的问题(多核并发冲突、伪共享),从底层上理解并发问题存在的根因,然后讲解原子操作与CAS,之后讲解操作系统为解决并发问题的锁机制、信号量,然后介绍下高并发框架Disruptor利用这些机制的高性能实现,最后回到Go中的atomic.Value以及建立在aotmic.Value和Mutex之上的Context,最后的最后回答下那位老哥问题,怎么使用Context来做goroutine的调度。
并发与并行
并行是并发的一个子集;并发(ConcurrentMode)强调的是从任务调度角度来看,同时安排多个是任务;任务可以穿插着执行,并不一定是同一时刻在同时进行;并行(Parallel)是从实际执行角度来看真的有多个任务在同一时刻同时执行。
现代CPU多半是多核设计,所以我理解会存在以多核并行的方式进行高并发运行。当然单核单线程依然存在并行,它上面也存在真正的“并行”。只不过,这个并行并不是CPU内部的、线程之间的并行;而是CPU执行程序的同时,DMA控制器也在执行着网络报文收发、磁盘读写、音视频播放/录制等等任务。
不像js这种单线程异步的语言,向java、Go语法上看起来都是以多线程同步阻塞的形式运行,一般多个请求来时,都是开启一个线程池利用多线程的方式进行处理。得益于CPU多核的优势,可以快速并行运行请求任务,但是同样因为这个原因会天然的引起多线程访问数据冲突。
硬件底层原因
并发冲突
并发资源冲突的底层原因与CPU设计有关,先看CPU的缓存结构
可以看到i7-8700k是6核,右下角L1、L2都6x多少kb,可以看到L1、L2缓存是各个核心独有的,L3是共用的,读取数据时会先从内存中把数据和指令读到自己的L1缓存中,这就可以知道当两个线程访问同一个数据时,CPU角度他们其实在各自核心中都是独有的。
同样在写的时候,CPU为了节省跟内存通信带来的性能开销,也并不一定是程序更新后立即放到内存中。而是有两种策略:写直达和写回。写直达比较简单,都是每次写入到内存中,性能开销大。而写回是当L1数据缓存中的变量A被更改后不立即写回内存,只是做一个脏标记,这样多次写同一个变量A可以一直在L1中处理,直到这个缓存位置的A不在处理了,需要A腾出地方给另一个变量B时,才把A的数据写到内存中,这样提升缓存命中率,减少与内存的通信提升性能。
可以想象的是,这种高性能在多核并发运行时,两个核心都读到了变量A值为0,核心1上运行的线程T1把A改成了10,但没有写到内存中,也没有通知核心2,它的L1缓存中A还是0,这时候核心2的线程T2把A改成了20;当他们都往内存写的时候,必然出现冲突。这就是在单机上多线程并发引起的资源冲突的底层原因,也是后续各种原子操作、锁、信号量等机制要解决的问题。推广到宏观业务场景是一样的,两个服务为了性能优化,先把数,0读到自己的内存中,一个改成了1,一个改成了2,而数据库看到的还是0,这时候两个服务都往数据库写,就会产生冲突。
原子操作
为了解决这个问题,早期科学家走了很多弯路,花了好多年才找到解决软件和硬件的解决方案。首先在CPU硬件层面,有两个手段:写传播和MESI协议。
写传播的方案就