当谈论协程时,我们在谈论什么

2023年 9月 12日 85.7k 0

「什么是协程?」几乎是现在面试的必考题。

一方面,Donald E. Knuth 说「子过程是协程的一种特殊表现形式」;另一方面,由于 coroutine 的中文翻译「协程」中包含有「程」字,因此一般会拿来与「进程」、「线程」进行比较,称为「轻量级线程」。

  • 第一部分介绍协程的历史;
  • 第二部分主要是介绍函数调用和协作式多任务处理,虽然其他介绍协程的文章中也都讲解了函数调用,在本文中,我在构思如何进行分享时,特意使用汇编来实现函数调用 (汇编实现 main 调用 hello),为后面实现简单的协程库做好铺垫,而这正是理解协程切换的关键,推荐大家阅读;
  • 第三部在实现了一个简单的协程库后,通过对比来加深理解,然后介绍 libco hook 的实现;
  • 第四部分介绍使用协程时需要注意的一些问题。

在本文中,我将试着去回答以下四个问题:

  • Q1 (Why): **为什么需要协程?**我们会一起回顾协程出现的历史背景,当时要解决什么问题;同时,现在是什么场景,需要使用协程来进行处理?为什么进程或者线程不能够很好地满足当下的使用场景?
  • Q2 (What): **到底什么是协程?**我们一直在谈论协程。由于协程中包含有「程」这个字眼,因此经常被拿来与进程线程进行对比,称协程为「用户态线程」;但又有人从协程实现的角度,说「协程是一种泛化的函数」。这就把我们给绕晕了。我们不禁要问,到底什么是协程?在本次分享中,我会试着进行回答。
  • Q3 (How): **怎么实现协程 (库)?**在回答了协程是什么之后,第三个问题就紧随而来,我们可以自己去实现一个简单的协程或者协程库吗?如果可以实现,那我们应该怎么实现呢?
  • Q4 (Usage): **使用协程时需要注意什么?**通过实际编码实现一个简单的协程库后,我们再来看 libco 的实现,就会清晰明了。我们会在第四部分介绍使用协程时需要注意的一些问题。

这就是我本次分享想要达成的目标 —— 回答这四个问题。

首先我们来看第一个问题,为什么需要协程?

Q1: 为什么需要协程?

在 1958 年,协程概念的提出者 Melvin Conway,想要为 COBOL 高级编程语言去实现一个 one-pass 的编译器。

Melvin Conway,康威定律 (Conway's Law) 的提出者,「设计系统的架构受制于产生这些设计的组织的沟通结构。」

这里的 pass,我们可以简单地理解为,一个 pass 就是对输入进行一次完整的扫描。也就是说他希望只扫描一次输入就实现编译,他通过借助协程来完成了这一工作。

为什么需要实现 one-pass 的 COBOL 编译器呢?目前找到的原因如下,来源 协程的历史,现在和未来:

COBOL 语言的限制:COBOL 不是 LL-parse 型语法

使用磁带作为存储设备,磁带存储设备只支持顺序存储,不支持随机访问

依次执行编译步骤并依靠中间文件通信的设计是不现实的,各步骤必须同步前进。

在 Conway 的设计里,词法分析和语法分析是交织在一起。编译器的控制流在词法分析、语法分析之间来回进行切换:当词法分析读入足够多的 token,控制流就交给语法分析;当语法分析消化完所有 token,控制流就交给词法分析。有点类似于我们所熟知的生产者消费者模式。词法分析模块和语法分析模块需要分别独立维护自身的运行状态。他所构建的这种协同工作机制,需要参与者主动让出控制权,同时记住自身状态,以便在控制权返回时能够从上次让出的位置继续往下执行。

他的这些思想发表在 1963 年的论文 "Design of a separable transition-diagram compiler" 中,明确了协程的概念 —— “可以暂停和恢复执行”的函数。

我们知道 1960 年代出现进程的概念,那根据这里提到的年份,可以推断,协程的概念应该是早于进程的,那也就更早于线程。

但是协程不符合当时 (一直持续到 1990 年代) 以 C 语言为代表的命令式编程语言所崇尚的“自顶向下”的程序设计思想,因此对协程的使用和讨论一直都很低迷。

直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,也就是我们需要高性能的网络服务器,以 C10K 为代表,需要单机同时支持 1 万个并发连接,到 2013 年时候的单机要支持 1 千万并发连接 (C10M 问题,微信开源 libco:简单易用高性能的协程库 中 libco 通过共享栈支持单机千万连接),协程开始重新进入视野。

如果要满足单机 1 千万的并发连接,我们先来看一下以下两种方案:

  • 第一种是,多线程同步模型:

对应 C10K 中的 “一个服务器线程服务一个客户端,使用同步阻塞 IO”,即预先创建出很多个处理线程,每个线程采用同步阻塞 IO 的方式串行处理请求,由操作系统通过线程切换来实现并发处理。这种方式开发者编码简单,但由于线程堆栈占用空间大,内存消耗太快,同时线程切换代价高,导致系统整体性能较差,现实开发中很少有人会使用这种模型。

  • 第二种是,基于事件驱动的异步网络模型,以 Nginx 为代表,Nginx 将事件驱动+异步回调做到了极致:

对应 C10K 中的 “一个线程服务多个客户端,使用非阻塞 IO 和就绪时通知机制”,这种方式由应用框架来实现事件驱动和状态切换,可以充分利用 CPU,性能较高,但因为处理都是基于回调,逻辑代码过于分散,导致代码开发效率不高,代码逻辑不易懂也容易出错。

那是否有一种方式,可以综合二者的优点,同时又不会引入太高的复杂度,就可以解决 C10K 乃至 C10M 的问题,解决好服务器充斥着的大量的 IO 请求的问题呢?

也就是,我既要「同步编程风格」,能够让业务开发人员很便捷地去开发业务逻辑代码,同时能够达到「异步回调模型的性能」。

那就是协程大展拳脚的场景了 (协程 + IO Hook)。那什么是协程呢?

“其实不应该把协程和多线程做类比,协程更多的是取代异步状态机的数据结构,如果明确这点,就能够清晰使用场景了。” —— from libco 的实现者

Q2: 到底什么是协程?

首先我们来看一下维基百科对协程的定义:

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed. —— from Wikipieda

协程是一类程序组件,它是对子过程概念的泛化,并且是属于非抢占的多任务处理。

这里有两个关键概念,分别对应 co-routine 的两个部分:

  • 泛化的子过程 (generalize subroutines),也就是说协程是泛化的函数 (subroutines, alias procedures, functions, methods, handlers etc) -- 那协程在哪些方面,相较函数更为泛化呢?这里也有提到,就是说协程可以暂停和恢复执行的 (allowing execution to be suspended and resumed),与我们前面讲到的 Conway 对协程概念的定义是一致的;
  • 非抢占的多任务处理 (non-preemptive multitasking) -- 这里说明了协程的作用,也就是协作式多任务处理 (cooperative multitasking),同时也点明了协程的特点,协作,因此需主动让出 CPU。
  • CSAPP Section 3.7 Procedures. "Procedures come in many guises in different programming languages—functions, methods, subroutines, handlers, and so on—but they all share a general set of features."

    接下来我们展开对这两个概念的讲解:函数和多任务处理,并且讨论这两个概念与协程的关系。

    函数与函数调用

    函数,是我们日常开发中最常用到的一种封装手段,它将一组实现特定功能的代码段封装起来,接受一些输入参数,返回一些输出参数。

    例如,下面这段简短的 代码片段 (跳转过去看看汇编,AT&T 汇编语法),main 调用 hello,hello 调用 world:

    int world(int num) {
      // ....
      return 42;
    }
    
    int hello(int num) {
      int x = 32;
      int* y = &x;
      // ....
      return world(num);
    }
    
    int main() {
      int num = 5;
      hello(num); //  序言 (prologue)
    # store old `%rsp`
    
    movq hello_stack_sp, %rsp # 设置 `hello` 调用栈 !!!
    movq $0x5, %rdi           # 设置入参
    call hello                # 1) 保存返回地址
                              # 2) 跳转到 `hello` 执行
    
    # restore and resume       --> 后记 (epilogue)
    # resume old `%rsp`
    

    借助下面的图方便进行理解。记住,这里是理解协程的关键。

    从这里我们也可以看出,其实函数调用栈就是一块内存,它不一定需要连续 ("the stack is a simple block of memory")。

    按照这个思路,接下来我们看一下编码实现。

    #include 
    #include 
    #include 
    
    // 调用栈太小的话,在执行函数调用时会报错 (printf)
    // fish: Job 1, './call-hello' terminated by signal SIGSEGV (Address boundary
    // error)
    #define STACK_SIZE (64 * 1024) //  libc.so;
    
  • 这样应用程序就会调用步骤 1 中实现的 recv()。
  • 但我们最终还是要调用系统调用 recv 的,那如何实现直接调用系统调用呢?

    将 dlsym 的第一个参数指定 RTLD_NEXT - 这样就会跳过找到的第一个 symbol 地址,获取该 symbol 对应的第二个地址,这样就获取得到了真实的系统调用入口地址。

    // 使用 `dlsym` 查找动态库中 `recv` symbol 的地址
    // `RTLD_NEXT` - 跳过找到的第一个地址,获取该 symbol 对应的第二个地址
    static recv_pfn_t g_sys_recv_func = (recv_pfn_t)dlsym(RTLD_NEXT, "recv");
    

    libco 是如何将 dlsym hook 系统调用与协程有机结合起来的呢?

    来看代码,libco 的 recv 实现。

    • 首先,获取系统调用的 recv() 的符号地址;

    • 然后,判断当前协程是否开启了 Hook,如果没有开启,则使用系统调用;libco 中,所有协程初始化时,Hook 都是没有开启的;

    • 根据 fd 获取对应的 rpc 信息,包含协程的结构体信息,也就是前面提到的 fd 与协程的映射关系;

    • 如果没有开启非阻塞,直接调用系统调用;

    • 设置超时时间,通过 poll 等待读完成(poll 也被 hook 了),这里会切换到父协程,父协程会在读事件/超时事件触发时回到该协程;

    • 有数据了,或者超时,协程恢复执行,读取数据,调用的是系统调用,会判断是否读取成功;

    • 这就是一个同步的写法了,看起来就很清爽。

      ssize_t recv(int socket, void* buffer, size_t length, int flags) {
      // 获取系统调用的 recv() 的符号地址
      HOOK_SYS_FUNC(recv); // read_timeout.tv_sec * 1000) + (lp->read_timeout.tv_usec / 1000); // 超时时间
      // 通过 poll 等待读完成,这里会切换到父协程,父协程会在读事件/超时事件触发时回到该协程
      struct pollfd pf = {0};
      pf.fd = socket;
      pf.events = (POLLIN | POLLERR | POLLHUP);
      poll(&pf, 1, timeout); // poll 也被 hook 了 // ctx.ss_sp = stack_mem->stack_buffer;
      lp->ctx.ss_size = at.stack_size;

      return lp;
      }

      stStackMem_t* co_alloc_stackmem(unsigned int stack_size) {
      stStackMem_t* stack_mem = (stStackMem_t*)malloc(sizeof(stStackMem_t));
      stack_mem->occupy_co= NULL;
      stack_mem->stack_size = stack_size;
      stack_mem->stack_buffer = (char*)malloc(stack_size); // stack_bp = stack_mem->stack_buffer + stack_size;
      return stack_mem;
      }

      协程栈默认 128KB,最大 8MB。

      在进行业务开发时,如果直接在协程栈上分配一块大内存,就直接爆栈了。

      int send() {
        char raw_buffer[400000 + 8]; // 400KB
        // ... do sth with raw_buffer
      }
      

      Case 2: 在协程中使用线程锁,yield前未释放

      看下面这段代码片段:

      // 示例 1: 显式使用线程锁,`co_yield` 协程切换时未释放该线程锁
      std::mutex g_mutex;
      void hello() {
        const std::lock_guard lock(g_mutex);
        co_yield(); // 请求 rpc,或其他 libco hook 了的非阻塞 IO
        // `g_mutex` is released when lock goes out of scope
      }
      

      协程 1 在持有线程锁的同时,yield 让出 CPU;同一个线程中的其他协程,获取得到 CPU,同样执行这一段代码,就会尝试去获取锁,但一直得不到锁,就阻塞了,从而整个线程都阻塞了。

      Locks the mutex. If another thread has already locked the mutex, a call to lock will block execution until the lock is acquired.

      这个是显式使用线程锁,比较容易发现。

      我们再来看一个隐式使用线程锁的例子,下面这段代码,一个经典的单例模式的实现:

      // 示例 2: Meyers Singleton Pattern,隐式使用线程锁
      class Singleton {
      public:
      static Singleton &GetInstance() {
      static Singleton instance; //

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论