我们先来看一张图;
在研究UDP前我们先来回答两个问题:
第一个问题由于在报头里面有16位UDP长度(表示的是有效载荷+报头长度),而报头长度8字节是固定的,所以分离时我们只需要用整个报文的大小减去固定的8字节报头数据即可。
第二个问题回答就很容易了,由于报头字段中存在着16位的目的端口号,所以我们根据目的端口号进行交付即可。
上面这张图大家在学习计网时肯定都看见过,那么我们很好奇,在内核中是如何用具体的数据描述上面的呢?其实在内核是用了位段
来组织上面的报文的。
16位校验和是用来进行校验的,如果校验和出错, 就会直接丢弃该报文。(ps:博主在讲解UDP/TCP时都弱化了校验和)
UDP没有真正意义上的发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃。
另外UDP传送数据是面向数据报的,也就是说应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并。假如用UDP传输100个字节的数据:如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节。
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部). 然而64K在当今的互联网环境下, 是一个非常小的数字. 如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。
UDP协议由于很简单,所以便不用过多的介绍,重点是接下来的TCP协议。
TCP全称为 “传输控制协议(Transmission Control Protocol”). 人如其名, 要对数据的传输进行一个详细的控制.
在讲解TCP协议前,我们还是要回答跟UDP协议一样的两个问题。首先报头与有效载荷如何分离?
在图中我们发现报头中是存在选项的,选项大小是不确定的。这时我们来介绍下4位首部长度:这4位首部长度是有单位的,单位为4字节;而4位2进制数能表示的范围从【0000】到【1111】,也就是0到15;所以整个报头大小范围是【0,60】,由于报头大小最小都得有20字节,所以报头的取值范围是【20,60】。(ps:报头包括选项)
但是我们明显发现在报头中是没有有效载荷的大小的,为什么呢?
有了解过的同学应该会想到:这应该是与TCP是面向字节流有关的。这个问题博主会放到后面来回答,这里不太好回答,所以大家可以先留下疑问,到时候就明白了。
至于有效载荷如何交付问题与UDP类似,由于报头字段中存在着16位的目的端口号。
另外TCP是具有发送缓冲区与接受缓冲区的,如何理解发送缓冲区与接受缓冲区呢?
在我们调用系统调用write/send
时并不是直接将数据发送给了对端,而是将数据拷贝给了发送缓冲区中,发送缓冲区的发送数据就是依靠某种具体的协议来发送(这个过程是由操作系统帮助我们完成).同理当我们调用read/recv
系统调用时是从对应的接受缓冲区中将数据拷贝到上层以供使用。
32位序号与确认序号:
我们知道TCP是可靠传输的,那么TCP实现可靠传输依靠的是什么策略呢?
我们拿CS模型来简单来说下:当客户端向服务端发送一个报文时,服务端收到了该报文后会发送一个确定报文给客户端,当客户端收到了该确认报文时才认为之前的报文发送成功,才能够发送后面的报文。那假如服务端发送的确认报文丢失了呢?这时客户端不会收到应答报文,那么客户端就不会发送报文了吗?显然不是这样的,在客户端会维护着一个超时计时器,当超过规定时间后客户端就会重发报文。
32位序号就是发送的带有有效载荷报文的编号,32位确认序号是确认报文的编号。从上图来看,我们好像不用额外再专门搞一个32位确认序号,直接让有有效载荷报文和确认报文共同用一个序号不就好了吗?
注意这是一个坑,不要这么误判。因为TCP是全双工的,所以在发送报文的同时也可能在接受确认报文,假如用同一个编号的话不就混淆了有效载荷报文和确认应答报文吗。
确认序号的命名规则:X:表示X-1前的报文已经全部收到,下次发送报文从X开始发送。
我们再来看6位标志位:
ACK和SYN很好理解:
上面图中是我们3次握手建立连接的图示。
URG则与16紧急指针密切相关,紧急指针是用来干嘛的呢?举一个栗子:当我们要传送一个大文件的时候,假如传送了一部分后我们想要立即停止传输,这时就可以用到紧急指针了,紧急指针可以将要传送的数据标识为紧急数据,优先传送。(ps:紧急数据最大只有1个字节)
其他字段大家看上面的介绍应该就能够看懂。
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要经过三次握手建立连接, 四次挥手断开连接。
在详细讲解3次握手和4次挥手前,我们先来谈谈连接?
在OS内肯定存在着大量连接,而OS为了高效的管理这种连接一定是采用了某种数据结构来维护,那么维护这种连接一定是有代价的。(存在着CPU和内存的消耗)
我们再来看看3次握手:
这时大家心里会想:3次握手一定能保证双方连接建立成功吗?
这个其实是不能够保证的;因为在上面1 2 3的任何地方报文都有可能会丢失。假如在1和2的时候报文丢失,其实是没有关系的,因为由于确认应答机制和超时重传机制会将丢失的报文重新发送。但是假如在3阶段的时候报文就丢失了呢?这时客户端已经认为它的连接建立完毕,但是服务端却收不到对应的应答报文,此时服务端会发送RST,要求重新建立连接。直至服务端收到应答(一般来说第3个报文丢失的概率极小,就算丢失了,也有相对应的策略来处理)。
那么此时大家心里应该还有疑问?那么一次握手/两次握手/四次握手/五次握手可行吗?
我们一个一个来回答,首先是一次握手。假如一次握手就连接建立成功会发生什么?
我的乖乖,假如一次握手连接就建立成功的话,那么假如有大量的恶意客户端就专门发送ACK(客户端并没有维护连接),此时连接建立成功后服务端肯定要维护连接,而这样服务端就会遭受到SYN洪水
(SYN flood)攻击,造成了使用了大量资源来维护这些恶意客户端的连接,我想要搞垮服务器只需要让大量的客户端只连接不就行了。所以这种存在着明显的设计漏洞的设计方式肯定是行不通的。
那么两次握手呢?
两次握手与一次握手的原理一样,当我客户端只向你发送SYN,而当服务器收到SYN后就认为连接建立完毕,服务端肯定要维护连接,但是此时客户端并没有维护连接(客户端可以直接舍弃ACK),不又是造成了SYN洪水
问题了吗。
那么四次握手呢?
大家看看四次握手的致命缺陷是什么?
当是偶数
次握手的时候,先建立连接的是服务端,也就是服务端已经建立好了连接,但是我客户端可以选择不建立连接呀(只要我最后一次ACK不接受不就好了吗),又是跟上面的缺陷一样。所以只要是偶数次握手都是不行的。
那么五次握手呢?
其实这个已经是没有必要的了,既然3次握手就已经能够比较好的完成,那么你再误次握手的意义是什么呢?七次九次……握手也是同理。
在回过头来看看三次握手的优势:
由于我们是奇数次握手,所以客户端会先建立连接,此时就不会出现SYN洪水的问题了。
3次握手的好处:
这里在额外提醒一点,3次握手并不能够保证服务端的绝对安全,只是尽可能的避免了一些可能存在的漏洞,要想让服务端安全还得加上其他措施(不在博主知识范围内)。
有了三次握手的讲解后四次挥手就变得容易多了。
大家看看下面这种图就能够明白:
此时在挥手的时候客户端与服务器是具有同等地位的,客户端向服务器提出分手,服务器应答好啊,此时服务器也要像客户端提出分手,客户端说好啊。当然这里面肯定还有更为详细的划分,等会总结时再详谈。
服务端状态转化:
客户端状态转化:
大家可以对照着上面图来看效果更好噢。
再这里面有一个比较有意思的状态:TIME_WAIT
TIME_WAIT状态是主动断开的一方要进入的状态,这时一种临时性状态过一段时间就消失了。(ps:这里主动断开的一方可能是客户端,也可能是服务端)
我们可以来验证一下:
我们使用下面命令来连接服务端:
telnet 127.0.0.1 8849
如果大家没有安装telnet可以使用下面命令安装:
yum list telnet* 列出telnet相关的安装包
yum install telnet-server 安装telnet服务
yum install telnet.* 安装telnet客户端
权限不够使用sudo
提权。
当我们退出时:
我们再来查看:
这个也很好的解释了为啥当我们关掉服务器后马上再重新重启服务器是要换一个端口号的原因,是因为此时原来端口号的进程处于TIME_WAIT状态,要过一段时间才会消失。
那么为什么需要TIME_WAIT状态呢?
- TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.。我们使用Ctrl+C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口。
- MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;可以通过
cat /proc/sys/net/ipv4/tcp_fin_timeout
查看msl的值。- MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK)。
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的。
使用setsockopt
设置socket描述符的 选项SO_REUSEADDR为1
, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
另外还有一个CLOSE_WAIT 状态:
当我们服务端read
返回0,即客户端已经关闭了连接时服务端忘记了关闭套接字:
当我们再次查看状态时:
由于多个客户端连接了服务器后客户端直接退出而导致了出现大量的sock泄漏,所以上面存在着很多的CLOSE_WAIT状态。
所以这里大家一定要记住:在写套接字代码时,当客户端退出后,服务器一定要手动回收套接字,否则就可能使系统可用的套接字数量越来越少。
相信大家在学习算法的时候一定对这个名词不陌生,其实我们在这里讲解的滑动窗口在本质上就是我们学习的双指针
,只不过是比较特殊的双指针,是一种同向双指针。
我们如何理解滑动窗口呢?其实滑动窗口我们可以把它当作成一个大数组,而这个大数组其本质就是我们之前讲的发送缓冲区和接受缓冲区。
但是在这里大家或许就有了下面的一些问题:
先来回答第一第二个问题:首先大家要明确一点滑动窗口不可以向左滑动,只能够向右滑动;滑动窗口的大小不是固定的,而是可以改变的(可变大也可变小);滑动窗口的大小是由Min(对方接受缓冲区剩余空间大小,拥塞窗口大小)
拥塞窗口大家先别急,后面就会介绍。
滑动窗口的设计可以使用循环队列那样形成一个环状,这样就不会出现越界问题了:
那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论。
情况1:数据包已经抵达, ACK被丢了。
在这种情况下我们发现其实是不要紧的,因为尽管我们丢了ACK1001,ACK3001,ACK4001,但是我们收到了ACK5001,所以我们认为前面的报文都已经正常收到了。
情况2: 数据包就直接丢了。
当1~ 1000的数据成功传输后,主机B会向主机A发送一个ACK1001;当1001~2000的数据丢失后,主机B会再向主机A发送一个ACK1001来表示1000之前的数据我已经收到,我需要接受1001 ~ 2000的数据;同理在发送2001 ~ 3000/3001 ~ 4000/……主机B都会向主机A发送一个ACK1001。直到发送了3次ACK1001后,主机A就会重新发送1001~2000的数据报文。注意在此时除了1001 ~ 2000的数据主机B没有接受到外,其他的数据主机B均已经接受,现在只需要将1001 ~2000的数据报文接受即可。
这种机制被称为 高速重发控制(也叫 快重传)
这里使用快重传的机制在效率上应该是要比超时重传机制要高效些。
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应.。因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)
接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位。
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据.。但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题. 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的. TCP引入 慢启动
机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
此处引入一个概念程为拥塞窗口。发送开始的时候, 定义拥塞窗口大小为1,每次收到一个ACK应答, 拥塞窗口加1;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口。
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快。为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍. 此处引入一个叫做慢启动的阈值。当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K; 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M。
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;那么所有的包都可以延迟应答么? 肯定也不是的。
具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms。
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的. 意味着客户端给服务器说
了 “How are you”, 服务器也会给客户端回一个 “Fine, thank you”; 那么这个时候ACK就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端。
创建一个TCP的socket, 同时在内核中创建一个发送缓冲区和一个接收缓冲区。
调用write时, 数据会先写入发送缓冲区中;如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;然后应用程序可以调用read从接收缓冲区拿数据;另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据,这个概念叫做全双工。
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:写100个字节数据时, 可以调用一次write写100个字节,也可以调用100次write, 每次写一个字节;读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次。
首先要明确, 粘包问题中的 “包” , 是指的应用层的数据包.在TCP的协议头中, 没有如同UDP一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段.站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中.站在应用层的角度, 看到的只是一串连续的字节数据.那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包。
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界:
另外注意UDP是不存在粘包问题的,因为UDP是面向数据报的,要么收到完整的UDP报文, 要么不收。
进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别。
机器重启: 和进程终止的情况相同。
机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接。
对于服务器, listen
的第二个参数设置为 2, 并且不调用 accept
,此时启动 3 个客户端同时连接服务器, 用 netstat
查看服务器状态, 一切正常;但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了:客户端状态正常, 但是服务器端出现了SYN_RECV
状态, 而不是 ESTABLISHED
状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
SYN_SENT
和SYN_RECV
状态的请求)ESTABLISHED
状态,但是应用层没有调用accept取走请求)而全连接队列的长度会受到 listen 第二个参数的影响.全连接队列满了的时候, 就无法继续让当前连接的状态进入 ESTABLISHED
状态了.这个队列的长度通过上述实验可知, 是 listen 的第二个参数 + 1。
为什么TCP这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能。
可靠性:
提高性能:
基于TCP应用层协议:
当然, 也包括我们自己写TCP程序时自定义的应用层协议。
TCP/UDP对比:
我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单, 绝对的进行比较;TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播。归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定。
这里提一个问题:如何用用UDP实现可靠传输?