抖音技术能力大揭密!钜惠大礼、深度体验,尽在火山引擎增长沙龙,就等你来! 立即报名>> 了解详情
写点什么

C/C++ 协程库 libco:微信怎样漂亮地完成异步化改造

2016 年 12 月 13 日

编者按

如今,微信拥有月活跃用户 8 亿。

不可否认,当今的微信后台拥有着强大的并发能力。

不过, 正如罗马并非一日建成;微信的技术也曾经略显稚嫩。

微信诞生于 2011 年 1 月,当年用户规模为 0.1 亿左右;2013 年 11 月,微信月活跃用户数达到 3.55 亿,一跃成为亚洲地区拥有最大用户群体的移动终端即时通讯软件。

面对如此体量的提升,微信后台也曾遭遇棘手的窘境;令人赞叹的是技术人及时地做出了漂亮的应对。

这背后有着怎样的技术故事?

此时此刻,你在微信手机端发出的请求,是怎样被后台消化和处理的?

这次,InfoQ 聚焦微信后台解决方案之协程库 libco。

该项目在保留后台敏捷的同步风格同时,提高了系统的并发能力,节省了大量的服务器成本;自 2013 年起稳定运行于微信的数万台机器之上。

本文源自 InfoQ 对 Leiffy 的采访和《揭秘:微信如何用 libco 支撑 8 亿用户》的整理。

微信后端遇到了问题

早期微信后台因为业务需求复杂多变、产品要求快速迭代等需求,大部分模块都采用了半同步半异步模型。接入层为异步模型,业务逻辑层则是同步的多进程或多线程模型,业务逻辑的并发能力只有几十到几百。

随着微信业务的增长,直到 2013 年中,微信后台机器规模已达到 1 万多台,涉及数百个后台模块,RPC 调用每分钟数十亿。在如此庞大复杂的系统规模下,每个模块很容易受到后端服务或者网络抖动的影响。因此我们急需对微信后台进行异步化的改造。

异步化改造方案的考量

当时我们有两种选择:

  • A 线程异步化:把所有服务改造成异步模型,等同于从框架到业务逻辑代码的彻底改造
  • B 协程异步化:对业务逻辑非侵入的异步化改造,即只修该少量框架代码

两者相比,工作量和风险系数的差异显而易见。虽然 A 方案服务器端多线程异步处理是常见做法,对提高并发能力这个原始目标非常奏效;但是对于微信后台如此复杂的系统,这过于耗时耗力且风险巨大。

无论是异步模型还是同步模型,都需要保存异步状态。所以两者在技术细节的相同点是,两个方案,都是需要维护当前请求的状态。在 A 异步模型中方案,当请求需要被异步执行时,需要主动把请求相关数据保存起来,再等待状态机的下一次调度执行;而在 B 协程模型方案中,异步状态的保存与恢复是自动的,协程恢复执行的时候就是上一次退出时的上下文。

因此,B 协程方案不需要显式地维护异步状态:一方面在编程上可以更简单和直接;另一方面协程中只需要保存少量的寄存器。因此在复杂系统上,协程服务的性能可能比纯异步模型更优。

综合以上考虑,最终我们选择了 B 方案,通过协程的方式对微信后台上百个模块进行了异步化改造。

接管历史遗留的同步风格 API

方案敲定之后,接下来做的就是实现异步化的同时尽可能地少做代码修改。

通常而言,一个常规的网络后台服务需要 connect、write、read 等系列步骤,如果使用同步风格的 API 对网络进行调用,整个服务线程会因为等待网络交互而挂起,这就会造成等待并占用资源。原来的这种情况很明显地影响到了系统的并发性能,但是当初这样的选择是因为对应的同步编程风格具有其独特的优势:代码逻辑清晰、易于编写并且支持业务快速迭代敏捷开发。

我们的改造方案需要消除同步风格 API 的缺点,但是同时还希望保持同步编程的优点。

最后在不修改线上已有的业务逻辑代码的情况下,我们的 libco 框架创新地接管了网络调用接口(Hook)。把协程的让出与恢复作为异步网络 IO 中的一次事件注册与回调。当业务处理遇到同步网络请求的时候,libco 层会把本次网络请求注册为异步事件,当前的协程让出 CPU 占用,CPU 交给其它协程执行。在网络事件发生或者超时的时候,libco 会自动的恢复协程执行。

libco 的架构

libco 架构从设计的时候就已经确立下来了,最近的在 GitHub 上一次较大更新主要是功能上的更新。(注:libco 为开源项目,源码同步更新,可移步: https://github.com/tencent/libco )。

libco 框架有三层:分别是协程接口层、系统函数 Hook 层以及事件驱动层。

协程接口层实现了协程的基本源语。co_create、co_resume 等简单接口负责协程创建于恢复。co_cond_signal 类接口可以在协程间创建一个协程信号量,可用于协程间的同步通信。

系统函数 Hook 层负责主要负责系统中同步 API 到异步执行的转换。对于常用的同步网络接口,Hook 层会把本次网络请求注册为异步事件,然后等待事件驱动层的唤醒执行。

事件驱动层实现了一个简单高效的异步网路框架,里面包含了异步网络框架所需要的事件与超时回调。对于来源于同步系统函数 Hook 层的请求,事件注册与回调实质上是协程的让出与恢复执行。

相比线程,选择协程意味着?

比起线程,对于很多人而言,协程的应用并不是那么轻车熟路。

线程和协程的相同点是什么?

我们可以简单认为协程是一种用户态线程,它与线程一样拥有独立的寄存器上下文以及运行栈,对程序员最直观的效果就是,代码可以在协程里面正常的运作,就像在线程里面一样。但是线程和协程还是有区别的,我们需要重点关注是运行栈管理模式与协程调度策略。关于这两点的具体执行,在本文后续部分会谈及。

那两者的不同点呢?

协程的创建与调度相比线程要轻量得多,而且协程间的通信与同步是可以无锁的,任一时刻都可以保证只有本协程在操作线程内的资源。

我们的方案是使用协程,但这意味着面临以下挑战:

  1. 业界协程在 C/C++ 环境下没有大规模应用的经验 ;
  2. 如何处理同步风格的 API 调用,如 Socket、mysqlclient 等 ;
  3. 如何控制协程调度 ;
  4. 如何处理已有全局变量、线程私有变量的使用 ;

下面我们来探讨如何攻克这四个挑战。

挑战之一:前所未有的大规模应用 C/C++ 协程

实际上,协程这个概念的确很早就提出来了,但是确是因为最近几年在某些语言中(如 lua、go 等) 被广泛的应用而逐渐的被大家所熟知。但是真正用于 C/C++ 语言的、并且是大规模生产的着实不多。

而这个 libco 框架中,除了协程切换时寄存器保存与恢复使用了汇编代码,其它代码实现都是用 C/C++ 语言编写的。

那为什么我们选择了 C/C++ 语言?

当前微信后台绝大部分服务都基于 C++,原因是微信最早的后台开发团队从邮箱延续而来,邮箱团队一直使用 C++ 作为后台主流开发语言,而且 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 也成为了微信后台框架的基石。

作者简介

李方源, 微信高级工程师,目前负责微信后台基础框架及优化,致力于高性能、高可用的大规模分布式系统设计及研发,先后参与微信后台协程化改造项目、微信后台框架重构等项目。

libco 为开源项目,源码同步更新,可移步: https://github.com/tencent /libco

拓展阅读:揭秘:微信如何用 libco 支撑 8 亿用户?

2016 年 12 月 13 日 16:2921187
用户头像

发布了 58 篇内容, 共 38.8 次阅读, 收获喜欢 22 次。

关注

评论

发布
暂无评论
发现更多内容

C语言实用第三方库Melon开箱即用之多线程模型

码哥比特

c c++ Linux 后端 框架

1.开篇(听说你还在艰难的啃react源码)

全栈潇晨

React React Hooks react源码

日记 2021年2月17日(周三)

Changing Lin

2月春节不断更

数据量大读写缓慢如何优化(5)【读缓存】

我爱娃哈哈😍

redis 缓存 架构设计实战 缓存设计

1480. 一维数组的动态和

小马哥

算法 七日更

【LeetCode】数组拆分Java题解

HQ数字卡

算法 LeetCode 2月春节不断更

这是我的第一次JavaScript初级技巧

魔王哪吒

JavaScript 学习 程序员 前端 2月春节不断更

IDEA插件:快速删除Java代码中的注释

xiaoxi666

代码注释 Java 8 JavaParser

第一次异地过年有感

石君

思考 情感

C语言第三方库Melon开箱即用之词法分析器使用

码哥比特

c c++ Linux 后端 框架

揭秘登上2021春晚舞台的黑科技-XR技术

架构精进之路

黑科技 vr 春晚 XR MR

给hugo博客添加评论功能

LanLiang

Hugo 静态博客 utterances

诊所数字化:诊所老板为什么拒绝预约制?

boshi

数字化转型 医院 七日更

【活动回顾】4步2小时,搭建爆火的语音聊天室

ZEGO即构

ElasticSearch.03 - 基本原理

insight

elasticsearch 2月春节不断更

春节快过腻了?不妨关心下太空探索

脑极体

翻译:《实用的Python编程》01_05_Lists

codists

人工智能 后端 python 爬虫 列表 数据结构与算法

程序员成长第五篇:如何选择城市工作?

石云升

程序员成长 2月春节不断更 选择城市

gradle中的增量构建

程序那些事

maven Gradle 程序那些事 构建工具

【STM32】PWM 输出 (标准库)

AXYZdong

硬件 stm32 2月春节不断更

消息队列Kafka:入门基础

正向成长

kafka

开发利器——C语言必备实用第三方库

码哥比特

c c++ Linux 后端 框架

微信红包封面,2021年为啥突然火了?

架构精进之路

春节 微信红包封面 商业洞察

13. 如果自己写的 Python 程序出错了,怎么办?

梦想橡皮擦

python 爬虫 2月春节不断更

深度讲解背包问题:面试中每五道动态规划就有一道是背包模型 ...

宫水三叶的刷题日记

深度思考 LeetCode 动态规划 数据结构与算法 面试数据结构与算法

今日笔记

Nydia

第四章作业-编写一个用例文档

秦挺

3.Fiber(我是在内存中的dom)

全栈潇晨

React React Hooks react源码

写公号大半年,看看我都收获了些啥

架构精进之路

技术 总结 微信公众号 成长笔记

2.react心智模型(来来来,让大脑有react思维吧)

全栈潇晨

React React Hooks react源码

EternalWallet为您提供快速、便捷、低价的国际汇款服务

Geek_c610c0

Study Go: From Zero to Hero

Study Go: From Zero to Hero

C/C++协程库libco:微信怎样漂亮地完成异步化改造-InfoQ