写点什么

C++ 协程的近况、设计与实现中的细节和决策

发布于: 2021 年 05 月 27 日

时至 2018 年的今天,C++ 在互联网服务端开发方向依然占据着相当大的份额;百度,腾讯,甚至以 java 为主流开发语言的阿里都在大规模使用 C++做互联网服务端开发,而这恰恰是本文想要讨论的范畴。

C++协程实现及原理分析视频讲解:

C/C++ Linux 服务器开发高级架构学习视频点击:C/C++Linux服务器开发高级架构/Linux后台架构师-学习视频

协程的实现与原理剖析丨掌握协程的运用丨实例讲解(上)

协程的实现与原理剖析丨掌握协程的运用丨实例讲解(下)

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

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

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

每个流程都要定义一个上下文 struct,并手动保存与恢复;

每次回调都会切断栈上变量的生命周期,导致需要延续使用的变量必须申请到堆上或存入上下文结构中;

由于 C++是无 GC 的语言,碎片化的逻辑给内存管理也带来了更多挑战;

回调式的逻辑是“不知何时会被触发”的,用户状态管理也会有更多挑战;

这些具体的难题综合起来,在工程化角度呈现出的效果就是:代码编写复杂,开发周期长,维护困难,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 在协程之外,粗粒度的线程锁等等。

关于 C/C++ Linux 后端开发网络底层原理知识 点击 学习资料 获取,内容知识点包括 Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux 内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK 等等。

5.语言级语言级的协程实现

代表作:golang 语言

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

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

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

1.使用操作系统提供的 api:ucontext、fiber 这种方式是最安全可靠的,但是性能比较差。(切换性能大概在 200 万次/秒左右)

2.使用 setjump、longjump:代表作:libmill

3.自己写汇编码实现这种方式的性能可以很好,但是不同系统、甚至不同版本的 linux 都需要不同的汇编码,兼容性奇差无比,代表作:libco

4.使用 boost.coroutine 这种方式的性能很好,boost 也帮忙处理了各种平台架构的兼容性问题,缺陷是这东西随着 boost 的升级,并不是向后兼容的,不推荐使用

5.使用 boost.context 性能、兼容性都是当前最佳的,推荐使用。(切换性能大概在 1.25 亿次/秒左右)

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

不愿意依赖 boost 库的用户直接编译即可选择第 1 种方案;

追求更佳性能的用户编译时使用 cmake 参数-DENABLE_BOOST_CONTEXT=ON 即可选择第 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

使用这种方案单机启动了上千万协程。

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

1.协程切换慢:每次协程切换,都需要 2 次 Copy 协程栈内存,这个内存量基本上都在 1KB 以上,通常是几十 kb 甚至几百 kb,这样的 2 次 Copy 要花费很长的时间。

2.栈上引用失效导致隐蔽的 bug:例如下面的代码

点击此处添加图片说明文字

​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 作为框架级的协程库,调试机制是必不可少的。

1.可以设置 co_sched.GetOptions().debug 打印一些 log,具体 flag 见 config.h

2.可以设置一个协程事件监听器,详见 tutorial 文件夹下的 sample12_listener.cpp 教程代码

3.编译时添加 cmake 参数:-DENABLE_DEBUGGER=ON 开启 debug 信息收集后,可以使用 co::CoDebugger 类获取一些调试信息,详见 debugger.h 的注释

4.后续还会提供更多调试手段

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

前文提到了很多功能都可以在线程上执行: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 相关的处理也并不轻松。

未来的发展方向

1.目前是使用 go、go_stack、go_dispatch 三个不同的宏来设置协程的属性,这种方式不够灵活,后续要改成: go stack(1024 * 1024) dispatch(::co::egod_robin) func; 这样的语法形式,可以更灵活的定制协程属性。

2.基于(1)的新语法,实现“协程亲缘性”功能,将协程绑定到指定线程上,并防止被 steal。

3.优化协程切换速度:

A)使用环切调度替代现在的星切调度(CoYeild 时选择下一个切换目标),必要时才切换回线程处理 epoll、定时器、sleep 等逻辑,同时协调好多线程调度

B)调度器的 Run 函数里面做了很多协程切换之外的事情,尽量降低这部分在非必要时的 cpu 消耗,比如:有任务加入定时器是设置一个 tls 标记为 true,只有标记为 true 时才去处理定时器相关逻辑。

C)调度器中的 runnable 队列使用了自旋锁,没有竞争时对原子变量的操作也是比较昂贵的,runnable 队列可以优化成多写一读,仅在写入端加锁的队列。

4.协程对象 Task 内存布局调优,tls 池化,每个池使用多写一读链表队列,申请时仅在当前线程的池中申请,可以免锁,释放时均衡每个线程的池水水位,可以塞入其他线程的池中。

5.libgo 之外,会进一步寻找和当前已经比较成熟的非协程的开发框架的结合方案,让还未能用上协程的用户低成本的用上协程。

用户头像

Linux服务器开发qun720209036,欢迎来交流 2020.11.26 加入

专注C/C++ Linux后台服务器开发。

评论

发布
暂无评论
C++ 协程的近况、设计与实现中的细节和决策