tcp-server 项目实现流程、细节与 muduo 对比分析


一、整体架构概览

1.1 核心设计模式:Reactor + One Loop Per Thread

┌─────────────────────────────────────────────────────────┐
│                      TcpServer                          │
│  ┌──────────┐   ┌──────────────┐   ┌─────────────────┐ │
│  │ Acceptor  │   │ EventLoop    │   │ LoopThreadPool  │ │
│  │ (监听fd)  │   │ (_baseloop)  │   │ (工作线程池)     │ │
│  └────┬─────┘   └──────┬───────┘   └────────┬────────┘ │
│       │                │                     │          │
│       │    新连接fd     │         分配给       │          │
│       ├───────────────►├────────────────────►│          │
│       │                │                     │          │
│  ┌────┴─────┐   ┌──────┴───────┐   ┌────────┴────────┐ │
│  │ Channel  │   │   Poller     │   │  LoopThread[]   │ │
│  │ (事件分发)│   │  (epoll封装) │   │ (每个线程一个    │ │
│  └──────────┘   └──────────────┘   │  EventLoop)     │ │
│                                    └─────────────────┘ │
│  ┌──────────────────────────────────────────────────┐  │
│  │              Connection (连接管理)                 │  │
│  │  Socket + Channel + Buffer(in/out) + Any(context)│  │
│  │  状态机: DISCONNECTED->CONNECTING->CONNECTED->    │  │
│  │         DISCONNECTING                             │  │
│  └──────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────┐  │
│  │         TimerWheel (定时器 -- 时间轮)              │  │
│  │  60个槽位, timerfd驱动, 1秒精度                    │  │
│  │  shared_ptr/weak_ptr 实现刷新与取消                │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

1.2 类清单与职责

类名 行数 职责
Buffer 45-167 应用层读写缓冲区,vector + 读写游标
Socket 170-311 socket fd 的 RAII 封装
Channel 315-370 fd 到回调函数的映射,事件分发器
Poller 372-444 epoll 的封装
TimerTask 449-466 单个定时任务
TimerWheel 468-571 60槽时间轮 + timerfd 驱动
EventLoop 573-685 核心反应器:Poller + TimerWheel + 任务队列 + eventfd
LoopThread 686-717 一个线程持有一个 EventLoop
LoopThreadPool 719-747 管理 N 个 LoopThread,轮询分配
Any 750-800 类型擦除容器(类似 std::any)
Connection 807-1028 TCP 连接的完整生命周期管理
Acceptor 1030-1061 监听套接字管理
TcpServer 1063-1133 顶层服务器,组合所有组件
NetWork 1150-1157 静态初始化,忽略 SIGPIPE

二、实现流程详解(从启动到收发数据)

2.1 服务器启动流程

TcpServer(port)
  │
  ├─ 1. 初始化 _baseloop (主线程 EventLoop)
  │     ├─ 创建 epollfd (epoll_create)
  │     ├─ 创建 eventfd (用于跨线程唤醒)
  │     ├─ 给 eventfd 注册可读回调 (ReadEventfd)
  │     └─ 创建 TimerWheel (创建 timerfd, 注册回调)
  │
  ├─ 2. 初始化 Acceptor(&_baseloop, port)
  │     ├─ Socket::CreateServer(port)
  │     │   ├─ socket() 创建套接字
  │     │   ├─ bind() 绑定地址
  │     │   ├─ listen() 开始监听
  │     │   └─ ReuseAddress() 设置 SO_REUSEADDR + SO_REUSEPORT
  │     ├─ 创建 Channel(loop, listenfd)
  │     └─ 设置 HandleRead 回调 (新连接到达时调用)
  │
  ├─ 3. Acceptor::Listen()
  │     └─ Channel::EnableRead() -> 注册 EPOLLIN 到 epoll
  │
  └─ 4. TcpServer::Start()
        ├─ LoopThreadPool::Create() — 创建 N 个工作线程
        │   ├─ 每个线程内创建 EventLoop (在栈上)
        │   ├─ 用 mutex + condition_variable 同步,确保 EventLoop 创建完毕
        │   └─ 每个线程调用 loop.Start() 进入事件循环
        └─ _baseloop.Start() — 主线程进入事件循环

2.2 新连接到达处理流程

epoll_wait 返回 (listenfd 可读)
  │
  ├─ Acceptor::HandleRead()
  │   ├─ socket.Accept() -> 得到 newfd
  │   └─ 调用 _accept_callback(newfd)
  │
  ├─ TcpServer::NewConnection(fd)
  │   ├─ _next_id++ (生成唯一连接ID)
  │   ├─ _pool.NextLoop() -> 轮询选择一个工作线程的 EventLoop
  │   ├─ 创建 Connection(worker_loop, id, fd)
  │   │   ├─ 设置 Channel 回调: HandleRead/HandleWrite/HandleClose/HandleError/HandleEvent
  │   │   └─ 状态设为 CONNECTING
  │   ├─ 设置用户回调: connected/message/closed/event
  │   ├─ 设置 _server_closed_callback = RemoveConnection
  │   ├─ 如果启用非活跃超时: EnableInactiveRelease(timeout)
  │   └─ conn->Established()
  │       └─ RunInLoop -> EstablishedInLoop()
  │           ├─ 状态 CONNECTING -> CONNECTED
  │           ├─ Channel::EnableRead() (在工作线程的 EventLoop 中注册 EPOLLIN)
  │           └─ 调用 _connected_callback
  │
  └─ _conns[id] = conn (存入连接管理表)

2.3 数据收发流程

接收数据:

工作线程 epoll_wait 返回 (connfd 可读)
  │
  ├─ Channel::HandleEvent() -> _read_callback
  │
  ├─ Connection::HandleRead()
  │   ├─ socket.NonBlockRecv(buf, 65535) 读取数据
  │   ├─ _in_buffer.WriteAndPush(buf, ret) 写入输入缓冲区
  │   └─ _message_callback(shared_from_this(), &_in_buffer) 调用业务回调
  │
  └─ (业务层处理数据,比如 Echo 回显或 HTTP 解析)

发送数据:

业务层调用 conn->Send(data, len)
  │
  ├─ 创建临时 Buffer,拷贝数据 (防止 data 是栈上临时变量)
  ├─ RunInLoop(SendInLoop)
  │   ├─ _out_buffer.WriteBufferAndPush(buf) 追加到发送缓冲区
  │   └─ Channel::EnableWrite() 注册 EPOLLOUT
  │
  ├─ epoll_wait 返回 (connfd 可写)
  │   └─ Connection::HandleWrite()
  │       ├─ socket.NonBlockSend(out_buffer.ReadPosition(), out_buffer.ReadAbleSize())
  │       ├─ _out_buffer.MoveReadOffset(ret) 推进读偏移
  │       └─ 如果发送完毕:
  │           ├─ Channel::DisableWrite() 关闭写事件监控
  │           └─ 如果状态是 DISCONNECTING -> Release()
  │
  └─ (如果一次没发完,下次 EPOLLOUT 继续发)

2.4 连接关闭流程

触发条件: 对端关闭 / 读取返回错误 / 业务层调用 Shutdown()
  │
  ├─ Connection::Shutdown()
  │   └─ RunInLoop -> ShutdownInLoop()
  │       ├─ 状态设为 DISCONNECTING
  │       ├─ 处理剩余输入数据 (调用 _message_callback)
  │       ├─ 如果有剩余输出数据 -> EnableWrite() 等待发送完毕
  │       └─ 如果没有待发送数据 -> Release()
  │
  └─ Connection::Release()
      └─ QueueInLoop -> ReleaseInLoop()
          ├─ 状态设为 DISCONNECTED
          ├─ Channel::Remove() (从 epoll 中移除)
          ├─ socket.Close() (关闭 fd)
          ├─ 取消定时器 (如果有)
          ├─ 调用 _closed_callback (用户回调)
          └─ 调用 _server_closed_callback (TcpServer::RemoveConnection)
              └─ 从 _conns 中移除 -> shared_ptr 引用计数归零 -> 析构

2.5 跨线程通信机制

线程A (任意线程)                    线程B (EventLoop 所在线程)
     │                                    │
     │  RunInLoop(cb)                     │  epoll_wait 阻塞中...
     │  ├─ IsInLoop()? No                 │
     │  ├─ QueueInLoop(cb)                │
     │  │   ├─ mutex 保护下               │
     │  │   │  _tasks.push_back(cb)       │
     │  │   └─ WeakUpEventFd()            │
     │  │       write(eventfd, 1)  ──────►│  eventfd 可读
     │  │                                  │  ├─ ReadEventfd() 消费通知
     │  │                                  │  └─ epoll_wait 返回
     │  │                                  │
     │  │                                  │  RunAllTask()
     │  │                                  │  ├─ swap(_tasks, local)
     │  │                                  │  └─ 执行所有 cb
     │  │                                  │     包括我们刚才提交的 cb

为什么需要 eventfd? 如果 epoll_wait 在阻塞等待事件,另一个线程提交了任务,如果不唤醒,任务要等到下一个事件到来才能执行,造成延迟。eventfd 就是专门用来"叫醒" epoll_wait 的。


三、关键实现细节与设计决策

3.1 Buffer 的设计

内存布局:
+--------+----------------+----------------+
| 空闲区  |   可读数据      |   可写空间     |
+--------+----------------+----------------+
0    _reader_idx       _writer_idx      size

关键操作:
- EnsureWriteSpace: 尾部空间不够时,先尝试把数据搬到前面(空间回收)
  如果前面+后面都不够,才 resize 扩容
- FindCRLF: 用 memchr 查找 '\n',用于 HTTP 按行解析
- 没有使用 readv 分散读(与 muduo 的区别点)

3.2 TimerWheel 的 shared_ptr/weak_ptr 技巧

这是项目中最有技巧性的设计之一:

添加定时器:
  PtrTask pt(new TimerTask(id, delay, cb));
  _wheel[pos].push_back(pt);        // shared_ptr 放入时间轮槽位
  _timers[id] = WeakTask(pt);       // weak_ptr 放入 map

刷新定时器 (延长超时):
  PtrTask pt = _timers[id].lock();  // weak_ptr 提升为 shared_ptr
  int pos = (_tick + delay) % _capacity;
  _wheel[pos].push_back(pt);        // 在新槽位再放一份 shared_ptr

到期清理:
  _wheel[_tick].clear();            // 清空当前槽位的所有 shared_ptr

为什么这样设计?
- 刷新时:旧槽位的 shared_ptr 还在,新槽位也有一份,引用计数 >= 2
- 到期时:旧槽位 clear,如果刷新过(新槽位也有一份),引用计数只减1,对象不销毁
- 如果没刷新过(只有一份),clear 后引用计数归零,TimerTask 析构,执行回调
- 这样就自然实现了"刷新则延期,不刷新则到期执行"的效果

3.3 Connection 的状态机

           Established()           Release()
DISCONNECTED ──────────► CONNECTING ──────────► CONNECTED
                                ▲                    │
                                │    Shutdown()      │
                                │    ┌───────────────┘
                                │    ▼
                                │  DISCONNECTING
                                │    │
                                │    │ 数据发完 / 无数据
                                └────┘
                               Release()

为什么需要 CONNECTING 状态?
- 新连接的 fd 是在主线程 accept 的,但 Connection 要在工作线程中建立
- 从 accept 到工作线程执行 EstablishedInLoop() 之间,就是 CONNECTING 状态
- 防止在这个间隙中收到事件时出现状态混乱

为什么需要 DISCONNECTING 状态?
- Shutdown() 不是立即关闭,而是"优雅关闭"
- 先标记为 DISCONNECTING,把发送缓冲区中的数据发完
- HandleWrite() 中检查: 如果状态是 DISCONNECTING 且数据发完,才 Release()

3.4 Release 为什么用 QueueInLoop 而不是 RunInLoop?

void Release() {
    _loop->QueueInLoop(std::bind(&Connection::ReleaseInLoop, this));
}

关键原因:避免迭代中删除。 HandleEvent 正在遍历 epoll 返回的活跃 Channel 列表。如果此时 RunInLoop 立即执行 ReleaseInLoop(移除 Channel、关闭 fd),会导致当前正在遍历的数据结构被修改,产生未定义行为。QueueInLoop 把释放操作延迟到 RunAllTask 阶段,此时事件处理已经全部完成。

3.5 Send 为什么先拷贝到临时 Buffer?

void Send(const char *data, size_t len) {
    Buffer buf;
    buf.WriteAndPush(data, len);
    _loop->RunInLoop(std::bind(&Connection::SendInLoop, this, std::move(buf)));
}

关键原因:data 可能是栈上临时变量。 调用 Send 后,发送操作被压入任务队列,可能还没执行,data 指向的空间就已被释放。所以必须在调用时就拷贝一份。muduo 也是同样的处理方式。

3.6 为什么忽略 SIGPIPE?

当向一个已经关闭的 socket 写数据时,操作系统会发送 SIGPIPE 信号,默认行为是终止进程。服务器程序绝对不能因为一个连接的异常而崩溃,所以全局忽略它:

static NetWork nw; // 构造函数中 signal(SIGPIPE, SIG_IGN)

3.7 HttpServer 的 fall-through switch

switch(_recv_statu) {
    case RECV_HTTP_LINE: RecvHttpLine(buf);
    case RECV_HTTP_HEAD: RecvHttpHead(buf);
    case RECV_HTTP_BODY: RecvHttpBody(buf);
}

故意不写 break。 解析完请求行后,立即尝试解析头部;解析完头部后,立即尝试解析正文。这样一次 OnMessage 调用就可能完成整个请求的解析,而不是每次只解析一个部分。但要注意:如果 RecvHttpLine 把状态推进到了 RECV_HTTP_HEAD,fall-through 会继续执行 RecvHttpHead;如果 RecvHttpLine 发现数据不足一行(返回 true 但状态没变),fall-through 到 RecvHttpHead 时第一个 if 就会因为状态不匹配而跳过。


四、与 muduo 源码的详细对比

4.1 架构层面

对比项 muduo tcp-server
代码组织 多文件,base/ 和 net/ 分离,头文件/源文件分离 单文件 server.hpp(1158行),header-only
代码量 ~10000 行(net 部分) ~1158 行
编译标准 C++03 + Boost(后迁移到 C++11) C++11(直接用 std::thread/mutex/condition_variable)
依赖 Boost.Any, Boost 部分组件 无外部依赖
平台 Linux(epoll/poll),可移植性通过 Poller 抽象 Linux only(直接用 epoll)
命名空间 muduo::net 无命名空间
noncopyable 标记 所有资源类继承 noncopyable 未使用

4.2 核心组件对比

4.2.1 EventLoop
对比项 muduo tcp-server
线程绑定检查 __thread TLS 变量 t_loopInThisThread std::thread::id 成员变量比较
退出机制 quit_ 标志位,支持优雅退出 while(1) 无退出机制
任务队列交换 swap 模式(最小化锁持有时间) 相同,使用 swap
wakeup eventfd eventfd
定时器 TimerQueue(基于 set<Timestamp, Timer*> 排序) TimerWheel(60槽时间轮)
doPendingFunctors callingPendingFunctors_ 标志,避免递归唤醒 无此标志

关键差异 - 退出机制: muduo 的 EventLoop::loop() 是 while(!quit_),支持从外部调用 quit() 停止循环。你的实现是 while(1) 永远不退出。面试时可能被问到:“你的服务器如何优雅退出?” —— 这是一个可以改进的点。

关键差异 - callingPendingFunctors 标志: muduo 在执行 pending functors 时会设置 callingPendingFunctors_ = true,这使得 queueInLoop 在被正在执行 functor 的线程调用时也会 wakeup,确保新提交的任务能被及时处理。你的实现中,如果在 RunAllTask 执行过程中有新的 functor 被 QueueInLoop 提交,它会被放入队列但不会唤醒(因为当前线程已经在处理了),但会等到下一轮 loop 才被执行。这在大多数场景下不是问题,但在某些边界情况下可能导致一轮延迟。

4.2.2 Channel
对比项 muduo tcp-server
fd 所有权 不拥有 fd(TcpConnection/Acceptor 拥有) 不拥有 fd
tie 机制 weak_ptr<void> tie_,防止处理事件时对象被析构 无 tie 机制
index 状态 kNew/kAdded/kDeleted 三态 无显式状态(依赖 Poller 的 map 判断)
事件分发顺序 POLLHUP(无POLLIN) -> POLLERR -> POLLIN/POLLPRI/POLLRDHUP -> POLLOUT EPOLLIN/POLLRDHUP/EPOLLPRI -> EPOLLOUT -> EPOLLERR -> EPOLLHUP
EPOLLPRI 有(在 read 判断中)

关键差异 - tie 机制: 这是一个重要的生命周期保护机制。muduo 中 TcpConnection::connectEstablished 调用 channel_->tie(shared_from_this()),这样 Channel 持有 TcpConnection 的 weak_ptr。在 handleEvent 时,先 lock() 获取 shared_ptr,确保在处理事件期间 TcpConnection 不会被析构。你的实现中,Connection 的回调函数用 std::bind 绑定了 this 指针,如果 Connection 在事件处理期间被释放(比如 close 回调中释放了自己),可能导致悬空指针。不过你的实现通过 shared_from_this() 和 Release 使用 QueueInLoop 延迟执行,在一定程度上缓解了这个问题。

面试建议: 如果被问到 Channel 的 tie 机制,解释它是为了解决"事件处理过程中对象被析构"的问题。你可以指出你的实现通过 QueueInLoop 延迟释放来规避这个问题,但 tie 机制更加通用和安全。

4.2.3 Poller
对比项 muduo tcp-server
抽象程度 抽象基类 + EPollPoller/PollPoller 两个实现 直接实现 epoll
epoll 创建 epoll_create1(EPOLL_CLOEXEC) epoll_create(MAX_EPOLLEVENTS)
Channel 存储 map<int, Channel*> unordered_map<int, Channel*>
events 缓冲 vector<epoll_event> 动态扩容 固定大小数组 _evs[1024]
data 字段 event.data.ptr = channel(存指针) event.data.fd = fd(存 fd,再查 map)
Channel 状态跟踪 index 字段(kNew/kAdded/kDeleted) HasChannel 查询 map

关键差异 - data.ptr vs data.fd: muduo 在 epoll_event.data.ptr 中直接存储 Channel 指针,epoll_wait 返回后直接取指针,O(1)。你的实现存 fd,返回后需要在 unordered_map 中查找 Channel*,O(1) 平均但有哈希开销。muduo 的方式更高效。

关键差异 - EPOLL_CLOEXEC: muduo 用 epoll_create1(EPOLL_CLOEXEC),在 fork 时自动关闭 epoll fd,防止子进程继承。你的实现用 epoll_create,没有设置 CLOEXEC。在服务器 fork 守护进程的场景下可能出问题。

4.2.4 Socket
对比项 muduo tcp-server
nonblock 创建 SOCK_NONBLOCK | SOCK_CLOEXEC 先创建普通 socket,再 fcntl 设 O_NONBLOCK
accept accept4()SOCK_NONBLOCK | SOCK_CLOEXEC 普通 accept()
TCP 选项 setTcpNoDelay, setKeepAlive, setReuseAddr, setReusePort 只有 ReuseAddress
半关闭 shutdownWrite() 无半关闭(直接 Close)
连接信息 getTcpInfo / getTcpInfoString
RAII 析构调用 sockets::close 析构调用 close

关键差异 - 没有半关闭: muduo 的 TcpConnection::shutdown 调用 socket_->shutdownWrite(),只关闭写端,对端还能收到 FIN 并发送剩余数据。你的实现中 Shutdown 只是设置状态为 DISCONNECTING,最终 Release 时直接 close(fd),没有半关闭过程。在某些协议场景下(比如 HTTP/1.1 pipeline),半关闭是有意义的。

4.2.5 Buffer
对比项 muduo tcp-server
设计模型 Netty ChannelBuffer,三区域 两区域(读区 + 写区)
prepend 区域 有(kCheapPrepend=8),可前置写入 header
readFd readv + 栈上 64KB extrabuf,避免 FIONREAD 普通 recv 到栈上 65535 字节数组
网络字节序 readInt32/appendInt32 等
内部结构 vector<char> vector<char>

关键差异 - readFd 的 readv 技巧: muduo 的 Buffer::readFd 使用 readv 分散读,除了 buffer 自身的空间,还提供了一个栈上的 64KB extrabuf。如果一次 readv 把数据读入了 extrabuf,说明内核缓冲区还有大量数据,这时再把 extrabuf 的数据 append 到 buffer 中。这比你的实现(固定 recv 65535 字节)更高效,因为:

  1. 不需要 ioctl(FIONREAD) 查询可读数据量
  2. 一次系统调用尽可能读取更多数据
  3. 如果数据量小于 buffer 剩余空间,不需要额外拷贝

面试建议: 这是一个很好的优化点。如果被问到 Buffer 设计,可以提到 readv + extrabuf 的技巧。

4.2.6 定时器
对比项 muduo tcp-server
数据结构 set<pair<Timestamp, Timer*>> 按时间排序 60槽时间轮 vector<vector<shared_ptr<TimerTask>>>
精度 微秒级(Timestamp) 秒级
驱动方式 timerfd timerfd
刷新/取消 TimerId(Timer* + sequence)定位,set 的插入/删除 weak_ptr lock + 重新插入新槽位
重复定时 支持(Timer::restart) 不支持(只支持一次性)
最大延迟 无限制 60秒

关键差异 - 时间轮 vs 有序集合:

  • muduo 用 set(红黑树),按过期时间排序,O(logN) 插入/删除,适合定时器数量大、精度要求高的场景
  • 你的实现用时间轮,O(1) 添加/刷新,但精度只有秒级,最大延迟60秒
  • 时间轮更适合连接超时这类"精度要求不高、数量大、操作频繁"的场景

面试建议: 如果被问到定时器设计,可以对比两种方案的优劣,说明你选择时间轮是因为连接超时场景秒级精度足够,且时间轮的 O(1) 操作在高并发下性能更好。

4.2.7 TcpConnection
对比项 muduo tcp-server
智能指针 enable_shared_from_this enable_shared_from_this
回调类型 ConnectionCallback, MessageCallback, WriteCompleteCallback, HighWaterMarkCallback, CloseCallback ConnectedCallback, MessageCallback, ClosedCallback, AnyEventCallback
高水位回调 有(默认64MB),背压机制
WriteCompleteCallback 有,数据发完时通知
sendInLoop 优先尝试直接 write,写不完再缓冲 总是先放入缓冲区再启用写事件
context boost::any 自定义 Any
协议切换 无显式 Upgrade 接口 Upgrade() 方法,替换回调和 context
连接名 有(用于日志和调试)

关键差异 - sendInLoop 的直接写优化: muduo 的 sendInLoop 会先尝试直接 write(),如果一次写完就不需要启用 EPOLLOUT,减少了 epoll 的事件通知开销。你的实现总是先放入缓冲区再启用写事件,多了一次 epoll 通知。在发送小数据的场景下,muduo 的方式更高效。

关键差异 - 高水位回调: muduo 有 HighWaterMarkCallback,当输出缓冲区超过阈值时触发,用于实现背压(back-pressure)。如果对端读取慢,服务器的输出缓冲区会不断增长,高水位回调可以通知应用层暂停发送。你的实现没有这个机制,在极端情况下可能导致内存无限增长。

4.2.8 Acceptor
对比项 muduo tcp-server
EMFILE 处理 idleFd_ 技巧:预先打开 /dev/null 的 fd 无处理
accept accept4() + SOCK_NONBLOCK 普通 accept()
accept 后获取对端地址 getPeerAddr() 不获取

关键差异 - EMFILE 处理: 这是 muduo 的一个精妙设计。当进程的 fd 数量达到上限时,accept 会失败并返回 EMFILE。如果不处理,listenfd 仍然是可读的,epoll_wait 会立即返回,形成 busy loop(CPU 100%)。muduo 的解决方案:

  1. 预先打开一个 /dev/null 的 fd(idleFd_)
  2. 当 accept 返回 EMFILE 时,关闭 idleFd_,此时进程少了一个 fd
  3. 再次 accept(成功),然后立即 close 这个 fd
  4. 重新打开 /dev/null 恢复 idleFd_

这样 listenfd 从"可读"变为"不可读",epoll_wait 又会阻塞,避免了 busy loop。

面试建议: EMFILE 处理是一个非常加分的点,说明你考虑了边界情况。

4.3 muduo 有但 tcp-server 没有的组件

组件 说明 重要程度
Connector 主动发起连接的组件,带指数退避重试 中(客户端需要)
TcpClient 客户端封装,基于 Connector 中(客户端需要)
InetAddress IPv4/IPv6 地址封装 低(你的实现直接用 sockaddr_in)
Logger 分级日志系统 中(你用宏实现了简化版)
WeakCallback weak_ptr + function,安全回调 低(你的 Release 延迟执行部分替代了这个功能)
PollPoller poll(2) 后端 低(epoll 是 Linux 的主流选择)
Thread POSIX 线程封装 低(C++11 的 std::thread 已够用)
CountDownLatch 线程同步原语 低(用 mutex + condition_variable 实现了等价功能)

五、一些Q&A

5.1 基础问题

Q: 什么是 Reactor 模式?你的实现中哪些是 Reactor?

A: Reactor 模式是一种事件驱动的设计模式。核心思想是:一个线程通过 I/O 多路复用(epoll)监听多个 fd 的事件,事件就绪后分发给对应的处理函数。

在我的实现中:

  • EventLoop 是 Reactor,它持有 Poller(epoll),循环执行:事件监控 -> 事件处理 -> 执行任务
  • Channel 是事件分发器,将 fd 的事件映射到具体的回调函数
  • Poller 是 I/O 多路复用的封装

整体是 Reactor + One Loop Per Thread 模式:主线程的 Reactor 只处理新连接(accept),然后分配给工作线程的 Reactor 处理 I/O。

Q: 为什么用 epoll 而不是 select/poll?

A: select 有 fd 数量限制(FD_SETSIZE=1024),且每次调用都需要拷贝 fd 集合到内核。poll 虽然没有数量限制,但和 select 一样需要遍历所有 fd 检查就绪状态,O(N)。epoll 通过回调机制,只返回就绪的 fd,O(就绪数)。在连接数多但活跃连接少的场景下,epoll 性能远优于 select/poll。

Q: epoll 的水平触发和边缘触发有什么区别?你用的是哪种?

A: 我用的是水平触发(LT),这也是默认模式。

  • LT(Level Triggered):只要 fd 的缓冲区有数据可读/可写空间,每次 epoll_wait 都会返回这个 fd。编程简单,但可能重复通知。
  • ET(Edge Triggered):只在 fd 状态变化时通知一次。必须一次性读完所有数据(循环读直到 EAGAIN),否则不会再次通知。编程复杂但效率更高。

muduo 也用 LT,因为 LT 更安全,不容易丢事件。

5.2 进阶问题

Q: 你的服务器如何处理高并发?one loop per thread 的好处是什么?

A: 核心是 非阻塞 I/O + 事件驱动 + 线程池

  1. 所有 socket 是非阻塞的,read/write 不会阻塞线程
  2. epoll 负责事件通知,一个线程可以管理成千上万个连接
  3. 主线程只负责 accept,工作线程负责 I/O 读写和业务处理
  4. 新连接轮询分配给工作线程(round-robin),实现负载均衡

One Loop Per Thread 的好处:

  • 每个连接的所有 I/O 操作都在同一个线程中完成,不需要对连接状态加锁
  • 避免了多线程竞争同一个连接的问题
  • 线程间通过 eventfd 通信,开销小

Q: 跨线程调用是怎么实现的?为什么需要 eventfd?

A: 通过 RunInLoop / QueueInLoop + eventfd 实现。

如果调用者在 EventLoop 自己的线程中,直接执行回调。否则,把回调放入任务队列(mutex 保护),然后写 eventfd 唤醒目标线程的 epoll_wait。

需要 eventfd 是因为:如果 epoll_wait 在阻塞等待事件,另一个线程提交了任务到队列,如果不唤醒,这个任务要等到下一个 I/O 事件到来才会被执行,造成不必要的延迟。

Q: Connection 为什么用 shared_ptr 管理?enable_shared_from_this 的作用?

A: Connection 的生命周期不确定——它可能在任意时刻被对端关闭,也可能在事件处理过程中被释放。用 shared_ptr 管理可以自动处理生命周期。

enable_shared_from_this 的作用:在回调函数中需要传递 Connection 的指针给业务层,但业务层可能会长期持有这个指针。通过 shared_from_this() 获取一个 shared_ptr,保证在业务层持有期间 Connection 不会被释放。

Q: 定时器是怎么实现的?为什么用时间轮?

A: 定时器基于 时间轮 + timerfd

  1. timerfd 每秒产生一次可读事件(1秒精度)
  2. 时间轮有60个槽位,每秒指针走一步,清空当前槽位
  3. 添加定时器时,把 shared_ptr 放入 (当前tick + delay) % 60 的槽位
  4. 用 weak_ptr 在 map 中保存引用,支持刷新(延长超时)

选择时间轮的原因: 连接超时场景下,定时器数量可能很大(每个连接一个),但精度要求不高(秒级)。时间轮添加/刷新都是 O(1),比有序集合的 O(logN) 更适合。

Q: 怎么处理"定时器刷新"(连接有活动时重置超时)?

A: 利用 shared_ptr/weak_ptr 的特性:

  • 添加定时器时,在时间轮槽位放一份 shared_ptr,在 map 中放 weak_ptr
  • 刷新时,通过 weak_ptr::lock() 获取 shared_ptr,在新槽位再放一份
  • 到期时,旧槽位 clear() 释放 shared_ptr,但新槽位还有一份,所以对象不会销毁
  • 只有没刷新过的定时器,clear() 后引用计数归零,才会执行回调

Q: SIGPIPE 是什么?为什么要忽略?

A: 当向一个已经关闭对端的 socket 写数据时,内核会发送 SIGPIPE 信号给进程,默认行为是终止进程。服务器不能因为一个连接异常就崩溃,所以 signal(SIGPIPE, SIG_IGN) 全局忽略它。写操作会返回 EPIPE 错误码,通过检查返回值来处理。

5.3 与 muduo 对比的问题

Q: 你的实现和 muduo 有什么区别?为什么这样简化?

A: 主要简化了以下方面:

  1. 单文件实现:muduo 分离了 base 和 net,几十个文件。我的实现全部在一个 header 文件中,便于学习和理解
  2. 去掉了 Boost 依赖:muduo 原版用 boost::any,我手写了 Any 类;用 C++11 的 thread/mutex 替代 pthread
  3. 去掉了 Poller 抽象:muduo 支持 epoll 和 poll 两种后端,我直接用 epoll
  4. 简化了定时器:muduo 的 TimerQueue 用 set 红黑树,微秒精度,支持重复定时;我用时间轮,秒级精度,只支持一次性
  5. 简化了地址管理:muduo 有 InetAddress 类支持 IPv4/IPv6,我直接用 sockaddr_in
  6. 增加了协议切换:我加了 Connection::Upgrade() 接口,支持运行时切换协议(如 HTTP -> WebSocket)

Q: muduo 有哪些设计是你的实现缺少的?如果要改进,你会怎么做?

A: 按重要程度排序:

  1. Channel 的 tie 机制 — 防止事件处理过程中对象被析构。改进方案:在 Channel 中加入 weak_ptr tie_,handleEvent 前 lock
  2. Acceptor 的 EMFILE 处理 — 预打开 /dev/null 的 fd,避免 fd 耗尽时 busy loop
  3. Buffer 的 readv 优化 — 用 scatter-gather I/O 减少系统调用
  4. sendInLoop 的直接写优化 — 优先直接 write,减少 EPOLLOUT 事件
  5. 高水位回调(HighWaterMarkCallback) — 背压机制,防止输出缓冲区无限增长
  6. EventLoop 的退出机制 — 支持优雅退出
  7. EPOLL_CLOEXEC — 防止 fd 被子进程继承

5.4 HTTP 服务器相关问题

Q: HTTP 请求是怎么解析的?怎么处理"粘包"?

A: HTTP 解析用状态机:RECV_HTTP_LINE -> RECV_HTTP_HEAD -> RECV_HTTP_BODY -> RECV_HTTP_OVER

TCP 是流式协议,没有消息边界,所谓"粘包"是指一次 recv 可能收到不完整的请求或多个请求。处理方式:

  1. 用 Buffer 缓存数据,按行读取(找 \n
  2. 不足一行就等待更多数据
  3. 解析 Content-Length 确定正文长度,增量接收
  4. 一次 OnMessage 中循环解析,处理多个请求(pipeline)

Q: fall-through switch 是什么?为什么不用 break?

A: 这是故意的。解析完请求行后立即尝试解析头部,解析完头部后立即尝试解析正文。这样一次 OnMessage 调用就可能完成整个请求的解析。如果某一步发现数据不足,状态不变,fall-through 到下一步时因为状态不匹配会立即返回。

Q: 怎么防止目录遍历攻击?

A: ValidPath 函数按 / 分割路径,计算目录深度。如果深度小于0(说明有 .. 超出了根目录),返回 false。比如 /../etc/passwd 分割后第一个就是 ..,深度变为 -1,拒绝访问。


六、改进方向总结

项目改进:

  1. Channel 的 tie 机制 — 生命周期保护,防止悬空指针
  2. Acceptor 的 EMFILE 处理 — 防止 fd 耗尽时 CPU 100%
  3. EventLoop 退出机制quit_ 标志位,支持优雅退出
  4. Buffer 的 readv 优化 — 减少系统调用次数
  5. sendInLoop 直接写 — 小包场景减少 epoll 通知
  6. 高水位回调 — 背压机制
  7. EPOLL_CLOEXEC — 安全性
  8. Connector + TcpClient — 完善客户端能力
  9. IPv6 支持 — InetAddress 封装
  10. 日志系统 — 分级、异步、可配置
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐