杰克-逊の黑豹,恰饭啦 []( ̄▽ ̄)
躁动的好奇心
协程,一个迷幻之词。Python有它,Cpp有它,Nodejs有它,Rust有它,Go有它,kotlin有它。。。。协程究竟是什么,各有纷纭。越是如此,我越是好奇,越是想搞明白它。好了,闭环了。概念层出不穷,可你要真的理解,就要亲手搞一个,哪怕它粗鄙简陋。好奇心人皆有之,我是前端,我也想搞个明白。
协程简述
网上有很多资料介绍协程,落实到具体的编程语言,协程的概念会更具体一些,但共性是不变的。简言之,协程就是用户线程。什么?用户线程?用一个没解释清楚的东西去解释另一个没解释清楚的东西,强行套娃是吧?
别急。我们知道,真正做事情的是CPU。不论你搞出什么抽象,进程也好、线程也罢,最终都是CPU干活儿,这一点,像极了当代牛马,区别是我们生病,它冒烟儿。CPU要干活儿的话,只需要哪些东西呢?嗯,指令和栈。
单看进程或者线程,CPU要执行的指令是咱程序员写的,可是栈呢,是操作系统默认给的。一个CPU不是如此单纯,它三心二意,雨露均沾,执行多个进程或者线程。CPU想达成这种成就,就要换掉指令和栈。这个事儿就是调度,操作系统搞起来的。
如果说,栈是程序员准备好的,调度也是程序员给出的代码搞定的,这就是协程,准确来讲,是有栈协程。与之相对的,是无栈协程,本文不涉及。
进程和线程是很具体的概念,因为操作系统用C语言的结构体将它们定义出来了。照猫画虎,协程也是如此,程序员可以使用C语言的结构体定义它。
协程都该有什么
协程作为结构体,它规定了CPU要执行哪些指令,在哪里执行指令。协程里边,该有点什么呢?
展开说之前,先说一句废话。我们作为软件开发人员,无法直接控制CPU去做什么,但我们可以利用CPU指令去控制寄存器的值,来改变CPU的行为。比如,我们可以用"ret"指令,改变PC寄存器的值,告诉CPU接下来执行什么指令。我们可以用"mov"指令改变堆栈寄存器的值,告诉CPU接下来要到哪个栈存数据、读数据。如果说最上层的开发,是面向API和系统调用开发,那么搞一个协程就是面向ABI和寄存器开发。
为了让CPU知道接下来的操作是在哪个栈发生的,协程要定义一些变量,记录栈顶寄存器、栈底寄存器。
为了让CPU知道接下来要执行哪个指令,协程要定义一个变量,追踪PC寄存器。
有了这点儿抽象,就可以玩起来了。
所谓调度,就是从多个协程结构体里,选出来一个结构体,然后把CPU的寄存器切换成结构体里对应的变量值,我们就说,CPU执行这个协程了。
搞起来搞起来
具体实现离不开具体的平台,本文是基于aarch64 macOS, 指令用的是arm指令,编译器用的就是系统自带的gcc(clang)。
完整代码:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#define STACK_SIZE 1024
typedef struct {
/** 栈顶寄存器 */
u_int64_t sp;
/** 栈底寄存器 */
u_int64_t x29;
/** 返回地址寄存器 */
u_int64_t x30;
/** 主协程结束,跳回main函数使用 */
u_int64_t endAddr;
u_int64_t endSp;
u_int64_t endX29;
u_int64_t endX30;
uint64_t entry;
u_int8_t* stack;
size_t size;
/** 标记协程是否执行完毕 */
int dead;
} Routine;
typedef void (*Entry)();
/** 记录当前执行的协程 */
Routine* current;
/** 模拟协程池 */
Routine queue[2];
Routine createRoutine(Entry entry) {
u_int8_t* stack = (u_int8_t*)malloc(STACK_SIZE);
if (stack == NULL) {
perror("Failed to allocate stack memory");
exit(EXIT_FAILURE);
}
Routine r;
uint64_t aligned_sp = (uint64_t)(stack + STACK_SIZE - 1) & ~0xF; // 16字节对齐
r.sp = aligned_sp;
r.x29 = aligned_sp;
r.entry = (uint64_t)entry;
r.x30 = (uint64_t)entry;
r.size = STACK_SIZE - 1;
r.dead = 0;
r.stack = stack;
return r;
}
void switchToRoutine(Routine* routine) {
__asm__ volatile(
"add %0, sp, #32\n\t"
"mov %1, x29\n\t"
"mov %2, x30\n\t"
: "=r"(current->sp), "=r"(current->x29), "=r"(current->x30)
:
:"memory"
);
current = routine;
__asm__ volatile(
"ldr x10, [%0]\n\t"
"mov sp, x10\n\t"
"ldr x29, [%1]\n\t"
"ldr x30, [%2]\n\t"
"ret\n\t"
:
:"r"(¤t->sp), "r"(¤t->x29), "r"(¤t->x30)
:"x10", "x30", "memory"
);
}
void queueOneDead() {
queue[1].dead = 1;
}
Routine* queueZero() {
return &queue[0];
}
Routine* queueOne() {
return &queue[1];
}
// 模拟用户给协程定义要执行的代码
void hello() {
printf("hello\n");
switchToRoutine(&queue[0]);
printf("world\n");
// 标记协程结束;
// 之所以放在一个函数中完成,是因为编译器会优化代码,
// 将 queue[1] 写入栈中,当写入queue[1]时,并不会
// 更新全局作用域的queue[1], 而是更新栈上的内容;
// 这会调至 switchToRoutine 跳转到另一个协程时,
// 出现问题。
queueOneDead();
switchToRoutine(queueZero());
}
void mainRoutineEntry() {
while(1) {
printf("enter main routine\n");
// 模拟调度逻辑
// 模拟结束调度的条件,目前我们是一个协程,
// 如果多个协程,改用循环语句判断
if (queue[1].dead == 1) {
printf("yes\n");
Routine* a = queueZero();
__asm__ volatile(
"mov sp, %0\n\t"
"mov x29, %1\n\t"
"mov x30, %2\n\t"
"ret\n\t"
:
:"r"(a->endSp), "r"(a->endX29), "r"(a->endX30)
);
} else {
// 模拟选中下一个协程,切换过去
switchToRoutine(&queue[1]);
}
}
};
void execute() {
__asm__ volatile(
"add sp, sp, #16\n\t"
"mov x10, sp\n\t"
"str x10, [%0]\n\t"
"str x29, [%1]\n\t"
"str x30, [%2]\n\t"
"ldr x10, [%3]\n\t"
"mov sp, x10\n\t"
"ldr x29, [%4]\n\t"
"ldr x30, [%5]\n\t"
"ret\n\t"
:
:"r"(&queue[0].endSp), "r"(&queue[0].endX29), "r"(&queue[0].endX30),"r"(&queue[0].sp), "r"(&queue[0].x29), "r"(&queue[0].x30)
:"x10", "x30", "memory"
);
}
int main() {
Routine routine = createRoutine(hello);
Routine Main = createRoutine(mainRoutineEntry);
queue[0] = Main;
queue[1] = routine;
current = &queue[0];
execute();
printf("ok, that's right\n");
free((void *)(routine.stack));
free((void*)(Main.stack));
printf("wow\n");
return 0;
}
如果你的电脑也是aarch64 macOS,可以下载这段代码,本地编译运行,看看效果:
gcc main.c -o main
# 如果上面的代码无法执行起来,或者执行报错,可以尝试这种方式
gcc main.c -O1 -o main
为了便于理解,做一下简短的介绍,完整版在这里.完整版里,记载了更多内容,包含上述代码的缺陷和改进版本,还介绍了lldb调试时,一般场景下用到的指令。
aarch64体系里,x29寄存器设置的是栈底寄存器,sp寄存器设置的是栈顶寄存器。bl指令用于跳转到某个函数,它会把PC寄存器设置为那个函数的地址,还会把bl指令下一个指令的地址写入到x30寄存器,当函数结尾执行"ret"指令时,就会把x30寄存器的值写入到PC寄存器,函数就返回了。我们无法直接使用指令改写PC寄存器的值,但是可以通过这个机制,设置x30寄存器的值,间接改变PC寄存器的值。
上边这一大坨代码,整体思路是这样的:
- 建立好主协程Main和普通协程routine, 存入到协程队列queue。协程的入口函数,在C语言中,函数名就代表着它的内存地址,又因为咱们的平台是64位,因此用一个64位的无符号整数存储即可,这个要存入到结构体的x30变量里,在协程运行的时候,我们会往x30寄存器里写入这个数,CPU就可以跳转到入口函数执行了。
- 执行execute函数,先把主协程运行起来,同时,要把execute后边的指令存储起来,等所有的协程执行完毕后,我们把x30寄存器改为这个值,就能回到main函数了。
- 主协程和普通协程之间来回切换执行,直到所有的普通协程执行完毕。
- 主协程切换回main函数,释放用作栈的堆内存,程序结束。
即便是一个玩具,写的过程中也遇到了很多问题。最主要的就是访问非法内存的问题。我们的内联汇编改写了sp寄存器。而在hello这样的C函数中,使用局部变量必须要依靠sp寄存器在内存中寻址。sp寄存器咱们没写入正确的数据,就会有内存问题。不得不感叹C语言,给予程序员的自由度真大,危险也不小,但这种危险在开发阶段是避免不了的,很值得。同时,还能感受到,最终结果虽然是一致的,但是C语言的表述和汇编代码,仍有不同,编译器不同优化程度下的汇编代码,也很不同。
结束语
天气冷了,也干燥了,今天介绍就这么点儿东西,有点难以下咽,但没关系,多喝水,保重身体,未来还长,海平面还没涨,祝各位熬过“寒冬”(狗头保命表情)。