UDP是在网络协议中传输层的一种协议。它位于操作系统内部。我们在网络基础那里了解过,UDP是一种无连接,不可靠,面向数据报的协议,其设计目标是高效传输数据,而非保证可靠性。它直接将应用层数据封装成UDP数据报发送,不建立连接,不确认数据,不重传丢失报文,适用于对实时性要求高(如直播)或数据量小(如DNS查询)的场景。
一.通信五元组
端口号是用来标识一台主机上唯一的一个进程的。当主机收到网络来的报文,通过解包和分用到了传输层之后,要明确这个报文是发送给那个进程的。
而我们使用源ip和目的ip,就可以知道全网中通信的两台主机。再通过源端口和目的端口,就可以得知这两台主机上要进行通信的两个进程。
但在通信过程中,传输层协议有不止一种,如果通信双方使用不同的传输层协议,就会导致报文到了传输层后,双发无法识别报头信息,导致网络通信失败。所以,在网络通信过程中,除了借助源ip,源端口,目的ip和目的端口号外,还有增加协议号,来表示通信双方使用的是什么传输层协议。
一般TCP协议号为6,UDP协议号为17.
综上,源ip地址,源端口号,协议号,目的ip地址,目的端口号就可以唯一确定一条通信链路。这五部分称为“五元组”。
二.UDP协议
应用层要发送数据到网络中,要贯穿整个网络协议栈,且需要进行封装和解包、分用操作。所以,在传输层,从应用层下来的数据需要在传输层添加对应的协议报头,才能传入下一层中。
1.UDP报头
我们可以看到,udp报头是定长的,占8个字节。而报头又包含16位udp长度字段,该字段表明在传输层UDP报头+有效载荷的总长度。
我们通过一下几个问题来了解UDP报头:
0x1.UDP如何做到将报头和有效载荷分离?
UDP报头中的16位UDP长度表明udp报头+有效载荷的总长度,而udp报头又是定长的。所以,我们使用16位udp长度,减去8字节,就可以得知有效载荷大小。
所以,在接收端传输层每一个报文的长度都是固定的,这也就意味着报文之间是有边界的。
0x2.UDP如果做到分用?将有效载荷传递给那个进程?
UDP报头中包含16位目的端口号,该端口号映射的就是该主机上唯一的一个进程。
16位UDP校验和:可选字段(可设为 0),用于检测数据在传输中是否出错(非可靠校验,出错时直接丢弃)。
而udp在操作系统内核中,其实就是一个结构体变量:
struct udphdr {
__u16 source; // 源端口号
__u16 dest; // 目的端口号
__u16 len; // UDP 数据报总长度(首部 + 数据)
__u16 check; // 校验和(用于错误检测)
};
因为UDP协议在内核中,而操作系统都是c语言写的,且网络部分代码都是一样的,所以在操作系统内部,传递UDP时直接传递结构体变量。
2.UDP的特点
UDP协议是无连接的,不可靠,面向数据报的。这些都是他的特点,并不是说不可靠就是它的缺点。
无连接:
使用UDP进行通信时,只需要对方的ip地址和端口号就可以直接进行通信。不像TCP,通信前还需要进行connect。节省了连接建立和释放的开销。
适合对实时性要求高、数据量小的场景(如视频流、实时游戏、DNS 查询等)。
不可靠:
在使用UDP在进行通信时,不关系对方是否收到了数据报。并且数据报在通信中丢失,UDP对丢失的数据报不进行任何操作(重传)。
UDP不保证数据的顺序性与完整性,数据报可能乱序、重复或丢失,需要应用层自行处理(如有需要)。
面向数据报:
UDP 传输的基本单位,它是一个独立、完整的信息单元,包含UDP报头和有效载荷。
我们可以将数据报理解为一个一个的信件,信件之间相互独立,彼此没有关联。我们sendto发送几次,对端recvform就收到几次。就像我发送3个信件给你,你就会收到3个信件。
数据报之间存在明显的边界,就好比信件一样。每个数据报在网络中独立路由,可能因路径不同导致到达顺序与发送顺序不一致。
接收端只要收到报文,那么就一定是完整的。
UDP报头包含16位UDP长度,也就意味着UDP报文的最大长度就为16位全1,即64kb。然而在当今的互联网,64kb是一个非常小的数字。如果需要传输的数据大小超过了64kb,则需要我们在应用层手动进行分包,多次发送,并在接收端进行手动拼接。
3.UDP缓冲区
UDP并不存在真正意义上的发送缓冲区。调用sendto会直接将报文交给内核,由内核将报文交由网络层进行下一层封装。而且UDP其实并没有必要存在发送缓冲区。
TCP之所以有发送缓冲区,是因为TCP是可靠的,数据在发送过程中,如果出现了丢包,TCP是要进行重传的。所以,TCP通过write将数据拷贝到发送缓冲区后,tcp将数据发送到网络之后,不会立即将缓冲区清空,而是等到数据被对方收到后在进行清空。这样就能保证在传递过程中对丢包数据进行重传。
而UDP是不可靠的,不关系数据是否丢包,也就不需要重传,数据直接发出去即可,不需要发送缓冲区。
UDP具有接收缓冲区。但是这个接受缓冲区不能保证收到的UDP报文顺序和发送UDP报文的顺序一致。且如果发送缓冲区剩余空间不足报文长度,就会将UDP数据丢弃。
UDP具有接收缓冲区是合理的,收数据的时候,应用层、网络层、传输层就好比一个生产者消费者模型。当缓冲区满的时候,此时就不能再生产了,应由消费者进行消费。并且,数据拿到之后可能会进行数据路由,或者交给后端的线程池去执行,还有很长的路程,如果没有接收缓冲区,网络层每收到一个数据报,必须立即触发应用层处理(类似同步阻塞模式),应用层若正处理其他任务(如复杂业务逻辑),内核被迫等待,期间到达的新数据报只能丢弃。
应用层读取数据报后,可能需经历:
数据报 → 协议解析(如解析为JSON) → 路由决策(判断转发至哪个后端服务) → 线程池调度(分配工作线程处理) → 业务逻辑执行
若没有接收缓冲区,整个处理链的任何延迟(如线程池满、数据库写入阻塞)都会直接导致内核无法接收新数据报。
三.sk_buff
对于一台服务器来说,它在任意一个时刻内可能受到来自多个客户端发来的报文。也就是说,操作系统内部可能会同时存在多个报文。那么操作系统就要对这些报文进行管理——先描述再组织。
struct sk_buff {
struct sk_buff *next; // 链表指针,用于批量处理
struct sock *sk; // 关联的Socket(若有)
struct net_device *dev; // 接收/发送的网络设备
unsigned int len; // 数据长度
unsigned int data_len; // 数据缓冲区长度
__be16 protocol; // 上层协议类型(如ETH_P_IP)
unsigned char *head; // 数据缓冲区起始地址
unsigned char *data; // 当前数据区起始地址
unsigned char *tail; // 当前数据区结束地址
unsigned char *end; // 数据缓冲区结束地址
unsigned int truesize; // 总分配大小(含sk_buff本身)
atomic_t users; // 引用计数(用于零拷贝)
// 网络层和传输层头部指针
struct ethhdr *ethhdr; // Ethernet头部
struct iphdr *iph; // IP头部
struct tcphdr *th; // TCP头部
struct udphdr *uh; // UDP头部
// 路由和QoS相关信息
__u32 priority; // 数据包优先级
__u32 mark; // 用于防火墙标记
// 时间戳和统计信息
ktime_t tstamp; // 接收时间戳
};
每当接收一个报文,就会在操作系统内部创建一个sk_buff结构,用来描述一个报文。报文要在内存中存储,肯定就得有物理内存,也就是缓冲区。sk_buff就指向该缓冲区,且sk_buff中有特殊的字段指向报文,描述报文的报头与有效载荷。 sk_buff中head指针和end指针分别指向一个报文的头部空间和尾部空间,来表示一个报文。tail指针指向应用层数据结尾,即有效载荷的结尾。
data指针刚开始指向应用层数据的开始,接着发送数据要进行封装,即添加报文。此时将传输层协议报头添加在有效载荷头部,此时data指针移动,执行TCP报头。向下封装的过程中,data指着一直在移动。
所以,封装和解包的本质,就是移动data指针在缓冲区的指向。
因为有sk_buff的存在,就算应用层正在对报文进行解析、处理,操作系统依旧可以从网络总读取报文,链入sk_buff中,由内核管理,与应用层处理完全解耦。即使应用层处理缓慢,内核仍可继续从网卡读取数据并暂存到
sk_buff
队列中。