深入理解协程

462次阅读  |  发布于2年以前

C++ 在互联网服务端开发方向依然占据着相当大的份额;百度,腾讯,甚至以java为主流开发语言的阿里都在大规模使用C++做互联网服务端开发,今天以C++为例子,分析一下要支持协程,需要考虑哪些问题,如何权衡利弊,反过来也可以了解到协程适合哪些场景。

第1章 C++协程近况简介

协程分两种,无栈协程(stackless)和有栈协程(stackful),前者无法解决异步回调模式中上下文保存与恢复的问题,在此不做论述,文中后续提到的协程均指有栈协程。

第1节.旧时代

在2014年以前,C++服务端开发是以异步回调模型为主流,业务流程中每一个需要等待IO处理的节点都需要切断业务处理流程、保存当前处理的上下文、设置回调函数,等IO处理完成后再恢复上下文、接续业务处理流程。

在一个典型的互联网业务处理流程中,这样的行为节点多达十几个甚至数十个(微服务间的rpc请求、与redis之类的高速缓存的交互、与mysql\mongodb之类的DB交互、调用第三方HttpServer的接口等等);被切割的支离破碎的业务处理流程带来了几个常见的难题:

这些具体的难题综合起来,在工程化角度呈现出的效果就是:代码编写复杂,开发周期长,维护困难,BUG多且防不胜防。

第2节.新时代

2014年腾讯的微信团队开源了一个C风格的协程框架libco,并在次年的架构师峰会上做了宣讲,使业内都认识到异步回调模式升级为协程模式的必要性,从此开启了C++互联网服务端开发的协程时代。BAT三家旗下的各个小部门、业内很多与时俱进的互联网公司都纷纷自研协程框架,一时呈百花齐放之态。

笔者所在的公司当时也试用了一段时间libco,修修补补很多次,终究是因为问题太多而放弃,改用了自研的libgo作为协程开发框架。

聊协程就不能不提到主打协程功能和CSP模式的golang语言,google从09年发布golang至今,经过近10个年头的发酵,已成为互联网服务端开发主流开发语言之一,许多项目和开发者从C++、java、php等语言转向golang。笔者自研的libgo也汲取了golang的设计理念和多年的实践经验。

本文后续针对C++协程框架的设计与实现、与golang这种语言级别支持的协程的差距在哪里、怎样尽力弥补这种差距等方面展开讨论。

第2章.协程库的设计与实现

个人认为,C++协程库从实现完善程度上分为以下几个层次

1.API级

实现协程上下文切换api,或添加一些便于使用的封装;特点:没有协程调度。

代表作:boost.context, boost.coroutine, ucontext(unix), fiber(windows)

这一层次的协程库,仅仅提供了一个底层api,要想拿来做项目,还有非常非常遥远的距离;不过这些协程api可以为我们实现自己的协程库提供一个良好的基础。

2.玩具级

实现了协程调度,无需用户手动处理协程上下文切换;特点:没有HOOK

代表作:libmill

这一层次的协程库,实现了协程调度(类似于操作系统有了进程调度机制);稍好一些的意识到了阻塞网络io与协程的不协调之处,自己实现了一套网络io相关函数;

但是这也意味着涉及网络的第三方库全部不可用了,比如你想用redis?不好意思,hiredis不能用了,要自己轮一个;你想用mysql?不好意思,mysqlclient不能用了,要自己轮一个。放弃整个C/C++生态全部自己轮,这个玩笑开的有点大,所以只能称之为“玩具级”。

3.工业级

以部分正确的方式HOOK了网络io相关的syscall,可以少改甚至不改代码的兼容大多数第三方库;特点:没有完整生态

代表作:libco

这一层次的协程库,但是hook的不够完善,未能完全模拟syscall的行为,只能兼容行为符合预想的同步模型的第三方库,这虽然只能覆盖一部分的第三方库,但是通过严苛的源码审查、付出代价高昂的测试成本,也可以勉强用于实际项目开发了;

但其他机制不够完善:协程间通讯、协程同步、调试等,因此对开发人员的要求很高,深谙底层机制才能写出没有问题的代码;再加上hook不完善带来的隐患,开发过程可谓是步步惊心、如履薄冰。

4.框架级

以100%行为模拟的方式HOOK了网络io相关的syscall,可以完全不改代码兼容大多数第三方库;依照专为协程而生的语言的使用经验,提供了协程开发所必须的完整生态;

代表作:libgo

这一层次的协程库,能够100%模拟被hook的syscall的行为,能够兼容任何网络io行为的同步模型的第三方库;由于协程开发生态的完善,对开发人员的要求变得很低,新手也可以写出高效稳定的代码。但由于C++的灵活性,用户行为是不受限的,所以依然存在几个边边角角的难点需要开发者注意:没有gc(开发者要了解协程的调度时机和生命期),TLS的问题,用户不按套路出牌、把逻辑代码run在协程之外,粗粒度的线程锁等等。

5.语言级

语言级的协程实现

代表作:golang语言

这一层次的协程库,开发者的一切行为都是受限行为,可以实现无死角的完善的协程。

下面会尽可能详尽的讨论libgo设计中的每一个重要决策,并会列举一些其他协程库的决策的优劣与实现方式

第1节.协程上下文切换

协程上下文切换有很多种实现方式:

libgo在这一块的方案是1+5:

第2节.协程栈

我们通常会创建数量非常庞大的协程来支持高并发,协程栈内存占用情况就变成一个不容忽视的问题了;

如果采用线程栈相同的大栈方案(linux系统默认8MB),启动1000个协程就要8GB内存,启动10w个协程就要800GB内存,而每个协程真正使用的栈内存可以几百kb甚至几kb,内存使用率极低,这显然是不可接受的;

如果采用减少协程栈的大小,比如设为128kb,启动1000个协程要128MB内存,启动10w个协程要12.8GB内存,这是一个合理的设置;但是,我们知道有很多人喜欢直接在栈上申请一个64kb的char数组做缓冲区,即使开发者非常小心的不这样奢侈的使用栈内存,也难免第三方库做这样的行为,而只需两层嵌套就会栈溢出了。

栈内存不可太大,也不可太小,这其中是很难权衡的,一旦定死这个值,就只能针对特定的场景,无法做到通用化了;针对协程栈的内存问题,一般有以下几种方案。

静态栈(Static Stack)

固定大小的栈,存在上述的难以权衡的问题;

但是如果把问题限定在某一个范围,比如说我就只用来写微信后台、并且严格review每一个引入的第三方库的源码,确保其全部谨慎使用栈内存,这种方案也是可以作为实际项目来使用的。

典型代表:libco,它设置了128KB大小的堆栈,15年的时候我们把它引入我们当时的项目中,其后出现过多次栈溢出的问题。

分段栈(Segmented Stack)

gcc提供的“黄金链接器”支持一种允许栈内存不连续的编译参数,实现原理是在每个函数调用开头都插入一段栈内存检测的代码,如果栈内存不够用了就申请一块新的内存,作为栈内存的延续。

这种方案本应是最佳的实现,但如果遇到的第三方库没有使用这种方式来编译(注意:glibc也是这里提到的”第三方库"),那就无法在其中检测栈内存是否需要扩展,栈溢出的风险很大。

拷贝栈(Copy Stack)

每次检测到栈内存不够用时,申请一块更大的新内存,将现有的栈内存copy过去,就像std::vector那样扩展内存。

在某些语言上是可以实现这样的机制,但C++ 是有指针的,栈内存的Copy会导致指向其内存地址的指针失效;又因为其指针的灵活性(可以加减运算),修改对应的指针成为了一种几乎不可能实现的事情(参照c++ 为什么没办法实现gc原理,详见《C++11新特性解析与应用》第5章 5.2.4节)。

共享栈(Shared Stack)

申请一块大内存作为共享栈(比如:8MB),每次开始运行协程之前,先把协程栈的内存copy到共享栈中,运行结束后再计算协程栈真正使用的内存,copy出来保存起来,这样每次只需保存真正使用到的栈内存量即可。

这种方案极大程度上避免了内存的浪费,做到了用多少占多少,同等内存条件下,可以启动的协程数量更多,libco使用这种方案单机启动了上千万协程。

但是这种方案的缺陷也同样明显:

bar这个协程函数里面,启动了一个新的协程,然后bar等待新协程结束后再退出;当切换到新协程时,由于bar协程的栈已经被copy到了其他位置,栈上分配的变量a已经失效,此时调用a.foo就会出现难以预料的结果。

这样的场景在开发中数不胜数,比如:某个处理流程需要聚合多个后端的结果、父协程对子协程做一些计数类的操作等等等等

有人说我可以把变量a分配到堆上,这样的改法确实可以解决这个已经发现的bug;那其他没发现的怎么办呢,难道每个变量都放到堆上以提前规避这个坑?这显然是不切实际的。

早期的libgo也使用过共享栈的方式,也正是因为作者在实际开发中遇到了这样的问题,才放弃了共享栈的方式。

虚拟内存栈(Virtual Memory Stack)

既然前面提到的4种协程栈都有这样那样的弊端,那么有没有一种方案能够相对完美的解决这个问题?答案就是虚拟内存栈。

Linux、Windows、MacOS三大主流操作系统都有这样一个虚拟内存机制:进程申请的内存并不会立即被映射成物理内存,而是仅管理于虚拟内存中,真正对其读写时会触发缺页中断,此时才会映射为物理内存。

比如:我在进程中malloc了1MB的内存,但是不做读写,那么物理内存占用是不会增加的;当我读写这块内存的第一个字节时,系统才会将这1MB内存中的第一页(默认页大小4KB)映射为物理内存,此时物理内存的占用会增加4KB,以此类推,可以做到用多少占多少,冗余不超过一个内存页大小。

基于这样一个机制,libgo为每个协程malloc 1MB的虚拟内存作为协程栈(这个值是可以定制化的);不做读写操作就不会占用物理内存,协程栈使用了多少才会占用多少物理内存,实现了与共享栈近似的内存使用率,并且不存在共享栈的两大弊端。

典型代表:libgo

第3节.协程调度

像操作系统的进程调度一样,协程调度也有多种方案可选,也有公平调度和不公平调度之分。

栈式调度

栈式调度是典型的不公平调度:协程队列是一个栈式的结构,每次创建的协程都置于栈顶,并且会立即暂停当前协程并切换至子协程中运行,子协程运行结束(或其他原因导致切换出来)后,继续切换回来执行父协程;越是处于栈底部的协程(越早创建的协程),被调度到的机会越少;

甚至某些场景下会产生隐晦的死循环导致永远在栈顶的两个协程间切来切去,其他协程全部无法执行。

典型代表:libco

星切调度(非对称协程调度)

调度线程 -> 协程A -> 调度线程 -> 协程B -> 调度线程 -> …

调度线程居中,协程画在周围,调度顺序图看起来就像是星星一样,因此戏称为星切。

将当前可调度的协程组织成先进先出的队列(runnable list),顺序pop出来做调度;新创建的协程排入队尾,调度一次后如果状态依然是可调度(runnable)的协程则排入队尾,调度一次后如果状态变为阻塞,那阻塞事件触发后也一样排入队尾,是为公平调度。

典型代表:libgo

环切调度(对称协程调度)

调度线程 -> 协程A -> 协程B -> 协程C -> 协程D -> 调度线程 -> …

调度线程居中,协程画在周围,调度顺序图看起来呈环状,因此戏称为环切。

从调度顺序上可以发现,环切的切换次数仅为星切的一半,可以带来更高的整体切换速度;但是多线程调度、WorkSteal方面会带来一定的挑战。

这种方案也是libgo后续优化的一个方向

多线程调度、负载均衡与WorkSteal

本节的内容其实不是协程库的必选项,互联网服务端开发领域现在主流方案都是微服务,单线程多进程的模型不会有额外的负担。

但是某些场景下多进程会有很昂贵的额外成本(比如:开发一个数据库),只能用多线程来解决,libgo为了有更广阔的适用性,实现了多线程调度和Worksteal。同时也突破了传统协程库仅用来处理网络io密集型业务的局限,也能适用于cpu密集型业务,充当并行编程库来使用。

libgo的多线程调度采用N:M模型,调度线程数量可以动态增加,但不能减少;每个调度线程持有一个Processer(后文简称: P),每个P持有3个runnable协程队列(普通队列、IO触发队列、亲缘性队列),其中普通队列保存的是可以被偷取的协程;当某个P空闲时,会去其他P的队列尾部偷取一些协程过来执行,以此实现负载均衡。

为了IO方面降低线程竞争,libgo会为每个调度线程在必要的时候单独创建一个epoll;

关于每个epoll的使用,会在后面的本章第4节.HOOK-网络io中展开详细论述;其他关于多线程的设计会贯穿全文的逐个介绍。

第4节.HOOK

是否有HOOK是一个协程库定位到玩具级和工业级之间的重要分水岭;HOOK的底层实现是否遵从HOOK的基本守则;决定着用户是如履薄冰的使用一个漏洞百出的协程库?还是可以挥洒自如的使用一个稳定健壮的协程库?

基本守则:HOOK接口表现出来的行为与被HOOK的接口保持100%一致

HOOK是一个精细活,需要繁琐的边界条件测试,不但要保证返回值与原函数一致,相应的errno也要一致,做的与原函数越像,能够支持的三方库就越多;但只要不做到100%,使用时就总是要提心吊胆的,因为你无法辨识哪些三方库的哪些逻辑分支会遇到BUG!

比如我们在试用libco的时候就遇到这样一个问题:

众所周知,新建的socket默认都是阻塞式的,isNonBlock应该为false。但是当这段代码执行于libco的协程中时,被hook后的结果isNonBlock居然是true!

连接成功后,read的行为更是怪异,既不是阻塞式的无限等待,也不是非阻塞式的立即返回;而是阻塞1秒后返回-1!

如果第三方库有表情的话,此时一定是一脸懵逼的。。。

而且libco的HOOK不能支持真正的全静态链接,这也是我们放弃它的一个重要因素。

网络io

libgo的HOOK设计与实现严格的遵守着HOOK的基本守则,在linux系统上hook的socket函数列表如下:

connect、accept read、readv、recv、recvfrom、recvmsg write、writev、send、sendto、sendmsg poll、select、__poll、close

fcntl、ioctl、getsockopt、setsockopt dup、dup2、dup3

协程挂起:

如果协程对一个或多个socket的IO阻塞操作(read/write/poll/select)无法立即完成,那么协程会被设置为io-block状态并保存到io-wait队列中,将当期协程的sentry保存在socket的等待队列中,然后将这一个或多个socket添加到当前线程所属的epoll中;

协程唤醒:

如果这一个或多个socket被epoll监听到协程关心的事件触发了,对应的协程就会被唤醒(设置成runnable状态),并追加到所属P的IO触发队列尾部,等待再次被调度。

唤醒后的清理:

协程被唤醒后的首次调度,会从socket的等待队列中清除当期协程的sentry,如果socket读写事件对应的等待队列被清空且没有设置为ET模式,则会调用epoll_ctl清理epoll对socket的对应监听事件。

显而易见,调用void set_et_mode(int fd);接口将频繁读写的socket设置成et模式可以减少epoll相关的系统调用,提升性能;libgonet就做了这样的优化。

关于阻塞、非阻塞的问题,libgo是这样解决的:

为了实现协程的挂起,socket是必须被转换成非阻塞模式的,libgo在其上封装了一个状态:user_nonblock,表示用户是否主动设置过nonblock,并hook相关函数,屏蔽掉socket真实的阻塞状态,对用户呈现user_nonblock。

如果用户设置过nonblock,即user_nonblock == true,则对用户呈现一个非阻塞socket的所有特质(调用读写函数都不会阻塞,而是立即返回)。

如果用户没有设置过nonblock,即socket的真实状态是非阻塞的,但是user_nonblock == false,此时对用户呈现一个阻塞式socket的所有特质(调用读写函数不能立即完成就阻塞等待,并且阻塞时间等同于RCVTIMEO或SNDTIMEO)。

为了可以正确维护user_nonblock状态,就必须把dup、dup2、dup3这几个复制fd的函数给hook了,另外fcntl也是可以复制fd的,也要做出类似的处理。

libgo的HOOK不但可以100%模拟原生syscall的行为,还可以做一些原生syscall没能实现的功能,比如:带超时设置的connect。

在libgo的协程中调用connect之前,可以先调用void set_connect_timeout(int milliseconds);接口设置connect的超时时长。

DNS

libgo在linux系统上hook的dns函数列表如下:

gethostbyname gethostbyname2 gethostbyname_r gethostbyname2_r gethostbyaddr gethostbyaddr_r

其中,形如getXXbyYY的三个函数是其对应的getXXbyYY_r函数外层封装了一个TLS缓冲区的实现;

HOOK后的实现中,libgo使用CLS替代了原生syscall里的TLS的功能。

通过观察glibc源码发现,形如getXXbyYY_r的三个函数内部还使用了一个存在struct thread_info结构体中的TLS变量缓存调用远程dns服务器使用的socket,实测中发现libco提供的HOOK __res_state函数的方案是无效的,getXXbyYY_r会并发乱序的读写同一个socket,导致混乱的结果或长久的阻塞。

libgo针对这个问题HOOK了getXXbyYY_r系列函数,在函数入口使用了一个线程私有的协程锁,解决了同一个线程的getXXbyYY_r乱序读写同一个socket的问题;又由于P中的IO触发队列的存在,getXXbyYY_r由于内部的__poll挂起再重新唤醒后,保证了会在原线程完成后续代码的执行。

signal

linux上的signal是有着不可重入属性的,在signal处理函数中处理复杂的操作极易出现死锁,libgo提供了解决这个问题的编译参数:

其他会导致阻塞的syscall

libgo还HOOK了三个sleep函数:sleep、usleep、nanosleep

在协程中直接使用这三个sleep函数,可以让当前协程挂起相应的时间。

第5节.完整生态

依照golang近10年的实践经验来看,我们很容易发现协程是核心功能,但只有协程是远远不够的。我们还需要很多周边生态来辅助协程更好地完成并发任务。

Channel

和线程一样,协程间也是需要交换数据。

很多时候我们需要一个能够屏蔽协程同步、多线程调度等各种底层细节的,简单的,保证数据有序传递的通讯方式,golang中channel的设计就刚好满足了我们的需求。

libgo仿照golang制作了Channel功能,通过如下代码:

即创建了一个不带额外缓冲区的、传递int的channel,重载了操作符<<和>>,使用

向其写入一个整数1,正如golang中channel的行为一样,此时如果没有另一个协程使用

尝试读取,当前协程会被挂起等待。

如果使用

则表示从channel中读取一个元素,但是不再使用它。channel的这种挂起协程等待的特性,也通常用于父协程等待子协程处理完成后再向下执行。

也可以使用

创建一个带有长度为10的缓冲区的channel,正如golang中channel的行为一样,对这样的channel进行写操作,缓冲区写满之前协程不会挂起。

这适用于有大批量数据需要传递的场景。

协程锁、协程读写锁

在任何C++协程库的使用中,都应该慎重使用或禁用线程锁,比如下面的代码

协程A首先被调度,加锁后调用sleep导致当前协程挂起,注意此时mtx已然是被锁定的。

然后协程B被调度,要等待mtx被解锁才能继续执行下去,由于mtx是线程锁,会阻塞调度线程,协程A再也不会有机会被调度,从而形成死锁。

这是一个典型的边角问题,因为我们无法阻止C++程序员在使用协程库的同时再使用线程同步机制。

其实我们可以提供一个协程锁来解决这一问题,比如下面的代码

代码与前一个例子几乎一样,唯一的区别是mtx的锁类型从线程锁变成了libgo提供的协程锁。

协程A首先被调度,加锁后调用sleep导致当前协程挂起,注意此时mtx已然是被锁定的。

然后协程B被调度,要等待mtx被解锁才能继续执行下去,由于mtx是协程锁,协程锁在等待时会挂起当前协程而不是阻塞线程,协程A在sleep时间结束后会被唤醒并被调度,协程A退出foo函数时会解锁,解锁的行为又会唤醒协程B,协程B被调度时再次锁定mtx,然后顺利完成整个逻辑。

libgo还提供了协程读写锁:co_rwmutex

另外,即便开发者有意识的规避第一个例子那样的场景,也很容易踩到另外一个线程锁导致的坑,比如在使用zookeeper-client这样会启动后台线程来call回调函数的第三方库时:

看起来好像没什么问题,但其实routine里面的线程锁会阻塞整个调度线程,使得其他协程都无法被及时调度。

针对这种情况最优雅的处理方式就是使用Channel,因为libgo提供的Channel不仅可以用于协程间交换数据,也可以用于协程与线程间交换数据,可以说是专门针对zk这类起后台线程的第三方库设计的。

定时器

libgo框架的主调度器提供了一个基于红黑树的定时器,会在调度线程的主循环中被执行,这样的设计可以与epoll更好地协同工作,无论是定时器还是epoll监听的fd都可以最及时的触发。

使用co_timer_add接口可以添加一个定时任务,co_timer_add接口接受两个参数,第一个参数是可以是std::chrono::system_clock::time_point,也可以是std::chrono::steady_clock::time_point,还可以是std::chrono库里的一个duration。第二个参数接受一个回调函数,可以是函数指针、仿函数、lambda等等;

当第一个参数使用system_clock::time_point时,表示定时任务跟随系统时间的变化而变化,可以通过调整操作系统的时间设置提前或延缓定时任务的执行。

当第一个参数使用另外两种类型时,定时任务不随系统时间的变化而变化。

co_timer_add接口返回一个co::TimerId类型的定时任务id,可以用来取消定时任务。

取消定时任务有种方式:co_timer_cancel和co_timer_block_cancel,均会返回一个bool类型表示是否取消成功。

使用co_timer_cancel,会立即返回,即使定时任务正在被执行。

使用co_timer_block_cancel,如果定时任务正在被执行,则会阻塞地等待任务完成后返回false;否则会立即返回;

需要注意的是co_timer_block_cancel的阻塞行为是使用自旋锁实现的,如果定时任务耗时较长,co_timer_block_cancel的阻塞行为不但会阻塞当前调度线程,还会产生高昂的cpu开销;这个接口是设计用来在libgo内部使用的,请用户谨慎使用!

CLS(Coroutine Local Storage)(协程本地存储)

CLS类似于TLS(Thread Local Storage);

这个功能是HOOK DNS函数族的基石,没有CLS的协程库是无法HOOK DNS函数族的。

libgo提供了一个行为是TLS超集的CLS功能,CLS变量可以定义在全局作用域、块作用域(函数体内)、类的静态成员,除此TLS也支持的这三种场景外,还可以作为类的非静态成员。

注:libco也有CLS功能,但是仅支持全局作用域

CLS的使用方式参见tutorail文件夹下的sample13_cls.cpp教程代码。

线程池

除了前文提到的各种边角问题之外,还有一个非常常见的边角问题:文件IO 笔者曾经努力尝试过HOOK文件IO操作,但很不幸linux系统中,文件fd是无法使用poll、select、epoll正确监听可读可写状态的;linux提供的异步文件IO系统调用nio又不支持操作系统的文件缓存,不适合用来实现HOOK(这会导致用户的所有文件IO都不经过系统缓存而直接操作硬盘,这是一种不恰当的做法)。

除此之外也还会有其他不能HOOK或未被HOOK的阻塞syscall,因此需要一个线程池机制来解决这种阻塞行为对协程调度的干扰。

libgo提供了一个宏:co_await,来辅助用户完成线程池与协程的交互。

在协程中使用

可以把func投递到线程池中,并且挂起当前协程,直到func完成后协程会被唤醒,继续执行下去。也可以使用

等待bar在线程池中完成,并将bar的返回值写入变量a中。co_await也同样可以在协程之外被调用。

另外,为了用户更灵活的定制线程数量,也为了libgo不偷起后台线程的操守;线程池并不会自行启动,需要用户自行启动一个或多个线程执行co_sched.GetThreadPool().RunLoop();

调试

libgo作为框架级的协程库,调试机制是必不可少的。

协程之外(运行在线程上的代码)

前文提到了很多功能都可以在线程上执行:Channel、co_await、co_mutex、定时器、CLS

跨平台

libgo支持三大主流系统:linux、windows、mac-os

linux是主打平台,也是libgo运行性能最好的平台,master分支永远支持linux

win分支支持windows系统,会不定期的将master分支的新功能合入其中

mac的情况同windows

上层封装

笔者另有一个开源库:libgonet,是基于libgo封装的linux协程网络库,使用起来极为方便。

如果你要开发一个网络服务或rpc框架,更推荐从libgonet写起,毕竟即使有协程,socket相关的处理也并不轻松。

未来的发展方向

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8