天天看点

(十四)深入浅出TCPIP之初识UDP理解报文格式和交互流程

关于UDP

        TCP层的实现已经介绍了,现在开始介绍UDP层,这一层属于传输层应用,UDP协 议基于IP层,而UDP程序基于UDP协议。其实UDP无所谓什么协议,它没有自己的状态 机,仅仅是在IP层上做了一些封装,不保证报文能准确到达,没有请求应答机制,所有的 行为,和IP应用协议一样。只不过,它多了一个port的概念,此 port不是指主机上的网络 端口,而是从操作系统内核的角度看到的应用程序“标识"。我们都知道如何调用操作系统的 接口,但操作系统是如何“调用”应用程序的呢?在现在的PC机操作系统中,这是无法办 到的。

于是人们为应用程序设置一个标识,内核根据这个标识确定是哪一个应用程序曾经给它 发过请求,然后把数据发给应用程序,这样就避兔操作系统把所有的数据发给所有在等特数 据的应用程序,从操作系统的角度看,这个标识就是一个个的端口,比如你在网络上和一个妹子撩骚,同时也和一个教授讨论问题。你当然不希望发给妹子的话教授也能收到,这就是传输层网络应用中加入的port概念。在IP层,每个应用的标识就是中地址,内核根据I来 处理报文,要么给本机,要么转给别人,在UDP和TCP层,不仅要有IP地址,而且还要port 号,内核根据IP地址确定了属于本机的报文后,还要根据port号确定哪一个应用程序才是报 文的终极目的地。

代码示例

我们先给出UDP应用程序的一般示例, 如下面两段代码

服务器端伪码:

1. socket(.... SOCK DGRAM, 0)

2. bind(.... sservaddr, ...)

3.recvfrom(.... &clientaddr. ...)

客户端伪码:

1.socket(....SOCK_DGRAM,0);

2.sendto(....&servaddr,...);

C代码:

/*


server.c 创建UDP服务器实现服务器和客户端的通信


*/


#include<stdio.h>


#include<sys/socket.h>


#include<sys/types.h>


#include<string.h>


#include<unistd.h>


#include<netinet/in.h>


#include <arpa/inet.h>


//创建UDP实现服务器和客户端的通信


int main()
{
//创建socket连接


  int serfd=0;
  serfd=socket(AF_INET,SOCK_DGRAM,0);
  if(serfd<0)
  {
    perror("socke failed");
    return -1;
  }
  printf("socket success\n");
  //绑定IP地址和端口信息


  int ret=0;
  struct sockaddr_in seraddr={0};
  seraddr.sin_family=AF_INET;
  seraddr.sin_addr.s_addr=inet_addr("172.16.0.9");
  seraddr.sin_port=htons(8888);
  
  ret=bind(serfd,(struct sockaddr *)&seraddr,sizeof(seraddr));
  if(ret<0)
  {
    perror("bind failed");
    close(serfd);
    return -1;
  }
  printf("bind success\n");
  //接收发送自客户端的消息


  while(1)
  {
    int addrlen=0;
    char buf[1024]={0};
    struct sockaddr_in clientaddr={0};
    addrlen=sizeof(clientaddr);
    ret=recvfrom(serfd,buf,sizeof(buf),0,(struct sockaddr *)&clientaddr,&addrlen);
    if(ret<0)
    {
      perror("recvfrom failed");
      close(serfd);
      return -1;
    }
    printf("IP=%s,port=%u\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port));
    printf("recvfrom success\n");
    printf("receive:  %s\n",buf);
    //向客户端发送消息  


    memset(buf,0,sizeof(buf));
    gets(buf);
    ret=sendto(serfd,buf,strlen(buf),0,(struct sockaddr *)&clientaddr,addrlen);
    if(ret<0)
    {
      perror("sendto failed");
      close(serfd);
      return -1;
    }
    printf("sendto success\n");
  }
  close(serfd);
  return 0;
}      
/*


client.c 创建UDP实现服务器和客户端的通信


*/


#include<stdio.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<string.h>
#include<unistd.h>
#include<netinet/in.h>
#include <arpa/inet.h>  
//创建socket连接
int main()
{
//创建socket连接


  int clifd=0;
  clifd=socket(AF_INET,SOCK_DGRAM,0);
  if(clifd<0)
  {
    perror("socke failed");
    return -1;
  }
  printf("socket success\n");
  //向服务器发送消息


  while(1)
  {
    int tolen=0;
    int ret=0;
    char buf[1024]={0};
    gets(buf);
    
    struct sockaddr_in seraddr={0};
    seraddr.sin_family=AF_INET;
    seraddr.sin_addr.s_addr=inet_addr("172.16.0.9");
    seraddr.sin_port=htons(8888);
    tolen=sizeof(seraddr);
    ret=sendto(clifd,buf,strlen(buf),0,(struct sockaddr *)&seraddr,tolen);
    if(ret<0)
    {
      perror("sendto failed");
      close(clifd);
      return -1;
    }
    printf("sendto success\n");
    //接收发送自服务器的消息  


    ret=recvfrom(clifd,buf,sizeof(buf),0,NULL,NULL);
    if(ret<0)
    {
      perror("recvfrom failed");
      close(clifd);
      return -1;
    }
    printf("recvfrom success\n");
    printf("receive:  %s\n",buf);
  }
  close(clifd);
    
  return 0;
}      

UDP数据报格式

UDP数据报格式有首部和数据两个部分。首部很简单,共8字节。包括:

◆源端口(Source Port) : 2字节,源端口号。

◆目的端口(Destination Port) : 2字节,目的端口号。

◆长度(Length) : 2字节,UDP用户数据报的总长度,以字节为单位。

◆检验和(Checksum) : 2字节,用于校验UDP数据报的数字段和包含UDP数据报首部的

“伪首部”。其校验方法同IP分组 首部中的首部校验和。

伪首部,又称为伪包头(Pseudo Header) :是指在TCP的分段或UDP的数据报格式中,在

数据报首部前面增加源IP地址、目的IP地址、 IP分组的协议字段、TCP或UDP数据报的总长度

等共12字节,所构成的扩展首部结构。此伪首部是一个临时的结构,它既不向上也不向下传

递,仅仅只为了保证可以校验套接宇的正确性。

UDP端口号特殊的-一个方面是其源端口号可以置为0,表示没有指定端口,用于不期望响应且没有所需端口号的场合。-般人会认为, UDP 的端口号非常简单,简单到似乎没有必

要存在,除非用于socket事务标识。UDP和IP的区别就是这个端口号,我们下面要研究的内容也和这个端口号有密切的关系。

●端口用一个 16 bit端口号进行标志。

●端口号只具有本地意义, 即端口号只是为了标志本计算机应用层中的各进程。在因特网中不同计算机的相同端口号是没有联系的。

再次理解UDP数据包

(1)UDP报文大小的影响因素,主要有以下3个

[1] UDP协议本身,UDP协议中有16位的UDP报文长度,那么UDP报文长度不能超过2^16=65536.

[2] 以太网(Ethernet)数据帧的长度,数据链路层的MTU(最大传输单元)。

[3] socket的UDP发送缓存区大小

(2) UDP数据包最大长度

根据UDP协议,从UDP数据包的包头可以看出,UDP的最大包长度是2^16-1的个字节。由于UDP包头占8个字节,而在IP层进行封装后的IP包头占去20字节,所以这个是UDP数据包的最大理论长度是2^16 - 1 - 8 - 20 = 65507字节。如果发送的数据包超过65507字节,send或sendto函数会错误码1(Operation not permitted, Message too long),当然啦,一个数据包能否发送65507字节,还和UDP发送缓冲区大小(linux下UDP发送缓冲区大小为:cat /proc/sys/net/core/wmem_default)相关,如果发送缓冲区小于65507字节,在发送一个数据包为65507字节的时候,send或sendto函数会错误码1(Operation not permitted, No buffer space available)。

(3) UDP数据包理想长度

理论上UDP报文最大长度是65507字节,实际上发送这么大的数据包效果最好吗?我们知道UDP是不可靠的传输协议,为了减少UDP包丢失的风险,我们最好能控制UDP包在下层协议的传输过程中不要被切割。相信大家都知道MTU这个概念。MTU最大传输单元,这个最大传输单元实际上和链路层协议有着密切的关系,EthernetII帧的结构DMAC+SMAC+Type+Data+CRC由于以太网传输电气方面的限制,每个以太网帧都有最小的大小64字节,最大不能超过1518字节,对于小于或者大于这个限制的以太网帧我们都可以视之为错误的数据帧,一般的以太网转发设备会丢弃这些数据帧。由于以太网EthernetII最大的数据帧是1518字节,除去以太网帧的帧头(DMAC目的MAC地址48bit=6Bytes+SMAC源MAC地址48bit=6Bytes+Type域2bytes)14Bytes和帧尾CRC校验部分4Bytes那么剩下承载上层协议的地方也就是Data域最大就只能有1500字节这个值我们就把它称之为MTU。

在下层数据链路层最大传输单元是1500字节的情况下,要想IP层不分包,那么UDP数据包的最大大小应该是1500字节 – IP头(20字节) – UDP头(8字节) = 1472字节。不过鉴于Internet上的标准MTU值为576字节,所以建议在进行Internet的UDP编程时,最好将UDP的数据长度控制在 (576-8-20)548字节以内。

UDP数据包的发送和接收问题

(1) UDP的通信有界性

在阻塞模式下,UDP的通信是以数据包作为界限的,即使server端的缓冲区再大也要按照client发包的次数来多次接收数据包,server只能一次一次的接收,client发送多少次,server就需接收多少次,即客户端分几次发送过来,服务端就必须按几次接收。

(2) UDP数据包的无序性和非可靠性

client依次发送1、2、3三个UDP数据包,server端先后调用3次接收函数,可能会依次收到3、2、1次序的数据包,收包可能是1、2、3的任意排列组合,也可能丢失一个或多个数据包。

(3) UDP数据包的接收

client发送两次UDP数据,第一次 500字节,第二次300字节,server端阻塞模式下接包,第一次recvfrom( 1000 ),收到是 1000,还是500,还是300,还是其他?

由于UDP通信的有界性,接收到只能是500或300,又由于UDP的无序性和非可靠性,接收到可能是300,也可能是500,也可能一直阻塞在recvfrom调用上,直到超时返回(也就是什么也收不到)。

在假定数据包是不丢失并且是按照发送顺序按序到达的情况下,server端阻塞模式下接包,先后三次调用:recvfrom( 200),recvfrom( 1000),recvfrom( 1000),接收情况如何呢?

由于UDP通信的有界性,第一次recvfrom( 200)将接收第一个500字节的数据包,但是因为用户空间buf只有200字节,于是只会返回前面200字节,剩下300字节将丢弃。第二次recvfrom( 1000)将返回300字节,第三次recvfrom( 1000)将会阻塞。

(4) UDP包分片问题

如果MTU是1500,Client发送一个8000字节大小的UDP包,那么Server端阻塞模式下接包,在不丢包的情况下,recvfrom(9000)是收到1500,还是8000。如果某个IP分片丢失了,recvfrom(9000),又返回什么呢?

根据UDP通信的有界性,在buf足够大的情况下,接收到的一定是一个完整的数据包,UDP数据在下层的分片和组片问题由IP层来处理,提交到UDP传输层一定是一个完整的UDP包,那么recvfrom(9000)将返回8000。如果某个IP分片丢失,udp里有个CRC检验,如果包不完整就会丢弃,也不会通知是否接收成功,所以UDP是不可靠的传输协议,那么recvfrom(9000)将阻塞。

UDP丢包问题

在不考虑UDP下层IP层的分片丢失,CRC检验包不完整的情况下,造成UDP丢包的因素有哪些呢?

[1] UDP socket缓冲区满造成的UDP丢包

通过 cat /proc/sys/net/core/rmem_default 和cat /proc/sys/net/core/rmem_max可以查看socket缓冲区的缺省值和最大值。如果socket缓冲区满了,应用程序没来得及处理在缓冲区中的UDP包,那么后续来的UDP包会被内核丢弃,造成丢包。在socket缓冲区满造成丢包的情况下,可以通过增大缓冲区的方法来缓解UDP丢包问题。但是,如果服务已经过载了,简单的增大缓冲区并不能解决问题,反而会造成滚雪球效应,造成请求全部超时,服务不可用。

[2] UDP socket缓冲区过小造成的UDP丢包

如果Client发送的UDP报文很大,而socket缓冲区过小无法容下该UDP报文,那么该报文就会丢失。

[3] ARP缓存过期导致UDP丢包

ARP的缓存时间约10分钟,APR缓存列表没有对方的MAC地址或缓存过期的时候,会发送ARP请求获取MAC地址,在没有获取到MAC地址之前,用户发送出去的UDP数据包会被内核缓存到arp_queue这个队列中,默认最多缓存3个包,多余的UDP包会被丢弃。被丢弃的UDP包可以从/proc/net/stat/arp_cache的最后一列的unresolved_discards看到。当然我们可以通过echo 30 > /proc/sys/net/ipv4/neigh/eth1/unres_qlen来增大可以缓存的UDP包。

UDP的丢包信息可以从cat /proc/net/udp 的最后一列drops中得到,而倒数第四列inode是丢失UDP数据包的socket的全局唯一的虚拟i节点号,可以通过这个inode号结合lsof(lsof -P -n | grep 25445445)来查到具体的进程。

UDP冗余传输

在外网通信链路不稳定的情况下,有什么办法可以降低UDP的丢包率呢?一个简单的办法来采用冗余传输的方式。如下图,一般采用较多的是延时双发,双发指的是将原本单发的前后连续的两个包合并成一个大包发送,这样发送的数据量是原来的两倍。这种方式提高丢包率的原理比较简单,例如本例的冗余发包方式,在偶数包全丢的情况下,依然能够还原出完整的数据,也就是在这种情况下,50%的丢包率,依然能够达到100%的数据接收。

UDP真的比TCP要高效吗

相信很多同学都认为UDP无连接,无需重传和处理确认,UDP比较高效。然而UDP在大多情况下并不一定比TCP高效,TCP发展至今天,为了适应各种复杂的网络环境,其算法已经非常丰富,协议本身经过了很多优化,如果能够合理配置TCP的各种参数选项,那么在多数的网络环境下TCP是要比UDP更高效的。

影响UDP高效因素

(1) 无法智能利用空闲带宽导致资源利用率低

一个简单的事实是UDP并不会受到MTU的影响,MTU只会影响下层的IP分片,对此UDP一无所知。在极端情况下,UDP每次都是发小包,包是MTU的几百分之一,这样就造成UDP包的有效数据占比较小(UDP头的封装成本);或者,UDP每次都是发巨大的UDP包,包大小MTU的几百倍,这样会造成下层IP层的大量分片,大量分片的情况下,其中某个分片丢失了,就会导致整个UDP包的无效。由于网络情况是动态变化的,UDP无法根据变化进行调整,发包过大或过小,从而导致带宽利用率低下,有效吞吐量较低。而TCP有一套智能算法,当发现数据必须积攒的时候,就说明此时不积攒也不行,TCP的复杂算法会在延迟和吞吐量之间达到一个很好的平衡。

(2) 无法动态调整发包

由于UDP没有确认机制,没有流量控制和拥塞控制,这样在网络出现拥塞或通信两端处理能力不匹配的时候,UDP并不会进行调整发送速率,从而导致大量丢包。在丢包的时候,不合理的简单重传策略会导致重传风暴,进一步加剧网络的拥塞,从而导致丢包率雪上加霜。更加严重的是,UDP的无秩序性和自私性,一个疯狂的UDP程序可能会导致这个网络的拥塞,挤压其他程序的流量带宽,导致所有业务质量都下降。

(3) 改进UDP的成本较高

可能有同学想到针对UDP的一些缺点,在用户态做些调整改进,添加上简单的重传和动态发包大小优化。然而,这样的改进并比简单的,UDP编程可是比TCP要难不少的,考虑到改造成本,为什么不直接用TCP呢?当然可以拿开源的一些实现来抄一下(例如:libjingle),或者拥抱一下Google的QUIC协议,然而,这些都需要不少成本的。

上面说了这么多,难道真的不该用UDP了吗?其实也不是的,在某些场景下,我们还是必须UDP才行的。那么UDP的较为合适的使用场景是哪些呢?

UDP的使用场合

通信实时性和持续性

在分组交换通信当中,协议栈的成本主要表现在以下两方面:

[1] 封装带来的空间复杂度[2] 缓存带来的时间复杂度

以上两者是对立影响的,如果想减少封装消耗,那么就必须缓存用户数据到一定量在一次性封装发送出去,这样每个协议包的有效载荷将达到最大化,这无疑是节省了带宽空间,带宽利用率较高,但是延时增大了。如果想降低延时,那么就需要将用户数据立马封装发出去,这样显然会造成消耗更多的协议头等消耗,浪费带宽空间。

因此,我们进行协议选择的时候,需要重点考虑一下空间复杂度和时间复杂度间的平衡。通信的持续性对两者的影响比较大,根据通信的持续性有两种通信类型:[1] 短连接通信 [2] 长连接通信。对于短连接通信,一方面如果业务只需要发一两个包并且对丢包有一定的容忍度,同时业务自己有简单的轮询或重复机制,那么采用UDP会较为好些。在这样的场景下,如果用TCP,仅仅握手就需要3个包,这样显然有点不划算,一个典型的例子是DNS查询。另一方面,如果业务实时性要求非常高,并且不能忍受重传,那么首先就是UDP了或者只能用UDP了,例如NTP 协议,重传NTP消息纯属添乱(为什么呢?重传一个过期的时间包过来,还不如发一个新的UDP包同步新的时间过来)。如果NTP协议采用TCP,撇开握手消耗较多数据包交互的问题,由于TCP受Nagel算法等影响,用户数据会在一定情况下会被内核缓存延后发送出去,这样时间同步就会出现比较大的偏差,协议将不可用。

多点通信

UDP使用场景