分享

也来谈谈协程

 半佛肉夹馍 2023-10-20 发布于河南

很多技术上的东西,你拦腰来一下或者在末梢上抓几个典型,就容易简单问题复杂化。这篇文章的问题就在于此:作者拦腰从“用协程优化线程”这个奇怪的中间应用场景开始,没头没尾开始“揭露真相”;然后又有很多人从末梢开始,引入“协程本质上是状态机”、“协程用于解决callback hell”、“协程在IO密集型业务中才能发挥威力”等莫名其妙却又言之凿凿的结论。

于是,事情就越发复杂化,越发不可解决了。

其实,这类问题有一个很简单的解决办法,那就是——追根溯源。

彻底追到根子上,看看进程、线程、协程究竟是怎么回事——这看起来是绕了弯路,大家想解决实际问题呢,你这不识相的却跑去挖什么纯理论去了……

不过,如果您肯耐着性子,跟我到它们诞生的那个时刻看一看,一切就迎刃而解了。


进程是什么,我们都知道。这里就不多解释了。

然后,我们还知道,早年的Windows 3.x是非抢夺式多任务。也叫协作式多任务。

这种多任务方式存在一个很大的弊端,那就是必须等一个进程主动让出执行权,其它进程才有机会执行。

如果当前进程不让(比如陷入死循环、比如调用非阻塞API循环死等总是不来的网络报文、比如用错误的接口循环死等读取硬件故障的磁盘),那么整个系统就会陷入瘫痪。

从Windows 95开始,微软切换到了抢夺式多任务:每个进程给你一个时间片,用完就强制休眠;甚至时间片未到但更紧急的事件出现了,也会强制低优先级进程休眠。

抢夺式多任务的确比非抢夺式多任务可靠得多。一个水货写的程序把事情搞砸了,其他人可以不受影响。系统会强制剥夺它的执行权,确保正常程序仍然有机会执行。

亦因此,Windows 95是一个里程碑。它标志着一个真正支持多进程的操作系统出现了。


记住下面这两个概念。它们很重要。

协作式多任务:多个进程独立运行,但每个进程都要发扬风格,不执行规模过大的计算;或者执行规模较大的计算时,每隔一段时间主动调用一下OS提供的特定API,让出控制权给其它进程。

总之,人之初,性本善。每个人都替别人着想,世界就会很美好。

那万一出个恶人、病人呢?

世界崩塌了。

抢夺式多任务:系统里跑的程序,有的是坏人写的,也有的会意外病倒。操作系统要监控所有进程,公平分配CPU时间片等资源给每个应用;如果一个应用用完了自己的份额,那么操作系统就要强制暂停它的执行,保存它的执行现场,把CPU安排给另一个进程——从而避免坏进程/病态进程影响系统的正常运行。

现在,操作系统把你们都当坏人防着。你就是故意写流氓软件,也不可能轻易就把别人“憋死”了。


无论是协作式多任务还是抢夺式多任务,外在表现上都是“用户可以同时运行多个程序,可以前台开着字处理软件,后台放着音乐,另外还有个聊天工具藏在幕后……”

但随着计算机技术的发展,多CPU系统越发普及;就连桌面CPU都悄悄开始双核化。

那么这时候,我们就会想到很多很酷的应用场景。比如说,可以一边从网上下载电影,一边开个视频播放器观看。

但这样就出现了很多新问题:如果下载电影的进程速度更慢,视频播放器读到尚未填充有效数据的区域怎么办?

或者,电影下载进程下了100k数据,一校验,是坏的。结果视频播放器快手快脚拿过去就放;刚放了一帧,电影下载进程作废了这段数据,把读指针跳回到100k前;而电影播放器呢,它还保留着一个无效的读指针……

这时候,程序员就不得不做很多的同步工作。

这可不是一个小工程。你得先约定共享内存/共享文件格式,约定控制数据存储位置(当前有效数据首尾指针等信息);做好约定,确保双方都能找到锁;锁是读写锁还是简单的mutex……等等等等。

除非同一个团队做,不然想要配合默契,显然是极难极难的。

但既然是同一个团队做,其实没必要搞成两个进程,没必要动用复杂的进程间通讯机制——没错,两个进程可以分别在两个CPU核心上跑,更充分的利用硬件资源;但操作系统也可以允许一个进程存在两个执行绪啊。

于是,线程诞生


有了进程设计的经验,线程自然一开始就搞的非常完善:进程和线程都要在OS里面注册,这才能接受OS的调度,充分利用多颗CPU核心(或者,某些CPU有多线程支持,一颗CPU核心可以用不同的逻辑电路同时执行两个线程)。

两者的区别是,进程持有资源,一旦退出,进程申请的各种资源都会被OS强制回收;而线程依附于进程,资源不和它绑定。

不仅如此,从一开始,OS就汲取了过去的教训,把线程也做成了抢夺式多任务。

但直接把抢夺式多任务思路延续到线程,问题就来了。
OS里面,不同进程是诸多水平参差不齐、目的各异(甚至本就存心做流氓软件)的人设计的。因此设定一个硬杠杠,时机成熟强制剥夺其执行权,这是极其必要的。

而同一个进程里面的一组线程,它们必然来自于同一个设计团队。哪怕他们用了第三方库,其中的线程也全都在这个团队控制之下。因此“水平良莠不齐”“存在恶意”也就无从谈起了。

一旦不需要对付“水货”和“坏分子”,抢夺式多任务带来的好处就没那么重要了;而“抢夺”造成的“执行时序紊乱”问题越发突出。

比如,A线程负责从网上下载内容;每下载一段、校验无误,它就要更新一下共享内存(这可能仅仅是一个把一块数据挂到链表末尾的操作)——如果不存在“抢夺”,那么什么时候它把内容更新完了,什么时候主动让出控制权,那就不会有任何问题。

但一旦存在抢夺,A线程就可能在刚刚执行到“修改了链表的末指针、但尚未来得及修改最后一块的前向指针”时,被OS强制剥夺执行权;而B线程负责播放,它刚读到这块信息,用户点了“回退5秒钟”,于是它循着A线程尚未来得及修改正确的前向指针,跑不知哪里去了……

哪怕在单核单线程CPU上跑,这都会造成各种意想不到的执行序紊乱问题。

因此,程序员们不得不在使用共享数据时加锁,确保自己不会把事情搞砸。

此时,非抢占式多任务的好处就出来了:大家都一家人,都想齐心合力把事情做好;因此,当“我”事情没做完而且并不会耽误太久时,你们就应该等我;而一旦我事情做完了、或者需要等待网络信号/磁盘准备好时,“我”也会痛快的主动交出控制权。

这个做法,使得协作式多任务之间执行权的交接点极为明晰;那么只要逻辑考虑清楚了,锁就是完全没必要的——反正不会抢夺嘛,事情没告一段落我就不会交执行权;交执行权之前确保不存在“悬置的、未确定未提交的修改”,脏读脏写就杜绝了。


因此,协程这个概念的提出,使得程序逻辑更为清晰,执行更加可控。

协程实质上是一种在用户空间实现的协作式多线程架构。

它不能让OS知道自己的存在,无法利用多核CPU/CPU的多线程支持;但这恰恰是它的优点。

注意,我在这里的措辞是“协程不能让OS知道自己的存在”。

这是因为,OS并没有协程支持;如果你想让OS知道你的存在,那么它就会把你当线程调度——于是抢占式多任务就又回来了,“协程”这个“协”字就名不副实了。

为什么说这个“无法在CPU上并行”的束缚恰恰是协程的优点呢?

因为它是协作式多任务,不存在执行绪紊乱的可能。

没错,每次执行中,协程之间的具体执行顺序可能千变万化;但协程执行权切换只会发生在用户明确放弃执行权之后——比如你明确执行了yield语句时。

当然,如果你非要先修改链表后向指针、改完了yield一下然后才去修改链表前向指针,那谁都救不了你。

记住,除非你确定现在的共享数据不怕被其它协程查看/更改,否则不要在共享数据修改完成前随便放弃你的执行权。

当然,多数情况下,使用协程是为了满足“开个小差做点别的”的同时,不希望阻塞主要执行绪。这种简单应用场景多半也没有什么数据需要共享。


一旦挖到根子,是不是一下子所有的一切都清晰起来了呢?

现在,让我们回头看看这些讨论吧。

1、如果资源存在相互依赖,线程是否有必要存在?

答:那要看什么依赖。

比如,我遇到过的一个案例:一组线程负责从磁盘上加载大量日志(可达数百G);第二组线程分头分析日志;第三组线程把日志分析后得到的结果通过网络发送出去。

那么,在这个场景里,虽然线程组2严重依赖于线程组1载入的数据,线程组3又完全依赖于线程组2的输出内容;但使用线程是绝对有必要的。

这是因为,线程组1是磁盘密集型任务,不占用多少CPU;而线程组2是CPU密集型任务,它和IO无关;最后的线程组3呢,它专心和网卡打交道……

在这个典型的生产者-消费者模型里,三组线程齐头并进,就可以把服务器的磁盘、CPU、网卡同时利用起来,最大化执行效率。

当然,当初的设计者闹了个大笑话。他让线程组1先载入若干G数据,载入完毕之前禁止线程组2运行;等载入结束,线程组1停止运行,等线程组2分析数据;分析完,所有线程组2的线程全部停止执行了,才启动线程组3发报;等线程组3忙完,这才再次启动线程组1。

这个设计很可笑。

该并行的,他给弄的彻底不并行了,磁盘不忙完,CPU只能干瞪眼;CPU没搞定,网卡只能空闲。

不该并行的,他却强制并行了:磁盘读取时,一大窝线程乱纷纷你抢我夺,严重拖慢效率;忙完了,不用抢了,让磁盘闲着,交给线程组2,又是一窝蜂的争夺内存/CPU访问权;然后磁盘CPU都闲着,看一窝新的线程你争我夺的折腾网卡……

所以你看,压根不是线程好协程坏或者协程穿没穿衣服的问题。

问题的关键点在于,究竟在哪些地方并行可以提高效率?哪些地方并行反而损失效率?如何做出一个精确、智能的设计,使得框架可以自动安排合理数目的线程,把磁盘、CPU、网卡同时利用起来?

显然,多路IO场景下,协程已经可以同时发起多个读取请求;那么如果系统有多块网卡、多块磁盘,OS自然会并行利用它——因为这些接口本来就是异步的(调用同步接口会导致整个进程被挂起,别这样做),OS会自动给它排队,能并行就安排并行了。

但是,想充分利用CPU核心,你就必须用线程。

比如前面的案例中,第一三两组线程就可以用协程替代;但第二组线程就必须是线程。且一三两组协程都应该在一个单独的线程里,不能共享第二组线程。

2、回调地狱问题

这货和协程没什么关系。也就是写起来更好看罢了。

事实上,在这个示例中,改成协程反而会导致语义改变,引出时序相关bug来:

foreach session:
v1 = io()
if v1.is_good:
v2 = io()
if v2.is_good:
v3 = io()
if v3.is_good:
v4 = io()
if v4.is_good:
handle(v1, v2, v3, v4)

这段的语义本来是,v1先做io,得到good结果后,v2再做io,以此类推。

如果机械的改成协程,那么就成了v1~v4同时io,然后因为不满足时序要求大量失败。

除非v1~v4本就可以并行;但此时用线程/协程都一样。只是协程写起来更简单一点罢了。

协程不是状态机。除非你精心设计了它的状态。“看起来像”和“是”差了十万八千里。


总结:协程是一种抛弃了在CPU上并行执行能力的、协作式多任务的执行框架。

这个设计使得你可以像线程一样使用它,却无需担心棘手的数据相关问题。

因为它的执行权交接在你的控制之下,你不交出控制权,别人就不能强插一脚。

借助“遇到等待主动交控制权”这个诀窍,你可以用协程避免一个单线程程序阻塞。

只要你记得在合适时机主动交出控制权,不要调用系统提供的、可能阻塞的API(而是使用协程库提供的非阻塞版、或者使用协程库推荐写法),你甚至可以让磁盘、CPU、网络等不同设备并行运行——这本就是操作系统给你提供一个虚拟的、可并行界面的背后原理。你的操作系统原理学的扎实,那么到这里就不会迷惑。

你可以用线程做“领班”,借助多线程充分利用CPU;同时又在每条线程内部,借助协程并行IO、或者无阻塞的执行互不相关的一组任务——比如,累加一大堆数据,同时每隔100ms更新主界面上的显示。

但要注意,不要在不同线程间共享同一个协程控制器,那会把抢夺式多任务的“执行权随时切换”这个“恶魔”带进协程空间,破坏掉“协作式多任务”这个基本保证。

除非你的协程库允许你这么做。

但哪怕协程库允许,你最好还是不要这么做。因为为了保证协程语义,共享了协程控制器的线程们很可能被这个库用锁给“传染”成“协程”——除非协程库作者给你详细说明,告诉你怎样做才能既不受共享数据被破坏之害、又能享受真正的并行之利。

随便提一句,不要用这种“强大”的协程库。

这种库的作者多半喜欢无意义的炫技。对真正有需求的人来说,自己造轮子可比用这种脱裤子放屁的“高级功能”简单直白太多了。

抽象到这种程度,无论实现还是接口都会变得太过复杂,是对“依赖倒置原则”的严重违背——不仅不能体现其技术水平,反倒暴露出不懂接口设计的缺陷来。

还是开头那段话:技术问题,最好返璞归真。

离根子越近,花里胡哨的东西越少,封装越简单、越清晰、越质朴,它才越可靠、越好用。

反之,拉进来的东西越多,就代表这人的头脑越不清醒,出问题的可能就越大——比如说协程拉进来状态机/回调地狱的,显然就对协程的本质缺乏了解。


最后,出一道思考题。把它做出来,你才会真正明白线程和协程的本质区别。

我曾提到,写一个网络代理软件实质上就是简单的把一个网卡过来的数据转给另一个网卡(也可以是虚拟网络设备,比如tun/tap设备)。而想要这个数据转发高效、低延迟,就应该把它写成单线程。

这是因为,如果你分别用多个线程处理多个网卡的收发,那么一旦网络繁忙,且CPU也比较忙的话,那么很可能其中一条线程就要满负荷跑满一个时间片;在这个线程被剥夺执行权之前,另一个线程可能得不到执行机会。于是造成数据经过代理后ping值不稳定问题。

而用单线程搞呢,你可以给它一个较高的优先级,使得有网络报文它就立即被唤醒执行;不把报文处理完就不交控制权。

那么,只要你程序写对了,它就一定能用最高的效率完成数据转发工作。

那么,这道思考题就是:不允许使用协程,你如何在一个普通的单线程C程序里,用一个while循环,做到多块网卡并行工作,既不阻塞自己、又会在没有报文时主动交出执行权、不空耗时间片呢?

(一个拿了实时优先级的程序空耗时间片可是个非常非常严重的问题,随时可能让整个OS崩掉的那种。)

这个问题很简单。查查socket相关资料,推敲推敲各个接口参数,你自然就知道该怎么办了(提示:需要综合硬件中断原理、OS调度原理等知识)。

但它极其重要。

能想通这个,关于协程的讨论才会有的放矢。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多