前端der搞个协程玩玩儿

188 阅读8分钟

杰克-逊の黑豹,恰饭啦 []( ̄▽ ̄)

躁动的好奇心

协程,一个迷幻之词。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"(&current->sp), "r"(&current->x29), "r"(&current->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寄存器的值。

上边这一大坨代码,整体思路是这样的:

  1. 建立好主协程Main和普通协程routine, 存入到协程队列queue。协程的入口函数,在C语言中,函数名就代表着它的内存地址,又因为咱们的平台是64位,因此用一个64位的无符号整数存储即可,这个要存入到结构体的x30变量里,在协程运行的时候,我们会往x30寄存器里写入这个数,CPU就可以跳转到入口函数执行了。
  2. 执行execute函数,先把主协程运行起来,同时,要把execute后边的指令存储起来,等所有的协程执行完毕后,我们把x30寄存器改为这个值,就能回到main函数了。
  3. 主协程和普通协程之间来回切换执行,直到所有的普通协程执行完毕。
  4. 主协程切换回main函数,释放用作栈的堆内存,程序结束。

即便是一个玩具,写的过程中也遇到了很多问题。最主要的就是访问非法内存的问题。我们的内联汇编改写了sp寄存器。而在hello这样的C函数中,使用局部变量必须要依靠sp寄存器在内存中寻址。sp寄存器咱们没写入正确的数据,就会有内存问题。不得不感叹C语言,给予程序员的自由度真大,危险也不小,但这种危险在开发阶段是避免不了的,很值得。同时,还能感受到,最终结果虽然是一致的,但是C语言的表述和汇编代码,仍有不同,编译器不同优化程度下的汇编代码,也很不同。

结束语

天气冷了,也干燥了,今天介绍就这么点儿东西,有点难以下咽,但没关系,多喝水,保重身体,未来还长,海平面还没涨,祝各位熬过“寒冬”(狗头保命表情)。