目录
认识知名端口号(Well-Know Port Number)
传输层负责数据能够从发送端传输接收端.
再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序;
在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);
端口号范围划分
0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的.
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.
认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
ssh服务器, 使用22端口
ftp服务器, 使用21端口
telnet服务器, 使用23端口
http服务器, 使用80端口
https服务器, 使用443
执行下面的命令, 可以看到知名端口号
at /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号.
两个问题
1. 一个进程是否可以bind多个端口号?
可以
2. 一个端口号是否可以被多个进程bind?
不可以
netstat
netstat是一个用来查看网络状态的重要工具.
语法:netstat [选项]
功能:查看网络状态
常用选项:
n 拒绝显示别名,能显示数字的全部转化成数字
l 仅列出有在 Listen (监听) 的服务状态
p 显示建立相关链接的程序名
t (tcp)仅显示tcp相关选项
u (udp)仅显示udp相关选项
a (all)显示所有选项,默认不显示LISTEN相关
pidof
在查看服务器的进程id时非常方便.
语法:pidof [进程名]
功能:通过进程名, 查看进程id
如何学习下三层协议
UDP协议
UDP协议端格式
这个报头是操作系统给我们加的 ,采用的是定长报头。
如何将有效载荷交给下一层呢?通过目的端口号,通过这个就可以很容易识别目标进程了。
1.16位UDP长度, 表示整个数据报(UDP首部+UDP数据)的最大长度;
2.如果校验和出错, 就会直接丢弃;。(不保证可靠性)
UDP的特点
UDP传输的过程类似于寄信.
1.无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
2.不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
3.面向数据报: 不能够灵活的控制读写数据的次数和数量;
面向数据报
一个udp报文和另一个udp报文是没有任何关系的。
对于udp来说不需要进行过多的io处理,因为每次接收到的都是单独且结构完整的一个报文。
应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;
用UDP传输100个字节的数据:
如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节;
UDP的缓冲区
1.UDP没有真正意义上的 发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
2.UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 同时如果缓冲区满了, 再到达的UDP数据就会被丢弃;
UDP的socket既能读, 也能写, 这个概念叫做 全双工
对于发送方和接收方来说,他们的内核中都存在很多的 udp报文,那么我们也需要先描述再组织地去管理这些报文,这些报文被结构体sk_buff描述并管理
UDP使用注意事项
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部).
然而64K在当今的互联网环境下, 是一个非常小的数字.
如果我们需要传输的数据超过64K,, 就需要在应用层手动把报文拆分。
基于UDP的应用层协议
NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议(用于无盘设备启动)
DNS: 域名解析协议
当然, 也包括我们自己写UDP程序时自定义的应用层协议;
TCP协议
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;
其中的传输和控制我们都能轻易理解,但是这里的控制是怎么回事呢?
其实就是说,例如我们应用层把数据write到对应内核中的缓冲区中,但是至于这个发送缓冲区里面的内容怎么发,发多少,出错了怎么办这些都是由tcp协议自主决定的。
(这张图其实是和我们之前将io几乎一样 。)
TCP协议段格式
1.源端口号与目的端口号
这两个是一对的概念,目的端口号用于找到上层指定进程,将数据交付
2.四位首部长度
我们将报头与有效载荷分离是通过 固定长度+自描述字段 进行分离的
这里的固定长度是指标准报头中的20字节的长度,这部分是一定存在的
而选项内容则是不一定会有,因此标准报头中有一个4位首部长度,其取值为[0,15],但是比较特殊的是,它有自己的大小单位,每个单位长度为4字节,因此如果选项中没有额外内容,那么这个4位首部长度的值就是5 。
3.十六位窗口大小
因为这个十六位窗口大小与tcp可靠性有关,因此要讲这里的十六位窗口大小,就先要提三个预备知识了
1.客户端和服务器基于tcp协议进行通信,互发消息的时候,发送的是完整的tcp报文,携带的一定是完整的tcp报头。
2.为了保证可靠性,tcp协议中有一个确认应答机制,它是保证可靠性最重要的一个点,即若客户端向服务器发送消息,服务器如果收到,那么就会返回一个确认应答,反之也一样。
3.tcp是不害怕丢包等问题的,因为tcp具有重传机制保证可靠性。如果我们的报文发送速度太快,丢失了报文我们也不需要担心,tcp会进行重传。但是这种方案并不优秀。如果我们可以根据收消息一方的接收缓冲区大小来控制发送方发送消息的速度,那么这种可能大量丢失报文的情况就不太会出现了。因此tcp还有流量控制的方案。
综合上述三点,我们的接收方接收到报文之后,进行确认应答返回一个完整的报文,其中的十六位窗口大小正是它自身的接收缓冲区中剩余空间的大小。
4.32位序号与32位接收序号
在介绍这两个字段之前我们也需要补充一些知识
首先我们知道,如果我们发送的报文得到了应答,那么说明我们最近发送的消息被对方收到了
而如果没有被应答,我们就无法保证其可靠性。
客户端与服务器双方最新发送的一条消息是没有应答的 ,所以我们无法保证发出的消息是百分百可靠的。
但是虽然我们不能保证整个通信过程的可靠,但是我们可以保证局部的可靠,即最新一条消息之前的消息是可以保证可靠的。
为了提高通信效率, 通信的时候双方都可能会捎带应答,即发送需要发送消息的同时也将应答带上,因为应答本身是不需要数据的的。
而处理应答的方式就是:如果发送方一段时间没有收到所发报文的应答,那么它就会认为数据丢失了。
但是串型一发一应答的方式效率还是太低,因此一般采用并型发送,同时发送许多报文,不过这种方式无法保证发送报文的顺序,因此我们就需要序号来标记每个报文。这样接收方接收到报文后,根据序号进行重新排序就能保证报文的有序了。
那么序号又是什么呢?
我们知道用户层会把数据拷贝到tcp的发送缓冲区,我们可以将这个缓冲区看成一个数组,那么每个字节都会有自己的编号,数组的下标就天然可以作为一种序号。
而每个tcp报文的序号实际上就是要发送的数据块的最后一个字符的下标。
而接收序号填充的则是收到报文的序号+1。
有人会疑惑,为什么要多此一举多弄一个确认序号呢?发送方发送会携带一个序号,确认应答的时候把这个序号返回不久可以了。
主要有两点原因。其一是上面我们说的,捎带应答的情况,报文本身需要一个序号,应答也需要一个序号,因此必须将序号与接收序号分离。其二是因为这里的客户端和服务器双方通信是平等的,不像我们前面学习的http协议,服务器只能被动接收客户端的消息,然后返回。
那为什么确认序号要+1呢?
其一是表示该序号之前的数据,我已经全部收到了。
其二就是下一次发送,填写数据从确认序号开始填写。
(实际上序号是会由随机值+数组下标进行确认,这样可以极大避免重复,避免新服务取到老的数据,毕竟我们无法保证time wait之后网络中的数据就真的全部消散了,这点可以看到后面连接之后再回来看)
5.6个标记位
通常来说,客户端与服务器的比重是n:1,那么这些客户端的状态必然是不可能一模一样的。
它们有的可能正在建立连接,有的正在通信,而有的需要断开连接。但是不管做什么,它们都需要发送tcp报文给服务器。
因此 服务器需要辨别不同类型的报文,来进行不同的处理。此时6个标记位就可以用于标记报文的类型。
1.ACK: 确认号是否有效
2. SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
3.FIN: 通知对方, 本端要关闭了
这三个标记位较为好理解,分别对应建立连接,通信中,断开连接三种情况。
4.PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
我们以服务器的接收缓冲区为例,客户端一方向其写数据,服务器一方从其读数据。
这样的一种机制,非常类似我们之前学习过的生产者消费者模型。流量控制本质就是对于 发送过程的一个同步的过程。
如果客户端将服务器的接收缓冲区写满了,而服务器端又迟迟不读,那么客户端方发的报文就会发送PSH,提醒服务器尽快把TCP的接收缓冲区内的数据读走。
那如果服务器的接收缓冲区有空间了,客户端如何知道呢?有两种策略共同作用
1.客户端向服务器发送报文询问是否接收缓冲区有剩余空间,服务器进行响应
2.当服务器的接收缓冲区内的数据被读走,存在剩余空间的时候,服务器主动向客户端发送报文,告知客户端。
5. RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
我们首先需要认识到,虽然TCP保证可靠性,但是TCP是允许建立连接失败的。
因此我们引出下面这种情况来举例子(连接异常还有许多情况)
1.客户端与服务器进行三次握手建立连接。
由于第三次握手时,客户端发送的报文是没有应答的,那么对于客户端来说当其发送了这条报文,它就认为连接已经建立了。而对于服务器来说,它必须接收到这条报文,它才认为连接成功建立。
2.此时若服务器并没有收到第三次握手的报文,服务器端认为此次连接建立失败,而客户端在发送完报文之后就认为连接建立成功了,就已经开始发送数据了。
3.那么当客户端自顾自开始通信,服务器接收到报文后,服务器就会识别到这样的报文是非法的,就会给客户端返回携带填写RSR标志位的报文,让客户端重新连接。
(当然,服务器要维护管理“连接”,也需要成本,管理策略也是先描述再组织)
除了这种,还有类似浏览器中连接被重置的情况
6. URG: 紧急指针是否有效
这个标记位是与16位紧急指针相结合发挥作用的。
十六位紧急指针会指向数据中的一个位置,其大小为1字节,因此并不需要起始位置和长度。
(将其设置为1个字节是因为tcp总体要保证数据的有序性,1字节的较小的数据,对于整体影响较小)
在上层,这个紧急数据被称为带外数据,我们调用send接口的时候将flags修改就能发送带外数据了。
那么我们在什么情况才会使用这种带外数据呢?
下面进行举例。
例如我们客户端向服务器 发起请求,但是发现这个服务器不给我们响应,或者响应非常慢。可是我们ping的时候发现这个服务器的机器是好的。此时我们就想知道是为什么。
我们就可以利用紧急数据来询问服务器现在的状态。不过这需要我们的服务器支持读取紧急数据,并且相应软件功能中支持提供状态编号。
此时我们向服务器发送带外数据,被服务器优先处理,并且响应可以同样使用带外数据返回结果,这样客户端就能快速获得服务器的状态了。
确认应答(ACK)机制
TCP将每个字节的数据都进行了编号. 即为序列号
每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.
确认应答机制我们前面已经提过,这里不再重复,但是需要一提的是,这个序号和发送缓冲区的数组下标是两套不同的机制,序号为了避免重复,起始序号可能会取一个随机值,并且结合数据的位置,然后形成序号。我们前面那样说是为了好理解一些
超时重传机制
主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发;
但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了
因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.
这时候我们可以利用前面提到的序列号 , 就可以很容易做到去重的效果.
那么, 如果超时的时间如何确定?
最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回".
但是这个时间的长短, 随着网络环境的不同, 是有差异的.
如果超时时间设的太长, 会影响整体的重传效率;
如果超时时间设的太短, 有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍.
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接.
连接管理机制
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接
connect只负责 发起三次握手,至于三次握手的细节,上层的接口并不过多参与。
而accept并不参与三次握手,而是把建立好的链接直接拿上来,如果拿不到链接,它就会一直阻塞住。
客户端TIME_WAIT之后会再等待一段时间,然后CLOSED。
如图我们可以看到,客户端把第三次的ACK发送,状态就会更改为established,而服务器只有接收到这个ACK,其状态才会变为established。和我们前面提的是一样的。
所以客户端和服务器在三次握手期间,对应的链接状态是会改变的。这个状态是由双方的操作系统自主维护的。我们可以理解为客户端有对应的INT字段,上面这些大写的用下划线隔开的都是宏。
TCP通信是基于连接的。
建立和断开
三次握手和四次挥手。
这里三次握手实际上也可以看成四次握手,只是因为服务器捎带应答了而已。
在建立连接的时候,正常情况下服务器肯定是会接受的。因此可以捎带应答。
而断开连接的时候,可能客户端并没有要发的数据了,已经断开连接,可是服务器还有内容没发,还不能断开连接。所以可以捎带应答的情况其实是具有偶然性的。
那么我们为什么需要三次握手呢?
1.可靠地验证全双工。
三次握手,客户端和服务器都至少进行过一次收和发,因此可以用于验证全双工。
有人说两次握手客户端和服务器也都至少进行过一次收和发,但是为什么不行呢?
因为只有两次握手并不能可靠地验证全双工,没有客户端再返回的ACK,服务器无法证明自己发出的报文是否被客户端接收。
2.奇数次握手可以保证一般情况下握手失败的连接成本是嫁接在客户端的
维护连接是有成本的,每一个连接都要消耗服务器的资源。
如果一个客户端向服务器发送大量的SYN,因为不需要应答,所以服务器只能接着这些SYN并建立连接。有人说这样客户端也是需要建立连接的。但要是我客户端和你玉石俱焚呢?我就偏要一直发SYN。并且由于没有应答,即使客户端本身无法继续建立连接,客户端仍然可以继续发送SYN,直到服务器没办法继续建立连接。
这样就很容易出现服务器上连接资源被占用满的情况。这种我们就称为SYN洪水。
(虽然说三次握手遇到大量的SYN请求的冲击也会收到影响,但是最起码三次握手并没有比较大的硬伤)
而进行两次握手还有的一个比较大的问题就是这样会优先使服务器做出建立连接的动作。
在服务器发送确认应答的时候服务器就会建立连接了,而客户端需要接收到这个应答才会建立连接。
如此,如果服务器的应答丢失,服务器这边是会正常消耗资源建立连接,可是客户端是不会建立连接的。如果有5%的应答因为各种原因出现异常,那么此时这部分连接是不会有客户端方发送信息的。这些资源就被白白浪费了。由于服务器与客户端是1对N的关系,所以服务器方的影响会更大。因此,若是对某些资源让步的话,优先要让客户端让步。
为什么要进行四次挥手?
断开连接:没有数据要给对方发送了
发送数据是双方都可能发。所以必须断开两次 。
而且实际上关闭连接的那方是可以收到另一方的数据的。所以如果客户端已经没有要发的数据了,关闭了连接,但是服务器还有要发的数据,那么服务器就可以先把要发的数据发了,然后再关闭连接
三次握手的一些补充内容
1.连接建立成功与否和上层accept是没有关系的,三次握手是双方操作系统自行完成的
2.listen的第二个参数(backlog)
这个参数的值+1表示底层已经建立好的连接队列的最大长度。客户端与服务器三次握手建立的连接被称为全连接,全连接建立好后会被放入全连接队列。accept接收连接即从这个队列里取。若全连接队列已满,服务器会丢弃后续的 ACK 包,导致客户端重传,此连接会被放入半连接队列,并将服务器端连接状态置为SYN_RECV
在全连接队列和半连接队列的长度有限的情况下,即使有非常多的syn请求被恶意发往服务器,此时服务器的资源仍然 不会被浪费太多。但是正常用户却很难再建立连接,这叫做SYN洪水。
3.服务器端不会长时间维持SYN_RECV状态。被连接的一方处于SYN_RECV状态被称为半连接,半连接也由一个有一定规定长度的队列管理。但是这种半连接的节点不会被长时间维护。若服务器未在规定时间内收到客户端的 ACK,会重发 SYN-ACK(默认多次重试),超时后清除半连接记录。如下图所示。
4.服务器端与客户端建立连接不一致的问题。从上面几点我们可以看到,可能客户端方连接处于ESTABLISHED状态时,服务器方连接因为连接数目已经达到上限,还处于SYN_RECV状态。甚至客户端方连接处于ESTABLISHED状态时,服务器方连接信息已经被清理。此时如果客户端方发送数据给服务器,服务器会认为客户端再次发起了连接,从而又重新处于SYN_RECV状态。
5.全连接队列为什么不能太长,为什么不能没有
全连接队列如果太长,会导致在上层忙碌无法继续accept新连接的情况下,全连接队列依然 会耗费资源维护那些不进行通信的连接,在上层忙碌的情况下还增加了系统的负担。
如果没有全连接队列,那么当上层有资源进的时候,accept无法立刻接收连接,上层资源无法被充分利用。
四次挥手的一些补充知识
理解TIME_WAIT状态
主动断开连接的一方在四次挥手完成之后会进入time_wait状态,等待若干时长之后会自动释放。
在工程上,我们前面已经遇到过了,如果我们将服务器断开,然后立即重启,系统会提示我们端口号已经被占用 ,我们就无法立即重启这个服务。此时我们需要在上层调用setsockopt接口,使得我们可以立即重启服务。
而客户端虽然也会进入time_wait状态,但是由于其端口号是由系统随机绑定的,因此一般情况下不会影响使用。
那么TIME_WAIT等待多长时间?为什么要等待?
会等待两个MSL时间, MSL是TCP报文的最大生存时间(不是指报文从一端发送到另一端的时间,这个时间仅仅有几毫秒),在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;,
1. 因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失,一般来说即将这些报文收到后丢弃,因为如果超时了,服务器早已经重发过报文了(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
2.同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK),虽然超时之后服务器自动就会关闭,但是使得服务器正常关闭更显其容错性;
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
1.接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
2.窗口大小字段越大, 说明网络的吞吐量越高;
3.接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
4.发送端接受到这个窗口之后, 就会减慢自己的发送速度;
5.如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.
那么通信双方第一次正式通信的时候,是怎么保证发送的数据量是合理的?
答:不要将三次握手仅仅理解为三次握手,双方是交换了报文的,此时就已经协商了双方的接受能力。
第一次与第二次握手不能携带数据,而第三次握手时是可以携带数据的(捎带应答),这说明协商报文接受能力在前两次握手的时候 已经完成了
16位窗口字段, 就是存放了窗口大小信息;
那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
TCP窗口默认就是这么大,但是 TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位(移几位就乘以2的几次);
如图,接收端主机满的情况下,发送端就不会再继续发送带数据的报文,而是会每隔一段时间发送一个窗口探测,进行询问,接收端也会给予答复。
同时,当接收端有空闲资源的时候,接收端也会主动向发送端发送窗口更新通知。
这样双方都有主动的权利,一方出现异常,另一方也能及时通知到,提高了容错性。并且如果双方都无法把自己的信息发给对方,那就说明连接出现异常,一段时间后这个异常连接就会被关闭。
那么流量控制是属于可靠性,还是属于效率呢?
直接上肯定是属于可靠性的,因为它可以避免正常的丢包。不过侧面来说,它避免了正常丢包,避免了大量的重传,也就提高了效率
滑动窗口
1.已经发出去,但是暂时没有收到应答的报文,它会被tcp暂时保存起来
2.像这样的报文,可能会在发送方存在多个
那么这些报文会被保存在哪里呢?
实际上我们发送的数据,它们原本就在发送缓冲区中,发送只不过是拷贝一次到下一层去。
因此我们可以将发送缓冲区大致分为三个部分。(但是其实待发送区后还有一块空间)
第一部分是已经发送并且被对方确认的消息,这部分空间可以被覆盖,那么我们就可以理解为这部分数据已经被移除,因为系统已经不再维护它了
第二部分是可以进行发送,或者已经发送但是未被确认的数据的区域。
第三部分则是待发送的区域。
1.滑动窗口在哪里? 是我们发送缓冲区的一部分
2.滑动窗口的最大大小? 目前我们认为是对方接收窗口的大小,即不超过对方的接收缓冲区的剩余空间大小。
3.如何理解区域划分?通过指针/下标来区分即可
问题一、如果丢包了怎么办
a.应答丢失
我们这时候要联想到确认序号的定义,确认序号x是指序号之前的报文我们全都收到了。
(这保证了滑动窗口线性连续地向右更新,不出现跳跃)
以下图为例,即使2001,3001 ,4001都丢了,只要5001的应答成功发送过去,对方就知道5001前的报文都已经被成功收到了。因此其实是允许少量ack丢失的。
如果是5001的ack丢失,那么对方接收不到就只能等待超时重传了,但是这种情况对资源的浪费并不大。
b.携带数据的报文丢失
如果某个报文丢失,由于确认序号的定义,后续的确认应答都将会是最后一个正常发送的报文的确认序号,如图为1001.
而发送端如果收到三个一样的确认应答时,会立即对未成功发送的报文进行重发,这被称为快重传。重发的报文成功发送至接收端的之后,接收端的确认应答会更新为最新一个正常发送的报文的确认应答,图中更新为7001.
有人说已经有了快重传,为什么还需要有超时重传?
因为超时重传虽然好,但是它是有条件的,即必须收到三个重复的确认应答。这种快重传对大量数据通信的时候可以很好地提高效率。但是如果发送的报文数目较少,那么就只能等待超时重传了,即超时重传可以理解为是兜底的
问题二、滑动窗口如何移动?
滑动窗口是不能向左移动的,但是会向右移动。移动的时候大小是会动态变化的。
我们这里提供一钟理解方式,帮助理解。
即我们把滑动窗口的两段用start和end作标记。
每次start根据确认序号进行更新。
而end则是通过确认序号和对方返回的窗口大小共同作用,进行更新。这样对方的窗口大小动态变化,我们滑动窗口的大小也会动态变化。这也就和流量控制联系起来了。
(不过有时候待发送区的有效数据较少,那么end更新到待发送区结尾即可)
问题三、滑动窗口会在滑动的过程中越界吗?
并不会,tcp处理滑动窗口的时候采用了环状算法,思想和我们数组中取模来模拟环形队列类似。
延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.
1.假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
2.但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
3.在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
4.如果接收端稍微等一会再应答, 那么这个时候返回的窗口大小就是1M;
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率,但是同样,也不是所有的包都能延迟应答的,例如实时性要求很强的场景下,这时候包就不适合延迟应答。
延迟应答一般有以下策略
1.数量限制: 每隔N个包就应答一次;
2.时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;
捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收" 的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";
那么这个时候ACK就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端
捎带应答我们在前面就已经提过了,这里不再赘述。
小总结
tcp在可靠性和提高性能的方面都作出了很大努力。
三次握手除了建立连接也协商了起始序列号和双方缓冲区大小。
但是我们也发现,几乎所有的策略,都是在两段的机器上起作用的。
但是数据包大部分时候都是在网上的,因此我们还应该考虑网络状态,应该对网络信道有所评估。(但也只是评估,tcp并不能直接影响网络。客户端和服务器对于网络出现的问题是无能为力的)因此我们引出拥塞控制
拥塞控制
有人可能会问,网络资源那么多,会因为我们一个主机发送数据的多少受影响吗?
我们一个主机当然不能影响,但是tcp协议这么规定,实际上是让所有的主机对网络拥塞形成共识,当所有主机都意识到网络拥塞并减少报文发送,那么此时网络资源就会有很大的空余,等网络缓过来,我们再慢慢恢复通信。
而且也并不是所有主机都会同时意识到网络拥塞,例如某些主机已经意识到网络拥塞并且减少了报文发送,另一些主机刚开始发送报文,发送了两个,虽然都丢了,但是由于其它主机减少了报文发送使网络恢复,后续报文又都正常发送,此时该主机就不会意识到网络拥塞了。
那么tcp到底会怎么做呢?
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的.
TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
1.此处引入一个概念程为拥塞窗口
2.发送开始的时候, 定义拥塞窗口大小为1;
3.每次收到一个ACK应答, 拥塞窗口加1;(这样就会形成一个指数级的增长)
4.每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快.
1.为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
2.此处引入一个叫做慢启动的阈值
3.当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
面向字节流
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
1.调用write时, 数据会先写入发送缓冲区中;
2.如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
3.如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
4.接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
5.然后应用程序可以调用read从接收缓冲区拿数据;
6.另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次从缓冲区read 100个字节, 也可以每次从缓冲区read一个字节, 重复100次;
例如用户层认为它发送了四个请求,但是TCP并不这样看,它只认为这就是一串字节数据。交给接收端之后,接收端也是这么认为的。tcp协议不关心上层协议,也不关心上层报文格式,只有字节概念。因此向我们之前写的网络版本计算器,我们在应用层要不断地读,然后自行分析收到的数据。
粘包问题
这是一个应用层的问题。
首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段.
站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中.
站在应用层的角度, 看到的只是一串连续的字节数据.
那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界(这就是为什么我们前面写网络计算器的时候encode和decode)
即在应用层定义协议
如何在应用层解决粘包问题?下面简单列举几个策略
1.采用定长报文 例如我们规定一个报文为100字节,那么每次将数据读上来之后按照100字节分隔即可
2.使用特殊字符分隔 例如我们某次通信发送的数据中不带有换行符,我们就可以以换行符作为分隔。
3.自描述字段+定长报头 例如我们规定报头是八个字节,前四个字节用于描述有效载荷的长度
4.自描述字段+特殊字符 例如我们http协议里用换行符作为特殊字符接收请求行和请求报头,然后有一个content length作为自描述字段来描述有效载荷,同时也有一个空行用于分隔。
思考: 对于UDP协议来说, 是否也存在 "粘包问题" 呢?
对于UDP, UDP是一个一个把数据交付给应用层. 就有很明确的数据边界.
站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收. 不会出现"半
个"的情况.
TCP异常情况
1.进程终止: 进程终止会释放文件描述符, 但是仍然可以发送FIN. 和正常关闭没有什么区别.
连接 是和文件直接相关的,文件的生命周期又随进程
2.机器重启: 和进程终止的情况相同.机器要重启就需要关闭所有进程。
3.机器掉电/网线断开: 接收端和发送端无法进行四次挥手,此时接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器 。
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制, 在应用层实现类似的逻辑;
例如:
引入序列号, 保证数据顺序;
引入确认应答, 确保对端收到了数据;
引入超时重传, 如果隔一段时间没有应答, 就重发数据;
......
最最重要的是要问清楚应用场景,既然要用udp实现可靠传输,那么肯定只需要一部分的可靠性保证,否则直接使用tcp不就好了
理解一下socket与文件的关系
下面进行简单的介绍,其实整个过程是非常复杂的,这里只大概说明 。
1.每个进程都有一个task_struct,同时它们也有自己的 struct file_struct 用于管理文件描述符,可以通过其中数组内的指针找到相应的struct_file。
2.我们可以看到图中struct file里有两个指针,第一个指针是指向与网络有关的一些方法集,第二个指针则是当该文件是一个网络文件的时候,会指向struct socket。
3.struct socket中有许多字段,例如图中给出的区分socket类型的字段,指向网络方法的字段,而且它还能返回去指向struct file。最重要的是这里的struct sock*。它指向的struct sock结构体中有发送缓冲区与接收缓冲区。但是因为tcp与udp的套接字有所不同,因此tcp和udp的sock把第一个字段都设置为struct sock,这样struct socket就可以比较统一地访问这两种sock。而如果要访问其它字段,只需要进行强转。也就是类似c++中多态的实现。
4.由于我们的操作系统是c语言写的,因此实际上传输层和网络层内是大量用数据结构表述的协议,以及和各种协议匹配的方法集
5.我们之前说过了,服务器会收到很多的报文,我们同样使用先描述再组织的方式管理这些报文,即用struct sk buff结构体。里面的head data tail end分别指向整个报文的头部,有效载荷的头部,有效载荷的尾部,以及整个报文的尾部。整个报文在各层“流动的时候”,对于报头的封装和拆解,实际上就是移动指针的位置,扩展或减少空间来进行的。传输层网络层数据链路层都是在系统中的,因此它们不必要进行繁杂的拷贝,而是只需要在接收缓冲区进行指针的移动即可封装与拆解报头。