微信服务器协程原理解析及优化

发表于2016-06-15
评论1 1.53w浏览
一、缘起
  前段时间把几个接入服务,做了协程的优化。微信开发框架里很好的集成了协程(基于部门开源的libco),所以涉及的开发量很少,但涉及到的思考却很多,要追溯到几年前曾经困扰我的问题。
  曾经开发这种类型的服务:接入层负责做一点点处理、然后转发给后端的服务做实际的运算;相比接入层,后端处理速度非常慢,有数千倍的差距,在后端计算时,接入层是等在那里的(也就是同步、阻塞)。
  为了应付不断增长的请求量,后端不断扩容。假设后端有10台机器,每个机器开启24个线程;为了把后端压满,前端就需要至少240个线程来匹配,如果是3个C1的机器,那每个机需要80个线程。如果后端扩容一倍,那前端线程也需要扩容一倍。每个机器160个线程感觉太多了,这些线程大部分时间是等待io的,直观上感觉系统的context switch开销会很大,CPU没有完全用于实际的业务处理。

二、有没有办法优化这个问题呢?
  Todo:linux的线程实现实际上效率很高,一个机器多少线程才算多?多少context switch算高,才需要优化?需要实际测试,或者实际业务具体分析。
  我们知道,网络服务框架大多采用半异步半同步的架构,比如上面的svkkit的框架模型图。利用epoll来处理client发起的连接、读写请求,这是框架的“异步”那部分(高效)。而框架同步的那部分,就发生在worker实际处理请求、以及RPC调用时,也是发生等待的地方(cpu等io),比较低效。


  直观的想法是,如果我们把RPC也利用epoll+ callback的方式来处理,这样不就高效了吗?
  然后我就着手开始进行开发、封装接口,很快就发现了问题所在。这种模式需要注册各种callback,成功的、失败的、超时的等等,把这种复杂性暴露出来很难管理和理解;此外更重要的是还需要一种灵活的保持上下文的机制,如何在调用callback时获得上次请求的上下文、临时变量等等?如何处理各种错误逻辑?即便咬牙实现了,也不够易用,远离了框架的初衷。
  Nginx却恰恰是在异步这条路上走到了极致,提到Nginx常见的描述都是异步、高性能、复杂、不好理解等等。基于Nginx模块开发也不失为一个方案。Python的Twisted也是类似的思路,笔者也用过一阵子,感觉比较难用。
  后来看到python的gevent(基于greenlet)以及由此接触到协程(coroutine)这个概念,貌似是解决这个问题的银弹。当一个greenlet遇到IO操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。可以认为greenlet增强了python的协程机制,什么是协程呢?

三、什么是coroutine
  coroutine不是一个新概念,早在70年代就已经出现了,后来由于某种原因一直没引起大家的重视,近几年却开始被广泛使用(跟深度学习的尿性很像)。
  本质上讲Coroutine是一种特殊类型的subroutine,它可以在执行过程中多次暂停(yield)、然后过一段时间又可以重新从暂停的地方开始执行(resume),在重新执行时subroutine的上下文(局部变量)是保持的。
  function object也可以有类似的保存上下文的能力,coroutine不同的地方在于每次是从上次暂停的地方开始继续执行,而function object每次是从头开始执行。


  此外coroutine和进程、线程也比较像,都可以理解为一种“指令的执行序列”。协程和这俩之间有啥关系和区别呢?
  首先,协程对于OS来说透明的,OS只知道进程、线程(统称task),但不知道协程。这意味着对应同一线程的多个协程之间只是伪并发,不能像多线程那样利用多核,因为OS只能调度线程。
  如果OS不知道协程,那协程是如何被调度、执行的呢?答案是程序员自己控制协程什么时候暂停,什么时候切换、以及切换到哪个协程。
  我们知道OS对整个计算机负责,控制着各种权限,开放出一些syscall给process、thread调用访问系统资源,决定下一步执行那个task,保证整个计算机的高效、安全的运行;如果开放出调度的权限给我们,那整个计算机不就乱套了吗?事实上也不会有这种问题,同样是因为OS并不知道coroutine,多个coroutine只会瓜分同一个task的资源(OS分配的cpu时间片之类),至于如何瓜分,就随便程序员你了,OS并不care。

四、coroutine有啥用
  Coroutine主要几个用途,最开始应用在数据处理、xml解析等情景,利用coroutnie能够代码逻辑比较清晰,但是这种需求并不广泛。;coroutine也可以应用在网络服务、并发编程里;但可能由于机器性能、OS优化,进程、线程的改进,直接利用线程来做类似的事情也不会差到哪里去,所以协程就被大家遗忘了。但现在海量服务越来越多,利用coroutine优化网络服务性能这个需求又冒了出来,所以还是时势造英雄啊。
  微信的libco专注于利用协程解决RPC情景遇到的问题,通过hooksocket系统调用的方式(类似于python gevent),程序员可以采用同步的写法,写出异步执行的代码。
  启用协程后,协程在会阻塞的地方(比如socket read),自动挂起,然后选择执行其他ready的协程执行;未来某个时间协程等待的数据到达时,协程转化为就绪状态,可以重新开始执行。而协程的挂起、继续执行对程序员是透明的。但这一切的基础是协程的context switch相比线程要是非常高效的,否则利用线程完全可以做同样的事情。

五、coroutine的调度
  那我该如何控制coroutine的调度呢?有两个问题要回答:选择哪个coroutine执行,如何让他执行。
  我们都学过OS的进程、线程调度,OS的调度目标是保证公平、响应时间、优先级等条件下最大化机器的利用率。
  而协程这里要考虑的问题要简单些,一个task内的多个协程谁能跑谁就跑,至于跑多长时间则无所谓,如果有个协程一直能跑那也可以一直不切换。但如果当前正在执行的协程跑不动了(网络IO),那它要主动退出(靠自觉),靠另外一个Manager协程选择另一个能跑的协程继续跑。
  Manager如何知道哪个协程能跑呢?这个就要自己实现了,一般协程挂起时,都是由于需要等待一个条件的满足,比如socket的可读事件,如果socket有数据可读就会触发相应的协程由等待状态转化为就绪状态。
  由此可见协程是一种cooperative类型的调度策略,而OS的调度一般都是preemptive的,不然有些恶意程序一直while(true)死循环,这个机器就跪了。
  如何让某个协程开始执行呢?简单说来就是利用getcontext、makecontext、swapcontext来做用户态的contextswitch。
  就像在本文开始提到的,我们之所以使用coroutine技术就是期望协程具有比线程更好的context switch性能,因此swapcontext的性能对于协程就至关重要。
  Boost::Context、 libco都有自己的实现(汇编代码,跟硬件架构有关)。Boost::Context测试比系统调用的swapcontext性能有几十倍的提升,这样整个coroutine的技术方案终于make sense了。


六、并发带来的复杂性
  微信的开发框架基本都利用libco支持了协程,总体工作量很小。但由于引入了协程,在开发的过程中需要额外注意一些问题。
  我们都知道线程的引入带来了很多好处,多线程向进程内部引入了额外的并发,线程之间共享地址空间、共享打开的文件。这些并发、共享带来了高效率,但恰恰由于这些并发、共享,多线程开发就需要额外注意共享资源之间的同步问题,需要引入线程锁、原子操作等复杂性来保证多线程程序的正常操作。
  类似地,协程进一步向线程内部引入了并发,这时线程局部变量对协程来说也是共享的,同样需要注意并发安全的问题。
  我觉得最好要保证函数是reentrant的,因为有可能一个协程在函数内部挂起,然后另一个协程重新进入这个函数。这就意味着要特别注意对全局变量、静态变量的使用。还要特别注意锁的使用。libco提供了协程局部变量,使这个问题容易解决一些。
  其实为了处理协程之间访问共享变量的竞争的问题,只要不发生协程切换就好了,对于libco就是不发生网络IO。

七、协程的其他实现
  python、Boost提供了更通用的协程实现。当然还有很多其他语言。
  python的协程可以看看这个pdf:
  "A Curious Course on Coroutines and Concurrency "
  http://www.dabeaz.com/coroutines/Coroutines.pdf
  libco的代码是公司内开源的,阅读下也挺好的

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引