刚开始接触网络编程那阵子,我最常干的事就是一边翻 man 手册,一边对着师兄留下的代码抄。后来真开始自己写服务端、调协议栈的时候才发现,光知道函数签名远远不够,真正值钱的是“这个函数在什么场景下为什么这么用”。这份网络编程核心函数速查,就是我从实际项目里一点点攒出来的,重点不是罗列参数,而是把 socket 生命周期里那些最容易踩坑的经典用法讲清楚,适合正在学 Linux 网络编程、C/C++ 后端的同学,也适合写了好几年业务代码但没系统捋过 socket 底层细节的朋友。
1. 为什么要有一份“网络编程核心函数速查”
1.1 这份速查表解决什么问题
网络编程的函数体系其实非常稳定,从 BSD socket 诞生到现在,核心接口基本没变过。但稳定不等于简单,真正让人头疼的是这些函数的语义藏在细节里:比如 send() 返回了多少字节到底代表什么,listen() 的 backlog 参数在不同内核版本里行为完全不一样,accept() 返回的 fd 和监听 fd 又有哪些天壤之别。这些知识点散落在 man 手册、内核源码和各种博客里,新手想一次性吃透,难度相当大。
我见过不少同事,写 TCP 客户端代码的时候一上来就调 connect(),完全不考虑非阻塞场景下它会返回 EINPROGRESS;写 UDP 的时候拿 send() 去发数据,结果发现报文发是发出去了,但和 sendto() 的语义完全不同。这些问题不是代码写不出来,而是对函数背后的设计逻辑缺少整体认知。所以这份速查不是把函数签名抄一遍,而是把所有经典用法按“套接字生命周期-数据收发-地址解析-事件驱动”这条主线串起来,让你在写具体代码前,脑子里先有一张完整的调用地图。
1.2 速查怎么用才能变成真正的能力
拿到这份速查,千万别把它当成字典从头读到尾。我的建议是:先看第 2 章,把套接字从创建到关闭的完整链路捋顺,这一章是整个网络编程的骨架;然后带着自己平时写代码时遇到的问题去查第 3 章和第 4 章,这两章解决的是“数据怎么发”“地址怎么写”的实操问题;最后再看第 5 章的 IO 多路复用,因为真正的高并发服务端几乎离不开 epoll,但没搞清楚前几章,直接上 epoll 只会让你在回调地狱里越陷越深。
我的个人习惯是,每学一个函数,就在草稿纸上画一遍它的状态转换,然后对照这这份速查里的“注意事项”看一遍自己有没有漏掉边界情况。比如 accept() 返回 EMFILE 怎么办、send() 碰到 SIGPIPE 怎么处理,这些在普通文档里很少被强调,但恰恰是线上事故的高发源头。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 套接字生命周期函数:从创建到关闭的完整链路
2.1 socket():三参数背后的现代Linux扩展
int socket(int domain, int type, int protocol) 是网络编程的起点。domain 决定协议族,AF_INET 是 IPv4,AF_INET6 是 IPv6,AF_UNIX 是本地域套接字;type 决定通信语义,SOCK_STREAM 代表可靠的字节流(TCP),SOCK_DGRAM 代表不可靠的数据报(UDP);protocol 通常填 0,表示由内核根据前两个参数自动选择默认协议。
这里有个细节很多人没注意到:在 Linux 上,type 参数可以叠加额外标志位,比如 SOCK_NONBLOCK 和 SOCK_CLOEXEC。它的作用是在创建套接字的同时就设置好非阻塞属性和执行 exec 时自动关闭,省去了后续再调 fcntl() 的麻烦。如果你在写高性能服务,这两个标志位几乎是必选的,因为它们能有效避免多线程环境下 fcntl() 和 exec() 之间的竞态窗口。早期代码里常见的 socket(AF_INET, SOCK_STREAM, 0) 后跟 fcntl(fd, F_SETFL, O_NONBLOCK) 的写法,在新代码里其实可以一行合并。
2.2 bind()与listen():服务端地址绑定与监听队列的细节
bind() 的作用是把套接字和本地的 IP 地址、端口绑定在一起。服务端必须调用 bind(),否则内核会随机分配一个临时端口,客户端根本没法提前知道该连哪里。但客户端的 connect() 一般不需要主动 bind(),除非你有特殊需求(比如固定源端口),否则就让内核在选择源地址时自动处理。
bind() 最容易踩的坑是返回 EADDRINUSE,意思是端口已经被占。这个错误除了端口确实被别的进程占用之外,最常见的场景是服务端刚重启,旧的连接还处于 TIME_WAIT 状态。解决方法是先设置 SO_REUSEADDR,具体原理我放到第 4 章讲。
listen() 的作用是让套接字进入被动监听状态,它的第二个参数 backlog 非常值得说道。在 Linux 2.2 之前,backlog 表示“未完成连接队列 + 已完成连接队列”的总和;2.2 之后,它只表示已完成连接的队列长度。内核还会拿 /proc/sys/net/core/somaxconn 去截断这个值,如果你在代码里写 listen(fd, 1024),但系统的 somaxconn 是 128,那实际生效的就是 128。所以部署高并发服务时,记得检查这个内核参数,别让 backlog 设置成了摆设。
2.3 accept()与connect():三次握手两侧的真实面貌
accept() 是从已完成握手的队列里取出一个连接,返回一个全新的文件描述符。这个新 fd 才是用于实际收发数据的套接字,而监听 fd 依然只负责接收新连接。很多新手容易把 accept 和三次握手的时序搞混:实际上三次握手完全由内核完成,accept() 只是在握手成功后把结果从队列里拿给应用层,所以阻塞在 accept() 上并不代表握手没完成。
connect() 在 TCP 客户端侧发起连接。阻塞模式下,它会一直等到三次握手完成或失败才返回;非阻塞模式下,connect() 通常会立即返回 -1,并且 errno 被设置为 EINPROGRESS,代表连接还在内核里继续。这时候需要用 select() 或 epoll() 去监听套接字的可写事件,触发后还要再通过 getsockopt() 取 SO_ERROR 来确认连接是否真的成功。这套流程稍有不慎就会出现连接状态误判,是面试和实战里都常考的经典点。
3. 数据收发函数:TCP与UDP的经典用法差异
3.1 send()/recv():TCP流式通信的核心语义
TCP 是字节流协议,没有消息边界。send() 的返回值表示有多少字节被复制到了内核发送缓冲区,不代表对端已经收到,更不代表对端的应用层已经 recv 到了。recv() 的返回值则更直观:返回正数表示读取到的字节数,返回 0 表示对端正常关闭了连接,返回 -1 需要结合 errno 判断异常。
正因为 TCP 没有边界,send() 并不保证一次把你要发的数据全发出去,尤其在大数据量或发送缓冲区满的时候,它可能只发了一部分。所以生产级代码里几乎都要写一个发送循环,把 send() 的返回值累加,直到所有数据都写入缓冲区。recv() 同理,一次调用未必能读完对端发来的所有数据,需要循环读取直到满足应用层协议要求的长度。
send() 有一个容易被忽视的标志 MSG_NOSIGNAL。如果对端已经关闭,而本地还在 send(),默认行为是触发 SIGPIPE 信号,进程默认会被直接终止,这在服务器上往往意味着整个进程崩溃。加上 MSG_NOSIGNAL 后,内核就不发信号,而是让 send() 返回 -1,errno 是 EPIPE,然后你就可以在代码里优雅地处理这个错误。任何长期运行的服务端代码,都应该处理这种场景。
3.2 sendto()/recvfrom():UDP报文式和收发的边界问题
UDP 是无连接、基于报文的。sendto() 每次调用发送一个完整的数据报,recvfrom() 每次接收一个完整的数据报。由于 UDP 报文有边界,recvfrom() 返回的字节数就是一个数据报的长度,如果提供的缓冲区太小,内核会直接截断超出的部分,并且不给你任何提示,这在业务上是个隐患,所以收 UDP 数据时缓冲区一定要按 MTU 上限来分配。
recvfrom() 还能通过后面的两个参数拿到发送端的地址和端口,这在实现无连接服务时特别有用。比如你写一个 DNS 转发服务,收到一个查询请求后,必须知道客户端是从哪个地址和端口发来的,才能把响应回发过去,用的就是 recvfrom() 带出的 sockaddr_in。
UDP 没有流量控制和拥塞控制,所以 sendto() 不会像 TCP 那样长时间阻塞,但它的发送缓冲区满了之后一样会返回 EAGAIN(非阻塞模式下),这时需要业务层决定丢弃还是排队。UDP 的丢包、乱序、重复这些特性不是 bug,是协议设计的天然属性,应用层必须做好容错。
3.3 sendmsg()与readv/writev:高性能场景的进阶选择
sendmsg() 和 recvmsg() 是 BSD socket 里最强大的收发原语,因为它们基于 struct msghdr 结构体,支持将多个不连续的内存块一次性发送(scatter/gather),还能携带辅助数据(ancillary data)。比如在发送文件时,如果你想避免多次系统调用和内存拷贝,可以把文件内容映射到内存后,用 sendmsg() 配合多个 iovec 一次性发出。
不过说实话,日常业务代码里直接用 sendmsg() 的人不多,它的主要舞台在中间件、网关、代理这类高性能网络程序里。我自己的经验是,先掌握 readv()/writev() 这类通用的 gather 读写函数会更实用,它们能显著减少系统调用次数。比如把一个 HTTP 响应拆成“响应头 + 响应体多段数据”,用 writev() 一次写完,比多次调用 send() 更高效。
4. 地址转换与选项设置:写对每一条参数
4.1 字节序转换:htonl/htons的使用边界
网络协议规定,多字节整数在网络上传输时统一使用大端字节序(网络字节序),而 x86 等常见 CPU 使用的是小端字节序。所以往 socket 地址里填端口和 IP 时,必须用 htons()(host to network short)和 htonl()(host to network long)做转换。
但这里要特别注意:htonl() 处理的是 32 位整数,传统上用于 IPv4 地址;而 socket 地址结构体 sockaddr_in 中的地址字段是 in_addr_t,同样需要 htonl()。不过现代代码更推荐直接用 inet_pton() 把点分十进制的字符串转成二进制地址,少碰这些字节序函数,能避免很多低级错误。端口转换倒是离不开 htons(),因为端口号是一个 16 位整数,在填 sockaddr_in.sin_port 之前必须转换。
从对端收到数据时,反过来用 ntohs() 和 ntohl(),比如从 recvfrom() 拿到的客户端端口就是网络字节序,直接打印会得到完全不对的值。
4.2 inet_pton/inet_ntop与getaddrinfo:现代地址解析
inet_pton() 是 address presentation to numeric 的缩写,负责把 "192.168.1.1" 这样的字符串转换成二进制的 sockaddr_in 地址字段;inet_ntop() 则相反,把二进制地址转回字符串。这两个函数同时支持 IPv4 和 IPv6,是安全性和可读性都更好的选择。
但要说现代网络编程的推荐入口,还得是 getaddrinfo()。这个函数把你的主机名、服务名、协议族、套接字类型等输入参数,转换成一个 addrinfo 链表,每个节点都包含了可以直接用于 socket() 和 connect() 的完整参数。好处在于:你不用自己判断填 AF_INET 还是 AF_INET6,也不用手动做 htonl(),getaddrinfo() 会帮你把一切都处理好。写代码时注意,getaddrinfo() 返回的链表必须用 freeaddrinfo() 释放,否则就是内存泄漏。
4.3 setsockopt():三个最常用的socket选项
setsockopt() 是网络编程里的“调参神器”,但我见过不少人随意设置,完全不理解每个选项背后的作用。这里挑三个最常用的展开。
第一个是 SO_REUSEADDR。服务器重启时,大量旧连接可能还处于 TIME_WAIT 状态,导致 bind() 失败。设置 SO_REUSEADDR 可以让内核允许新套接字重新绑定处于 TIME_WAIT 状态的本地地址。这个选项在 TCP 服务端几乎必加,不加的话,频繁重启服务很容易遇到“Address already in use”。
第二个是 TCP_NODELAY。TCP 默认启用 Nagle 算法,它会把小的数据包合并后再发送,目的是减少网络上小包的数量。但 Nagle 算法与延迟 ACK 机制配合时,可能产生约 40ms 的确认延迟,对实时性要求高的服务(比如游戏、金融行情)是不可接受的。设置 TCP_NODELAY = 1 可以禁用 Nagle 算法,让每个小包立即发送。不过代价是网络利用率下降,小包频繁的场景下要自己权衡。
第三个是 SO_LINGER。它控制 close() 在还有未发送数据时的行为。默认情况下,close() 会立即返回,内核尝试把缓冲区里的数据发送出去,这叫作“优雅关闭”。但如果设置了 SO_LINGER 且 l_linger = 0,close() 会直接发送 RST 重置连接,丢弃所有未发送数据。这个选项在网络异常检测里偶尔会用到,但正常业务不建议乱设,因为它会破坏 TCP 的正常关闭流程。
5. IO多路复用:从select到epoll的核心函数对比
5.1 select()/poll():经典模型的使用要点
select() 是历史最悠久的 IO 多路复用函数,它通过三个 fd_set 分别监控可读、可写、异常事件。问题在于,fd_set 是固定大小的位图,默认上限是 FD_SETSIZE(通常 1024),而且每次调用都要把整个 fd_set 从用户态拷贝到内核态,再拷贝回来,连接数一上来性能就很差。
poll() 解决了 fd 数量限制的问题,改用 pollfd 数组来承载事件,但它没有解决“每次都要遍历全部 fd”的效率问题。在高并发场景下,100 万个连接里只有几个活跃事件,poll() 依然要线性扫描所有 fd,效率依然不高。
如果你接触过早期教科书代码,会发现 select() 还有个隐藏很深的坑:select() 会修改传入的 fd_set,所以你必须每次调用前都重新设置要监听的事件集合。很多线上 bug 就是漏了这一步,导致第一次 select() 之后,后续事件全都不见了。
5.2 epoll三件套:epoll_create/epoll_ctl/epoll_wait的坑
epoll 是 Linux 下高性能网络编程的事实标准,由三个函数组成。epoll_create1() 创建 epoll 实例,epoll_ctl() 管理注册事件,epoll_wait() 等待事件发生。和 select() 的最大区别是,epoll 的事件集合由内核维护,epoll_wait() 只返回就绪的 fd,不会把所有 fd 都拷来拷去。
epoll_ctl() 里有三个操作:EPOLL_CTL_ADD 注册新 fd,EPOLL_CTL_MOD 修改已注册 fd 的事件类型,EPOLL_CTL_DEL 删除注册。事件类型里最常用的是 EPOLLIN(可读)和 EPOLLOUT(可写),另外还有 EPOLLRDHUP(对端关闭连接)和 EPOLLET(边缘触发)。
这里要特别讲一下水平触发(LT)和边缘触发(ET)的区别。LT 是默认模式,只要 fd 上还有数据没读,epoll_wait() 就会一直通知你;ET 是通知一次,之后必须把数据全部读完才不会再通知,不然就丢了。很多高性能服务会特意使用 ET,因为它能明显减少系统调用次数。但 ET 模式下你几乎必须配合非阻塞 IO,用 while 循环读到返回 EAGAIN 为止,代码复杂度会高一个台阶。我自己的建议是:刚开始用 epoll 的时候先老老实实用 LT,等把编程模型吃透了再切换到 ET,不要一上来就追求“高级”。
6. 常见网络编程问题与排查技巧实录
6.1 地址被占用与TCP状态相关的边界问题
“Address already in use”恐怕是服务端上线第一天就碰到的问题,原因通常是上一次连接没有走完关闭流程,留下了 TIME_WAIT 状态的连接。TCP 主动关闭的一方,最后要经历 2MSL(最大报文生存时间)的等待,在这个时间内端口还不能复用。解决办法就是前面说的 SO_REUSEADDR。
还有一种情况更隐蔽:accept() 在高并发下返回 EMFILE,因为进程的文件描述符表满了。这会导致新连接被内核丢弃,但客户端那边毫无感知,只会奇怪为什么连接不上。生产级的处理思路是预留一个“救急 fd”,在 accept() 返回 EMFILE 时先关闭救急 fd,腾出一个空位,把新连接 accept 下来后再立刻关闭,同时把这个事件记录成监控指标。这个技巧能帮你把故障从“连接直接失败”变成“能观察到、能报警”,线上排查会舒服很多。
6.2 EINTR/SIGPIPE/EAGAIN等错误码的正确处理
EINTR 是网络编程里最经典的“假错误”。当进程收到信号时,阻塞的系统调用(比如 recv() 阻塞中)会被中断,返回 -1,errno 为 EINTR。这不是真正的错误,正确做法是重新调用一次。教科书里几乎都会写一个 while (n < 0 && errno == EINTR) 的循环,就是为了处理这个场景。
SIGPIPE 前面已经提到,写操作碰到已关闭的 TCP 连接会触发。这不仅是“要不要忽略信号”的问题,而是要理解内核的设计思路:SIGPIPE 的存在是为了让进程在管道和 socket 写端关闭后,能得到一个即时的失败信号,避免程序在不知情的情况下一直写下去。服务端代码里用 MSG_NOSIGNAL 或者忽略信号,都是合理的处理方式。
EAGAIN(有些系统也叫 EWOULDBLOCK)是处理非阻塞 IO 时绕不开的错误码。当非阻塞 socket 的接收缓冲区里没有数据、发送缓冲区已满时,recv() 或 send() 会返回 EAGAIN,意思是“现在做不了这事,你等会儿再试”。在 epoll 模型里,EAGAIN 甚至被你当成一种“正常的退出信号”,因为 ET 模式下循环读到 EAGAIN 就表示数据已经读完了。
6.3 粘包、半包与收发的应用层边界处理
TCP 是字节流,理论上没有“包”的概念。粘包和半包问题的根源在于:发送方多次 send() 的数据,接收方可能一次 recv() 全收到,也可能收到一半,具体取决于内核缓冲区。解决办法只能是应用层自己定义消息边界,常用方案有三种。
第一种是固定长度:每条消息长度相同,接收方按长度截取,简单但浪费。第二种是分隔符:比如 HTTP 的空行,或者自定义的 \n,实现简单但遇到内容里含分隔符时需要转义。第三种是 TLV(Type-Length-Value)方案:头部用固定字节数表示消息类型和长度,接收方先读头部,再按长度读 body。这是目前网络通信协议的主流做法,长短消息都能高效承载。
检查粘包问题时,我最常用的工具就是 tcpdump 抓包,先确认是应用层读取逻辑的问题,还是内核重组导致的误解。很多时候,“粘包”只是接收方的循环读逻辑写错了,把一次读到的多个业务消息当成一条处理了,而不是 TCP 本身有什么“病”。
7. 把速查表变成自己的东西
这份速查里的每一个函数,我都尽量写清了“它为什么这么做”和“哪些场景容易踩坑”。但真正把这些知识变成肌肉记忆,还是得回到代码里去练。我的体会是,每学一个函数,就想想它解决的到底是内核给应用层暴露的哪个能力缺口——socket 是进程间通信的抽象,bind 是给通信双方一个确定地址,listen 和 accept 是让服务端能被动地接客,send/recv 是应用层和内核缓冲区的搬运工。
最后分享一个小技巧:写 socket 代码之前,我习惯先在纸上画一张状态图,把阻塞和非阻塞下每个函数可能的返回值和错误码全标出来,再对照这份速查检查一遍。网络编程的大部分问题,本质上都是对函数语义理解不透彻导致的。把这份速查里的经典用法吃透,比盲目堆代码要有效得多。
