目录
前言
一、端口号
1、概念
2、相关命令
二、UDP协议
1、UDP数据报格式
2、UDP的特点
3、UDP的缓冲区
三、TCP协议
1、TCP数据报格式
2、确认应答(ACK)机制
3、缓冲区
4、TCP报文的6位标志位
5、连接管理机制
5.1 状态变化
5.1 三次握手
5.2 四次挥手
6、超时重传机制
7、滑动窗口
8、流量控制
9、拥塞控制
10、延迟应答
11、粘包问题
12、TCP异常情况
四、总结
1、TCP/UDP对比
2、用UDP实现可靠传输(经典面试题)
3、理解 listen 的第二个参数
总结
传输层协议主要有两个,分别是UDP协议和TCP协议。
端口号(Port)标识了一个主机上进行通信的不同的进程。在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信。
netstat:
netstat是一个用来查看网络状态的重要工具。
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
pidof:
在查看服务器的进程id时非常方便。
语法:pidof [进程名]
功能:通过进程名, 查看进程id
我们注意到, UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大是64K(包含UDP首部)。然而64K在当今的互联网环境下, 是一个非常小的数字。如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。
UDP的socket既能读, 也能写, 这个概念叫做全双工。全双工的意思是recvfrom和sendto可以同时被调用。
各部分用途:
其余部分后面详细介绍。
TCP是可靠的,所以发送下一个数据前必须确认对方已经收到之前的数据。所以每次再发送数据后,都需要对方主机进行应答,来表明已经收到数据。
主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B。如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发。当然传回来的应答也可能丢掉,这个情况下即便B已经收到数据但A不知道,依旧会重发数据,要百分之百确保B收到了上一条数据。重复的数据OS会根据32位序号进行去重操作。
32位序号和32位确认序号:
tcp是面向字节流的,TCP将每个字节的数据都进行了编号,即为序列号。我们可以理解成有一个数组以字节为单位线性的存储了缓冲区的数据,每个字节对应一个下标。每个报文在发送时会携带一串数据,这个报文的序号为这一串数据对应的最大下标。发送下一串数据时就能通过下标锁定起始位置。
TCP协议是自带发送和接收缓冲区的。用户层在进行write/send操作时,并不是把数据发到网络中,而是拷贝到TCP协议的缓冲区中。用户层接收数据时同理,也是直接从TCP协议的缓冲区中拿速度。
缓冲区的作用:
在任何一个时刻,服务器都可能收到成百tcp上千个报文,每个报文的类别和要实现的功能都不相同。所以服务器要根据TCP的标志位对它们进行区分。
- URG: 紧急指针是否有效。由于TCP报文是有序号的,所以一般是按序进行处理。如果某个报文中携带了紧急信息可以用URG标识,会被优先处理。TCP组成中的报文指针就是指向紧急数据的,紧急数据最多占一个字节,否则会破坏TCP的有序性。
- ACK: 确认序号。
- PSH: TCP缓冲区快满的时候,发送端会发送带有PSH的报文,提示接收端应用程序立刻从TCP缓冲区把数据读走。
- RST: 对方要求重新建立连接。我们把携带RST标识的称为复位报文段。
- SYN: 请求建立连接。我们把携带SYN标识的称为同步报文段。
- FIN: 通知对方, 本端要关闭了。我们称携带FIN标识的为结束报文段。
服务端状态转化:
- [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接。
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文。
- [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了。
- [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT。
- [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
- [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接。
客户端状态转化:
- [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段。
- [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据。
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入FIN_WAIT_1。
- [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段。
- [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK。
- [TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态。
TIME_WAIT状态:
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server, 结果是:
这是因为server的应用虽然终止了,但是tcp的协议并没有完全断开,因此不能再次监听同样的server端口。
TIME_WAIT的时间为为什么是2MSL?
解决TIME_WAIT状态引起的bind失败的方法:
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的:
解决办法:使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
CLOSE_WAIT 状态:
如果服务器忘记调用close关闭socket,四次挥手没有正确完成,将会使服务器这一边的连接卡在CLOSE_WAIT状态,不能正确退出。
tcp协议是面向链接的,tcp socket在通信的时候要先进行connect操作建立链接。在建立链接的过程中需要进行三次握手。
三次握手过程:
在客户端看来,在第三步向服务器发送ACK后连接就已经建立完成了。但在服务器看来必须收到ACK后才算连接完成。但是第三步发送的带有ACK的报文可能在中途弄丢,这时候就会三次握手失败。但用户端确以为连接成功了,于是可能就会开始向服务器发送数据,而服务器发现在交互数据前并没有建立好连接,就会察觉双方连接异常了,从而向客户端发送带有RST标识的报文,要求对方重新建立连接。
这里说明一下什么是连接:在服务器中存在大量的连接,这些连接是要被管理的,管理的本质就是先描述再组织。所以在三次握手完成后,双方的OS会为这个连接创建相应的数据结构描述相关信息并加以维护,很显然,维护连接是有成本的。
为什么是三次呢?
握手是为了判断主机和网络是否都正常,三次是验证全双工双方都有收发的最小次数。A向B请求连接,第一次握手A向B发送SYN,第二次握手A收到了B发来的确认响应,此时可以判断A具有收发数据的能力,首先A收到了B发来的数据证明它有收数据的能力,其次B给了A响应证明了A成功向B发送了数据,具有发数据的能力。但此时只能判断B有收数据的能力,因为B无法判断是否成功发送了数据,需要A向B进行反馈,也就是第三次握手,B才能知道自己成功发送了数据。
三次握手在安全方面也有一定的优势:之前提到了连接是需要维护的,如果是一次握手就能连接成功,那么向要攻击某个服务器只需要向它发送大量的SYN请求就能消耗掉它非常大的空间,从而使其崩溃。两次握手和一次握手一样,因为进行第二次握手时无法判断对方有没有收到,所以本质上还是需要收到SYN就建立连接维护。如果是三次握手的话,攻击方想要向对某个服务器发动攻击,需要在发送SYN后把服务器的相关信息维护起来,因为要等待服务器二次握手后再对服务器发送ACK。这个过程对攻击方来说成本也是非常大的,同样需要维护很多资源,一定程度上避免了攻击方用一台电脑就能轻松的搞垮某个服务器。
一般而言,请求建立连接的是客户端,而终止连接是双方的事情。在终止连接时要进行四次挥手。
四次挥手过程:
前面提到过,在主机A发送的数据收不到主机B的应答时就会进行重传。最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回"。但是这个时间的长短, 随着网络环境的不同, 是有差异的:
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间:
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答。收到ACK后再发送下一个数据段,这样做有一个比较大的缺点, 就是性能较差。 尤其是数据往返的时间较长的时候。既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
滑动窗口大小:滑动窗口大小指的是无需等待确认应答就可以继续发送数据的最大值。以上图为例,滑动窗口的大小为4000,再发送前四个段的数据时无需等待。
滑动窗口是处在发送缓冲区中的,发送缓冲区中的数据可以分成三部分,分别是已经发送的数据,滑动窗口中的数据(也就是正在发送和准备发送的数据),和没有发送的数据。
在收到确认应答后,滑动窗口会向后滑动。具体移动多少要根据具体情况分析:
情况一: 数据包已经抵达, ACK被丢了。
如上图所示,中间的ACK丢了,并不会造成任何影响。因为TCP的特性的是报文中的确认序号代表的是序号前的数据全部收到。通过确认序号为6001的应答发送方就可以判断出前面发送的数据已经被接收到。所以滑动窗口起始处直接移动到6001的位置。但是如果是末尾的ACK丢了,比如发送方最后接收到5001的应答,但6001丢了,那么发送方就要重发5000到6000的数据。
情况二:数据包就直接丢了
在某一段数据发送过程中丢失后,接收端会重复发送相同的ACK,比如如果1000到2000缺失,那么接收方之后不管收到任何数据都会发送确认序号为1001的ACK,提醒发送端1001后有数据缺失。在发送端收到三个相同序号的确认应答时则从此序号处重发部分数据,一旦接收方收到缺失数据后应答的确认序号就会马上恢复正常,发送方收到后就开始从正常的序号后继续发数据。
从上述例子中可以看出,滑动窗口的左侧一般会随着收到的确认序号而移动,保证左侧的数据对方全部收到。而右侧的移动则是根据应答报文的窗口大小也就是接收缓冲区的大小而改变,确保不会发送超出对方缓冲区剩余空间的数据。所以说滑动窗口不但位置会改变,大小也会改变。在三次握手期间,接收方的应答中是没有数据的,但是已经包含了窗口大小,发送方就会根据对方窗口大小设置滑动窗口初始值。
这种机制被称为 "高速重发控制"(也叫 "快重传")。快重传机制和超时重传机制在TCP中协同进行的,快重传机制保证了重传的速度,但因为必须收到三次重复应答后才会触发,某些情况下可能条件不会满足。而超时重传可以确保对方没有收到的数据一定被重传。换句话说超时重传是用来给快重传兜底的。
在前面介绍窗口大小的时候提到过,接收端处理数据的速度是有限的,如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度。这个机制就叫做流量控制。
TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据,但如果不加以控制的话数据过多很可能出现问题。流量控制主要考虑的是对方主机的接收能力。除此之外,网络的承受力也是有限的。因为网络中存在很多计算机发送数据,如果当前网络状态已经比较拥堵,导致大量数据到达缓慢或丢包,此时如果触发了重传机制重新发送大量数据很可能会使网络状态雪上加霜。因此,TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
此处引入一个概念程为拥塞窗口:
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快。为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.,此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。当发生网络拥塞时,网络窗口的值会降为原来的一半,
在发生网络拥塞后,ssthresh阈值变为拥塞窗口的一半,拥塞窗口重新从1开始增长。少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞。当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降。拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
假设接收端缓冲区为1M. 一次收到了500K的数据,如果立刻应答, 返回的窗口就是500K。但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了。在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来。如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M。
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包。
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界。对于定长的包, 保证每次都按固定大小读取即可,对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可)。
进程终止:文件的生命周期是随进程的,进程终止会释放文件描述符,发送FIN,和正常关闭没有什么区别。
机器重启:和进程终止相同,因为机器在关机前会先把所有进程关掉。
机器掉电/网线断开:服务器认为连接还在,一旦服务器有写入操作就会发现连接已经不在了,就会进行reset。即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在。如果对方不在, 也会把连接释放。
我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单, 绝对的进行比较。归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定。
很多小伙伴再第一次见到这道题的时候都有点蒙,实际上最好的可靠传输协议已经摆在我们面前了,那就是TCP协议。想增加UDP的可靠性,只要参考TCP的可靠性机制, 在应用层实现类似的逻辑即可:
Linux内核协议栈为一个tcp连接管理使用两个队列:
在写套接字的时候,我们接触过一个listen接口,其中的第二个参数作用为设置全连接队列的长度,全连接队列的长度等于listen第二个参数加1。在调用cannect请求后,如果没有被接收方accept接收,那么这个连接会处在EATABLISHED状态存在全连接队列中等待被接收,全连接里的连接本质上已经建立完成了。如果全连接队列已满时,那么之后收到的连接请求都会在一次挥手后暂停,卡在SYN_RECV状态存在半连接队列中。
为什么全连接一定要有数量限制呢?举个例子,一般生意火爆的饭店(例如海底捞)都会在门口放几张椅子,如果在店里满的时候有客人来可以在坐在椅子上等待。如果没有这几张椅子,客人在店满员的时候会直接走掉,如果等店里有客人出来时恰好有没有新客人来,那么店铺里就会有桌子空出来,造成资源浪费。所以增加几张椅子使门外总有一些等待的客人,就能使资源利用率最大化。但椅子设的太多也没有意义,因为店内不可能同时涌出大量客人,几张椅子上的人就足够补足空缺。全等待队列也是如此,全连接队列的意义是在应用层空闲时可以立刻把建好的连接加到应用层中,把长度设的过长并没有太大的意义,而且全连接队列中的连接也需要资源去维护,如果长度过长的话,在维护过程中反而会浪费掉很多资源。
总结
本文主要对TCP/IP协议中的传输层进行了讲解,希望能给大家带来帮助。江湖路远,来日方长,我们下次见。