1. 项目概述:为什么我们需要22种高并发模型?
在Linux服务器开发领域,高并发处理能力是衡量一个系统性能的核心指标。无论是支撑千万级日活的社交应用后端,还是处理高频交易的金融系统,其底层都离不开一套高效、稳定的网络编程模型。很多开发者,尤其是刚入行的朋友,常常会陷入一个误区:认为高并发就是简单地用多线程或多进程。实际上,面对不同的业务场景、流量特征和硬件资源,单一模型往往捉襟见肘,甚至成为性能瓶颈。
这就是我花时间整理并实现这22种高并发模型的初衷。它不是一个炫技的玩具,而是一套应对真实生产环境复杂性的“工具箱”。C++11标准引入的现代特性,如智能指针、lambda表达式、线程库、原子操作等,为我们以更安全、更清晰的方式构建这些模型提供了可能。而Linux内核提供的epoll、多线程、多进程、信号等机制,则是我们手中的“原材料”。
通过这个项目,我希望你能理解:没有“银弹”模型。一个简单的echo服务器和一个需要维持大量长连接、且连接间有状态共享的实时推送服务,其最优的架构选择可能截然不同。掌握多种模型,意味着你能在需求评审会上,更自信地为你的系统选择最合适的技术栈,而不是被有限的认知所束缚。
2. 核心模型分类与设计哲学
在深入代码之前,我们必须建立一个清晰的认知框架。这22种模型并非随意堆砌,而是按照其核心的并发处理单元和I/O调度方式进行系统化分类。理解这个分类,比死记硬背代码更重要。
2.1 按进程/线程维度划分
这是最基础的分类,决定了程序的宏观结构。
- 单进程/单线程模型:这是所有模型的起点,如经典的阻塞式迭代服务器。它一次只处理一个连接,性能极差,但代码简单,常用于理解基础Socket编程或极低负载的场景。它的价值在于作为其他所有复杂模型的对比基线。
- 多进程模型:典型代表是Apache HTTPd的prefork模式。主进程监听端口,接受连接后
fork()出子进程来处理。进程间内存空间隔离,稳定性高(一个进程崩溃不影响其他进程),但创建和销毁开销大,进程间通信(IPC)复杂,且能承载的连接数受限于系统进程数上限。 - 多线程模型:这是目前最主流的模型之一。一个进程内创建多个工作线程,共享内存空间,数据交换方便,线程创建开销远小于进程。但这也带来了著名的“线程安全”问题,对共享资源的访问必须通过锁、原子操作等进行同步,编程复杂度陡增,调试困难。
- 混合模型:结合了进程和线程的优势。例如,
Nginx采用的就是多进程+单线程(每个Worker进程内是单线程事件驱动)的架构。这样既利用了多核CPU,又避免了多线程的锁竞争,同时进程隔离保证了稳定性。
2.2 按I/O处理方式划分
这决定了程序如何应对海量的网络I/O事件,是高性能的关键。
- 阻塞I/O(Blocking I/O):调用
read/write等函数时,线程会一直等待,直到数据就绪或操作完成。在传统的“一个连接一个线程”模型中,大量线程会因阻塞而空转,消耗大量系统资源。 - 非阻塞I/O(Non-blocking I/O):将Socket设置为非阻塞模式后,
read/write会立即返回。如果数据未就绪,则返回一个错误(如EAGAIN或EWOULDBLOCK)。程序需要不断轮询(polling)所有Socket,效率低下,CPU占用率高。 - I/O多路复用(I/O Multiplexing):这是Linux高性能网络的基石。核心思想是使用一个系统调用(如
select、poll、epoll)来同时监视多个文件描述符(Socket)的状态。当其中任何一个描述符就绪(可读、可写或出错)时,该调用才返回,程序再去处理就绪的描述符。这实现了用单个线程管理成千上万个连接。 - 异步I/O(Asynchronous I/O, AIO):由内核发起I/O操作,并在整个操作(数据从内核缓冲区拷贝到用户缓冲区)完成后通知应用程序。理论上这是最高效的模型,但Linux原生对网络Socket的AIO支持并不完善,
libaio主要针对磁盘I/O,而io_uring是新的希望。
注意:我们常说的“异步”在Linux网络编程语境下有时是模糊的。Node.js、Nginx的“异步”通常指的是基于I/O多路复用的事件驱动、非阻塞模型,而非POSIX AIO。本项目主要探讨的是这种主流的、实践验证的事件驱动模型。
本项目的22种模型,正是以上述分类为经纬,进行排列组合与深度优化后的产物。例如,“单线程Reactor”、“多线程Reactor”、“多进程Reactor”、“Leader-Follower线程池”等,都是“I/O多路复用”与不同并发单元结合的典范。
3. 基础构建块:C++11与Linux系统调用的现代结合
在动手搭建高楼之前,必须先熟悉砖瓦。C++11和Linux系统调用是我们构建所有模型的基石。
3.1 利用C++11 RAII管理资源
资源泄露是C++网络程序的老大难问题。C++11的RAII(资源获取即初始化)思想是解决这一问题的利器。
class Socket { public: explicit Socket(int domain, int type, int protocol) { fd_ = ::socket(domain, type, protocol); if (fd_ < 0) { throw std::system_error(errno, std::system_category(), "socket creation failed"); } } ~Socket() { if (fd_ >= 0) { ::close(fd_); } } // 禁用拷贝,允许移动 Socket(const Socket&) = delete; Socket& operator=(const Socket&) = delete; Socket(Socket&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; } Socket& operator=(Socket&& other) noexcept { if (this != &other) { if (fd_ >= 0) ::close(fd_); fd_ = other.fd_; other.fd_ = -1; } return *this; } int fd() const { return fd_; } private: int fd_ = -1; };这个简单的Socket类封装了原生的文件描述符。其构造时创建socket,析构时自动关闭。通过删除拷贝构造/赋值,并定义移动语义,我们确保了socket资源所有权的清晰,完全避免了重复关闭或泄露的可能。在实际项目中,类似的RAII封装应应用于所有系统资源:文件、内存映射、锁等。
3.2 核心系统调用详解:从select到epoll
I/O多路复用的进化史,就是Linux高性能网络的发展史。
select/poll:两者本质类似,都需要在每次调用时,将用户态关注的文件描述符集合(fd_set或pollfd数组)完整地拷贝到内核态。内核遍历这个集合,检查状态,再拷贝回用户态。这个“全量拷贝+线性遍历”的过程,在连接数(nfds)很大时(如数千),会成为显著的性能瓶颈。select还有描述符数量上限(通常1024)的限制。epoll:它是解决C10K问题的关键。其核心优势在于:- 高效的数据结构:使用红黑树管理待监听的描述符,使用就绪链表存放就绪事件。
- 边缘触发(ET)与水平触发(LT):这是
epoll的精髓。LT是默认模式,只要一个socket的读缓冲区不为空,epoll_wait就会一直通知你;只要写缓冲区不满,就会一直通知你可写。ET模式则只在状态变化时(如缓冲区由空变为非空)通知一次。ET模式效率更高,但要求应用程序必须一次性读完或写完所有数据,否则会丢失事件,编程更复杂。 - 接口使用:
epoll_create1: 创建epoll实例。epoll_ctl(EPOLL_CTL_ADD/MOD/DEL): 向epoll实例增、删、改关注的事件。这是一个关键优化点:与select每次全量传递不同,epoll_ctl只需修改变化的那个socket。epoll_wait: 等待事件发生,返回就绪的事件数组。
// 一个简化的epoll使用框架 int epoll_fd = epoll_create1(0); epoll_event event{}; event.events = EPOLLIN | EPOLLET; // 关注可读事件,使用边缘触发 event.data.fd = listen_socket_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_socket_fd, &event); const int MAX_EVENTS = 64; epoll_event events[MAX_EVENTS]; while (running) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; ++i) { if (events[i].data.fd == listen_socket_fd) { // 处理新连接 int conn_fd = accept(listen_socket_fd, ...); set_nonblocking(conn_fd); // ET模式必须设为非阻塞 epoll_event conn_event{}; conn_event.events = EPOLLIN | EPOLLET | EPOLLONESHOT; conn_event.data.ptr = new Connection(conn_fd); // 通常用data.ptr携带连接上下文 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &conn_event); } else { // 处理已连接套接字的I/O Connection* conn = static_cast<Connection*>(events[i].data.ptr); handle_event(conn, events[i].events); } } }3.3 线程同步:std::mutex、std::atomic与无锁编程
多线程模型的核心挑战是同步。C++11标准库提供了完善的工具。
std::mutex与std::lock_guard/std::unique_lock:这是最直接的互斥锁。务必使用RAII包装器,保证异常安全。std::mutex g_cache_mutex; std::unordered_map<std::string, Data> g_cache; Data get_data(const std::string& key) { std::lock_guard<std::mutex> lock(g_cache_mutex); // 构造时加锁,析构时自动解锁 auto it = g_cache.find(key); if (it != g_cache.end()) return it->second; // ... 未命中则加载 }std::atomic:对于简单的计数器、标志位,使用原子变量可以避免锁的开销,性能极高。std::atomic<int> connection_count{0}; void on_new_connection() { connection_count.fetch_add(1, std::memory_order_relaxed); // 宽松内存序,适用于非同步的统计 }- 无锁队列:对于生产者-消费者场景,如线程池的任务队列,无锁队列可以消除锁竞争带来的瓶颈。你可以使用
boost::lockfree或自己基于std::atomic实现一个简单的版本。这是实现高性能线程池的关键。
实操心得:不要畏惧锁,但要有策略地用锁。我的经验法则是:粗粒度锁用于初始化或低频变更的配置数据;细粒度锁(如每个连接一个锁)用于高频访问的独立对象;原子操作用于简单的状态标志;无锁数据结构用于核心的、高吞吐的数据通路(如任务队列)。在性能分析中,如果发现锁竞争(
contention)是热点,再考虑优化。
4. 经典模型深度解析与C++11实现
下面我们挑选几个最具代表性的模型,看看如何用现代C++将其实现。
4.1 模型1:单线程Reactor(事件驱动)
这是理解事件驱动编程的范本。所有工作都在一个线程内完成:主线程运行一个事件循环(Event Loop),使用epoll_wait等待I/O事件,然后分发给对应的回调处理器(Handler)。
核心组件:
- EventLoop(事件循环):核心类,持有
epoll实例,不断调用epoll_wait。 - Channel(通道):封装一个文件描述符(如socket)及其关注的事件(可读、可写等)和对应的回调函数。它是
EventLoop和具体事件处理器的桥梁。 - Poller(轮询器):抽象层,底层可以是
epoll,也可以是poll或select,便于跨平台或测试。 - Acceptor(接收器):一个特殊的
Channel,专门监听新连接。其读事件回调就是执行accept。
C++11实现亮点:
- 使用
std::function和std::bind作为回调:将事件处理逻辑以可调用对象的形式绑定到Channel上,比传统的函数指针更灵活安全。class Channel { public: using EventCallback = std::function<void()>; void setReadCallback(EventCallback cb) { readCallback_ = std::move(cb); } void handleEvent() { if (revents_ & EPOLLIN) { if (readCallback_) readCallback_(); } // ... 处理其他事件 } private: int fd_; EventCallback readCallback_; // ... }; - 使用智能指针管理连接对象生命周期:当连接关闭时,确保其对应的
Channel和资源被正确释放。通常使用std::shared_ptr<Connection>,并在Channel中存储其弱引用std::weak_ptr,避免循环引用。
优点:模型简单,没有锁竞争,对于CPU密集型的业务不友好,但非常适合I/O密集型、且业务处理逻辑轻快的场景(如消息转发、协议解析简单的网关)。缺点:无法利用多核CPU,一个慢请求会阻塞整个事件循环。
4.2 模型2:多线程Reactor - One Loop Per Thread
这是对单线程Reactor的自然扩展,也是muduo网络库采用的经典模式。它创建多个EventLoop线程(通常与CPU核心数相等),每个线程独立运行自己的事件循环。
关键设计:
- 主Reactor(Main Reactor):通常只有一个,运行在主线程。它只负责监听和接受(
accept)新连接。 - 从Reactor(Sub Reactor):有多个,运行在独立的I/O线程中。主Reactor接受一个新连接后,会通过轮询(Round-Robin)或其他策略,将其分发给一个从Reactor。
- 连接分发:分发的不是连接本身,而是连接的文件描述符。这通常通过线程间通信完成,例如使用
eventfd或管道(pipe)来通知目标I/O线程,或者更高效地,使用Linux的sendmsg系统调用,配合SCM_RIGHTSancillary data(辅助数据)来直接传递文件描述符。
C++11实现难点与技巧:
- 线程安全的队列:主Reactor需要将新连接的描述符放入某个从Reactor的任务队列。这个队列必须是线程安全的。可以使用
std::mutex+std::queue,或者无锁队列。class EventLoopThread { public: void start() { thread_ = std::thread([this] { this->runInThread(); }); // 等待线程内EventLoop创建完成 std::unique_lock<std::mutex> lock(mutex_); cond_.wait(lock, [this] { return loop_ != nullptr; }); } EventLoop* getLoop() { return loop_; } private: void runInThread() { EventLoop loop; // 每个线程有自己独立的loop { std::lock_guard<std::mutex> lock(mutex_); loop_ = &loop; cond_.notify_one(); } loop.loop(); // 开始事件循环 } std::thread thread_; EventLoop* loop_ = nullptr; std::mutex mutex_; std::condition_variable cond_; }; - 连接归属:一个连接的生命周期完全在其所属的从Reactor线程中管理,所有对该连接的读写操作都在同一个线程,天然避免了并发访问的锁问题。这是该模型高性能的关键。
优点:充分利用多核,连接被均匀分摊到多个I/O线程,每个线程内仍是单线程事件驱动,无锁竞争,扩展性好。缺点:业务处理如果很重,仍可能阻塞单个I/O线程。这就需要引入业务线程池。
4.3 模型3:Reactor + 线程池(计算与I/O分离)
这是对“One Loop Per Thread”模型的增强,也是目前大型互联网后端最常用的架构模式之一。I/O线程(Reactor线程)只负责网络I/O的读写(数据的收与发),而将耗时的业务计算、数据库查询、RPC调用等任务,投递到一个独立的、全局的线程池中去执行。
工作流程:
- I/O线程从socket读取到一个完整的请求包。
- I/O线程将请求包封装成任务对象(Task)。
- I/O线程将此任务
push到线程池的任务队列中。 - 线程池中的某个工作线程(Worker Thread)从队列中
pop出任务并执行。 - 工作线程处理完毕后,生成响应数据。这里有一个关键点:响应数据如何写回socket?不能由工作线程直接写,因为它可能持有其他连接的锁或不在正确的I/O线程上下文。正确的做法是:将写回操作包装成另一个任务,通过
EventLoop::runInLoop或类似接口,交还给该连接所属的I/O线程去执行。
C++11线程池实现示例:
class ThreadPool { public: explicit ThreadPool(size_t numThreads) { for (size_t i = 0; i < numThreads; ++i) { workers_.emplace_back([this] { for (;;) { std::function<void()> task; { std::unique_lock<std::mutex> lock(queue_mutex_); condition_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop(); } task(); // 执行任务 } }); } } template<class F> void enqueue(F&& f) { { std::lock_guard<std::mutex> lock(queue_mutex_); tasks_.emplace(std::forward<F>(f)); } condition_.notify_one(); } ~ThreadPool() { /* ... 优雅关闭逻辑 ... */ } private: std::vector<std::thread> workers_; std::queue<std::function<void()>> tasks_; std::mutex queue_mutex_; std::condition_variable condition_; bool stop_ = false; };优点:清晰分离I/O密集和计算密集任务,避免慢业务阻塞快I/O,系统整体吞吐量和响应延迟得到优化。缺点:增加了任务派发和回调的复杂度,需要仔细设计任务和结果传递的机制,避免内存拷贝开销。
4.4 模型4:Proactor模式(模拟实现)
真正的异步I/O(AIO)在Linux网络编程中不成熟,但我们可以用同步I/O模拟Proactor的思想。其核心是:由主线程(或专用线程)来执行所有I/O操作(read/write),而将“完成事件”的通知和业务处理交给工作线程。
模拟实现思路:
- 主线程使用
epoll监听所有socket。 - 当某个socket可读时,主线程自己执行
read系统调用,将数据读入一个缓冲区。 - 主线程将这个装满数据的缓冲区,连同连接信息,打包成一个“完成事件”(Completion Event),放入一个全局完成队列。
- 线程池中的工作线程从完成队列中取出事件,进行业务逻辑处理。
- 处理完毕后,工作线程将响应数据放入连接对应的输出缓冲区,并通知主线程此socket可写。
- 主线程监听到可写事件后,自己执行
write系统调用,将数据发送出去。
与Reactor的区别:
- Reactor:通知你“可以开始I/O了”,具体读/写操作由事件处理线程自己调用。
- Proactor:通知你“I/O操作已经完成了”,你直接处理结果数据即可。
优点:将I/O操作集中化,可能在某些场景下(如磁盘I/O)更有优势,编程模型上业务线程更纯粹。缺点:在Linux网络编程中,这种模拟增加了数据拷贝次数(从内核到主线程缓冲区,再到工作线程),且主线程可能成为I/O瓶颈。因此,纯正的Proactor模式在实际的Linux网络服务器中不如Reactor流行。Windows的IOCP是真正的Proactor实现。
5. 高级主题与性能调优实战
掌握了基础模型,我们还需要关注一些高级主题和调优技巧,它们往往决定了生产环境系统的最终性能表现。
5.1 缓冲区设计与内存管理
网络数据是流式的,应用层协议包可能被TCP拆分成多个报文到达。一个高效、易用的缓冲区(Buffer)类至关重要。
设计要点:
- 连续内存与零拷贝:避免小内存频繁申请释放。内部使用
std::vector<char>或自定义的连续内存块。提供peek()、retrieve()接口,支持在不移动数据的情况下读取数据,以及移动读指针来标记数据已消费,避免拷贝。 - 扩容策略:当写入空间不足时,如果缓冲区前端有已读出的空间(空闲空间),可以先尝试将未读数据移动到缓冲区起始处(
makeSpace),而不是直接扩容。这利用了“读指针”和“写指针”的思想。 - 与系统调用结合:提供
readFd(int fd)这样的成员函数,内部直接使用::readv系统调用,一次性将数据读入缓冲区的多个空闲区域(如果空间不连续),甚至直接读入用户提供的额外缓冲区(分散读),减少拷贝次数。
class Buffer { public: ssize_t readFd(int fd, int* savedErrno) { // 栈上的额外缓冲区,用于应对缓冲区剩余空间不足的情况 char extrabuf[65536]; struct iovec vec[2]; const size_t writable = writableBytes(); vec[0].iov_base = begin() + writerIndex_; vec[0].iov_len = writable; vec[1].iov_base = extrabuf; vec[1].iov_len = sizeof(extrabuf); // 使用readv一次读入两个缓冲区 const int iovcnt = (writable < sizeof(extrabuf)) ? 2 : 1; const ssize_t n = ::readv(fd, vec, iovcnt); if (n < 0) { *savedErrno = errno; } else if (static_cast<size_t>(n) <= writable) { writerIndex_ += n; // 数据全部读入了内部缓冲区 } else { writerIndex_ = buffer_.size(); // 内部缓冲区已写满 append(extrabuf, n - writable); // 将额外缓冲区的数据追加进来 } return n; } private: std::vector<char> buffer_; size_t readerIndex_; size_t writerIndex_; };5.2 定时器管理
服务器需要处理大量的定时任务,如连接超时、心跳检测、缓存过期等。如何高效地管理数以万计的定时器?
常见方案对比:
| 方案 | 数据结构 | 添加/删除复杂度 | 触发复杂度 | 特点 |
|---|---|---|---|---|
| 排序链表 | std::list(按超时时间排序) | O(n) | O(1) | 实现简单,但插入效率低,适用于少量定时器。 |
| 最小堆 | std::priority_queue | O(log n) | O(log n) | 最常见的方案。超时时间最近的定时器在堆顶,epoll_wait的超时时间可以设置为堆顶定时器的剩余时间。 |
| 时间轮 | 环形数组+链表(哈希桶) | O(1) | O(1)(平均) | Netty、Linux内核采用。将时间划分为一个个tick,定时器散列到对应的槽中。精度固定,添加删除极快,适合海量短周期定时器。 |
| 红黑树 | std::set/map | O(log n) | O(log n) | 有序,可以方便地查找和删除任意定时器,但整体性能不如最小堆专一。 |
C++11实现最小堆定时器的要点:
using TimerCallback = std::function<void()>; struct Timer { int64_t expiration; // 绝对时间戳,微秒 TimerCallback cb; bool operator<(const Timer& t) const { return expiration > t.expiration; } // 小顶堆 }; class TimerQueue { public: void addTimer(TimerCallback cb, int delayMs) { int64_t expiration = getNowMicros() + delayMs * 1000; timers_.push(Timer{expiration, std::move(cb)}); // 更新下一个超时时间,可能需要唤醒EventLoop重新设置epoll_wait的超时 } void handleExpiredTimers() { int64_t now = getNowMicros(); while (!timers_.empty() && timers_.top().expiration <= now) { Timer t = std::move(timers_.top()); timers_.pop(); t.cb(); // 执行回调 } } private: std::priority_queue<Timer> timers_; };在EventLoop中,每次循环开始前,计算距离最近定时器的时间差,作为epoll_wait的timeout参数。超时返回后,调用handleExpiredTimers。
5.3 性能调优与监控
模型选对了,代码写好了,上线前还需要最后一轮打磨。
系统参数调优:
- 文件描述符限制:使用
ulimit -n或修改/etc/security/limits.conf,提高进程可打开的最大文件数。 - TCP参数:
net.ipv4.tcp_tw_reuse = 1&net.ipv4.tcp_tw_recycle = 1(谨慎使用,在NAT环境下可能有问题):快速回收TIME_WAIT状态的连接。net.ipv4.tcp_max_syn_backlog:增大SYN队列长度。net.core.somaxconn:增大listen系统调用的backlog参数上限。
- 端口范围:
net.ipv4.ip_local_port_range:增大客户端连接可用的本地端口范围。
- 文件描述符限制:使用
应用层优化:
- 避免内存拷贝:使用前面提到的
readv、writev,或更激进的splice、sendfile进行零拷贝传输。 - 对象池与内存池:对于频繁创建销毁的小对象(如连接对象、缓冲区对象),使用对象池进行复用,减少
malloc/free或new/delete的开销。 - 日志异步化:将日志写入操作放入单独的线程或队列,避免同步写磁盘阻塞主业务线程。
- 避免内存拷贝:使用前面提到的
监控与诊断:
- 关键指标:QPS(每秒查询率)、连接数、各阶段耗时(I/O等待、业务处理)、内存使用、CPU使用率。
- 使用工具:
perf:分析CPU热点和缓存命中率。valgrind/AddressSanitizer:检查内存泄露和越界访问。strace/ltrace:跟踪系统调用和库函数调用。tcpdump/Wireshark:分析网络包,排查协议问题。
6. 模型选型指南与常见陷阱
面对这么多模型,如何选择?这里有一个简单的决策流:
- 连接数多少?如果预计连接数很少(<1000),多进程或多线程阻塞模型最简单。
- 业务逻辑是否耗时?如果业务处理非常快(微秒级),单线程或One Loop Per Thread的Reactor可能就够了。如果业务慢(涉及数据库、RPC),必须引入业务线程池(Reactor+ThreadPool)。
- 是否需要状态共享?如果需要跨连接共享复杂状态,多线程模型(共享内存)比多进程模型(需要IPC)更合适。
- 对延迟是否极度敏感?如果是,要尽量减少线程切换和数据拷贝,单Reactor配合用户态协议栈或DPDK可能是终极方案。
常见陷阱与避坑指南:
- ET模式的坑:使用ET模式必须将socket设为非阻塞,并且必须循环读/写直到返回
EAGAIN,否则会丢失事件。对于读事件,通常没问题;对于写事件,最佳实践是:只在需要写数据时才监听EPOLLOUT事件,数据写完立即取消监听,避免epoll被频繁触发(busy loop)。 - 多线程下的对象生命周期:这是最易出错的地方。一个连接对象可能在I/O线程被处理时,又被主线程或另一个线程因超时而删除。务必使用引用计数(如
std::shared_ptr)和弱引用(std::weak_ptr)来安全地管理对象生命周期。或者,确保一个对象的所有操作都在其所属的固定线程中进行。 epoll惊群(thundering herd):在旧版本内核中,多个进程/线程同时epoll_wait同一个epoll fd,当一个新连接到来时,所有等待者都会被唤醒,但只有一个能accept成功,造成资源浪费。解决方案:使用EPOLLEXCLUSIVE标志(Linux 4.5+),或者确保只有一个进程/线程在监听监听socket。- 信号处理:网络服务器需要处理
SIGPIPE(对方关闭连接后继续写会产生,默认行为是终止进程)和SIGTERM/SIGINT(优雅退出)。建议使用sigaction忽略SIGPIPE,并为退出信号设置处理函数,在函数内优雅地关闭监听socket,通知事件循环退出,并等待所有工作线程结束。
7. 从模型到框架:构建可复用的网络库
实现22种模型的意义,不仅在于理解它们,更在于抽象出共性,构建属于自己的、轻量级且高性能的网络库基础框架。一个良好的网络库通常包含以下层次:
- 基础层:对Socket、InetAddress、Buffer、Timer的RAII封装。
- 核心层:
EventLoop、Poller、Channel、EventLoopThread、EventLoopThreadPool。这是反应堆模式的核心。 - 协议层:
TcpConnection、TcpServer、Acceptor、Connector。这些类将核心层组件组装起来,提供面向连接的TCP编程抽象。 - 应用层:用户编写的业务逻辑,通常通过设置
TcpConnection的各种回调(连接建立、消息到达、发送完成、连接关闭)来注入。
通过这样的分层,用户只需关注应用层的回调函数实现,而无需关心底层是用了epoll还是poll,是多线程还是多进程。这极大地提升了开发效率和代码的可维护性。
最后,我想强调的是,网络编程是实践性极强的领域。读十遍理论,不如动手写一个简单的echo服务器,然后逐步为它添加多线程、缓冲区、定时器等功能。在不断的调试、压测和优化中,你对这些模型的理解才会真正深入骨髓。这22种模型,就像武术中的招式,理解了它们的原理和适用场景,你才能在实际项目中灵活组合,见招拆招,设计出最适合当前业务的高并发系统。