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不是银弹,它只是把线程的管理责任扔给了运行时库,但资源还得你自己管。

⚠️ 注意: 生产环境中,绝对不要用"每连接一线程"模型处理高并发场景。线程创建销毁的开销、上下文切换的成本、以及fd数量的限制,都会成为瓶颈。一般建议使用线程池 + 非阻塞I/O。

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的短板——所有事件处理都在同一个线程里,一旦某个操作阻塞(比如数据库查询),整个事件循环就卡住了。

💡 实战技巧: 我在项目中通常把Reactor和线程池结合使用。Reactor只负责I/O事件的监听和分发,耗时的业务逻辑丢到线程池里去执行。这样既利用了epoll的高效I/O,又避免了阻塞事件循环。

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的典型流程是这样的:

  1. 应用程序发起一个异步读操作,并提供一个缓冲区
  2. 操作系统在内核中完成读操作,把数据直接拷贝到用户提供的缓冲区
  3. 操作系统把完成事件投递到完成事件队列
  4. 应用程序从完成事件队列中取出事件,处理已经准备好的数据

用代码来理解会更直观:

// 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!
}

为什么不行?因为sendrecv本身不是线程安全的。多个线程同时调用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_;
};
⚠️ 注意: 加锁虽然能保证线程安全,但会降低并发性能。如果多个线程频繁竞争同一个socket的锁,性能可能还不如单线程。所以,优先考虑"每个socket一个线程"的设计,加锁只是最后的兜底方案。

23.5 实战中的选择建议

说了这么多,到底该怎么选?我根据自己的经验,整理了一个简单的对照表:

场景 推荐模式 原因
连接数少(<100),每个连接处理时间长 每连接一线程 实现简单,线程数可控
连接数多(1000+),I/O密集 Reactor + 线程池 epoll高效,线程池控制并发度
连接数多,计算密集 Proactor + 线程池 异步I/O不阻塞计算线程
Windows平台,高并发 IOCP(Proactor) Windows原生支持,性能最佳
Linux平台,需要极致性能 Reactor + epoll Linux上epoll是事实标准

最后说一句:不管用哪种模式,一定要在开发初期就把线程安全问题考虑进去。我见过太多项目,前期图省事用全局变量传fd,后期加锁加得想哭。设计模式不是银弹,但它能帮你把复杂的问题结构化,让代码更容易理解和维护。

好了,关于并发网络编程的核心内容就这些。记住:模式是死的,需求是活的。理解每种模式的优缺点,才能在实际项目中做出正确的选择。


公众号:蓝海资料掘金营,微信 deep3321