万字长文带你深入理解协程|业界设计和实现的决策分析

C语言与CPP编程 2024-01-16 08:30

击上方“C语言与CPP编程”,选择“关注/置顶/星标公众号

干货福利,第一时间送达!


摘要: 讲述C++协程的近况、设计与实现中的细节与决策


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



第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在协程之外,粗粒度的线程锁等等。


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之外,会进一步寻找和当前已经比较成熟的非协程的开发框架的结合方案,让还未能用上协程的用户低成本的用上协程。


libgo开源地址:

 https://github.com/yyzybb537/libgo

EOF

你好,我是飞宇,本硕均于某中流985 CS就读,先后于百度搜索字节跳动电商以及携程等部门担任Linux C/C++后端研发工程师。

最近跟朋友一起开发了一个新的网站:编程资源网,已经收录了不少资源(附赠下载地址),如果屏幕前的靓仔/女想要学习编程找不到合适资源的话,不妨来我们的网站看看,欢迎扫码下方二维码白嫖~

同时,我也是知乎博主@韩飞宇,日常分享C/C++、计算机学习经验、工作体会,欢迎点击此处查看我以前的学习笔记&经验&分享的资源。                       

C语言与CPP编程 C语言/C++开发,C语言/C++基础知识,C语言/C++学习路线,C语言/C++进阶,数据结构;算法;python;计算机基础等
评论
  • 物联网(IoT)的快速发展彻底改变了从智能家居到工业自动化等各个行业。由于物联网系统需要高效、可靠且紧凑的组件来处理众多传感器、执行器和通信设备,国产固态继电器(SSR)已成为满足中国这些需求的关键解决方案。本文探讨了国产SSR如何满足物联网应用的需求,重点介绍了它们的优势、技术能力以及在现实场景中的应用。了解物联网中的固态继电器固态继电器是一种电子开关设备,它使用半导体而不是机械触点来控制负载。与传统的机械继电器不同,固态继电器具有以下优势:快速切换:确保精确快速的响应,这对于实时物联网系统至
    克里雅半导体科技 2025-01-03 16:11 185浏览
  • 光耦合器,也称为光隔离器,是一种利用光在两个隔离电路之间传输电信号的组件。在医疗领域,确保患者安全和设备可靠性至关重要。在众多有助于医疗设备安全性和效率的组件中,光耦合器起着至关重要的作用。这些紧凑型设备经常被忽视,但对于隔离高压和防止敏感医疗设备中的电气危害却是必不可少的。本文深入探讨了光耦合器的功能、其在医疗应用中的重要性以及其实际使用示例。什么是光耦合器?它通常由以下部分组成:LED(发光二极管):将电信号转换为光。光电探测器(例如光电晶体管):检测光并将其转换回电信号。这种布置确保输入和
    腾恩科技-彭工 2025-01-03 16:27 178浏览
  • 车身域是指负责管理和控制汽车车身相关功能的一个功能域,在汽车域控系统中起着至关重要的作用。它涵盖了车门、车窗、车灯、雨刮器等各种与车身相关的功能模块。与汽车电子电气架构升级相一致,车身域发展亦可以划分为三个阶段,功能集成愈加丰富:第一阶段为分布式架构:对应BCM车身控制模块,包含灯光、雨刮、门窗等传统车身控制功能。第二阶段为域集中架构:对应BDC/CEM域控制器,在BCM基础上集成网关、PEPS等。第三阶段为SOA理念下的中央集中架构:VIU/ZCU区域控制器,在BDC/CEM基础上集成VCU、
    北汇信息 2025-01-03 16:01 203浏览
  • 根据Global Info Research项目团队最新调研,预计2030年全球封闭式电机产值达到1425百万美元,2024-2030年期间年复合增长率CAGR为3.4%。 封闭式电机是一种电动机,其外壳设计为密闭结构,通常用于要求较高的防护等级的应用场合。封闭式电机可以有效防止外部灰尘、水分和其他污染物进入内部,从而保护电机的内部组件,延长其使用寿命。 环洋市场咨询机构出版的调研分析报告【全球封闭式电机行业总体规模、主要厂商及IPO上市调研报告,2025-2031】研究全球封闭式电机总体规
    GIRtina 2025-01-06 11:10 99浏览
  • 这篇内容主要讨论三个基本问题,硅电容是什么,为什么要使用硅电容,如何正确使用硅电容?1.  硅电容是什么首先我们需要了解电容是什么?物理学上电容的概念指的是给定电位差下自由电荷的储藏量,记为C,单位是F,指的是容纳电荷的能力,C=εS/d=ε0εrS/4πkd(真空)=Q/U。百度百科上电容器的概念指的是两个相互靠近的导体,中间夹一层不导电的绝缘介质。通过观察电容本身的定义公式中可以看到,在各个变量中比较能够改变的就是εr,S和d,也就是介质的介电常数,金属板有效相对面积以及距离。当前
    知白 2025-01-06 12:04 141浏览
  • 彼得·德鲁克被誉为“现代管理学之父”,他的管理思想影响了无数企业和管理者。然而,关于他的书籍分类,一种流行的说法令人感到困惑:德鲁克一生写了39本书,其中15本是关于管理的,而其中“专门写工商企业或为企业管理者写的”只有两本——《为成果而管理》和《创新与企业家精神》。这样的表述广为流传,但深入探讨后却发现并不完全准确。让我们一起重新审视这一说法,解析其中的矛盾与根源,进而重新认识德鲁克的管理思想及其著作的真正价值。从《创新与企业家精神》看德鲁克的视角《创新与企业家精神》通常被认为是一本专为企业管
    优思学院 2025-01-06 12:03 104浏览
  • 在智能家居领域中,Wi-Fi、蓝牙、Zigbee、Thread与Z-Wave等无线通信协议是构建短距物联局域网的关键手段,它们常在实际应用中交叉运用,以满足智能家居生态系统多样化的功能需求。然而,这些协议之间并未遵循统一的互通标准,缺乏直接的互操作性,在进行组网时需要引入额外的网关作为“翻译桥梁”,极大地增加了系统的复杂性。 同时,Apple HomeKit、SamSung SmartThings、Amazon Alexa、Google Home等主流智能家居平台为了提升市占率与消费者
    华普微HOPERF 2025-01-06 17:23 134浏览
  • 随着市场需求不断的变化,各行各业对CPU的要求越来越高,特别是近几年流行的 AIOT,为了有更好的用户体验,CPU的算力就要求更高了。今天为大家推荐由米尔基于瑞芯微RK3576处理器推出的MYC-LR3576核心板及开发板。关于RK3576处理器国产CPU,是这些年的骄傲,华为手机全国产化,国人一片呼声,再也不用卡脖子了。RK3576处理器,就是一款由国产是厂商瑞芯微,今年第二季推出的全新通用型的高性能SOC芯片,这款CPU到底有多么的高性能,下面看看它的几个特性:8核心6 TOPS超强算力双千
    米尔电子嵌入式 2025-01-03 17:04 54浏览
  • 本文介绍Linux系统更换开机logo方法教程,通用RK3566、RK3568、RK3588、RK3576等开发板,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。制作图片开机logo图片制作注意事项(1)图片必须为bmp格式;(2)图片大小不能大于4MB;(3)BMP位深最大是32,建议设置为8;(4)图片名称为logo.bmp和logo_kernel.bmp;开机
    Industio_触觉智能 2025-01-06 10:43 87浏览
  • 大模型的赋能是指利用大型机器学习模型(如深度学习模型)来增强或改进各种应用和服务。这种技术在许多领域都显示出了巨大的潜力,包括但不限于以下几个方面: 1. 企业服务:大模型可以用于构建智能客服系统、知识库问答系统等,提升企业的服务质量和运营效率。 2. 教育服务:在教育领域,大模型被应用于个性化学习、智能辅导、作业批改等,帮助教师减轻工作负担,提高教学质量。 3. 工业智能化:大模型有助于解决工业领域的复杂性和不确定性问题,尽管在认知能力方面尚未完全具备专家级的复杂决策能力。 4. 消费
    丙丁先生 2025-01-07 09:25 65浏览
  • 每日可见的315MHz和433MHz遥控模块,你能分清楚吗?众所周知,一套遥控设备主要由发射部分和接收部分组成,发射器可以将控制者的控制按键经过编码,调制到射频信号上面,然后经天线发射出无线信号。而接收器是将天线接收到的无线信号进行解码,从而得到与控制按键相对应的信号,然后再去控制相应的设备工作。当前,常见的遥控设备主要分为红外遥控与无线电遥控两大类,其主要区别为所采用的载波频率及其应用场景不一致。红外遥控设备所采用的射频信号频率一般为38kHz,通常应用在电视、投影仪等设备中;而无线电遥控设备
    华普微HOPERF 2025-01-06 15:29 114浏览
  • 自动化已成为现代制造业的基石,而驱动隔离器作为关键组件,在提升效率、精度和可靠性方面起到了不可或缺的作用。随着工业技术不断革新,驱动隔离器正助力自动化生产设备适应新兴趋势,并推动行业未来的发展。本文将探讨自动化的核心趋势及驱动隔离器在其中的重要角色。自动化领域的新兴趋势智能工厂的崛起智能工厂已成为自动化生产的新标杆。通过结合物联网(IoT)、人工智能(AI)和机器学习(ML),智能工厂实现了实时监控和动态决策。驱动隔离器在其中至关重要,它确保了传感器、执行器和控制单元之间的信号完整性,同时提供高
    腾恩科技-彭工 2025-01-03 16:28 170浏览
  • PLC组态方式主要有三种,每种都有其独特的特点和适用场景。下面来简单说说: 1. 硬件组态   定义:硬件组态指的是选择适合的PLC型号、I/O模块、通信模块等硬件组件,并按照实际需求进行连接和配置。    灵活性:这种方式允许用户根据项目需求自由搭配硬件组件,具有较高的灵活性。    成本:可能需要额外的硬件购买成本,适用于对系统性能和扩展性有较高要求的场合。 2. 软件组态   定义:软件组态主要是通过PLC
    丙丁先生 2025-01-06 09:23 77浏览
  •     为控制片内设备并且查询其工作状态,MCU内部总是有一组特殊功能寄存器(SFR,Special Function Register)。    使用Eclipse环境调试MCU程序时,可以利用 Peripheral Registers Viewer来查看SFR。这个小工具是怎样知道某个型号的MCU有怎样的寄存器定义呢?它使用一种描述性的文本文件——SVD文件。这个文件存储在下面红色字体的路径下。    例:南京沁恒  &n
    电子知识打边炉 2025-01-04 20:04 94浏览
  • 在快速发展的能源领域,发电厂是发电的支柱,效率和安全性至关重要。在这种背景下,国产数字隔离器已成为现代化和优化发电厂运营的重要组成部分。本文探讨了这些设备在提高性能方面的重要性,同时展示了中国在生产可靠且具有成本效益的数字隔离器方面的进步。什么是数字隔离器?数字隔离器充当屏障,在电气上将系统的不同部分隔离开来,同时允许无缝数据传输。在发电厂中,它们保护敏感的控制电路免受高压尖峰的影响,确保准确的信号处理,并在恶劣条件下保持系统完整性。中国国产数字隔离器经历了重大创新,在许多方面达到甚至超过了全球
    克里雅半导体科技 2025-01-03 16:10 122浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦