什么是协程
我刚开始工作的时候,需要调研熟悉freertos,由于学习过程中一直使用Linux,不太了解实时操作系统,所以简单去官网看了下介绍,很快就被协程吸引了注意:
当时的我只是了解进程和线程,协程是个陌生词汇。查阅一些资料后,获取了如下相关信息,初步认识了一下协程:
- 协程也常常被称为,“轻量级线程”、“微线程”、“纤程(fiber)”等,英文词汇常使用coroutine
- 协程是用户层面上自行实现的任务调度对象,其与用户态线程非常相似,主要差异是每个协程不被内核抢占式的调度,一般是通过单例调度器去主动create,resume,yield。
- 协程适用于 IO 密集型的任务。常见提供原生协程支持的语言有:c++20、go,lua、python 等,其他语言以库的形式提供协程功能,比如 C++20 之前腾讯的 fiber 和 libco ,c语言的ntyco(wangbojing);
- 我们可以简单以协程和线程做类比,表面上他们都可以运行你注册上去的回调函数,但是线程(比如posix_thread)需要用内核线程去绑定运行,而协程是由用户态的调度器实例,去自行维护协程切换上下文,他们的并发机理不一样,开销也不同。实际上,协程是对线程资源的进一步代理。
进程 | 线程 | 协程 | |
---|---|---|---|
定义 | 独立的执行单位 | 一个进程中的执行单位 | 在线程内部的执行单位 |
资源 | 拥有独立的资源空间 | 共享进程的资源空间 | 共享线程的资源空间 |
调度 | 由操作系统进行调度 | 由线程调度器进行调度 | 由协程调度器进行调度 |
切换 | 切换开销较大 | 切换开销较小 | 切换开销极小 |
并发性 | 可以实现真正的并发性(并行) | 可以实现并发性(内核线程真并行) | 由协程调度器进行并行调度 |
通信 | 进程间通信机制(ipc) | 线程间共享进程虚拟内存空间 | 通过消息传递 |
同步 | 使用进程间同步机制 | 使用线程同步机制 | 使用协程同步机制 |
开发成本 | 相对较高 | 中等 | 相对较低 |
协程有什么用
上面也提到了,协程可以在CPU密集型的多任务处理场景中大展拳脚,他可以把线程的cpu时间代理,细分为多个协程,每个协程分得一点资源去运行自己的回调,也就是充分的利用了cpu。同时整体编程难度约等于同步编程,但是性能却是异步编程的性能,通过非阻塞方式处理任务,提高程序的并发性和响应性。在网络编程、Web开发、爬虫等场景中特别常见。
此处举例ntyco作者(wangbojing)对于协程的调侃:
在做网络 IO 编程的时候 ,有一个非常理想的情况,就是每次 accept 返回的时候 ,就为新来的客户端分配 一个线程 ,这样一个客户端对应线程 。就不会有多个线程共用一sockfd 。每请求线程的方式, 并且代码逻辑非常易读。 但是这只理想 ,线程创建代价调度就呵呵了。先来看一下每请求一个线程的 情况,代码如下 :
while(1)
{
socklen_t len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd, (struct sockaddr*)&remote, &len);
pthread_t thread_id;
pthread_create(&thread_id, NULL, client_cb, &clientfd);
}
这样的做法 ,写完 放到生产环境下面,如果你的老板不打死你,你来找我 来帮你老板,为民除害。如果我们有协程 ,我们就可以这样实现 。参考代码如下:https://github.com/wangbojing/NtyCo/blob/master/nty_server_test.c
while (1)
{
socklen_t len = sizeof(struct sockaddr_in);
int cli_fd = nty_accept(fd, (struct sockaddr*)&remote, &len);
nty_coroutine *read_co;
nty_coroutine_create(&read_co, server_reader, &cli_fd);
}
这样的 代码是完全可以放在生产环境下面。如果你 的老板要打死,你来找 我,我帮你把老板打死 ,为民除害 。
通过以上的玩笑,我们可以初步领略一下协程的风采,协程背后的高性能是如何确保的呢?
协程的实现及应用
协程由于要对线程中的cpu时间片进行划分,调度算法且不说是基于抢占式还是完全公平,协程的上下文切换就不得不作为主要考虑的问题,目前协程基于context_switch的区别主要分为以下几种:
- longjmp,setjmp(跨栈goto)
- ucontext
- 汇编操作寄存器实现上下文切换
以neyco为例,该库基于汇编实现寄存器的切换,此处需要补充部分寄存器知识点:
x86_64 的寄存器有16个64位寄存器,分别是:%rax, %rbx, %rcx, %esi, %edi, %rbp, %rsp, %r8, %r9, %r10, %r11, %r12,%r13, %r14, %r15。
其中:
- %rax 作为函数返回值使用的。
- %rsp 栈指针寄存器,指向栈顶
- %rdi, %rsi, %rdx, %rcx, %r8, %r9 用作函数参数,依次对应第1参数,第2参数。。。(所以参数超过六个要借助栈偏移传输,要被吃不少性能)
- %rbx, %rbp, %r12, %r13, %r14, %r15 用作数据存储,遵循调用者使用规则,换句话说,就是随便用。调用子函数之前要备份它,以防它被修改
- %r10, %r11 用作数据存储,就是使用前要先保存原值。
上下文切换的时候,通过mov指令,将当前的寄存器状态保存到当前协程实例中,将下一个协程实例的寄存器状态再mov到当前寄存器上。
来源:https://github.com/wangbojing/NtyCo/blob/master
__asm__ (
" .text \n"
" .p2align 4,,15 \n"
".globl _switch \n"
".globl __switch \n"
"_switch: \n"
"__switch: \n"
" movq %rsp, 0(%rsi) # save stack_pointer \n"
" movq %rbp, 8(%rsi) # save frame_pointer \n"
" movq (%rsp), %rax # save insn_pointer \n"
" movq %rax, 16(%rsi) \n"
" movq %rbx, 24(%rsi) # save rbx,r12-r15 \n"
" movq %r12, 32(%rsi) \n"
" movq %r13, 40(%rsi) \n"
" movq %r14, 48(%rsi) \n"
" movq %r15, 56(%rsi) \n"
" movq 56(%rdi), %r15 \n"
" movq 48(%rdi), %r14 \n"
" movq 40(%rdi), %r13 # restore rbx,r12-r15 \n"
" movq 32(%rdi), %r12 \n"
" movq 24(%rdi), %rbx \n"
" movq 8(%rdi), %rbp # restore frame_pointer \n"
" movq 0(%rdi), %rsp # restore stack_pointer \n"
" movq 16(%rdi), %rax # restore insn_pointer \n"
" movq %rax, (%rsp) \n"
" ret \n"
);
当然,此处展示的仅仅是原理上的东西,一个被整个领域盛赞的项目是需要根据需求痛点,结合大大小小技术点去逐步完善的,如果项目中有地方需要使用协程,不妨花时间去阅读源码并结合测试,相信读到此处大家对协程这个概念已经不陌生了,本帖的任务也已经完成,再会。