文章目录
- 为什么使用协程
- 协程的理解
- 协程优势
- 协程的原语操作
- yield 与 resume 是一个switch操作(三种实现方式):
- 基于 ucontext 的协程
- 基于 XFiber 库的操作
- 1 包装上下文
- 2 XFiber 上下文调度器
- 2.1 CreateFiber
- 2.2 Dispatch
- 基于C++20的co_return 协程的关键字实现协程
- 参考
为什么使用协程
从性能方面来看,对于使用异步 io 的线程,存在三个问题:
- 系统线程占用大量的内存空间
- 线程切换占用大量的系统时间
- 为了线程安全,线程间需要加锁保护资源,降低执行的效率
从编程角度来看,无论同步还是异步编程方式,都是基于事件驱动的。事件驱动流程包括注册事件,绑定回调,触发回调,提高了系统的并发。但是由于回调的多层嵌套,使得编程复杂,降低了代码的可维护性。
在资源有限的前提下,高性能服务需要解决的问题有:
- 减少线程的重复高频创建:线程池
- 尽量避免线程的阻塞
- Reactor + 非阻塞回调:解决问题的能力有限
- 响应式编程:容易陷入回调地狱,割裂业务逻辑
- 协程:将同 io 转成异步 io
- 提升代码的可维护与可理解性:减少回调函数,减少回调链深度
而协程的出现,可以很好地解决上述问题。
协程的理解
协程(Coroutine)是一种能够挂起个恢复的函数过程 是一种轻量级的并发编程方式,也称为用户级线程。它与传统的线程(Thread)相比,具有更低的开销和更高的执行效率。 协程通常运用在异步调用中。
协程运行在线程之上。当一个协程调用阻塞 io,主动让出 cpu ( yield 原语) ,让另一个协程运行在当前线程之上( resume 原语)。协程没有增加线程数量,只是在线程的基础上通过分时复用的方式运行多个协程,降低了系统内存。而且协程的切换在用户态完成,减少了系统切换开销。
协程优势
消耗系统资源和切换代价更小
协程可以实现无锁编程
简化了异步编程,可以达到以同步的编程方式实现异步的性能。
- 协程适用于 I/O 密集型业务,线程切换频繁。其他情况,性能不会有太大的提升。
协程的原语操作
- yield: 协程主动让出CPU给调度器。时机:业务提交 -> epoll_wait
- resume: 调度器恢复协程的运行权。时机:epoll_wait -> 业务处理
- resume 和 yield 是两个可逆的原子操作。
yield 与 resume 是一个switch操作(三种实现方式):
- 1.longjump/setjump
- 2.ucontext
- 3.汇编实现
基于 ucontext 的协程
协程的实现与线程的主动切换有关,当“当前上下文”可能阻塞时,需要主动切换到其它上下文来避免操作系统将当前线程挂起从而降低效率。
在Linux中定义了ucontext_t结构体来表示线程的上下文结构。
typedef struct ucontext_t {
struct ucontext_t *uc_link;//表示当当前上下文阻塞时会被切换的上下文。
sigset_t uc_sigmask;//被当前线程屏蔽的信号
stack_t uc_stack;//线程栈
mcontext_t uc_mcontext;//与机器相关的线程上下文的表示
} ucontext_t;
与上下文相关的有四个函数:
getcontext(ucontext_t* ucp): 调用后基于当前上下文初始化ucp所指向的上下文结构体。
setcontext(const ucontext_t* ucp): 切换到ucp所指向的上下文,如果调用成功则不会返回,因为上下文已经被切换。
makecontext(ucontext_t* ucp, void (*func)(), int argc, ...): 用于指定上下文需要执行的函数,要求在调用之前context已经确定栈和 uc_link. 当切换到该上下文后,函数func就会被执行。函数返回后,后继线程就会被切换到,如果uc_link为NULL,则线程退出。
swapcontext(ucontext_t* restrict oucp, const ucontext_t* restrict ucp): 将当前上下文保存到oucp中,然后切换到ucp对应的上下文中。与setcontext的区别在于是否保存当前上下文。
附上stack_t的定义:
typedef struct {
void* ss_sp;
int ss_flags;
size_t ss_size;
} stack_t;
比如可以通过下面的程序实现循环打印:
#include <ucontext.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int idx = 0;
ucontext_t ctx1;
getcontext(&ctx1);
printf("%d\n", idx);
idx++;
sleep(1);
setcontext(&ctx1);
return 0;
}
基于 XFiber 库的操作
https://github.com/HiYx/xfiber
以XFiber为例讲解一下一个轻量级协程库的基本实现方式。
1 包装上下文
Linux提供的协程结构体比较简陋,并不足以供协程库使用,因此需要进行包装一下。
struct Fiber {
uint64_t seq_;
XFiber* xfiber_;
std::string fiber_name_;
ucontext_t ctx_;
uint8_t* stack_ptr_;
size_t stack_size_;
std::function<void()> run_;
WaitingEvents waiting_events_;
};
其中XFiber为协程的调度器,后面会讲。WatingEvents为协程所需要等待的读和写的文件描述符。
struct WaitingEvents {
std::vector<int> waiting_fds_w_;
std::vector<int> waiting_fds_r_;
int64_t expire_at_;
};
2 XFiber 上下文调度器
2.1 CreateFiber
先从最基本的创建一个协程开始,首先注意协程和线程的区别,协程代表一段可以分开执行的逻辑,但是和其它协程还是保持串行执行,因此协程创建并不会马上执行,而是由协程调度器统一执行。
先看看创建协程函数的签名:
void XFiber::CreateFiber(std::function<void()> run, size_t stack_size, std::string fiber_name);
run即要执行的函数,这里作者设定了只能是无参数、无返回值的函数类型,但是其实可以借助C++模板实现各种类型函数的注册。
协程调度器主要维护两个协程队列,分别是运行队列和就绪队列,运行队列中的协程会被切换到,而就绪队列中的协程会在下一次的循环中被切换到。
同时维护两个map,io_waiting_fibers_表示监听的文件描述符所对应的一对读和写的协程,expire_fibers_的value为一个有序集合,表示在某个时间点会超时的协程集合。
2.2 Dispatch
当Dispatch函数开始运行时,各协程才开始运行。该函数主要分为三个部分:
-
处理已经就绪的协程。将就绪队列move到运行队列中,然后将就绪队列清空,这样做的原因是这一循环的就绪队列在运行中可能重新回到就绪队列中(主动Yield就会回到就绪队列)。
-
协程切换过程就涉及到上面的swapcontext函数,为了使得协程能够在返回后能够重新回到XFiber中,其结构体中维护了一个sched_ctx_成员表示调度器的上下文。因此每一条Fiber在被创建时都将sched_ctx_作为接下来切换到的上下文,这样就保证了每一条协程在执行完成以后都能过回到调度器来,并由调度器处理接下来的就绪协程。
-
检查超时的协程,对于超时的协程集合,需要将这些协程通过 WakeupFiber 函数进行唤醒。
-
调用 epoll 相关方法,检查所有的epoll事件,并唤醒相关协程。
基于C++20的co_return 协程的关键字实现协程
co_return :co_return 是 C++20 中引入的关键字,用于在协程中返回结果或结束协程。它用于替代 return 关键字,在协程函数中表示返回值,并触发协程的完成。
main函数创建了一个进程, 进程里面创建了一个主线程,然后执行每个函数就是子线程。
- 先进入bar()函数, 先执行call bar ,
- 然后执行before bar 经过挂起点然后挂起,
- 这时候一个线程跳出了bar函数, 到main里面,
- 另一个线程执行fool函数,
- fool函数执行完以后, 再回到bar 函数里面继续执行
这就是本代码的大概思路
参考
https://blog.csdn.net/txh1873749380/article/details/134174067
https://www.cnblogs.com/kaleidopink/p/16387004.html
https://blog.csdn.net/m0_74036006/article/details/135960299