基于epoll的单线程Reactor:Tinyredis的网络层实现

https://github.com/zthinedge/TinyRedis/

1. 为什么 TinyRedis 选择单线程 Reactor

TinyRedis 是一个基于 C++17 实现的 仿Redis 项目。对这类内存型服务来说,网络层既要承担连接管理,也要把协议解析、命令执行和响应发送稳定地串起来

在项目当前阶段,我选择的是基于 epoll 的单线程 Reactor,而不是一开始就上多线程网络模型,原因主要有三点。

第一,当前目标不是把架构做复杂,而是先把主链路做对。

相比多线程 Reactor 或线程池模型,单线程事件循环更容易保证模块边界清晰,也更方便我验证请求在系统中的完整流动过程。

第二,单线程天然避开了共享状态带来的锁竞争。

TinyRedis 当前的核心路径是 net -> protocol -> command -> storage,把这条链路放在一个线程里顺序执行,能够减少并发同步成本,也更符合 Redis 这类系统在早期版本中的演进思路。

第三,epoll 很适合高并发下的大量连接管理。

它不需要像 select 那样做线性扫描,事件驱动的方式也和 Reactor 模型天然契合。

至于为什么当前采用 LT 而不是 ET,核心原因是项目当前优先级是正确性和可调试性。
LT 模式语义更直观,只要缓冲区里还有数据,内核就会继续通知;它可能带来更多事件唤醒,但实现容错更高,更适合项目前期把网络链路先跑通。

2. TinyRedis 网络层的整体流程

TinyRedis 的网络层核心类是 EpollServer。它同时负责:

  • 初始化监听 socket
  • 创建并维护 epoll 实例
  • 管理客户端连接状态
  • 驱动 RESP 协议解析
  • 把请求转交给命令层
  • 在可写事件到来时回写响应

整个请求链路可以概括为:

text 复制代码
Client
  |
  v
listenFd / clientFd
  |
  v
epoll_wait
  |
  +--> acceptClients()        // 新连接接入
  +--> handleClientRead()     // 读取请求
  |      -> RESPParser
  |      -> CommandParser
  |      -> CommandDispatcher
  |      -> writeBuf
  |
  +--> handleClientWrite()    // 回写响应
         -> send()

如果把它放进 TinyRedis 的整体架构里看,请求会继续沿着下面这条主线流动:

text 复制代码
Client
-> EpollServer
-> RESPParser
-> CommandParser
-> CommandDispatcher
-> InMemoryDB
-> RESPEncoder
-> ClientSession::writeBuf
-> send()

这个设计的好处是职责分层非常明确:

  • net 层只负责连接、事件和收发
  • protocol 层只负责 RESP 编解码
  • command 层只负责参数校验和命令分发
  • storage 层只负责真正的数据读写

从工程角度看,这比把所有逻辑塞进一个 socket 循环里更容易维护,也更适合后续继续扩展 AOF、复制和更多数据结构。

3. 核心实现分析:连接、读事件、写事件

3.1 新连接是怎么接入的

服务启动时,EpollServer::init() 会依次完成三件事:

  1. 初始化监听 socket
  2. 创建 epoll 实例
  3. listenFd 注册到 epoll

监听 fd 只关注 EPOLLIN,因为它只承担一件事: 接受新连接。

epoll_wait 返回的事件来源于 listenFd 时,TinyRedis 会进入 acceptClients()

这个函数会循环执行 accept,直到返回 EAGAINEWOULDBLOCK,说明当前这一轮已经把内核 accept 队列里的连接取干净了。

新连接建立后,TinyRedis 会做两件关键的事情:

  • 把 client fd 设置为非阻塞
  • 为这个连接创建一个 ClientSession

ClientSession 当前很轻,但非常关键:

cpp 复制代码
struct ClientSession {
    RESPParser parser;
    std::string writeBuf;
    bool closeAfterWrite = false;
    bool replica = false;
};

它说明 TinyRedis 对每个连接保存的,不只是一个 fd,还包括这个连接当前的处理进度。

比如,这个连接上次读到的数据有没有形成半包、还有没有没发完的响应、是不是需要延时关闭,都会放在 ClientSession 里统一管理。 这正是 Reactor 模型里"连接状态对象"的雏形。

3.2 读事件如何驱动整个命令执行链路

当某个 client fd 上出现 EPOLLIN 事件时,事件循环会调用 handleClientRead(fd)

这个函数本质上做了四件事:

  1. 循环 recv 读取 socket 数据
  2. 把字节流喂给 RESPParser
  3. 尝试从缓冲区中连续解析出完整请求
  4. 把解析结果交给命令层执行,并把响应写入 writeBuf

它的主逻辑可以抽象成下面这样:

cpp 复制代码
for (;;) {
    n = recv(fd, buf, ...);
    if (n <= 0) { ... }

    session.parser.feed(buf, n);

    for (;;) {
        RESPObject obj;
        if (!session.parser.parse(obj)) {
            break;
        }

        CommandParser::toArgv(obj, argv, err);
        reply = dispatcher_.dispatch(argv);
        session.writeBuf += reply;
    }
}

这里最值得分析的点,不是"调用了哪些函数",而是它解决了什么问题。

第一,它天然支持半包和粘包。

TCP 是字节流协议,应用层不能假设一次 recv 就正好拿到一条完整命令。TinyRedis 通过 RESPParser::feed 持续喂入数据,再通过 parse 尝试提取完整 RESP 对象,因此能够正确处理:

  • 一条命令被拆成多次到达的半包
  • 多条命令一次性到达的粘包

第二,它支持一次读事件中解析多条请求。

这意味着如果客户端一次性发来了多条 RESP 请求,TinyRedis 不会只处理第一条,而是会在一次事件处理中尽可能多地把完整请求都消费掉。

第三,协议层和命令层是解耦的。

网络层拿到的是原始字节流,协议层把它转成 RESPObject,命令层再把它转成 argv 并调用分发器。这样的分层比"边读边手写字符串判断命令"要干净得多。

第四,错误处理也被纳入了事件驱动模型。

如果 RESP 解析抛出异常,TinyRedis 不会直接崩掉,而是会向 writeBuf 里追加一条协议错误响应,并设置 closeAfterWrite = true。也就是说,它会优先把错误返回给客户端,再在写回完成后关闭连接。

这个设计很像真实服务端里的"优雅失败",而不是粗暴断开。

3.3 写事件为什么不能简单理解成 send 一次就结束

很多人刚写网络服务时,容易把"生成响应"直接等同于"发送完成"。

但在非阻塞 socket 下,这两者完全不是一回事。

TinyRedis 的做法是把响应先追加到 session.writeBuf,等 fd 上出现 EPOLLOUT 事件后,再由 handleClientWrite(fd) 负责真正发送。

它的逻辑可以概括为:

cpp 复制代码
while (!session.writeBuf.empty()) {
    n = send(fd, session.writeBuf.data(), session.writeBuf.size(), 0);
    if (n < 0 && errno == EAGAIN) {
        break;
    }
    session.writeBuf.erase(0, n);
}

if (session.writeBuf.empty()) {
    updateClientEvents(fd, false);
}

这里有三个关键点。

第一,非阻塞写可能写不完。

如果内核发送缓冲区满了,send 可能只发出一部分数据,甚至直接返回 EAGAIN。所以服务端不能假设"一次 send 就能清空所有响应"。

第二,writeBuf 是应用层输出缓冲区。

它保证了未发送完的数据不会丢。只要 writeBuf 还没空,这个连接就应该继续关注 EPOLLOUT,等待下一次可写机会。

第三,发送完成后要取消对 EPOLLOUT 的关注。

如果响应已经写空,但还持续监听可写事件,那么 epoll 很可能会不断返回这个 fd,造成无意义的空转。TinyRedis 在 writeBuf 清空后把事件重新改回 EPOLLIN | EPOLLRDHUP,这是一个很典型也很必要的优化。

4. 这套实现解决了什么问题,还存在哪些局限

4.1 它解决了什么问题

站在 TinyRedis 当前阶段看,这套单线程 Reactor 已经很好地解决了几个核心问题。

首先,它把网络事件、协议解析和命令执行稳定地串了起来。

现在 TinyRedis 已经不是一个只能收发字符串的小 demo,而是一条完整的请求处理链路。

其次,它保证了模块边界清晰。

网络层不直接关心 SET/GET 的语义,命令层也不直接碰 socket 细节。这样的分层让后续演进 AOF、复制和更多数据结构时,改动范围更可控。

再次,它对当前项目复杂度非常合适。

单线程模型避免了锁和线程同步问题,也降低了调试门槛。对一个正在持续迭代中的内核型项目来说,这种"先做对,再做快"的策略是合理的。

4.2 它还存在哪些局限

当然,这套实现并不意味着没有代价。

第一,单线程吞吐一定有上限。

随着连接数、命令复杂度和响应体积增加,单线程 Reactor 最终会成为瓶颈。

第二,当前输出缓冲区基于 std::string

这种实现简单直接,但面对大响应或高频写入时,可能会带来额外的内存拷贝和扩容成本。

第三,当前使用的是 LT 模式。

它实现简单,但在高负载场景下可能产生更多重复通知;如果未来追求更高性能,可以考虑进一步评估 ET 模式。

第四,当前还是单 Reactor 结构。

它还没有演进到主从 Reactor、多 IO 线程或者网络线程 + 工作线程池模型,因此在极端并发下扩展能力有限。

所以从工程视角看,TinyRedis 当前的网络层更适合被理解为"一个正确、清晰、可扩展的第一版实现",而不是"最终性能形态"。

6. 总结

TinyRedis 当前的网络层,本质上是一个基于 epoll 的单线程 Reactor。

它用一个事件循环把连接接入、请求读取、RESP 解析、命令执行和响应发送串成了一条完整主链路。

这套实现的价值,不只是"能跑起来",而是它已经具备了比较清晰的工程骨架:

  • 连接有自己的状态对象
  • 协议层和命令层被拆开
  • 读写事件分离
  • 输出缓冲区具备基本的非阻塞发送语义

对 TinyRedis 来说,这意味着后续无论是继续补数据结构、增强持久化,还是演进复制能力,网络层都已经有了一个可以持续扩展的基础版本。

相关推荐
yinbinggang2 小时前
vmware安装虚拟机
c++
小小de风呀3 小时前
de风——【从零开始学C++】(三):类和对象(中序):默认成员函数全解析
开发语言·c++
迷途之人不知返3 小时前
vector的模拟实现
c++
snow@li3 小时前
数据库-Oracle:常用语法 / Oracle 核心知识技能梳理
数据库·redis·缓存
浅念-3 小时前
分治算法专题|LeetCode高频经典题目详细题解
数据结构·c++·算法·leetcode·职场和发展·排序·分治
zhougl9963 小时前
Redis 防止丢数据
java·redis·mybatis
H Journey3 小时前
C++ 性能瓶颈分析与优化
c++·性能优化·gprof·perf·valgrind·瓶颈分析
熬夜敲代码的猫4 小时前
C++继承:让你从入门到深入
c++·算法·继承
txz20354 小时前
2,使用功能包组织C++节点
开发语言·c++·ros