2026/10/27 3:07:47

从 socket 到 accept:TCP 协议常用 API 与客户端服务端实现拆解

从 socket 到 accept:TCP 协议常用 API 与客户端服务端实现拆解 服务端进程重启bind直接报Address already in use明明上一个进程已经退出了ps也查不到端口却像是被谁焊死在那里。这个场景我见过太多次提问的人通常还会补一句“我代码没改过昨天还是好的”。问题往往不在代码改没改而在于对 TCP 服务端从socket到accept这条调用链的内核行为缺少一层理解。这篇就把TCP 协议常用 API和TCP 客户端服务端的实现拆开讲透每个调用的入参在表达什么、返回值在告诉你什么、哪些事情内核替你做了、哪些必须你自己兜住。适合刚写完第一个 echo 服务的同学也适合写过不少但一直靠改参数试出来的朋友。1. 先搞清楚内核和你的代码各自负责哪一段写 TCP 程序最容易产生的一个错觉是以为send就是“把数据发到网线上”、recv就是“从网线上把数据收下来”。真实的模型更像是你去寄快递你把包裹交到网点签收单上写的只是“网点已收件”不代表收件人已经拿到。TCP 的“可靠”是由内核的发送缓冲区、重传定时器、滑动窗口、拥塞控制这一整套机制共同保证的应用层能做的只有两件事——把数据正确地交给内核以及正确地理解内核交回来的结果。想明白这个分层后面所有 API 的行为都能推导出来而不是靠背。1.1 三次握手被拆散到了哪几个调用里教科书上讲三次握手是一张时序图落到代码上它其实是被切碎分配到不同调用里的。服务端的listen是把 socket 从“主动连接”状态切换为“被动监听”状态的分界线调用之后内核才开始接受 SYN 报文客户端调用connect发出 SYN此时内核开始重传定时服务端内核收到 SYN 后回 SYNACK这条连接进入“半连接队列”客户端收到 SYNACK 回 ACK同时connect返回成功服务端内核收到最后一个 ACK把连接从半连接队列挪到全连接队列等待accept把它取走。这里有两个非常关键的推论。第一个connect返回成功只代表客户端这一侧的握手完成了服务端的应用层可能压根还没调用accept。所以客户端连上就发数据、服务端还没准备好读取是完全正常的数据会躺在服务端的接收缓冲区里。第二个服务端listen之后不调用accept连接照样能建立最多堆到全连接队列满之后新来的 SYN 会被丢弃或收到 RST。这解释了很多“服务端起没起来”的误判——端口在监听不代表业务逻辑在跑。1.2 缓冲区是这个模型里最容易被忽略的角色每个 TCP socket 在内核里都挂着发送缓冲区和接收缓冲区。send干的事情是把用户态数据拷进发送缓冲区然后就返回了真正什么时候发、发多少、要不要重传全由内核决定。recv则是从接收缓冲区把已经到达并且按序排好的数据拷到用户态。缓冲区的大小直接决定了send会不会阻塞、recv一次能拿到多少。我见过一个很典型的误解有人以为recv传入 4096 字节的缓冲区对端发 100 字节就会返回 100对端发 8000 字节就会分两次各返回 4096。前半句大致成立后半句在边界上不成立——TCP 是字节流不保留任何消息边界。对端连续调用了三次send每次 100 字节你这边一次recv完全可能拿到 300 字节也可能拿到 100、50、50 这样拆开的片段。这不是实现 bug是协议本来的设计。理解这一条后面“粘包”那一节就不用再纠结了。2. 服务端这五个调用的边界比参数本身更重要一个最简 TCP 服务端的骨架是socket → bind → listen → accept → 读写 → close。代码短到可以背下来但每个调用都有几个“不看文档就会踩”的细节。我把它们按调用顺序拆开说顺带解释每个参数为什么是这个值。2.1 socket 与 bind地址、端口、字节序三处容易翻车socket(AF_INET, SOCK_STREAM, 0)这三个参数分别定的是协议族、套接字类型和具体协议。第三个参数填 0 表示由前两个推导AF_INET SOCK_STREAM推导出来就是 TCP所以绝大多数情况下填 0 即可。返回的是一个文件描述符本质就是进程 fd 表里的一个索引所以用完一定要close否则 fd 泄漏跑到EMFILE的时候accept会一直失败。bind的坑主要集中在地址结构体上。sin_port必须用htons转成网络字节序sin_addr用INADDR_ANY表示监听本机所有网卡。字节序这件事如果不是从小就写网络程序很容易忘。判断方法很简单小端机器上htons(8080)得到的值跟 8080 完全不一样你printf一下就知道自己有没有转。#include arpa/inet.h #include netinet/in.h #include stdio.h #include string.h #include sys/socket.h #include unistd.h int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); return 1; } printf(bound on port %d\n, ntohs(addr.sin_port)); close(listen_fd); return 0; }这段代码里SO_REUSEADDR是故意加的它的作用放到第 6 节展开。地址转换不要再用inet_addr它返回的是in_addr_t失败时返回INADDR_NONE跟广播地址255.255.255.255撞车判断不了错误。用inet_pton(AF_INET, 127.0.0.1, addr.sin_addr)返回值 1 表示成功0 表示地址格式不合法-1 表示协议族不支持三种情况分得清清楚楚。反向转换用inet_ntop比inet_ntoa安全因为后者返回的是静态缓冲区多线程下会互相覆盖。2.2 listen 的 backlog 不是“最大连接数”listen(fd, backlog)这个 backlog 被误解的频率极高。它控制的不是“服务端最多能服务多少个客户端”而是已完成三次握手、等待应用层accept取走的连接队列长度上限。Linux 上实际生效的值是min(backlog, /proc/sys/net/core/somaxconn)很多发行版somaxconn默认是 4096老版本是 128。你传 1024但系统上限是 128那实际就是 128。为什么这个值重要如果应用层accept的速度跟不上新连接涌入的速度全连接队列会满。队列满了之后Linux 的默认行为tcp_abort_on_overflow0是静默丢弃客户端的 ACK让客户端以为握手还没完成触发重传。表现出来就是客户端偶发连接超时而服务端日志里什么异常都没有因为连接根本没进到应用层。这种问题极难从应用日志看出来得看ss -lnt输出里Send-Q那一列或者netstat -s | grep -i listen里的 overflow 计数。实践中我的做法是backlog 传SOMAXCONN同时保证accept循环里不要在取到连接后做耗时操作。取到连接立刻扔给工作线程或事件循环accept本身要尽快回到下一轮。2.3 accept 返回的是新 fd监听 fd 一直留在原地accept成功后返回一个全新的文件描述符代表这条具体连接。原监听 fd 什么都不变继续监听。很多初学者在这里写出这样的代码拿到返回值之后把它赋给了listen_fd或者在循环外只accept一次结果第二个客户端连不上。for (;;) { struct sockaddr_in peer; socklen_t len sizeof(peer); int conn_fd accept(listen_fd, (struct sockaddr *)peer, len); if (conn_fd 0) { if (errno EINTR) continue; /* 被信号打断重试 */ if (errno EMFILE) { /* fd 耗尽见下文 */ } perror(accept); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); printf(client %s:%d connected\n, ip, ntohs(peer.sin_port)); /* 处理这条连接 */ close(conn_fd); }accept的错误处理里有三个必须区分的情况。EINTR表示被信号打断直接continue重试就行不重试就等于丢了一次连接机会。ECONNABORTED表示客户端在握手完成前后放弃了也重试。EMFILE表示进程 fd 用完这个最麻烦——此时连接还在队列里你accept不出来只能等有 fd 释放。业界有个小技巧预留一个 fd启动时open(/dev/null)遇到EMFILE就close掉它accept一条连接立刻close然后再把预留 fd 打开这样能用最小代价把堆积的连接清掉避免把监听端口拖死。3. 客户端代码短但 connect 的返回值不能只看 -1客户端骨架是socket → connect → 读写 → close比服务端少两个调用陷阱却一点不少。最核心的一条是connect返回 -1 不代表连接一定失败得分阻塞和非阻塞两种模式看。3.1 阻塞模式下 connect 的超时到底有多长阻塞connect返回 0 表示握手完成返回 -1 表示失败。但这里的“失败”有个时间尺度问题如果没有SO_SNDTIMEO干预内核会按tcp_syn_retries重传 SYN默认 6 次总耗时大约 127 秒。也就是说对一个不可达的地址调用connect你的程序可能卡两分多钟。生产环境里这基本不可接受。想控制连接超时标准做法是把 socket 设成非阻塞connect立刻返回 -1 且errno EINPROGRESS然后用select或poll等这个 fd 可写配一个自己定的超时时间。可写之后不能直接认为成功必须用getsockopt(fd, SOL_SOCKET, SO_ERROR, err, len)把待处理的错误取出来err 0才算连上。int connect_with_timeout(const char *ip, int port, int timeout_ms) { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) return -1; struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, ip, addr.sin_addr) ! 1) { close(fd); return -1; } int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int ret connect(fd, (struct sockaddr *)addr, sizeof(addr)); if (ret 0) { fcntl(fd, F_SETFL, flags); return fd; } if (errno ! EINPROGRESS) { close(fd); return -1; } fd_set wfds; FD_ZERO(wfds); FD_SET(fd, wfds); struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; ret select(fd 1, NULL, wfds, NULL, tv); if (ret 0) { close(fd); return -1; } /* 超时或出错 */ int err 0; socklen_t len sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, err, len); if (err ! 0) { close(fd); return -1; } fcntl(fd, F_SETFL, flags); /* 恢复阻塞后续读写按阻塞模型走 */ return fd; }注意最后那行恢复O_NONBLOCK的操作。很多人忘了这一步后面recv直接返回 -1 加EAGAIN然后误判成对端断开排查半天。另外select的可写条件在出错时也会触发所以那一步必须用SO_ERROR复核不能省。3.2 客户端什么时候必须自己 bind绝大多数客户端不需要bind内核会自动选一个临时端口。但有几种场景必须自己绑需要固定源端口做访问控制的、需要绑到特定网卡做多路径分流的、以及某些协议要求源端口在特定范围内比如按端口规则做的白名单。一旦自己bind就要处理端口被占用的问题还要注意SO_REUSEADDR在客户端侧的行为和服务端不完全一样——它解决的是本地端口冲突不是服务端那种 TIME_WAIT 占用。还有一种情况是 IPv4/IPv6 双栈。用AF_INET6加IPV6_V6ONLY0可以让一个 socket 同时接受两种地址省掉两套代码。但如果对端地址可能是 v4 映射地址getaddrinfo返回的地址结构要按ai_family分别处理不能统一往sockaddr_in里塞这是崩溃和乱码的高发区。4. send 和 recv 的返回值才是真正的“协议约定”写完能连上之后下一个坎就是收发。这个地方的判断逻辑如果写错表现出来的现象是“数据偶尔少一段”“偶尔收到乱码”“进程莫名被杀”都很隐蔽。4.1 短写send 返回了不代表请求的量都发出去了send(fd, buf, len, 0)的返回值是实际拷进发送缓冲区的字节数可能是len也可能小于len。阻塞模式下如果缓冲区满了send会一直卡着直到有空间非阻塞模式下会直接返回 -1 且errno EAGAIN或EWOULDBLOCK。还有一种情况是返回小于len的正数说明只写进去一部分。所以一个能用的发送函数必须带循环ssize_t send_all(int fd, const void *buf, size_t len) { size_t sent 0; const char *p buf; while (sent len) { ssize_t n send(fd, p sent, len - sent, MSG_NOSIGNAL); if (n 0) { sent n; continue; } if (n 0 (errno EINTR || errno EAGAIN)) continue; return -1; } return (ssize_t)sent; }这里MSG_NOSIGNAL是关键它避免了向已关闭连接写入时触发SIGPIPE。如果不加这个标志也必须在进程启动时signal(SIGPIPE, SIG_IGN)否则默认行为是直接杀掉进程。我见过线上服务因为一个客户端提前断开、服务端继续推送心跳整个进程被SIGPIPE干掉日志里没有任何痕迹只有一句被内核记录的信号退出。这次经历之后我所有网络服务的main函数第一行都是忽略SIGPIPE。那为什么不干脆全部用MSG_NOSIGNAL就行了MSG_NOSIGNAL是 Linux 特有的send系列支持用write的时候没法传标志。所以更保险的组合是进程级忽略SIGPIPE兜底发送时再加MSG_NOSIGNAL。双保险多花不了什么成本。4.2 recv 返回 0 的语义只有一种对端关闭了写方向recv的返回值有三种情况必须分开处理返回值含义正确处理大于 0读到了这么多字节累加到缓冲区继续读或处理等于 0对端发送了 FIN关闭了写方向关闭本端连接不要重试小于 0出错按 errno 分支EINTR/EAGAIN 重试ECONNRESET 等直接关闭把返回 0 当成“暂时没数据”去continue是一个能烧满一个 CPU 核的死循环。而把EAGAIN当成断连去close是另一个方向的错误。这两个错误我在不同项目里都见过前者表现为进程 CPU 打满后者表现为连接频繁无故断开。另外要特别注意recv的flags参数。传MSG_WAITALL会让recv尽量读到请求的字节数但对端关闭或出错时仍可能提前返回不能依赖它来“读满一个包”。传MSG_PEEK只窥探不消费适合判断连接是否还活着。这两个标志都不是什么场景都合适尤其是MSG_WAITALL如果协议对端发的是变长数据用它会让整个线程卡住。4.3 粘包是应用层没定边界不是 TCP 的毛病TCP 是字节流协议只保证字节按序到达不保证“消息边界”。对端两次send的内容接收方一次recv全部拿到这叫“粘包”对端一次send的内容接收方分两次recv拿到这其实连“拆包”都不能算就是正常的字节流读取。两者都是协议允许的所以应用层必须自己定义边界。常见的三种定界方式各有适用面定长消息每条消息固定 N 字节读满 N 字节就算一条。实现最简单但长度不可变浪费带宽只适合控制指令类场景。分隔符用\n或\r\n之类不会出现在正文里的字符分隔HTTP 的头部、Redis 的 RESP 都用了类似思路。要处理“分隔符恰好被切在两个 recv 之间”的情况所以接收缓冲要能跨包保留。长度前缀TLV 的 L包头固定 4 字节大端整数表示后面的正文长度先读 4 字节算出长度再读正文。这是最通用、最高效的方案绝大多数 RPC 协议都这么设计。我一般用长度前缀加一个魔数校验结构大概是magic(2字节) version(1字节) type(1字节) length(4字节) payload。魔数用来快速发现“两边协议版本对不上”或者“连错了端口”这类问题比拿到乱七八糟的 payload 再去反序列化报错要容易排查得多。解析状态机要能处理任意切分所以实现上是一个循环尽量从缓冲区头部取取不够就等下一次recv。还要注意长度字段的合法性校验。如果不校验length攻击者发一个length 0xFFFFFFFF你的程序可能直接malloc4GB 然后挂掉。设一个业务上合理的上限比如 16MB超了直接断开这是必须写的防御。5. 从单连接到多连接阻塞模型撑不住的那一天只有一个客户端的时候阻塞模型完美工作。第二个客户端连进来你会发现它一直卡着因为服务端还在第一条连接的recv上阻塞。这是所有网络编程入门者都会撞上的墙。5.1 为什么 fork 一个进程或线程不是长久之计最直观的改法是每条连接fork一个子进程或者pthread_create一个线程。几百个连接的时候还行上到几千个线程栈默认 8MB和上下文切换开销就会变成主导。而且线程之间的数据共享和生命周期管理会迅速变复杂尤其在需要广播、需要统计全局状态的时候。真正适合高并发的模型是“一个线程管很多个 socket”靠 IO 多路复用把“哪个 fd 有新事件”这件事交给内核。这就是select、poll、epoll这一族系统调用的意义。5.2 select / poll / epoll 怎么选机制数据结构每次调用的开销fd 数量上限适用场景select位图需要把 fd 集合从用户态拷到内核返回后还要遍历受 FD_SETSIZE 限制通常 1024教学、古董代码、跨平台兼容poll结构体数组同样要拷贝整个数组没有固定上限连接数不大、需要跨平台epoll内核红黑树 就绪链表只在epoll_ctl时注册epoll_wait只返回就绪的受 fd 上限约束Linux 下高并发首选epoll的核心优势是“事件注册一次之后只取就绪的”所以每次epoll_wait的开销跟你监听了多少个 fd 基本无关。它的两个触发模式要分清楚水平触发LT默认只要缓冲区还有数据就会一直通知你写起来不容易漏边缘触发ET只在状态变化时通知一次必须配合非阻塞 fd 和循环读到底否则数据会一直留在缓冲区里没人处理。ET 效率略高但坑更多我的建议是除非确实在追性能否则先用 LT 把逻辑写对。int epfd epoll_create1(0); struct epoll_event ev, events[64]; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); for (;;) { int n epoll_wait(epfd, events, 64, -1); for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { int conn accept(listen_fd, NULL, NULL); if (conn 0) continue; set_nonblocking(conn); ev.events EPOLLIN | EPOLLET; ev.data.fd conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, ev); } else { /* 读事件处理ET 模式下要循环到 EAGAIN */ } } }这段骨架里set_nonblocking是必须的。ET 模式下如果用阻塞 fd读到没有数据时就会卡死整个事件循环。另外连接关闭时的清理要用epoll_ctl(EPOLL_CTL_DEL)显式移除虽然close会自动移除但要考虑同一个 fd 号被复用的时序问题先 DEL 再 close 更稳。6. 上线前值得逐条过的几件事代码跑通和能上线之间还隔着一层我把这一层里跟 TCP API 直接相关的几条列一下。6.1 SO_REUSEADDR 到底解决什么和 SO_REUSEPORT 有什么区别回到开头那个Address already in use。原因通常是上一条连接处于TIME_WAIT状态主动关闭方会在这个状态停留 2MSLLinux 上大约 60 秒期间这个四元组不能复用。如果服务端是被动关闭方一般不会遇到但如果你在服务端里主动close了客户端连接比如超时踢人服务端就成了主动关闭方重启时bind就会撞上。SO_REUSEADDR的作用是允许bind到一个处于TIME_WAIT状态的本地地址但它不允许多个进程同时监听同一个端口。很多人把它和SO_REUSEPORT搞混。SO_REUSEPORT才是真正的“多进程共享监听”多个进程可以bind同一个端口内核在它们之间做负载均衡。做多进程 worker 模型时主进程创建监听 fd 后fork给子进程共享是不需要SO_REUSEPORT的让每个子进程自己bind同一端口才需要它。两者的粒度不同别混用。顺带说一个常见的错误配置有人为了“避免端口冲突”在服务端和客户端都加SO_REUSEADDR然后发现端口还是冲突。原因是他把两个不同的 socket 绑到了完全相同的地址和端口上——SO_REUSEADDR处理的是TIME_WAIT和通配地址0.0.0.0与具体 IP之间的冲突不是让两个活跃 socket 共享端口。6.2 TCP_NODELAY 与 Nagle小包延迟从哪来TCP 有个 Nagle 算法会把多个小数据合并成一个大的段发出去以提升带宽利用率。这个行为对大批量传输友好对交互式场景就是灾难你发一个小请求内核说“等会儿看还有没有更多数据”等了几十毫秒才发出去。表现为请求-响应式的协议比如数据库客户端、RPC延迟莫名偏高。设置setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, one, sizeof(one))可以关掉 Nagle。但也不是所有场景都该关——如果是持续的大流量传输关掉反而会让网络里多出一堆小包。判断标准是你的协议是不是请求-响应式的、单条消息是不是通常小于 MSS约 1460 字节是就关掉。还有个容易忽略的组合问题Nagle 和延迟 ACK 同时生效时会出现互相等待的死锁延迟被放大到 200ms 量级。这类问题在本地测不出来只有真实网络才出现排查的时候不要只盯着自己的代码抓包看 TCP 层的时间间隔会快很多。6.3 关闭连接close 之前该做什么close是个很粗暴的操作它把 fd 的引用计数减一减到 0 才真正关闭同时如果还有未发送的数据内核会尝试发完再发 FIN。但如果你只是想让对端知道“我不再发数据了”应该用shutdown(fd, SHUT_WR)这会立刻发一个 FIN同时本端仍然可以继续recv。HTTP 里的半关闭、一些文件传输协议都会用到这种模式。还有个坑多线程环境下一个线程在recv阻塞另一个线程直接close(fd)行为是未定义的可能卡住也可能崩溃。正确做法是先用shutdown唤醒阻塞的recv它会返回 0等线程退出后再close。这个问题我在做服务优雅退出的时候踩过重启时偶发挂住不退出查了很久才发现是这个原因。另外别忘了设置SO_LINGER的默认行为默认情况下close立即返回内核在后台把数据发完。如果进程紧接着退出数据可能丢。对可靠性要求高的场景要么等业务层确认要么显式设置SO_LINGER让close阻塞到数据发完l_onoff1, l_linger正数但这会让关闭变慢要权衡。7. 我排查 TCP 连接类问题的固定动作最后分享一套我自己的排查顺序基本上从现象能快速定位到是哪一层的问题比漫无目的地看代码高效得多。先看连接到底建没建起来。ss -antp看状态分布ESTABLISHED数量对不对SYN_RECV堆积说明服务端在丢握手包CLOSE_WAIT堆积说明应用层没有正确closeTIME_WAIT数量大说明主动关闭方在本地。这四个状态各自的成因完全不同看到哪一个就往对应的方向想。再看队列有没有溢出。ss -lnt里Recv-Q是全连接队列当前长度Send-Q是 backlog 上限Recv-Q逼近Send-Q就说明accept跟不上。这时候别急着调大 backlog先看看accept循环里是不是干了什么耗时的事。接着抓包。tcpdump -i any -nn port 9000 -w tcp.pcap抓下来用工具看时序重点看三件事SYN 有没有回 SYNACK、有没有 RST、有没有重传。RST 出现的位置特别有信息量——如果客户端刚连上就被 RST多半是服务端accept队列满了或者程序崩了如果传输中途出现 RST通常是某一方在已关闭的连接上写了数据。最后才看代码。把send和recv的返回值都打印出来包括errno。我见过太多“数据没收到”的案例最后发现是send返回了 -1 加EPIPE而代码里压根没检查返回值。网络编程里每一个系统调用的返回值都必须检查这不是代码洁癖是这个领域唯一可靠的调试手段。写 TCP 程序写到最后你会发现真正难的从来不是那几个 API 怎么调而是对“字节流 缓冲区 半开半关”这套模型的理解是否到位。API 参数查文档就有但什么时候该关 Nagle、什么时候该用shutdown、粘包该怎么定边界这些得靠一次次踩坑攒出来的判断。我自己现在的习惯是任何新的 TCP 服务先把send_all、recv_some、定界解析这三块写成可复用的独立模块然后写一个能自动重连、能发随机长度消息的压力小工具跑一轮很多边界问题在这一步就暴露了比上线之后再查要省太多事。