【网络编程】从与 TCP 服务器的对比中探讨出 UDP 协议服务器的并发方案(C 语言)
文章目录
- 从 TCP 服务器的回顾到 UDP 服务器的前瞻
- TCP 协议的并发回顾
- `bind` 函数的内核底层
- `listen` 函数和 `accept` 函数的内核底层
- Socket 套接字的接收缓冲区容量
- `setsockopt` 函数的内核底层
- `fcntl` 函数:文件控制
- `recvfrom` 函数与 `recv` 函数的区别与底层机制
- `send` 函数与 `sendto` 函数的区别与底层机制
- `close` 函数的内核底层
- 从 UDP 报文到 UDP 服务器的思考
- `connect` 函数的内核底层
- UDP 的数据报套接字的内部结构
- UDP 服务器的 C 代码实现
- 服务器的初始化
- 服务器安置新的来访客户端(accept)
- 服务器接收信息的函数
- 服务器的运行函数
- UDP 客户端测试代码
- 代码测试
推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习: https://github.com/0voice 链接。
从 TCP 服务器的回顾到 UDP 服务器的前瞻
我曾写过关于 TCP 和 UDP/KCP 协议的文章,分别是
1、介绍 TCP 协议的(链接 在这)
2、介绍 UDP/KDP 协议的(链接 在这)
3、介绍 reactor 网络通信模型的(链接 在这)
TCP 协议的并发回顾
我们先复习 reactor 网络通信模型的步骤(我给出关键函数的使用):
第一步,先得初始化服务器。 我们需要以 SOCK_STREAM
数据流模式建立一个网络套接字(之所以称之为 “流”,是因为 TCP 协议是可靠的不会丢包的传输协议,由三次握手,四次挥手等),然后设置套接字的权限、协议行为 setsockopt
(端口复用)。接着把这个套接字绑定 bind
本机的 IP 地址和对应的端口 PORT。最后,把这个套接字升格 listen
成监听套接字 。
#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0)if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {// 如果未设置 SO_REUSEADDR,导致端口被占用后无法立即重用(TIME_WAIT 状态)perror("setsockopt failed\n");return -3;
}bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))listen(sockfd, 10)
第二步,创建内核 IO 事件驱动回调触发的 EPOLL 实例。它用来时刻检测内核中是否有读写事件发生,并且能匹配到自身红黑树的某个节点,进而转为用户态触发。首先得,创建实例 epoll_create(10)
;然后,把监听套接字加入 epoll_ctl
到 EPOLL 管理全集的红黑树之中(内核之后会把其检测到的 IO 事件,拿到红黑树里面查询),监听套接字的读事件是水平触发状态,只要用户还没接收,就一直提醒;创造用户态的 IO 事件的 epoll_event
数组。
#include <sys/epoll.h> // EPOLL 高并发机制
int epfd = epoll_create(1);struct epoll_event ev;
ev.events = EPOLLIN; // 监控读事件
ev.data.fd = sockfd; // 监控 sockfd 的读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev)struct epoll_event events[1024] = {0};
第三步,等待来访的客户端,并接受连接请求。首先我们调用 EPOLL 的 epoll_wait 用户态获取内核锁监听到的事件把他记录到长度有限的用户态数组 events[1024]
,如果刚好就是监听到监听套接字的读事件,那就得接受 accept
新的来访者,并且要设置 fcntl
该分配套接字的读写模式。并且让他以边沿读取 EPOLLIN | EPOLLET
模式加入 epoll_ctl
到 EPOLL 实例之中。
int nready = epoll_wait(epfd, events, 1024, 5);if (events[i].data.fd == sockfd) { // 监听功能,前台小姐姐因为监听到了访客,触发了 epoll_wait,并在其上排到了号struct sockaddr_in client_addr; // 申请 IP 内存socklen_t client_len = sizeof(client_addr);memset(&client_addr, 0, sizeof(struct sockaddr_in)); // 清空内存int clientfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_len); int flags = fcntl(clientfd, F_GETFL, 0); // F_GETFL 是获取标志的命令fcntl(clientfd, F_SETFL, flags | O_NONBLOCK); // 要注意 “|” 是按位或操作,能进行掩码叠加;F_SETFL 是设置文件状态标志,在原来的基础上增加非阻塞功能ev.events = EPOLLIN | EPOLLET; ev.data.fd = clientfd;epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev)
}
第四步,建立连接的客户端发来消息,我方主动接受 recv
消息(循环读取,直至没有消息可读,这就是边沿模式)。
int clientfd = events[i].data.fd;
char buffer[BUFFER_LENGTH] = {0};// ET 边沿模式需循环读取,原因是要保证网络 I/O 所有内容都被读取!
while (1) {ssize_t len = recv(clientfd, buffer, BUFFER_LENGTH - 1, 0);// recv 会因无输入而阻断// 函数 recv: 读取固定字节的内容。// 返回值 > 0:表示成功接收了数据,返回值表示实际接收到的字节数。// 返回值 == 0:表示对端已经关闭了连接(TCP连接的正常关闭)。这是TCP协议的对端关闭连接的标志。// 返回值 == -1:表示接收操作失败。错误原因可以通过 errno 获取if (len > 0) {buffer[len] = '\0'; // 没有终结符是无法正常打印的// 成功,打印结果printf("Recv: %s (%zd bytes)\n", buffer, len); // zd 占位符表示 size_t 类型的输出占位值。} else if (len == 0 || (len < 0)) {close(clientfd); // ev 一直以来就是单位存在,作用与 clientfd 一样,是用来重复赋值的。ev.events = EPOLLIN;ev.data.fd = clientfd;epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev); // 从老板的名单里删除这个客人break;}
}
第五步,发送 send 消息。
ssize_t n = send(sockfd, ptr, length, flags);
第六步,主动结束连接
close(sockfd); /* 成功返回 0,失败返回 -1 并置 errno */
bind
函数的内核底层
一、 函数原型
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数含义:
sockfd
: 要绑定地址的套接字文件描述符。addr
: 一个指向通用地址结构体的指针。它的具体类型取决于协议族(IPv4、IPv6等)。addrlen
:addr
结构体的实际长度。
以 IPv4 sockaddr_in
为例
struct sockaddr_in {sa_family_t sin_family; // 地址族: AF_INET (IPv4)in_port_t sin_port; // 网络字节序的16位端口号struct in_addr sin_addr; // 网络字节序的32位IP地址char sin_zero[8]; // 填充字段,未使用
};
sin_family
: 告诉内核这是哪种地址(AF_INET for IPv4)。sin_port
: 你想要绑定的端口号。如果设置为0,内核会随机分配一个可用端口(常用于客户端)。sin_addr
: 你想要绑定的本地IP地址。INADDR_ANY
(宏,通常是0.0.0.0):绑定到所有本地接口(网卡)。服务器通常这样设置,表示可以接受从任何网卡来的连接。- 指定一个具体的IP(如192.168.1.100):只接受目标为该IP地址的数据。
二、bind 函数在内核底层发生了什么
- 权限与冲突检查:内核收到请求后,首先检查当前进程是否有权限绑定到指定的IP和端口。
- 检查端口是否可用:内核检查该IP和端口的组合是否已经被其他套接字绑定。这就是
SO_REUSEADDR
和SO_REUSEPORT
选项发挥作用的地方,它们可以放松这个检查的严格程度。 - 更新套接字对象:如果检查通过,内核会将用户传入的地址信息(IP和Port)写入到该套接字对应的内核
struct sock
对象中。此时,套接字就有了一个明确的本地身份(<本地IP:本地端口>)。 - 对于 UDP/TCP 服务器:
bind
是listen
/recvfrom
的前置条件,它为服务指定了一个众所周知的监听地址。
一个端口只能有一个监听套接字,否则其他客户端过来这个端口访问,系统就不知道把内容该放置在哪里。
listen
函数和 accept
函数的内核底层
listen 和 accept 是 TCP 服务器协议栈实现的精髓所在,它们共同构建了著名的“三次握手”。
一、listen(int sockfd, int backlog)
的内核运作
listen 的作用是宣告一个套接字愿意接受连接,并为其建立管理连接的基础设施。它是一个被动开启的过程。在内核中,一个 TCP 套接字有多种状态。bind 之后,它处于 CLOSED 状态。listen 调用会将其状态变为 LISTEN 状态,并触发以下关键操作:
- 创建连接管理队列(最核心的操作)
内核会为这个监听套接字创建两个队列:
半连接队列(SYN Queue / Incomplete Connection Queue)
——存放什么:存放已完成第一次握手(收到客户端 SYN 包)、但尚未完成三次握手的连接。这些连接对应的内核对象处于 SYN_RCVD 状态。
——用途:用于等待第三次握手(客户端的 ACK)的到达。
全连接队列(Accept Queue / Complete Connection Queue)
——存放什么:存放已经完成三次握手、但尚未被应用程序通过 accept 取走的连接。这些连接处于 ESTABLISHED 状态。
——用途:作为应用程序待处理的已建立连接的缓冲区。 - 设置
backlog
参数
backlog 参数历史上被解释为这两个队列之和的最大长度。但在现代 Linux 内核中,其含义有所变化:
——/proc/sys/net/core/somaxconn
定义了系统级别的最大 backlog 值。你的程序设置的 backlog 会和这个值比较,取其中较小者作为最终的限制。
——在 Linux 2.2 之后,backlog 的大小仅指全连接队列的最大长度。半连接队列的长度由另一个参数/proc/sys/net/ipv4/tcp_max_syn_backlog
控制。
因而务必要注意下面这段代码,指的是内核里的套接字文件 sockfd
所维护的全连接队列(Accept Queue / Complete Connection Queue)的长度是 10
,但是半连接队列(SYN Queue / Incomplete Connection Queue)也不可以任意长,它是有一个严格的上限。这个队列是被用户态的 accept 函数消费的。
listen(sockfd, 10)
内核为监听套接字维护的两个队列中,每一个元素都不是一个简单的数据结构,而是一个庞大的内核对象(C struct)。最重要的两个结构是:
- 半连接队列(SYN Queue)中的元素:通常是
struct request_sock
或其更具体的子类(如struct tcp_request_sock
)。 - 全连接队列(Accept Queue)中的元素:是完整的
struct sock
(或struct inet_connection_sock
),这已经是一个几乎和普通套接字一样的内核对象了。
这些结构体包含的信息远不止IP和端口,它们构成了TCP状态机的核心。一个队列元素(以全连接队列中的 struct sock
为例)主要包含:
- 连接的四元组信息:本地IP、本地端口、对端IP、对端端口。这正是用来唯一标识一个连接的信息。
- TCP状态机状态(state):例如 SYN_RCVD(在半连接队列中)或 ESTABLISHED(在全连接队列中)。
- 大量的TCP控制块(TCB)信息:
- 发送和接收序列号(Sequence Number, Acknowledgment Number)
- 接收窗口(Window)大小
- 拥塞控制信息(拥塞窗口、慢启动阈值等)
- 定时器(用于重传、保活等)
- 发送和接收缓冲区
- 指向套接字本身的其他元数据。
所以,队列的每个单元不是一个简单的(IP, Port)元组,而是一个包含了整个连接所有状态信息的、非常复杂的“连接对象”。
总结 listen 的职责:它不做任何网络通信,而是初始化内核数据结构,准备好“接待区域”(两个队列),并告诉协议栈:“我已经准备好了,可以开始处理来自客户端的连接请求了”。
二、accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
的内核运作
accept 的作用是从全连接队列中取出一个已经建立好的连接,并为这个连接创建一个新的套接字返回给应用程序。它的底层运作是一个(可能)阻塞的过程:
- 检查队列
——当应用程序调用 accept 时,内核会首先去检查这个监听套接字的全连接队列是否为空。 - 队列非空(有已完成握手连接)
——如果全连接队列中有条目,内核会立即从队列头中取出一个连接。
——然后,内核会创建一个全新的套接字(connfd)。这个新套接字的状态已经是 ESTABLISHED。
——内核会初始化这个新套接字的所有信息,包括对端的 IP 地址和端口等,并将其文件描述符返回给应用程序。
——原来的监听套接字(sockfd)保持不变,继续用于监听新的连接请求。 - 队列为空(无已完成握手连接)
——如果队列为空,默认情况下(阻塞模式),调用 accept 的进程会被投入睡眠,直到有一个连接完成三次握手,被放入全连接队列后,才被唤醒并返回这个新连接。
——如果监听套接字被设置为非阻塞模式,accept 会立即返回 -1,并设置错误码为 EAGAIN 或 EWOULDBLOCK。
关键点:accept 是一个“消费”过程,它只是从内核已经建立好的连接队列中取出结果。TCP 的三次握手是由内核协议栈独立、异步完成的,完全不需要应用程序参与。应用程序只在握手完成后,通过 accept 来“收货”。
三、TCP 三次握手完全由操作系统内核协议栈完成,绝对不在用户态
基于 Linux 内核实现的、更精确的步骤分解:
-
收到 SYN 包
1.1——客户端发送 SYN 报文到达服务器网卡。
1.2 ——内核网络协议栈处理这个包,根据目标端口找到对应的监听套接字。
1.3 ——关键点:内核此时不会先去检查全连接队列(队列长度有限且固定)。它的首要任务是响应握手。 -
创建半连接条目并发送 SYN-ACK
2.1—— 内核协议栈会创建一个 struct request_sock 对象(这是一个轻量级的、代表“连接请求”的内核对象)。这个对象包含了连接的五元组信息、序列号等初始状态。
2.2—— 内核将这个 request_sock 对象放入半连接队列(SYN Queue)。这个队列通常是一个哈希表或链表,便于快速查找,而不是一个数组。
2.3—— 然后,内核直接发送 SYN-ACK 包给客户端。 -
收到 ACK 包(完成握手)
3.1—— 收到客户端的 ACK 后,内核协议栈通过五元组在半连接队列的哈希表中查找到对应的 request_sock 对象。
3.2—— 此时,内核才会检查全连接队列(Accept Queue)是否已满:
——3.2.1—— 如果队列未满:内核会创建一个完整的、新的 struct sock 对象(这就是最终的连接套接字的内核形态)。它包含了完整的 TCB(传输控制块),其中就有滑动窗口、拥塞控制、数据缓冲区等所有状态信息。然后,内核将这个 sock 对象放入全连接队列的尾部(移动“生产指针”)。
——3.2.2—— 如果队列已满:行为取决于配置。默认情况下,内核会丢弃这个 ACK,假装没收到,导致客户端重传 ACK。如果开启了 tcp_abort_on_overflow,内核可能会直接发送 RST 包重置连接。
3.3—— 无论成功与否,之后内核都会将半连接队列中的 request_sock 对象移除(释放)。 -
accept 系统调用(用户态消费)
4.1—— 当应用程序调用 accept() 时,内核会检查全连接队列的头部。
4.2—— 如果队列不为空:内核会从队列头部取出一个 sock 对象。
4.3—— 然后,内核会创建一个新的文件描述符(connfd),并将这个文件描述符与取出的 sock 对象关联起来。
4.4—— 最后,将这个 connfd 返回给应用程序。
4.5—— 关键点:accept 并不构建 TCB。TCB 在第 3 步内核完成握手、创建 sock 对象时就已经完全构建好了。accept 的作用仅仅是“领取”这个已经准备好的连接。
Socket 套接字的接收缓冲区容量
大家可以看我的这篇文章 《TCP 协议栈的知识汇总》,里面介绍的 TCB (TCP 控制块,TCP Control Block)是管理着滑动窗口的,套接字底层的接收、发送缓冲区就包含滑动窗口。接收缓冲区的滑动窗口是管理剩余可用空间+可读取空间+已读取空间(共三者的)。
每个Socket套接字在内核中都有一个接收缓冲区(receive buffer 或 receive queue),它的大小绝对不是无限的。这个缓冲区的大小是可配置的,并且有上下限约束。
-
作用:这个缓冲区用于暂存内核已经收到、但应用程序尚未通过 recv/recvfrom 等系统调用读取的数据。它解耦了网络数据送达的不可预测性(可能瞬间到来大量数据包)和应用程序读取数据的节奏(可能正在处理其他任务,无法立即读取)。
-
大小限制:
—— 默认值:系统为每个套接字的接收缓冲区设置了一个默认大小。这个值因操作系统和版本而异,通常在几十KB到几百KB之间(例如,现代Linux系统可能默认在128KB ~ 256KB)。
—— 可配置:你可以使用 setsockopt 系统调用 with SO_RCVBUF 选项来增大这个缓冲区的大小。
—— 系统上限:但你无法无限增大它。系统有一个全局的最大值限制(/proc/sys/net/core/rmem_max on Linux),你设置的数值不能超过这个上限。管理员可以修改这个全局最大值。
int recv_buf_size = 1024 * 1024; // 1MB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));
- 缓冲区满时的行为:
——对于TCP:接收缓冲区是TCP流量控制和拥塞控制的核心。当接收缓冲区快满时,TCP会在ACK包中通告一个很小的接收窗口(甚至为0)。发送方看到这个零窗口就会停止发送数据,从而防止接收端缓冲区溢出和数据丢失。这是一种背压机制。
——对于UDP:没有流量控制。如果应用程序读取速度跟不上数据到达的速度,导致接收缓冲区满,新到的UDP数据报会被内核直接丢弃。这就是为什么UDP是不可靠的。
内核缓冲区大小有限,所以才需要用户态缓冲区,他还有一个作用,那就是按照协议去切割内容。我之后也会写关于用户态缓冲区的博客。
setsockopt
函数的内核底层
一、函数原型与参数解析
#include <sys/socket.h>int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
int sockfd
作用:这是你想要配置的目标套接字的文件描述符。它指定了操作对象。int level
作用:定义了选项的协议层级。它指定了 optname 属于哪个协议模块。
常见枚举常量:
——SOL_SOCKET
:通用套接字层选项。这些选项与协议无关,是套接字本身的基础属性(如广播、调试、重用地址等)。
——IPPROTO_IP
:IPv4 协议层选项。
——IPPROTO_IPV6
:IPv6 协议层选项。
——IPPROTO_TCP
:TCP 协议层选项。这些选项专门用于调节 TCP 协议的行为(如延迟、保活等)。
——IPPROTO_UDP
:UDP 协议层选项(较少使用)。int optname
作用:在指定的 level 下,你想要设置的具体选项名称。这是最核心的参数。
常见枚举常量(按 level 分组):
SOL_SOCKET 层级:
——SO_REUSEADDR
:允许重用本地地址(和端口)。这是解决“Address already in use”错误的关键,对服务器快速重启至关重要。
——SO_REUSEPORT
(Linux 3.9+):允许多个套接字绑定到完全相同的地址和端口,用于实现负载均衡,提升多进程/多线程服务器性能。
——SO_BROADCAST
:允许发送广播数据报(UDP)。
——SO_SNDBUF
/SO_RCVBUF
:设置套接字的发送和接收缓冲区大小。
——SO_KEEPALIVE
:启用TCP保活机制,定期检测连接是否存活。
——SO_LINGER
:控制 close() 函数在套接字还有未发送数据时的行为。
——SO_DEBUG
:启用内核调试信息记录。
IPPROTO_TCP 层级:
——TCP_NODELAY
:禁用 Nagle 算法。Nagle算法通过合并小数据包来减少网络报文数量,但会增加延迟。设置此选项可以降低延迟,适合交互式应用(如SSH、游戏)。
——TCP_MAXSEG
:设置TCP最大报文段大小(MSS)。
——TCP_KEEPIDLE
/TCP_KEEPINTV
L /TCP_KEEPCNT
:细粒度地控制TCP保活探测的参数(首次探测前空闲时间、探测间隔、探测次数)。const void *optval
作用:一个指向包含新选项值的缓冲区的指针。这个指针的类型完全取决于optname
。
常见类型:
——int*
:大多数选项使用一个整数(如SO_REUSEADDR
,TCP_NODELAY
)。1表示启用,0表示禁用。
——struct timeval*
:用于超时选项。
——struct linger*
:用于SO_LINGER
选项。
——int*
:用于缓冲区大小选项(SO_SNDBUF
,SO_RCVBUF
)。socklen_t optlen
作用:指定了 optval 缓冲区的大小(以字节为单位)。例如,如果 optval 指向一个 int,那么 optlen 通常就是 sizeof(int)。
二、底层发挥了什么作用?
setsockopt 是用户态程序与内核协议栈进行配置交互的桥梁。它的底层运作可以概括为:
- 查找套接字对象:内核根据你传入的文件描述符
sockfd
,在内核的进程文件描述符表中找到对应的struct socket
内核对象。 - 参数验证与复制:内核会检查你传入的参数是否合法(例如,
level
和optname
组合是否有效,optval
和optlen
是否匹配)。 - 设置内部状态:这是核心步骤。内核会根据
level
和optname
,去修改这个struct socket
对象(或其底层关联的struct sock
对象)的特定字段。
——例如:设置SO_REUSEADDR
时,内核会设置一个标志位sk->sk_reuse
。
——例如:设置SO_SNDBUF
时,内核会修改sk->sk_sndbuf
的值,并可能据此重新分配缓冲区内存。
——例如:设置TCP_NODELAY
时,内核会设置inet_connection_sock->icsk->icsk_ack.pingpong
或类似的字段来禁用Nagle算法。 - 触发副作用:某些选项的设置会立即触发内核的特定动作。
——例如:设置缓冲区大小时,内核会立即尝试调整内存分配。
——例如:设置TCP_NODELAY
后,内核可能会立即将发送缓冲区中堆积的小数据包发送出去。 - 返回用户态:操作完成后,系统调用返回0(成功)或-1(失败,并设置 errno)。
fcntl
函数:文件控制
fcntl
(File Contl)是一个用于对已打开的文件描述符进行各种控制的系统调用。它功能非常强大,可以操作文件描述符的许多底层属性。
#include <unistd.h>
#include <fcntl.h>int fcntl(int fd, int cmd, ... /* arg */ );
fd
:要操作的文件描述符。cmd
:指定要执行的操作命令。arg
:可变参数,根据不同的 cmd,它可能是整数、指针或结构体。
常见操作(cmd
)及其内核运作:
命令 (cmd ) | 作用描述 | 底层内核运作 |
---|---|---|
F_GETFL | 获取文件描述符的状态标志 | 内核从文件描述符表对应的条目中取出 flags 字段(如 O_RDONLY , O_NONBLOCK )并返回。 |
F_SETFL | 设置文件描述符的状态标志 | 内核将新的标志位(如 O_NONBLOCK , O_APPEND )写入文件描述符的 flags 字段。这会立即改变该描述符的行为。 |
F_GETFD | 获取文件描述符标志(close-on-exec ) | 内核检查并返回 close-on-exec 标志的状态。 |
F_SETFD | 设置文件描述符标志 | 内核设置或清除 close-on-exec 标志。这决定了 exec 系列函数执行新程序时是否会关闭此描述符。 |
F_DUPFD | 复制文件描述符 | 内核在进程的文件描述符表中找一个空闲的、>= arg 的槽位,创建一个新的描述符,它和旧描述符指向同一个内核文件对象。 |
在网络编程中的经典用法:设置非阻塞(Non-blocking)IO。这是 fcntl 最常见的用途。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...// 1. 首先获取当前的标志位
int flags = fcntl(sockfd, F_GETFL, 0);
// 2. 在此基础上加上非阻塞标志
flags |= O_NONBLOCK;
// 3. 将新标志位设置回去
fcntl(sockfd, F_SETFL, flags);// 现在,对 sockfd 的 accept, read, write 等调用都不会阻塞了。
当设置了 O_NONBLOCK
后,内核在该文件描述符的后续操作中,如果发现操作会导致进程睡眠(例如读空缓冲区、写满缓冲区),它会立即返回一个错误(EAGAIN
或 EWOULDBLOCK
) 而不是让进程阻塞等待。
fcntl
与 setsockopt
的核心区别
特性 | fcntl | setsockopt |
---|---|---|
管理对象 | 通用的文件描述符 | 专门的套接字描述符 |
操作层次 | VFS(虚拟文件系统)层 | 协议栈层(SOL_SOCKET , IPPROTO_TCP 等) |
控制范围 | 控制文件描述符的通用行为 | 控制套接字协议相关的特定行为 |
典型功能 | 设置非阻塞(O_NONBLOCK )、异步信号驱动(O_ASYNC )、追加模式(O_APPEND )、文件描述符复制、close-on-exec | 设置地址重用(SO_REUSEADDR )、缓冲区大小(SO_SNDBUF )、禁用Nagle(TCP_NODELAY )、保活(SO_KEEPALIVE ) |
比喻 | 汽车的通用操控:如切换手动/自动模式(阻塞/非阻塞)、设置儿童锁(close-on-exec) | 汽车的发动机和传动系统调校:如调整节气门响应(Nagle算法)、更换更大的油箱(缓冲区大小)、调整怠速(保活间隔) |
简单来说:
fcntl
是用来控制 “如何与文件描述符交互” 的(比如要不要等、会不会被继承)。setsockopt
是用来调整 “套接字协议本身的行为” 的(比如怎么发数据、缓冲区多大)。
一个套接字描述符首先是一个文件描述符,所以它同时受到 fcntl
和 setsockopt
的共同影响。例如,你可以用 fcntl
设置一个TCP套接为非阻塞,同时用 setsockopt
来禁用Nagle
算法和增大它的发送缓冲区。
recvfrom
函数与 recv
函数的区别与底层机制
这两个函数非常相似,核心区别在于是否需要知道数据的来源地址。
一、recvfrom 函数
用法:用于从套接字读取数据,并获取发送方的地址信息(对于IP套接字,就是IP地址和端口号)。
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd
: 套接字描述符。buf
和len
: 应用程序提供的缓冲区地址和大小,用于存放接收到的数据。flags
: 标志位(如O_NONBLOCK
非阻塞)。src_addr
: 这是一个出参。函数返回时,如果此参数非NULL
,内核会把发送方的地址信息填充到这里。addrlen
: 这是一个入出参。调用前需要设置为src_addr
指向内存的大小;返回后会被内核更新为实际地址结构的长度。- 返回值:读取字段的字节长度
适用场景:主要用于无连接的套接字(SOCK_DGRAM
,如UDP)。因为每次数据的来源都可能不同,所以需要 recvfrom
来告诉你是谁发来的消息。
二、recv
函数
用法:用于从套接字读取数据,但不关心发送方的地址。
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 参数:比
recvfrom
少了最后两个地址参数。 - 适用场景:主要用于已连接的套接字(
SOCK_STREAM
,如TCP;或已connect
的UDP套接字)。因为对于一个已连接的套接字,通信对象是固定的,对方的地址在连接时就已经知道,无需每次接收都再获取一遍。
特性 | recvfrom | recv |
---|---|---|
主要用途 | 接收数据并获取发送方地址 | 接收数据不关心发送方地址 |
适用协议 | UDP(主要)、原始套接字 | TCP、已连接的UDP套接字 |
地址参数 | 需要提供 src_addr 和 addrlen 参数 | 无地址参数 |
底层实现 | 是更基础的系统调用 | 通常是调用 recvfrom(fd, buf, len, flags, NULL, NULL) |
使用者两个函数时,底层发生了什么事情:
-
检查接收缓冲区:调用发生时,内核首先检查该套接字的接收缓冲区中是否有数据。
-
缓冲区有数据:如果有数据,内核将数据从接收缓冲区拷贝到用户提供的缓冲区 buf 中。对于 recvfrom,内核还会将数据包的来源地址信息填充到 src_addr 指向的结构体中。
-
缓冲区无数据:
阻塞模式:调用线程被投入睡眠,直到有数据到达接收缓冲区后被唤醒。
非阻塞模式:立即返回-1,并设置错误码为EAGAIN
或EWOULDBLOCK
。 -
滑动窗口的更新(这是对于 TCP 协议来说的):这是一个至关重要的副作用。当数据被应用层
recv
或recvfrom
读取后,接收缓冲区的空闲空间就变大了。内核会在下一次发送 ACK 确认报文时,增大通告给发送方的接收窗口(RWND),通知对方:“我现在有更多空间了,你可以多发点数据过来”。这个更新是异步的,并非在 recv 调用时立即发生。
对于 UDP (SOCK_DGRAM
):
- 通信单元是数据报:UDP 的接收缓冲区是一个数据报的队列。每个节点都是一个完整的、独立的报文。
recvfrom
的语义:每次调用recvfrom
或recv
的意图都是读取下一个完整的数据报。- 截断与丢弃:如果用户提供的缓冲区
len
小于数据报的实际长度,内核会只拷贝len
个字节到用户空间,然后静默丢弃数据报剩余的所有字节。该数据报从此消失。 - 返回值:系统调用返回的是实际拷贝的字节数(在这个场景下就是
len
)。默认情况下,应用程序无法知道发生了截断(除非使用 recvmsg 并检查 MSG_TRUNC 标志)。
UDP 的比喻: 这就像从邮箱里取信。recvfrom
一次取一封信。如果你的手(缓冲区)太小拿不下大信,你就只能抓住信的一角(len
字节),剩下的部分就掉在地上被风吹走了(被内核丢弃)。你永远不知道那封信完整的内容是什么。
对于 TCP (SOCK_STREAM
):
- 通信单元是字节流:TCP的接收缓冲区是一个字节的队列,或者说是连续的字节流。它没有“消息”或“数据报”的边界。发送方多次
send
的数据可能会被TCP合并成一个大的数据块接收,反之,一次send
的大量数据也可能被拆分成多次recv
调用才能收完。 recv
的语义:每次调用recv
的意图是从字节流中读取当前可用的、最多不超过len
个字节。- 绝无“丢弃”:TCP 内核永远不会因为你的缓冲区小而去主动丢弃后续的字节。 这是 TCP可靠性的核心保证。所有到达的字节都会按顺序、无差错地保存在接收缓冲区中,直到被应用程序读取。
- 返回值:系统调用返回的是当前实际读取到的字节数。这个数字可能大于0且小于等于 len。它只表示这一次调用返回了多少字节,绝不意味着发送方发送的消息就此结束。剩下的字节仍然安然无恙地留在内核缓冲区里,等待你下一次调用
recv
来读取。
TCP 网络收取信息的一个生动的比喻
把TCP接收缓冲区想象成一个由许多节车厢(sk_buff)组成的火车,每节车厢里都装着一部分连续的货物(数据字节)。
- 应用程序是卸货工人。
- recv 调用是工人每次拿着一个篮子(用户缓冲区)来取货。
- 工人的任务是从火车头开始,连续地取货,直到篮子装满(len)或者当前连续的货物被取完。
每次取货可能发生的情况:
- 取空一节车厢:工人从第一节车厢取走了里面所有的货。结果:这节车厢被解开并拖走(内核释放这个 sk_buff)。
- 取走一节车厢的部分货:工人从第一节车厢只取了一部分货,篮子就满了。结果:这节车厢仍然留在火车上,但里面的货物变少了(内核修改 sk_buff 的指针和长度)。
- 跨车厢取货:工人的篮子很大,他从第一节车厢取完了所有货还不够,又继续从第二节车厢取了一部分。结果:第一节车厢被拖走,第二节车厢剩下一部分货。
在这个比喻中,工人(recv)完全不关心每节车厢最初是从哪里来的(对应哪个TCP报文段),他只关心从火车头开始能连续拿到多少货。
send
函数与 sendto
函数的区别与底层机制
一、用法区别
send(int sockfd, const void *buf, size_t len, int flags)
用于已连接的套接字(如TCP套接字或已 connect
的UDP套接字)。因为它不需要指定目标地址,地址在连接时已确定。
参数
sockfd
:必须是 已连接 的套接字描述符(TCP 经过 connect/accept;UDP 调过 connect)。buf
:用户空间缓冲区指针,内核把它拷进协议栈。len
:你想让内核“尝试”发送多少字节;实际返回值 ≤ len。flags
:0 表示默认行为,比如O_NONBLOCK
- 返回值:≥0 实际拷贝到 内核发送缓冲区 的字节数;−1 表示失败,
errno
定位原因。
sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen)
前 4 个参数与 send 完全一样,意义相同。新增的两个:
dest_addr
:指向一个sockaddr_in/in6
结构,告诉内核“把这份报文发给谁”。addrlen
:结构体的真实长度,如sizeof(struct sockaddr_in)
。
用于无连接的套接字(如原始UDP套接字)。每次调用都必须指定目标地址。这说明了 connect
函数并不是发送消息所必须的,只要我们不打算作二次信息发送,只做一锤子买卖的话,直接 sendto
发送就行了。
二、当我们调用这两个函数时底层发生了什么
它们的底层路径在协议无关层之后几乎是相同的。核心过程如下:
- 用户态到内核态的拷贝:无论哪个函数,都会将用户态缓冲区 buf 中的数据拷贝到内核态的套接字发送缓冲区中。这是一个昂贵的操作,是零拷贝技术(如
sendfile
)旨在优化的点。 - 内核协议栈处理:数据进入发送缓冲区后,内核协议栈(特别是TCP)就开始独立工作了:
- TCP:协议栈将缓冲区中的数据划分为合适的报文段(MSS),添加 TCP 头(包括序列号、由接收方通告的发送窗口大小等),然后交给 IP 层。发送窗口(SWND) 决定了最多能发送多少未被确认的数据,其上限是
min(接收方通告窗口, 拥塞窗口)
。如果窗口已满,send
调用可能会阻塞(默认行为)或返回EAGAIN
错误(非阻塞模式)。 - UDP:因为没有流量控制,内核通常会立即将数据打包成UDP数据报,交给IP层。如果发送缓冲区已满,数据报可能被丢弃。
- TCP:协议栈将缓冲区中的数据划分为合适的报文段(MSS),添加 TCP 头(包括序列号、由接收方通告的发送窗口大小等),然后交给 IP 层。发送窗口(SWND) 决定了最多能发送多少未被确认的数据,其上限是
- 异步发送:
send
/sendto
调用在数据成功放入发送缓冲区后就返回了,并不代表数据已经发送到对端,更不代表对端已经收到。数据的实际发送是由内核网络协议栈异步完成的。
send
是 sendto
在已连接情况下的便捷包装。底层都是拷贝数据到内核发送缓冲区,并由内核根据滑动窗口和拥塞控制规则决定何时发送。
三、UDP 的发送机制:“All-or-Nothing”(要么全发,要么不发)
内核操作单元:UDP的发送缓冲区管理的是一个数据报的队列。每个队列元素都是一个完整的、独立的UDP数据报。
sendto
的底层步骤:
- 应用程序调用
sendto(sockfd, buf, len, flags, dest_addr, addrlen)
。 - 内核立即在内存中构建一个完整的UDP数据报(包括UDP头、你的数据载荷)。
- 内核尝试将这个完整的数据报对象(例如Linux中的
sk_buff
)添加到该套接字的发送缓冲区队列的尾部。 - 关键决策点:
- 如果队列未满:添加成功。
sendto
调用返回(通常返回你传入的len
)。内核网络栈会在后台异步地将这个数据报发送出去。 - 如果队列已满:添加失败。内核直接丢弃刚才构建的整个数据报。
sendto
的行为取决于套接字模式:- 阻塞模式(默认):调用进程被投入睡眠,直到队列中有空间容纳一个新的数据报为止。
- 非阻塞模式(通过
fcntl
设置O_NONBLOCK
):sendto
立即返回-1
,并设置错误码EAGAIN
或EWOULDBLOCK
,通知应用程序“请重试”。
- 如果队列未满:添加成功。
核心原则:UDP协议绝不拆分一个应用层的数据报。它视每次 sendto 调用所提供的数据为一个不可分割的整体。这就是你所说的“强调完整性”。如果缓冲区没有空间容纳这个“整体”,那就整个放弃。
四、TCP “尽力而为”(能发多少发多少)
TCP 的行为与 UDP 截然不同,因为它管理的是字节流,而不是数据报队列。内核操作单元:TCP 的发送缓冲区是一个字节数组(或字节流链表)。
send
的底层步骤:
- 应用程序调用
send(sockfd, buf, len, flags)
。 - 内核检查发送缓冲区中的空闲空间大小(
free_space
)。 - 关键决策点:
- 如果
free_space >= len
:内核将用户提供的 len 个字节全部拷贝到发送缓冲区。send
返回len
。 - 如果
0 < free_space < len
:内核只会拷贝 free_space 个字节到发送缓冲区(即能放多少就放多少)。send
的返回值是实际拷贝的字节数(free_space
),而不是len
。 - 如果
free_space == 0
:- 阻塞模式:进程睡眠,直到缓冲区有空闲空间(例如对方确认接收了一些数据,释放了窗口)。
- 非阻塞模式:立即返回-1,错误码为
EAGAIN
。
- 如果
核心原则:TCP协议会尽力接受应用程序提供的所有数据。如果空间不足,它会接受它能接受的部分,并告诉应用程序实际接受了多少。应用程序需要检查返回值,并在必要时重新调用 send 来传输剩余的字节。TCP内核会在后台负责将这些字节流分割成多个TCP报文段、排序、重传,从而对应用程序隐藏网络的复杂性。
五、比喻作总结
UDP 像寄快递:你拿来一个完整包裹(数据报)交给快递站(内核)。如果快递站的发货货架(发送缓冲区)满了,快递员就直接告诉你:“货架满了,你这包裹我不收”(丢弃/阻塞/返回错误)。一个包裹是一个不可分割的整体。
TCP 像用漏斗往瓶子里灌水:你倒水(字节数据)进去。如果瓶子(发送缓冲区)快满了,水流(你的 send 调用)就会变慢甚至停止(阻塞),但绝不会有多余的水洒出来。你会看到实际倒进去了多少(返回值),如果没倒完,你可以等一会儿或者换个时间再继续倒。水是连续的流体,可以按任意容量接收。(这说明,用户需要时刻检测 send
的返回值,更新缓冲区的指针,传送剩余的内容。)
close
函数的内核底层
一、close()
触发 TCP 四次挥手
当应用程序调用 close(fd)
时,底层发生以下事情:
-
递减引用计数:内核中的套接字对象有一个引用计数。
close()
调用会减少这个计数。只有当计数变为 0(意味着没有其他进程或文件描述符引用这个套接字)时,真正的关闭序列才会开始。 -
检查发送缓冲区:内核会检查该套接字的发送缓冲区是否还有未被发送或已被发送但未得到 ACK 确认的残留数据。
- 如果有残留数据(默认行为):协议栈会尝试将剩余数据发送出去。进程可能会阻塞(
SO_LINGER
选项可控制此行为)。 - 如果没有残留数据,或数据已成功发送/丢弃:TCP 协议栈会构建一个 FIN 包( FIN 标志位设置为 1 的 TCP 报文),并将其放入发送队列,表明本方向的数据流已结束。
- 如果有残留数据(默认行为):协议栈会尝试将剩余数据发送出去。进程可能会阻塞(
-
状态变更:套接字的内核状态从
ESTABLISHED
变为FIN_WAIT_1
。至此,close()
系统调用通常就返回了,但连接并未完全关闭,后续的挥手过程由内核协议栈在后台异步完成。
二、四次挥手的完整内核状态机流程
假设客户端先调用 close()
。
第一步:客户端发送 FIN
- 客户端:调用
close()
,发送FIN包,状态由ESTABLISHED -> FIN_WAIT_1
。 - 服务端:收到 FIN 包,内核状态由
ESTABLISHED -> CLOSE_WAIT
。
第二步:服务端发送 ACK
- 服务端:内核协议栈立即响应一个 ACK 包,确认收到了客户端的 FIN 包。此时,服务端内核知道客户端已结束发送,但服务端应用可能还有数据要发送。
- 客户端:收到 ACK 包,状态由
FIN_WAIT_1 -> FIN_WAIT_2
。此时,从客户端到服务端的单向连接已经关闭(客户端不能再发送数据,但还可以接收)。
第三步:服务端发送 FIN
- 服务端:当服务端应用程序也调用
close()
关闭套接字时,服务端内核会发送自己的FIN包,状态由CLOSE_WAIT -> LAST_ACK
。 - 客户端:收到服务端的FIN包,状态由
FIN_WAIT_2 -> TIME_WAIT
。
第四步:客户端发送 ACK
- 客户端:内核协议栈立即响应一个 ACK 包,确认收到了服务端的FIN包。
- 服务端:收到这个最终的 ACK 包后,套接字状态变为
CLOSED
,内核资源被释放。 - 客户端:在
TIME_WAIT
状态等待 2MSL(两倍最大报文段生存时间)后,状态变为CLOSED
,释放资源。
我们需要知道的是服务端向客户端发送的 ACK 包和 FIN 包不会合并在一起发的,因为 TCP 协议是可靠的连续传输协议,他必须要最后完成所有对客户端的传输任务才能结束,向客户端说自己已经准备好了。
四、套接字内部发生了什么
在整个挥手过程中,套接字内核对象(struct sock
)经历以下变化:
- 状态字段(state):严格按照TCP状态机图进行变更(如
ESTABLISHED -> FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSED
)。 - 发送和接收缓冲区:开始排水和清理。确保所有已排队的数据都被处理。
- 定时器:
- 重传定时器:如果发出的 FIN 或 ACK 丢失,定时器超时会触发重传。
TIME_WAIT
定时器:在TIME_WAIT
状态启动一个 2MSL 的定时器,这是挥手过程的最后一步。其目的是:- 可靠地终止连接:确保最后的 ACK 丢失后,可以重传(对方会重传 FIN)。
- 让旧连接的迷途报文在网络中消散,避免被相同四元组的新连接错误接收。
从 UDP 报文到 UDP 服务器的思考
从 UDP 报文可以看出 UDP 协议不讲仪式。它从不回头确认信号的完整性。也就是说它是毫无底层 “机制” 可言的,他是不会去管对端知不知道自己收不收到信息的,这说明我们如果用 UDP 协议做服务器就根本用不到 “滑动窗口” 的,本地接收缓冲区不需要内置 “滑动窗口”,当然也无需要 “控制块” 的存在(控制块控制滑动窗口)。
0 1 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 <-- 对齐 2 个字节 (16 位)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| checksum | <-- UDP 报文的头部(共 8 个字节)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data Payload (可变长度) | <-- 数据荷载,实际数据,对齐值为 2 个字节
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
它不需要内核为它完成三次握手。TCP 协议的三次握手其实就是三句话可以概括:
1、客户端说:”我想跟你建立长连接“。
2、服务器说:”我知道了你想跟我连接,我批准了“。
3、客户端说:”我知道你批准了“。
TCP 协议进行三次握手,尽管三次握手无需要用户态干涉,但是用户需要主动去构造半连接队列和全连接队列以承接 ”三次握手“ 的结果,而且还要用户主动去消耗全连接队列以获取管理连接的句柄——套接字。UDP 无需做到这一点,因而无需要 listen
和 accept
这两个函数。(此过程还产生了 TCB,即 TCP 控制块)
它不需要内核为他做四次挥手。TCP 协议的四次挥手其实就是四句话可以概括:
1、客户端说:”我们断开连接吧“。
2、服务端说:”我收到你的请求了,但请等一下,我还有一些信息没向你发完“。
3、过了一会,服务端:”我处理好了,现在可以结束了“。
4、客户端说:”我收到了,我们现在可以正式关闭了“。
UDP 协议连控制块和滑动窗口都没有,又怎么会需要四次挥手呢,更不需要触发四次挥手的 close
函数。
那么,我们做 UDP 服务器究竟需要什么呢?它依然需要套接字,只不过它不需要跟客户端打任何招呼,他需要命令套接字认准这个客户端的 IP 地址和端口 PORT 即可。该套接字 记忆 完客户端的地址端口信息后,它想就可以想什么时候发就什么时候发信息。这里也有一个前提,客户端也找到一个套接字专门 记忆 服务端的 IP 地址和端口 PORT 信息。它们都不用跟对方商量,只要一发信息,各自的套接字都能接收到对应的信息,这是默契。
我们其实就需要套接字把对端位置信息记录起来,知道对方位置就行了,之后就可以正常发送信息了。这时就得引出 connect
函数的内核底层机理。
connect
函数的内核底层
#include <sys/socket.h>
int connect(int sockfd,const struct sockaddr *addr,socklen_t addrlen); /* 成功 0,失败 -1 置 errno */
参数:
sockfd
= 套接字描述符;addr
= 目标地址结构体的指针;addrlen
= 地址结构体的长度。
当对一个 UDP 套接字调用 connect
时,内核的操作非常简单且纯粹本地化。UDP 是无连接的,所以这里的 connect
不会产生任何网络流量。内核底层运行机制:
- 参数检查:
- 内核检查传入的地址是否有效。
- 记录对端地址:
- 内核将你传入的
struct sockaddr
(对端的 IP 和 Port)保存到该套接字的内核对象中的一个特定字段(例如sk->sk_daddr
和sk->sk_dport
)。
- 内核将你传入的
- 设置内部过滤器与路由:
- 出站过滤:此后,当你调用
send()
(注意不是sendto
)时,内核会直接使用之前记录的对端地址作为目标,不再需要你每次指定。 - 入站过滤:更重要的是,内核会为这个套接字设置一个入站过滤器。此后,内核协议栈只会将源地址与记录的对端地址完全匹配的 UDP 数据报投递到这个套接字的接收缓冲区。其他主机发来的数据报会被直接丢弃。
- 出站过滤:此后,当你调用
- 状态变更:
- 套接字在内核中被标记为一个“已连接”的 UDP 套接字。这只是一个内部的布尔标志位,UDP 协议本身没有任何状态变化。
UDP 的 connect
是一个“配置动作”。它只是在内核中缓存了对方的地址信息,并设置了一个包过滤器。它不消耗网络资源,也几乎不会失败(除非地址非法)。
我们在写 TCP 客户端连接的时候也是会用到 connect
的,现在给出他们的差别,TCP 的 connect 函数是会发送报文的,UDP 的是不需要发送报文,只是一个本地配置的工作。
特性 | TCP connect | UDP connect |
---|---|---|
本质 | 协议动作:建立双向连接 | 配置动作:设置默认地址和过滤器 |
网络活动 | 是:触发三次握手,发送 SYN 包 | 否:不发送任何网络数据包 |
协议交互 | 是:需要与对端协同完成 | 否:纯本地操作,与对端无关 |
可能失败的原因 | 对端无响应、拒绝连接、网络不通等 | 地址非法(如无效IP或端口) |
内核状态变化 | 复杂:CLOSED -> SYN_SENT -> ESTABLISHED | 简单:仅设置一个“已连接”标志位 |
后续影响 | 必须使用 send /recv | 可使用 send /recv ,也可用 sendto /recvfrom (但会覆盖默认地址) |
错误交付 | 连接建立后,数据只会来自对端 | 通过过滤器屏蔽了非指定对端的数据 |
内核如何路由UDP数据报
当UDP数据报到达服务器网卡时,内核协议栈会按照以下精确的顺序来决定哪个套接字接收它:
-
精确匹配已连接的套接字:内核首先查找是否有已调用
connect()
的UDP套接字,其连接的对端地址(IP:Port)
与数据报的源地址完全匹配,且本地端口与数据报的目标端口匹配。 -
通配匹配监听套接字:如果没有找到精确匹配的已连接套接字,内核会查找绑定在数据报目标端口上的未连接的 UDP 套接字(通常是主监听套接字)。
这个查找顺序确保了:
- 如果存在专门为某个客户端创建的"已连接"套接字,数据报会直接路由到该套接字
- 否则,数据报会由主监听套接字处理
UDP 的数据报套接字的内部结构
在操作系统内核中,一个 UDP 套接字通常由一个核心的数据结构表示(在 Linux 中主要是 struct sock)。与复杂的 TCP 套接字相比,它的内部构造要简单得多,主要包括以下几个关键部分:
- 本地绑定信息(Local Endpoint):
- 本地 IP 地址:通过 bind() 系统调用绑定的IP地址。如果绑定的是 INADDR_ANY(0.0.0.0),则表示监听所有本地接口。
- 本地端口号:通过 bind() 系统调用绑定的端口号。这是数据报接收的“门户”。
- 对端信息(Remote Endpoint - 可选):
- 对于一个未连接的 UDP 套接字,这部分是空的。
- 当调用 connect() 函数后,内核会在这里记录对端的 IP 地址和端口号。这并不会真正建立连接,但会使套接字变成一个“已连接”的 UDP 套接字,内核会基于此设置过滤器和路由。
- 接收缓冲区(Receive Buffer / Queue):
- 这是一个数据报的队列(FIFO),而不是字节流。每个队列元素都是一个完整的数据报(包括数据和源地址信息)。
- 当网络卡收到一个目标地址和端口匹配本套接字的 UDP 数据报时,内核协议栈会将其作为一个整体追加到这个队列的尾部。
- 如果队列已满,新到的数据报会被静默丢弃。
- 发送缓冲区(Send Buffer / Queue):
- 同样是一个队列。当应用程序调用 sendto() 或 send() 时,数据并不会立即发送到网络。而是先被组装成一个 UDP 数据报,然后作为一个整体放入发送缓冲区的队列中。
- 内核网络栈会在稍后(几乎是立即)异步地从队列中取出数据报并将其发送出去。这个缓冲区主要用于解耦应用程序的发送请求和网络接口的实际发送能力。
- 选项和状态标志:
- 存储通过
setsockopt()
设置的选项,例如:SO_BROADCAST
:允许发送广播数据报。SO_RCVBUF
/SO_SNDBUF
:接收/发送缓冲区的大小。
- 简单的错误状态,如
SO_ERROR
。
- 存储通过
与 TCP 套接字的巨大区别在于 UDP 套接字内部没有以下复杂组件:
1、连接状态机:没有 ESTABLISHED
, TIME_WAIT
等状态。
2、重传计时器:数据报发出后不会等待ACK,也不会重传。
3、拥塞控制算法:没有慢启动、拥塞避免等逻辑。
4、流量控制窗口:没有滑动窗口机制。发送速率完全由应用层控制。
接收缓冲区(Receive Buffer)
- 数据结构:一个由数据报套接字缓冲区描述符构成的链表或环形队列。每个描述符指向一个存储了完整 UDP 数据报(包括 UDP 头、载荷数据以及源/目标地址信息)的内存块(
sk_buff
结构)。 - 工作流程:
- 入队:
网卡收到数据报 -> 内核协议栈处理 -> 检查目标端口找到对应套接字 -> 将整个数据报push到该套接字的接收缓冲区队列尾
。 - 出队:应用程序调用
recvfrom() -> 内核从接收缓冲区队列头pop出一个完整的数据报 -> 将数据报的载荷内容拷贝到用户提供的缓冲区,同时填充源地址信息 -> 返回给应用程序
。
- 入队:
- 关键特性:
- 消息边界保留:这是最重要的特性。调用一次
recvfrom()
就必然取出一个完整的、发送端一次sendto()
发送的数据报。不会出现半个数据报或粘包问题。 - 队列长度有限:队列有最大长度限制(由 SO_RCVBUF 选项设置)。如果队列已满,新到的数据报会被直接丢弃,不会有任何通知。这就是UDP“不可靠”的体现之一。
- 没有流量控制:发送方可以以任意速率向接收方的缓冲区发送数据,直到将其“冲垮”。
- 消息边界保留:这是最重要的特性。调用一次
发送缓冲区(Send Buffer)
- 数据结构:与接收缓冲区类似,也是一个数据报的队列。
- 工作流程:
- 入队:应用程序调用
sendto() -> 内核将用户数据组装成 UDP 数据报 -> 将该数据报push到发送缓冲区队列尾
。sendto()
调用此时就基本完成了。 - 出队:内核网络栈(在软中断上下文中)
从发送缓冲区队列头取出数据报 -> 交给IP层、链路层 -> 通过网卡发送出去 -> 发送成功后,释放该数据报占用的内存。
- 入队:应用程序调用
- 关键特性:
- 异步发送:
sendto()
的成功返回只意味着“数据已成功进入本机的发送队列”,绝不代表数据已到达对端甚至已发送到网络。 - 队列长度有限:如果应用程序发送数据报的速度持续超过网卡发送数据的速度,发送队列会被填满。此时,
sendto()
调用可能会:- 阻塞(默认的阻塞模式),直到队列有空间。
- 立即返回错误
EAGAIN
/EWOULDBLOCK
(非阻塞模式)。
- 速率不受约束:只要队列不满,应用程序可以以任何速率提交发送请求。网络出口的速率最终由网卡和网络路径的带宽决定,这可能导致中间路由器的队列溢出和丢包。
- 异步发送:
UDP 服务器的 C 代码实现
经过前文冗长的前情知识复习,我们一定知道了 UDP 协议的服务器该怎么写了。我们是从内核底层来思考的。我们切进了 UDP 服务器的特点,明确知道他是不会与对方客户端打招呼的,因而无需 listen
、accept
和 close
函数。他要的只是找出一个专门的套接字去 ”记忆“ 客户端的位置信息,它需要套接字,但不需要复杂的内核组件,这个函数就是 connect
函数。 UDP 服务器网络通信当然也要套接字,套接字 socket
代表着内核 IO 文件,还需要 setsockopt
函数去设定套接字的状态权限。使用 sendto
和 recvfrom
发送、接收信息。
一个监听套接字会在自己所在的端口处分配一个套接字,系统内核是会先检查这个端口上有没有记录,如果有就会发送到对应的套接字里,如果没有就发送到本地端口的监听套接字里面。初次发送信息时候,信息就躺在了监听套接字里面,之后通过 connect
完成绑定,从第二次开始信息就会发送到新分配的套接字里面。
首先,准备头文件。
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <pthread.h>
#include <assert.h>#define SO_REUSEPORT 15 // 复用端口#define MAXBUF 10240 // 10 KB 左右的用户态缓冲区
#define MAXEPOLLSIZE 100 // EPOLL 的用户态单次处理规模int count = 0; // 用作全局的测试计数
服务器的初始化
my_addr
是一个传出参数,在函数外部定义,在此处用于初始化。并且可以传给接收函数。
int init_server(unsigned short port, struct sockaddr_in *my_addr) {int listener;if ((listener = socket(PF_INET, SOCK_DGRAM, 0)) == -1) {perror("socket");exit(1);} else {printf("socket OK\n");}my_addr->sin_family = PF_INET;my_addr->sin_port = htons(port);my_addr->sin_addr.s_addr = INADDR_ANY;if (bind(listener, (struct sockaddr *) my_addr, sizeof(struct sockaddr)) == -1) { // perror("bind");exit(1);} else {printf("IP bind OK\n");}int ret = 0;int opt = 1; // int*:大多数选项使用一个整数(如 SO_REUSEADDR, TCP_NODELAY)。1表示启用,0表示禁用。ret = setsockopt(listener,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));if (ret) {exit(1);}ret = setsockopt(listener, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));if (ret) {exit(1);}// 设置非阻塞模式int flags = fcntl(listener, F_GETFL, 0);flags |= O_NONBLOCK;fcntl(listener, F_SETFL, flags);return listener;
}
服务器安置新的来访客户端(accept)
在 TCP 协议里面,accept
函数是自动分配套接字的。在 UDP 里面是要用户主动分配套接字的,并且用 connect
绑定套接字的远程地址。
// 代表服务器从监听套接字中获取来访者信息(没有使用 accept 函数)
int udp_accept(int sd, struct sockaddr_in my_addr)
{int new_sd = -1;int ret = 0;int reuse = 1;char buf[16];struct sockaddr_in peer_addr;socklen_t cli_len = sizeof(peer_addr);// 每次只接受 16 字节的内容,peer_addr 获取到来访 IP 的信息ret = recvfrom(sd, buf, 16, 0, (struct sockaddr *)&peer_addr, &cli_len);if (ret < 0) {return -1;}// printf("ret: %d, buf: %s\n", ret, buf);if ((new_sd = socket(PF_INET, SOCK_DGRAM, 0)) == -1) {perror("child socket");exit(1);} else {printf("%d, parent:%d new:%d\n",count++, sd, new_sd); //1023}// IP 复用ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse));if (ret) {exit(1);}// 端口复用ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse));if (ret) {exit(1);}#if 1 // 让新分配的套接字负责当前新来访 IP 的数据收发工作(bind 函数会分配新的端口)//my_addr.sin_port += count;ret = bind(new_sd, (struct sockaddr *) &my_addr, sizeof(struct sockaddr));if (ret){perror("chid bind");exit(1);} else {}
#endif peer_addr.sin_family = PF_INET; // // 本地配置if (connect(new_sd, (struct sockaddr *) &peer_addr, sizeof(struct sockaddr)) == -1) {perror("chid connect");exit(1);} else {}out:return new_sd;
}
服务器接收信息的函数
int read_data(int sd)
{char recvbuf[MAXBUF + 1];int ret;struct sockaddr_in client_addr;socklen_t cli_len=sizeof(client_addr);bzero(recvbuf, MAXBUF + 1);ret = recvfrom(sd, recvbuf, MAXBUF, 0, (struct sockaddr *)&client_addr, &cli_len);if (ret > 0) {printf("read[%d]: %s from %d\n", ret, recvbuf, sd);} else {printf("read err:%s %d\n", strerror(errno), ret);}//fflush(stdout);
}
服务器的运行函数
是靠内核事件回调机制(也就是 EPOLL)运行的,EPOLL 管理服务器全局的 IO 信息(包括服务器自身的监听套接字)。
int udp_server_run(unsigned short port)
{struct sockaddr_in my_addr;bzero(&my_addr, sizeof(my_addr));int listener = init_server(port, &my_addr); // 传出地址参数,并且返回对应端口上的监听套接字int kdpfd, nfds, n; // EPOLL 的实例、用户态响应数、用户态轮询迭代数//socklen_t len;struct epoll_event ev;struct epoll_event events[MAXEPOLLSIZE];kdpfd = epoll_create(MAXEPOLLSIZE); // 这是 EPOLL 的实例ev.events = EPOLLIN | EPOLLET;ev.data.fd = listener;// 把监听套接字插入 EPOLL 实例之中if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev) < 0) {fprintf(stderr, "epoll set insertion error: fd=%dn", listener);return -1;} else {printf("ep add OK\n");}while (1) {nfds = epoll_wait(kdpfd, events, MAXEPOLLSIZE, -1);if (nfds == -1) {perror("epoll_wait");break;}for (n = 0; n < nfds; ++n) {if (events[n].data.fd == listener) {int new_sd; struct epoll_event child_ev;while (1) {new_sd = udp_accept(listener, my_addr);if (new_sd == -1) break;child_ev.events = EPOLLIN;child_ev.data.fd = new_sd;if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, new_sd, &child_ev) < 0) {fprintf(stderr, "epoll set insertion error: fd=%d\n", new_sd);return -1;}}} else {read_data(events[n].data.fd);}}}close(listener);return 0;
}
UDP 客户端测试代码
这个测试很简单,测试规模是 1024 个,每次都是先发送一个 ”握手包“,然后睡眠,给时间服务端准备套接字。醒来后,再把测试语句发送出去。
#include <unistd.h> // 用于睡眠测试
#include <string.h>#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/time.h>
#include <sys/resource.h>void createClient(int id,int myPort,int peerPort){int reuse = 1;int socketFd;struct sockaddr_in peer_Addr;peer_Addr.sin_family = PF_INET;peer_Addr.sin_port = htons(peerPort);peer_Addr.sin_addr.s_addr = inet_addr("192.168.152.128");struct sockaddr_in self_Addr;self_Addr.sin_family = PF_INET;self_Addr.sin_port = htons(myPort);self_Addr.sin_addr.s_addr = inet_addr("0.0.0.0"); if ((socketFd = socket(PF_INET, SOCK_DGRAM| SOCK_CLOEXEC, 0)) == -1) {perror("child socket");exit(1);} int opt=fcntl(socketFd,F_GETFL);fcntl(socketFd,F_SETFL,opt|O_NONBLOCK);if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))){exit(1);}if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))){exit(1);}if (bind(socketFd, (struct sockaddr *) &self_Addr, sizeof(struct sockaddr))){perror("chid bind");exit(1);} else {}if (connect(socketFd, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr)) == -1) {perror("chid connect");exit(1);}usleep(1); // --> keychar buffer[1024] = {0};memset(buffer, 0, 1024);sprintf(buffer, "hello %d", id);sendto(socketFd, buffer, strlen(buffer), 0, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr_in));memset(buffer, 0, 1024);sprintf(buffer, "test_id %d arrive at the second time", id);usleep(1000);sendto(socketFd, buffer, strlen(buffer), 0, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr_in)); // 模拟第二次发送}void serial(int clinetNum){for(int i=0;i<clinetNum;i++){createClient(i,2000+i,2000);}
}
int main(int argc, char * argv[])
{serial(1024);printf("serial success\n");return 0;
}
代码测试
大家准备两台 Ubuntu 的虚拟机,分别运行就可以了。