
最近被问得最多的一个东西还是 Linux 下网络编程要怎么看、怎么学。很多新手甚至干了一两年的后端同学手里拿着《Unix 网络编程》那本厚得像砖头一样的书却不知道哪些 API 是日常真正在用的哪些只要听过名字就行。这篇文章我就用实际项目里最常见的调用路径把 Linux 网络编程的核心 API 全部过一遍告诉你每个函数是干什么的、参数怎么填、有哪些坑是文档里不会明说但一定会踩到的。这篇文章适合两类人看一类是刚入门网络编程想知道从哪下手的新手另一类是写过一点 socket 代码但遇到性能问题或者诡异 bug 时只能瞎猜的开发者。我尽量把每个函数都配一个真实场景你遇到相同问题的时候可以直接照着排查。1. 从 socket() 到 close()一条完整连接的生死全程网络编程说穿了就是围绕一个文件描述符做文章。服务端创建 socket、绑定地址、进入监听状态、接受连接客户端创建 socket、发起连接然后双方收发数据最后关闭连接。这个过程里涉及的 API 不多但每个函数的参数和返回值的坑都不少。1.1 socket() 创建端点domain、type、protocol 怎么选理论上 socket() 有两个参数就够用第三个 protocol 填 0 就行。但很多人不清楚为什么 TCP 要写成SOCK_STREAMUDP 要写成SOCK_DGRAM这和内核里协议栈的注册机制有关。#include sys/socket.h int sockfd socket(AF_INET, SOCK_STREAM, 0); // TCP int sockfd socket(AF_INET, SOCK_DGRAM, 0); // UDP int sockfd socket(AF_INET6, SOCK_STREAM, 0); // IPv6 TCP第一个参数domain选择地址族也就是你将来要跟谁通信。AF_INET是 IPv4AF_INET6是 IPv6。现在写新代码我建议直接考虑 IPv6因为国内很多云厂商的负载均衡已经全面 IPv6 化了双栈环境下手写AF_INET有时候会遇到 bind 失败的问题。第二个参数type决定套接字的语义SOCK_STREAM提供有序、可靠、基于字节流的双向连接SOCK_DGRAM提供无序、不可靠、基于数据报的连接。第三个参数protocol在绝大多数场景填 0让内核根据前两个参数自动选择协议。有个不太被人注意的点你可以在socket()里额外加上SOCK_NONBLOCK和SOCK_CLOEXEC标志例如socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0)。这两个标志能在创建时一次性完成两件过去要分别调fcntl()才能做的事。SOCK_CLOEXEC防止exec后子进程意外继承这个 fd 导致端口释放不掉SOCK_NONBLOCK则避免 accept 回来的 fd 在继承时仍处于阻塞模式。线上服务中我建议一上来就带上这两个标志省得后续还要再单独设置。1.2 bind() 绑定本地地址端口复用和地址选择的小心思bind() 做的事情是把 socket 和本地地址绑定服务端不 bind 就没法被客户端找到。客户端的 bind 不是必须的内核会自动分配临时端口。但在固定本地端口的场景下比如数据库客户端程序要求从指定端口出去你就需要显式 bind。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); if (bind(sockfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return -1; }INADDR_ANY表示监听所有网卡一般服务器都是这么干的。如果你的机器上有多个 IP且只希望服务跑在其中一个 IP 上就把sin_addr.s_addr设成那个 IP 的网络字节序整数。bind 有四个高频报错场景我逐个说EADDRINUSE端口已被占用。最常见的处理是设置SO_REUSEADDR这个选项允许 TIME_WAIT 状态下重用本地端口。但注意不要把SO_REUSEADDR和SO_REUSEPORT混为一谈后者允许多个 socket 绑定同一个端口做负载均衡Linux 3.9 之后才支持用法也完全不同。EACCES绑定 1024 以下的特权端口需要 root 权限或CAP_NET_BIND_SERVICE能力。如果你在容器里跑服务注意容器编排系统分配能力的方式。EADDRNOTAVAIL要绑定的 IP 不是本机的 IP也就是写错了地址或者没等网卡就绪。EINVAL最常见的诱因是 socket 已经进入了连接状态或者 bind 已经在之前被调用过。我在调 bug 时见过不少人在循环里重复 bind。1.3 listen() 与 accept()让内核帮你排队listen() 只干一件事把主动 socket 变成被动 socket并告诉内核这个 socket 的完成连接队列能排多长。第二个参数 backlog 就是队列长度。这里有一个流传很广的误解backlog 是最大并发连接数。实际上不是backlog 只控制尚未被accept()取走的已完成连接队列的长度。已完成连接指的是 TCP 三次握手已经完成连接已经建立成功就等你的应用层去取。它和最大并发数是两个维度你的应用如果每秒钟只 accept 一次哪怕 backlog 是 128也可能把完成队列塞满。listen(sockfd, 128);backlog 填多少合适Linux 内核 2.2 以后backlog 已经是已完成连接队列长度上限。但内核会对这个值做修正比如做一些向上取整并乘以 2 的操作。硬要建议的话传统 Linux 服务填 128 或者 256 即可现在内核允许你填更大但注意队列越长当进程被占满时新连接等待时间越长客户端可能先超时然后不断重试反而加剧系统压力。我一般填 128配合比较快的 accept 处理循环效果很好。accept() 从队列里取一个连接返回一个新的 fd 用于和该客户端通信这个过程不阻塞其他连接排队等待。int conn_fd accept(listen_fd, (struct sockaddr *)peer_addr, addr_len);很多新手会有一个错误习惯从 accept() 返回的客户端地址里去读端口和 IP。如果你只关心有没有连接进来可以直接传 NULL但如果你做白名单或者审计那么读出来存下来是必要的。不过要小心 addr_len 的初始化必须提前设成sizeof(peer_addr)否则内核会把无效地址写回来而且可能报 EINVAL。这个错很隐蔽因为编译器完全不报错行为也不稳定。我在生产环境排查过两次这种情况最后都是栽在最基础的 addr_len 初始化上。1.4 connect()客户端的临门一脚connect() 是客户端主动发起连接的核心调用。它在 TCP 场景下会触发三次握手默认是阻塞的也就是说直到握手完成或出错才返回。if (connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect); }connect 的错误信号很有讲究。最典型的是ECONNREFUSED这个错说明服务端根本没有监听那个端口内核 RST 包直接回来了。另一种常见的是ETIMEDOUT这表示 SYN 包发出去石沉大海没有响应。如果直接EHOSTUNREACH则往往是对端路由器不可达。如果 socket 被设置成非阻塞connect 不会立刻返回成功而是返回EINPROGRESS。这时候你需要用 select 或 epoll 去等这个 fd 变可写然后再用getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)取出真正的连接结果。有的同学在这里会犯一个大错即认为 select 返回可写就说明连接成功其实如果连接失败fd 同样会变可写你必须再查一次 SO_ERROR 才能确定。这个细节在写高并发连接器时很关键我见过不少线上故障都是因为少查了这一步。1.5 close() 与 shutdown()快速回收和优雅断开的博弈close() 减少 fd 引用计数只有当引用计数归零时才真正发送 FIN 并关闭连接。这个特性在 fork 之后特别重要父子进程各自持有同一个 socket 的引用只有大家都 close 了才真正断开。shutdown() 则不管引用计数直接控制连接的读写方向。SHUT_WR表示关闭写方向发送 FINSHUT_RD表示关闭读方向直接丢弃后续到达的数据SHUT_RDWR两个方向都关但注意它不会发 FIN只是让这个 fd 上的读写都无效。实战经验告诉我服务器主动断开连接前先 shutdown 写方向然后再 close能让客户端第一时间感知到 EOF而不是等超时。这在做 HTTP/1.1 服务时尤其常见服务端返回完响应后先 shutdown 写方向客户端 read 才能返回 0。2. 收发数据核心 APIread/write、send/recv 与 sendto/recvfrom 的精细差异连接建好之后收发包的逻辑决定了服务的吞吐和延迟。很多人在这一步就停了只知道调用read和write完全没意识到还有更多精细控制的参数可以用。2.1 send() 与 recv()仅在 socket 上可用send()相当于带 flags 的write()第四个参数 flags 是它存在的唯一理由。recv()同理。ssize_t n send(conn_fd, buf, len, MSG_NOSIGNAL); ssize_t n recv(conn_fd, buf, len, 0);flags 里MSG_NOSIGNAL是我认为最重要的一个。默认情况下如果对端关闭连接后你还 write/send内核会向进程发送 SIGPIPE 信号进程默认动作是终止。如果你没做信号处理进程就莫名其妙地挂了。加上MSG_NOSIGNAL之后send 返回 -1errno 为 EPIPE你可以自己决定怎么处理出错连接。高并发服务我建议所有的 send 都加上这个标志。MSG_PEEK也很实用它可以把数据从内核接收缓冲区里“瞄一眼”而不真正取走。比如你可以在收协议头之前先 peek 一下看数据量够不够再决定要不要全量读取。这会减少一次系统调用的次数对某些协议解析场景有性能优势。recv()的返回值语义必须烂熟于心返回正数读到的字节数返回 0对端正常关闭了连接发送了 FIN返回 -1出错需要进一步区分阻塞与非阻塞的行为非阻塞模式下如果接收缓冲区没有数据recv 返回 -1 且 errno 为EAGAIN或EWOULDBLOCK这不算真正的错误只是告诉你现在没有数据。很多刚接触非阻塞编程的人一看到 -1 就当错误处理毛躁地关掉连接这是一个非常典型的误判。2.2 sendto/recvfromUDP 和原始套接字的标配UDP 没有连接的概念所以 sendto/recvfrom 每次都要带上目标地址。ssize_t n sendto(sockfd, buf, len, 0, (struct sockaddr *)dest_addr, sizeof(dest_addr)); ssize_t n recvfrom(sockfd, buf, len, 0, (struct sockaddr *)src_addr, src_len);这里有个性能要点如果 UDP socket 已经通过 connect() 指定了对端地址那么你可以直接用 send()/recv() 来收发包内核会省去每次地址复制的开销。UDP 下 connect() 不做握手只是设定默认对端。还有一个隐藏点容易被忽略UDP recvfrom 有收包大小限制。如果缓冲区比对端发来的数据报小内核会截断数据包并设置MSG_TRUNC标志位该数据报剩余部分直接丢弃。所以 UDP 收包缓冲区要么足够大要么处理MSG_TRUNC否则数据会悄无声息地被截断业务层很难发现。if (flags MSG_TRUNC) { // 数据被截断需要扩大缓冲区 }2.3 recvmsg/sendmsg统一派生的超集接口recvmsg/sendmsg 有点像一个多合一接口它可以同时携带多个缓冲区分散读/集中写、辅助数据control message、以及各种 flags。比如你想同时读到发送者的 IP 和端口想带 out-of-band 数据一起收都可以通过它实现。struct msghdr msg; msg.msg_name src_addr; msg.msg_namelen sizeof(src_addr); msg.msg_iov iov; msg.msg_iovlen 1; recvmsg(sockfd, msg, 0);recvmsg 强大是强大但日常业务直接用它的场景不多。它更多见于库和框架底层比如 Redis 的 epoll 事件循环会用它一次收多个数据片段。我建议你先掌握 recv 的常规用法等真遇到拆分多个 header 再回头研究这个。2.4 收发缓冲区与字节流的边界问题TCP 是字节流协议它不保证你一次 recv 到的数据正好等于一次 send 的数据。这就是所谓的拆包和粘包。解决思路通常是要么定长包每个包固定字节数要么在包开头加一个长度字段要么使用某种分隔符。// 常见长度字段协议 struct packet_header { uint32_t body_len; // 网络字节序 };收包时先把 4 字节的包头读完整解析出 body_len再根据 body_len 去循环读取剩余数据直到读完一个完整包。循环读取这个动作千万不要省因为内核不保证一次 recv 就能把想要的长度全部返回。即使阻塞模式下 recv 没超时也可能返回部分数据。我写过一个工具类来简化这个逻辑核心就是维护一个应用层缓冲区把每次 recv 到的数据追加进去然后循环检查当前缓冲区是否够一个完整包头够的话解析出长度再做整包的切割。这个模式基本适用于所有 TCP 业务。3. 网络字节序与地址结构新手最容易翻车的地方网络字节序这块属于“看起来很简单但错误代价极高”的知识点。你要是把端口传错了顺序客户端连的就不一定是你要连的那个端口而且这种错误不会报错只是你觉得服务不可用。3.1 htons/htonl/ntohs/ntohl大小端转换的关键TCP/IP 协议栈规定多字节整数在网络中统一使用大端字节序。而 x86 和大多数 ARM 处理器是 little-endian 的所以你要在主机字节序和网络字节序之间做转换。uint16_t port htons(8080); // host to network short uint32_t addr htonl(INADDR_ANY); // host to network long规则很简单往 sockaddr_in 里填值用 htons/htonl从 sockaddr_in 里取值读给用户看用 ntohs/ntohl。但这里有一个人尽皆知却有人不断犯的细节htonl和ntohl操作的是 32 位整数htons和ntohs操作的是 16 位整数。端口号必须用 htonsIP 地址必须用 htonl。如果你把端口号用 htonl 转了结果是未定义的在 x86 上通常得到一个大错特错的端口。现在推荐的现代做法是尽量少用手动转换多用getaddrinfo这种高层接口它会返回已经填好网络字节序的 address帮你规避这块出错的风险。3.2 struct sockaddr_in 与 struct sockaddr 的关系纠结这两个结构体的区别是每个初学者的必经之路。简单说struct sockaddr是通用形式方便函数支持多种地址族struct sockaddr_in是 IPv4 专用形式。API 统一接收struct sockaddr *但你需要先填 IPv4 专用结构体然后强转。struct sockaddr_in addr; // 填充 addr 后 bind(sockfd, (struct sockaddr *)addr, sizeof(addr));如果你用 IPv6对应的是struct sockaddr_in6定义在netinet/in.h。编译器不对结构体类型做强制检查是因为当初设计就是靠长度和前几个字节区分类型的。3.3 inet_pton/inet_ntop字符串 IP 与二进制地址的双向转换inet_pton是推荐的新接口用来把点分十进制的字符串转成二进制的地址结构inet_ntop做反向操作。struct in_addr addr; if (inet_pton(AF_INET, 192.168.1.100, addr.sin_addr) ! 1) { // 转换失败 } char buf[INET_ADDRSTRLEN]; inet_ntop(AF_INET, addr.sin_addr, buf, sizeof(buf));注意返回值是 1 表示成功0 表示输入的字符串格式不对-1 表示地址族的支持问题。很多人只判了小于 0把格式错误忽略过去导致绑定了错误地址还在那里一头雾水。另外尽量使用INET_ADDRSTRLEN和INET6_ADDRSTRLEN来定义接收字符串的缓冲区大小这两个宏会自适应 IPv4/IPv6 的最大长度别自己拍脑袋定一个 64 或 128。3.4 getaddrinfo现代网络编程里的地址解析第一选择getaddrinfo 是我最想推荐所有人优先使用的接口。它把主机名解析、服务名解析、IPv4/IPv6 选择一次性搞定并且返回结果直接可用于 socket/bind/connect。struct addrinfo hints, *res; memset(hints, 0, sizeof(hints)); hints.ai_family AF_UNSPEC; // 允许 IPv4 或 IPv6 hints.ai_socktype SOCK_STREAM; // TCP int ret getaddrinfo(www.example.com, 80, hints, res); if (ret ! 0) { fprintf(stderr, getaddrinfo: %s\n, gai_strerror(ret)); return -1; }getaddrinfo 有一个坑是遇到多个解析结果时需要逐个尝试。比如一个域名解析出多个 IP你需要把 res 链表从头到尾走一遍先试第一个失败再试下一个全部失败才算失败。struct addrinfo *p; for (p res; p ! NULL; p p-ai_next) { int fd socket(p-ai_family, p-ai_socktype, p-ai_protocol); if (fd 0) continue; if (connect(fd, p-ai_addr, p-ai_addrlen) 0) break; close(fd); }这个接口在网络编程中还有一个大坑如果主机的 DNS 解析超时getaddrinfo 是一个阻塞调用会卡住当前线程好几秒。如果你在高并发的多线程环境下统一用 getaddrinfo 解析地址多个线程同时等 DNS 超时整个服务的线程池会瞬间被打满。所以有些极端场景下需要自己维护 DNS 缓存或者用异步解析方案。4. IO 多路复用与事件驱动select、poll、epoll 的取舍单线程处理多个连接的从底层讲就是 IO 多路复用。理解这三个 API 的差异是从“会写 socket”到“能写高并发服务”的必经台阶。4.1 select()老牌 API 的容量限制和使用要点select 的模型很简单把一组 fd 交给内核内核告诉你有哪些 fd 可读、可写、有异常。fd_set readfds; FD_ZERO(readfds); FD_SET(listen_fd, readfds); FD_SET(conn_fd, readfds); struct timeval tv {3, 0}; int max_fd conn_fd listen_fd ? conn_fd : listen_fd; int ret select(max_fd 1, readfds, NULL, NULL, tv); if (ret 0) { if (FD_ISSET(listen_fd, readfds)) { // 有新连接 } if (FD_ISSET(conn_fd, readfds)) { // 有数据可读 } }select 有几个很要命的限制fd_set 大小上限受 FD_SETSIZE 限制通常为 1024。这意味着你要管超过 1024 个连接时 select 帮不上忙。其次 select 每次调用都会把 fd_set 从用户态拷贝到内核态然后内核线性扫描全部 fdfd 多了之后性能急剧下降。还有一个坑是 select 会把 fd_set 原地修改所以下次调用前必须重新设置不然就没有监听效果了。4.2 poll()没有数量上限但仍然是线性扫描poll 通过数组方式突破了 FD_SETSIZE 限制用pollfd结构数组表达关注的事件使用方式比较人性化不再需要每次重新加入集合只需维持数组状态。struct pollfd fds[1024]; fds[0].fd listen_fd; fds[0].events POLLIN; int ret poll(fds, nfds, 3000); if (ret 0 (fds[0].revents POLLIN)) { // 有连接 }poll 仍然要全量扫描数组性能还是 O(n)。但比起 select 已经灵活很多。它在嵌入式 Linux 和兼容性要求高的场景下用得较多因为不支持 epoll 的平台也能用 poll。4.3 epoll()Linux 高并发的基石epoll 是 Linux 独有的也是目前高性能网络框架的事实标准。它用一组 API 而不是单个函数来控制事件集合。int ep_fd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[128]; int n epoll_wait(ep_fd, events, 128, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { int conn_fd accept(listen_fd, NULL, NULL); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; epoll_ctl(ep_fd, EPOLL_CTL_ADD, conn_fd, ev); } else { // 处理普通 fd 的读写 } }epoll 的事件类型这里要专门说EPOLLIN表示可读EPOLLOUT表示可写EPOLLERR表示错误EPOLLHUP表示挂断。在你收到 EPOLLIN 后去 read 返回 0说明对方关闭了连接这支 fd 要清理掉。epoll 有两种触发模式水平触发LT和边缘触发ET。水平触发默认只要数据没读完每次 epoll_wait 都会通知你。编程简单不容易漏数据。边缘触发只有状态从无数据变为有数据的瞬间通知你一次。如果你没把数据读完之后可能不再通知直到有新的数据到达。所以使用 ET 模式时必须一直读到返回 EAGAIN 为止。我个人的建议是新手用 LT简单不容易出问题需要追求极致的吞吐量时用 ET但必须严格要求每次 read 循环到 EAGAIN。如果你守着 ET 模式却在一次读取后退出循环在流量高峰时大概率会出现数据滞留导致业务超时。epoll_ctl 的 EPOLL_CTL_MOD 也经常被误用。想要修改一个 fd 监听的事件时用 EPOLL_CTL_MOD 而不是重新 ADD。重复 ADD 会返回 EEXIST很多人被这个错误搞懵。正确的打开方式先从 epoll 里 MOD再在 events 里按位或操作。4.4 三者的核心差异对照维度selectpollepoll最大 fd 数量FD_SETSIZE 限制理论无上限理论无上限时间复杂度O(n) 线性扫描O(n) 线性扫描O(1) 就绪链表fd 集合复用内核会修改集合需重置用户态维护数组内核事件表持久化跨平台性几乎所有平台几乎所有平台仅 Linux性能表现连接几百个可接受数千个一般上万连接首选触发模式仅水平触发仅水平触发LT ET如果做 Linux 本地的网络服务不用犹豫直接上 epoll。如果是写跨平台的开源库那么 poll 通常是平衡兼容性和性能的最佳选择。select 现在唯一的价值可能是在极老代码和维护低性能嵌入式设备时才会用到。5. 其他高频网络编程 API 与结构化看代码的顺序除了上面列的主流程 API实际工作里还会碰到不少配套函数不掌握它们会遇到很多莫名问题。5.1 setsockopt/getsockopt隐藏的配置开关setsockopt 是网络编程里的万能调节器。服务端构建时建议至少设置这几个选项int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); setsockopt(listen_fd, SOL_SOCKET, SO_KEEPALIVE, opt, sizeof(opt)); int send_buf 64 * 1024; setsockopt(conn_fd, SOL_SOCKET, SO_SNDBUF, send_buf, sizeof(send_buf));SO_KEEPALIVE会在空闲两小时以后自动发送探测包这是最小程度的死连接检测机制。注意它默认 2 小时对于大部分实时业务来说太慢了所以应用层通常还会自己加心跳。TCP_NODELAY是禁用 Nagle 算法。这个算法会把小包合并后发送减少网络包数量但同时增加了延迟。对延迟敏感的服务比如 RPC 通信一定要设置int nodelay 1; setsockopt(conn_fd, IPPROTO_TCP, TCP_NODELAY, nodelay, sizeof(nodelay));如果不关 Nagle 算法你可能遇到诡异的低吞吐场景一次 HTTP 请求返回后要等很久才收到数据尤其在有连续交互指令时非常明显。SO_RCVBUF和SO_SNDBUF可以调整内核 socket 缓冲区大小。但注意内核会把这个值翻倍并限制范围。比如你设置成 64KB内核可能实际给你 128KB。没必要把它当主性能调优点因为 epoll 非阻塞模式下应用层缓冲区往往才是核心瓶颈。5.2 fcntl()非阻塞模式的设置入口设置非阻塞有两种方式一种是创建时用SOCK_NONBLOCK另一种就是运行时用 fcntlint flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);这个模式在 epoll 模型中是标配把 fd 全部设为非阻塞通过 epoll_wait 驱动事件然后尽力读取直到 EAGAIN。5.3 getsockname/getpeername我是谁与对面是谁getsockname 获取本地地址getpeername 获取对端地址。当你需要输出日志时这两个函数很有用struct sockaddr_in peer; socklen_t len sizeof(peer); getpeername(conn_fd, (struct sockaddr *)peer, len); char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); printf(client: %s:%d\n, ip, ntohs(peer.sin_port));这个组合可以打印出每个连接的来源 IP 和端口对排查哪个客户端发来的异常请求非常重要。5.4 从源码角度理解这些 API 的调用顺序我刚学网络编程时真切感到迷茫的不是这些函数怎么用而是它们之间的调用顺序和层级关系。其实可以把它们归类成四个阶段创建与配置阶段socket() - setsockopt() - fcntl()地址绑定与监听阶段bind() - listen()客户端跳过直接 connect()接受连接与数据交互阶段accept() - recv/send - 循环读写关闭与清理阶段shutdown() - close()服务端的完整伪代码可以这样看int listen_fd socket(AF_INET, SOCK_STREAM, 0); setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, ...); bind(listen_fd, ...); listen(listen_fd, 128); while (1) { int conn_fd accept(listen_fd, ...); // 处理该连接或交给子进程/线程/事件循环 }客户端的完整伪代码更简洁int sockfd socket(AF_INET, SOCK_STREAM, 0); connect(sockfd, ...); send(sockfd, request, len, MSG_NOSIGNAL); recv(sockfd, response, len, 0); close(sockfd);把整个调用链记在脑子里后看任何网络库的代码都不会再一头雾水因为它的骨架大概率就是这么长的。6. 实战踩坑错误处理、边界条件与调试技巧网络编程里真正让人崩溃的不是 API 不会用而是出了问题之后不知道怎么定位。我总结了一些高频场景按排查顺序列出来。6.1 errno 与 perror先学会看错误码每个失败的系统调用返回 -1 后errno 会保存具体的错误原因。读 errno 是网络调试的基本功。我整理了最容易遇到的几个errno含义常见触发场景EAGAIN/EWOULDBLOCK资源暂时不可用非阻塞 socket 下无数据可读或者非阻塞 connect 还在进行中ECONNRESET连接被重置对端崩溃没发 FIN直接发了 RSTEPIPE管道破裂对端关闭后仍然尝试写数据配合 MSG_NOSIGNAL 可捕获ECONNREFUSED连接被拒绝对端端口没有进程监听ETIMEDOUT连接超时对端不可达或防火墙丢弃了 SYNEINTR被信号中断系统调用阻塞时收到信号需要重新调用或处理6.2 EINTR被信号打断的系统调用很多人写循环代码时忽略了 EINTR。场景通常是进程收到了信号如 SIGCHLD、SIGTERM阻塞中的系统调用被中断返回 -1 且 errno 为 EINTR。最标准的处理是重新调用一次while (1) { ssize_t n recv(fd, buf, len, 0); if (n 0 errno EINTR) { continue; } break; }如果你写过长期运行的高并发服务最后一定会遇到一次这个错误。6.3 常见错误排查链路如果客户端连不上服务端我的排查顺序是先确认 IP 和端口对不对拿telnet或者nc -vz试一下。确认服务端的 listen socket 是否在工作ss -lntp看有没有 LISTEN。检查防火墙iptables -L或者云安全组规则有没有放行端口。用 tcpdump 抓包看 SYN/SYN-ACK/RSTtcpdump -i any port 8080。如果服务端收不到数据顺序则是确认是否用了 epoll 且加了 accept 逻辑。确认是否设置了非阻塞读取时是否一直读到 EAGAIN。检查接收缓冲区是不是太小导致数据被内核丢弃。用ss -tin看对端的发送队列有没有堆积堆积说明应用层读取过慢。如果连接断得快优先怀疑心跳超时或 keepalive 配置不对。6.4 调试工具与技巧速记网络调试离不开工具我常用的几个nc最快速的 TCP/UDP 连通性测试工具tcpdump抓包分析排查握手、挥手、RST 来源ss列出连接状态、队列长度比 netstat 更详细strace追踪系统调用能看到你的程序在哪个调用上卡住了perf性能分析查 CPU 占用用 strace 是最直接的分析方法strace -p pid -f -e tracenetwork如果你怀疑程序卡在某个 socket 上strace 会直接告诉你它正在 read 哪个 fd。我有一次排查线上服务偶发卡顿就是用 strace 发现连接在recvfrom上等了很久最后定位到 UDP 包接收逻辑有 bug问题立刻浮出水面。6.5 关于信号处理的说明网络服务里常见信号有两个SIGPIPE和SIGCHLD。SIGPIPE 前面说过如果不加MSG_NOSIGNAL又不处理这个信号进程会被默默杀死。signal(SIGPIPE, SIG_IGN);SIGCHLD 适用于多进程模型下的子进程回收fork()之后要配合waitpid()避免僵尸进程。这两个信号是实战中必须会的。此外还有SIGURG用在带外数据场景下日常业务已经开始接触得很少了解即可。7. 从写 demo 到搭框架一套核心 API 的完整实战串联全文都快结束的时候我觉得还是应该给一套完整的代码逻辑框架让你把这些 API 串起来。下面这个例子是一个典型的单进程多连接 epoll 回声服务配合非阻塞 socket 和边缘触发模式。这段代码是简化版不能直接照抄应对生产环境但骨架是对的。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include netinet/in.h #include sys/epoll.h #include fcntl.h #define MAX_EVENTS 128 #define BUF_SIZE 4096 int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 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(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listen_fd, 128) 0) { perror(listen); return 1; } int ep_fd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; while (1) { int n epoll_wait(ep_fd, events, MAX_EVENTS, -1); if (n 0 errno EINTR) continue; for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { while (1) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { if (errno EAGAIN) break; perror(accept); break; } set_nonblock(conn_fd); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; epoll_ctl(ep_fd, EPOLL_CTL_ADD, conn_fd, ev); } } else { int fd events[i].data.fd; if (events[i].events (EPOLLHUP | EPOLLERR)) { close(fd); continue; } while (1) { ssize_t rlen recv(fd, buf, sizeof(buf), 0); if (rlen 0) { if (errno EAGAIN) break; perror(recv); close(fd); break; } else if (rlen 0) { close(fd); break; } else { send(fd, buf, rlen, MSG_NOSIGNAL); } } } } } }这段代码里面有个非常关键的细节listen socket 也设成了非阻塞accept 返回 EAGAIN 时说明完成队列已经没有连接了就退出内部 while 循环。如果你的监听 socket 不是非阻塞在边缘触发模式下连续 accept 可能会阻塞住整个事件循环这一条能让你省不少调试的时间。生产级框架里你还需要做几件事为每个连接维护一个应用层读缓冲区因为边缘触发模式下一次没办法保证读完接受新连接前检查当前连接数有没有超限对异常 fd 做主动清理并加入对应的日志。这些都属于架构层面的补充本文先不展开。8. 最后再分享几个经验之谈网络编程的 API 数量其实不算庞大核心的就十几个但每一个的细微差异都可能造成完全不同级别的线上事故。我自己这些年踩过的坑里最想拿出来提醒你们的有三件事。第一件写服务端时SO_REUSEADDR一定要设置。否则服务重启时如果还有连接处于 TIME_WAITbind 就会失败。这个问题在开发环境不明显因为流量小一旦生产环境每次发布都要等 60 秒才能重新监听端口那是完全没法接受的。set 这个选项带来的影响微乎其微但能避免一类非常尴尬的发布事故。第二件多进程模型里注意 fork 之后 socket fd 的引用计数。每个子进程都会复制一份父进程的 fd 表如果你在父进程里 accept 了连接再 fork那么这个连接 fd 在父子进程里都有引用。子进程不主动 close父进程即使 close连接也不会真正释放。这种事异常隐蔽表现形式是连接数缓慢增长最终到达上限。我的习惯是fork 之前把最近 accept 到的连接全部 close或者干脆用 Reactor 模型避免这种复杂度。第三件调试时多用ss -tnp看连接状态和用户态 socket 信息。很多你以为要抓包才能解释的问题ss 直接告诉你了。比如Recv-Q突然变大说明应用层没来得及收包Send-Q积压说明对端不服务或网络拥塞。这个命令的输出信息量远比 netstat 丰富是排查线上问题的好帮手。网络编程是一个靠实践积累的领域看 API 手册只能让你知道工具长什么样真正让你成为高手的是一次次的断连排查、一次次的内存泄漏分析和一个个因为边界条件没处理好而引发的线上事故。你可以把这份速查当成手边的字典但一定要亲手敲几遍代码跑几个服务等到你不再需要查这篇文章就能写出完整的 epoll 服务端程序时你才算是真正跨过了网络编程的门槛。