https://www.infoq.cn/article/CplusStyleCorourtine-At-Wechat
libco 的架构
libco 架构从设计的时候就已经确立下来了,最近的在 GitHub 上一次较大更新主要是功能上的更新。(注:libco 为开源项目,源码同步更新,可移步: https://github.com/tencent/libco )。
libco 框架有三层:分别是协程接口层、系统函数 Hook 层以及事件驱动层。
协程接口层实现了协程的基本源语。co_create、co_resume 等简单接口负责协程创建于恢复。co_cond_signal 类接口可以在协程间创建一个协程信号量,可用于协程间的同步通信。
系统函数 Hook 层负责主要负责系统中同步 API 到异步执行的转换。对于常用的同步网络接口,Hook 层会把本次网络请求注册为异步事件,然后等待事件驱动层的唤醒执行。
事件驱动层实现了一个简单高效的异步网路框架,里面包含了异步网络框架所需要的事件与超时回调。对于来源于同步系统函数 Hook 层的请求,事件注册与回调实质上是协程的让出与恢复执行。
们可以简单认为协程是一种用户态线程,它与线程一样拥有独立的寄存器上下文以及运行栈,对程序员最直观的效果就是,代码可以在协程里面正常的运作,就像在线程里面一样。但是线程和协程还是有区别的,我们需要重点关注是运行栈管理模式与协程调度策略。
那两者的不同点呢?
协程的创建与调度相比线程要轻量得多,而且协程间的通信与同步是可以无锁的,任一时刻都可以保证只有本协程在操作线程内的资源。
我们的方案是使用协程,但这意味着面临以下挑战:
- 业界协程在 C/C++ 环境下没有大规模应用的经验 ;
- 如何处理同步风格的 API 调用,如 Socket、mysqlclient 等 ;
- 如何控制协程调度 ;
- 如何处理已有全局变量、线程私有变量的使用 ;
挑战之一:前所未有的大规模应用 C/C++ 协程
实际上,协程这个概念的确很早就提出来了,但是确是因为最近几年在某些语言中(如 lua、go 等) 被广泛的应用而逐渐的被大家所熟知。但是真正用于 C/C++ 语言的、并且是大规模生产的着实不多。
而这个 libco 框架中,除了协程切换时寄存器保存与恢复使用了汇编代码,其它代码实现都是用 C/C++ 语言编写的。
我们的 C++ 后台服务框架增加了协程支持之后,高并发和快速开发的矛盾解决了。开发者绝大部分情况下只需要关注并发数的配置,不需要关注协程本身。
挑战之二:保留同步风格的 API
这里的做法我们在上文中提到了处理同步风格的 API 的思路方法:大部分同步风格的 API 我们都通过 Hook 的方法来接管了,libco 会在恰当的时机调度协程恢复执行。
怎样防止协程库调度器被阻塞?
libco 的系统函数 Hook 层主要处理同步 API 到异步执行的转换,我们当前的 hook 层只处理了主要的同步网络接口,对于这些接口,同步调用会被异步执行,不会导致系统的线程阻塞。当然,我们还有少量未 Hook 的同步接口,这些接口的调用可能会导致协程调度器阻塞等待。
与线程类似,当我们操作跨线程数据的时候,需要使用线程安全级别的函数。而在协程环境下,也是有协程安全的代码约束。在微信后台,我们约束了不能使用导致协程阻塞的函数,比如 pthread_mutex、sleep 类函数(可以用 poll(NULL, 0, timeout) 代替)等。而对于已有系统的改造,就需要审核已有代码是否符合协程安全规范。
挑战之三:调度千万级协程
调度策略方面,我们可以看下 Linux 的进程调度,从早期的 O(1) 到目前 CFS 完全公平调度,经过了很复杂的演进过程,而协程调度事实上也是可以参考进程调度方法的,比如说你可以定义一种调度策略,使得协程在不同的线程间切换,但是这样做会带来昂贵的切换代价。在进程 / 线程上面,后台服务通常已经做了足够多的工作,使得多核资源得到充分使用,所以协程的定位应该是在这个基础上发挥最大的性能。
libco 的协程调度策略很简洁,单个协程限定在固定的线程内部,仅在网络 IO 阻塞等待时候切出,在网络 IO 事件触发时候切回,也就是说在这个层面上面可以认为协程就是有限状态机,在事件驱动的线程里面工作,相信后台开发的同学会一下子就明白了。
那怎么实现千万级别呢?
libco 默认是每一个协程独享一个运行栈,在协程创建的时候,从堆内存分配一个固定大小的内存作为该协程的运行栈。如果我们用一个协程处理前端的一个接入连接,那对于一个海量接入服务来说,我们的服务的并发上限就很容易受限于内存。
所以量级的问题就转换成了怎样高效使用内存的问题。
为了解决这个问题,libco 采用的是共享栈模式。(传统运行栈管理有 stackfull 和 stackless 两种模式)简单来讲,是若干个协程共享同一个运行栈。
同一个共享栈下的协程间切换的时候,需要把当前的运行栈内容拷贝到协程的私有内存中。
为了减少这种内存拷贝次数,共享栈的内存拷贝只发生在不同协程间的切换。
当共享栈的占用者一直没有改变的时候,则不需要拷贝运行栈。
再具体一点讲讲共享栈的原理:libco 默认模式 (stackfull) 满足大部分的业务场景,每个协程独占 128k 栈空间,只需 1G 内存就可以支持万级协程。 而共享栈是 libco 新增的一个特性,可以支持单机千万协程,应对海量连接特殊场景。
实现原理上,共享栈模式在传统的 stackfull 和 stackless 两种模式之间做了个微创新,用户可以自定义分配若干个共享栈内存,协程创建时指定使用哪一个共享栈。
不同协程之间的切换、 如何主动退出一个正在执行的协程?我们把共享同一块栈内存的多个协程称为协程组,协程组内不同协程之间切换需要把栈内存拷贝到协程的私有空间,而协程组内同一个协程的让出与恢复执行则不需要拷贝栈内存,可以认为共享栈的栈内存是“写时拷贝”的。
共享栈下的协程切换与退出,与普通协程模式的 API 一致,co_yield 与 co_resume,libco 底层会实现共享栈的模式下的按需拷贝栈内存。
挑战之四:全局变量 VS 私有变量
在 stackfull 模式下面,局部变量的地址是一直不变的;而 stackless 模式下面,只要协程被切出,那么局部变量的地址就失效了,这是开发者需要注意的地方。
libco 默认的栈模式是每一个协程独享运行栈的,在这个模式下,开发者需要注意栈内存的使用,尽量避免 char buf[128 * 1024] 这种超大栈变量的申请,当栈使用大小超过本协程栈大小的时候,就可能导致栈溢出的 core。
而在共享栈模式下,虽然在协程创建的时候可以映射到一个比较大的栈内存上面,但是当本协程需要让出给其它协程执行的时候,已使用栈的拷贝保存开销也是有的,因此最好也是尽量减少大的局部变量使用。
更多的,共享栈模式下,因为是多个协程共享了同一个栈空间,因此,用户需要注意协程内的局部栈变量地址不可以跨协程传递。
协程私有变量的使用场景与线程私有变量类似,协程私有变量是全局可见的,不同的协程会对同一个协程变量保存自己的副本。开发者可以通过我们的 API 宏声明协程私有变量,在使用上无特别需要注意的地方。
多进程程序改造为多线程程序时候,我们可以用 __thread 来对全局变量进行快速修改,而在协程环境下,我们创造了协程变量 ROUTINE_VAR,极大简化了协程的改造工作量。
关于协程私有变量,因为协程实质上是线程内串行执行的,所以当我们定义了一个线程私有变量的时候,可能会有重入的问题。
比如我们定义了一个 __thread 的线程私有变量,原本是希望每一个执行逻辑独享这个变量的。但当我们的执行环境迁移到协程了之后,同一个线程私有变量,可能会有多个协程会操作它,这就导致了变量重入的问题。
为此,我们在做 libco 异步化改造的时候,把大部分的线程私有变量改成了协程级私有变量。
协程私有变量具有这样的特性:
当代码运行在多线程非协程环境下时,该变量是线程私有的;当代码运行在协程环境的时候,此变量是协程私有的。底层的协程私有变量会自动完成运行环境的判断并正确返回所需的值。
协程私有变量对于现有环境同步到异步化改造起了举足轻重的作用,同时我们定义了一个非常简单方便的方法定义协程私有变量,简单到只需一行声明代码即可。
简而言之
一句话总结 libco 库的原理,在协程里面用同步风格编写代码,实际运作是事件驱动的有限状态机,由上层的进程 / 线程负责多核资源的使用。
最终效果,大功告成
我们曾把一个状态机驱动的纯异步代理服务改成了基于 libco 协程的服务,在性能上比之前提升了 10% 到 20%,并且,在基于协程的同步模型下,我们很简单的就实现了批量请求的功能。
正如当时所愿,我们使用 libco 对微信后台上百个模块进行了协程异步化改造,在整个的改造过程中,业务逻辑代码基本没有改变,修改只是在框架层代码。我们所做的是把原先在线程内执行的业务逻辑转到了协程上执行。
改造的工作主要是复核系统中线程私有变量、全局变量、线程锁的使用,确保在协程切换的时候不会数据错乱或者重入。
至今,微信后台绝大部分服务都已是多进程或多线程协程模型,并发能力相比之前有了质的提升,而在这过程中应运而生的 libco 也成为了微信后台框架的基石。
作者简介
李方源, 微信高级工程师,目前负责微信后台基础框架及优化,致力于高性能、高可用的大规模分布式系统设计及研发,先后参与微信后台协程化改造项目、微信后台框架重构等项目。