frankfan的胡思乱想

学海无涯,回头是岸

UDP编程实践与细节

基于以下几点考虑,本文章并不会使用udp协议来实际工程编码实现聊天室。这几点分别是:

  • udp实践IM(即时通讯)并不是科学合理的实践(学习udp-socket的用法无可厚非)
  • udp通信的实践领域更多的是音视频通讯(传输)这种场景上(实时性高,但对消息完整性要求不高)
  • 无论是从学习还是工作的角度,没必要对udp投入过多的精力(想研究使用UDP来模拟TCP或者基于UDP来达成或超越TCP的功能,直接研究谷歌的QUIC协议即可)

基于以上几点考虑,本文章不会对使用UDP-socket的工程实践投入过多笔墨。

因此本章依然是立足『用户态-内核协议栈』的基本原理以及『数据接受与发送』方面的话题。

大部分同学在学习socket编程的时候更多的还是站在socket接口使用与一些工程实践上面,但是对网络编程还是缺乏一个全局的认识,甚至很难有效辨别出使用socket编程与使用接口操作文件的区别是什么。

所以本文力图在几天的学习时间内,让同学们对我们使用的socket编程有一个感性与理性的认识,此后工程实践的时候,就能做到网络编程与文件操作编程一样的境界了。

本章内容若无特别说明,则当讨论到传输层协议时,特指UDP

发送数据时,我们操作的数据包与真实完整的数据包

image.png

这个图反复出现,不断的提醒大家,我们的『用户数据』调用一个sendTo函数后,究竟发生了什么事情。也就是数据从我们用户态到操作系统内核中去,数据经历过一个什么过程。

在这个过程中,我们应该知道:

  • 我们能操作的数据在哪一层

  • 数据被进行了什么加工(添加了什么额外的东西)

  • 为什么要这么加工

  • 从一端sendTo到另一端recvFrom数据经历过什么

我们自始至终能够操作的数据,就是『用户数据』,就是我们调用sendTo函数时,发送的那个buf,它位于用户态这一层。

//client.cpp
#pragma pack(1)//内存依次排列
struct DtPckHeader{
  short version;
  int buf_length;
};
struct DataPackage{
  DtPckHeader header;//定长包头
  char buf[0];//flexible-array
};
#pragma pack()

int main(){
  
  //...udp socket prepare
  
  //preparing user data
  char *userdata = "this is a udp data package...";
  DtPckHeader header = {0x1,strlen(userdata)+1};
  DataPackage data;
  data.header = header;
	memcpy(data.buf,(char*)userdata,header.buf_length);
  
  //sending udp data
  int nRet = sendto(sockClient, (const char*)data, sizeof(data)+header.buf_length, 0, (sockaddr*)&siServer, sizeof(siServer));
  
  //...todo...
  
  return 0;
}

以上,我们做了两个关键的事情:

  • 准备用户数据
  • 发送用户数据
image.png

我们可以在buf前面添加任何我们自定义的头,甚至我们经常所说的自定义协议等无非不是定义一个buf前面的包头,给这个包头添加各种字段,以及定义一系列操作这些字段的逻辑。这样就制定了一个『自定义协议』。

但是,无论怎么自定义协议,这都是一个位于用户态层面的数据包

在下层协议栈的眼里,你定义的所谓『包头』本身就是用户数据。当然,你可以从协议栈中取经,学习协议栈是怎么封装数据,怎么定义包头,包头中包含哪些字段,这些字段的具体意义啥的,然后将其应用到自己的用户数据包中。而不管我们如何定义我们的用户数据包,实现我们的数据包操作逻辑,但根本上地位都是用户态层面的,是站在内核协议栈基础上的,我们不可能在用户态实现一个对标TCP或者UDP的协议,可以设计得超越它,但只要还位于用户态,就不能超脱它们的限制。

除非你的设计足够优秀,让Linux或者Windows的实现者都觉得优秀,这时候他们会考虑将你的设计纳入内核协议栈的支持里面去,如谷歌的QUIC协议就是这样的代表。


那么,当我们sendTo一个UDP包后,发生了一些什么事呢?

image.png

可见,UDP包头能记录的最大数据大小为65536个字节(64KB),不过这个包括了自己的定长包头,8个字节,因此用户数据最大(65536-8)字节。

但是,这是传输层UDP协议的包头,再往下,是IP层,而IP层同样有个包头,IP包头里有个2字节的字段Total Length用来表示IP包的总长度(包括上层数据长度+自己IP包头发小),同样的,这个只能记录的最大大小为65536个字节,因此,一个用户发送的UDP包要能正确的经过传输层和IP层,那么用户的数据的最大尺寸为65536-8(UDP包头尺寸)-20(IP包头尺寸) = 65508字节

那么,当用户发送(sendTo)一个65508尺寸的包后,会发生什么呢?

image.png

在链路层,将传输大小限制为1500字节(这与物理元器件有关 如网卡),从图示可以发现,1500个字节的限制包括了IP头(20字节),UDP头(8字节)剩下的为用户数据1472个字节,若用户数据大于1472个字节,那么到数据到链路层时就会超出1500字节的限制,此时IP包便会分片,从1个IP包分成多个IP包。

image.png

我们说UDP是用户数据报协议,一个多大的UDP包发出去,就有一个多大的UDP收到,这是站在用户态层面的。实际上,当我们将一个UDP包sendTo后,就是讲用户态数据拷贝进内核协议栈中,协议栈会处理我们传(拷贝)下来的数据,对其进行处理(添加包头)以及做分片处理(如有必要),此时并不一定马上就通过网卡发出去了(即使通常是这样),协议栈会决定发送的时机,此时发送到网络上的数据包并不一定是1个,有可能是分片后的N个,而数据包到对端网络协议栈后,对端主机的内核协议栈会负责将这些数据包重新合并打包成一个数据包(UDP),然后通过socket这个接口转交给用户态进程,这是用户态进程收到的是一个完整的UDP包(如没丢包的话),所以,当我们站在socket之上的用户态观察时,会觉得发出去一个N大小的包,收到的就是一个N大小的包(UDP)。(实际工作都是内核协议栈帮我们做了)

image.png

通过socket发送udp数据包,站在用户态层面,就是这样,发送一个包,收到的就是一个包。发一次,收一次,也只能收一次。

因此,如果想要使用udp来做应用层开发,想要在A客户端通过udp发送一个文件(大尺寸)到B客户端,就会是这样的模型:

image.png

发送端发送(sendTo)一系列小包1、2、3、4,那么接收端(用户态层)收到到顺序不一定1、2、3、4,也就是顺序是不保证的,甚至都不一定能(用户态层)收到1、2、3、4这4个数据包。

这就面临2个最基本问题

  • 包的乱序到达
  • 包的丢失

那么有什么解决方案呢?

最容易想到最简单粗暴的方式就是:

『发送-等待-确认』模式。

发送端发送数据包1,然后阻塞等待(recvfrom)直到接收端返回确认包,然后接着发送第2号数据包...

//acknowledge.cpp
struct Ack{
  short type;//类型
  unsigned long seq;//本包序号
  unsigned long ack;//ack号
};

这样的模式无论是作为发送端,还是接收端,都能逻辑最简化,也不容易出错。但最基本也最关键的问题在于,这太™慢了!

所以,我们开始另寻思路...

其实,我们并不需要另寻思路,直接将目光聚焦在UDP的兄弟协议TCP协议,这些问题TCP都有它独有的解决之法。我们向其取经即可。

  • 给包编号
  • 设计一个ack类型的确认包
  • 发送端维护一个发送窗口(缓存队列),分为2部分,发送已确认部分,可发送(还未发送)部分
  • 接收端维护一个接受窗口(缓存队列),分为2部分,已接受部分,未接受部分

ack是接收端特有的包(其实一个socket实体本身就既可以是发送端又可以是接收端),这个包有2个语义,当ack包的ack号n时,则表示

  • 已经收到了n-1号包,注意,是指收到了1号到n-1号所有的包。
  • 现在请发第n号包给我。

这种设计的好处在于并不需要发送一个包就确认一个包,这样效率太慢,而是发送N个包,然后一次性的确认。

这是ack包的2个语义。此外的操作语义还可以有『当一个端连续收到3个ack号相同的ack包时,则意味着这个端发送的第ack n号包已经丢失,需要重传』。

比如A端收到了对端发送过来的3个ack-10号包,则意味着对端在提供A端,A端之前发送的第10号包已经丢包了,需要重传。(这在TCP中被称为『快速重传』)

image.png image.png

接收端如图示,接受窗口左侧为已接受字节,右侧为未接收字节,而只有处于接受窗口之内的序列号才能被接受,否则统统都会被丢弃。而位于接受窗口之内的序号又分为已接受的和为接受的,图示情况下,接收端会快速发送三次ack包,ack序列号是4,告诉发送端将4号包发送过来,如果此时收到4号包,则接收端发送一个ack 7,接受窗口右移,说明此时4-6号包都已接受成功,等待接受接下来的7号包...

这里需要说明的是1、2、3、4...n号包指的是包的序号,而不是第几个字节,一个包里面可能包含M多个字节。

image.png

通过系列这样的机制,我们就能做到在效率、乱序、丢包问题中有一个较好的平衡。

而这一切,都不过是在模拟TCP的功能,只不过是站在用户态去模拟,既然如此,我们为何不直接使用TCP进程编程呢?

下一章节中,我们讨论TCP协议以及实践需要关注的问题。

posted on 2021-12-28 09:40  shadow_fan  阅读(93)  评论(0编辑  收藏  举报

导航