底层网络知识详解:最重要的传输层
概述
这个专栏的计算机网络协议,我是在极客时间上学习 已经有三万多人购买的刘超老师的趣谈网络协议专栏,讲的特别好,像看小说一样学习到了平时很枯燥的知识点,计算机网络的书籍太枯燥,感兴趣的同学可以去付费购买,绝对物超所值,本文就是对自己学习专栏的总结,评论区可以留下你的问题,咱们一起讨论!
传输层中有两个重要的协议,UDP和TCP,这也是在开发中经常用到的协议,同样也是面试的重点。本篇将分为三节进行介绍:
- UDP协议
- TCP协议
- 套接字Socket
一、UDP协议
很多人都会被问到 TCP和UDP的区别,那么大部分人都会回答,TCP面向连接,UDP面向无连接;
建立连接:是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性;
简单介绍下TCP和UDP之间的区别:
- TCP 提供可靠交付,UDP继承了IP包的特性,不保证不丢失,不保证按时到达;
- TCP是面向字节流的,发送的时候发的是一个流,没头没尾的。UDP继承了IP的特性,基于数据报的,一个个发,一个个收;
- TCP是可以有拥堵控制的,可以根据网络环境调整自己的行为;UDP就是应用让我发,我就发,管它洪水滔天;
- TCP是一个有状态的服务,通俗的讲就是有脑子的,可以精确的记着,自己发送了没有,接收到没有,发送到哪个了,应该接收到哪个了,错一点儿都不行;UDP其实是一个无状态服务,无脑子,天真无邪的发出去就发出去呗;
UDP的包头
UDP的包头格式很简单,只有源端口号和目标端口号:
UDP的三大特点
- 沟通简单,秉承性善论,相信网络通路默认就是很容易送达的,不容易被丢弃的;
- 轻信他人,不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据,也可以传给任何人数据;
- 愣头青,做事不懂权变,不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发;
UDP的三大使用场景
- 需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用;
- 不需要一对一沟通,建立连接,而是可以广播的应用;UDP的不面向连接的功能,可以使得可以承载广播或者多播的协议。DHCP就是一种广播的形式,就是基于UDP协议的;
- 需要处理速度快,时延低,可以容忍少数丢包,即便网络堵塞,也毫不退缩,一往无前的时候;UDP简单、处理速度快,不像TCP一样,操那么多心;TCP在网络不好出现丢包的时候,拥塞控制策略会主动的退缩,降低发送速度,这就相当于本来环境就差,还自断臂膀,用户本来就卡,这下更卡了
基于UDP的实际应用
- 网页或者APP的访问,访问网页和手机APP都是基于HTTP协议(基于TCP)的,建立连接需要多次交互,比较耗时,Google提出了QUIC实现快速连接建立、减少重传时延,自适应拥塞控制;
- 流媒体的协议,直播协议多使用RTMP(基于TCP),当数据丢包或者网络不好,影响直播的实时性,很多直播应用,都基于UDP实现了自己的视频传输协议;
- 实时游戏,采用自定义的可靠UDP协议,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响;
- IoT物联网,物联网通信协议Thread,就是基于UDP协议的,解决了物联网领域终端资源少,实时性要求高的问题;
- 移动通信领域:4G网络里,移动流量上网的数据面对的协议GTP-U是基于UDP的;
总结:
- 如果将TCP比作成熟的社会人,UDP则是头脑简单的小朋友;TCP复杂,UDP简单;TCP维护连接,UDP谁都相信;TCP会坚持知进退;UDP铁憨憨一个,勇往直前;
- UDP简单但有简单的用法。它可以用在环境简单、需要多播、应用层自己控制传输的地方。例如DHCP、VXLAN、QUIC等
二、TCP协议(上)
TCP秉承的是性恶论,天然认为网络环境是恶劣的,丢包、乱序、重传、拥塞都是常见的事情,需要从算法层面来保证可靠性。
TCP包头格式
- 源端口号和目标端口号:知道谁发的和发给谁的;
- 序号:编号是为了解决乱序问题;
- 确认序号:发出去的包应该有确认,没有收到就应该重新发送,直到送达;
- 状态位:SYN是发起一个连接、ACK是回复、RST是重新连接、FIN是结束连接;
- 窗口大小:TCP要做流量控制,通信双方各声明一个窗口,标识自己当前能够的处
理能力,别发送的太快,撑死我,也别发的太慢,饿死我;
通过对TCP头的解析,我们知道要掌握TCP协议,重点应该关注以下几个问题:
- 顺序问题 ,稳重不乱;
- 丢包问题,承诺靠谱;
- 连接维护,有始有终;
- 流量控制,把握分寸;
- 拥塞控制,知进知退;
2.1 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建立连接后空着,不发数据;
2.2 TCP的四次挥手
过程如下:
A:B啊,我不想玩了;
B:哦,你不想玩了啊,我知道;
此时的A很可能是发送完最后的数据就准备不玩了,不能在ACK的时候就关闭连接,此时B还没有忙完自己的事情,还是可以发送数据的,称为半关闭状态;
B:A啊,好吧,那我也不玩了,拜拜;
A:好的,拜拜;
断开连接的时序图如下所示:
双方一开始都是处于建立连接的状态:
- A 发送连接释放报文,FIN=1,就进入FIN_WAIT_1的状态;
- B 收到之后发出确认,此时 TCP 属于CLOSE_WAIT(半关闭)状态,B 能向 A 发送数据但是 A 不能向 B 发送数据;
- 当 B 不再需要连接时,发送连接释放报文,FIN=1,就进入FIN_WAIT_2的状态
- A 收到后发出确认,进入 TIME-WAIT 状态,等待 2 MSL(最大报文存活时间)后释放连接;
- B 收到 A 的确认后释放连接;
四次挥手的原因
客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。
TIME_WAIT
客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:
- 确保最后一个确认报文能够到达。如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A
等待一段时间就是为了处理这种情况的发生。 - 等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文。
2.3 TCP状态机
加黑加粗的部分,是上面说到的主要流程,其中阿拉伯数字的序号,是连接过程中的顺
序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端A的状态变迁,加粗的虚线是服务端B的状态变迁;
三、TCP协议(下)
参考了CS-Notes的博文,总结的很好!
TCP传输是可靠的,需要很多机制保证传输的可靠性,里面也要有恒心,就是各种重传的策略;还需要有智慧,里面包含着大量的算法。
如何成为一个靠谱的协议?
TCP中为了保证顺序性,每一个包都有一个ID;建立连接的时候,会商定起始的ID是什么,然后按照ID一个个发送。采用**累计确认或者累计应答(cumulative acknowledgment)**的方式去保证不丢包;
为了记录所有发送的包和接收的包,TCP也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的ID一个个排列,根据处理的情况分成四个部分:
- 发送了并且已经确认的;
- 发送了并且尚未确认的;
- 没有发送,但是已经等待发送的;
- 没有发送,并且暂时还不会发送的;
3.1 可靠传输
TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。
一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下:
其中,0 ≤ a < 1,RTTs 随着 a 的增加更容易受到 RTT 的影响。
超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下:
其中 RTTd 为偏差的加权平均值。
3.2 TCP滑动窗口
窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。
发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。
接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。
3.3 TCP 流量控制
流量控制是为了控制发送方发送速率,保证接收方来得及接收。
接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
3.4 TCP 拥塞控制
如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。
TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。
发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。
为了便于讨论,做如下假设:
- 接收方有足够大的接收缓存,因此不会发生流量控制;
- 虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。
3.4.1 慢开始与拥塞避免
发送的最初执行慢开始,令 cwnd = 1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 …
注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能性也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。
如果出现了超时,则令 ssthresh = cwnd / 2,然后重新执行慢开始。
3.4.2 快重传与快恢复
在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。
在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3。
在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。
慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。
四、套接字Socket
在通信之前,双方都要建立一个Socket。Socket编程进行的是端到端的通信,也只能是端到端协议之上网络层和传输层的。
在网络层中,Socket函数需要指定到底是IPv4还是IPv6,分别对应设置为AF_INET和AF_INET6。还要指定到底是TCP还是UDP,TCP协议是基于数据流的,所以设置为SOCK_STREAM,而UDP是基于数据报的,因而设置为SOCK_DGRAM。
4.1 基于TCP协议的Socket程序函数调用过程
两端创建Socket之后,TCP的服务端调用bind函数监听一个端口, 给这个Socket赋予一个IP地址和端口;
当服务端有了IP和端口号,就可以调用listen函数进行监听。此时的客户端就可以发起连接请求了;
在内核中为每个Socket维护两个队列,分别是已经建立了连接、完成三次握手后处于established状态的队列;一个是还没有完全建立连接的队列,三次握手还没完成,处于syn_rcvd的状态。
接下来,服务端调用accept函数,拿出一个已经完成的连接进行处理。
在服务端等待的时候,客户端可以通过connect函数发起连接。先在参数中指明要连接的IP地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的accept就会返回另一个Socket。
连接建立之后双方开始通过read和write函数来读写数据,下图是基于TCP协议的Socket程序函数调用过程:
4.2 基于UDP协议的Socket程序函数调用过程
UDP是没有连接的,所以不需要三次握手,也就不需要调用listen和connect,但是,UDP的的交互仍然需要IP和端口号,因而也需要bind函数;但正是没有连接状态,每次通信的时候,都调用sendto和recvfrom,都可以传入IP地址和端口;
下图就是基于UDP协议的Socket程序函数调用过程:
4.3 服务器如何支持高并发?
在学习了上面的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的数目也非常的多;
总结
- 需要记住TCP和UDP的Socket的编程中,客户端和服务端都需要调用哪些函数;
- 能够支撑大量连接的高并发的服务端不容易,需要多进程、多线程,而epoll机制能解决C10K问题。
转载:https://blog.csdn.net/ghw15221836342/article/details/100531810