这个专栏的计算机网络协议,我是在极客时间上学习 已经有三万多人购买的刘超老师的趣谈网络协议专栏,讲的特别好,像看小说一样学习到了平时很枯燥的知识点,计算机网络的书籍太枯燥,感兴趣的同学可以去付费购买,绝对物超所值,本文就是对自己学习专栏的总结,评论区可以留下你的问题,咱们一起讨论!
传输层中有两个重要的协议,UDP和TCP,这也是在开发中经常用到的协议,同样也是面试的重点。本篇将分为三节进行介绍:
很多人都会被问到 TCP和UDP的区别,那么大部分人都会回答,TCP面向连接,UDP面向无连接;
建立连接:是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性;
简单介绍下TCP和UDP之间的区别:
UDP的包头
UDP的包头格式很简单,只有源端口号和目标端口号:
UDP的三大特点
UDP的三大使用场景
基于UDP的实际应用
总结:
TCP秉承的是性恶论,天然认为网络环境是恶劣的,丢包、乱序、重传、拥塞都是常见的事情,需要从算法层面来保证可靠性。
通过对TCP头的解析,我们知道要掌握TCP协议,重点应该关注以下几个问题:
TCP中所有的问题,都要先建立连接,需要先看连接维护的问题,TCP的连接建立,常被称为三次握手;
A:您好B,我是A.
B:您好A,我是B.
A:您好B
采用 请求->应答->应答之应答的方式,保证二者的消息传送都是有来有回的;
三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是TCP包的序号的问题。 每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的,可以看成一个32位的计数器,每4ms加一,其时序图如下:
1、刚开始客户端和服务端都处于CLOSED状态,服务端先监听某个端口,处于LISTEN状态;
2、客户端主动发起连接请求SYN=1,ACK=0,初始序号为x,之后处于SYN-SENT状态;
3、服务端收到发起的连接请求,如果同意连接就返回SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y,之后处于SYN-RCVD状态;
4、客户端收到服务端发送的SYN和ACK之后,发送ACK的ACK,确认号为 y+1,序号为 x+1。之后处于ESTABLISHED状态,因为它一发一收成功了;
5、服务端收到ACK的ACK之后,处于ESTABLISHED状态,因为它也一发一收了。
两次握手或者四次不行吗?
举个例子:
在一个网络环境不可靠的情况下,A发出一个连接请求,发出一个请求杳无音信就会一直发,终于有一个包到B了,但是A还不知道会继续发;
收到A的请求之后,B如果同意连接就会发送应答包给A;但是B的应答包也是一入网络深似海啊,不知道能不能到A,所以当然不能认为和A已经建立了连接;
还有一个问题就是,A和B建立起短暂的连接通信之后,A之前发送的请求包饶了地球不知道多少圈竟然又到了B,假如B认为这是一个正常的连接请求,同意建立连接,但这个连接不会进行下去,也没有个终结的时候,纯属单相思了,因而两次握手肯定不行。
B发送的应答可能会发送多次,但是只要一次到达A,A就认为连接已经建立了,因为对于A来讲,他的消息有去有回。A会给B发送应答之应答,而B也在等这个消息,才能确认连接的建立,只有等到了这个消息,对于B来讲,才算它的消息有去有回。
当然A发给B的应答之应答也会丢,也会绕路,甚至B挂了。按理来说,还应该有个应答之应答之应答,这样下去就没底了。四次握手、还是四十次握手都是可以的,哪怕四百次握手也不能百分百保证可靠,只要双方的消息都有去有回就可以了。
我们在程序设计的时候可以开启keepalive机制,防止A建立连接后空着,不发数据;
过程如下:
A:B啊,我不想玩了;
B:哦,你不想玩了啊,我知道;
此时的A很可能是发送完最后的数据就准备不玩了,不能在ACK的时候就关闭连接,此时B还没有忙完自己的事情,还是可以发送数据的,称为半关闭状态;
B:A啊,好吧,那我也不玩了,拜拜;
A:好的,拜拜;
断开连接的时序图如下所示:
四次挥手的原因
客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。
TIME_WAIT
客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:
加黑加粗的部分,是上面说到的主要流程,其中阿拉伯数字的序号,是连接过程中的顺
序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端A的状态变迁,加粗的虚线是服务端B的状态变迁;
参考了CS-Notes的博文,总结的很好!
TCP传输是可靠的,需要很多机制保证传输的可靠性,里面也要有恒心,就是各种重传的策略;还需要有智慧,里面包含着大量的算法。
如何成为一个靠谱的协议?
TCP中为了保证顺序性,每一个包都有一个ID;建立连接的时候,会商定起始的ID是什么,然后按照ID一个个发送。采用**累计确认或者累计应答(cumulative acknowledgment)**的方式去保证不丢包;
为了记录所有发送的包和接收的包,TCP也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的ID一个个排列,根据处理的情况分成四个部分:
TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。
一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下:
其中,0 ≤ a < 1,RTTs 随着 a 的增加更容易受到 RTT 的影响。
超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下:
其中 RTTd 为偏差的加权平均值。
窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。
发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。
接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。
流量控制是为了控制发送方发送速率,保证接收方来得及接收。
接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。
TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。
发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。
为了便于讨论,做如下假设:
发送的最初执行慢开始,令 cwnd = 1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 …
注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能性也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。
如果出现了超时,则令 ssthresh = cwnd / 2,然后重新执行慢开始。
在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。
在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3。
在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。
慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。
在通信之前,双方都要建立一个Socket。Socket编程进行的是端到端的通信,也只能是端到端协议之上网络层和传输层的。
在网络层中,Socket函数需要指定到底是IPv4还是IPv6,分别对应设置为AF_INET和AF_INET6。还要指定到底是TCP还是UDP,TCP协议是基于数据流的,所以设置为SOCK_STREAM,而UDP是基于数据报的,因而设置为SOCK_DGRAM。
两端创建Socket之后,TCP的服务端调用bind函数监听一个端口, 给这个Socket赋予一个IP地址和端口;
当服务端有了IP和端口号,就可以调用listen函数进行监听。此时的客户端就可以发起连接请求了;
在内核中为每个Socket维护两个队列,分别是已经建立了连接、完成三次握手后处于established状态的队列;一个是还没有完全建立连接的队列,三次握手还没完成,处于syn_rcvd的状态。
接下来,服务端调用accept函数,拿出一个已经完成的连接进行处理。
在服务端等待的时候,客户端可以通过connect函数发起连接。先在参数中指明要连接的IP地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的accept就会返回另一个Socket。
连接建立之后双方开始通过read和write函数来读写数据,下图是基于TCP协议的Socket程序函数调用过程:
UDP是没有连接的,所以不需要三次握手,也就不需要调用listen和connect,但是,UDP的的交互仍然需要IP和端口号,因而也需要bind函数;但正是没有连接状态,每次通信的时候,都调用sendto和recvfrom,都可以传入IP地址和端口;
在学习了上面的Socket函数之后,可以写一个简单的网络交互程序;
系统会用一个四元组来标识一个TCP连接:
{本机IP, 本机端口, 对端IP, 对端端口}
最大TCP连接数=客户端IP数×客户端端口数,对IPv4,客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP连接数,约为2的48次方。
当然最大的TCP连接数还要受到 Socket中的文件描述符以及内存的限制;
如何在资源有限的情况下,进行更多的连接?
方案一:多进程式
你相当于一个代理,一旦监听到请求,建立连接就会有一个已连接的Socket,这个时候可以采用fork函数创建一个子进程,将基于已连接Socket的交互交给这个新的子进程来做。子进程就可以通过这个已连接Socket和客户端进行互通了,当通信完毕之后,就可以退出进程。父进程可以通过进程ID查看子进程是否完成项目,是否需要退出。
相当于来了一个项目,你就找一个外包公司帮你解决这个问题。
方案二:多线程式
上面这种方式的问题在于,每次有项目都找外包,这个是不划算的;
线程就相当于一个公司成立项目组,一个项目做完了,那这个项目组就可以解散,组成另外的项目组;
通过pthread_create创建一个线程,也是调用do_fork,新的线程也可以通过已连接Socket处理请求,从而达到并发处理的目的。
基于进程或者线程模型都存在一个问题:
新到来一个TCP连接,就需要分配一个进程或者线程。一台机器无法创建很多进程或者线程,就是C10K的问题;
C10K:
一台机器要维护1万个连接,就要创建1万个进程或者线程,那么操作系统是无法承受的。如果维持1亿用户在线需要10万台服务器,成本也太高了。
方案三:IO多路复用,一个线程维护多个Socket
简述一下就是,一个项目组可以看多个项目,每个项目组都应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。
Socket是文件描述符,因而某个线程盯的所有的Socket,都放在一个文件描述符集合fd_set(项目进度墙)中,调用select函数来监听文件描述符集合是否有变化,一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在fd_set对应的位都设为1,表示Socket可读或者可写,从而可以进行读写操作,然后再调用select,接着盯着下一轮的变化。
方案四:IO多路复用
方案三中采用select函数来查看fd_set是否有Socket发生变化,每次轮询都会影响性能,且能查看的数量由FD_SETSIZE限制;
改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。
通过epoll多路复用模型,它不是通过轮询的方式,而是通过注册callback函数的
方式,当某个文件描述符发送变化的时候,就会主动通知。
如上图所示,进程打开了Socket m, n, x等多个文件描述符,现在需要通过epoll来监听这些Socket是否都有事件发生。其中epoll_create创建一个epoll对象,对应着打开文件列表中那个的一项,通过红黑树来保存这个epoll要监听的所有Socket。
当epoll_ctl添加一个Socket的时候,其实是加入这个红黑树;当一个Socket来了一个事件的时候,可以从这个列表中得到epoll对象,并调用call back通知它。
这种通知方式使得监听的Socket数据增加的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常的多;
总结