引言
协程是一个近几年比较火热的话题,其主要作用是提供一个由用户管理的“线程”,所以其也常常被称为“用户级线程”。协程通常来讲是语言级的,所以对于操作系统乃至更底层其是不可见的。我们知道,常见的任务可以大致分为两类,计算密集型任务和io密集型任务。计算密集型任务可以通过并行的提升来提升一些性能(并不是所有计算密集型任务都可以)是因为操作系统层面可见,我们知道现代操作系统调度的最小单位是线程,所以多个线程可以为任务抢占更多的时间片。当然这只是其中一个理解的角度,但是从这个方向上讲,协程由于是运行于线程之上,所以并不会在操作系统层面增加计算密集型任务的并行程度。也就是说协程对于大部分计算密集型任务作用较小,当然前提是承担任务的协程是运行在单个线程上时。
阻塞型IO在执行时,会阻塞IO线程,于是IO线程上的所有协程也同样运行不了,因为承担其的线程已经被阻塞了。但是如果我们设置两个线程,一个IO线程,一个当前线程。在IO线程上打开一个协程,在当前线程上打开一个协程,再去使用当前线程上的协程等待IO线程上的协程返回,这样就可以消除回调。因为当前线程的协程可以阻塞等待IO线程的协程返回,但是协程的阻塞不会影响线程,所以当前线程同时还可以在这个期间去执行其他任务。
所以如上所述,协程主要在IO密集型任务中作用很大。当然有些现代语言将协程和线程融为一体,做出了一个统一模型,整体由语言去调度,比如go语言的 goroutine 。
有栈协程和无栈协程
协程目前也有两个主流的分类,有栈协程 和 无栈协程。
有栈协程的模型类似于线程,每个协程携带自己的栈,好处在于无需外界为其保存状态,调度时可以直接将其整体拿出,然后恢复即可,但是栈是需要占用内存空间的。
无栈协程则没有自己的栈,其状态依靠一个或多个对象或结构体或其他形式来保存,好处是占用的空间较小。但是其需要外部为其保存状态,可能还需要编译器做额外支持;或者手写状态机,不过这种方式不太灵活。
无栈协程其实我们可以粗糙的理解为一个跳转模块:
struct Coroutine{
int state;
int data;
};
void run(struct Coroutine coroutine)
{
//根据不同状态执行内容
switch (coroutine.state)
{
case 0:
coroutine.data=0;
//更新状态
coroutine.state=1;
break;
case 1:
coroutine.data+=1;
coroutine.state=2;
break;
case 2:
coroutine.data+=2;
coroutine.state=3;
break;
case 3:
coroutine.data+=3;
coroutine.state=-1;
break;
}
}上面是一个非常粗糙的跳转模型,我们也可以看一下现实语言中的大致实现,我们以kotlin语言为例,其协程是一个典型的无栈协程:
先看原始代码:
suspend fun test(index: Int){
delay(1000.milliseconds)
println(index)
}
fun main() {
runBlocking {
launch {
for (i in 1..5) {
test(i)
}
}
}
}上面的代码很简单,我们主要看看反编译到Java的产物:
public static final void main() {
BuildersKt.runBlocking$default((CoroutineContext)null, new Function2((Continuation)null) {
int label;
// $FF: synthetic field
private Object L$0;
public final Object invokeSuspend(Object $result) {
IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
CoroutineScope $this$runBlocking = (CoroutineScope)this.L$0;
return BuildersKt.launch$default($this$runBlocking, (CoroutineContext)null, (CoroutineStart)null, new Function2((Continuation)null) {
int I$0;
int label;
public final Object invokeSuspend(Object $result) {
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
int i;
switch (this.label) {
case 0:
ResultKt.throwOnFailure($result);
i = 1;
break;
case 1:
i = this.I$0;
ResultKt.throwOnFailure($result);
++i;
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
while(i < 6) {
Continuation var10001 = (Continuation)this;
this.I$0 = i;
this.label = 1;
if (TKt.test(i, var10001) == var3) {
return var3;
}
++i;
}
return Unit.INSTANCE;
}
public final Continuation create(Object value, Continuation $completion) {
return (Continuation)(new <anonymous constructor>($completion));
}
public final Object invoke(CoroutineScope p1, Continuation p2) {
return ((<undefinedtype>)this.create(p1, p2)).invokeSuspend(Unit.INSTANCE);
}
// $FF: synthetic method
// $FF: bridge method
public Object invoke(Object p1, Object p2) {
return this.invoke((CoroutineScope)p1, (Continuation)p2);
}
}, 3, (Object)null);
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
}
public final Continuation create(Object value, Continuation $completion) {
Function2 var3 = new <anonymous constructor>($completion);
var3.L$0 = value;
return (Continuation)var3;
}
public final Object invoke(CoroutineScope p1, Continuation p2) {
return ((<undefinedtype>)this.create(p1, p2)).invokeSuspend(Unit.INSTANCE);
}
// $FF: synthetic method
// $FF: bridge method
public Object invoke(Object p1, Object p2) {
return this.invoke((CoroutineScope)p1, (Continuation)p2);
}
}, 1, (Object)null);
}可以很明显看到main函数中有与粗糙模型一样的switch跳转的影子,同时也有一个类似的保存状态的对象。
所以如果我们想实现一个无栈协程,要么使用编译器插件,在编译期修改,要么使用宏等替换。实现比较有难度。
有栈协程实现
我们主要还是看有栈协程的实现。有栈协程的实现非常接近于线程。
我们便于理解可以直接实现一个有栈协程的模型,我们选用aarch64平台来实现,好处是汇编比较简单,不会太影响我们理解有栈协程(毕竟要分清主次)。
我们先直接上代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CTX_SIZE 1024
int YIELD_COUNT;
char **FUNC = NULL;
char **MAIN_CTX = NULL;
extern void swap_ctx(char **current, char **next);
char **init_func(char *func)
{
size_t size = sizeof(char *) * CTX_SIZE;
char **ctx = malloc(size);
memset(ctx, 0, size);
*(ctx + CTX_SIZE - 1) = (char *) func;
*(ctx + CTX_SIZE - 14) = (char *) (ctx + CTX_SIZE - 16);
return ctx + CTX_SIZE;
}
void yield() {
switch ((YIELD_COUNT++) % 2) {
case 0:
swap_ctx(MAIN_CTX, FUNC);
break;
case 1:
swap_ctx(FUNC, MAIN_CTX);
break;
default:
break;
}
}
void fun(){
int tag = 99;
for (int i = 0; i < 3; i++) {
printf("func, tag: %d, index: %d\n", tag, i);
yield();
}
}
int main(){
MAIN_CTX=init_func((char *) main);
FUNC=init_func((char *) fun);
int tag = rand() % 100;
for (int i = 0; i < 3; i++) {
printf("main, tag: %d, index: %d\n", tag, i);
yield();
}
free(FUNC-CTX_SIZE);
free(MAIN_CTX-CTX_SIZE);
return 0;
}
//1
.text
.globl swap_ctx
.type swap_ctx, @function
//2
swap_ctx:
mov x2,x0
// 保存栈指针到内存
mov x3,sp
STR x3, [x2, #-112] // 保存SP到内存
// 保存基准指针寄存器到内存
STR x29, [x2, #-24] // 保存FP到内存
// 保存链接寄存器到内存
STR LR, [x2, #-8] // 保存LR到内存
// 保存调用者的寄存器到内存
STR x19, [x2, #-32]
STR x20, [x2, #-40]
STR x21, [x2, #-48]
STR x22, [x2, #-56]
STR x23, [x2, #-64]
STR x24, [x2, #-72]
STR x25, [x2, #-80]
STR x26, [x2, #-88]
STR x27, [x2, #-96]
STR x28, [x2, #-104]
mov x2,x1
// 加载栈指针寄存器的值
LDR x3, [x2, #-112]
mov sp,x3
// 加载基准指针寄存器的值
LDR x29, [x2, #-24]
// 加载被调用者保存的寄存器的值
LDR x19, [x2, #-32]
LDR x20, [x2, #-40]
LDR x21, [x2, #-48]
LDR x22, [x2, #-56]
LDR x23, [x2, #-64]
LDR x24, [x2, #-72]
LDR x25, [x2, #-80]
LDR x26, [x2, #-88]
LDR x27, [x2, #-96]
LDR x28, [x2, #-104]
// 加载链接寄存器的值
LDR LR, [x2,#-8]
mov x29,sp
RET可以看到其是使用c语言和汇编混合编写的,这里我们使用的是aarch64汇编(笔者最初是使用的nasm格式的x86汇编,但是这个版本找不到了🥲)。
汇编部分
下面我们首先看汇编代码,因为这部分做的事情其实不多。在注释 1 处,用伪指令声明了函数swap_ctx,这里提一下,我们使用的编译器是 gcc ,这部分伪指令是满足编译器要求,让c语言可以直接调用。函数有两个参数,两个都是协程的栈的地址。
注释2引出了函数体,函数体的内容。注释很清楚,首先保存了当前的栈寄存器和计数寄存器的值,然后将各种寄存器的值保存到函数参数给的第一个参数地址中的栈中。然后将第二个参数地址中的栈中的信息取出加载到当前寄存器中。这一套其实很像线程调度时的做法,这些寄存器中保存的是当前运行的状态,比如执行到哪一个指令了,计算结果等等,我们想要恢复到当前状态,当然需要保存。
c语言部分
下面看c语言代码。
先看init_func函数:
char **init_func(char *func)
{
size_t size = sizeof(char *) * CTX_SIZE;
char **ctx = malloc(size);
memset(ctx, 0, size);
*(ctx + CTX_SIZE - 1) = (char *) func;
*(ctx + CTX_SIZE - 14) = (char *) (ctx + CTX_SIZE - 16);
return ctx + CTX_SIZE;
}函数首先开辟了一个空间,然后将空间中所有内存全部置零。然后先在栈顶存入了函数地址,这个函数地址是函数入口,不过一般来说只会在第一次进入函数的时候从入口开始执行。然后后续的所有地址均为协程自己的栈空间。最后将整个空间的地址返回。
然后是yield函数:
void yield() {
switch ((YIELD_COUNT++) % 2) {
case 0:
swap_ctx(MAIN_CTX, FUNC);
break;
case 1:
swap_ctx(FUNC, MAIN_CTX);
break;
default:
break;
}
}比较简单,就是选择性的去调度函数,至于为什么取名yield,相信熟悉python的同学知道,这里有意和python的协程调用同名而已。
下面是两个执行函数:
void fun(){
int tag = 99;
for (int i = 0; i < 3; i++) {
printf("func, tag: %d, index: %d\n", tag, i);
yield();
}
}
int main(){
MAIN_CTX=init_func((char *) main);
FUNC=init_func((char *) fun);
int tag = rand() % 100;
for (int i = 0; i < 3; i++) {
printf("main, tag: %d, index: %d\n", tag, i);
yield();
}
free(FUNC-CTX_SIZE);
free(MAIN_CTX-CTX_SIZE);
return 0;
}分别在内部自动让出了执行权。
打印效果是两个函数会交叉打印,交叉打印时因为这里的调度是顺序调度,如果我们加入调度策略,就会随机打印,看上去就是一个并发执行的效果,我们这里并不是在理解调度策略,所以文中也简略实现,你可以自己基于上面的demo深入改进。
结语
通过上述讲解和案例,应该能比较清晰的理解协程这个概念。协程的底层概念还是比较简单的,当然现代编程语言的协程模型其实是非常复杂,如果想要掌握,只了解上述内容肯定是远远不够的。最后还要提一嘴,上述无栈协程的程序是伪代码,并不能直接执行;有栈协程的实现在aarch64平台,你可以自己查一下如何将c语言文件和汇编文件通过gcc混合编译。有栈协程代码的实现距今有很长一段时间,那时我还是一个“新兵蛋子”,其中可能会存在一些小bug,但是在当前代码和所述的平台下可以正确执行。有问题和想法欢迎交流。