☰
muduo源码解析:Acceptor与TcpConnection的建立与优雅关闭
2026/10/16 7:45:32 网站建设 项目流程

最近在啃muduo网络库的源码,这是第五篇笔记。前几篇把EventLoop、Channel和Poller之间的关系梳理得差不多了,事件循环怎么转、回调怎么分发、定时器怎么挂,这些底层骨架基本清晰。今天想集中解决连接生命周期里最吃功夫的两块:Acceptor如何把监听套接字干净地接进事件循环,TcpConnection又如何从accept出来的一个裸fd,变成一个能收发数据、能在合适时机优雅关闭的连接对象。尤其是“优雅关闭”这四个字,真不是close()一扔了事,里面涉及的半关闭、事件分发、回调顺序,坑非常多。这篇笔记就围绕Acceptor的监听接入、TcpConnection的建立流程、优雅关闭的底层逻辑,以及实际使用中最容易踩的时序问题展开。

1. Acceptor:监听套接字是怎么被接入事件循环的

1.1 为什么不能直接在主流程里写accept

熟悉Reactor模型的人都知道,muduo的核心是“注册事件、等待分发、调用回调”。但很多初学者在看TcpServer时会产生一个疑问:监听socket的accept()到底该放在哪里?直观想法是放在accept循环里,不停地accept直到EAGAIN。这个思路没有错,但关键在于accept不能是独立的阻塞调用,它必须由EventLoop统一调度。原因有两个。

第一,如果某个线程直接阻塞在accept()上,那么这个线程就没法处理其他连接上的读写事件。muduo是单线程Reactor处理所有I/O的模型,listen fd和client fd的事件必须共用一个epoll实例。如果accept阻塞了,其他连接的数据就全卡住。

第二,muduo的Channel机制天然就是为了解决“某个fd上发生可读事件时,调用对应的回调”。监听套接字也是一个fd,它的事件就是EPOLLIN,表示有新的连接可以被accept。所以最自然的设计就是:把listen fd做成一个Channel,注册进EventLoop,由它可读时触发Acceptor::handleRead。这样一来,accept和普通连接读事件就统一到了同一个事件循环里,逻辑非常干净。

1.2 Acceptor的构造:从socket()到listen()之间藏着哪些细节

看Acceptor的构造函数,第一眼会觉得很简单,但其实每一步都有讲究。它接收EventLoop指针、监听地址和reuseport标志。构造函数里依次做了非阻塞socket创建、设置SO_REUSEADDR(以及可选的SO_REUSEPORT)、bind到本地地址、listen,然后创建Channel。

这里最容易忽略的是“非阻塞”。监听套接字必须是非阻塞的吗?严格说,listen fd在accept之前,如果阻塞模式,在事件循环中一旦可读就立即accept,一般不会阻塞。但为了绝对安全,尤其是accept循环里accept到最后一个时返回EAGAIN的情况,非阻塞是必须的。muduo里用createNonblockingOrDie,底层调用socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC),一次搞定非阻塞和fd执行时关闭。

还有SO_REUSEADDR,这个不用多解释,TIME_WAIT状态下重启服务必备。SO_REUSEPORT则是在多线程模型下把多个listen fd绑到同一端口实现内核负载均衡,但muduo默认不开启,因为它的Reactor模型通常是一个线程一个EventLoop,用不到。

1.3 handleRead里的一次accept能带出多少个连接

当监听Channel触发可读时,EventLoop会调用Acceptor::handleRead。这里的核心是一个while循环:

void Acceptor::handleRead() { InetAddress peerAddr; int connfd = accept4(listenfd_.fd(), peerAddr.getSockAddr(), &peerAddr.getSockAddrLen(), SOCK_NONBLOCK | SOCK_CLOEXEC); if (connfd >= 0) { if (newConnectionCallback_) { newConnectionCallback_(connfd, peerAddr); } else { sockets::close(connfd); } } else { // 处理EAGAIN、EMFILE等 } }

实际muduo代码里不完全是直接accept4,但思路是这样:一次事件循环里尽量把当前已pending的连接全部accept完,减少下一次epoll_wait的重复唤醒。如果accept到一半返回EAGAIN,说明内核里没有新的连接了,退出循环即可。而EMFILE这类fd耗尽的情况会触发idleFd_逻辑,后面细说。

这里我当时的疑惑是:为什么不在handleRead里调用accept直接处理,而要绕一圈通过newConnectionCallback_通知外部?原因是Acceptor本身只负责“接进来”,后续这个连接是交给TcpServer还是别的逻辑,Acceptor不关心。把accept的结果通过回调抛出去,可以让TcpServer决定如何构造TcpConnection。这其实是一种很典型的职责分离:Acceptor管监听,TcpServer管分发。

1.4 idleFd的作用:一个反直觉的防御手段

Acceptor里有一个很容易被忽略的成员叫做idleFd_。它的初始值是::open("/dev/null", O_RDONLY | O_CLOEXEC)。为什么一个网络库要保留一个指向/dev/null的fd?

这要从EMFILE说起。当进程打开的文件描述符数量达到上限时,accept会返回EMFILE。这时候一个尴尬的问题是:因为fd耗尽,你无法在这个反复accept失败的过程中新建任何fd来处理问题,甚至连打开一个文件都不行。更麻烦的是,如果listen fd一直处于可读状态,epoll会反复触发EPOLLIN,形成busy loop,把那一个线程的CPU打满。

muduo的做法是:预先保留一个空闲fd,当accept返回EMFILE时,先close掉这个idleFd_,腾出一个fd名额,然后立即accept一次,把那个等待中的连接取出来,再立刻close掉它,最后重新open("/dev/null")把空闲fd补回来。这样一来,操作系统里的pending连接被消费掉了,epoll不会疯狂触发,同时fd名额也恢复。整个操作非常土,但非常有效,属于在极端条件下保住进程不卡死的兜底手段。

2. TcpConnection的建立:从裸fd到完整连接对象的组装

2.1 newConnectionCallback里发生了什么

当Acceptor::handleRead成功拿到一个connfd后,会调用TcpServer设置的回调。TcpServer内部会在newConnectionCallback里做这几件事:根据connfd的sockaddr包装出localAddr和peerAddr,从连接池中取出一个名字,创建TcpConnection对象,把TcpConnection加入容器管理,然后设置它的各种回调,最后调用connectEstablished。

这里最关键的一步是“创建TcpConnection对象”。新连接是一个裸fd,只有把它包装成TcpConnection,才能让它参与事件循环。muduo的TcpConnection构造函数接收EventLoop*、连接名、已经accept出来的sockfd、localAddr、peerAddr。构造过程中它会用一个Socket对象管理这个sockfd,并创建一个Channel。

这个Channel的事件回调绑的是TcpConnection自己的handleRead、handleWrite、handleClose和handleError。等于把“这个fd上发生什么事件”完全映射到TcpConnection内部的方法上。这一步不完成,连接只能停留在fd层面,无法收发数据。

2.2 绑定Channel时最容易被忽略的细节

TcpConnection构造之后,虽然创建了Channel,但此时并没有把Channel注册到EventLoop里。真正的注册发生在connectEstablished:

void TcpConnection::connectEstablished() { setState(kConnected); channel_->tie(shared_from_this()); channel_->enableReading(); connectionCallback_(shared_from_this()); }

这里顺序非常讲究。先置状态为kConnected,再tie,再enableReading,最后才回调用户连接建立事件。tie的作用是把TcpConnection对象和Channel的生命周期绑定。因为Channel的回调是裸函数指针,如果连接对象被提前析构,回调触发时访问连接对象就是悬空指针。通过weak_ptr和tie机制,Channel在执行回调前会先尝试把TcpConnection提升为shared_ptr,保证回调期间对象一定存活。

enableReading是把这个connfd的读事件注册进epoll。到这里连接才算真正开始接收数据。我把这理解为“通电仪式”:监听阶段只是告诉你有人敲门,现在门开了,你需要上电工作。

2.3 TCP_NODELAY与TCP_QUICKACK:连接建立后的两个小动作

在TcpServer创建TcpConnection后,通常会调用tcpNoDelay(true)和tcpKeepAlive等选项。TCP_NODELAY禁用Nagle算法,这对于低延迟交互非常重要。默认情况下,Nagle算法会合并小包,对于RT类业务就是灾难。比如客户端发了一条20字节的命令,服务端如果启用Nagle,可能会等上一个包的ACK到达后再发,导致延迟增加。

TCP_QUICKACK是Linux的特有选项,表示立即发送ACK,不延迟确认。这个选项并非所有版本都默认开启,且需要在连接建立后尽早设置。muduo的Socket类里可以设置,但通常需要用户显式调用。我在实际使用中会在创建TcpConnection后立刻设置这两个选项,尤其是在做长连接心跳、网关转发这类对延迟敏感的场景。

这个地方能看出muduo的设计目标:高性能网络编程。它把底层TCP细节全部封装成选项,让上层业务不用关心,但是又保留了设置入口。

2.4 kConnecting到kConnected的状态切换时机

TcpConnection内部有一个连接状态枚举:kDisconnected、kConnecting、kConnected、kDisconnecting。很多人第一次看会疑惑:accept完成之前是kDisconnected?为什么不是kConnecting?其实TcpConnection刚构造时,fd已经从accept拿出来了,连接还没真正建立完成,状态为kConnecting。然后connectEstablished把它切换到kConnected。而kDisconnecting则发生在主动关闭时,表示“我已经通知对端要关闭,但还在等待数据读写完毕”。这个状态机非常有用,后续所有关闭逻辑都要根据它判断。

我自己的体会是,如果把状态机的变化顺序打印出来,一条连接的建立到关闭会形成严格的线性序列:kConnecting -> kConnected -> kDisconnecting -> kDisconnected。任何跳变都意味着处理逻辑出了问题,排查时先看状态机往往能定位一半问题。

3. 优雅关闭连接:shutdown与close的本质区别

3.1 直接close有什么后果:以一次粘包丢失为例

初学者最常犯的错误就是连接不需要了,直接close(sockfd)。如果当前内核发送缓冲区里还有数据没有发送完,close会尝试发送剩余数据,但此时对端可能还在继续发送数据,而你的进程已经不再监听这个fd,对端发来的数据会触发RST。更麻烦的是,RST一旦发出,内核会丢弃接收缓冲区中尚未读取的数据,你也无法再通过FIN握手完成优雅关闭。

举个实际情景:服务端向客户端发送完响应后,立刻close。如果客户端的数据还在路上,服务端这个close很可能导致对端收到RST,而不是FIN。客户端读到RST后,所有之前缓冲的数据可能全部作废。这在高并发网关中会表现为客户端突然报错“连接被重置”。

TCP的关闭本来应该是一个双方协商的过程:A发送FIN,表示“我没有数据要发了”;B收到FIN后,仍然可以继续发送剩余数据;等B也发送FIN,连接才算完整关闭。直接close打断这个过程,就是用RST替代了FIN,破坏了协商。

3.2 shutdownWrite发送FIN后,读方向为什么还保留

muduo的优雅关闭核心是TcpConnection::shutdown()。它做的事是:如果状态是kConnected,先置为kDisconnecting,然后在loop中调用shutdownInLoop。shutdownInLoop有一个非常关键的分支:

void TcpConnection::shutdownInLoop() { if (!channel_->isWriting()) { socket_->shutdownWrite(); } }

意思是:如果当前没有正在写的数据,直接调用socket.shutdownWrite(),也就是系统调用shutdown(fd, SHUT_WR)。这个调用会发送FIN到对端,同时保留这个连接上的读方向。服务端还可以继续读取客户端发来的数据,这对于处理“客户端收到FIN后可能还会发送最后一条消息”的场景至关重要。

如果当前Channel正在写,也就是还有数据在内核发送缓冲区或应用层Buffer里没写完,就不能立刻shutdownWrite。否则你前一半数据还没发完,FIN就跟着出去了,对端会以为数据已经发完,导致数据不完整。必须等当前写操作完成后,在handleWrite里再次检查状态为kDisconnecting时,再补发shutdown。这里体现的是“先送完最后一程,再关门”的思想。

3.3 对端半关闭时EPOLLRDHUP与EPOLLIN的事件差异

当对端调用shutdown(SHUT_WR)后,本端会收到什么事件?这里有三个层次。

第一层:读事件EPOLLIN被触发,因为接收缓冲区里有一份EOF标记,read会返回0。很多传统写法就靠read返回0判断对端关闭。第二层:现代Linux提供了EPOLLRDHUP,它专门表示对端关闭了写半端。muduo在Poller里默认会关注EPOLLRDHUP,这样在触发时就知道是“半关闭”而不是有数据到来。第三层:如果对端直接close而不是shutdown,那么对端也不会再发数据,本端同样能看到EPOLLIN|EPOLLRDHUP,read返回0。

注意区分:只收到EPOLLIN但read还返回大于0,是正常数据;read返回0,是对端写半关闭;收到EPOLLRDHUP,是内核明确告诉我们对端的写方向关了。muduo的Channel把这些事件统一映射到handleRead中,handleRead里如果read返回0,就会调用handleClose,而不会把EOF当数据上报给用户。

这也是“优雅”的体现:半关闭状态不让用户手动猜,而是通过事件和read返回值明确告知。

3.4 连接释放的三段式检查:writeComplete、pending写缓冲、Channel失能

一次完整的优雅关闭,muduo并不是收到FIN就立刻析构TcpConnection,而是走一个相对严谨的流程。

首先是主动关闭方向上的检查。如果还有应用层Buffer没写完,Channel会继续保持写事件关注,直到全部写出后触发handleWrite,handleWrite里发现状态已经是kDisconnecting,于是shutdownWrite发送FIN。其次是读方向上的结束。当对端也关闭后,本端read返回0,触发handleClose,此时会做三件事:把Channel从EventLoop中移除(disableAll),停止所有事件关注;把状态置为kDisconnected;然后调用closeCallback_通知TcpServer移除这个连接,由TcpServer从ConnectionMap里erase掉,最后TcpConnection析构,fd释放。

这个过程中最关键的是“先disableAll,再析构”。如果不先移除Channel,即使连接状态已经是kDisconnected,事件循环仍然可能因为残留事件触发回调,访问半死不活的对象。muduo里TcpConnection::handleClose调用的connectDestroyed,内部是这样:

void TcpConnection::connectDestroyed() { if (state_ == kConnected) { setState(kDisconnected); channel_->disableAll(); connectionCallback_(shared_from_this()); } channel_->remove(); }

注意顺序:disableAll关闭所有事件,再回调用户“连接关闭”通知,再remove。我不会说这个顺序是绝对真理,但实践证明,先屏蔽事件再通知用户,能避免回调里再次操作Channel时的重入问题。

4. 关闭时序里的那些坑:实测回调顺序与状态检查

4.1 我方主动关闭时的回调顺序回顾

我在自己写测试服务时,最关心的就是主动shutdown之后,各个回调怎么触发。假设服务端调用了conn->shutdown(),如果当前没有写缓冲,会立即发送FIN。此时客户端会收到EPOLLRDHUP,read返回0,客户端如果也选择关闭,它会回送FIN。服务端收到这个FIN后,read返回0,触发handleClose。整个时序是:

  1. 服务端调用shutdown,状态置为kDisconnecting,发送FIN。
  2. 客户端收到EOF,回调onConnection,告知连接半关闭。
  3. 客户端调用shutdown或close,发送FIN。
  4. 服务端read返回0,handleClose,状态置为kDisconnected,调用用户的onConnection(传关闭状态),然后移除Channel。

一个容易混淆的点是:服务端调用shutdown后,并不会立刻触发自己的onConnection关闭回调。它要等到对端的FIN到达,在handleClose里才回调。所以看到的现象可能是:状态里长时间停在kDisconnecting,直到对端也关闭,才进入kDisconnected。

4.2 对端断开时的分支处理与handleClose

对端断开分三种情况。第一种是对端正常shutdown(SHUT_WR),随后不关闭,等待我们发送剩余数据,这是半关闭。第二种是对端正常close,发送FIN并彻底关闭。第三种是对端异常(比如进程崩溃、网络中断),可能是RST,也可能触发EPOLLERR。

muduo的handleRead里统一检查n == 0,也就是read返回0,然后进入handleClose,无论是对端shutdown还是close,只要读不到数据了,都视为连接关闭。好处是逻辑统一,坏处是业务层如果想区分“半关闭”和“全关闭”,需要自己记录对端是否还有数据要发,或者利用EPOLLRDHUP这一额外事件。muduo的Channel不会直接把EPOLLRDHUP上报给业务,它只在内部使用。我在接入层就曾遇到过一个业务需要区分半关闭的情况,最终只能通过自己维护一个“对端是否已经EOF”的标记来解决。说实话这也是muduo作为通用库的一个取舍。

4.3 重复关闭与重复removeChannel问题

在我自己的测试里,最容易出的bug就是连接关闭回调里又调了一次close,造成重复关闭。muduo的状态机一定程度上能防住这个:第一次关闭时状态从kConnected变成kDisconnecting,重复调用shutdown会发现状态已经不是kConnected,直接忽略。但有个暗坑:handleClose触发后,状态已经置为kDisconnected,通道也已经remove了,如果此时你再显式调用conn->shutdown(),它检查状态是kDisconnected,不会走入shutdown逻辑,OK。但如果你在connectionCallback里拿到一个连接后,不检查状态就调用send,就会往一个已经remove的Channel上写数据,轻则丢包,重则段错误。

我的经验是:在业务层维护一个连接有效标志,每次收到底层关闭回调后立即把标志置为false;发送前先检查这个标志。不要依赖TcpConnection内部状态,因为内部状态能防住库的内部问题,防不住你业务逻辑层面的误用。

4.4 日志里的关键字段:怎么快速定位是半关闭还是全关闭

调试连接关闭时,日志非常重要。我一般会在TcpConnection的handleRead返回0、handleWrite补shutdown、handleClose这三个位置打印当前状态和Channel是否在监听写事件。排查“连接卡在FIN_WAIT_2”或者“对端已经关闭但本端还在等”这类问题,先看三个东西:

  • 对端是否还有未读数据,本端read是否已经返回0;
  • 本端是否还有未发完的写缓冲(Channel isWriting是否为true);
  • 状态机的值是不是停在kDisconnecting。

有一次线上连接一直不释放,日志显示服务端发了FIN但客户端没有回FIN。排查后发现客户端读到了EOF,但是因为业务逻辑里有一个阻塞的数据库操作,迟迟没有进入关闭流程,导致连接半开。当时如果日志里能一眼看出“read=0, state=kDisconnecting”就能马上定位,不用在代码里翻半天。

顺带补充一个小工具建议:抓包是最直接的验证半关闭的手段。用抓包工具看FIN是否发出、对端是否回ACK+数据+FIN,比任何日志都准确。我第一次验证muduo优雅关闭时就是一边抓包一边看日志,才完全理解shutdown和close的差别。

5. 把一次完整连接从建立到关闭串起来看

5.1 一条连接的生命周期事件表

学习完Acceptor和TcpConnection之后,我习惯把一条连接从无到有、从有到无的完整事件列成一张表。下面这张表是我自己总结的,新同学看完这篇笔记可以直接拿去做索引:

阶段触发源头核心操作回调/状态
监听Acceptor构造socket/bind/listen,创建Channel并注册无
连接到达listen fd可读accept,取得connfdnewConnectionCallback_
对象组装TcpServer创建TcpConnection,设置回调,tie无
连接就绪connectEstablished状态置kConnected,enableReadingconnectionCallback_
数据收发connfd可读/可写handleRead/handleWrite,用户收发数据messageCallback_等
主动关闭用户调用shutdown状态置kDisconnecting,shutdownWrite发送FIN无
对端FINconnfd可读(read=0)handleRead返回0,走handleCloseconnectionCallback_关闭
连接销毁connectDestroyeddisableAll,remove Channel,关闭fdcloseCallback_

这张表能解释绝大多数的连接问题。尤其是“对端FIN”和“连接销毁”这两行,很多人会以为handleClose之后连接就立即析构,其实中间还隔着一层TcpServer的remove操作。如果不经过closeCallback_把连接对象从Map中移除,TcpConnection即使内部关闭了,也不会被析构,这就会造成内存泄漏。

5.2 这个连接生命周期对业务代码有什么指导意义

理解了这套生命周期,写业务逻辑时至少有三个改变。

第一,不要再傻乎乎地在onConnection回调里根据bool参数判断连接,然后立刻去send。因为onConnection回调时,连接虽然已经建立,但Channel可能还没完全进入稳定状态,而muduo已经保证connectEstablished里先enableReading再回调用户,所以正常读写没问题。但如果你在onConnection里做耗时操作,会阻塞事件循环,建议只做标记。

第二,主动关闭时不要裸调close,要使用conn->shutdown()。如果业务上确实想立即释放资源,可以调forceClose,这是muduo提供的强制关闭路径,它会把连接状态置为kDisconnected并立即走关闭流程。但常规场景下用shutdown,给对端一个完整的结束协议,是更稳的做法。

第三,对端断开后,如果想优雅地接收完剩余数据再关闭,需要在messageCallback里判断是否已经收到过EOF标志。muduo的标准行为是read返回0就走handleClose,不会再把EOF作为数据报给用户,所以半关闭场景要自己实现透明传输逻辑。我当时做过一个需要在关闭前透传最后一段数据的接入层,就是靠自定义一个EOF标记来手动控制。

5.3 下一步我会去啃什么

连接建立和关闭的骨架理解完之后,下一步最值得啃的就是TcpConnection的send和Buffer实现。Buffer的内存管理、读缓冲和写缓冲的腾挪,以及发送Buffer非空时如何处理Channel写事件,这些跟优雅关闭的衔接关系非常紧密。我打算下一篇笔记专门整理send的完整路径,以及HighWaterMark防大包内存暴涨的那套机制。

在学习连接生命周期时还有一个心得:不要只看某个类的头文件,要把Acceptor、TcpServer、TcpConnection、Channel这四者之间的回调关系画成数据流。我第一次看的时候,就是被回调绕晕了。现在回过头来,核心就是一句话:Acceptor只负责把fd交出来,TcpServer负责组装TcpConnection,TcpConnection负责管理这个连接上所有事件,而Channel是这一切事件的翻译官。把这条主线抓到手里,后面的所有细节都有地方安放。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询