【高性能网络库与HTTP Server系列】:基于主从 Reactor 模型实现高性能 C++ 网络库与 HTTP Server

🔥 本文专栏:高性能网络库与HTTP Server

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录真正能让一个项目变强的,不是堆功能,而是把每一层职责边界想清楚。


思维导图

引入

在此前的学习中,我们已经实现过一个基于 epoll 的高并发 HTTP 服务器。

该服务器的核心思路是:主线程通过 epoll 统一监听多个连接的 I/O 事件,当某个连接的读写事件就绪之后,再将对应任务投递给线程池中的工作线程处理。

这种模型已经比传统的"一个连接一个线程"要好很多。

因为它避免了线程数量随着连接数量线性增长的问题,也避免了大量线程阻塞在 read / send 等系统调用上,从而减少线程创建、线程切换以及 CPU 空转带来的开销。

但是,随着继续深入学习网络库的设计,就会发现仅仅实现一个 HTTP Server 其实还不够。

原因在于:HTTP Server 本质上只是一个具体的应用层服务器,而不是一个通用的网络库框架。


例如,在此前的服务器中,网络 I/O、连接管理、HTTP 解析、业务处理、定时器、日志等逻辑可能会耦合在同一套处理流程中。

对于一个学习型项目来说,这种写法可以帮助我们快速跑通 HTTP 请求与响应流程;但是从网络库设计的角度来看,这种结构仍然存在几个问题:

  1. epoll 事件分发逻辑与连接对象管理逻辑耦合较重;
  2. 连接的生命周期不够清晰;
  3. I/O 读写逻辑、业务回调逻辑和协议解析逻辑没有完全分层;
  4. 多线程任务投递需要更明确的线程归属模型;
  5. 定时器、唤醒机制、HTTP 层、日志系统等模块不够统一;
  6. 如果后续想支持其他协议,原来的 HTTP Server 不容易复用。

所以,这个项目的目标并不是简单地再写一个 HTTP 服务器,而是进一步抽象出一个类似 Muduo 思想的 C++ 网络库。

也就是说,底层网络库只负责:

text 复制代码
连接建立
事件监听
事件分发
非阻塞读写
跨线程任务投递
连接生命周期管理
定时器处理
回调机制

而上层协议层再基于网络库实现 HTTP Server:

text 复制代码
HTTP 请求解析
HTTP 响应构造
静态资源访问
GET / POST 处理
LRU 缓存
重定向
错误响应

这样一来,整个项目就从"写一个 HTTP Server"升级为了"实现一个可以支撑 HTTP Server 的 C++ 网络库"。

这便是本文要介绍的项目:

text 复制代码
ReactorNet:基于主从 Reactor 的高性能 C++ 网络库与 HTTP Server

从单 Reactor 到主从 Reactor:为什么要进一步拆分线程模型?

在此前的 epoll HTTP Server 中,我们已经知道,I/O 多路复用模型的核心是:

text 复制代码
少量线程统一监听大量 fd 的 I/O 就绪事件

当某个 fd 的事件就绪后,线程再去执行对应的读写逻辑。

但是,如果只有一个 Reactor 线程,那么这个线程通常要同时负责:

text 复制代码
监听新连接
处理已连接 fd 的读事件
处理已连接 fd 的写事件
执行部分业务逻辑
处理关闭事件
处理超时任务

在低并发或者中等并发场景下,这种模型是可以工作的。

但是当连接数量增加之后,单 Reactor 线程会逐渐成为瓶颈。

因为所有连接的事件分发都集中在一个 epoll_wait 循环中,一旦某些连接的业务处理稍微复杂,或者读写事件比较频繁,就会影响其他连接事件的处理及时性。


因此,我们可以进一步将 Reactor 拆分成两类:

text 复制代码
Main Reactor
Sub Reactor

其中:

text 复制代码
Main Reactor 只负责监听 listenfd,并接收新连接

Sub Reactor 负责已连接 fd 的 I/O 事件处理和业务回调执行

也就是说,主线程不再直接处理每一个客户端连接的读写事件,而是只负责 accept

当新连接建立之后,主线程会按照一定策略选择一个 Sub Reactor,并将这个已连接套接字分发给它。

后续该连接的所有读事件、写事件、关闭事件、错误事件以及业务回调,都会在所属的 Sub Reactor 线程中串行执行。

整体流程可以表示为:

text 复制代码
客户端连接到来
      |
      v
Main Reactor 监听 listenfd
      |
      v
Acceptor 接收新连接,得到 connfd
      |
      v
TcpServer 选择一个 Sub Reactor
      |
      v
将 connfd 投递给目标 EventLoop
      |
      v
目标 Sub Reactor 创建 TcpConnection 并注册 Channel
      |
      v
后续该连接的 I/O 事件都在所属 Sub Reactor 中处理

从整体上看,这就是典型的:

text 复制代码
one loop per thread

即一个线程对应一个 EventLoop,一个 EventLoop 管理一批连接。


主从 Reactor 模型的核心价值

主从 Reactor 模型的核心价值不只是"多开几个线程"。

更重要的是,它让连接和线程之间形成了稳定的归属关系。

也就是说,一个连接一旦被分配给某个 Sub Reactor,那么后续这个连接的 I/O 事件都由这个 Sub Reactor 负责。

这样设计有几个好处。


1. 减少跨线程锁竞争

如果一个连接的读写操作可能被多个线程同时处理,那么就需要给连接对象、输入缓冲区、输出缓冲区等结构加锁。

但是在 one-loop-per-thread 模型下,一个连接只属于一个 EventLoop 线程。

因此,对于这个连接来说,它的读事件、写事件、关闭事件都是在同一个线程中串行执行的。

这就意味着连接内部的大部分状态不需要频繁加锁。

例如:

text 复制代码
inputBuffer
outputBuffer
Channel 关注事件
连接状态
HTTP 解析上下文

这些结构都可以在所属 EventLoop 线程中串行访问。


2. 提高缓存局部性

线程切换不仅有调度开销,还会影响 CPU cache 命中率。

如果一个连接今天由线程 A 处理,下一次又被线程 B 处理,那么该连接相关的数据可能不断在不同 CPU 核心之间迁移。

而在 one-loop-per-thread 模型下,一个连接长期绑定在某个 EventLoop 线程上,连接对象、Buffer、HTTP 上下文等数据更容易被当前线程反复访问,从而具有更好的缓存局部性。


3. 连接生命周期更加清晰

在网络服务器中,最容易混乱的问题之一就是连接对象什么时候创建、什么时候销毁、由哪个线程销毁。

如果连接的创建、注册、关闭、销毁分布在多个线程中,就很容易出现悬空指针、重复关闭、事件仍然触发但对象已经释放等问题。

因此,在 ReactorNet 中,连接对象的生命周期需要有明确的归属:

text 复制代码
TcpServer 负责维护连接表

TcpConnection 负责描述一个连接

EventLoop 负责执行该连接相关的 I/O 事件

Channel 负责将 fd 上的事件回调到 TcpConnection

同时,为了防止连接在事件处理过程中被提前释放,连接对象通常需要配合 shared_ptr 进行生命周期管理。


ReactorNet 整体模块拆分

整个项目从网络库角度可以拆分为以下几层:

text 复制代码
ReactorNet
|
|-- reactor
|   |-- EventLoop
|   |-- Channel
|   |-- Epoller
|
|-- tcp
|   |-- Socket
|   |-- Acceptor
|   |-- TcpConnection
|   |-- TcpServer
|   |-- Buffer
|
|-- thread
|   |-- EventLoopThread
|   |-- EventLoopThreadPool
|
|-- timer
|   |-- TimerWheel / Timer
|
|-- http
|   |-- HttpServer
|   |-- HttpRequest
|   |-- HttpResponse
|   |-- HttpContext
|
|-- log
|   |-- Logger
|
|-- daemon
|   |-- Daemon

其中,网络库核心层主要包括:

text 复制代码
EventLoop
Channel
Epoller
Socket
Acceptor
TcpConnection
TcpServer
Buffer
EventLoopThreadPool

HTTP 层则建立在 TcpServer 之上。

也就是说,TcpServer 并不关心上层协议到底是 HTTP、RPC 还是自定义协议。

它只负责把网络数据读进来,然后通过业务回调交给上层。


EventLoop:一个线程的事件循环

EventLoop 是整个 Reactor 模型中最核心的组件。

它可以理解为:

text 复制代码
一个线程级别的事件调度器

或者说:

text 复制代码
一个 EventLoop 就代表一个 Reactor 线程

每个 EventLoop 内部通常维护以下内容:

cpp 复制代码
class EventLoop
{
private:
    std::thread::id _threadId;          // 所属线程 ID
    std::unique_ptr<Epoller> _poller;   // epoll 封装
    std::vector<Channel*> _activeChannels;

    int _eventFd;                       // 跨线程唤醒
    std::vector<Functor> _pendingFunctors;
    std::mutex _mutex;

    bool _quit;
};

它的核心职责包括:

text 复制代码
1. 调用 epoll_wait 等待事件就绪
2. 将就绪事件分发给对应 Channel
3. 执行跨线程投递过来的任务
4. 通过 eventfd 完成线程唤醒
5. 处理 timerfd 或时间轮产生的定时任务

EventLoop 的主循环大致如下:

cpp 复制代码
void EventLoop::loop()
{
    while (!_quit)
    {
        _activeChannels.clear();

        _poller->poll(&_activeChannels);

        for (Channel* channel : _activeChannels)
        {
            channel->handleEvent();
        }

        doPendingFunctors();
    }
}

从这个结构可以看出,EventLoop 本身并不直接知道某个 fd 是监听套接字,还是客户端连接套接字。

它只知道:

text 复制代码
当前有哪些 Channel 就绪了

然后调用这些 Channel 对应的事件处理函数。


Channel:fd、事件和回调的封装

Channel 是 Reactor 模型中非常关键的一层抽象。

在 Linux 中,epoll 监听的是文件描述符 fd 上的事件。

但是在 C++ 网络库中,我们不能只操作裸 fd。

因为一个 fd 背后通常还需要绑定:

text 复制代码
关注的事件
实际就绪的事件
读回调
写回调
关闭回调
错误回调
所属 EventLoop

所以,Channel 的本质就是:

text 复制代码
fd + 事件 + 回调

一个简化后的 Channel 可以表示为:

cpp 复制代码
class Channel
{
public:
    using EventCallback = std::function<void()>;

private:
    EventLoop* _loop;
    int _fd;

    uint32_t _events;   // 当前关注的事件
    uint32_t _revents;  // 当前实际就绪的事件

    EventCallback _readCallback;
    EventCallback _writeCallback;
    EventCallback _closeCallback;
    EventCallback _errorCallback;
};

epoll_wait 返回后,Epoller 会设置 Channel 的 _revents 字段。

随后 EventLoop 调用:

cpp 复制代码
channel->handleEvent();

Channel 内部再根据实际就绪事件调用不同回调:

cpp 复制代码
void Channel::handleEvent()
{
    if ((_revents & EPOLLHUP) && !(_revents & EPOLLIN))
    {
        if (_closeCallback) _closeCallback();
    }

    if (_revents & EPOLLERR)
    {
        if (_errorCallback) _errorCallback();
    }

    if (_revents & (EPOLLIN | EPOLLPRI | EPOLLRDHUP))
    {
        if (_readCallback) _readCallback();
    }

    if (_revents & EPOLLOUT)
    {
        if (_writeCallback) _writeCallback();
    }
}

这里需要注意的是,Channel 本身并不处理业务逻辑。

它只是一个事件分发器。

真正的读写逻辑通常在 TcpConnection 中完成。


Epoller:对 epoll 的简单封装

Epoller 负责封装 Linux 下的 epoll_createepoll_ctlepoll_wait

它的核心作用是将底层系统调用封装起来,让 EventLoop 不直接操作 epoll 细节。

Epoller 内部通常会维护:

text 复制代码
epoll fd
event 数组
fd 到 Channel 的映射关系

简化结构如下:

cpp 复制代码
class Epoller
{
private:
    int _epollFd;
    std::vector<epoll_event> _events;
    std::unordered_map<int, Channel*> _channels;

public:
    void updateChannel(Channel* channel);
    void removeChannel(Channel* channel);
    void poll(std::vector<Channel*>* activeChannels);
};

其中:

text 复制代码
updateChannel 负责添加或修改 fd 关注事件
removeChannel 负责从 epoll 中删除 fd
poll 负责调用 epoll_wait 并返回就绪 Channel

在 ReactorNet 中,Epoller 属于 EventLoop 的底层组件。

也就是说:

text 复制代码
EventLoop 负责事件循环
Epoller 负责 epoll 系统调用
Channel 负责 fd 事件回调

三者之间的关系可以表示为:

text 复制代码
EventLoop
   |
   v
Epoller
   |
   v
epoll_wait 返回就绪事件
   |
   v
Channel::handleEvent()

Socket:对原生 fd 的 RAII 封装

Socket 用于封装原生套接字 fd。

它主要负责:

text 复制代码
创建 socket
bind
listen
accept
设置非阻塞
设置地址复用
关闭写端
析构释放 fd

在 C++ 中,直接到处传递裸 fd 很容易导致资源泄漏或者重复关闭。

因此,可以使用 Socket 对 fd 进行 RAII 管理。

cpp 复制代码
class Socket
{
private:
    int _sockfd;

public:
    explicit Socket(int sockfd)
        : _sockfd(sockfd)
    {}

    ~Socket()
    {
        ::close(_sockfd);
    }

    int fd() const
    {
        return _sockfd;
    }

    void bindAddress(const InetAddress& addr);
    void listen();
    int accept(InetAddress* peeraddr);
    void shutdownWrite();
    void setReuseAddr(bool on);
    void setNonBlock();
};

Socket 不负责事件监听,也不负责业务处理。

它只负责把系统调用封装成更清晰的 C++ 接口。


Acceptor:只负责接收新连接

Acceptor 是对监听套接字的进一步封装。

它内部通常包含:

text 复制代码
Socket listenSocket
Channel acceptChannel
NewConnectionCallback

其中 listenSocket 负责监听,acceptChannel 负责将监听 fd 注册到 Main Reactor 中。

当 listenfd 上有读事件就绪时,说明有新连接到来。

此时 Channel 会触发 Acceptor 的读回调:

cpp 复制代码
void Acceptor::handleRead()
{
    InetAddress peerAddr;
    int connfd = _listenSocket.accept(&peerAddr);

    if (connfd >= 0)
    {
        if (_newConnectionCallback)
        {
            _newConnectionCallback(connfd, peerAddr);
        }
    }
}

需要注意的是,Acceptor 只负责接收新连接。

它不负责创建完整的 TcpConnection,也不负责决定这个连接交给哪个线程。

这些工作应该交给 TcpServer。

也就是说:

text 复制代码
Acceptor:负责 accept

TcpServer:负责连接管理和线程分发

这也是网络库中非常重要的职责边界。


Buffer:用户态缓冲区设计

在非阻塞 I/O 中,不能假设一次 read 就能读到完整请求,也不能假设一次 send 就能把所有数据发送出去。

因此,网络库必须为每个连接维护输入缓冲区和输出缓冲区。

在 ReactorNet 中,Buffer 的职责是:

text 复制代码
保存从 socket 读取到的用户态数据
支持应用层按需取出数据
保存暂时无法一次性发送完的数据

Buffer 不应该持有 Socket 指针。

因为 Buffer 只是一个数据缓冲区,它不负责执行 I/O 操作。

真正负责协调 Socket 和 Buffer 的是 TcpConnection。

关系如下:

text 复制代码
Socket 负责 fd 读写

Buffer 负责用户态数据缓存

TcpConnection 负责把 Socket 中的数据读入 Buffer
TcpConnection 负责把 Buffer 中的数据写回 Socket

简化后的 Buffer 可以表示为:

cpp 复制代码
class Buffer
{
private:
    std::vector<char> _buffer;
    size_t _readerIndex;
    size_t _writerIndex;

public:
    size_t readableBytes() const;
    size_t writableBytes() const;
    const char* peek() const;

    void retrieve(size_t len);
    void retrieveAll();

    void append(const char* data, size_t len);
    ssize_t readFd(int fd, int* savedErrno);
};

对于一次读事件来说,流程大致为:

text 复制代码
socket 可读
   |
   v
TcpConnection::handleRead()
   |
   v
Buffer::readFd()
   |
   v
数据追加到 inputBuffer
   |
   v
触发 MessageCallback

对于一次写事件来说,流程大致为:

text 复制代码
outputBuffer 中存在未发送数据
   |
   v
fd 可写
   |
   v
TcpConnection::handleWrite()
   |
   v
从 outputBuffer 取数据发送
   |
   v
如果全部发送完成,关闭写事件关注

TcpConnection:一个连接对象的完整抽象

TcpConnection 是整个网络库中最复杂、也最关键的组件之一。

一个 TcpConnection 对象描述一个已经建立好的 TCP 连接。

它内部通常包含:

text 复制代码
所属 EventLoop
连接名称
连接状态
Socket
Channel
输入缓冲区
输出缓冲区
连接建立回调
消息到达回调
写完成回调
关闭回调

简化结构如下:

cpp 复制代码
class TcpConnection : public std::enable_shared_from_this<TcpConnection>
{
private:
    EventLoop* _loop;
    std::string _name;
    State _state;

    std::unique_ptr<Socket> _socket;
    std::unique_ptr<Channel> _channel;

    Buffer _inputBuffer;
    Buffer _outputBuffer;

    ConnectionCallback _connectionCallback;
    MessageCallback _messageCallback;
    WriteCompleteCallback _writeCompleteCallback;
    CloseCallback _closeCallback;
};

TcpConnection 的核心职责可以概括为:

text 复制代码
1. 管理一个连接的状态
2. 处理该连接上的读事件
3. 处理该连接上的写事件
4. 维护输入缓冲区和输出缓冲区
5. 触发上层业务回调
6. 处理连接关闭与资源释放

TcpConnection 的读事件处理

当客户端发送数据后,连接 fd 上会触发读事件。

此时调用链大致如下:

text 复制代码
epoll_wait 返回读事件
      |
      v
EventLoop 遍历 activeChannels
      |
      v
Channel::handleEvent()
      |
      v
TcpConnection::handleRead()
      |
      v
Socket 数据读取到 inputBuffer
      |
      v
触发 messageCallback

对应代码逻辑大致为:

cpp 复制代码
void TcpConnection::handleRead()
{
    int savedErrno = 0;
    ssize_t n = _inputBuffer.readFd(_channel->fd(), &savedErrno);

    if (n > 0)
    {
        _messageCallback(shared_from_this(), &_inputBuffer);
    }
    else if (n == 0)
    {
        handleClose();
    }
    else
    {
        handleError();
    }
}

这里需要注意的是,TcpConnection 并不直接处理具体协议。

它只是把读取到的数据放进 Buffer,然后交给上层回调。

如果上层是 HTTP Server,那么 messageCallback 中就会进行 HTTP 解析。

如果上层是自定义协议,那么也可以在 messageCallback 中完成自定义报文解析。

这就实现了网络库和协议层的解耦。


TcpConnection 的写事件处理

在非阻塞 I/O 中,发送数据不能简单地认为一次 send 一定可以全部发送完成。

如果内核发送缓冲区空间不足,send 可能只发送一部分数据,甚至返回 EAGAIN

因此,TcpConnection 需要维护 outputBuffer。

发送数据时大致分为两种情况:

text 复制代码
1. 如果当前没有积压数据,可以先尝试直接 send
2. 如果没有发送完,剩余数据追加到 outputBuffer,并关注 EPOLLOUT

当 fd 再次可写时,触发写事件:

cpp 复制代码
void TcpConnection::handleWrite()
{
    if (_channel->isWriting())
    {
        ssize_t n = ::write(_channel->fd(),
                            _outputBuffer.peek(),
                            _outputBuffer.readableBytes());

        if (n > 0)
        {
            _outputBuffer.retrieve(n);

            if (_outputBuffer.readableBytes() == 0)
            {
                _channel->disableWriting();

                if (_writeCompleteCallback)
                {
                    _loop->queueInLoop(
                        std::bind(_writeCompleteCallback, shared_from_this())
                    );
                }
            }
        }
    }
}

当 outputBuffer 中的数据全部发送完成之后,就可以取消对写事件的关注。

因为 EPOLLOUT 通常比较容易触发,如果一直关注写事件,很容易导致 epoll 频繁返回可写事件,从而造成无意义的事件唤醒。


runInLoop 与 queueInLoop:跨线程任务投递

在主从 Reactor 模型中,一个非常关键的问题是:

text 复制代码
如果当前线程想让另一个 EventLoop 线程执行某个任务,应该怎么办?

例如,Main Reactor 接收到了一个新连接,但是这个连接最终要交给某个 Sub Reactor 管理。

此时,Main Reactor 不能直接在自己的线程中操作目标 Sub Reactor 的内部结构。

正确做法是:

text 复制代码
把任务投递到目标 EventLoop 的任务队列中
然后唤醒目标 EventLoop
让目标 EventLoop 在线程内执行这个任务

这就是 runInLoopqueueInLoop 的作用。


runInLoop

runInLoop 的语义是:

text 复制代码
如果当前线程就是 EventLoop 所属线程,则直接执行任务;
否则,将任务投递到该 EventLoop 的任务队列中。
cpp 复制代码
void EventLoop::runInLoop(Functor cb)
{
    if (isInLoopThread())
    {
        cb();
    }
    else
    {
        queueInLoop(std::move(cb));
    }
}

queueInLoop

queueInLoop 的语义是:

text 复制代码
无论当前是否在 EventLoop 所属线程,都先将任务加入任务队列。
必要时唤醒 EventLoop。
cpp 复制代码
void EventLoop::queueInLoop(Functor cb)
{
    {
        std::lock_guard<std::mutex> lock(_mutex);
        _pendingFunctors.push_back(std::move(cb));
    }

    if (!isInLoopThread())
    {
        wakeup();
    }
}

这里为什么需要唤醒?

因为目标 EventLoop 线程可能正阻塞在 epoll_wait 中。

如果没有任何 I/O 事件到来,它就无法及时执行任务队列中的任务。

所以我们需要借助 eventfd 来唤醒它。


eventfd:跨线程唤醒机制

eventfd 是 Linux 提供的一种事件通知机制。

在 ReactorNet 中,每个 EventLoop 内部会创建一个 eventfd,并将其封装成一个 Channel 注册到 epoll 中。

当其他线程向该 EventLoop 投递任务时,会向 eventfd 写入数据。

这样一来,EventLoop 所在线程阻塞在 epoll_wait 时,就会因为 eventfd 可读而被唤醒。

整体流程如下:

text 复制代码
线程 A 向线程 B 的 EventLoop 投递任务
      |
      v
任务加入 pendingFunctors
      |
      v
向线程 B 的 eventfd 写入 8 字节数据
      |
      v
线程 B 的 epoll_wait 返回
      |
      v
处理 eventfd 读事件
      |
      v
执行 pendingFunctors 中的任务

eventfd 的意义在于:

text 复制代码
把"跨线程任务通知"也统一纳入 epoll 事件循环

这样 EventLoop 就可以同时处理:

text 复制代码
普通 socket I/O 事件
timerfd 定时事件
eventfd 唤醒事件

这也是 Reactor 模型中非常优雅的一点。


EventLoopThread 与 EventLoopThreadPool:Sub Reactor 线程池

在主从 Reactor 模型中,Main Reactor 通常运行在主线程中,而 Sub Reactor 运行在线程池中。

因此,我们需要对 EventLoop 线程进行封装。

一个 EventLoopThread 通常负责:

text 复制代码
创建一个新线程
在线程函数中创建 EventLoop
启动 EventLoop::loop()
对外返回该线程中的 EventLoop 指针

需要注意的是,EventLoop 对象必须属于它所在的线程。

因此,EventLoopThread 在启动线程后,需要通过互斥锁和条件变量同步,确保 EventLoop 创建完成之后,再返回给外部使用。

大致流程如下:

text 复制代码
主线程调用 startLoop()
      |
      v
创建子线程
      |
      v
子线程中构造 EventLoop
      |
      v
通知主线程 EventLoop 已经创建完成
      |
      v
主线程拿到 EventLoop*
      |
      v
子线程进入 loop()

EventLoopThreadPool 则负责管理多个 EventLoopThread。

它内部通常维护:

text 复制代码
baseLoop
线程数量
EventLoopThread 数组
EventLoop 指针数组
next 下标

当 TcpServer 收到一个新连接时,可以通过轮询策略选择一个 Sub Reactor:

cpp 复制代码
EventLoop* EventLoopThreadPool::getNextLoop()
{
    EventLoop* loop = _baseLoop;

    if (!_loops.empty())
    {
        loop = _loops[_next];
        ++_next;
        if (_next >= _loops.size())
        {
            _next = 0;
        }
    }

    return loop;
}

这样就实现了新连接在多个 Sub Reactor 之间的负载分发。


TcpServer:整合所有模块的服务器入口

TcpServer 是网络库对外使用的核心类。

它将前面提到的模块全部组合起来:

text 复制代码
EventLoop
Acceptor
EventLoopThreadPool
TcpConnection
连接表
各类回调函数

TcpServer 的职责包括:

text 复制代码
1. 创建 Acceptor 并监听新连接
2. 启动 Sub Reactor 线程池
3. 为新连接选择一个 EventLoop
4. 创建 TcpConnection 对象
5. 维护连接表
6. 设置连接回调、消息回调、写完成回调
7. 处理连接关闭和销毁

新连接建立流程如下:

text 复制代码
listenfd 可读
      |
      v
Acceptor::handleRead()
      |
      v
accept 得到 connfd
      |
      v
TcpServer::newConnection()
      |
      v
选择一个 Sub Reactor
      |
      v
创建 TcpConnection
      |
      v
保存到连接表
      |
      v
将连接建立任务投递到目标 EventLoop
      |
      v
目标 EventLoop 注册 connfd 对应的 Channel

简化代码如下:

cpp 复制代码
void TcpServer::newConnection(int sockfd, const InetAddress& peerAddr)
{
    EventLoop* ioLoop = _threadPool->getNextLoop();

    std::string connName = _name + "#" + std::to_string(_nextConnId++);

    TcpConnectionPtr conn(new TcpConnection(ioLoop, connName, sockfd));

    _connections[connName] = conn;

    conn->setConnectionCallback(_connectionCallback);
    conn->setMessageCallback(_messageCallback);
    conn->setWriteCompleteCallback(_writeCompleteCallback);
    conn->setCloseCallback(
        std::bind(&TcpServer::removeConnection, this, std::placeholders::_1)
    );

    ioLoop->runInLoop(
        std::bind(&TcpConnection::connectEstablished, conn)
    );
}

这里最关键的是:

text 复制代码
TcpConnection 的初始化与 Channel 注册必须在所属 EventLoop 线程中完成。

这可以避免跨线程直接修改 Sub Reactor 的 epoll 结构。


连接关闭与生命周期管理

连接关闭是网络库中非常容易出错的一部分。

因为连接关闭并不只是简单地调用 close(fd)

它涉及:

text 复制代码
从 epoll 中移除 Channel
关闭 socket
从 TcpServer 连接表中删除 TcpConnection
确保事件处理过程中连接对象不会提前析构
确保销毁逻辑在所属 EventLoop 线程中执行

所以,在 TcpConnection 中通常需要使用 shared_from_this() 延长对象生命周期。

例如在读事件回调中:

cpp 复制代码
_messageCallback(shared_from_this(), &_inputBuffer);

这样可以确保业务回调执行期间,当前连接对象不会被提前释放。

关闭流程大致如下:

text 复制代码
客户端关闭连接 / 服务器主动关闭 / 发生错误
      |
      v
TcpConnection::handleClose()
      |
      v
关闭 Channel 关注事件
      |
      v
触发 closeCallback
      |
      v
TcpServer::removeConnection()
      |
      v
从连接表中删除
      |
      v
投递 connectDestroyed 到所属 EventLoop
      |
      v
移除 Channel
      |
      v
连接对象最终释放

这个流程的核心是:

text 复制代码
TcpServer 负责连接表
TcpConnection 负责连接状态
EventLoop 负责在线程内执行销毁动作

职责边界清晰之后,连接生命周期就不会混乱。


HTTP 层:基于 TcpServer 实现 HttpServer

有了 TcpServer 之后,HTTP Server 就可以作为上层协议模块实现。

HTTP 层主要包括:

text 复制代码
HttpServer
HttpRequest
HttpResponse
HttpContext

其中:

text 复制代码
HttpRequest:描述 HTTP 请求
HttpResponse:描述 HTTP 响应
HttpContext:保存 HTTP 解析上下文
HttpServer:组合 TcpServer,对外提供 HTTP 服务

TcpServer 只负责网络收发,而 HttpServer 负责设置 messageCallback。

当 TcpConnection 读到数据后,会触发 HttpServer 的消息处理函数:

text 复制代码
TcpConnection::handleRead()
      |
      v
messageCallback
      |
      v
HttpServer::onMessage()
      |
      v
HttpContext 解析 Buffer 中的数据
      |
      v
得到完整 HttpRequest
      |
      v
业务回调生成 HttpResponse
      |
      v
TcpConnection::send()

HttpContext:为什么需要 HTTP 解析上下文?

HTTP 请求并不一定一次性完整到达。

例如一个 HTTP 请求可能被拆成多次 TCP 报文到达:

text 复制代码
第一次读到:
GET /index

第二次读到:
.html HTTP/1.1\r\nHost: xxx\r\n\r\n

如果没有上下文,就无法知道当前解析到了哪一步。

所以,HttpContext 需要保存解析状态。

常见状态包括:

text 复制代码
解析请求行
解析请求头
解析请求体
解析完成
解析错误

可以表示为:

cpp 复制代码
enum HttpRequestParseState
{
    kExpectRequestLine,
    kExpectHeaders,
    kExpectBody,
    kGotAll
};

解析流程如下:

text 复制代码
Buffer 中存在数据
      |
      v
解析请求行
      |
      v
解析请求头
      |
      v
根据 Content-Length 判断是否需要请求体
      |
      v
解析请求体
      |
      v
得到完整 HttpRequest

HttpContext 的意义在于:

text 复制代码
让每个连接都拥有独立的 HTTP 解析状态

因为不同连接的数据到达进度是不一样的。

有的连接可能已经收到了完整请求,有的连接可能只收到了一半请求行。

所以 HTTP 上下文必须跟随 TcpConnection 保存,而不能作为全局变量。


HttpRequest:描述请求报文

HttpRequest 主要保存客户端请求中的关键信息:

text 复制代码
请求方法
请求路径
HTTP 版本
请求头
请求体
查询参数

简化结构如下:

cpp 复制代码
class HttpRequest
{
private:
    std::string _method;
    std::string _path;
    std::string _version;
    std::unordered_map<std::string, std::string> _headers;
    std::string _body;

public:
    void setMethod(const std::string& method);
    void setPath(const std::string& path);
    void setVersion(const std::string& version);

    void addHeader(const std::string& key, const std::string& value);
    std::string getHeader(const std::string& key) const;

    const std::string& method() const;
    const std::string& path() const;
    const std::string& body() const;
};

请求行示例:

http 复制代码
GET /index.html HTTP/1.1

解析后可以得到:

text 复制代码
method  = GET
path    = /index.html
version = HTTP/1.1

HttpResponse:构造响应报文

HttpResponse 负责构造服务端返回给客户端的 HTTP 响应。

它通常包含:

text 复制代码
状态码
状态描述
响应头
响应体
是否关闭连接

一个 HTTP 响应大致如下:

http 复制代码
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Connection: keep-alive

<html>...</html>

HttpResponse 最终需要序列化到 Buffer 中,然后通过 TcpConnection 发送出去:

cpp 复制代码
void HttpResponse::appendToBuffer(Buffer* output) const
{
    output->append("HTTP/1.1 " + std::to_string(_statusCode) + " " + _statusMessage + "\r\n");

    for (const auto& header : _headers)
    {
        output->append(header.first + ": " + header.second + "\r\n");
    }

    output->append("\r\n");
    output->append(_body);
}

这样,HTTP 层只需要负责构造响应内容,而真正的网络发送仍然交给 TcpConnection。


静态资源访问与 LRU Cache

对于 HTTP Server 来说,GET 请求最常见的场景就是访问静态资源。

例如:

text 复制代码
/index.html
/css/style.css
/js/main.js
/image/logo.png

如果每次请求都直接从磁盘读取文件,会产生较多 I/O 开销。

因此,可以在 HTTP 层增加 LRU Cache,用于缓存热点静态资源。

LRU 的核心思想是:

text 复制代码
最近访问的数据保留
长时间未访问的数据淘汰

对于静态资源来说:

text 复制代码
第一次访问 index.html:
    从磁盘读取,并放入缓存

后续再次访问 index.html:
    直接从 LRU Cache 中获取

整体流程如下:

text 复制代码
HTTP GET 请求
      |
      v
根据 URL 找到本地资源路径
      |
      v
检查 LRU Cache
      |
      +-- 命中:直接构造响应
      |
      +-- 未命中:从磁盘读取文件,并加入缓存

LRU Cache 可以使用:

text 复制代码
哈希表 + 双向链表

其中:

text 复制代码
哈希表:快速定位缓存节点
双向链表:维护访问顺序

定时器:慢连接与空闲连接清理

在网络服务器中,不能只考虑正常客户端。

还要考虑一些异常场景,例如:

text 复制代码
客户端建立连接后长期不发送数据
客户端只发送一半请求,然后一直不继续发送
客户端读取响应非常慢
大量空闲连接占用服务器资源

这类问题如果不处理,就可能导致服务器连接资源被长期占用。

因此,ReactorNet 中需要引入定时器机制,用于处理连接超时。

在 Linux 中,定时器可以通过 timerfd 统一纳入 epoll 事件循环。

整体思路如下:

text 复制代码
timerfd 到期
      |
      v
EventLoop 被唤醒
      |
      v
执行定时任务
      |
      v
检查连接是否超时
      |
      v
关闭长期空闲或异常连接

如果使用时间轮,则可以将连接按照过期时间挂载到不同槽位中。

每次 timerfd 触发时,时间轮向前推进一个槽位,并处理该槽位中的超时任务。

时间轮的意义在于:

text 复制代码
避免每次都遍历所有连接

对于大量连接的服务器来说,这比简单全量扫描更适合高并发场景。


日志系统:还原多线程网络程序的执行现场

在多线程网络程序中,调试难度远高于普通单线程程序。

因为多个 Reactor 线程会并发处理不同连接的事件,执行流是交错的。

很多问题并不是稳定复现的,而是和时序有关。

例如:

text 复制代码
某个连接什么时候建立?
被分配到了哪个 EventLoop?
什么时候触发读事件?
读到了多少字节?
HTTP 是否解析成功?
响应是否发送完成?
连接为什么关闭?

这些问题单靠断点调试很难还原。

因此,日志对于网络库非常重要。

在 ReactorNet 中,日志系统主要用于记录:

text 复制代码
服务器启动
监听地址和端口
新连接建立
连接分发到哪个 EventLoop
读写事件触发
HTTP 请求解析状态
响应发送
连接关闭
错误信息
守护进程启动状态

日志的价值并不只是"打印信息"。

更重要的是:

text 复制代码
在多线程和网络场景中还原真实执行流

也就是记录案发现场。


守护进程:让服务器在后台长期运行

对于一个服务端程序来说,最终通常不是在终端中前台运行,而是作为后台服务长期运行。

因此,可以为 TcpServer 或启动入口增加守护进程支持。

守护进程的基本流程通常包括:

text 复制代码
fork 创建子进程
父进程退出
子进程调用 setsid 脱离原会话
忽略或处理相关信号
重定向标准输入输出错误
设置工作目录或使用绝对路径
设置 umask

其中比较关键的是 setsid()

它的作用是让当前进程创建新的会话,并脱离原来的控制终端。

也就是说,服务器进程不再依赖当前终端窗口。

如果终端关闭,服务器仍然可以继续运行。

守护进程流程可以表示为:

text 复制代码
启动服务器
      |
      v
fork()
      |
      +-- 父进程退出
      |
      v
子进程 setsid()
      |
      v
重定向标准输入输出
      |
      v
启动 TcpServer
      |
      v
EventLoop::loop()

需要注意的是,如果程序变成守护进程后修改了工作目录,那么相对路径可能会失效。

因此,在处理日志路径、静态资源路径、配置路径时,最好使用绝对路径,或者在程序启动阶段统一转换路径。


ReactorNet 一次请求的完整处理流程

接下来,我们把前面的模块串起来,看一次 HTTP 请求在 ReactorNet 中的完整处理路径。


1. 服务器启动

text 复制代码
main()
   |
   v
创建 EventLoop baseLoop
   |
   v
创建 HttpServer / TcpServer
   |
   v
设置线程数
   |
   v
设置 HTTP 请求处理回调
   |
   v
启动 TcpServer
   |
   v
baseLoop.loop()

2. 新连接建立

text 复制代码
客户端发起连接
      |
      v
Main Reactor 中 listenfd 可读
      |
      v
Acceptor::handleRead()
      |
      v
accept 得到 connfd
      |
      v
TcpServer::newConnection()
      |
      v
选择一个 Sub Reactor
      |
      v
创建 TcpConnection
      |
      v
保存连接表
      |
      v
投递 connectEstablished 到目标 EventLoop
      |
      v
目标 EventLoop 注册连接 Channel

3. 请求数据到达

text 复制代码
客户端发送 HTTP 请求
      |
      v
connfd 读事件就绪
      |
      v
Sub Reactor epoll_wait 返回
      |
      v
Channel::handleEvent()
      |
      v
TcpConnection::handleRead()
      |
      v
数据读入 inputBuffer
      |
      v
触发 HttpServer::onMessage()

4. HTTP 解析与响应构造

text 复制代码
HttpContext 从 Buffer 中解析请求
      |
      v
解析请求行
      |
      v
解析请求头
      |
      v
解析请求体
      |
      v
得到完整 HttpRequest
      |
      v
根据 method + path 处理业务
      |
      v
构造 HttpResponse

5. 响应发送

text 复制代码
HttpResponse 序列化到 Buffer
      |
      v
TcpConnection::send()
      |
      v
尝试直接 write
      |
      +-- 全部发送完成:触发写完成回调
      |
      +-- 未发送完成:剩余数据放入 outputBuffer,并关注 EPOLLOUT

6. 连接关闭

text 复制代码
客户端关闭 / 服务端关闭 / 超时 / 错误
      |
      v
TcpConnection::handleClose()
      |
      v
触发 closeCallback
      |
      v
TcpServer 从连接表中移除连接
      |
      v
所属 EventLoop 移除 Channel
      |
      v
释放 TcpConnection

与此前 epoll HTTP Server 的区别

此前实现的 epoll HTTP Server 更偏向于:

text 复制代码
围绕 HTTP 服务本身实现一个可运行服务器

而 ReactorNet 更偏向于:

text 复制代码
先实现一个通用网络库,再基于网络库实现 HTTP Server

两者的区别可以总结为:

对比点 epoll HTTP Server ReactorNet
核心目标 实现 HTTP 服务器 实现 C++ 网络库 + HTTP Server
线程模型 epoll + 线程池 Main Reactor + Sub Reactor
连接归属 相对弱 one-loop-per-thread
模块拆分 偏应用层 网络层与协议层解耦
事件抽象 直接操作 epoll 事件较多 Channel 封装 fd + 事件 + 回调
连接对象 可运行即可 TcpConnection 管理完整生命周期
跨线程任务 线程池任务分发 runInLoop / queueInLoop + eventfd
HTTP 层 和服务器耦合较多 基于 TcpServer 上层封装
工程化 基础服务器 日志、定时器、守护进程、缓存等模块更完整

从学习路径上看,此前的 epoll HTTP Server 更像是第一阶段:

text 复制代码
理解 epoll + 非阻塞 I/O + HTTP 服务器基本流程

而 ReactorNet 则是第二阶段:

text 复制代码
理解高性能网络库的分层设计和 Reactor 工程化实现

项目目录结构

ReactorNet 的目录结构可以设计为:

text 复制代码
ReactorNet
├── include
│   └── reactornet
│       ├── http
│       │   ├── HttpContext.h
│       │   ├── HttpRequest.h
│       │   ├── HttpResponse.h
│       │   └── HttpServer.h
│       ├── net
│       │   ├── reactor
│       │   │   ├── Channel.h
│       │   │   ├── Epoller.h
│       │   │   └── EventLoop.h
│       │   ├── server
│       │   │   └── TcpServer.h
│       │   ├── tcp
│       │   │   ├── Acceptor.h
│       │   │   ├── Buffer.h
│       │   │   ├── Socket.h
│       │   │   └── TcpConnection.h
│       │   └── timer
│       │       └── TimerWheel.h
│       ├── log
│       │   └── Logger.h
│       └── daemon
│           └── Daemon.h
├── src
│   ├── http
│   ├── net
│   ├── log
│   └── daemon
├── examples
├── tests
├── CMakeLists.txt
└── README.md

这种目录结构的好处是:

text 复制代码
网络库核心模块集中在 net 中
HTTP 协议层独立放在 http 中
日志、守护进程等工程化模块独立管理
examples 用于展示如何使用网络库
tests 用于后续模块测试

wrk 压测方式

服务器启动后,可以使用 wrk 对 HTTP Server 进行压力测试。

例如:

bash 复制代码
wrk -t4 -c1000 -d30s http://服务器IP:端口/

参数含义如下:

text 复制代码
-t4      使用 4 个压测线程
-c1000   保持 1000 个并发连接
-d30s    持续压测 30 秒

压测时需要关注:

text 复制代码
Requests/sec
Latency
Transfer/sec
Socket errors
Non-2xx or 3xx responses

其中,Requests/sec 可以反映吞吐能力,Latency 可以反映请求响应延迟。

不过,压测结果不仅取决于服务器代码,还会受到机器配置、网络环境、静态资源大小、线程数量、日志开销、系统参数等因素影响。

因此,压测的核心意义并不是单纯追求一个数字,而是通过测试观察服务器在高并发场景下是否稳定,以及瓶颈主要出现在什么位置。


项目总结

ReactorNet 的核心并不是简单地调用 epoll 写一个服务器,而是围绕 Reactor 模型重新组织整个网络库的结构。

从整体上看,这个项目主要完成了以下工作:

text 复制代码
1. 基于 epoll ET 和非阻塞 I/O 实现事件驱动网络模型
2. 使用 Main Reactor + Sub Reactor 拆分监听连接和已连接 fd 的 I/O 处理
3. 封装 EventLoop、Channel、Epoller、Socket、Acceptor 等网络库基础组件
4. 使用 TcpConnection 管理连接状态、输入输出缓冲区和连接生命周期
5. 使用 TcpServer 统一管理连接表、线程池和回调注册
6. 使用 EventLoopThreadPool 实现 one-loop-per-thread 的 Sub Reactor 线程池
7. 使用 runInLoop / queueInLoop + eventfd 实现跨线程任务投递与唤醒
8. 基于 Buffer 处理非阻塞 I/O 下的半包、粘包和未完成写入问题
9. 在网络库之上实现 HTTP Server,包括请求解析、响应构造和静态资源访问
10. 引入 LRU Cache 缓存热点静态资源
11. 引入 timerfd / 时间轮处理空闲连接和慢请求
12. 增加日志系统用于记录多线程网络程序的执行流
13. 支持守护进程模式,使服务器可以后台长期运行

通过这个项目,可以更加清晰地理解一个高性能 C++ 网络库的核心设计思想:

text 复制代码
不要让所有模块混在一起,而是要让每一层只做自己该做的事。

EventLoop 只负责事件循环。

Epoller 只负责 epoll 系统调用。

Channel 只负责 fd 事件和回调。

Socket 只负责套接字资源管理。

Acceptor 只负责接收新连接。

TcpConnection 只负责一个连接的完整生命周期。

TcpServer 只负责服务器层面的连接管理和线程分发。

HttpServer 只负责 HTTP 协议层逻辑。

当这些模块职责清晰之后,整个服务器的结构才会真正稳定下来。


结语

在学习网络编程的过程中,最开始可能只关注:

text 复制代码
socket 怎么创建?
epoll 怎么用?
HTTP 响应怎么返回?

但是当真正开始设计一个网络库时,关注点就会逐渐变化为:

text 复制代码
事件如何抽象?
连接如何管理?
线程如何归属?
任务如何跨线程投递?
缓冲区如何处理半包和未完成写?
连接生命周期如何保证安全?
协议层如何和网络层解耦?

这也是从"会写一个服务器"到"理解网络库设计"的关键一步。

ReactorNet 项目正是围绕这些问题展开的。

它不是简单地堆功能,而是通过主从 Reactor、one-loop-per-thread、Channel 回调分发、TcpConnection 生命周期管理、Buffer 缓冲区、HTTP 协议层、定时器、日志和守护进程等模块,将一个高性能 C++ 网络服务器逐步拆解成清晰、可复用、可扩展的网络库框架。

到这里,基于主从 Reactor 的 C++ 网络库与 HTTP Server 的整体设计就基本梳理完成了。

后续如果继续扩展,还可以在这个网络库之上支持:

text 复制代码
RPC 框架
WebSocket
异步日志
协程调度
更完善的路由系统
配置文件加载
更细粒度的性能统计

而这些扩展的基础,正是本文实现的 ReactorNet 网络库框架。

结语

那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!

相关推荐
什巳1 小时前
JAVA练习275-乘积最大子数组
java·开发语言·数据结构·算法
lang201509281 小时前
Apache POI Word(docx) 实战教程:从入门到精通表格合并、图片插入与文档合并
java·开发语言·word
HackTwoHub1 小时前
某单位 CMS 完整渗透链、SQL 注入→后台拿权→绕过宝塔 Waf 上线 shell
数据库·人工智能·sql·安全·网络安全·系统安全·安全架构
Robot_Nav1 小时前
C++ 面试常问问题汇总:基础语法、面向对象、STL、多线程与设计模式
c++·设计模式·面试
Mr.HeBoYan1 小时前
DPDK为什么越来越少使用rte_ring?——从Lock-Free到Run-to-Completion,重新理解现代DPDK架构演进(上)
linux·网络·算法·性能优化·架构·dpdk
Elastic 中国社区官方博客1 小时前
使用 Elasticsearch 作为 Grafana 的直接替代 Prometheus 后端
大数据·数据库·sql·elasticsearch·搜索引擎·grafana·prometheus
杨婷1232 小时前
2026 企业微信会话存档服务商选型指南:一维助手方案深度解析
大数据·数据库·企业微信
盐焗鹌鹑蛋2 小时前
【C++】二叉搜索树
c++
哎呦,帅小伙哦2 小时前
C++工程实战: 预处理宏、宏重定义避坑、LOG_TAG业务实践与__COUNTER__深度解析
c++