资讯动态

Linux Socket编程接口详解:从文件描述符到TCP收发

发布时间:2026/10/27 3:53:33 来源:尧图企业网站定制
1. 从一切皆文件说起socket返回值为什么是个普通整数第一次在 Linux 上写网络程序的人多半会卡在一个很反直觉的地方明明是要建立网络连接调用的接口却返回一个int。没有对象、没有句柄包装、没有回调就是一个整数。我当年看到int fd socket(AF_INET, SOCK_STREAM, 0);这行代码时第一反应是这玩意儿怎么用第二反应是这跟open()打开一个文件长得也太像了。答案是它就是故意长得像的。Linux 把套接字抽象成了文件描述符体系里的一员这套设计叫一切皆文件。理解这一点后面bind、listen、accept、send、recv这些接口的行为逻辑、返回值语义、错误码含义全都能顺着同一条线索推出来。所以这篇东西我不打算按手册顺序把函数原型抄一遍而是想把这套接口背后的设计意图、每个参数的取舍原因、以及我在实际项目里踩过的坑一层层拆开讲。适合谁看写过一点 C 语言、知道指针和结构体是什么、但网络编程还停留在抄个 demo 能跑就行阶段的人以及那些 demo 跑通了、一上真实流量就开始出现各种奇怪问题、想搞清楚底层到底发生了什么的人。全文会围绕 socket 编程接口这一组函数展开从描述符的本质讲起一直讲到怎么把这几个接口串成一个真正能用的服务端和客户端中间穿插字节序、地址结构、阻塞语义、返回值处理这些绕不开的细节。1.1 文件描述符表里多出来的那一格进程在内核里有一张文件描述符表本质是个数组下标就是那个整数。0、1、2 默认被标准输入、标准输出、标准错误占着所以你的第一个socket()调用大概率返回 3。这个数字不是随便给的内核总是分配当前最小的可用下标——这一点很重要因为它意味着描述符会被复用。你把一个连接close掉下一个socket()很可能就拿到同一个数字。如果你在别处还留着一份旧描述符的副本没清掉那你后面所有操作都会打到错误的连接上而且编译器不会给你任何提示。套接字在这个表里占的位置和普通文件占的位置是同一类槽位所以read()、write()、close()对套接字同样有效。这也是为什么很多老代码里直接用read/write收发数据而不是send/recv——它们在套接字上确实能工作只是少了几个控制标志位。反过来说fcntl()设置非阻塞、select/poll/epoll监听可读可写事件对套接字和普通文件是同一套机制。这一层统一是 Linux I/O 模型最漂亮的地方也是很多为什么我改个标志位就整个模型变了的困惑源头。不过套接字和普通文件有一个关键差异普通文件的读写基本不会等除非是管道或特殊设备而套接字的读写随时可能因为对端没发数据、发送缓冲区满了而阻塞。这个差异导致同一套接口在两类对象上表现完全不同后面讲阻塞语义时会重点说。1.2 socket()的三个参数各自在跟谁说话原型是int socket(int domain, int type, int protocol);三个参数分别回答三个问题在哪一层协议栈通信、用什么通信语义、具体用哪个协议。domain决定地址家族。日常开发里 95% 是AF_INETIPv4要支持 IPv6 就上AF_INET6。本地进程间通信可以用AF_UNIX它不经过网络协议栈性能比 TCP 回环高不少同一台机器上的服务间通信值得考虑。这里有个常见误区AF_INET和PF_INET在 Linux 上是同一个值历史遗留问题随便用哪个都行但同一个项目里最好统一别一半 AF 一半 PF看着乱。type决定通信语义。SOCK_STREAM是面向连接、可靠、有序、字节流SOCK_DGRAM是无连接、不可靠、保留消息边界。这两个是主力。SOCK_RAW需要 root 权限一般做抓包或自定义协议才用。Linux 还提供了可以按位或的组合标志比如SOCK_STREAM | SOCK_NONBLOCK直接创建非阻塞套接字SOCK_CLOEXEC让描述符在exec后自动关闭。这两个标志我强烈建议养成习惯用上SOCK_CLOEXEC能避免子进程意外继承监听套接字导致的端口被占用但查不到是谁占的这类灵异问题SOCK_NONBLOCK比先创建再fcntl少一次系统调用也少一个错误处理分支。protocol通常写 0表示让内核根据前两个参数选默认协议。AF_INETSOCK_STREAM就是 TCPAF_INETSOCK_DGRAM就是 UDP。只有在一个 type 对应多个协议时才需要显式指定实际项目里基本见不到。1.3 合法组合与最常见的误配socket()失败返回 -1 并设置errno几个高频错误码值得记住errno含义常见诱因EAFNOSUPPORT地址家族不支持写了AF_INET6但内核模块没加载EPROTONOSUPPORT协议不支持type 和 protocol 组合非法如SOCK_STREAM配IPPROTO_UDPEMFILE进程描述符耗尽连接没关泄漏了ENFILE系统级描述符耗尽整机层面的问题比较罕见ENOBUFS/ENOMEM内核内存不足高并发场景下猛开连接EMFILE这个错误我在线上环境见过不止一次症状是服务跑几天之后突然所有新连接都失败accept返回 -1。排查的时候光看业务日志完全看不出来得ls /proc/pid/fd | wc -l数一下描述符数量再跟ulimit -n对比。十有八九是某条错误分支上忘了close。这类问题的根因往往不在网络代码本身而在出错时提前 return的路径上——正常路径都有close异常路径漏了。还有一个容易忽略的点socket()只是个创建动作它不发起任何网络行为也不占用端口。你可以创建一万个套接字而一个都不 bind。真正的资源消耗从bind和connect才开始。理解这一点对排查问题有帮助——看到描述符数量上去了但端口没占用说明是创建了没用或者用完没关。2. bind之前必须理清的地址结构sockaddr与sockaddr_in的错位关系socket()之后紧接着就是bind()而bind()的第二个参数类型是const struct sockaddr *。几乎所有人第一次写的时候都会疑惑我明明要填 IP 和端口为什么传进去的是个sockaddr_in还要强转成sockaddr这两个结构体到底是什么关系答案很朴素sockaddr是个通用占位符它只保证前两个字节是地址家族标识剩下的字段布局完全由具体家族决定。sockaddr_in是 IPv4 的具体布局sockaddr_in6是 IPv6 的sockaddr_un是 Unix 域的。接口设计成接收通用指针是为了让同一组函数能服务所有家族——这是 C 语言里典型的手工多态。2.1 强制类型转换背后的内存布局struct sockaddr定义大致是这样struct sockaddr { sa_family_t sa_family; /* 2 字节地址家族 */ char sa_data[14]; /* 14 字节家族相关数据 */ };总共 16 字节。struct sockaddr_in也是 16 字节struct sockaddr_in { sa_family_t sin_family; /* 2 字节 */ in_port_t sin_port; /* 2 字节网络字节序 */ struct in_addr sin_addr; /* 4 字节网络字节序 */ char sin_zero[8];/* 8 字节填充必须清零 */ };前两个字节都是家族标识所以把sockaddr_in*转成sockaddr*传给内核内核先读家族字段判断是哪个家族再按对应布局解释后面的字节。这个转换在内存层面是零成本的只是让编译器闭嘴。sockaddr_in里那个sin_zero[8]是个历史包袱纯粹为了凑够 16 字节对齐。标准做法是用memset把整个结构体先清零再填字段。不 memset 的后果是这 8 个字节里可能是栈上的随机垃圾某些老实现或某些工具比如某些版本的getsockname输出会把它当有效数据看出现莫名其妙的地址显示错误。我个人的习惯是struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY);三步走雷打不动。这不是形式主义是省掉以后半夜爬起来查问题的成本。bind的第三个参数addrlen有个坑传sizeof(struct sockaddr)和传sizeof(struct sockaddr_in)在 IPv4 下都是 16没区别但如果将来代码要泛化到 IPv6sockaddr_in6是 28 字节写死 16 就会出问题。稳妥写法是sizeof(addr)让编译器替你算。2.2 字节序htons和htonl不是可选项网络字节序是大端而 x86 是小端。这意味着你直接addr.sin_port 8080;写进去内核看到的是 0x901F 而不是 0x1F90绑到完全不相干的端口上。而且这个错误不会报错端口会成功绑定只是绑错了地方。这里的换算函数有四个htonshost to network short16 位用于端口、htonl32 位用于 IPv4 地址、ntohs、ntohl反向。记法很简单s 结尾的是端口l 结尾的是地址。有个特别容易被搞混的点inet_pton和inet_addr这两个把点分十进制字符串转成二进制地址的函数出来的已经是网络字节序了不需要再套htonl。而INADDR_ANY、INADDR_LOOPBACK这些宏本身是主机字节序需要套htonl。新手经常在这里二重转换把地址转反了。/* 正确写法字符串转换后直接用 */ inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); /* 正确写法宏需要转换 */ addr.sin_addr.s_addr htonl(INADDR_ANY); /* 错误写法多转一次变成 1.0.0.127 */ addr.sin_addr.s_addr htonl(0x7f000001);顺便说一句inet_addr这个函数最好别用了它用INADDR_NONE0xFFFFFFFF也就是 255.255.255.255表示错误导致你没法区分输入合法但正好是广播地址和输入非法两种情况。inet_pton用返回值区分0 表示格式错-1 表示家族不支持语义干净得多。2.3 INADDR_ANY的绑定语义与安全取舍服务端一般写htonl(INADDR_ANY)值就是 0意思是本机所有网络接口上的这个端口我都要。如果机器有多块网卡、多个 IP写具体 IP 就只绑那一个。这两个选择背后是实实在在的取舍。绑INADDR_ANY方便服务部署到哪台机器都能起来不用改配置但暴露面也大如果机器上有一块对外网可见的网卡服务就直接对外了。绑具体 IP 更安全但机器 IP 变化、或者容器环境里 IP 是动态分配的时候就会绑定失败报EADDRNOTAVAIL。我遇到过服务在物理机上跑得好好的一进容器就起不来最后发现是配置文件里写死了宿主机 IP。实际项目里我的建议是默认INADDR_ANY然后用防火墙或安全组控制访问来源。别指望靠绑哪个 IP来做访问控制那层的粒度太粗而且很容易被绕过。bind的其他常见错误errno含义排查方向EADDRINUSE端口已被占用ss -lntp看是谁占的注意 TIME_WAIT 状态EACCES权限不足1024 以下端口需要 root 或CAP_NET_BIND_SERVICEEADDRNOTAVAIL地址不属于本机写死 IP 的典型症状EINVAL参数非法套接字已绑定过或 addrlen 不对3. listen与accept把被动连接变成可执行代码的那几行服务端和客户端的分水岭就在listen。调用listen之前这个套接字是主动的只能用来connect调用之后它变成被动的只接受别人的连接请求自己不能再connect。这个状态切换是不可逆的——你能shutdown一个监听套接字但不能把它变回主动套接字。int listen(int sockfd, int backlog);这个函数的参数少得可疑但backlog是最容易理解错误的一个参数我在面试里问过不少人答对的不到三成。3.1 backlog究竟控制哪个队列很多人以为 backlog 是最多能同时处理多少个连接。不对。它控制的是内核为这个监听套接字维护的队列长度上限。这里还有个更细的点Linux 上其实有两个队列。半连接队列SYN queue客户端发了 SYN服务端回了 SYNACK但还没收到客户端的 ACK连接处于SYN_RECV状态这些连接排在半连接队列里。这个队列的长度由net.ipv4.tcp_max_syn_backlog控制不受listen的 backlog 参数影响。全连接队列accept queue三次握手完成连接进入ESTABLISHED状态等着应用层调accept取走。这才是backlog控制的队列。队列满了之后新完成的连接会被内核丢弃默认行为是丢掉并回 RST或者忽略取决于tcp_abort_on_overflow。关键点Linux 实际生效的 backlog 是min(backlog, net.core.somaxconn)。somaxconn在老内核上默认是 128新内核5.4 之后默认 4096。这意味着你代码里写listen(fd, 1024)在旧内核上实际只有 128。我见过一个服务在高并发下大量连接被拒查了半天代码逻辑最后发现就是 backlog 被内核截断了改somaxconn加调整 backlog 之后立刻好转。所以实践建议写listen(fd, SOMAXCONN)这个宏会展开成系统最大值并且检查一下/proc/sys/net/core/somaxconn的值。如果预期 QPS 很高但 accept 处理不够快队列就是第一道防线别让它成为瓶颈。3.2 accept返回的新描述符和监听描述符是两回事int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);这个函数最容易被误解的地方是返回值。它返回的是一个全新的文件描述符代表这条具体的连接。原来的sockfd监听套接字继续有效继续监听。int listen_fd socket(...); bind(listen_fd, ...); listen(listen_fd, SOMAXCONN); while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int conn_fd accept(listen_fd, (struct sockaddr *)cli, len); if (conn_fd 0) { /* 错误处理 */ continue; } /* conn_fd 是这条连接的listen_fd 继续用 */ handle(conn_fd); close(conn_fd); }这里有两个细节值得强调。第一addrlen是值-结果参数。传进去时告诉内核缓冲区有多大返回时内核告诉你实际填了多少。如果客户端是 IPv6 而你的缓冲区是sockaddr_in内核会截断并更新addrlen为 16你拿到的地址信息是不完整的。正确做法是按最大可能尺寸准备缓冲区用struct sockaddr_storage128 字节能装下所有家族然后根据sa_family判断实际类型再转。第二accept返回 -1 不代表一定要退出。常见的可恢复错误有三个EINTR被信号打断继续循环、EAGAIN/EWOULDBLOCK非阻塞模式下暂时没有新连接正常现象、ECONNABORTED连接在握手过程中被对端放弃忽略继续。真正该退出的是EMFILE描述符耗尽和ENFILE。区分不清这几种情况写出来的代码要么在信号到来时莫名崩溃要么在描述符耗尽时死循环刷日志把磁盘写满。3.3 connect在三次握手里的阻塞点客户端这边是int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);。默认情况下它是阻塞的阻塞时长取决于内核的重传策略——通常几十秒到两分钟不等取决于net.ipv4.tcp_syn_retries。connect返回 0 表示连接建立成功返回 -1 时要看errnoerrno含义说明ECONNREFUSED对端拒绝目标端口没在监听内核直接回 RSTETIMEDOUT超时目标不可达或中间设备静默丢包ENETUNREACH/EHOSTUNREACH网络/主机不可达通常是路由问题EINPROGRESS进行中只在非阻塞模式下出现不是错误EINPROGRESS这个返回值特别容易误判。非阻塞connect立刻返回 -1 并设EINPROGRESS意思是握手已经发出去了结果还没回来接下来要用select/poll/epoll监听这个描述符的可写事件可写了再用getsockopt(fd, SOL_SOCKET, SO_ERROR, err, len)取真正的结果。注意这里必须用SO_ERROR不能只看可写事件就认为连上了——连接失败时描述符同样会变成可写这是很多人写异步连接时的经典 bug。还有一个小细节connect失败之后同一个套接字不能直接重试 connect必须close之后重新socket。因为失败的连接会把套接字状态搞乱重试的行为是未定义的。4. 数据收发接口的返回值语义send和recv不是发多少就多少这是套接字编程里最容易写出 bug 的一块因为它跟大多数人的直觉相反send(fd, buf, 1000, 0)返回 1000 当然好但返回 300 是完全合法的不代表出错。TCP 是字节流协议内核有发送缓冲区SO_SNDBUF。send做的事只是把数据从用户空间拷到内核发送缓冲区缓冲区有空位就拷拷多少算多少。缓冲区满了就阻塞阻塞模式或返回EAGAIN非阻塞模式。所以send返回一个小于请求长度的正数语义是我收了这么多剩下的你自己再来一次。4.1 短写与短读的标准循环写法正确的发送逻辑必须循环直到全部发完或者出错ssize_t send_all(int fd, const void *buf, size_t len) { const char *p buf; size_t left len; while (left 0) { ssize_t n send(fd, p, left, MSG_NOSIGNAL); if (n 0) { if (errno EINTR) continue; /* 被信号打断重试 */ if (errno EAGAIN || errno EWOULDBLOCK) { /* 非阻塞模式下缓冲区满需要等待可写事件 */ return -2; } return -1; /* 真正的错误 */ } p n; left - n; } return (ssize_t)len; }接收同理。recv(fd, buf, 4096, 0)返回 500 说明这次只读到 500 字节想要更多得再调。不能假设一次 recv 就读到一个完整消息——这是新手写协议解析时的第一大坑在本地测试永远不出问题一跨机房就出现消息被截断的诡异现象因为路径上的 MTU、缓冲区大小、网络抖动都会影响分片。正确做法是自己在应用层定义边界定长消息、长度前缀先读 4 字节长度再读 body、或者分隔符。长度前缀是最通用的一种/* 先保证读满 4 字节长度头 */ if (read_full(fd, net_len, 4) ! 4) return -1; uint32_t body_len ntohl(net_len); /* 再按长度读满 body */ if (read_full(fd, body, body_len) ! (ssize_t)body_len) return -1;read_full内部就是个循环逻辑和send_all对称。这个读满 N 字节的辅助函数几乎每个网络项目里都得有建议一开始就封装好。4.2 返回0和-1分别意味着什么recv返回 0 是一个特殊信号对端已经有序关闭了连接发了 FIN。这时候你该做的是处理完剩余数据然后close自己这一端。绝对不能把 0 当成暂时没数据继续循环读那会变成死循环空转烧 CPU。recv返回 -1 要分三种情况EINTR重试EAGAIN/EWOULDBLOCK在非阻塞模式下表示暂时没数据正常其他值才是真正的错误比如ECONNRESET对端发了 RST通常是对方进程崩了或者强制关闭。ECONNRESET在实际环境里非常常见尤其是客户端进程被kill -9的时候——内核会替它发 RST。这时候你正在recv的话就会拿到这个错误。它是正常的异常记录日志就行不用当故障处理。4.3 SIGPIPE那个会悄悄杀掉进程的信号这个坑我必须单独说因为它太隐蔽了。当你send到一个已经被对端关闭的连接时第一次send通常返回成功数据进了缓冲区第二次send会触发SIGPIPE信号。SIGPIPE的默认行为是终止进程。也就是说你的服务端在某个客户端异常断开后直接整个挂掉日志里什么都没有。解决方案有两个选一个即可全局忽略signal(SIGPIPE, SIG_IGN);放在main开头。每次发送带标志send(fd, buf, len, MSG_NOSIGNAL);我倾向于两个都用全局忽略作为兜底防止某些第三方库内部调用触发关键发送路径带MSG_NOSIGNAL明确意图。这样即使别人接手代码也能一眼看出这里考虑过这个问题。顺带提一下MSG_MORE标志它告诉内核我还有数据要发先别急着推出去配合 TCP_CORK 可以减少小包数量对提升吞吐有帮助。不过这类优化要在真的测出瓶颈之后再做过早优化只会增加复杂度。4.4 send/recv、read/write、sendto/recvfrom的适用边界同一件事有好几套接口什么时候用哪套接口适用场景说明read/write通用套接字和文件都能用无法传标志位处理 SIGPIPE 要靠全局忽略send/recvTCP 专用能传MSG_NOSIGNAL、MSG_DONTWAIT等标志sendto/recvfromUDP 专用每次收发都要带地址因为无连接sendmsg/recvmsg需要一次操作多个缓冲区的场景支持分散聚集 I/O高性能场景用TCP 场景我建议统一用send/recv理由就是标志位。UDP 必须用sendto/recvfrom因为数据报是自带地址的你没法像 TCP 那样连接一次然后随便发虽然 UDP 也能connect但那只是给套接字设了个默认目标地址不发包。另外shutdown和close的区别也值得说一句。close是减引用计数减到 0 才真正关闭如果有fork出来的子进程还持有这个描述符close不会发 FIN。shutdown(fd, SHUT_WR)则会立刻发 FIN告诉对端我不再发数据了但还能继续收。半关闭在实现请求-响应后关闭写端等对方把剩余数据发完这类协议时很有用。5. 把这几个接口串成一个能跑的最小骨架理论讲完得落地。下面这套代码我刻意写得朴素没有封装、没有错误处理抽象层目的是让每个接口调用和它的意图一一对应方便对照着看。5.1 服务端从socket到accept的完整链路#include stdio.h #include string.h #include unistd.h #include errno.h #include signal.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h int main(void) { signal(SIGPIPE, SIG_IGN); int listen_fd socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (listen_fd 0) { perror(socket); return 1; } int on 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); 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; } if (listen(listen_fd, SOMAXCONN) 0) { perror(listen); close(listen_fd); return 1; } for (;;) { struct sockaddr_storage cli; socklen_t cli_len sizeof(cli); int conn_fd accept(listen_fd, (struct sockaddr *)cli, cli_len); if (conn_fd 0) { if (errno EINTR || errno ECONNABORTED) continue; perror(accept); continue; } char buf[1024]; ssize_t n; while ((n recv(conn_fd, buf, sizeof(buf), 0)) 0) { ssize_t off 0; while (off n) { ssize_t w send(conn_fd, buf off, n - off, MSG_NOSIGNAL); if (w 0) break; off w; } } close(conn_fd); /* 关键每条连接都要关 */ } }这段代码里每一行的意图值得过一遍SOCK_CLOEXEC防止exec后泄漏SO_REUSEADDR解决重启时的EADDRINUSEmemset清sin_zeroSOMAXCONN拿系统上限accept的EINTR/ECONNABORTED分开处理recv返回 0 自然退出循环内层send循环处理短写close(conn_fd)在循环末尾而不是continue分支里漏掉。真实项目里当然不能这么写——单线程串行处理一个慢客户端就能卡死所有人。但作为理解接口的起点这个骨架的价值在于每个调用都能对应到一个具体语义等你要换成 epoll 或者线程池的时候改动是局部的不会连原理都搞不清。5.2 客户端connect的失败分支怎么处理int fd socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); struct sockaddr_in srv; memset(srv, 0, sizeof(srv)); srv.sin_family AF_INET; srv.sin_port htons(9000); inet_pton(AF_INET, 127.0.0.1, srv.sin_addr); if (connect(fd, (struct sockaddr *)srv, sizeof(srv)) 0) { perror(connect); close(fd); return 1; }connect的错误分支里必须close这点和socket失败时的处理不同——socket失败没拿到描述符不用关connect失败时描述符已经创建了不关就是泄漏。这个不对称是很多人第一次写就踩的坑尤其在重试逻辑里如果失败就sleep然后重新socket但旧的没关跑一会儿就EMFILE了。5.3 用ss和nc验证每个阶段代码写完了怎么确认每一步真的生效我的习惯是用系统工具逐层验证而不是一上来就抓包。服务端listen之后先ss -lntp | grep 9000应该能看到LISTEN状态和进程 PID。如果没有说明bind或listen出问题了这时候去看前面perror的输出。这一步能过滤掉一大半服务起不来的问题。客户端连上之后再ss -ntp | grep 9000应该能看到两条ESTABLISHED一条是服务端的conn_fd一条是客户端的fd。如果只有一条说明另一端的accept没成功或者已经关了。要模拟对端突然消失用nc最方便nc 127.0.0.1 9000连上之后CtrlC观察服务端是否正常收到recv返回 0 并关掉连接。这一步特别重要因为真实环境里客户端异常断开是常态你的代码必须能扛住。再模拟对端强制断开写个小脚本连上之后不发 FIN 直接进程退出kill -9服务端应该收到ECONNRESET。这时候如果没有忽略SIGPIPE进程很可能已经没了。6. 实测中反复出现的几类问题与排查链路这一节是我这些年攒下来的基本覆盖了用这套接口时 80% 的莫名其妙。6.1 EINTR慢系统调用被信号打断accept、recv、connect、send这些可能阻塞的函数统称慢系统调用。当进程收到信号时如果这个信号有处理器且没设SA_RESTART标志这些调用会被打断并返回 -1errno设为EINTR。这个行为在你没装任何信号处理器的时候不会出现默认处理是终止或忽略不会打断。但只要你用了alarm、SIGCHLD处理子进程、或者某些库内部装了SIGALRM就会开始偶发EINTR。症状很有特点随机出现、频率和信号频率相关、换个环境就消失。处理方式只有一个——if (errno EINTR) continue;所有可能阻塞的调用都要加。我见过一段代码只在accept加了send没加结果在高负载下偶尔丢一次响应查了两天才定位到。顺便说用sigaction装处理器时设上SA_RESTART可以让大部分慢调用自动重启但accept这类不一定生效取决于具体实现别指望它兜住所有情况。6.2 SO_REUSEADDR到底解决的是哪个问题很多人对它的理解是重启服务时端口不会被占用这个说法不准确。准确地说它解决两件事一是TIME_WAIT状态下的端口重用二是绑定INADDR_ANY时与已绑具体 IP 的套接字共存。TIME_WAIT是什么主动关闭连接的一方在发完最后一个 ACK 之后要等 2MSLLinux 上大约 60 秒才能释放端口。这段时间里这四元组不能被复用。服务端重启时如果之前的连接是服务端主动关的很常见比如读完请求就关端口就卡在TIME_WAIT新进程bind直接EADDRINUSE。加上SO_REUSEADDR之后允许新套接字绑到处于TIME_WAIT的端口上只要四元组不冲突。注意它不能解决同时有两个进程绑同一个端口的问题那个要靠SO_REUSEPORT多进程负载均衡场景用。排查TIME_WAIT的命令ss -tan state time-wait | wc -l看数量ss -tan state time-wait | awk {print $4} | sort | uniq -c看集中在哪些端口。如果某个端口堆积了成千上万的TIME_WAIT说明这个服务在频繁做短连接且由它主动关闭考虑改成连接复用或者让客户端主动关把TIME_WAIT转移到客户端那边去。6.3 描述符泄漏的定位手法前面提过EMFILE这里给一套完整的排查链路。第一步确认是不是真泄漏ls /proc/pid/fd | wc -l看当前数量隔一段时间再看单调递增就是泄漏。第二步看这些描述符是什么ls -l /proc/pid/fd | awk {print $11} | sort | uniq -c | sort -rn如果socket:[12345]占了大头就是网络层面漏了如果是普通文件路径那是另一类问题。第三步找泄漏点for fd in /proc/pid/fd/*; do echo $fd - $(readlink $fd); done | grep socket | head -20把 inode 号拿出来去ss -tanp里匹配看这些连接是ESTABLISHED还是CLOSE_WAIT。CLOSE_WAIT堆积是泄漏的典型指纹对端发了 FIN你的进程收到了所以进入CLOSE_WAIT但你的代码没有close这个描述符于是永远停在这个状态。看到CLOSE_WAIT数量持续上涨基本可以断定是某条代码路径漏了close——而且往往就在错误处理分支上。6.4 一个容易被忽略的小细节close之后别再碰那个fdclose(fd)之后fd这个数字就还给内核了。如果你在别的地方还保存着这个数字并再次使用有两种可能返回EBADF好情况或者更糟——这个数字已经被分配给了一个新连接你的操作打到了完全不相干的连接上。这类 bug 极其难查因为现象是不规律的数据发错人、连接莫名断开、日志对不上。根因是代码里存在野描述符引用。防御手段很简单close之后立刻把变量置为 -1所有使用前先判断if (fd 0) return;。这个习惯养成了能省掉很多时间。还有一个相关的坑多线程环境下一个线程正在recv另一个线程close了同一个 fd行为是未定义的。正确做法是用shutdown(fd, SHUT_RDWR)唤醒阻塞的recv它会返回 0等那个线程退出后再由主线程close。7. 从这套阻塞接口往后再走一步的方向把socket到close这一组接口吃透之后往下走的路其实很清楚但方向取决于你的场景。如果并发量不大几十上百个连接阻塞 每连接一个线程完全够用代码简单好维护出问题好排查。别一上来就上 epoll复杂度带来的收益在小规模下是负的。如果连接数量到了几千上万阻塞模型就撑不住了线程切换的开销和栈内存占用都会成为瓶颈这时候需要考虑 IO 多路复用。epoll的核心思想是把等哪个描述符可读可写这件事交给内核批量处理你的代码只在事件就绪时才去处理。有意思的是epoll用到的还是同一批接口——accept、recv、send一个都没变只是调用时机从阻塞等待变成了事件触发后再调。所以这一层的基础扎实了往上走的成本很低。再往上一层才是异步 IO、协程这些。但无论封得多高底层跑的还是这几个系统调用。我见过太多人在框架层面调参数调到崩溃最后发现是对recv返回 0 的含义理解错了。最后分享一个我自己养成的习惯每写一个网络相关的错误分支就在注释里写清楚这个 errno 对应什么现象、要不要重试、要不要关 fd。当时多花十秒后面少熬几个通宵。这套接口的函数不多但每个函数的返回值语义、每个 errno 的含义、每种状态转换的触发条件组合起来是个不小的状态机。真正把它跑通靠的不是背手册而是在一次次为什么这里返回 0、为什么连接卡住不动的具体问题里把每个细节和实际现象对应起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑