Coroutinelib项目实现

Posted by Sutdown on December 21, 2024

个人github链接:

GitHub - Sutdown/coroutinelib: coroutine lib

模块

  • thread

    线程模块,封装了pthread里面的一些常用功能,Thread,Semaphore,Mutex,RWMutex,Spinlock等对象,可以方便开发中对线程日常使用 为什么不适用c++11里面的thread 本框架是使用C++11开发,不使用thread,是因为thread其实也是基于pthread实现的。并且C++11里面没有提供读写互斥量,RWMutex,Spinlock等,在高并发场景,这些对象是经常需要用到的。所以选择了自己封装pthread

  • 协程类

    协程:用户态的线程,相当于线程中的线程,更轻量级。后续配置socket hook,可以把复杂的异步调用,封装成同步操作。降低业务逻辑的编写复杂度。 目前该协程是基于ucontext_t来实现的,后续将支持采用boost.context里面的fcontext_t的方式实现

  • 协程调度

    协程调度器,管理协程的调度,内部实现为一个线程池,支持协程在多线程中切换,也可以指定协程在固定的线程中执行。是一个N-M的协程调度模型,N个线程,M个协程。重复利用每一个线程。

  • 协程IO

    继承与协程调度器,封装了epoll(Linux),并支持定时器功能(使用epoll实现定时器,精度毫秒级),支持Socket读写时间的添加,删除,取消功能。支持一次性定时器,循环定时器,条件定时器等功能

  • 定时器

  • hook

    hook系统底层和socket相关的API,socket io相关的API,以及sleep系列的API。hook的开启控制是线程粒度的。可以自由选择。通过hook模块,可以使一些不具异步功能的API,展现出异步的性能。如(mysql)

thread

主要有两个类,SemaphoreThread

Semaphore

信号量,实现PV操作,主要用于线程同步

Thread

  1. 系统自动创建主线程t_thread

  2. 由thread类创建的线程。

    m_thread 通常是线程类内部的成员变量,用来存储底层的线程标识符

    t_thread 可能是外部管理线程生命周期的对象或容器,它可以是线程池、线程列表、智能指针等,帮助你在类外部管理多个线程的创建、执行、销毁等操作。

协程类

  • 非对称模型
  • 有栈协程,独立栈。

对于协程类,其中需要什么。协程首先需要随时切换和恢复,这里采用的是glibc的ucontext组件

ucontext_t

这个类中有成员:

1
2
3
4
5
6
7
8
// 当前上下文结束后下一个激活的上下文对象的指针,只在当前上下文是由makecontext创建时有效
struct ucontext_t *uc_link;
// 当前上下文的信号屏蔽掩码
sigset_t uc_sigmask;
// 当前上下文使用的栈内存空间,只在当前上下文是由makecontext创建时有效
stack_t uc_stack;
// 平台相关的上下文具体内容,包含寄存器的值
mcontext_t uc_mcontext;

函数:

1
2
3
4
5
6
7
8
9
10
11
// 获取当前上下文
int getcontext(ucontext_t *ucp);

// 恢复ucp指向的上下文
int setcontext(const ucontext_t *ucp);

// 修改当前上下文指针ucp,将其与func函数绑定
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

// 将当前上下文保存到oucp中,将执行转到ucp中
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

对于该协程类,有栈 or 无栈?对称 or 非对称?

  • 对于对称和非对称的话,对称协程更为灵活,非对称协程更为简单易实现。协程中一般存在协程调度器和协程两种角色,对称协程中相当于每个协程都要充当调度器的角色,程序设计复杂,程序的控制流也会复杂难以管理。

    常见的js中的async/awaitgo中的coroutine都是非对称协程,是因为非对称协程的切换过程是单项的,更适合事件驱动,任务队列等调度模型;但是c语言中的ucontext属于对称协程的经典实现,boost.context为对称协程的现代实现,更适合需要多个协程频繁通信的场景。

  • 有栈协程和无栈协程有栈和无栈的本质区别在于是否可以在任意嵌套函数中被挂起。一般有栈可以被挂起,无栈则不行。有栈比较适用于功能强大,支持嵌套调用和复杂控制流,灵活的操作上下文的需求,比如boost.COntext;无栈由于存储在内存中,适用于内存占用少,实现简单的场景,比如JavaScript async/awaitPromise,Erlang 和 Go的Goroutine

这里我们的协程类,采用的是非对称模型,有栈协程。因此可以推导出所需要的私有成员:

1
2
3
4
5
6
7
8
9
10
11
private:
    uint64_t m_id = 0;
    State m_state = READY;

    ucontext_t m_ctx;

    uint32_t m_stacksze = 0;    // 栈大小
    void *m_stack = nullptr;    // 栈空间

    std::function<void()> m_cb; // 运行函数
    bool m_runInScheduler;

对于协程类,我们需要一个主协程和其它的用户协程,以及一个协程调度器。对于主协程则是直接无参构造函数直接创建,(由于只能创建一次,因此私有),有参构造函数创建其它协程。同时需要设置resume,yield其它函数调度协程的运行。大概这个样子:

1
2
3
4
5
  // 线程局部变量,当前线程正在运行的协程
  static thread_local Fiber *t_fiber = nullptr;
  // 线程局部变量,当前线程的主协程,切换到这个协程,相当于切换到主线程
  static thread_local std::shared_ptr<Fiber> t_thread_fiber = nullptr;
  static thread_local Fiber *t_scheduler_fiber = nullptr;

协程调度

一个线程只有一个协程,一个协程类中会包含三个协程,分别是主协程(main),调度协程和任务协程。其中任务协程是由协程类自主创建,主协程和调度协程都是静态变量,在多种类中其实只存在一个实体。

协程调度致力于封装一些操作,因为调度协程本身需要创建协程,协程任务的执行顺序,如何利用多线程或者调度协程池保证效率,在协程任务结束之后也需要停止调度器释放资源。如果建立一个scheduler类封装这些操作,那么为用户开放的仅仅只有启动线程池,关闭线程池,添加任务三种操作了。

其中main主协程可以选择是否参与调度,如果不参与,那么比如在main开始调度时创建其它协程进行协程调度;如果参与,多线程的情况下和不参与相同。如果是单线程,那么只能等到main结束时开始调度其它协程。

虽然main是主协程(caller协程),不过main函数所在的线程也能执行任务,在实现相同调度能力的情况下,线程数越少,线程切换的开销也就更小。

最终过程:

  1. main函数主协程运行,创建调度器
  2. 向调度器添加任务,开始协程调度,main让出执行权,调度协程执行任务队列中的任务
  3. 每次执行任务时,调度协程都要让出执行权,再回到调度协程继续下一个任务
  4. 所有任务执行完后,调度协程让出执行权切回main函数主协程结束。

协程IO

在前面的协程调度模块中,调度器对协程的调度是无条件执行的,在调度器已经启动调度的情况下,任务一旦添加成功,就会排队等待调度器执行。调度器不支持删除调度任务,并且调度器在正常退出之前一定会执行完全部的调度任务,所以在某种程度上可以认为,把一个协程添加到调度器的任务队列,就相当于调用了协程的resume方法。

IO协程调度支持为描述符注册可读和可写事件的回调函数,当描述符可读或可写时,执行对应的回调函数。

有的库不仅可以处理socket fd事件,还可以处理定时器事件和信号事件。这些事件库的实现原理基本类似,都是先将套接字设置成非阻塞状态,然后将套接字与回调函数绑定,接下来进入一个基于IO多路复用的事件循环,等待事件发生,然后调用对应的回调函数。

  • 改造协程调度器,将epoll和协程调度结合。IO协程调度关注FdCOntext信息,也就是描述符,事件,回调函数三元组。
  • 基于epoll实现IO事件的添加,删除,调度,取消等功能
  • timer会给协程IO外挂一个定时器管理模块,epoll会根据定时器的超时时间确定超时参数

pipe设置的作用

类似于进程间通信.

属于状态传递。比如p[0]和p[1],p[0]为可读,p[1]为可写。在本过程中,p[0]中存放阻塞的协程,当有协程任务完成时会放入p[1],p[1]会通知p[0],从而让调度器run

epoll 中,如果使用 边缘触发(ET) 模式,只有当管道的状态从不可读变为可读时,epoll 才会通知调度器。这就意味着,调度器需要及时读取管道中的数据,以确保不会错过事件。

管道变为可读时,会唤醒协程的原因是管道充当了一个信号通知机制。通过向管道的写端写入数据,调度器可以通过读取管道来检测到事件的发生,从而恢复挂起的协程。在协程 I/O 模型中,管道的可读状态就是调度器知道某个事件发生并且可以继续执行协程的信号。这种机制使得协程的调度更加高效,避免了繁重的轮询操作,并且能够通过 I/O 多路复用和事件驱动的方式来处理并发任务。

  1. 协程执行 I/O 操作并挂起:
  • 一个协程在执行 I/O 操作时,可能会遇到阻塞情况(例如等待网络数据或磁盘读写),于是它会被挂起。挂起的协程会注册相关的文件描述符(如 m_tickleFds[0])到 epoll,让调度器等待这些文件描述符的状态变化。
  1. 信号发送(管道写端)
  • 另一个协程或线程会向 m_tickleFds[1] 写入数据。这通常表示某个事件或任务已经完成,或者需要通知调度器去恢复某个协程的执行。
  • 当数据写入 m_tickleFds[1] 时,管道的读端(m_tickleFds[0])就变为可读。
  1. epoll 通知调度器:
  • epoll 会在 m_tickleFds[0] 变为可读时通知调度器,这时调度器知道管道中有数据,可以读取并继续执行后续操作。
  1. 调度器恢复协程:
  • 调度器通过 epoll_wait() 等待管道事件的触发,当管道变为可读时,调度器会恢复挂起的协程。恢复后的协程将继续执行它的任务,直到下一个 I/O 操作发生,或者任务完成。
  1. 读取管道数据:
  • 当管道可读时,调度器会调用 read(m_tickleFds[0], ...) 从管道中读取数据。此时,管道中的数据只是一个信号,指示协程应该恢复执行。读取数据的操作不会对协程本身产生影响,但它确保了管道的数据被消费,防止事件丢失。

epoll and scheduler

  • epoll:当某个文件描述符准备好进行读写操作时,epoll 会通知应用程序。
  • scheduler:管理和调度多个协程的执行
  1. 协程执行 I/O 操作,发起 readwrite 等阻塞操作。
  2. 协程调度器将该协程挂起,并将文件描述符注册到 epoll 中,等待 I/O 完成。
  3. 调度器继续调度其他协程或任务(例如,处理其他 I/O 请求)。
  4. epoll 监听文件描述符的状态,发现某个文件描述符可读或可写时,通知调度器。
  5. 调度器根据 epoll 返回的事件唤醒对应的协程,恢复执行。

idle的触发,自动触发还是人为触发

idle 协程的作用是作为一个事件循环,专门处理 I/O 事件和定时器超时事件。它通常在 没有其他待处理任务时调度运行。(调度器为空时,处理epoll的IO事件)

idle 协程的执行流程

  1. 进入 idle 协程:当协程调度器发现没有任何任务需要执行时,它会选择 idle 协程。idle 协程会通过 epoll_wait 等机制进入阻塞状态,等待 I/O 或定时器事件。
  2. 事件发生时唤醒 idle 协程
    • I/O 事件:当某个文件描述符变得可读或可写,或者出现连接事件时,epoll_wait 会返回相应的事件。idle 协程会从阻塞状态中唤醒并处理这些事件。
    • 定时器事件:如果有定时器超时,idle 协程会处理定时器回调,并将相应的任务调度到执行队列中。
  3. 完成事件处理后重新挂起
    • 在处理完所有的事件之后,idle 协程会检查是否有新的任务需要处理。如果没有,它会调用 yield() 或其他方式挂起自己,等待下一次事件发生。

定时器

最小堆定时器

  1. 创建协程和事件注册
  • 协程开始执行某些 I/O 操作时(例如,网络读取、文件读取等),如果该操作是阻塞的,它会通过 epoll 进行非阻塞的 I/O 多路复用,等待 I/O 完成。
  • 同时,如果需要设置超时或定时任务,定时器会创建并开始计时。定时器会生成一个文件描述符,可以通过 epoll 监视。
  1. 调度器挂起协程
  • 调度器将执行中的协程挂起,并将其状态保存。
  • 调度器通过 epoll 将相关的文件描述符(如网络套接字、定时器等)注册到 epoll 中,以便监听 I/O 事件和定时器事件。
  1. epoll 等待事件
  • 调度器会调用 epoll_wait(),等待 I/O 事件或定时器事件的发生。
  • epoll_wait() 是阻塞的,它会一直等待,直到至少一个注册的文件描述符发生事件(如 I/O 准备好或定时器超时)。
  1. 事件发生,唤醒协程
  • 一旦某个文件描述符发生了事件(如 I/O 完成或定时器到期),epoll 会通知调度器。
  • 调度器根据事件类型(如文件描述符是否可读、可写或定时器是否到期)选择唤醒对应的协程,并恢复执行。
  1. 协程继续执行
  • 恢复的协程可以继续执行其原本的 I/O 操作,或是进行其他任务。

hook

hook是对系统调用API进行一次封装,将其封装成一个与原始的系统调用API同名的接口在应用这个接口时,会先执行封装中的操作,再执行原始的系统调用API。

本项目中,出于保障协程能够在发生阻塞时随时切换,因此会对IO协程调度中相关的系统调用进行hook,让调度协程尽可能把时间片花在有意义的操作上。 hook的重点在于替换API的底层实现同时完全模拟原本的行为*/

本项目中,关于hook模块和IO协程调度的整合,一共有三类接口需要hook:

  1. sleep延时系列接口。对于这些接口,给IO协程调度器注册一个定时事件,定时事件触发之后再执行当前协程即可。 注册完之后yield让出执行权。

  2. socket IO系系列接口。包括read/write/recv/send/connect/accept…这类接口的hook需要先判断fd是否为socket fd, 以及用户是否显式的对该fd设置过非阻塞模式,如果都不是,就不需要hook了。如果需要hook,现在IO协程调度器上注册对应读写事件,事件发生后再继续当前协程。当前协程注册完IO之后即可yield让出执行权。

  3. socket/fcntl/ioctl/close…这类接口主要用于处理边缘情况, 比如fd上下文,处理超时,显示非阻塞等。

我的钩子函数如何覆盖系统调用的

假设以sleep为例,下面可以确保sleep_f可以指向原始的系统调用sleep函数

1
2
3
#define XX(name) name##_f = (name##_fun)dlsym(RTLD_NEXT, #name);
    HOOK_FUN(XX)
#undef XX

参考

  1. 代码随想录 - coroutine-lib - github
  2. libco - github
  3. 出于什么样的原因,诞生了「协程」这一概念
  4. 协程理论
  5. 什么是协程
  6. 协程的好处