从零开始手写一个协程库(一)

📅 2026/7/1 4:17:15
从零开始手写一个协程库(一)
引言从本篇文章开始我会逐一介绍协程库的一些重要代码和知识点~~~什么是协程库简单来说就是一个超级轻量级的线程这个线程还是用户态线程。那么我们为什么要实现协程库呢在高并发的环境下如果因为一个任务的阻塞而无法执行其他任务会导致整个系统的死机。而协程就帮助了这个点可以在阻塞的时候去执行其他的任务resume 恢复yeild 暂停API的简介对于项目里面的四个主要的API进行介绍一下重点getcontextucontext_t *ucp这个是获取当前的上下文放到ucp中具体就是直到CPU的状态sp指针寄存器等等setcontextconst ucontext_t *ucp这个是设置当前的上下文把ucp指向的地址里面的内容取出来设置为当前要执行的代码和上面那个不同的是我们要执行代码必须要把上下文交给CPU让CPU去执行你的代码。当然这个上下文也就是这个ucp来源有两个一个是getcontext那就继续执行这个调用相当于再执行一遍之前的代码还有一个是makecontext这个我们会先执行第二个参数如果第二个参数返回则恢复makecontext第一个参数指向的上下文第一个参数指向的上下文context_t中指向的uc_link。如果uc_link为NULL则线程退出makecontext(ucontext_t *ucp, void (*func)(), int argc, ...这个是创建一个新的上下文而创建一个上下文需要的是当前的环境一些信号量啥的所以我们的第一个参数就是传递一个原来的上下文指针这个样子我们就不需要关心环境问题了所以我们必须要提前调用getcontext调用之后因为这是一个新的上下文所以要新的栈所以我们需要新的空间去存储这个栈规定栈的大小还要交代uc_link也就是这个函数结束之后我们应该跳转到哪个函数而这一切都是在getcontext之后因为如果在之前那么CPU会记录下我们现在使用的栈和SP指针可是我们现在运行的代码还是原来的上下文也就是说我们直接把我们辛辛苦苦改的新值用旧值覆盖了。但是这个函数不是执行函数这个只是创建了一个上下文swapcontextucontext_t *oucp, ucontext_t *ucp这是一个执行函数保存当前上下文到oucp结构体中然后激活upc上下文#include ucontext.h #include stdio.h void func1(void * arg) { puts(1); puts(11); puts(111); puts(1111); } void context_test() { char stack[1024*128]; ucontext_t child,main; getcontext(child); //获取当前上下文 child.uc_stack.ss_sp stack;//指定栈空间 child.uc_stack.ss_size sizeof(stack);//指定栈空间大小 child.uc_stack.ss_flags 0; child.uc_link main;//设置后继上下文 makecontext(child,(void (*)(void))func1,0);//修改上下文指向func1函数 swapcontext(main,child);//切换到child上下文保存当前上下文到main puts(main);//如果设置了后继上下文func1函数指向完后会返回此处 } int main() { context_test(); return 0; }为了大家更加方便的理解我们这里还抛出一个小问题Hello World这里是不可以被打印出来的原因就是makecontext已经修改了上下文导致setcontext调用完之后发现下一个指向的函数被设定为NULL那么不会返回到之前的地方继续执行了#include stdio.h #include ucontext.h void func1() { puts(In func1); } int main() { ucontext_t context; getcontext(context); context.uc_stack.ss_sp malloc(8192); context.uc_stack.ss_size 8192; context.uc_link NULL; makecontext(context, func1, 0); setcontext(context); puts(Hello World); return 0; }前置知识class Fiber : public std::enable_shared_from_thisFiber这个代码是让Fiber这个类继承了public std::enable_shared_from_this 大类其内部的主要就是给这个fiber类塞进去一个weak_ptr指针这个指针当这个类被调用shared_from_this()函数的时候让weak_ptr执行lock就会让其引用计数1。不要小看这个1。我们举一个简单的例子一个协程在运行的过程中执行了一个读取文件的操作这个读取文件的操作要很长时间所以我们会交给另外一个函数去执行而当前的函数则继续向下执行假设很快就结束了生命周期结束那么这个时候就会将引用-1如果没有这个1那么引用计数变为0指针消失。等到读取文件的操作结束回调这个函数指针的时候发现这个函数指针已经被销毁直接抛出异常。而我们的1就保证了这个错误不会发生只有当整个程序完全的结束我们才会销毁这个指针。而在我们刚刚的一个例子里面就涉及到了另一个理解的点就是一个协程的函数里面套了另外一个函数这两个函数是没有任何关系的也就是说一个函数的结束不会影响另外一个函数。class Fiber : public std::enable_shared_from_thisFiber { public: void doSomethingAsync() { // 假设这里要发起一个异步操作需要把“我自己”传给回调函数 // 如果直接传 this 指针异步操作完成时这个 Fiber 对象可能已经被销毁了 // 正确做法在内部安全地获取自己的 shared_ptr延长生命周期 auto self shared_from_this(); asyncCall([self]() { // 只要 self 还在Fiber 对象就绝对不会被释放 self-onComplete(); }); } };Thread类的实现协程是运行在线程里面的所以线程线程就是协程运行的环境我们主要有两个类一个是信号量这个信号量就是为了保证创建线程的时候资源不会发生冲突一个是线程这个线程里面的主要函数一个是构造函数一个是run函数#ifndef THREAD_H #define THREAD_H #include condition_variable #include mutex #include functional namespace fengyue { class Semaphore { private: std::mutex mutex; std::condition_variable cv; int count; public: explicit Semaphore(int count_ 0) : count(count_) {} // p操作 void wait() { std::unique_lockstd::mutex lock(mutex); while (count 0) { cv.wait(lock); } count--; } // v操作 void signal() { std::unique_lockstd::mutex lock(mutex); count; cv.notify_one(); // 这里的one指的不止有一个线程可能有多个线程 } }; /* 创建并管理底层线程为协程提供运行环境同时通过线程局部存储和同步机制为协程调度提供必要条件 */ class Thread { public: Thread(std::functionvoid() func, const std::string name); ~Thread(); pid_t getId() const {return m_id;} // 该Thread管理的线程的id const std::string getName() const {return m_name;} void join(); // 等待线程执行完成 public: static pid_t GetThread(); // 获取系统分配线程的id当前执行上下文的进程 static Thread* GetThis(); // 获取当前所在的进程 static const std::string GetName(); // 获取当前线程的名称 static void SetName(const std::string name); // 设置当前线程的名称 private: static void* run(void* arg); private: pid_t m_id -1; pthread_t m_thread 0; std::string m_name; // 线程名称 std::functionvoid() m_func; // 线程运行函数 Semaphore m_semaphore; // 引入信号量的类来完成线程的同步创建操作 }; } #endif首先我们用thread_local将每一个线程作为副本隔离开防止互相的干扰然后设置了每一个线程对应的指针。构造函数我们需要在创建线程的时候传递这个线程要执行的函数和函数的参数这里我们有两个细节一个是我们run使用的是静态函数因为pthread_create只接受c风格的参数而c的类函数里面自己包含了隐式的this指针导致函数不符合。所以我们用static消除this指针变成c风格的函数然后同时将this指针作为参数传递进去用void* arg来接受接受之后再一顿赋值保证执行这个函数的时候环境正确然后拿到我们创建线程时候传参构造的函数通过swap这么一个高效的赋值方式来把函数传递给func如果m_func很大那么就要拷贝很多东西效率极低我们这里选择浅拷贝最后我们看一下信号量是怎么运用的首先在创建线程的函数里面调用wait但是因为初始值是0所以这个线程睡着了然后创建的线程当创建完毕之后会发出信号唤醒这个主线程这个主线程才可以继续往下执行。这完美的避免了同时创建很多个要执行的线程。#include Thread.h #include sys/syscall.h #include iostream #include unistd.h namespace fengyue { /* static表示变量的生命周期在生命周期结束才会被销毁 thread_local表示变量是线程局部的每个线程都会拥有一个独立的Thread指针和当前对象的名称多个线程互相不干扰 */ static thread_local Thread* t_thread nullptr; // 当前线程的Thread对象指针 static thread_local std::string t_thread_name UNKNOWN; // 当前线程的名称 pid_t Thread::GetThread() { return syscall(SYS_gettid); // 一个系统调用用于获取当前线程的唯一ID。SYS_gettid 是Linux特定的系统调用编号 } void* Thread::run(void* arg) { Thread* thread (Thread*)arg; t_thread thread; t_thread_name thread-m_name; thread-m_id GetThread(); /* pthread_self()返回当前线程的ID用于设置线程名称 给当前线程在操作系统层面起一个专属的名字 只截取15个字节是因为Linux严格限制了名称长度最多只能有 16 个字节 */ pthread_setname_np(pthread_self(), thread-m_name.substr(0, 15).c_str()); std::functionvoid() func; func.swap(thread-m_func); thread-m_semaphore.signal(); func(); return nullptr; } Thread::Thread(std::functionvoid() func, const std::string name) : m_func(func), m_name(name) { int ret pthread_create(m_thread, nullptr, Thread::run, this); if (ret) { std::perror(pthread_create error); } m_semaphore.wait(); // 等待线程创建完成 } }总结本篇文章到这里就结束了希望可以帮助大家理解协程库~~~