tcp-server 项目实现流程、细节与 muduo 对比分析
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 字节)更高效,因为:
- 不需要 ioctl(FIONREAD) 查询可读数据量
- 一次系统调用尽可能读取更多数据
- 如果数据量小于 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 的解决方案:
- 预先打开一个
/dev/null的 fd(idleFd_) - 当 accept 返回 EMFILE 时,关闭 idleFd_,此时进程少了一个 fd
- 再次 accept(成功),然后立即 close 这个 fd
- 重新打开
/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 + 事件驱动 + 线程池:
- 所有 socket 是非阻塞的,read/write 不会阻塞线程
- epoll 负责事件通知,一个线程可以管理成千上万个连接
- 主线程只负责 accept,工作线程负责 I/O 读写和业务处理
- 新连接轮询分配给工作线程(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:
- timerfd 每秒产生一次可读事件(1秒精度)
- 时间轮有60个槽位,每秒指针走一步,清空当前槽位
- 添加定时器时,把 shared_ptr 放入
(当前tick + delay) % 60的槽位 - 用 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: 主要简化了以下方面:
- 单文件实现:muduo 分离了 base 和 net,几十个文件。我的实现全部在一个 header 文件中,便于学习和理解
- 去掉了 Boost 依赖:muduo 原版用 boost::any,我手写了 Any 类;用 C++11 的 thread/mutex 替代 pthread
- 去掉了 Poller 抽象:muduo 支持 epoll 和 poll 两种后端,我直接用 epoll
- 简化了定时器:muduo 的 TimerQueue 用 set 红黑树,微秒精度,支持重复定时;我用时间轮,秒级精度,只支持一次性
- 简化了地址管理:muduo 有 InetAddress 类支持 IPv4/IPv6,我直接用 sockaddr_in
- 增加了协议切换:我加了 Connection::Upgrade() 接口,支持运行时切换协议(如 HTTP -> WebSocket)
Q: muduo 有哪些设计是你的实现缺少的?如果要改进,你会怎么做?
A: 按重要程度排序:
- Channel 的 tie 机制 — 防止事件处理过程中对象被析构。改进方案:在 Channel 中加入 weak_ptr tie_,handleEvent 前 lock
- Acceptor 的 EMFILE 处理 — 预打开 /dev/null 的 fd,避免 fd 耗尽时 busy loop
- Buffer 的 readv 优化 — 用 scatter-gather I/O 减少系统调用
- sendInLoop 的直接写优化 — 优先直接 write,减少 EPOLLOUT 事件
- 高水位回调(HighWaterMarkCallback) — 背压机制,防止输出缓冲区无限增长
- EventLoop 的退出机制 — 支持优雅退出
- EPOLL_CLOEXEC — 防止 fd 被子进程继承
5.4 HTTP 服务器相关问题
Q: HTTP 请求是怎么解析的?怎么处理"粘包"?
A: HTTP 解析用状态机:RECV_HTTP_LINE -> RECV_HTTP_HEAD -> RECV_HTTP_BODY -> RECV_HTTP_OVER。
TCP 是流式协议,没有消息边界,所谓"粘包"是指一次 recv 可能收到不完整的请求或多个请求。处理方式:
- 用 Buffer 缓存数据,按行读取(找
\n) - 不足一行就等待更多数据
- 解析 Content-Length 确定正文长度,增量接收
- 一次 OnMessage 中循环解析,处理多个请求(pipeline)
Q: fall-through switch 是什么?为什么不用 break?
A: 这是故意的。解析完请求行后立即尝试解析头部,解析完头部后立即尝试解析正文。这样一次 OnMessage 调用就可能完成整个请求的解析。如果某一步发现数据不足,状态不变,fall-through 到下一步时因为状态不匹配会立即返回。
Q: 怎么防止目录遍历攻击?
A: ValidPath 函数按 / 分割路径,计算目录深度。如果深度小于0(说明有 .. 超出了根目录),返回 false。比如 /../etc/passwd 分割后第一个就是 ..,深度变为 -1,拒绝访问。
六、改进方向总结
项目改进:
- Channel 的 tie 机制 — 生命周期保护,防止悬空指针
- Acceptor 的 EMFILE 处理 — 防止 fd 耗尽时 CPU 100%
- EventLoop 退出机制 —
quit_标志位,支持优雅退出 - Buffer 的 readv 优化 — 减少系统调用次数
- sendInLoop 直接写 — 小包场景减少 epoll 通知
- 高水位回调 — 背压机制
- EPOLL_CLOEXEC — 安全性
- Connector + TcpClient — 完善客户端能力
- IPv6 支持 — InetAddress 封装
- 日志系统 — 分级、异步、可配置
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)