1、端口号
我们在应用层创建的套接字,是需要通过bind()接口绑定我们的IP地址与端口号的,这是因为数据从传输层向上交付到应用层时,需要用端口号来查找特定的服务进程。一般在网络通信时,用IP地址标识一台主机,用端口号表示该主机上特定的服务。所以一台主机上,可能同时存在各种不同的服务,每一种服务都有它对应的端口号。在传输层,系统会根据我们的端口号找到应用层的进程,将我们的数据交给应用层。所以网络通信最后实际上是把数据包向上根据端口号交付给特定进程。在TCP/IP协议中,使用五元组(源IP、源端口、目的IP、目的端口、协议号)保证通信中进程的一对一。
端口号的是16位无符号整数,所以取值范围应该是0~65535,实际上在使用云服务器时,我们在bind()时,会发现有时候bind不了,因为在云服务器中的一些端口号是知名端口号,我们熟知的HTTP、FTP、SSH这些广泛使用的应用层协议,它们有自己的端口号,一般是固定不变的,被业界广泛承认固定,所以我们无法绑定这些端口号。知名端口号范围是0~1023,从1024~65535才是操作系统动态分配的端口号,一般客户端在随机分配端口的时候,是从这个范围中分配的。
有两个问题,(1)一个进程能否绑定多个端口号?(2)一个端口号能否被多个进程绑定?答案分别是可以和不可以。数据在交付时,通过网络分层,一定是自底向上进行交付的,所以一定要保证从端口号到进程的唯一关系。而一个进程绑定多个端口号其实不破坏端口号到进程的唯一关系,所以是可以的。举个例子,我们可以写一个服务器,他有一个80号端口使用TCP协议,我们为其创建一个套接字,我们在创建一个使用UDP协议的套接字,绑定81号端口,在网络通信时既可以使用80号端口也可以使用81号端口,它们都与一个进程绑定,可以用不同的端口提供不同的服务,比如使用UDP端口发指令,使用TCP端口发数据。
UDP
1、报文格式
下面这张图就是UDP报文的格式分布,最下面有一个数据,代表应用层向下交付的所有数据,也成为UDP报文的有效载荷。所以我们在应用层使用套接字和sendto接口发送数据时,并不是直接讲数据发送到网络中去,而是发给了传输层,UDP协议会为我们的有效载荷进行添加报头封装,形成完整的UDP报文,进而进一步向下交付。
具体来看,UDP报头里面包含16位源端口,16位目的端口,16位UDP长度和16位UDP校验和。在我们进行应用层代码编写时,写套接字时,我们绑定端口号为什么一定要使用uint_16?因为传输层和网络层时是属于操作系统内容,是由Linux内核进行管理的,在操作系统内部,端口号使用16位来表示的,这就决定了我们在应用层也要使用16位进行设置。16位校验和是UDP保证数据基本的正确性的一种策略。
那么UDP报文在传输层是如何做封装和解包的?大部分的应用层协议为了让报头和有效载荷分离,要么规定特殊符号,比如\r\n来标识报头和有效载荷,要么直接在报头设置文件描述字段,比如在自己的报文前面带上长度。在UDP这里是怎么实现的呢?非常简单,UDP采用的策略叫做定长报头,传输层如果发现接收到的报文是一个UDP报文,内核中会直接把报文的前八个字节移走,移走之后再把有效载荷向上交付就可以了。这种策略可以说是所有协议中,设计最简单的,大小固定,在收发通信时,报头长度永远都是固定的,报头一旦固定约定好,客户端和服务器都认为报头是八个字节,所以再封装的时候加八个字节,解包的时候提取前八个字节,剩下的都是有效载荷。
传输层上面还有很多的应用层协议(包括http、https、ssh),在传输层解包之后,后续的行为叫做对报文分用,什么是分用?是指传输层把报头和有效载荷分开之后,将有效载荷交给上层的特定协议。如何实现分用?报头中有16位目的端口号,系统根据目的端口号找到特定的进程,也就是应用层协议,将有效载荷向上交付,报文在向上交付到指定进程这个行为,其实就是交付到指定的应用层协议,可以认为应用层协议、进程、端口号是三位一体的。
我们可以看到报头中有一个字段叫做16位UDP长度,这个长度代表整个UDP报文的长度(包含报头),这也就意味着整个UDP报文的最大长度是2的16次方,也就是64kb,在当前的网络环境中,64kb其实是一个很小的数据量,如果我们要在传输层使用UDP协议传输大于64kb的数据,我们必须要在应用层将报文拆成64kb一下的数据,不然会发送失败。
2、UDP的特点
(1)无连接。我们使用UDP协议实现一个服务器与客户端通信功能时,客户端建立套接字,bind自己的ip地址和端口号,并知名目的端口和ip,就可以直接发送消息,就像我们发送邮件时,是不需要确认自身与接收方的连接关系的。不像TCP在正式通信之前要经过三次握手确认两端已经连接好了才能发送数据。
(2)不可靠。不论任何协议,在数据传输过程中可能发生丢包问题,一个报文在路上要经过无数个主句,无数个路由器,无数个转发设备进行转发,所以丢包其实是一个很常见的情况。关键在于不同的协议面对丢包的处理方式不同,UDP协议下,丢包了之后什么都不做,这就是所谓的不可靠。而TCP为了保证可靠性设计了一系列策略,超时重传、连接管理、流量控制、拥塞控制等。
(3)面向数据报。首先,面向数据报我们可以类比为收发快递,当商家给我们发送三个快递,我们一定是要收三个快递的,不能只收一个或者一个半或者五个,不存在我们取快递时先取走半个,下次再取后半个,必须整发整取。在实际代码层面,服务器接收客户端报文,调用recvfrom接口时,要么别读,要么调用recvfrom成功时,必定读取到一个完整的报文,客户端发了十个报文,代表其调用了10次sendto接口,服务端也必须调用10次recvfrom接口,这个次数是一比一的(在不考虑丢包的前提下)。所以在写代码时,在UDP协议下,读取报文时不需要验证读取报文的完整性的,读取到报文之后只需要考虑数据的序列和反序列化(结构化)问题就可以了,而在TCP协议下,基于其面向字节流的特性,我们在应用层还需要设置一些验证报文完整性的策略。
这里讲讲面向字节流,它的特点就是,发送端发数据可能发了十几二十次,但是接收方并不知道发送端发了多少次,上层也不知道报文与报文之间在传输层有什么样的边界,发了十多次,接收方可能一次就把数据读完了,也可能100次才读完。至于如何保证读取报文的完整性,程序员需要在应用层自己去定协议,自己从字节流中提取一个完整的报文。
3、UDP的缓冲区
在讲UDP的缓冲区之前,先通过了解TCP的缓冲区对传输层缓冲区有一个系统的了解。
这一段所讲的全部都是在TCP协议下的。在应用层我们调用的对套接字进行操作的接口,诸如read、write、send等等,我们在调用这些接口时并没有把数据从应用层,直接发送到网络里,我们只是通过这样的接口把数据交给了下层传输层,然后再继续向下交付。需要明确一点,我们用的这些网络IO接口,其实并不是直接发送,而是拷贝接口。在TCP这样的协议下,实际上通信双方会在各自的传输层维护发送和接收缓冲区,客户端和服务器都有,在调用send和write接口时,我们应用层中也要维护一块缓冲区,也许是一个char buffer[1024],我们从标准输入流中拿到数据也需要先保存到应用层的缓冲区中,接着调用send和write时,我们并没有直接将数据发送到网络中,而是把应用层的数据拷贝到自己的发送缓冲区中。拷贝好了之后,再由传输层,也就是操作系统,来控制发送缓冲区里的数据什么时候发?发多少?最终经过网络把数据放到对方的接收缓冲区里了。客户端读取时,也并不是从网络里读取上来,而是从接收缓冲区把数据拷贝到应用层中。TCP协议下,通信双方都维护了自己的发送缓冲区和接收缓冲区,可以同时实现两个方向的数据流动,互不干扰,这样的通信方式,称之为全双工。当应用层通过调用send或者write接口把数据交付给TCP之后,接口就直接返回了,相当于数据交付给了操作系统,后续什么时候发,发多少,丢包了怎么办,由操作系统来自主地决定(这也是为什么TCP叫做传输控制协议),应用层可以继续进行后续的业务,所以传输层缓冲区存在的价值,除了支撑全双工,还能够直接提高我们发送数据的效率。总而言之,用户把数据从应用层拷贝到对应的操作系统内部,操作系统再把数据从缓冲区刷新到网络里,有人放有人取,这个模型特别像之前提过的生产消费者模型,也是发送端和接收端进行解耦,解决忙闲不均问题。
以上就是我们的传输层缓冲区的概念,可以联想到我们使用系统调用读写文件,我们基于文件描述符对文件进行读写,调用write时并不是把数据直接写到了磁盘上,因为IO太费时间了,调用write只是将数据拷贝到内核维护的一块缓冲区中,系统会等到数据到达一定数量或者读到\n再把数据刷新到磁盘上。
现在来谈谈UDP,其实UDP没有真正意义上的发送缓冲区,它不需要发送缓冲区,应用层的数据交付到传输层,UDP直接添加上所谓的报头,直接交给在下一层,它不用支持可靠性机制,所以不需要暂时把数据暂存下来。我们应用层调用sendto把数据交给操作系统操作系统将数据传输给网络层,之后进行后续的传输动作。而UDP是具有接收缓冲区的,用于保存收到的数据,本质上,也是为了应对传输层收到数据了,而应用层还在对上一次接收到的数据进行处理而来不及接收的问题。这个接收缓冲区不保证数据有序,即不保证接收到的数据和发送的数据顺序一致,乱序本身是不可靠的一种情况。如果缓冲区中的数据满了,后续的数据会直接丢弃,这时UDP的处理策略。UDP整体上是支持通信双方同时读写的,因此它也具有全双工的特点。