从一个 socket 收到多名客户端的数据,理解 Linux UDP 网络编程
一个 UDP 回显服务器通常只有一个 socket 文件描述符。客户端 A 发来的消息从这个描述符读,客户端 B 发来的消息仍然从这里读,服务器回包时也继续使用同一个描述符。
这件事起初很容易想岔。如果把 socket 直接理解成一条连接,就会觉得每个客户端都该分到一个新的 socket。TCP 服务器确实会在 accept 之后得到已连接 socket,UDP 没有这个过程。每个 UDP 数据报都带着源地址和目的地址,服务器收到数据时还能同时拿到发送者的 IP 地址和端口号。一个 socket 因而可以接收多名客户端的数据,再按每个数据报携带的地址分别回复。
我把 Linux 手册和一个 UDP Echo 实现对在一起看时,逐渐确定了这部分最该抓住的主线。socket 创建通信端点,bind 确定服务器的本地地址,recvfrom 取出一份数据和它的来源,sendto 再把一份完整数据报送往指定地址。代码中的参数很多,背后发生的状态变化并不杂乱。
socket 创建的是通信端点
创建 IPv4 UDP socket 时,最常见的调用如下。
int socket(int domain, int type, int protocol);
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
三个参数分别确定地址体系、通信语义和具体协议。
| 参数 | 这里的取值 | 含义 |
|---|---|---|
domain | AF_INET | 使用 IPv4 地址体系 |
type | SOCK_DGRAM | 使用保留消息边界的数据报语义 |
protocol | 0 | 让内核选择与前两个参数相配的默认协议,这里通常是 UDP |
调用成功时,socket 返回一个非负文件描述符。应用程序保存的是这个整数,真正的 socket 对象和协议状态在内核中。调用失败时返回 -1,同时设置 errno。文件描述符耗尽、地址族不受支持、协议组合无效,都可能让它失败。
这里需要把一句常见的简化说法修正一下。创建 socket 不等于直接打开了某一块网卡。此时内核建立了一个通信端点,里面记录了地址族、socket 类型、协议以及收发队列等状态。刚创建的 UDP socket 还没有指定本地地址和远端地址,更没有固定到某一块物理网卡。后续的绑定、路由选择和发送过程会继续补充这些信息。
文件描述符只是进程引用内核对象的入口。把 sockfd 复制给另一个变量,不会得到第二个独立 socket。多线程共享同一个 sockfd 时,访问的仍是同一个内核通信端点。
sockaddr_in 把 IPv4 地址交给统一接口
IPv4 通信需要地址族、端口号和 32 位 IP 地址。Linux 用 struct sockaddr_in 保存这些内容,它的核心字段可以抽象成下面这样。
struct sockaddr_in {
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
};
sin_family 填写 AF_INET,告诉内核这是一份 IPv4 地址。sin_port 保存 16 位端口号,sin_addr 保存 IPv4 地址。结构里还可能有用于对齐的填充空间,应用程序不该依赖填充字节保存业务信息。
bind、sendto 和 recvfrom 需要兼容 IPv4、IPv6、UNIX 域等多种地址,所以它们统一接收 struct sockaddr *。IPv4 程序先填写 sockaddr_in,调用接口时再转换指针类型。
sockaddr_in local{};
local.sin_family = AF_INET;
bind(sockfd,
reinterpret_cast<const sockaddr *>(&local),
sizeof(local));
这种写法只是在适配统一的 C 接口。sockaddr 不是 C++ 基类,sockaddr_in 也不是它的派生类,两者之间没有继承关系。内核先看地址族,再结合 addrlen 按正确的具体结构解释内存。
还有一个状态边界很容易忽略。填写 local 时,数据只存在于用户空间的局部变量里。bind 成功以后,内核才把这份本地地址保存到 socket 状态中。系统调用会读取结构内容,调用返回后不会继续依赖这个局部变量,因此它可以正常离开作用域。
服务器为什么要显式 bind
socket 创建的 UDP 端点还没有固定的本地端口。服务器需要让客户端提前知道把数据发到哪里,所以它通常显式调用 bind。
int bind(int sockfd,
const struct sockaddr *addr,
socklen_t addrlen);
sockfd 指向要绑定的 socket,addr 指向本地地址结构,addrlen 给出结构的字节数。成功返回 0,失败返回 -1。地址和端口已经被占用时常见 EADDRINUSE,尝试绑定一个不属于本机的 IP 地址时可能得到 EADDRNOTAVAIL,重复绑定同一个 socket 也会失败。
服务器常见的地址准备方式如下。
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(server_port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
INADDR_ANY 是本地通配地址。把服务器绑定到它,表示同一个端口可以接收发往本机各个本地 IPv4 地址的数据。如果机器同时拥有 127.0.0.1、局域网地址和其他本地接口地址,这些地址上的相应数据报都可能交给这个 socket。
它不表示服务器拥有互联网上的任意 IP,也不表示允许任意远端绕过防火墙访问。云服务器控制台显示的公网 IP 可能由平台通过 NAT 映射到实例,公网地址未必直接配置在实例网卡上。程序强行绑定这个非本地公网地址,可能得到 EADDRNOTAVAIL。使用 INADDR_ANY 解决本机监听地址的选择,安全组、主机防火墙和云平台端口放行仍然是另外几层条件。
绑定前后的状态可以压缩成一张表。
| 时刻 | 内核中的本地地址 | 程序能否稳定对外提供服务 |
|---|---|---|
socket 刚成功 | 尚未指定 | 客户端没有稳定端口可依赖 |
bind 成功 | 本地 IP 条件和端口已经记录 | 客户端可以向这个端点发送数据 |
bind 失败 | 保持未成功绑定的状态 | 不应继续进入正常服务循环 |
服务器若忽略 bind 的返回值,后面的现象会变得很难判断。程序可能没有占用预期端口,客户端自然也无法按约定地址找到它。系统调用失败以后,应当先处理这一次失败,不能假定内核已经进入下一阶段。
客户端没有写 bind,不代表它没有本地端口
客户端发送 UDP 数据时,数据报里同样需要源 IP 和源端口,否则服务器不知道把回复发到哪里。客户端也要拥有本地端点,只是普通客户端通常不必亲自挑选端口。
Linux 中,一个尚未绑定的 UDP socket 第一次发送数据时,socket 层可以自动选择空闲的临时端口,并把它绑定到本地通配地址。路由过程随后根据目标地址选出合适的源 IP。服务器在 recvfrom 返回的源地址中,就能看到内核最终为客户端使用的 IP 和端口。
客户端 socket 创建完成
↓
本地 IP 和端口尚未指定
↓
第一次 sendto
↓
内核选择临时端口和合适的源地址
↓
客户端拥有可供服务器回复的本地端点
这种自动绑定适合大量短期客户端。每个客户端只需要一个当前可用的本地端口,具体数字通常不属于应用协议。所有客户端都把端口写死,反而容易在同一台机器上发生冲突。
客户端依然可以显式 bind。需要固定源端口、限制使用某个本地地址,或者协议要求对端识别稳定本地端点时,显式绑定就有意义。普通 Echo 客户端省略 bind 是常用选择,不是 UDP 禁止客户端调用它。
sendto 一次提交一份数据报
未连接的 UDP socket 通常用 sendto 指定每份数据报的目的地址。
ssize_t sendto(int sockfd,
const void *buf,
size_t len,
int flags,
const struct sockaddr *dest_addr,
socklen_t addrlen);
sockfd 选择发送端点,buf 和 len 描述要发送的数据,flags 为 0 时使用普通发送行为。dest_addr 和 addrlen 给出目标 IP、目标端口及地址结构大小。
UDP 保留数据报边界。应用调用一次 sendto,就是向内核提交一份消息。接收方不会把两份普通 UDP 数据报自动拼成一份,也不会把一份数据报按 TCP 字节流的方式留给多次普通读取慢慢取完。数据报大于接收缓冲区时,超出的部分会被截断和丢弃。发送报文过大而无法按数据报语义原子发送时,sendto 可能以 EMSGSIZE 失败。
成功时,sendto 返回发送的字节数,失败返回 -1。这个成功结果说明内核接受了本次发送请求。它没有证明数据报已经抵达远端,更没有证明远端应用完成了处理。UDP 本身不负责确认、重传和按序交付,途中丢失、重复或乱序都需要应用按自己的需求处理。
长度为 0 的 UDP 数据报也是合法数据报。调用 sendto 发送空消息时,返回值可以是 0。程序如果只把返回值大于 0 当成成功,就会把合法空数据报误判成异常情况。
recvfrom 同时取回数据和发送者地址
服务器需要知道消息内容,也要知道消息来自谁。recvfrom 一次完成这两件事。
ssize_t recvfrom(int sockfd,
void *buf,
size_t len,
int flags,
struct sockaddr *src_addr,
socklen_t *addrlen);
buf 指向接收缓冲区,len 是缓冲区容量。src_addr 指向由调用者准备的地址空间,内核把发送者地址写到这里。addrlen 是输入输出参数,调用前存放地址缓冲区的容量,成功返回后改成实际写回的地址长度。
sockaddr_in peer{};
socklen_t peer_len = sizeof(peer);
ssize_t n = recvfrom(sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<sockaddr *>(&peer),
&peer_len);
默认阻塞 socket 在接收队列为空时会等待。数据报到达后,内核从队列中取出下一份数据报,把有效载荷复制到 buffer,再把源 IP 和源端口写进 peer。成功返回收到的字节数,失败返回 -1。非阻塞 socket 暂时无数据时通常得到 EAGAIN 或 EWOULDBLOCK,信号在数据到达前中断调用时可能得到 EINTR。
UDP 中的返回值 0 不能解释为对方关闭连接。Internet 数据报 socket 允许接收零长度数据报,此时 recvfrom 合法地返回 0。TCP 字节流读取到 0 常用于表示有序关闭,两个协议的判断不能混用。
示例程序经常多准备一个字节,收到 n 个字节后再写入字符串结束符。
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, ...);
if (n >= 0) {
buffer[n] = '\0';
}
这个结尾只服务于后续把内容当 C 字符串打印。UDP 数据本身可以包含 \0,也可以是任意二进制内容。程序真正收到多少字节,应以 recvfrom 的返回值为准,不能依赖 strlen 推断网络数据长度。
一次 Echo 往返改变了哪些状态
服务器启动时先创建 socket,再绑定固定端口,然后阻塞在 recvfrom。客户端只创建 socket并填写服务器地址,第一次 sendto 会触发本地端点的自动选择。完整往返可以写成下面的顺序。
客户端 服务器
socket socket
创建未绑定 UDP 端点 创建未绑定 UDP 端点
↓
bind
固定本地端口
sendto 消息和服务器地址 ───────────────────────→ recvfrom
内核自动补全客户端本地端点 得到消息和客户端地址
↓
recvfrom 等待回复 ←─────────────────────── sendto
得到回显内容 使用刚得到的客户端地址
服务器的关键中间状态是 peer。它来源于当前这次 recvfrom,描述当前数据报的发送者。回显时把它原样交给 sendto,回复才会回到正确客户端。下一份数据报可能来自另一名客户端,peer 也会随之更新。
这也解释了为什么一个 UDP 服务器 socket 能服务多名客户端。内核的接收队列保存一份份独立数据报,每份数据报都带有来源信息。服务器不为每名客户端建立协议连接,只是在处理当前数据报时使用当前来源地址。
recvfrom 返回以后,这份数据报已经从 socket 接收队列中取出。业务代码处理得很慢时,主循环迟迟不能再次接收,后续数据报只能继续留在有限的内核接收队列中。队列装满以后,新的 UDP 数据报可能被丢弃。单线程 Echo 逻辑很轻,顺序处理足够清楚;耗时业务通常需要把网络接收和业务计算分开。
从回显服务扩展到字典和聊天室
Echo 服务器收到什么就回复什么。把回复规则换成查询词典,socket 部分几乎不需要变化。网络层仍然负责接收请求、保存来源地址和发送响应,业务函数只负责根据请求生成结果。
一种常见组织方式是让 UDP 服务器保存业务回调。网络循环收到 request 后调用回调得到 response,随后把响应发给本次请求的 peer。这种拆分让同一套 UDP 收发过程可以承载回显、单词翻译等不同业务,也避免让 socket 类知道词典文件怎样加载。
聊天室比字典服务多了一份应用状态。服务器收到某个 IP 和端口发来的第一份数据报后,可以把这个端点加入在线列表,再把消息转发给列表中的用户。这里的“在线”是应用程序自己定义的状态。UDP 没有连接建立和断开通知,客户端长时间不发消息、异常退出或 NAT 映射改变时,服务器不能仅靠 UDP 自动判断它是否仍然在线。心跳、超时删除和退出消息都属于应用层规则。
引入线程池以后,主循环可以继续接收数据,工作线程负责查词或转发消息。提交异步任务时,要让任务拥有消息和 peer 地址的有效副本。主循环里的局部变量会在下一轮被覆盖,后台任务若只保存对它们的悬空引用,使用时就可能读到错误地址。共享的在线列表和日志输出也需要同步。多个线程可以使用同一个 UDP socket 发送数据报,具体执行先后取决于调度,应用不能假设回复一定按照任务提交顺序出现。
IP 和端口要按网络格式保存
sockaddr_in 中的端口和 IPv4 地址都使用网络字节序。网络字节序采用大端表示,主机字节序由本机 CPU 决定。端口通常通过下面两个函数转换。
uint16_t htons(uint16_t host_value);
uint16_t ntohs(uint16_t network_value);
写入 sin_port 前调用 htons,从 peer.sin_port 读取并显示端口时调用 ntohs。在小端机器上直接写整数,字节顺序会颠倒;在大端机器上可能暂时看不出错误,代码仍应明确转换。
IP 地址还存在文本形式和二进制形式的转换。人习惯写 192.168.1.10,sin_addr 需要保存 32 位网络地址。新程序更适合使用 inet_pton 和 inet_ntop。
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af,
const void *src,
char *dst,
socklen_t size);
inet_pton(AF_INET, text, &addr.sin_addr) 成功返回 1,地址文本格式无效时返回 0,地址族不受支持时返回 -1。inet_ntop 由调用者提供输出缓冲区,成功时返回缓冲区指针,失败返回空指针。
旧代码中常见 inet_addr 和 inet_ntoa。inet_addr 用 INADDR_NONE 表示无效输入,但 255.255.255.255 本身也通常得到这个值,错误表达不够清楚。inet_ntoa 返回内部缓冲区的地址,后续调用会覆盖先前内容。当前 Linux 手册把 glibc 的 inet_ntoa 标记为 MT-Safe locale,所以不能笼统断言它在所有现代 Linux 实现中必然线程不安全。它的缓冲区所有权和可移植性仍然不够直观,调用者自备缓冲区的 inet_ntop 更适合新代码。
阅读 UDP 示例时容易漏掉的细节
地址结构应该完整初始化。下面这种写法只清零了一个 int 大小的区域,不能保证整个 sockaddr_in 都被初始化。
memset(&server, 0, sizeof(0)); // 错误
C++ 中可以直接进行值初始化。
sockaddr_in server{};
接收分支也不能只写 n > 0。UDP 的 n == 0 可能是一份合法空数据报,n < 0 才表示调用失败。若程序规定空请求无效,可以在成功接收后按应用协议拒绝它,不能把它冒充成系统调用错误。
sendto、recvfrom 和 close 都有返回值。示例为了突出主线,有时会省略部分错误处理,公开使用的程序不能照着省略。尤其是服务器回包失败时,程序至少应记录 errno,否则客户端没有收到回复时,很难区分业务没有生成结果、内核发送失败和数据报在网络中丢失。
最后还要分清 UDP 的消息边界和 C 字符串边界。sendto 的长度参数决定发送多少字节,recvfrom 的返回值决定收到多少字节。末尾补 \0 只是为了方便打印文本。只要业务可能传输二进制数据,就应该始终按显式长度处理。

9154

被折叠的 条评论
为什么被折叠?



