首页 > 面经不会告诉你的TCP秘密
头像
Speaive
发布于 08-08 17:42 北京
+ 关注

面经不会告诉你的TCP秘密

先看看面经咋说:

  1. TCP是一个传输层协议
  2. TCP是一个面向连接的协议
  3. TCP是一个字节流协议
  4. TCP是一个可靠的协议

再看看具体的定义:

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议

其实没差多少,但是我们看上面给的几点定义就可以看出来,我们实际应用中

  1. TCP的客户端会先与某个给定的服务器建立连接,再跨连接与那个服务器交换数据,然后终止这个连接;
  2. TCP会提供一些可靠性的保障:当TCP向另一端发送数据的时候,它要求对端返回一个确认,如果没有收到这个确认,TCP 就会自动重传数据并且等待更长时间。基本上数次重传失败后TCP才会放弃。这种尝试发送数据花费的时间大概是4~10分钟 吧,当然主要依赖于各种实现。
  3. 前提:世界上可没有百分百可靠的东西,所以TCP也只能做到传输失败多传几次,实在没办法只好告诉你一个err的办法 了>=<。
  4. TCP含有用于动态估算客户端和服务器的往返时间(round-trip time , RTT)算法,以便它知道等待一个确认需要多少时 间。
  5. TCP通过给其中每个字节关联一个序列号来对数据进行排序。
  6. TCP还有流量控制,TCP会告知对端在任何时刻它一次能够从对端接收多少字节的数据,学名叫通告窗口。任何时刻, 该窗口指出接收缓冲区当前可用的空间量,从而确保发送端发送的数据不会使接收缓冲区溢出。并且该窗口是时刻动态变 化的:当接收到发送方的数据时,窗口就减小,但是当接收端的应用从缓冲区读数据的时候,窗口就增大。
  7. TCP还是全双工的哦!

TCP的连接的建立和终止

我们都知道八股文里面说是三次握手和四次挥手,但是这次我们结合实际,从双端的角度去好好看看

连接的建立

服务端

1. 服务端必须准备好接受外来的连接。通常用如下几个函数来完成(后续会细说)。我们称之为被动打开

socket();
bind();
listen();
客户端

2. 一般是客户端调用connect()函数发起主动打开。在这个过程中会导致客户端TCP发送一个SYN分节,它会告诉服务器客户端将在即将建立的连接中发送的数据的初始序列号。通常这个SYN是不携带数据的,其所在IP数据报只含有一个IP首部、一个TCP首部及可能有的TCP选项。

服务端

3. 服务器需要确认客户端的SYN序列,具体做法是返回给客户端一个ACK。同时自己也发送一个SYN分节,它含有服务器将在同一个连接中发送数据的初始序列号。

客户端

4. 确认服务器的SYN。

总结一下:为什么我们都叫三次握手,就是因为这种交换至少需要3个分组。

半开连接

其实有个问题,如果最后一次握手的ACK K+1 这个东西发丢了,服务端接收不到,服务器无法建立连接,客户端认为自己建立连接了,这时候怎么办?这就是半开连接的场景。

处理:

  1. 服务端由于没有收到ACK,超时后会认为连接建立失败,释放资源。
  2. 客户端认为连接建立成功,发送数据->没有ACK->超时重传->超时->意识到自己连接建立失败。
  3. 客户端重新发起连接。
细节

观察上图细节:我们客户端的初始序列号是J,服务器的初始序列号是K,,而双方的ACK都是对方的序列号 + 1

TCP的选项

当我们发送SYN的时候是可以在SYN的分节中设置一些选项的。浪漫的名字----RFC 1323选项

MSS选项(maximum segment size)

发送SYN的一端需要使用这个选项去告诉对端自己的最大分节大小,即它在本连接的每个TCP分节中愿意接受的最大数据量。所以发送数据的一端的TCP会使用接收端的MSS作为发送分节的最大大小。

窗口规模选项

看下图配合文字!TCP的两端在连接的过程中可以告诉对方的最大窗口大小是65535(字节),因为下图的“窗口”里面写了占了16位。但是---- 现在大家的网络已经可以实现高速网络连接了(硬件发展的快啊),所以我们要求有更大的窗口来获得尽可能大的吞吐量,。所以这个窗口规模选项是用来指定TCP首部中的通告窗口必须扩大的位数(扩大形式是左移0~14)。因此所提供的最大窗口接近1GB(65535 * 2^14)。

小tips1:TCP的吞吐量指的是在一段时间内通过TCP连接传输的数据量,通常以每秒传输的数据量(比特/秒或字节/秒)来衡量。吞吐量是衡量网络传输效率的一个重要指标,它反映了在特定网络条件下,TCP连接能够实际传输多少数据

小tips2:听说早期有的硬件不支持这个选项我们只好应用如下规则------主动打开SYN的一方可以携带这个选项,但是必须对方的SYN也发送这个选项的前提下,主动打开的一方才可以扩大自己的窗口规模。不过好像现在也在应用这个规则,这个存疑没有具体考证!

时间戳选项

高速网络连接场景下时间戳是必须的!他可以防止“失而复得的分组”可能造成的数据损坏。编程人员应该不需要考虑。但是这个“失而复得”的概念需要好好聊一下---它不是指的超时重传的分组,当然你能想到这里也值得夸赞。它指的是暂时的路由原因造成迷路了的分组。而路由稳定后它们又会正确的到达目的地。而我们的分组序列号只有32位,我们知道我们是利用序列号保证数据有序的,所以高速网络下我们可能出现序列号重复的场景导致数据顺序异常。

思考:

  1. 初衷----利用序号保证多批次内容的可靠
  2. 实现----序列号利用了32位
  3. 问题----高速网络下会出现32位在某些数据暂时迷路很长时间的场景下出现序列号重复
  4. 解决----加上时间戳
  5. 思考----业务需求上如果涉及到数据的分片处理,是否可以很好的利用时间戳呢?

连接的终止

到了四次挥手的环节,刚才建立连接需要3个分节,终止一个连接则需要4个分节

终止连接的触发

1. 某个应用进程首先调用close()方法。close()方法会执行TCP的主动关闭功能。所以很显然,接收关闭分节的一端就是被动关闭方。(其实不是close()就立刻关闭,这个到了讲close()函数的时候会细说,等我!)

主动关闭方

2. TCP发送一个FIN分节,表示数据发送完毕

被动关闭方

3. 接收到这个FIN的一端很显然是被关闭的一方。所以TCP在确认这个FIN后(回执一个ACK)。它的接收也作为一个文件结束符EOF(end-of-file)传递给接收端的应用进程。(这里就可以看出来TCP只是一个传输层协议,具体还是要应用层灵活的运用)

因为FIN的接收意味着接收端的应用进程在这个连接上再也没有额外的数据需要接收了。

当然还有一点是,这个EOF一定是放在正在排队等待进程读该连接的数据的最后的,不然你不可能直接插队不让人家读没读完的数据呀!

被动关闭方

4. 当然,一段时间后,接收到这个EOF的应用进程会调用自己的close()函数来关闭这个套接字。同时这个函数会导致被动关闭方的TCP也发送一个FIN

主动关闭方

会接收到最终的FIN,并且确认这个FIN---即回执一个ACK

小TIP

当然了,虽然理论上四次挥手是要用4个分节去完成,但是某些场景下,比如 步骤1 的FIN可能是随着最后的数据一起发送的。颇有一种和你最后亲热一次顺便说分手的即视感;而步骤2和步骤3发送的分节由于都出自被动的一端,所以很可能合并成一个分节给你发送回来了。

观察上图我们可以发现,其实FIN和SYN是类似的,都占据一个字节的序列号空间(这就是我又放了一遍图的理由)。而它们每个的ACK确认都是对应的FIN + 1。

半关闭

上述步骤2和步骤3之间,为啥我说“等了一会”,因为是存在被动关闭的一端到主动关闭的一端的数据有流动的场景的,放在生活中就是你跟我说分手了,但是请让我最后对你好一次(几次)再结束我们的关系吧。所以这个极具舔狗色彩的称呼就叫----半关闭(half-close)。而这个称呼也好记,half ~half 叫声像条狗>=<!

浅谈套接字关闭

套接字关闭的时候TCP会发送一个FIN。我们刚才也说了,是由应用进程的close()发生的。不过我们需要明白,一个unix进程不管是自愿的(调用exit或者main的结束)还是非自愿的(接收到一个类似kill的终止信号)。反正只要是这个unix进程被终止了,那么这个进程打开的所有文件描述符都会被关闭而这个描述符关闭的时候会导致对应的任何一个TCP连接都会发送FIN

状态转换

我有一个好哥哥同事曾经给我分享过一些状态流转相关的东西,这给刚参加工作的我留下了深深的印象,使我清楚的知道一个业务流程中只要状态流转是明确的,那么其实不管这个业务流程有多繁琐,也可以很容易的梳理出主干脉络。而我学习了TCP之后我发现,原来早在我幼稚的业务开发这件事以前,前人们就针对TCP有了很好的状态流转的概念。先看TCP的状态转换图

查了一下TCP为一个连接定义了11种状态,并且TCP规则规定了如何基于当前状态该状态下所接收的分节从一个状态转换到另一个状态。例如:当某个进程是CLOSED状态下执行主动打开的时候,TCP是会发送一个SYN分节的,并且状态变成了SYN_SENT。如果这个TCP收到了一个带有ACK的SYN,它将发送另一个ACK,并且新的状态变成了ESTABLISHED

netstat -a

带着分组的流程

通过上述内容的介绍,我们可以总结一下完整的TCP连接的分组交换情况。包括连接的建立数据传送连接终止的三个阶段。

流程
  1. 观察内容我们可以发现首先服务器自身使用scoket、bind、listen去初始化自己的监听功能,将自身的监听端口设置好等待 客户端的连接,此时服务器阻塞在了accept的状态下,并且状态变成了LISTENED
  2. 客户端初始化自己的socket,并且针对服务器发起三次握手的第一次握手,分别发送了一个SYN序列,并且在tcp首部中添 加了自己的MSS来告诉服务器:“我客户端的最大分节大小是536字节的” 并且状态变成了SYN_SEND
  3. 此时服务端接收到客户端的连接请求后,回复客户端一个ACK = J + 1 并且附带了自己的SYN序列K,证明了自己也允许客 户端发起连接,同时附带了自己的MSS来告诉客户端:“我的最大分节大小是1460字节”,同时状态轮转成了SYN_RCVD
  4. 此时客户端收到服务端的SYN和ACK后明确了服务端也希望发起连接,于是connect方法开始返回,同时回复服务器最后一 个ACK。并且状态轮转成了ESTABLISHED状态。
  5. 服务端接收到最后一个连接ACK后,三次握手建立连接正式完成,于是accept方法返回对应的文件描述符,同时开始利用re ad方法读取客户端的数据,同时状态也轮转为ESTABLISHED
  6. (一般是客户端向服务器请求数据,所以客户端会先写自己的请求到服务器)客户端开始利用write函数写数据给服务端,数 据发送出去后会调用read等待服务端的回复。
  7. 服务端的read方法返回客户端的请求数据,于是进行数据应答,即利用write写数据发送给客户端同时附带ACK。最后依旧调 用read去等待客户端的响应
  8. 客户端收到服务端的数据后read返回内容并且回执一个ACK。
  9. 客户端数据交互结束,希望断开连接,调用了close方法,close方法会向服务端发送一个FIN序列,并且客户端的状态进入到 了FIN_WAIT_1 的状态。
  10. 服务端接收到客户端的FIN序列后,自身进入被动关闭的CLOSE_WAIT状态。并且read函数返回0。当然服务端会回复客户 端一个ACK证明自身确实收到了关闭请求。
  11. 客户端接收到服务端的ACK后,状态流转为FIN_WAIT_2,并且等待服务端数据处理完成后的FIN。
  12. 服务端数据处理完成后,调用close方法,向客户端发送一个FIN,并且状态流转为LAST_ACK
  13. 客户端接收到服务端的FIN后,状态流转为TIME_WAIT状态(很重要,后面会讲),并且回执一个ACK。
  14. 服务端收到ACK后状态流转为CLOSED状态。
注意

流程7里面服务器对客户端的应答和交互的数据一起返回给客户端了,这个通常在服务器处理请求并产生应答的时间少于200ms的时候才会发生的事。如果服务器耗时很长,那么我们看到的是先确认ack再应答数据的。

TIME_WAIT状态

其实这个time_wait状态有问多人都是有疑问的,和同事聊天的时候同事也提到曾经自己待过的公司因为time_wait状态的请求过多导致出现过问题(具体是啥他没和我说,下次请他吃饭问问。不过相信了解这个状态后我们自己也能推断出是什么问题!)我们停留在TIME_WAIT的时间一般是最长分节声明周期(MSL)的两倍,一般叫做2MSL。那么问题来了,这个MSL是什么鬼?

MSL

MSL是任何IP数据报能够在因特网中存活的最长时间。RFC1122的建议是2分钟,但是有些实现上改用了30s这个值。所以实际上这个2MSL大概在1min ~ 4min之间吧。

是不是没看懂,怎么莫名其妙就30s到1min了,存活时间是什么鬼啊?

实际上这个存活时间的值是就每个ip数据报的跳限(ipv6)或者ip数据报的TTL(ipv4)。下图分别是IPv4的首部和IPv6的首部

所以知道MSL是啥了吧。

那么问题来了,MSL的存在和TIME_WAIT到底有什么直接联系呢?

其实我们分组在网络中传输的时候偶尔会出现路由异常的场景(路由器有问题的时候大家都经历过),所以可能出现路由循环的场景--即A路由器把分组发给B,B路由器把分组发给A。这样当这个TCP分节陷入路由循环的时候,可能出现发送端TCP超时重传该分组,而超时重传的分组却安全无误的到达了目的地。但是不久后人家“迷路”了的分组自己走出“怪圈”了,也被送到了目的地。这时候TCP必须可以正确的处理这个分组。

所以如果没有TIME_WAIT的等待时间会怎么样?我们假设客户端的x端口和服务器通信,其中客户端的一个分组迷路了,客户端超时重传了另一个分组被服务器接收到,服务器最后数据处理完成,客户端和服务器完成四次挥手的断开操作。然后过了一段时间,客户端又使用x端口再一次和服务端通信并且那个迷路的分组找到服务端的路了,哭着说:“我才是你的女朋友,刚才的是冒牌货。”此时新的服务器连接一看卧槽,ip和端口号都相同,是自己人。于是接收了这个分组,然后服务端发现自己的数据异常了!你让服务器怎么面对这个“修罗场?”。所以TCP利用这个TIME_WAIT状态,等待2MSL的时间,保证迷路的分组要么死了,要么在服务器断开连接之前收到了,就不会出现上述“鸠占鹊巢”的情况了。

回到上述的流程13,如果客户端最后一个ACK丢了。迟迟没有发给服务端,此时服务端觉得自己是不是把FIN给弄丢了啊,于是重新发送了FIN分节给客户端,此时客户端如果没有TIME_WAIT则没法很好的维护这个FIN,但是有了这个状态就可以再发一次ACK。那客户端如果不维护这个状态就会响应一个RST分节,这个前面没讲过,这个分节的作用是被服务器解释错误。

所以TIME_WAIT的作用来了---就是:

  1. 可靠的实现TCP全双工连接的终止。
  2. 允许老的重复分节在网络中消逝

嘻嘻!

端口号

强调一下,不只是TCP用端口号啊,包括UDP、SCTP等都需要端口号的,只是在这里想好好的介绍一下。

端口号的定义

不想写,感觉全是定义好麻烦,能看到这个文档的人应该不会不知道啥是端口号,先偷个懒就这样吧,直接列几个核心点,看文档的自己去理解吧。

  1. 端口号是16位整数
  2. 端口号是用来区分多个进程使用通信协议通信用的
  3. 一般客户端使用短期存活的临时端口,这些端口通常是协议自己赋予的,使用者通常不关心,只要确定这个端口在主机中是唯一的就行啦!

套接字

端口通过那三条大概同学们心中有印象了。所以我们来聊聊刚才一直说的套接字套接字套接字这到底是个啥?

中华文化博大精深,我愣是没能在古文中找到套接字对应了阴阳五行八卦的啥玩意!

拿TCP举例:一个TCP连接的套接字对,是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地端口号。所以显而易见的套接字对是会唯一标识一个网络上的每个TCP连接的。

一个套接字:每个端点的两个值(IP地址和端口)称之为一个套接字。

并发服务器

刚才在端口号那一栏我们聊过,端口号是用来区分多个进程使用通信协议通信用的,而针对并发服务器程序来说,并发服务器的主进程会循环派生子进程来处理每个新的TCP连接。而如果我们的子进程使用我们主进程监听的端口来服务一个长时间的请求会发生什么?看图!

如上图所示,我们在服务器上启动一个服务程序,服务器的公网ip是12.106.32.254、自身的内网ip是192.168.42.1 服务程序监听的端口是21端口,并且服务器允许任意ip的任意端口的请求打进来。

后续我们在206.168.112.219这台机器上启动了客户端程序,客户端程序像服务器发起连接请求,同时将自己的端口设置为1500,于是构成了这样一个套接字对{206.168.112.219:1500 , 12.106.32.254:21}。

而服务器接收到客户端的连接后会自身fork(后面会讲)一个自己的子进程,这个子进程相当于主进程的一个副本。让子进程来处理这个TCP请求。

而假设我们的客户主机上又有另一个请求需要像服务器发起连接,此时会再给客户端分配一个端口例如1501,看下图!

我们发现构建了另一个套接字对{206.168.112.219:1501 , 12.106.32.254:21}。当然我们站在服务器的视角上也是可以观察到,两个连接确实是不一样的,因为一个是1500、一个是1501。所以我们的主进程一旦发现这个分节的来源是1500端口就会把数据交给子进程1去处理;一旦发现这个分节的来源是1501就会把数据交给子进程2去处理。如果二者都不是,那就交给自己处理。

缓冲区相关内容

我们前面讲了一些TCP协议的时候经常会说到交换数据相关内容,可是我们的数据具体是怎么交换的呢,我们数据到底一个分组能交换多少呢?下面就让我们来一起探讨一下吧>=<。

ip数据报的大小

还是上文的两张图片拿出来看一下分别是ipv4的首部、ipv6的首部。

我是IPv4的首部

我是ipv6的首部

数据报大小

ipv4

我们先观察ipv4的首部,其中total length是占据了16~31 一共16位(bit),我们知道8位是一个字节,所以16位就是2个字节,而两个字节可以表示的数最大是65535 (二进制转换成10进制这个教不了)。

所以一个ip数据的最大总长度是65535 单位是 (字节), 所以ipv4首部的最下面那个ip载荷 + ip首部的内容最大是65535字节也就是大概64KB。(知道流量都是咋没得了吧)

ipv6

有了ipv4的观察经验,我们再看ipv6的首部,我们会发现ipv6的首部标记了“有效载荷长度”是16位,这个就说明了,ipv6的载荷内容是最大65535字节,是不算首部的内容就最大65535字节,如果算上首部的首部的:

32位 * 10 = 4个字节 * 10 = 40个字节 = 40B (源地址和目标地址分别是4行)

所以IPv6的数据报最大长度就是65575字节

ipv6其实还有扩展头,里面有一个特大净荷选项(jumbo payload),它把净荷长度字段扩展到了32位,不过这个选项需要硬件支持,需要**MTU(maximum transmission unit,最大传输单元)**超过65535的数据链路提供支持。

其实可以从这里看出来,上层的技术进步一定是要依赖下层的支持的,最后就会落到硬件层面,个人入行没多久,有两个角度感触最深,一个是通信协议依赖底层硬件实现净荷的扩大;另一个就是在学习Java的时候了解到无锁编程最后依赖的是硬件层面的CAS支持。可以看出软件层面能走多远完全取决于硬件的建设、哪怕一点点的硬件进步上层软件都可以玩出花来。

MTU

许多网络中的MTU都是硬件去规范的。一般我们以太网的MTU是1500字节。还有一些诸如PPP链路的MTU都是人为配置的。一个IP数据报如果从某个接口送出时它的大小如果超过相应链路的MTU,那么就会对IP数据报进行分片。关于分片IPv4和IPv6的区别如下:

  1. IPv4的主机会对其产生的IP数据报进行分片;IPv4的路由器会对自己转发的IP数据报进行分片
  2. IPv6的主机会对其产生的IP数据报进行分片;IPv6的路由器不对其转发的数据报执行分片

我们看IPv4的首部有一个DF位里面就对当前数据报是否可以分片进行了设置,如果设置了不可以进行分片,则路由器不会对ip数据报进行分片。问题来了,你明明超过了MTU,还不让分片,那怎么办嘛?

很简单,报错:ICMPV4的出错消息:目的地不可达,需要进行分片但是DF位置已经设置不允许了

结合MSS

记得在将TCP的时候前文说过MSS的概念,它表示了告诉对方自己的最大分节大小,实际上大多数场景下,

我们的MSS = MTU - IP首部 - TCP首部的长度

这样的话在TCP层面的MSS设置好数之后,我们会发现我们ip层传输的数据一般都小于MTU,那么就可以在TCP层面尽可能的避免这种分片的情况出现。

TCP如何输出数据

上述数据报的大小、如何传输....我们都已经了解过了,下面我们来看一下具体某个进程写数据到一个TCP套接字的过程吧。

观察图片我们可以看见每个TCP套接字都有一个发送缓冲区,当进程使用了write函数的时候,内核会将进程缓冲区的数据全部写入套接字的发送缓冲区(此时是TCP这一层)。

有个问题:如果套接字发送缓冲区装不下怎么办?

答案:内核不从write的系统调用返回,直到全部写到套接字发送缓冲区为止

所以实际上,进程调用了write函数,如果返回数据了,只代表我们写到内核的套接字发送缓冲区了并不代表我们将数据发送到对端了

继续继续。。。。

我们的TCP会根据上述讲的TCP的规则去提取发送缓冲区的数据发送给对端的TCP,并且伴随着对端的ACK的送达,我们逐渐的抛弃掉发送缓冲区中已确认的数据

再去看IP层,TCP会把MSS大小的TCP分节发送给IP层,IP层给TCP分节装上IP首部构成IP数据报。并且根据IP地址查找路由表进行发送,或者根据数据链路层的限制进行数据分片。

小tips

数据链路层会维护一个输出队列,如果这个队列满了,那么新来的分组就会被丢弃,并且返回一个错误信息。该错误信息会从数据链路层返回到IP层再返回到TCP层,最终由TCP进行重发。此过程应用层是感知不到的。

小结

到此,TCP的概念上的东西讲完了,下一章将讲前文中的一些系统函数挖的坑,并且结合TCP的状态流转去明确每个函数在内核中的作用。

全部评论

(3) 回帖
加载中...
话题 回帖

近期热帖

近期精华帖

热门推荐