
昨晚一个朋友拉我去排障他刚写的 Linux 服务程序一启动就挂日志里只留了一行bind: only one usage of each socket address。我问他是不是动过监听端口他说没有就是快速重启了好几次。我说你把SO_REUSEADDR加上再试试他半信半疑改完服务果然起来了。这种场景在 socket 编程里太常见了几乎每个写过网络服务的人都会撞上。这篇文章聊的就是 Linux 下的 socket。不是单调地罗列 API而是从“socket 到底是什么”这个最朴素的问题出发把 TCP、UDP 服务端和客户端完整写一遍再集中拆解高频报错和长连接里的坑。适合刚学完 C 语言、想搞清楚两个进程怎么通过网络通信的初学者也适合已经能跑通 demo、但总在 bind、粘包、心跳设计上反复翻车的开发者。你不需要提前懂太多网络知识我会把每个关键点都掰开讲明白。1. socket 到底是什么网络世界的文件句柄1.1 一切皆文件的抽象在网络上同样成立Unix/Linux 有一个深入骨髓的设计思想叫“一切皆文件”。你打开磁盘上的文件open()返回一个文件描述符 fd之后read()/write()/close()走完整个生命周期。socket 虽然是网络通信不是操作磁盘但它复用了同一套接口模型。你用一个整数 fd 代表一条网络连接之后看似在读写文件实际数据是在内核协议栈里流转从网卡进出或者在回环接口内部直接完成拷贝。如果你之前只写过文件操作可以把socket()类比成open()向内核申请一个网络文件的句柄bind()是为这个句柄绑定一个“地址名”listen()/connect()是建立双向通道send()/recv()就是read()/write()最后close()释放句柄。这个类比不够精确但能帮新手先把程序骨架记在脑子里。很多人初学时的卡点不是函数记不住而是“我知道怎么打开文件但不知道如何打开网络”。当你把 socket 想成网络世界的 fd代码的流程一下就顺了创建、绑定、监听、接受、收发、关闭每步都有对应的系统调用。1.2 五元组内核靠什么唯一标识一条连接更进一步的问题是内核怎么区分同时存在的成千上万条连接答案是五元组源 IP、源端口、目的 IP、目的端口、协议类型。TCP 场景下协议类型就是 TCP。IP 和端口标识了通信双方的位置协议标识了这套连接使用的传输方式。服务端bind()时指定本地 IP 和端口相当于把一个固定门牌号挂到 socket 上。客户端connect()时不需要手动指定源端口内核会从系统临时端口范围里自动挑一个。可以用sysctl net.ipv4.ip_local_port_range查看本机临时端口范围通常类似 32768 到 60999。理解五元组对排查问题极有帮助。遇到Address already in use核心原因就是尝试绑定的 IP 端口在当前状态不允许复用遇到频率很高的“端口明明没进程用却 bind 失败”十有八九和时间等待状态 TIME_WAIT 有关具体在第 6 章讲。1.3 SOCK_STREAM、SOCK_DGRAM、SOCK_RAW三种不同的“脾气”socket()函数的第二个参数决定 socket 的类型三种常用类型有着完全不同的通信语义。SOCK_STREAM对应面向连接的可靠字节流通常配合 TCP 使用。它保证数据有序到达、不重复、不丢失。为了这些保证内核要维护连接状态处理三次握手、超时重传、拥塞控制。代价是 CPU 和内存开销高而且没有消息边界应用层需要自己切分消息。SOCK_DGRAM对应无连接的数据报通常配合 UDP 使用。每次sendto()发出一个独立报文对端recvfrom()一次收一个报文消息天然有边界。但 UDP 不保证送达也不保证顺序丢包全凭网络心情可靠性和顺序检测都得由应用层自己补。SOCK_RAW是原始套接字直接读写 IP 层甚至更底层的报文常用于抓包工具、Ping 程序、协议栈调试。业务开发我强烈建议别碰它对权限有要求数据结构复杂稍不留神就会踩进内核协议的深坑。第三个参数 protocol 常规填 0表示让内核根据 type 自动选择默认协议。只有同一种 type 下存在多个可选协议时才需要显式指定比如SOCK_RAW下方可能需要填IPPROTO_ICMP这类值。2. 先别写代码用 nc 亲眼看一下 socket 链路2.1 127.0.0.1、0.0.0.0 和局域网 IP 的区别写服务端之前先搞清楚地址怎么填这一步能让后面少踩很多坑。127.0.0.1是回环地址数据只在内核里绕一圈不出网卡相当于自己对着镜子说话。0.0.0.0不是一个真实 IP而是“所有本机地址”的通配符。服务端 bind 到0.0.0.0代表对当前主机所有网卡上的这个端口都进行监听。局域网 IP 比如192.168.1.10则只接受从该网卡进来的流量。我见过不只一次这样的排障现场服务端在配置里写了127.0.0.1监听然后拿手机连局域网 IP 访问怎么都连不上折腾半天最后才发现监听地址写错了。测试阶段先用127.0.0.1可以避开防火墙策略的干扰但如果要验证局域网访问就老老实实 bind0.0.0.0。2.2 用 nc 起服务端三分钟跑通一次通信想要最快速度感受 socket 链路不需要写 C 代码用netcat就够了。先确认系统里有没有 ncwhich nc || sudo apt install netcat-openbsd -y在一个终端起服务端nc -lv 127.0.0.1 9000再开一个终端执行连接nc 127.0.0.1 9000两边连上之后随便在一端输入几个字符回车另一端马上就能看到。这就是最朴素的 TCP 文本传输数据已经完整走过了“创建 socket - bind - listen - connect - accept - send - recv - close”这条链路。注意不同发行版的 netcat 参数有细微差异比如某些老版本里-l表示监听端口需要跟在-l后面写-p参数和-l同时用反而会报错。还是以本机nc -h的提示为准。2.3 tcpdump 看三次握手把抽象的网络概念变成可见的包代码能通了再往下一层观察内核态到底发生了什么。起一个 tcpdumpsudo tcpdump -i lo port 9000 -nn然后在另一个终端重新执行一次nc 127.0.0.1 9000。tcpdump 会输出一长串报文仔细观察前三个包客户端发 SYN标志位里只有 SYN服务端回 SYN-ACK同时带 SYN 和 ACK客户端再回 ACK三次握手完成连接建立后你输入文字时能看到PPSH和ACK同时置位的包关闭连接时发起关闭的一方发 FIN另一方回 ACK然后反向再走一次 FIN/ACK。我第一次在 tcpdump 输出里亲眼看到三次握手时很多名词突然就不抽象了。之后排查网络问题我也养成了一个习惯不靠猜先抓包用证据一步步缩小范围。tcpdump 是 socket 开发者的好朋友。3. 手写 TCP 服务端从 socket() 到 accept() 的每一步3.1 最小服务端代码逐行读一遍比背十个 API 有用下面这段 C 代码实现了最简单的 TCP 服务端监听0.0.0.0:9000每来一个客户端就发送一句话然后关闭连接。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 9000 int main() { int server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(server_fd, 16) 0) { perror(listen); exit(1); } printf(listening on 0.0.0.0:%d\n, PORT); while (1) { struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int client_fd accept(server_fd, (struct sockaddr *)client_addr, len); if (client_fd 0) { perror(accept); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, ip, sizeof(ip)); printf(accept from %s:%d\n, ip, ntohs(client_addr.sin_port)); const char *msg hello from server\n; send(client_fd, msg, strlen(msg), 0); close(client_fd); } close(server_fd); return 0; }编译运行gcc -Wall -o server server.c ./server几个容易被忽略的细节同时是很多 bug 的来源第一sockaddr_in必须先用memset清零。不初始化就直接填字段未设置的填充字节会变成不可预测的垃圾值bind 偶尔成功偶尔失败非常诡异。第二IP 和端口从主机字节序转换到网络字节序分别用htonl和htons。写成addr.sin_port 9000的后果是端口在高位和低位上被颠倒监听的根本不是你预想的端口。字节序问题对新手极不友好但记住“传输一律走网络字节序”就不会错。第三accept()返回的是新的 socket 描述符。监听 socket 还继续留在原处等待下一个连接而新描述符才代表和这个客户端的独立会话。很多人以为 accept 之后还应该用同一个 fd 收发数据这是最常见的误解。3.2 SO_REUSEADDR 为什么现在就要加我在代码里加上了一行容易被新手跳过的setsockoptint opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));它的作用是允许端口在 TIME_WAIT 状态下被重新绑定。TCP 连接关闭时主动关闭方会进入 TIME_WAIT 状态默认持续约 60 秒。如果服务端主动断开连接并快速重启不设置这个选项bind()就会报Address already in use。这个场景可以用一句话总结进程起来了端口却在等内核回收。几乎所有的 TCP 服务端代码里都有这一行不是玄学是必备配置。3.3 listen 的 backlogaccept 队列能容纳多长listen(server_fd, 16)这里的 16 是 backlog 参数不少人直接抄一个数字从没想过它到底控制什么。内核为监听 socket 维护两个队列半连接队列SYN 队列和全连接队列accept 队列。backlog 主要控制全连接队列的长度。当客户端的三次握手已经完成但应用层还没来得及调用accept()这个连接会临时待在 accept 队列里。如果队列满了新的连接请求就可能被内核丢弃。用ss -lnt可以查看监听 socket 的 Send-Q 和 Recv-Q其中 Recv-Q 可以粗略反映当前积压了多少个等待 accept 的连接。如果发现 Recv-Q 经常很高说明accept()处理不过来这时加 backlog 只能缓解根本办法是优化 accept 之后的数据处理逻辑或者把连接转发给更多工作进程。backlog 也不是越大越好每个连接都要占内核内存填一个天文数字反而可能导致资源被轻易耗尽。生产环境里常见的取值在 128 到 1024 之间具体需要压测确认。3.4 accept 之后交给谁处理单进程串行会卡死上面的示例代码是单进程串行处理一次只服务一个客户端发送完数据关闭连接才回到accept()等下一个。如果某次会话需要长时间 recv 等数据比如一个客户端连上来后 30 秒不发消息服务端在 recv 上阻塞其他所有客户端都会排队等待。这在生产环境里是不可接受的。最朴素的多连接方案是 forkpid_t pid fork(); if (pid 0) { close(server_fd); // 子进程使用 client_fd 与客户端通信 // 通信结束后 close(client_fd); exit(0); } close(client_fd);这里有两个非常容易遗漏的 close子进程要关闭继承来的 server_fd因为它不需要继续接受新连接父进程要关闭 client_fd因为它不参与这个会话的数据收发。fd 是引用计数的每个进程都持有自己的描述符表只有所有相关引用都关闭后内核才会真正释放这个文件对象。不 close 的后果是连接关闭后 fd 泄漏跑一段时间进程描述符耗尽服务彻底假死。3.5 ss 命令确认监听状态的利器服务端启动后用ss -lntp | grep 9000查看监听状态ss -lntp | grep 9000输出里的 Local Address 如果显示0.0.0.0:9000说明监听成功State 为 LISTENProcess 列会显示进程名和 PID。排查“客户端连不上服务端”时第一步永远不是改代码而是先确认服务端有没有真的在监听。这个习惯能省下大量时间。4. TCP 客户端与数据收发connect、send、recv 的真实行为4.1 最小客户端代码服务端有了客户端代码同样短#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define SERVER_IP 127.0.0.1 #define SERVER_PORT 9000 int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); exit(1); } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, addr.sin_addr); if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(connect); exit(1); } char buf[128] {0}; ssize_t n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; printf(recv: %s, buf); } close(fd); return 0; }注意inet_pton把字符串形式的 IP 转换成二进制的in_addr结构它比老旧的inet_addr更严谨支持 IPv6是推荐用法。启动服务端后开另一个终端运行客户端gcc -Wall -o client client.c ./client程序会输出服务端发来的hello from server然后正常退出。4.2 connect 是一个“有情绪”的系统调用默认情况下connect()是阻塞的也就是说它会一直等到 TCP 三次握手完成才返回。如果目标 IP 不可达客户端可能卡在 connect 上好几十秒甚至更久直到内核超时。最常见的错误是Connection refused意思是 ICMP 报文明确告知目标主机上这个端口没有服务在监听。排障顺序先确认服务端是否启动、监听的 IP 和端口对不对、防火墙有没有放行。非阻塞 connect 是另一个话题把 fd 设为非阻塞后connect()会立即返回但连接可能还没有建立。如果返回值是 -1 且 errno 是EINPROGRESS说明连接正在后台进行你需要用select()或poll()等待这个 fd 变成可写再通过getsockopt查SO_ERROR判断最终成功与否。这个技巧在需要控制连接超时的客户端里非常实用。4.3 send/recv真正读写的是内核缓冲区send()的返回值是成功写入本机发送缓冲区的字节数不是对端recv()收到的字节数。这是一个经常被误解的点。数据从发送缓冲区到对端接收缓冲区中间经过网卡、协议栈、重传机制这些都是内核异步完成的。recv()返回的是本次从接收缓冲取出的字节数。返回 0 有一个非常明确的含义对端已经关闭了连接你再怎么读也读不到新数据。处理这个场景时正确姿势是调用close()释放本地 socket而不是继续循环 recv。还有个实际现象值得留意TCP 是字节流协议send()1000 字节对端可能一次recv()读回 1000也可能分两次读回 600 和 400。这和发送端、接收端的缓冲区大小、网络拥塞状态都有关系。所以不能假设一次 send 对应一次 recv这个边界问题直接引出了第 6 章的“粘包与半包”。4.4 shutdown 和 close告别方式完全不同close()会减少 socket 的引用计数只有引用计数降到 0内核才会发起 FIN 关闭连接。如果同一个 socket 被 fork 到父子进程两边各自持有引用只有两边都 close 才会真正断开。shutdown(fd, SHUT_WR)则是立即切断发送方向的数据流发送 FIN 给对端但保留接收方向继续读数据。这个能力在处理“客户端发完请求但还想等服务端响应”时非常有用是优雅关闭协议的基础。实际开发中接收方如何知道“对端已经发完数据”最自然的方式就是recv()返回 0因为对端调用close()或shutdown(SHUT_WR)后FIN 使接收端半关闭。这个语义构成了很多应用层协议的设计前提。5. UDP 编程无连接带来的自由和坑5.1 UDP 最小服务端与客户端UDP 代码比 TCP 轻盈不少因为它没有 listen、accept、握手这些阶段。服务端 bind 后直接 recvfrom客户端 socket 后直接 sendto。服务端#include stdio.h #include string.h #include stdlib.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 9001 int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { perror(socket); exit(1); } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } char buf[1024]; while (1) { struct sockaddr_in peer; socklen_t peer_len sizeof(peer); ssize_t n recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)peer, peer_len); if (n 0) { perror(recvfrom); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); printf(recv %zd bytes from %s:%d\n, n, ip, ntohs(peer.sin_port)); sendto(fd, buf, n, 0, (struct sockaddr *)peer, peer_len); } close(fd); return 0; }客户端#include stdio.h #include string.h #include stdlib.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define SERVER_IP 127.0.0.1 #define SERVER_PORT 9001 int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { perror(socket); exit(1); } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, addr.sin_addr); const char *msg hello udp; sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)addr, sizeof(addr)); char buf[1024]; struct sockaddr_in peer; socklen_t peer_len sizeof(peer); ssize_t n recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)peer, peer_len); if (n 0) { buf[n] \0; printf(recv: %s\n, buf); } close(fd); return 0; }注意两点第一每次收包都要从 recvfrom 的peer参数里取出发送端地址因为 UDP 没有连接状态内核不会替你记住上一个包的来源。第二recvfrom 一次调用只会从队列里取一个完整报文一个 UDP 报文最大受 MTU 限制超过 MTU 后在 IP 层可能出现分片应用层要尽量避免发超大报文。5.2 UDP 的“断连”谁会告诉你对端不在了UDP 是面向无连接的说的极端一点发完包就松开手对端在不在、收不收你全都不管。当对端主机拒绝连接时系统可能在 ICMP 包层面给出“端口不可达”信息。如果这个 UDP socket 曾经connect()过固定对端后续recv()或send()可能收到ECONNREFUSED但如果只是普通sendto()这个错误码不一定会及时上报。更常见的情况是UDP 对端进程崩溃或机器断网本端毫不知情消息继续发送无人接收。所以在需要感知对端状态的 UDP 应用里必须自己设计心跳定期发探测包超过时间没收到回应就判定对端不可用。5.3 TCP 还是 UDP这不是速度之争是容忍度问题用一张表说清楚核心差异维度TCPUDP连接状态面向连接有三次握手无连接直接发数据可靠性可靠重传保证不可靠丢了就丢了顺序保证到达顺序不保证顺序消息边界无边界字节流有边界一个包一条消息开销维护状态头部开销大头部小无状态适用场景文件传输、RPC、数据库音视频、游戏同步、DNS很多人在“TCP 比 UDP 快”这个问题上有执念。实际体验中UDP 的低延迟来自无需握手和重传但它在弱网环境下丢包会让体验变得非常不稳定。如果你做的是转账、数据库同步这类不容丢数据的业务哪怕 UDP 省了那点握手时间应用层要自己补的可靠性代码也会把省下的复杂度加倍奉还。5.4 给 UDP 补可靠性应用层要做什么如果业务确实需要 UDP 的低延迟又希望获得类似 TCP 的可靠性可以在应用层实现以下机制每个报文带自增序号接收方据此检测丢包和乱序发送方启动重传定时器未收到 ACK 就重发接收方定期回 ACK或者采用 NACK 方式只反馈“缺哪些序号”引入拥塞控制避免大量丢包后无节制重传把链路打满这套逻辑听起来就是简化版 TCP。现实中很多高性能方案走的是 QUIC 这种基于 UDP 的可靠协议把握手和重传放到用户态内核里只做 UDP 收发。如果项目有时间钻研这条路可以走否则老老实实 TCP 更稳妥。6. 高频报错排查与长连接设计这些年踩过的 socket 坑6.1 bind 报 Address already in useTIME_WAIT 与 SO_REUSEADDR 的恩怨文章开头提到的bind: only one usage of each socket address是 socket 初学者的第一个拦路虎。当 TCP 连接关闭时主动关闭方会进入 TIME_WAIT 状态等待 2 倍 MSL 时间后才彻底释放。这样设计是为了防止“旧连接的延迟报文”被新连接错误接收。socket 底层地址和端口在 TIME_WAIT 期间看起来仍被占用所以立即重启服务去 bind 同一个端口就会失败。查看哪些连接还停留在 TIME_WAITss -tan state time-wait如果确认是自己的服务频繁重启导致SO_REUSEADDR是最好的解药它允许监听 socket 绑定到仍在 TIME_WAIT 状态的端口。绝大多数 web 服务都会设置这个选项因为这关系到服务能不能秒级重启。如果端口被完全不相关的进程占用先用lsof -i :9000查看占用进程确认没有业务在上面跑之后再做处理。切忌无脑 kill先搞清楚这个端口为什么被占用。另一个容易误用的选项是SO_REUSEPORT它允许多个 socket 绑定同一个端口用于多进程负载均衡。它和SO_REUSEADDR不是一回事使用条件也更苛刻新手不要顺手加上否则会导致连接被随机分发到多个进程行为非常难排查。6.2 Connection refused、No route to host、Operation now in progress这三个报错是我在排障现场最常听见的含义完全不同。Connection refused目标主机可达但目标端口没有进程在监听或者防火墙主动回了 RST。排查链路按顺序来先确认对端服务是否启动再看服务监听的是不是0.0.0.0最后检查防火墙策略有没有挡住端口。No route to host说明三层网络层面就没打通通常是 IP 配错、路由缺失或对端主机宕机。先ping目标 IP不通就查网卡和路由表通但连不上特定端口再回到端口层排查。Operation now in progress则出现在非阻塞 connect 场景它不算失败而是告诉你“连接正在建立中稍后再看结果”。如果把它当成错误立即重试反而会制造一堆无意义连接。配合select()等 fd 可写后再检查SO_ERROR是标准做法。如果这些手段都查不出来上 tcpdump 抓包看 SYN 包有没有发出去、有没有回包、回的是 RST 还是 ICMP证据永远比猜测可靠。6.3 粘包、半包TCP 字节流没有边界很多人第一次做长连接时都会遇到这样的问题A 端连续 send 了两条消息B 端接收到的却是一整个大包或者 A 只 send 了一条消息B 第一次 recv 只读到其中一半。这就是 TCP 字节流特性带来的“粘包”和“半包”。TCP 不关心你应用层怎么划分消息它只负责按序搬运字节。发送端两次 send 的数据可能被内核拼在一个数据段里发出去接收端一次 recv 就会读到两条消息反之一条大消息可能被拆成多个 TCP 段接收端需要多次 recv 才能拼完整。解决思路只有一种在应用层定义消息边界。常见方案有三种固定长度每条消息固定 1024 字节不足补零读满为止。简单但浪费带宽不适合变长消息。分隔符比如 HTTP 的\r\n\r\n读到分隔符才算一条消息完整。实现简单但消息内容里不能出现分隔符需要转义。长度前缀每条消息开头用 4 字节网络字节序表示消息体长度接收端先读长度再按长度读体。这是最常用、最通用的方案。伪代码如下// 接收端先读4字节长度 char len_buf[4]; recv_all(fd, len_buf, 4); uint32_t msg_len ntohl(*(uint32_t *)len_buf); // 再读 msg_len 字节的消息体 char *body malloc(msg_len); recv_all(fd, body, msg_len);注意这里的recv_all必须循环调用 recv直到凑满期望的字节数否则仍可能遇到半包。6.4 心跳设计TCP keepalive 别指望太多TCP 层确实有 keepalive 机制但默认配置非常保守探测间隔通常以小时计算很多系统里默认不开启或参数极长。对于一个需要快速感知对端掉线的长连接系统只依赖内核 keepalive 不现实。应用层心跳是常规做法双方约定每隔 N 秒发一个心跳包连续 M 次没收到对端的任何数据就判定连接失效主动断开并触发重连。N 和 M 的取值要根据业务容忍度来定比如 N30M3也就是 90 秒内无任何数据就判定断线。心跳包的本质是让连接在空闲期也有数据流动从而触发 TCP 的保活、NAT 映射刷新顺便让对端有机会发现异常。有的协议会把心跳合并到业务消息里只要在 N 秒内收到任意包就算存活不必单独发空包这样可以减少无效流量。6.5 本机通信别忽略 Unix domain socketMySQL 报错里藏着它互联网上有大量“ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock”的提问。这个报错里的 socket 不是网络上用的 TCP socket而是 Unix domain socket也就是本地 socket 文件。Unix domain socket 不经过 IP 和端口只通过文件系统路径标识通信端点。内核在同机进程间直接拷贝数据不走网卡因此它比 TCP 回环还要快也更安全因为外部机器根本不可能访问到这个路径。MySQL 客户端在本机连接时会优先走/tmp/mysql.sock。报错通常是以下几种原因mysqld 没启动、socket 文件路径不一致、目录权限不对。排查时先看文件是否存在ls -l /tmp/mysql.sock再确认 mysqld 进程状态最后检查配置里 socket 路径和服务端是否一致。理解了 socket 远不止“网络编程”这四个字这类报错就不会让你手足无措。6.6 从阻塞到 epoll高并发场景的下一步当连接数从几十涨到几千、几万多线程的“一个线程一个连接”方案会很快撞上资源瓶颈。这时候需要用 IO 多路复用用一个线程同时监视大量 fd 的读写事件。最早是select()但有 fd 数量上限默认通常是 1024且每次调用都要把整个 fd 集合复制到内核性能随 fd 数量线性下降。poll()去掉了数量上限但依然是线性扫描。真正适合高并发的方案是 epollint epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev); // 在循环里等待事件 struct epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { // 处理 events[i].data.fd 上的可读事件 }epoll 只在有事件发生的 fd 上通知你不用每次遍历全部连接所以适合大量空闲连接的场景。但别迷信 epoll如果你的服务只有几十个连接用select()或线程池维护起来更简单代码也更易读。架构选型永远是在复杂度、性能和可维护性之间做取舍没有银弹。—— 以上这些是我把 socket 从“会用”到“能排障”过程中反复验证过的内容。带新人时我习惯让他们按三步做实验第一步用 nc 验证端口通不通第二步写单连接服务端和客户端第三步改成多连接并自己加上粘包处理和心跳过程中随时用 tcpdump 对照抓包。三步走完再回头看网上那些 socket 面试题基本都能理解背后的原理。你也试试这个路径遇到报错别慌抓包看证据一步一步把问题缩小socket 没那么难。