先关注几个刚入门时最容易踩坑的点。muduo 这个网络库的学习曲线其实不算平缓,很多人卡在 Channel 和 Buffer 上,多数是因为直接去读代码,没有从事件循环的视角去理解。这篇笔记我打算把三个经常被放在一起讨论的东西串成一条完整链路:服务器监听类 Acceptor、TcpConnection 的建立,以及被很多人忽视的优雅关闭连接。如果你正在学 muduo 或者想弄明白一个高并发服务器的连接到底是怎么从无到有、再从有到无的,这篇笔记应该能帮你把散落的代码片段拼成一张完整的图。
我先说结论:muduo 里连接的生命周期管理,绝对不是“accept 一个 fd,然后 read/write”那么简单。Acceptor 只负责监听和 accept,真正的连接对象 TcpConnection 负责收发数据和状态迁移,而“优雅关闭”更是有一整套状态机和回调时序在背后支撑。理解了这三块,你对 Reactor 模式的理解会上一个台阶。
1. 在拆 Acceptor 之前,先把“连接”放进整体框架里看
muduo 的核心是事件驱动,这套体系里有几个关键抽象,如果不先把它们放到生态里的位置搞清楚,直接看 Acceptor 会很吃力。
EventLoop 是事件循环,负责在循环里调用 Poller(通常就是 epoll)去等待事件发生,然后把“文件描述符可读、可写、出错”这些事件分发给对应的 Channel。Channel 可以理解成“一个 fd 及其关心的事件、绑定的回调函数”的集合。它自己并不拥有 fd,只是封装它。这个设计非常关键:Channel 不拥有 fd,就意味着 fd 的所有权和生命周期管理要由外层对象负责。
在服务端场景下,这种分工是明确的:
- 监听 socket(listenfd)绑定在 Acceptor 上,Acceptor 只关心“新连接到来”这一件事
- 已连接的 socket(connfd)绑定在 TcpConnection 上,负责这个连接的读写、关闭等全部事件
也就是说,Acceptor 和 TcpConnection 是两个不同层级的组件,通过 EventLoop 和 Channel 连接起来。为什么要这么分层?直接在一个类里把 accept 和后续读写都处理了不行吗?可以,但那样连接对象会非常臃肿,事件的归属也会混乱。比如同一时间有多个连接在收数据,如果用同一个 fd 的事件处理函数来分发,要判断 fd 是谁,就得维护一张“fd 到对象”的映射表。muduo 的处理方式更符合 Reactor 模式,代码的扩展性也更好。
另一个关键点:muduo 的服务端代码里,监听 fd 和连接 fd 都设置了非阻塞,这个是必须的。因为当连接非常多、accept 事件一次不能完全处理完的时候,非阻塞模式可以保证 accept 循环不会卡在最后一个还没接受的连接上。我第一次写服务器时忘了设置非阻塞,结果高并发测试时程序“看起来卡死了”,其实就是阻塞在了 accept 上。
2. Acceptor:不仅仅是一个 accept 函数
2.1 核心成员到底在管什么
Acceptor 的类定义我按实际代码结构做了裁剪,但核心成员都在:
class Acceptor : noncopyable { public: using NewConnectionCallback = std::function<void(int sockfd, const InetAddress&)>; Acceptor(EventLoop* loop, const InetAddress& listenAddr, bool reuseport); ~Acceptor(); void setNewConnectionCallback(const NewConnectionCallback& cb); bool listenning() const; void listen(); private: void handleRead(); EventLoop* loop_; Socket acceptSocket_; Channel acceptChannel_; NewConnectionCallback newConnectionCallback_; bool listenning_; int idleFd_; };第一个要说的点,acceptSocket_是 Socket 类型的对象。Socket 类是对 fd 的高层封装,构造函数里做了 socket(2)、setNonBlock、setCloseOnExec,析构函数里做了 close。很多初学者会问,为什么不直接用 int 来放 fd?因为如果裸用 int,fd 的生命周期管理要自己盯着,一不小心就是 fd 泄漏或者双重关闭。用 RAII 的 Socket,对象析构时自动 close,稳得多。
再看acceptChannel_:它绑定 acceptSocket_ 的 fd,并注册到 loop_ 上。它的回调函数绑到handleRead(),也就是说每次循环发现 listenfd 可读,就会调用 handleRead。
newConnectionCallback_是“accept 成功后怎么处理新连接”的策略函数。Acceptor 自己并不知道新连接来了之后要怎么建 TcpConnection、怎么设置回调,它只需要把 fd 和客户端地址传出去,由上层(通常是 TcpServer)决定后续动作。这是典型的职责分离。学网络库的时候,要把握这种“通知-执行”分离的思路,muduo 整套代码都是这种风格。
idleFd_是一个特殊成员,作用是应对“文件描述符耗尽”的情况。先记住这个概念,后面在问题排查部分我会详细展开。
2.2 listen 和 handleRead 的细节
listen 函数的实现很精简,但值得逐行看:
void Acceptor::listen() { loop_->assertInLoopThread(); listenning_ = true; acceptSocket_.listen(); acceptChannel_.enableReading(); }assertInLoopThread是个很妙的调试手段:它断言当前调用 listen 的线程一定是事件循环所在的线程。muduo 所有对象操作都要求跟 loop 在同一线程,这个断言在开发阶段能提前暴露“跨线程操作”问题,比一上来就遇到内存竞争有意思得多。实战中,这个断言帮我抓过一次错误:某个业务线程里直接调了 listen,当时没崩,但监听一直不生效,后来主动加了这个断言才发现线程模型就错了。
enableReading()的含义是把 acceptChannel_ 关心的事件设置为可读事件,并且注册到 Poller 上。这时 Poller 开始盯着 listenfd 了,一旦有新的连接请求到达,loop 就会找到 acceptChannel_,然后调用 handleRead。
handleRead 是这样的:
void Acceptor::handleRead() { loop_->assertInLoopThread(); InetAddress peerAddr; int connfd = acceptSocket_.accept(&peerAddr); if (connfd >= 0) { if (newConnectionCallback_) { newConnectionCallback_(connfd, peerAddr); } else { sockets::close(connfd); } } else { // 处理 EMFILE / ENFILE / EAGAIN / EINTR 等 } }注意两个分支。第一个分支好理解,connfd 拿到了,如果设置了 newConnectionCallback_,就把 connfd 和 peerAddr 传出去;否则直接关掉。第二个分支是关键:返回值小于 0 时,要根据错误码区分情况。
- EAGAIN:连接已经全部 accept 完,没有更多连接了,正常返回
- EINTR:被信号中断,可以继续循环 accept
- EMFILE / ENFILE:系统文件描述符被耗尽
EMFILE 的处理逻辑,muduo 源码里有一段非常有名的实现:遇到 EMFILE 时,先 close 掉 idleFd_,再 accept 一个,然后再打开一个新 fd 把 idleFd_ 的位置补上。这样服务器始终有一个“预备役 fd”用来挡刀,不会因为 fd 耗尽而把主循环搞崩。如果没有这个机制,listenfd 会一直处于可读状态,每次 epoll 都返回它,每次 accept 都失败,形成忙轮询,CPU 被打满,整个服务就假死了。这个坑在线上环境遇到一次就够你记一辈子。
另外,muduo 的 accept 不是只 accept 一次,具体实现里通常会用一个循环不断 accept,把当前 backlog 里已经就绪的连接一次拿干净。这里的取舍是:如果只 accept 一个,在高并发场景下,新连接可能在下一秒又到达,导致频繁唤醒 epoll,白白增加睡眠唤醒的开销;一次性 accept 多个,则可以减少事件循环的唤醒次数。这个“一次事件多个处理”的设计思路,在后面 TcpConnection 的收发上同样存在。
3. 从 accept 到 TcpConnection:一条连接的生命看起来很长的原因
3.1 TcpServer 的角色
先别急着看 TcpConnection 的构造函数。我们得知道是谁把它创建出来的。一定是 TcpServer,因为在 muduo 的分层中,TcpServer 是“资源总控”的角色,它维护着一个连接管理器,负责所有 TcpConnection 的创建、清理和状态维护。
当 Acceptor 的 newConnectionCallback_ 被触发时,TcpServer 会执行 newConnection 逻辑,代码大致是:
void TcpServer::newConnection(int sockfd, const InetAddress& peerAddr) { loop_->assertInLoopThread(); InetAddress localAddr(sockets::getLocalAddr(sockfd); char buf[64]; snprintf(buf, sizeof buf, "-%s#%d", hostport, nextConnId_++); std::string connName = buf; TcpConnectionPtr conn(new TcpConnection(loop_, connName, sockfd, localAddr, peerAddr)); conns_[connName] = conn; // 登记到连接表 conn->setConnectionCallback(connectionCallback_); conn->setMessageCallback(messageCallback_); conn->setWriteCompleteCallback(writeCompleteCallback_); conn->setCloseCallback(...); conn->connectEstablished(); }这里有几个细节值得关注。第一,conns_是连接表,它保证了连接对象一直“活着”,不会被提前析构。这是一个隐藏的坑:如果 TcpServer 没有保存这份连接表,当函数返回时,连接对象引用计数递减,TcpConnection 就直接析构了,网络处理就完全没法用了。
第二,TcpConnectionPtr是 std::shared_ptr。连接对象用 shared_ptr 管理,这在 muduo 的整个架构中非常关键,因为连接会被传入用户的回调函数,用户可能保存一个 TcpConnectionPtr,以便以后向这个连接写数据。如果用裸指针或 unique_ptr,用户在回调里保存之后,对象可能已经销毁,悬空指针会让服务器直接崩溃。
3.2 TcpConnection 对象的初始化
TcpConnection 构造函数做了这些关键动作:
TcpConnection::TcpConnection(EventLoop* loop, const std::string& nameArg, int sockfd, const InetAddress& localAddr, const InetAddress& peerAddr) : loop_(loop), name_(nameArg), state_(kConnecting), socket_(new Socket(sockfd)), channel_(new Channel(loop, sockfd)), localAddr_(localAddr), peerAddr_(peerAddr), highWaterMark_(64 * 1024 * 1024), reading_(true) { channel_->setReadCallback(...); channel_->setWriteCallback(...); channel_->setCloseCallback(...); channel_->setErrorCallback(...); socket_->setKeepAlive(true); }此时状态还是 kConnecting,也就是说连接还处于半初始化状态。Channel 还没有注册到 Poller 上,只是准备好了回调。很多第一次读代码的人会问:连接创建出来就立刻可以收到数据吗?不是的,必须等 connectEstablished 调用之后,Channel 注册到事件循环上,才能收到数据。
这套“两阶段初始化”的设计是有意为之的:
- 第一阶段,先把对象建出来、成员准备好,避免在构造函数里依赖外部回调
- 第二阶段,由 TcpServer 在拿到新连接之后,把用户的业务回调设置上去,再正式开启事件监听
用户业务回调包括:连接建立回调、消息回调、写完成回调、关闭回调。这些回调在 TcpServer 创建 TcpConnection 之前,就应该通过 TcpServer 的对应接口设置好。如果你忘了设置写完成回调,那么当数据发送缓存被清空时,你根本得不到通知。这在大量数据发送的场景下相当致命。
3.3 connectEstablished 的完整流程
connectEstablished 是关键方法,它决定了连接的正式启用时间点:
void TcpConnection::connectEstablished() { loop_->assertInLoopThread(); assert(state_ == kConnecting); setState(kConnected); channel_->tie(shared_from_this()); channel_->enableReading(); connectionCallback_(shared_from_this()); }这行channel_->tie(shared_from_this()),我刚看时完全没懂它在干嘛。后来才明白,tie 是一个非常重要的安全机制。
Channel 内部会持有一个“生命保证”对象,目的是:当事件循环分发事件时,能保证 Channel 所在的宿主对象也就是 TcpConnection 还活着。因为事件循环里拿着的是 Channel 指针,如果 Channel 对应的 TcpConnection 已经被用户从连接表中删除并析构,而这个事件还躺在 Poller 的活动列表里,再去回调就会访问已经释放的内存。tie 的机制就是在事件分发前,让 Channel 尝试锁定 shared_ptr,从而把 TcpConnection 的析构延后到事件处理完成之后。这就是为什么 TcpConnection 一定要用 shared_ptr 管理,并且 connectEstablished 时要 tie 自己的原因。
然后调用channel_->enableReading(),把 Channel 的可读事件挂在 epoll 上。从这一刻起,那条连接上发来的数据就会唤醒事件循环。
最后调用connectionCallback_(shared_from_this()),也就是用户注册的“连接建立”回调。你会发现这个顺序很有意思:先标记状态、再注册事件、最后通知用户。这样用户在连接回调里就可以放心读写 socket 了,因为状态已经是 kConnected,事件也已经注册完成。
3.4 你可能会踩的坑:回调里的 shared_from_this
一个经常出现的 bug 是,在传给用户的连接回调里,用户把TcpConnectionPtr存进了某个全局 map,但又没注意连接关闭时要清理。等到连接关闭后,连接表还残留着这个空壳对象,每次向它写数据会报错或者根本没有反应。从 muduo 的实现角度看,这类问题往往不是库的问题,而是用户对连接生命周期的理解不到位。
另外要注意:不是所有回调都在事件循环里,比如有些业务回调可能由工作线程去执行,如果你在里面直接调用conn->send(),就等于跨线程操作 TcpConnection。这个时候就需要 runInLoop 或者 queueInLoop。muduo 的 TcpConnection 内部会检查线程:如果当前线程不是 loop 线程,send 内部会把写操作排到 loop 线程去执行,从而保证 TcpConnection 的操作是线程安全的。第一次接触这个机制时我还在想“这不就是加锁么”,其实不是,muduo 用“队列加跨线程唤醒”替代了锁,在高频场景下更干净。
4. 优雅关闭:不是一句 close(fd) 就完了
4.1 关闭连接为什么这么讲究
在网络编程里,关闭连接永远比建立连接复杂,因为关闭涉及两个方向的数据是否都已经发送完毕。业务千变万化,有的是“发完就关”,有的是“关之前还要等对方消息”,还有的是“客户端死活不关但你不想理他了”。如果一股脑 close(fd),在你还有数据要发给对方的时候,内核会直接丢数据,对方收到的将是“连接被重置”而不是“正常断开”。
TCP 有四次挥手,原理是:主动关闭方发送 FIN,被动关闭方收到后回复 ACK,被动方也发送 FIN,主动方回复 ACK。但只要有一方调用 close,这个流程就启动了。问题在于 close 的粗暴性:close 会立刻释放套接字资源,并把发送缓冲区里残存的数据直接丢弃。而 shutdown 可以只关闭写端或只关闭读端,更精细。
muduo 里优雅关闭的思路,就是分阶段关闭:
- 先调用 shutdown,相当于告诉对端“我没有更多数据要发了,你把我发完的数据收好”
- 仍然保留读端,继续接收对端可能还没发完的数据
- 对端也关闭后,内核层面完成完整四次挥手,然后才真正释放连接
这就是半关闭状态。很多人第一次接触这个流程会觉得繁琐,但正是这个繁琐,保证了不会丢数据。
4.2 TcpConnection 的关闭状态机
TcpConnection 的状态定义是:
enum StateE { kDisconnected, kConnecting, kConnected, kDisconnecting };- kConnecting:对象刚创建,还没 connectEstablished
- kConnected:连接已经在正常收发
- kDisconnecting:正在关闭,已经调用了 shutdownWrite,但还在等对端
- kDisconnected:连接已经彻底结束
用户调用conn->shutdown()时,状态由 kConnected 变成 kDisconnecting,接着执行 shutdownWrite,发送 FIN。真正的 close(fd) 并不会立刻执行,而是等 handleClose 的时序到来。
4.3 来自对端的读关闭与写关闭
被动关闭路径:对方发了 FIN,我们的 connfd 会触发可读事件,读返回 0。muduo 的 handleRead 会处理到这种情况:
ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno); if (n > 0) { messageCallback_(shared_from_this(), &inputBuffer_, receiveTime); } else if (n == 0) { handleClose(); } else { handleError(); }当n == 0表示对端关闭了写端,这时进入 handleClose。handleClose 的职责是:
- 执行用户注册的 closeCallback_,通常由 TcpServer 清理连接表
- 调用 connectDestroyed,把 Channel 从 Poller 上去掉
- 把状态置成 kDisconnected
注意一个细节:这里的“从 Poller 上去掉”并不是从内核里释放 fd,而是让 Channel 不再关心任何事件。紧接着,用户回调会把连接表里的 TcpConnectionPtr 清掉,TcpConnection 对象析构,析构函数里 socket_ 析构,最后才真正 close(fd)。
这个顺序保证了:在所有用户代码都认为连接不可用之前,底层 fd 不会被关闭;而一旦用户代码干净地解除了引用,fd 自然关闭。这就是 RAII 带来的优势。
主动关闭和被动关闭有一个差异:被动关闭是对方先发 FIN,所以可以直接走到 handleClose;主动关闭时,我们发了 FIN,对方会回 ACK 并且可能也关闭自己的写端。对方关闭写端后,我们这边就会触发 handleClose,从而形成闭环。
有时候会遇到这么个事:我主动 shutdown 了,但对方一直不发 FIN,比如对方程序有 bug 还在等什么,那连接会停在 kDisconnecting 状态。实际上 shutdownWrite 只关闭写端,读端还是开启的,所以依然能接收对方发来的数据。muduo 在 kDisconnecting 状态下会继续处理读事件,这正符合“优雅关闭”的要求,等对方把数据发完再关。
4.4 forceClose 是无路可走时的最终手段
优雅关闭有前提:双方都愿意配合。现实中大量情况是客户端断线重连、僵尸连接、或者对方主动发 RST。如果一直不强制关闭,连接会长期占着本地资源。muduo 提供了 forceClose 方法:
void TcpConnection::forceClose() { loop_->queueInLoop(std::bind(&TcpConnection::forceCloseInLoop, shared_from_this())); }forceCloseInLoop 里会调用 handleClose,彻底关闭连接,不再等对端。这个机制要在服务器主动踢掉一部分僵尸连接时使用,比如心跳超时后判定连接无效,直接 forceClose。注意这里用 shared_from_this 绑定也有讲究:因为绑定的是 shared_from_this,即使 forceClose 调用时连接表已经没什么引用,对象也不会在执行 forceCloseInLoop 之前被析构。
4.5 关闭时还有业务数据没发完怎么办
如果一个连接在关闭时,发送缓冲区里还积压着大量数据,直接关 fd 等于扔掉这些数据。优雅关闭在这时也应该给数据一条活路。
muduo 在 send 操作中会判断当前连接状态:如果 state_ 是 kConnected,才会往 socket 写数据或者把数据追加到 outputBuffer_;如果已经是 kDisconnecting,继续 send 会被拒绝。所以在业务层,如果你认为“发完就关”,正确的顺序是:先确保调用过 send 的数据已经进入发送队列,等待写完成事件触发后再调用 shutdown,或者依赖底层的事件循环,在 outputBuffer_ 清空后再执行 shutdown。
很多人在这个环节会把“调用 send 就叫发送完成”误解为“socket 的发送缓冲区已经写完了”,实际上 send 之后数据还在用户态缓冲区和内核缓冲区之间排队。muduo 的做法是:当发送完成(包括内核写缓冲为空)后,触发 writeCompleteCallback_,此时如果你已经在回调里设置 shutdown,就能保证数据发完再发 FIN。
实际项目里,需要“一口气发十几个包然后优雅关闭”的场景,基本都会用这个套路:发送完所有数据,等 writeComplete 回调,再 shutdown。如果这条链路没设计好,要么丢数据,要么连接迟迟不关闭,都是很常见的线上问题。
5. 一个完整的连接生命周期,用事件循环的视角再走一遍
5.1 从 epoll 视角看连接建立
用一个真实的流程把上面所有节点串起来。
假设服务器已经 listen(),acceptChannel_ 已经挂在 epoll 上。客户端发起连接:
- 内核完成 TCP 握手,listenfd 变为可读,epoll 返回 listenfd 的可读事件
- EventLoop 取出 acceptChannel_,调用 acceptChannel_ 的读回调,也就是 Acceptor::handleRead
- handleRead 中 accept 得到 connfd,调用 newConnectionCallback_
- TcpServer::newConnection 创建 TcpConnection,状态 kConnecting
- 用户通过 TcpServer 设置的回调被逐个注入 TcpConnection
- connectEstablished:状态改为 kConnected,Channel tie 自己,enableReading,调用用户的 onConnection 回调
- 此时 channel 已经挂在 epoll 上,连接正式进入可收发状态
整个链路中,所有步骤都在同一个线程也就是 loop 线程完成。在第 4 步时,如果用户回调中直接对这个连接调用了 shutdown,TcpConnection 也会顺利进入关闭流程,因为状态和注册时机都是正确的。这也是为什么 muduo 强调“不是你的线程别碰 loop 里的对象”,一次事件处理中的多个步骤要严格串行。
5.2 关闭流程的事件回放
继续上面的流程,如果某个时间点用户调用了 shutdown:
- TcpConnection::shutdown 进入 shutdownInLoop,执行 shutdownWrite,向对端发送 FIN
- 状态由 kConnected 变为 kDisconnecting
- 对端收到 FIN 后,可能会继续发送数据,也可能立即关闭。我们这边读端仍然开启,可以继续收到数据
- 对端也关闭写端,我们这边 connfd 可读,handleRead 读到 0
- handleClose 被调用:先触发用户 closeCallback_,TcpServer 从连接表中移除连接,再执行 connectDestroyed
- connectDestroyed 中把 Channel 从 Poller 中移除,状态置为 kDisconnected,并调用用户析构回调
- 用户回调结束后,TcpConnection 引用计数归零,析构,socket_ close fd
在网络包时序上,这就是标准的四次挥手。但要注意:如果第 3 步对方一直不关写端,我们就会停留在 kDisconnecting,直到对方主动关闭或我们调用 forceClose。
5.3 状态与回调,这两张表背下来能省很多事
| 状态 | 含义 | 典型操作 |
|---|---|---|
| kConnecting | 对象刚创建,Channel 未注册 | 设置回调、connectEstablished |
| kConnected | 正常收发 | send、shutdown、forceClose |
| kDisconnecting | 已发 FIN,等待对端 | 继续读,等待 handleClose |
| kDisconnected | 已彻底关闭 | 无 |
关闭回调的执行顺序是:handleClose 内部触发 closeCallback_,然后 connectDestroyed,最后析构。TcpServer 负责在 closeCallback_ 里把连接从连接表中移除,之后再让 TcpConnectionPtr 在容器里自然析构。
6. 常见问题与排查技巧实录
6.1 问题速查表
在实际学习和上手过程中,大家问得最多的几个问题,我按“症状-原因-解决”的方式整理成表,这些是我自己实践或观察别人踩过的坑。
| 症状 | 可能原因 | 解决建议 |
|---|---|---|
| 新连接建立后,数据一直收不到 | TcpConnection 还没有 enableReading,或者回调没有正确设置 | 确认 connectEstablished 被调用;检查 messageCallback_ 是否通过 TcpServer 正确设置 |
| 一收到大量高并发请求,进程卡死 | accept 阻塞在最后一个连接上,listenfd 非阻塞设置失效 | 检查 Socket 构造是否设置了 SOCK_NONBLOCK,或在 accept 前确保 fd 非阻塞 |
| 进程崩溃,栈里能看到 Channel 的 handleEvent | Channel 的宿主对象被提前析构 | 给 TcpConnection 正确调用 tie(shared_from_this()),检查有没有在事件回调中误删除连接 |
| 调用 send 后数据发出去了,但 shutdown 后对端收不到完整数据 | 数据还在用户态缓冲区,直接关闭连接导致丢弃 | 使用 writeCompleteCallback_ 明确“写完成”后再 shutdown |
| 连接表不断变大,内存增长 | 被动关闭时 closeCallback_ 没有清理连接表,或用户到处保存 shared_ptr 没释放 | 确保 TcpServer 的 closeCallback_ 从连接表中移除;检查用户侧是否持有生命周期过长的 TcpConnectionPtr |
| fd 耗尽,服务器无法接受任何新连接 | 没有 idleFd_ 或者文件描述符用尽 | 参考 Acceptor 的实现,维护一个备用 fd,遇到 EMFILE 时先关备用 fd 再 accept 再重新占位 |
| 对端崩溃(RST),收到 ECONNRESET | 对端异常断开 | 在 handleError 里记录错误日志,并走正常 handleClose,不要盲目重试写入 |
6.2 一个差点把服务器搞垮的教训:保存 shared_ptr 却忘记释放
这里展开讲一个我学习时很容易犯的错:在连接回调里把这个连接存到一个全局列表,但关闭回调里没把它删掉。当时做一个聊天服务器,用户列表一直存着 TcpConnectionPtr,关闭后没有移除,结果线上跑了两天,内存一路往上走,排查了很久才发现是引用没释放。这类问题用内存分析工具根本看不出来是网络层泄漏,因为对象还是被 shared_ptr 引用的,没有触发析构。所以在业务层新建的 shared_ptr 引用,记得在连接关闭时归还。
另外一个非常容易被忽视的点:不要随便在关闭回调里再次调用 conn->send()。此时连接已经处于 kDisconnected,send 里的逻辑会检查状态,状态不对就直接返回。但如果你在一个很深的回调链里调用了 send,又特别依赖“一定发出去了”,这个返回就是静默失败。排查时根本看不到报错,只能看到对端迟迟没收到。最好在 send 之后根据返回值或者状态做一次显式判断,或者打印日志。
6.3 半关闭状态卡住,抓包最直观
我在调试半关闭状态卡住的时候,最喜欢用抓包工具看时序。先连续看三次挥手包:第一次 FIN 发出、第二次 ACK、第三次 FIN 和 ACK。如果中间少了一个 FIN,往往就是对方一直在等业务数据,而你的 shutdown 发送了 FIN 后,对方忙于别的逻辑没处理。遇到这种情况优先检查两边的业务协议,而不是怀疑库。
关于错误码,handleError 里常见的是 ECONNRESET、ECONNABORTED、EPIPE。开发阶段如果经常见到 EPIPE,多半是你在对端已经关闭后还尝试写。这时不要慌,记下日志里的 fd,再尝试在服务端代码里拦截写入前状态即可。
7. 几个必须从源码里抠出来的细节和心得
7.1 Channel 与 Socket 的析构顺序,谁都别掉队
说实话,这些细节光看文档是学不到的,我都是从源码和实际运行日志里往回推出来的。
第一件,任何传进事件循环的裸指针,都要小心管理。TcpConnection 的 channel_ 是 new 出来的 Channel,TcpConnection 析构时它自然被析构。但 Channel 本身并不拥有 fd,fd 在 Socket 对象里。顺序很重要:TcpConnection 的成员声明里,socket_ 在 channel_ 之前,析构顺序与声明顺序相反,所以 channel_ 先析构,然后 socket_ 再 close。妙处在于:Channel 的析构并不会立刻从 epoll 中删除 fd,但如果 Channel 已经调用了 disableAll,也就是 connectDestroyed 会做的事,它就已经从 epoll 去掉了。之后 socket_ 关闭 fd 时,内核会负责清理。千万别反过来:如果 fd 先被关闭,Channel 还在 epoll 里,Epoll 返回的可能就是 EPOLLHUP,随后访问 fd 就会产生各种各样的诡异行为。
7.2 连接命名和日志:线上排查的可靠锚点
第二件,muduo 的连接命名规则其实是个调试辅助。TcpServer 生成的名字里包含本机地址、对端地址和自增序号。日志里看到这种命名,能快速定位到是哪台机器的哪个端口连过来的。这种命名习惯完全可以移植到自己的项目里。
我在自己的服务器里,会在 connectEstablished、handleClose、connectDestroyed、forceClose 这些关键节点都打上日志,输出连接名、状态、fd 编号。线上出问题的时候,翻这些日志基本能还原出每一次连接的来龙去脉。某次我们线上有个连接一直处于 kDisconnecting,过了几分钟都没消失,翻日志发现对方客户端根本没有关闭写端,于是设计了一个超时处理,超过一定时间就 forceClose。这个逻辑后来成了我们网关的必备选项。
7.3 事件循环里的回调切忌耗时
第三件,EventLoop 里跑的回调尽量不要做耗时操作。如果你在 connectionCallback_ 或 messageCallback_ 里直接跑了一个耗时三秒的数据库查询,整条事件循环就被阻塞,其他所有连接都挨饿。业务耗时一定要放到工作线程处理,通过网络再把结果写回。这个观点不是保守,而是高并发场景下的一个基本纪律。muduo 的事件循环本质是单线程串行,串行的好处是免锁、无竞争,代价就是不能容忍任何阻塞。
7.4 连接生命周期本质上就是引用计数
第四件,连接对象的生命周期不是“创建了就一直活着”,它完全取决于引用计数。TcpServer 的连接表持有 shared_ptr,Channel 的 tie 也持有,用户业务回调里如果临时保存了 TcpConnectionPtr,也会增加引用。
合理利用这一点,可以让连接对象活得比预期更长,比如某个业务异步操作完成之后还要向这个连接写数据,那就让该业务逻辑暂时保留 TcpConnectionPtr,同时在连接关闭时主动放弃。这里最常见的问题是“已经关闭了还在往连接发送数据”,我的建议是让业务逻辑在拿到 TcpConnectionPtr 的同时,注册一个连接关闭的清理回调,把业务对象里保存的 TcpConnectionPtr 置空。这样虽然不能百分百避免竞态,比如两个线程同时判断,但可以大幅度降低悬空引用的概率。
再说说 shutdown 和 close 到底什么时候调用。muduo 的 TcpConnection 析构函数里不会直接调用 ::close,而是依赖 socket_ 的析构。所以真正“关闭 fd”的动作,发生在 TcpConnection 对象销毁的那一瞬间。这也就是为什么连接表不清理、用户缓存一堆 TcpConnectionPtr 时,文件描述符会越来越多的原因。如果你在排查 fd 泄漏,第一件事不是看 fd 数量本身,而是把所有 TcpConnectionPtr 的持有点都找出来。我可以负责任地说,我见过的大多数网络服务“fd 只涨不降”的案例,最后都能归结为这种 shared_ptr 的不当持有。
最后说一个很多人会忽略的点:尽量用日志去观察连接状态的变化,并且在每个关键节点都打日志。不要小看这个习惯,它能帮你在最短时间内定位“连接为什么没建立”“关闭为什么卡住”这类问题。muduo 的学习本身也是这样,把状态机、回调时序和生命周期这三条线理清楚,后面再看其他网络库,很多设计你都能触类旁通。