23、并发网络编程:基于多线程的TCP服务器、Reactor与Proactor模式、线程安全的Socket操作
网络编程和并发编程,这两个东西放在一起,往往就是生产环境中最容易出幺蛾子的地方。我见过太多项目,单线程跑得稳稳当当,一上多线程就各种诡异崩溃。说白了,网络I/O天生就是异步的,而多线程又引入了共享资源的竞争——这两者碰撞在一起,如果没有一套成熟的设计模式兜底,代码很容易变成一团乱麻。
今天我们就来聊聊,怎么用C++把多线程和网络编程结合起来,写出既高效又安全的服务器。我会从最基础的多线程TCP服务器讲起,然后深入到Reactor和Proactor这两种主流模式,最后再聊聊那些容易被忽略的线程安全细节。
23.1 多线程TCP服务器的基本模型
先看一个最朴素的模型:主线程负责accept,每来一个连接,就创建一个新线程去处理。这种模式在教科书里很常见,但实际项目中我几乎不用——为什么呢?你想想看,如果瞬间来了一万个连接,系统就得创建一万个线程,光是上下文切换就能把CPU干趴下。
不过作为入门,我们还是先看看它的基本骨架:
// 最简单的"每连接一线程"模型
void handle_client(int client_fd) {
// 处理客户端请求...
char buffer[1024];
while (true) {
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n <= 0) break;
// 处理数据...
send(client_fd, buffer, n, 0);
}
close(client_fd);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// bind, listen...
while (true) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd,
(struct sockaddr*)&client_addr,
&addr_len);
if (client_fd < 0) continue;
// 每个连接开一个线程
std::thread t(handle_client, client_fd);
t.detach(); // 注意:detach后要自己管理资源
}
}
这段代码有个明显的坑——detach()之后,线程就失控了。我曾经在一个内部工具里这么写过,结果线上跑了一个月,发现内存泄漏得一塌糊涂。每个线程退出时没有清理干净,fd也没有正确关闭。嗯,这里要记住:detach不是银弹,它只是把线程的管理责任扔给了运行时库,但资源还得你自己管。
23.2 Reactor模式:事件驱动的核心思想
Reactor模式,说白了就是"我等着,有事叫我"。它用一个事件循环(Event Loop)来监听多个socket上的事件,当某个socket可读或可写时,再回调对应的处理函数。这样,一个线程就能管理成千上万个连接。
我最早接触Reactor是在做游戏服务器的时候。当时要支持几千人同时在线,如果用多线程模型,光是锁就能把人逼疯。后来改用Reactor + 线程池,整个世界清净了。
Reactor的核心组件就三个:
- 事件多路分发器(如epoll、kqueue、select)——负责监听多个fd上的事件
- 事件处理器(Event Handler)——处理具体I/O事件的回调函数
- 同步事件多路分离器(Initiation Dispatcher)——管理事件处理器注册和事件循环
来看一个简化版的Reactor实现:
class Reactor {
public:
void register_handler(int fd, EventHandler* handler, int events) {
// 注册fd到epoll,并关联handler
struct epoll_event ev;
ev.events = events;
ev.data.ptr = handler;
epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
}
void event_loop() {
const int MAX_EVENTS = 1024;
struct epoll_event events[MAX_EVENTS];
while (!stop_) {
int n = epoll_wait(epoll_fd_, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
EventHandler* handler =
static_cast<EventHandler*>(events[i].data.ptr);
handler->handle_event(events[i].events);
}
}
}
private:
int epoll_fd_;
std::atomic<bool> stop_{false};
};
你可能会问:Reactor里的事件处理是同步的,如果某个handler阻塞了怎么办?没错,这就是Reactor的短板——所有事件处理都在同一个线程里,一旦某个操作阻塞(比如数据库查询),整个事件循环就卡住了。
23.3 Proactor模式:异步操作的优雅方案
Proactor模式和Reactor最大的区别在于:Reactor是"事件就绪了通知你",Proactor是"操作完成了通知你"。说白了,Reactor让你自己去读数据,Proactor把数据读好了再告诉你。
在Windows上,Proactor可以通过IOCP(I/O Completion Port)实现。Linux上虽然没有原生的异步I/O(AIO在2.6内核之后有,但用起来比较痛苦),但我们可以用Reactor来模拟Proactor的行为。
Proactor的典型流程是这样的:
- 应用程序发起一个异步读操作,并提供一个缓冲区
- 操作系统在内核中完成读操作,把数据直接拷贝到用户提供的缓冲区
- 操作系统把完成事件投递到完成事件队列
- 应用程序从完成事件队列中取出事件,处理已经准备好的数据
用代码来理解会更直观:
// Proactor风格的异步读操作
class AsyncReader {
public:
void async_read(int fd, char* buffer, size_t size,
std::function<void(int)> callback) {
// 发起异步读操作
// 在Linux上,可以用epoll + 非阻塞I/O来模拟
struct iocb cb;
io_prep_pread(&cb, fd, buffer, size, 0);
io_set_callback(&cb, [](io_context_t ctx, struct iocb* iocb, long res) {
// 读完成后的回调
auto* reader = static_cast<AsyncReader*>(iocb->data);
reader->callback_(res);
});
cb.data = this;
callback_ = callback;
io_submit(io_ctx_, 1, &cb);
}
private:
io_context_t io_ctx_;
std::function<void(int)> callback_;
};
- Reactor:事件就绪 → 你主动读 → 处理数据
- Proactor:你发起读 → 系统帮你读 → 读完了通知你处理
我个人更倾向于在Linux上用Reactor + 线程池来模拟Proactor的效果。因为Linux的AIO接口用起来确实不够顺手,而且epoll的性能已经足够好了。
23.4 线程安全的Socket操作
聊完了设计模式,我们来说说最实际的问题:多线程环境下,怎么安全地操作Socket?
先看一个常见的错误:
// ❌ 错误示例:多线程同时写同一个socket
void thread_func(int fd) {
std::string msg = "Hello from thread " +
std::to_string(std::this_thread::get_id());
send(fd, msg.c_str(), msg.size(), 0); // 多个线程同时send!
}
为什么不行?因为send和recv本身不是线程安全的。多个线程同时调用send,数据可能会交错在一起,接收方收到的是乱序的字节流。我曾经在调试一个聊天服务器时遇到过这个问题——客户端收到的消息全是"你好好你",就是因为两个线程的send数据混在一起了。
解决方案其实不复杂:
- 每个socket只由一个线程操作——这是最推荐的做法。把socket的所有权绑定到一个线程上,其他线程通过消息队列来间接操作。
- 加锁保护——如果实在需要多线程共享socket,那就用
std::mutex保护每次的send/recv调用。 - 使用专门的I/O线程——所有socket操作都交给一个专门的I/O线程处理,业务线程通过队列传递数据。
来看一个线程安全的Socket封装:
class ThreadSafeSocket {
public:
explicit ThreadSafeSocket(int fd) : fd_(fd) {}
void send(const std::string& data) {
std::lock_guard<std::mutex> lock(mutex_);
::send(fd_, data.data(), data.size(), 0);
}
std::string recv(size_t size) {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<char> buf(size);
ssize_t n = ::recv(fd_, buf.data(), size, 0);
if (n > 0) {
return std::string(buf.data(), n);
}
return {};
}
void close() {
std::lock_guard<std::mutex> lock(mutex_);
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_;
std::mutex mutex_;
};
23.5 实战中的选择建议
说了这么多,到底该怎么选?我根据自己的经验,整理了一个简单的对照表:
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 连接数少(<100),每个连接处理时间长 | 每连接一线程 | 实现简单,线程数可控 |
| 连接数多(1000+),I/O密集 | Reactor + 线程池 | epoll高效,线程池控制并发度 |
| 连接数多,计算密集 | Proactor + 线程池 | 异步I/O不阻塞计算线程 |
| Windows平台,高并发 | IOCP(Proactor) | Windows原生支持,性能最佳 |
| Linux平台,需要极致性能 | Reactor + epoll | Linux上epoll是事实标准 |
最后说一句:不管用哪种模式,一定要在开发初期就把线程安全问题考虑进去。我见过太多项目,前期图省事用全局变量传fd,后期加锁加得想哭。设计模式不是银弹,但它能帮你把复杂的问题结构化,让代码更容易理解和维护。
好了,关于并发网络编程的核心内容就这些。记住:模式是死的,需求是活的。理解每种模式的优缺点,才能在实际项目中做出正确的选择。