谈谈对协程的理解
什么是协程
协程是在线程之上由“用户”构建的并发单元,对OS来说无感知,协程的切换由用户自己管理和调度。(这里的用户是相较于内核而言的,一些通用库这里也理解为用户)
C/C++怎么实现协程
作为一个C++后台开发,我知道像go, lua之类的语言在语言层面上提供了协程的api,但是我比较关心C++下要怎么实现这一点,下面的讨论都是从C/C++程序员的角度来看协程的问题的。
boost和腾讯都推出了相关的库,语言层面没有提供这个东西。我近期阅读了微信开源的libco协程库,协程核心要解决几个问题:
1. 协程怎么切换? 这个是最核心的问题,有很多trick可以做到这点,libco的做法是利用glibc中ucontext相关调用保存线程上下文,然后用swapcontext来切换协程上下文,libco的实现中对swapcontext的汇编实现做了一些删减和改动,所以在性能上会比C库的swapcontext提升1个数量级。
2. IO阻塞了怎么办?试想在一个多协程的线程里,一个阻塞IO由一个协程发起,那么整个线程都阻塞了,别的协程也拿不到CPU资源,多个协程在一起等着IO的完成。libco中的做法是利用同名函数+dlsym来hook socket族的阻塞IO,比如read/write等,劫持了系统调用之后把这些IO注册到一个epoll的事件循环中,注册完之后把协程yield掉让出cpu资源,在IO完成的时候resume这个协程,这样其实把网络IO的阻塞点放在了epoll上,如果epoll没有就绪fd,那其实在超时时间内epoll还是阻塞的,只是把阻塞的粒度缩小了,本质上其实还是用epoll异步回调来解决网络IO问题的。那么问题来了,对于一些没有fd的一些重IO(比如大规模数据库操作)要怎么处理呢?答案是:libco并没有解决这个问题,而且也很难解决这个问题,首先要明确的一点是我们的目的是让用户只是仅仅调用了一个同步IO而已,不希望用户感知到调用IO的时候其实协程让出了cpu资源,按libco的思路一种可能的方法是,给所有重IO的api都hook掉,然后往某个异步事件库里丢这个IO事件,在异步事件返回的时候再resume协程。这里的难点是可能存在的重IO这么多,很难写成一个通用的库,只能根据业务需求来hook掉需要的调用,然后协程的编写中依然可以以同步的方式调用这些IO。从以上可能的做法来看协程很难去把所有阻塞调用都hook掉,所以libco很聪明的只把socket族的相关调用给hook掉,这样可以libco就成为一个通用的网络层面的协程库,可以很容易移植到现有的代码中进行改造,但是也让libco适用场景局限于如rpc风格的proxy/logic的场景中。在我的理解里,阻塞IO让出cpu是协程要解决的问题,但是不是协程本身的性质,从实现上我们可以看出我们还是在用异步回调的方式在解决这个问题,和协程本身无关。
3. 如果一个协程没有发起IO,但是一直占用CPU资源不让出资源怎么办?无解,所以协程的编写对使用场景很重要,程序员对协程的理解也很重要,协程不适合于处理重cpu密集计算(耗时),只要某个协程即一直占用着线程的资源就是不合理的,因为这样做不到一个合理的并发,多线程同步模型由OS来调度并发,不存在说一个并发点需要让出资源给另一个,而协程在编写的时候cpu资源的让出是由程序员来完成的,所以协程代码的编写需要程序员对协程有比较深刻的理解。最极端的例子是程序员在协程里写个死循环,好,这个线程的所有协程都可以歇歇了。
协程有什么好处
说了这么多协程,协程的好处到底是啥?为什么要使用协程?
1. 协程极大的优化了程序员的编程体验,同步编程风格能快速构建模块,并易于复用,而且有异步的性能(这个看具体库的实现),也不用陷入callback hell的深坑。
2. 第二点也是我最近一直在纠结的一点,协程到底有没有性能提升?
1)从多线程同步模型切到协程来看,首先很明确的性能提升点在于同步到异步的切换,libco中把阻塞的点全部放到了epoll线程中,而协程线程并不会发生阻塞。其次是协程的成本比线程小,线程受栈空间限制,而协程的栈空间由用户控制,而且实现协程需要的辅助数据结构很少,占用的内存少,那么就会有更大的容量,比如可以轻松开10w个协程,但是很难说开10w个线程。另外一个问题是很多人拿线程上下文切换比协程上下文切换开销大来推出协程模型比多线程并发模型性能优这点,这个问题我纠结了很久。对于这个问题,我先做一个简单的具体抽象:在不考虑阻塞的情况下,假设8核的cpu,不考虑抢占中断优先级等因素,100个任务并发执行,100个线程并发和10个线程每个线程10个协程并发对比两者都可以把cpu资源利用起来,对OS来说,前者100个线程参与cpu调度,后者10个线程参与cpu调度,后者还有额外的协程切换调度,先考虑线程切换的上下文,根据Linux内核调度器CFS的算法,每个线程拿到的时间片是动态的,进程数在分配的时间片在可变区间的情况下会直接影响到线程时间片的长短,所以100个线程每个线程的时间片在一定条件下会要比10个线程情况下的要短,也就意味着在相同时间里,前者的上下文切换次数是比后者要多的,所以可以得出一个结论:协程并发模型比多线程同步模型在一定条件下会减少线程切换次数(时间片有固定的范围,如果超出这个范围的边界则线程的时间片无差异),增加了协程切换次数,由于协程的切换是由程序员自己调度的,所以很难说协程切换的代价比省去的线程切换代价小,合理的方式应该是通过测试工具在具体的业务场景得出一个最好的平衡点。
2)从异步回调模型切到协程模型来看,从一些已有协程库的实现来看,协程的同步写法却有异步性能其实还是异步回调在支撑这个事情,所以我认为协程模型是在异步模型之上的东西,考虑到本身协程上下文切换的开销(其实很小)和数据结构调用的一些开销,理论上协程是比异步回调的性能要稍微差一点,但是可以处于几乎持平的性能,因为协程实现的代价非常小。
3)从一些异步驱动库的角度来看协程的话,因为异步框架把代码封装到很多个小类里面,然后串起来,这中间会涉及相当多的内存分配,而数据大都在离散的堆内存里面,而协程风格的代码,可以简单理解为一个简洁的连续空间的栈内存池,辅助数据结构也很少,所以协程可能会比厚重的封装性能会更好一些,但是这里的前提是,协程库能实现异步驱动库所需要的功能,并把它封装到同步调用里。
如理解有偏差,欢迎指正。