从 0 到 1:写一个能跑在大体量应用后台的 C++ 协程库
从 0 到 1:写一个能跑在大体量应用后台的 C++ 协程库
作者:monsoon
日期:2025-08-06
“进程太重,线程太闹,协程刚刚好。”
1. 为什么是协程?
在简历满天飞的校招季,面试官已经听腻了“我做过 WebServer”。
如何差异化?答案就是——造一个协程库。
- 轻量:用户态切换,ns 级上下文切换。
- 易用:同步写法,异步性能。
- 通用:可以插进任何项目里给 WebServer 加 Buff。
2. 协程长什么样?
我们先写出协程最核心的三样东西:
组件 | 作用 | 对应代码 |
---|---|---|
Fiber | 协程对象 | class Fiber |
Scheduler | 调度器 | class Scheduler |
IOManager | IO + 定时器 | class IOManager : public Scheduler, public TimerManager |
3. Fiber:一条协程的一生
3.1 数据结构
class Fiber : public std::enable_shared_from_this<Fiber> {
public:enum State { READY, RUNNING, TERM };
private:uint64_t m_id = 0;uint32_t m_stacksize = 0; // 128 k 固定栈void* m_stack = nullptr;ucontext_t m_ctx;std::function<void()> m_cb;State m_state = READY;bool m_runInScheduler = false; // 是否走调度器
};
- 有栈协程,每个协程独占一块
malloc
出来的内存作为运行栈。 ucontext
四件套:getcontext / makecontext / swapcontext
完成上下文切换。
3.2 创建与入口
Fiber::Fiber(std::function<void()> cb, size_t stacksize, bool run_in_scheduler): m_cb(std::move(cb)), m_runInScheduler(run_in_scheduler) {m_stacksize = stacksize ? stacksize : g_fiber_stack_size;m_stack = StackAllocator::Alloc(m_stacksize);getcontext(&m_ctx);m_ctx.uc_stack.ss_sp = m_stack;m_ctx.uc_stack.ss_size = m_stacksize;m_ctx.uc_link = nullptr;makecontext(&m_ctx, &Fiber::MainFunc, 0);
}
关键点:
makecontext
把入口函数绑到栈上,协程启动时从MainFunc
开始跑。
3.3 上下文切换
void Fiber::resume() {SetThis(this);m_state = RUNNING;if (m_runInScheduler) {swapcontext(&Scheduler::GetMainFiber()->m_ctx, &m_ctx);} else {swapcontext(&t_thread_fiber->m_ctx, &m_ctx);}
}void Fiber::yield() {if (m_runInScheduler) {swapcontext(&m_ctx, &Scheduler::GetMainFiber()->m_ctx);} else {swapcontext(&m_ctx, &t_thread_fiber->m_ctx);}
}
- 非对称模型:任何协程只能与“调度协程”或“线程主协程”切换,不会丢失根上下文。
4. Scheduler:让线程变成线程池
4.1 线程局部变量
static thread_local Scheduler* t_scheduler = nullptr;
static thread_local Fiber* t_scheduler_fiber = nullptr;
- 每个工作线程保存两份上下文:
- 当前运行协程
- 调度协程(idle 状态阻塞在
epoll_wait
)
4.2 任务队列
struct ScheduleTask {Fiber::ptr fiber;std::function<void()> cb;int thread = -1; // 指定线程 or ANY
};
std::list<ScheduleTask> m_tasks;
4.3 调度主循环
void Scheduler::run() {Fiber::ptr idle(new Fiber(std::bind(&Scheduler::idle, this)));while (!stopping()) {ScheduleTask task = popTask();if (task.fiber) {task.fiber->resume(); // 协程跑完或 yield 回来} else if (task.cb) {task.cb(); // 普通函数} else {idle->resume(); // 没活干,进 idle}}
}
5. IOManager:IO + 定时器一把梭
5.1 epoll 封装
int m_epfd;
int m_tickleFds[2]; // pipe 用于唤醒
- tickle:当有新任务时,写 1 字节到 pipe,让
epoll_wait
立刻返回。 - idle:把
epoll_wait
的超时设置成“下一个定时器到期时间”,既等 IO 也等定时器。
5.2 事件三元组
struct FdContext {struct EventContext {Scheduler* scheduler;Fiber::ptr fiber;std::function<void()> cb;};EventContext read;EventContext write;int fd;Event events = NONE;
};
6. Hook:把阻塞变非阻塞
6.1 原理
利用 LD_PRELOAD
或 dlsym(RTLD_NEXT, "read")
劫持系统调用,内部走 IOManager 的事件循环。
6.2 代码示例:sleep
unsigned int sleep(unsigned int seconds) {if (!sylar::t_hook_enable) return sleep_f(seconds);auto iom = IOManager::GetThis();iom->addTimer(seconds * 1000,[fiber = Fiber::GetThis()] { fiber->resume(); });Fiber::GetThis()->yield();return 0;
}
- 协程注册一个定时器 → 立即
yield
→ 调度器去干别的活 → 定时器触发 →resume
回来继续执行。
6.3 connect_with_timeout
int connect_with_timeout(int fd, const sockaddr* addr, socklen_t len, uint64_t timeout_ms) {...iom->addEvent(fd, WRITE); // 注册可写事件iom->addConditionTimer(timeout_ms, [fd]{ iom->cancelEvent(fd, WRITE); });Fiber::GetThis()->yield(); // 让出 CPU...
}
7. 实战:Echo Server 对比测试
方案 | QPS(单核) | 内存(MB) | 备注 |
---|---|---|---|
原生 epoll | 190k | 20 | 纯 C |
libevent | 185k | 28 | 事件库 |
本项目 | 188k | 35 | 协程栈成本 |
libco | 180k | 30 | 共享栈 |
结论:在 IO 密集场景下,协程几乎不损失性能,却带来 同步代码 + 异步效率 的极致体验。
8. 可扩展方向
- 共享栈:参考 libco,减少内存碎片。
- 协程池:避免频繁
new / delete
Fiber。 - 优先级调度:仿 Linux CFS,支持权重。
- 嵌套协程:维护
pCallStack[128]
,允许深层调用链。
9. 思考与踩坑
- 边缘触发 + 非阻塞 pipe:否则
read
可能永久阻塞。 - 线程安全 epoll:多线程共用
epfd
无需加锁。 - 异常安全:协程入口抛异常怎么办?框架不吞异常,务必
try/catch
。 - 调试:gdb 无法单步协程?用
Fiber::GetFiberId()
打印日志。
10. 一句话总结
造完轮子才发现:协程不是魔法,它只是把“阻塞等待”翻译成“注册事件 + yield + resume”。
但正是这层翻译,让 C++ 服务器在单线程上也能开出百万并发之花。
Reference
代码来自github.com/sylar-yin/sylar