21、并发网络编程:Reactor与Proactor模式、多线程事件循环、线程安全socket操作、异步DNS解析
聊到并发网络编程,很多人的第一反应就是「用多线程处理连接」。嗯,这个思路没错,但怎么组织这些线程、怎么让它们高效协作,才是真正的难点。我在早期做高并发服务器时,就踩过不少坑——线程一多,锁一乱加,性能反而比单线程还差。今天我们就来拆解一下,并发网络编程里那些绕不开的核心模式。
Reactor 与 Proactor:两种截然不同的思路
先说说 Reactor 模式。说白了,它就是一个事件分发器。你注册好各种 I/O 事件(比如可读、可写),然后一个事件循环在那里等着。一旦有事件发生,它就回调你注册的处理函数。我习惯把 Reactor 叫做「拉模式」——你得主动去拉数据。
核心思想:Reactor 负责监听事件,业务逻辑在事件回调中执行。所有 I/O 操作都是非阻塞的。
Proactor 模式则完全相反。它是「推模式」——操作系统帮你把数据读好了,然后通知你「数据已经在你指定的缓冲区里了,拿去用吧」。说白了,Proactor 把「等待 I/O 完成」这件事交给了系统,你的代码只需要处理已经就绪的数据。
为什么会这样?因为 Proactor 通常需要操作系统支持异步 I/O(比如 Windows 的 IOCP,Linux 的 io_uring)。而 Reactor 只需要非阻塞 I/O + 事件通知(比如 epoll、kqueue)。
我的经验:在 Linux 上,我几乎只用 Reactor 模式。因为 epoll 太成熟了,而且 io_uring 虽然好,但很多老系统不支持。Windows 上我反而更倾向 Proactor,IOCP 用起来确实顺手。
来看一个简单的 Reactor 事件循环骨架:
// 伪代码:Reactor 事件循环
class Reactor {
std::unordered_map<int, EventHandler*> handlers_;
epoll_fd_ = epoll_create1(0);
public:
void register_handler(int fd, EventHandler* handler, uint32_t events) {
struct epoll_event ev;
ev.events = events;
ev.data.fd = fd;
epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
handlers_[fd] = handler;
}
void run() {
while (true) {
struct epoll_event events[1024];
int n = epoll_wait(epoll_fd_, events, 1024, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
handlers_[fd]->handle_read();
}
if (events[i].events & EPOLLOUT) {
handlers_[fd]->handle_write();
}
}
}
}
};
你看,核心就是一个死循环,不断调用 epoll_wait。有事件来了就分发。简单、直接、高效。
多线程事件循环:别让一个线程扛所有活
单线程 Reactor 能扛多少连接?我实测过,在普通服务器上,单线程 epoll 处理几千个长连接没问题。但一旦涉及大量计算或阻塞操作,单线程就撑不住了。这时候就需要多线程事件循环。
常见的做法是「一个主 Reactor + 多个子 Reactor」。主 Reactor 只负责 accept 新连接,然后把连接分发给子 Reactor。每个子 Reactor 有自己的事件循环,处理自己那部分连接的读写。
关键点:每个子 Reactor 运行在自己的线程里,线程之间尽量不共享数据。这样能极大减少锁竞争。
我曾经在一个项目中,用单线程 Reactor 处理 5000 个 WebSocket 连接,CPU 占用率直接飙到 90%。改成多线程事件循环后,同样 5000 连接,CPU 占用降到 30%,吞吐量翻了三倍。嗯,这就是多线程事件循环的价值。
来看一个多线程事件循环的分配逻辑:
// 伪代码:多线程事件循环分配
class MultiReactor {
std::vector<std::unique_ptr<Reactor>> sub_reactors_;
int next_ = 0;
public:
void start(int thread_count) {
for (int i = 0; i < thread_count; ++i) {
auto reactor = std::make_unique<Reactor>();
std::thread t([&reactor]() { reactor->run(); });
t.detach();
sub_reactors_.push_back(std::move(reactor));
}
}
void on_new_connection(int client_fd) {
// 轮询分配,也可以用更复杂的负载均衡
int idx = next_++ % sub_reactors_.size();
sub_reactors_[idx]->register_handler(
client_fd, new ClientHandler(), EPOLLIN | EPOLLET);
}
};
这里有个细节:轮询分配虽然简单,但如果某个子 Reactor 上的连接特别活跃,其他 Reactor 可能闲着。我后来改用「最少连接数」策略,效果更好。
线程安全 socket 操作:别让数据乱飞
多线程环境下操作 socket,最怕什么?数据竞争。比如两个线程同时往同一个 socket 写数据,结果数据混在一起,对端收到的是乱码。或者一个线程在读,另一个线程在 close,程序直接崩溃。
我建议的做法是:每个 socket 只属于一个线程。也就是说,一个 socket 的读写操作,始终在同一个事件循环线程中完成。这样就不需要加锁了。
注意:如果实在无法避免跨线程操作 socket,一定要用锁保护。但锁会带来性能开销,而且容易死锁。我曾经在一个项目里,因为跨线程写 socket 没加锁,导致线上数据错乱,排查了整整两天。
如果必须跨线程发送数据,可以用「消息队列」的方式:把要发送的数据封装成消息,投递到目标线程的队列里,由目标线程统一发送。这样既保证了线程安全,又避免了锁竞争。
// 伪代码:线程安全的跨线程消息投递
class ThreadSafeSocketWriter {
std::mutex mtx_;
std::queue<std::vector<char>> pending_;
int fd_;
public:
void post_send(const char* data, size_t len) {
std::lock_guard<std::mutex> lock(mtx_);
pending_.emplace(data, data + len);
// 通知事件循环有数据要发送
notify_event_loop();
}
void flush() {
std::lock_guard<std::mutex> lock(mtx_);
while (!pending_.empty()) {
auto& msg = pending_.front();
send(fd_, msg.data(), msg.size(), 0);
pending_.pop();
}
}
};
你看,核心就是「把数据交给正确的线程去处理」。别让多个线程直接操作同一个 socket。
异步 DNS 解析:别让网络卡住你的事件循环
DNS 解析是个大坑。传统的 gethostbyname 是阻塞的,如果 DNS 服务器响应慢,你的整个事件循环都会被卡住。我见过一个项目,因为 DNS 解析阻塞,导致所有连接都超时了。
解决方案就是异步 DNS 解析。常见的做法有两种:
- 使用专门的 DNS 线程池:把 DNS 解析任务丢给一个独立的线程池,解析完成后回调通知事件循环。
- 使用异步 DNS 库:比如 c-ares、libevent 自带的 DNS 解析器,它们本身就是非阻塞的。
我个人更倾向于第二种。c-ares 是纯异步的,它内部自己管理 socket 和事件循环,你只需要把它的 socket 注册到你的事件循环里就行。
// 伪代码:使用 c-ares 进行异步 DNS 解析
void dns_callback(void* arg, int status, struct hostent* hostent) {
auto* handler = static_cast<DnsHandler*>(arg);
if (status == ARES_SUCCESS) {
// 解析成功,拿到 IP 地址
handler->on_resolved(hostent->h_addr_list[0]);
} else {
handler->on_error("DNS resolution failed");
}
delete handler;
}
void async_resolve(const std::string& hostname) {
ares_channel channel;
ares_init(&channel);
auto* handler = new DnsHandler();
ares_gethostbyname(channel, hostname.c_str(), AF_INET, dns_callback, handler);
// 将 channel 的 socket 注册到事件循环中
register_ares_sockets(channel);
}
这里要注意:c-ares 的回调是在它自己的线程里触发的,所以回调里不能直接操作事件循环的数据。我习惯在回调里用消息队列把结果投递回去。
避坑指南:我曾经在回调里直接修改了事件循环的共享数据,结果导致数据竞争,程序随机崩溃。后来改成消息投递,问题就解决了。记住:回调函数里尽量只做「通知」,不做「操作」。
知识体系总览
下面这张图总结了本章的核心知识点和它们之间的关系:
这张图把四个核心知识点串起来了。Reactor 和 Proactor 是两种基础模式,多线程事件循环是 Reactor 的扩展,线程安全 Socket 是多线程环境下的必备技能,异步 DNS 解析则是网络编程中容易被忽视的细节。它们共同构成了并发网络编程的完整拼图。
好了,这一章的内容就到这里。记住:模式是死的,人是活的。理解每种模式的适用场景,比死记硬背代码更重要。