
最近在啃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里。真正的注册发生在connectEstablishedvoid 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|EPOLLRDHUPread返回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会继续保持写事件关注直到全部写出后触发handleWritehandleWrite里发现状态已经是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。此时客户端会收到EPOLLRDHUPread返回0客户端如果也选择关闭它会回送FIN。服务端收到这个FIN后read返回0触发handleClose。整个时序是服务端调用shutdown状态置为kDisconnecting发送FIN。客户端收到EOF回调onConnection告知连接半关闭。客户端调用shutdown或close发送FIN。服务端read返回0handleClose状态置为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但是因为业务逻辑里有一个阻塞的数据库操作迟迟没有进入关闭流程导致连接半开。当时如果日志里能一眼看出“read0, statekDisconnecting”就能马上定位不用在代码里翻半天。顺带补充一个小工具建议抓包是最直接的验证半关闭的手段。用抓包工具看FIN是否发出、对端是否回ACK数据FIN比任何日志都准确。我第一次验证muduo优雅关闭时就是一边抓包一边看日志才完全理解shutdown和close的差别。5. 把一次完整连接从建立到关闭串起来看5.1 一条连接的生命周期事件表学习完Acceptor和TcpConnection之后我习惯把一条连接从无到有、从有到无的完整事件列成一张表。下面这张表是我自己总结的新同学看完这篇笔记可以直接拿去做索引阶段触发源头核心操作回调/状态监听Acceptor构造socket/bind/listen创建Channel并注册无连接到达listen fd可读accept取得connfdnewConnectionCallback_对象组装TcpServer创建TcpConnection设置回调tie无连接就绪connectEstablished状态置kConnectedenableReadingconnectionCallback_数据收发connfd可读/可写handleRead/handleWrite用户收发数据messageCallback_等主动关闭用户调用shutdown状态置kDisconnectingshutdownWrite发送FIN无对端FINconnfd可读(read0)handleRead返回0走handleCloseconnectionCallback_关闭连接销毁connectDestroyeddisableAllremove 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负责组装TcpConnectionTcpConnection负责管理这个连接上所有事件而Channel是这一切事件的翻译官。把这条主线抓到手里后面的所有细节都有地方安放。