传输层---TCP

TCP

TCP全称为 "传输控制协议(Transmission Control Protocol"),要对数据的传输进行一个详细的控制

1、TCP协议格式

/* 在include/uapi/linux/tcp.h中定义 */
struct tcp_hdr {
    __be16 source;
    __be16 dest;
    __be32 seq;
    __be32 ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u16 res1:4,
        doff:4,
        fin:1,
        syn:1,
        rst:1,
        psh:1,
        ack:1,
        urg:1,
        ece:1,
        cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
    __u16 doff:4,
        res1:4,
        cwr:1,
        ece:1,
        urg:1,
        ack:1,
        psh:1,
        rst:1,
        syn:1,
        fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
    __be16 window;
    __sum16 check;
    __be16 urg_ptr;
};

在认识报头字段之前,我们依旧要两个问题需要处理

1、如何将数据和报头分离?

tcp的标准报头长度共20字节,其中包含首部长度字段,该字段表示的报头长度包含选项,该字段的取值范围为[0,15],单位是4字节,所以报头最大为40字节,选项最大有40字节,由此,我们可以将数据和报头进行分离

2、如何向上交付数据 --- 通过目的端口号

tcp的大致通信的模型如上图,是全双工通信。

2、确认应答机制

1)对于tcp数据传输的理解

下面我们来单独分析一个方向上的发送数据,另一个方向同理。

2)发送报文的方式

连续发送一组报文效率更快的原因在于重叠了报文的传输时间

那么它也同样回导致问题:

由于网络是不稳定的,我们无法确保数据的按需到达,即先发发送的数据未必先到达,这样数据就会乱序,无法保证数据的可靠性,如何做?

tcp会给每个报文一个序号,这样报文到达server之后再按照序号排序就能获得有序的数据,所以报头中有32位的序号字段

同时,由于网络原因,client也无法确定server发回来的ACK(报头中的一个标志位)对应的是哪一个报文,所以我们在返回应答的时候也需要带上确认序号,用来标识是对哪个报文的应答,所以报头中有32位的确认序号字段

tcp中确认序号的含义是保证当前确认序号之前的其他所有报文都被接收到了。这样定义的好处在于当server发送的应答出现部分丢失时,只要最后一个报文返回的应答被接收到,那么client就会知道它前面的报文都被接收到,就不用在重传之前的数据了。

这时,可能有人就会问,为什么tcp报头中设计了两个字段来标识序号和确认序号?明明我们共用一个字段就能实现确认应答呀???

注意,我们上面的分析都是单向的数据传输,如果是双向的呢(client <=> server)?

 所以tcp在保证数据可靠性的同时,还在尽可能的提高工作效率。

3)对序号的理解

注意:

  • 1、这里说的是序号,没说下标!!!
  • 2、每一对tcp连接都有自己的接收和发送缓冲区
  • 3、上面的缓冲区只是一种方便理解的逻辑结构,并不是tcp真正的缓冲区

3、报头中的标识符字段

为什么要有标识符字段?

因为一个服务器可以接收到不同的tcp数据报文,有的要建立连接(SYN),有的要断开连接(FIN),有的要应答(ACK),不同种类的报文有不同的功能,为了区分这些报文我们有了标识符字段

6位标志位

ACK:确认号是否有效

SYN:请求建立连接,我们把携带SYN标识的称为同步报文段

FIN:通知对方, 本端要关闭了

URG:紧急指针是否有效

用来传输紧急数据,配合紧急指针使用。紧急指针表示紧急数据在有效载荷中的偏移量,紧急数据的大小为1字节(可以用数字码表示不同的行为/状态)。可以用来检测服务器状态、终止上传数据行为等(基本很少用到)

PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走

当接收方缓冲区满了,发送方无法在发送数据时,发送方就可以采用轮询的方式,每隔一段时间就去询问接收方是否能接收数据(发送的报文不要带数据,只空发报头就行),我们就可以添加PSH,催促接收方的应用层快点拿数据。于此同时,如果接收方空出了缓冲区,也会向发送方发送消息提醒它可以发数据了。

除此之外,所有需要让应用层尽快拿走数据的场景都可以给报文加PSH

RST:对方要求重新建立连接; 我们把携带RST标识的称为复位报文段

注意:只要是连接出现问题,导致一端认为连接成功,另一端认为连接失败,但是成功的一端向失败的一端发送数据,这时失败的一端就会发送RST要求重新建立连接。

4、超时重传机制

无论是上面的哪一种情况,一旦在规定时间内没有收到应答,就会触发超时重传,重新发送数据。如果是应答丢失,导致的超时重传,主机B会通过序号对报文进行去重,如果就是数据丢失,则主机B会接收报文。

超时重传就意味着主机A发送的数据在一定时间内是不能被丢掉的,那么它存放再哪里呢?存放在滑动窗口中,下面讲到滑动窗口大家就懂了。

超时重传的关键在于时间如何设置?

  • 如果超时时间设的太长,会影响整体的重传效率
  • 如果超时时间设的太短,有可能会频繁发送重复的包
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间
  • Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍
  • 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传
  • 如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增
  • 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接

 5、连接管理机制

为什么需要三次握手建立连接???

  • 以最小成本验证全双工
  • 奇数次握手,能让客户端优先建立连接
  • 可以用四次握手+捎带应答的方式去理解

(一次握手/两次握手的问题:1、无法验证全双工  2、会引发SYN洪水,即服务器可能会因为一台主机发送大量的连接请求,而自己不建立连接导致服务器建立太多连接从而挂掉,因为一次/二次握手会导致服务器无法确定对方是否建立连接)


为什么需要四次挥手断开连接???

根据上面所说的,不少人会有疑问,为什么挥手需要4次,握手只要3次?明明都可以捎带应答啊!

client断开连接的原因在于数据传输完成了,所以它向server发送请求断开连接,但是server的数据未必全部发给了client,所以server的连接还不能断开,故它们一般需要分别断开,当然不排除它们的数据都处理好的情况,这时就能用三次挥手了

我们一般用的close关闭文件描述符,是将读写都关闭了,这里在介绍一个系统调用接口:

它能够只关闭套接字的读端,或者关闭写端


CLOSE_WAIT状态如何产生,过多会如何?

当我们不用close()关闭服务器端的连接,就会导致服务器将连接长时间维持在CLOSE_WAIT状态,导致服务器变得卡顿,变慢


为什么会出现建立连接失败的情况呢?

我们看看网络连接情况就会知道,这时的server处于TIME_WAIT状态,所以OS默认不会让你去使用还在使用中的端口号!!!

但是我们的服务器进程明明已经被终止了,这个连接通信又是谁在维护呢?显然只能是OS,注意应用层终止进程和传输层没有任何关系,从应用层来看通信结束,但是传输层OS还在做善后工作。那么如果我们想让处于这种状态下的端口号依旧能被使用,如何做?

int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

// setsockopt用来设置或修改套接字的属性/选项

如果你用套接字写过TCP通讯,那么你就会发现客户端并不需要调用setsockopt函数,为什么?因为客户端的端口号是OS随机分配的,很难出现连续使用两次相同端口号的情况,所以不用担心


为什么需要有TIME_WAIT这个状态?

为了防止server历史发送的报文还在网络中没有到达client,这样的数据会对二次通讯产生一些影响,所以设置这个状态来处理这些数据。

(可能有人会觉得这种情况是不可能出现的,因为双方都开始请求关闭连接,意味着所有的数据都有应答,即数据都被双方收到了,不可能有数据没被收到。但是如果出现数据被阻塞在网络中一直到超时重传的数据都到达对面了它还没到达,就可能出现上面说的极端情况)

那么这个时间为多大呢?

  • TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime) 的时间后才能回到CLOSED状态.
  • MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值

这个时间只是一种理论值,具体还得看各自OS的如何设置,但目的就是为了尽可能确保所有的报文都被接收/丢弃,不要影响二次通信。

这里其实还有一个策略用来防止历史报文对二次通信的影响:报文的起始序列号是随机的,结合缓冲区偏移量(可以理解为下标)就能依次得到以后每次发送数据的序列号了,这样又降低了历史报文被误收的可能性。这个起始序列号会在建立连接时,客户端和服务器双方就会协商好。


6、滑动窗口

这里滑动窗口具体指的是缓冲区中的一段数据。用来控制可以连续发送的报文的个数。

(将缓冲区看成事char类型的数组) 

如何理解滑动窗口?

1)如何标识一段数据属于滑动窗口?

所谓的标识一段数据本质就是标识一段区间,你如何表示区间,数学大家都学过,显然我们只要知道它的左右端点即可,这里也是同理,我们设置两个指针win_start和win_end用来指向区间的左右端点

2)如何确定滑动窗口的大小?

滑动窗口中的数据都是可以发送的数据,很明显要考虑到接收方的接收能力,即缓冲区剩余大小,由报头中的窗口大小字段确定,同时报头中的确认序列号,表明了滑动窗口的起始位置,故win_start=确认序列号,win_end=win_start+窗口大小字段的值,所以该窗口总体向右滑动(不能向左移动,根据定义左边的数据为被发送成功的数据,不需要再被发送了)

那么一开始发送数据时,滑动窗口的大小如何确定呢?在建立连接时就会得知窗口大小

有人可能注意到了报头中的窗口大小字段只有16个bit位,这意味着缓冲区最大只有65535字节,但这不是固定的,可以通过选项字段中的窗口扩大因子M进行改变---实际窗口大小是窗口字段的值左移M

3)滑动窗口的大小如何变化?

滑动窗口的大小由接收方的剩余缓冲区大小决定,接收方的剩余缓冲区大小如何变化,它就如何变化,所以滑动窗口既能变大,也能变小。体现出了流量控制的特点。可能有人关心滑动窗口越界如何处理,这里我们将缓冲区看成一个环形数组即可。

这里在说明一点:只能按照序列号顺序将数据放入接收方的缓冲区,如果中间出现缺失数据,则后面的数据无法放入缓冲区,得等待缺失的数据。

当然滑动窗口的大小不仅仅由接收方的剩余缓冲区大小决定,还会有其他因素影响,后面会说

4)报文丢失问题

如果是应答丢失,和之前讲过的一样,由于确认序号的定义,能允许出现少量的应答丢失,如果全部丢失,就会超时重传数据,所以并不会影响滑动窗口的移动。

快重传vs超时重传

两种策略,快重传是为了加快数据传输速度,不让每个报文丢失都需要等到超时才开始重传,只要收到连续三次及以上的相同确认序号的ACK报文就立即重传。超时重传是超出一定时间限制就会发生重传,为了保证所有的数据都必须被接收方收到。两者是互补的。

这里提出一个问题:既然tcp传送报文并不关心报文的大小,为什么我们要将滑动窗口中的数据分组进行发送,我直接一个报文发过去不好吗?很容易想得的一个原因就是一旦报文丢失,发生重传,那么成本就变大了,比如原先丢一个报文只要发1kb,现在要发10kb。 但是于此同时,多个报文的丢失概率也会变高,比如本来你只要保证一个报文不丢,现在你要保证10个报文不丢,显然丢失概率也会变高。所以除了重传成本高之外,还有其他的原因跟下层协议有关,后面再说。

7、拥塞控制

tcp在传输数据时,不能单单只考虑到通信双方主机的情况,还要考虑网络的情况,因为数据是在网络中传输的,故我们还要考虑到网络的拥塞情况。

tcp中识别网络拥塞的标准:出现少量丢包是正常现象,出现大量丢包是网络拥塞。一旦出现大量丢包,我们就需要进行网络拥塞控制,防止重传大量报文导致网络崩溃瘫痪。有人可能觉得我一台主机重传数据,就能导致网络崩溃啦?这里要注意一点:网络的瘫痪不是一个主机导致的,而是网络中的所有主机共同导致的,因为tcp是所有人都要遵守的网络协议。

TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
  • 此处引入一个概念程为拥塞窗口
  • 发送开始的时候,定义拥塞窗口大小为1
  • 每次收到一个ACK应答,拥塞窗口大小乘2

上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是初始时慢,但是增长速度非常快。

  • 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍
  • 此处引入一个叫做慢启动的阈值
  • 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长

具体的变化情况如上,拥塞窗口大小增大到阈值之后会变成线性增长,一旦遇到网络拥塞,拥塞窗口大小会从1重新开始增长,并且阈值变为之前窗口大小的一半,如此反复。能动态的反应网络的拥塞状况。

当然,tcp在传输数据时,发送的数据量还和接收方的接受能力有关,所以滑动窗口的大小=min(拥塞窗口的大小,接收方剩余缓冲区的大小),这样既考虑到网络,又考虑到了接收方的接收能力。

那么,如果网络一直不拥塞,拥塞窗口的大小会超范围吗?理论上发送那么多数据的过程中,网络很难一直保持正常,基本可以断定不存在这样的情况。

8、延迟应答

即接收方在收到数据时,不立即发送ACK报文,而是等待一段时间在发,让应用层尽可能得读取缓冲区数据,从而让返回的窗口大小更大,增大滑动窗口的大小,从而加快报文的传输

注意:窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率

延迟的时间有两种策略

  • 数量限制:每隔N个包就应答一次
  • 时间限制:超过最大延迟时间就应答一次

具体的数量和超时时间,依操作系统不同也有差异, 一般N取2,超时时间取200ms

9、捎带应答

在上面提到过,就是在传输数据时,顺带着对之前的收到的报文进行应答

10、对面向字节流的理解

现在,我们能很容易的理解什么叫做面向字节流,本质是我们在读/写缓冲区时,也是以字节为单位进行的,比如说一共100字节的数据,我们可以分成10份,一份10个字节的读/写,也可以一次性读取/写入,具体如何使用由应用层协议决定。

这其实和读写文件一样,我们在写文件时很简单,但是在读取文件时,我们就要考虑如何对文件中的数据进行拆解翻译,有没有什么好的方法呢?同网络通信一样,其实我们在写文件时,也可以设计相关的协议,通过序列化,发序列化等方式,便于我们读取数据。

11、粘包问题

这个问题是由面向字节流引起的,因为数据是按照字节进行读取,没法单独拿出来,导致和其他的数据"粘"在一起,需要应用层自己按照一定的规则,对数据进行划分,然后在使用。

一般有三种方法:

  1. 用特殊字符作为分隔符
  2. 指定报文长度
  3. 报头 + 自描述字段

这三种方法可以组合使用。

UDP协议不会有粘包问题,因为UDP协议是面向数据报进行传输的,数据本身就是分开的,有明显的数据边界,故没有粘包问题

12、TCP异常情况

1、进程终止:无论是正常终止还是异常终止,进程终止都会释放资源,释放文件描述符,OS会帮我们将连接断开,不会出问题

2、机器重启:类似不小心关闭电脑的情况,有过经历的都知道,电脑会在关闭之前询问是否关闭所有还在运行中的进程,如果选否,则电脑不关闭,如果选是,则变成了进程终止问题,也不要会出问题

3、机器断电/网线断开:这会导致客户端连接断开,但是服务器连接没断开,一般来说,服务器会设置一些策略保证连接正常,比如定期发送报文,累计多次没有收到应答就会自动关闭连接,这种策略称为tcp保活/心跳机制,所以也不会出问题。

13、listen系统调用的第二个参数

int listen(int sockfd, int backlog);

Linux 内核协议栈为一个 tcp 连接管理使用两个队列:
  • 半链接队列(用来保存处于SYN_SENTSYN_RECV状态的请求)--- 为了不让服务器建立连接。SYN_RECV状态持续的时间较短。
  • 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)保持的时间很长

而全连接队列的长度会受到 listen 第二个参数的影响。全连接队列满了的时候,就无法继续让当前连接的状态进入established状态了,这个队列的长度通过上述实验可知,是listen的第二个参数+1

全连接队列的作用,可以理解为你去海底捞吃饭,结果里面人满了,那么店员会让你在外面坐着等,等里面有人吃完了,你就可以立即进去吃了。一旦有资源,就会将你的文件描述符交给上层应用,能在一定程度提高效率。当然也不能设置的过大,这样客户端等待的时间就过长,而时间一旦过长,客户端就有很大概率断开连接,这样只会白白浪费系统资源,得不偿失。

基于TCP应用层协议:

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP

当然,也包括你自己写TCP程序时自定义的应用层协议

  • 21
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值