第25章:高并发架构设计——事件驱动、协程与网络编程
各位同学,今天我们来聊一个硬核话题——高并发架构。说实话,我入行头三年,一直觉得“高并发”就是多线程+锁。直到我在一个IM项目中,被几万个长连接搞得焦头烂额,才真正理解了什么叫“架构设计决定上限”。
这一章,我会把事件驱动、协程、libco、C++20协程这几个东西串起来讲。你想想看,它们本质上都在解决同一个问题:如何用更少的资源,处理更多的并发连接。
25.1 事件驱动架构:高并发的基石
事件驱动,说白了就是“来了事再干活”。传统的多线程模型,每个连接分配一个线程,线程多了上下文切换开销巨大。事件驱动则不同——它用一个或几个线程,轮询所有连接的事件。
我在项目中用过select、poll、epoll。说实话,select有1024个文件描述符的限制,poll虽然没限制但性能随连接数线性下降。真正能打的,是epoll。
核心思想: epoll维护一个事件表,应用程序调用epoll_wait等待事件。只有活跃的连接才会被返回,避免了遍历所有连接的浪费。
// epoll 核心用法示例
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
// 处理就绪的事件
handle_event(events[i]);
}
}
我的经验: 边缘触发(ET)比水平触发(LT)效率高,但容易漏事件。我习惯在ET模式下,用循环读直到返回EAGAIN,确保数据读干净。
25.2 协程:让异步代码像同步一样写
事件驱动虽然高效,但代码写起来很痛苦——回调地狱你懂的。协程的出现,就是为了解决这个问题。
协程是什么?说白了,就是用户态线程。它不依赖操作系统调度,切换开销极小(纳秒级)。每个协程有自己的栈和上下文,但共享进程地址空间。
| 对比项 | 线程 | 协程 |
|---|---|---|
| 创建开销 | ~1MB栈空间 | ~4KB栈空间 |
| 切换开销 | 微秒级(内核态) | 纳秒级(用户态) |
| 并发数 | 几千到几万 | 几十万到百万 |
| 编程模型 | 同步阻塞 | 同步非阻塞 |
嗯,这里要注意:协程不是银弹。如果你的任务是CPU密集型,协程反而因为单线程执行而吃亏。它最适合的是IO密集型场景——网络请求、数据库查询、文件读写。
25.3 libco:腾讯的开源协程库
libco是腾讯开源的一个C语言协程库,微信后台大量使用。我当年研究它的时候,最大的感受是:设计极其务实。
libco的核心机制是hook系统调用。它劫持了read、write、send、recv等阻塞函数,当协程发起IO时,自动让出CPU,等IO就绪后再恢复执行。
// libco 使用示例
void* routine_func(void* arg) {
int fd = *(int*)arg;
char buf[1024];
int n = co_read(fd, buf, sizeof(buf)); // 自动让出协程
// 处理数据...
return NULL;
}
int main() {
stCoRoutine_t* co;
co_create(&co, NULL, routine_func, &fd);
co_resume(co); // 启动协程
co_eventloop(co_get_epoll_ct(), NULL, NULL); // 事件循环
return 0;
}
避坑指南: 我曾经在libco中犯过一个错误——在协程里调用了阻塞的sleep函数,导致整个线程卡死。记住:在协程里,必须用libco提供的co_sleep,而不是系统的sleep。
libco的另一个亮点是共享栈。多个协程可以共享同一个栈空间,只在切换时保存/恢复必要的数据。这大大减少了内存占用——10万个协程,栈内存才几百MB。
25.4 C++20协程:现代C++的异步利器
C++20引入了标准协程,虽然它更像一个“框架”而非完整的协程库。你需要自己实现promise_type、awaitable等组件。
说实话,C++20协程的学习曲线有点陡。但一旦上手,写出来的代码非常优雅。
// C++20 协程示例
#include <coroutine>
#include <iostream>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
Task async_read(int fd) {
char buf[1024];
// co_await 一个异步读操作
int n = co_await async_read_some(fd, buf);
std::cout << "read " << n << " bytes" << std::endl;
co_return;
}
我的建议: 如果你在C++项目中使用协程,不要从零造轮子。直接用cppcoro、folly::coro这些成熟库。我团队之前自己实现了一套,踩了不少坑,后来还是换成了cppcoro。
25.5 架构对比:如何选择?
你可能会问:这么多方案,我该用哪个?我的回答是:看场景。
- 纯C项目:libco是最佳选择,成熟稳定,微信验证过。
- C++项目:C++20协程 + cppcoro,现代且可维护。
- 简单场景:事件驱动 + 状态机就够了,别上协程增加复杂度。
- 超高并发:协程 + 事件驱动混合,比如libco内部就是epoll + 协程。
25.6 实战:用libco写一个echo服务器
光说不练假把式。我们来看一个完整的libco echo服务器例子。这个代码我在内部培训时经常用,因为它展示了协程最核心的用法。
#include "co_routine.h"
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
void* echo_routine(void* arg) {
int fd = *(int*)arg;
char buf[1024];
while (1) {
int n = co_read(fd, buf, sizeof(buf));
if (n <= 0) break;
co_write(fd, buf, n);
}
close(fd);
return NULL;
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 1024);
while (1) {
int client_fd = accept(listen_fd, NULL, NULL);
stCoRoutine_t* co;
co_create(&co, NULL, echo_routine, &client_fd);
co_resume(co);
}
return 0;
}
注意: 上面的例子为了简洁,省略了错误处理和资源管理。实际项目中,一定要处理accept失败、co_read返回0等情况。我曾经在生产环境遇到过因为忘记处理EINTR导致协程死循环的bug。
25.7 性能调优:协程池与调度策略
协程虽然轻量,但也不能无限制创建。我建议使用协程池,控制最大并发数。libco本身没有内置池化机制,需要自己实现。
调度策略上,libco默认是非抢占式——协程主动让出CPU。这意味着如果一个协程死循环,整个线程就卡死了。解决方案是:在协程中定期调用co_yield_ct(),给其他协程执行机会。
我的调优经验: 在微信后台,每个worker线程管理5000-10000个协程。协程栈大小设置为8KB(默认是128KB),通过co_set_stacksize调整。这样10万协程只占800MB内存,完全可接受。
好了,这一章的内容就到这里。事件驱动和协程,说白了就是“用更少的资源做更多的事”。你可以在自己的项目中先从小规模开始尝试,比如用libco改造一个单线程的echo服务器,感受一下协程的魅力。