Linux -- 传输层协议TCP
TCP协议
TCP协议格式

TCP 报文段由两部分组成:TCP 报头和TCP 数据区(即实际要传输的应用层数据),TCP的报头长度,包括固定的部分20字节必须包含的字段,除了选项部分的字段,如源端口、目的端口、序号、确认号等。选项部分:0~40 字节(可选字段,如 MSS 协商、窗口缩放、时间戳等,常见为 4 字节的 MSS 选项)。
TCP 首部长度通过首部中的 “数据偏移”(Data Offset)字段标识,单位是 “32 位字”(4 字节)。
例如:
如果数据偏移为5,表示首部长度=4x5=20字节,说明此时只有固定部分。
如果数据偏移为6,表示首部长度=6 x4=24字节,说明此时选项部分也有数据。
数据偏移的最大值为15,即报头长度最大为60字节。
与UDP协议不同的是,TCP协议的数据报大小并不固定,需要在通信双方建立连接时相互通过MSS数据区最大长度确认。
MSS 并非随意设定,而是由底层网络的最大传输单元(MTU) 决定。
MTU 是 “数据链路层帧能携带的最大 IP 数据包长度”(含 IP 报头),不同网络的 MTU 默认值不同:
MSS 的计算逻辑:MSS = MTU - IP 报头长度 - TCP 报头长度
以太网默认场景:总长度 = 20(TCP 报头) + 1460(MSS) = 1480 字节(再加上 20 字节 IP 报头,总 IP 数据包长度为 1500 字节,恰好等于 MTU)
32位序号/32位确认号:将接受缓冲区的数组下标作为每一个字节对应的确认号,发送给对方时需要报头中需要带上,接收方回自动返回序号加1的确认号,发送方可以知道自己发送的消息成功到达了,这就是TCP的确认应答机制,待会我们会详细说。
6位标志位:
URG: 紧急指针是否有效ACK: 确认号是否有效PSH: 提⽰接收端应⽤程序⽴刻从TCP缓冲区把数据读⾛RST: 对⽅要求重新建⽴连接; 我们把携带RST标识的称为复位报⽂段SYN: 请求建⽴连接; 我们把携带SYN标识的称为同步报⽂段FIN: 通知对⽅, 本端要关闭了, 我们称携带FIN标识的为结束报⽂段
确认应答机制
系统在发送数据的时候不会将一个个字节单独发送,这样效率太慢了,而是将接受缓冲区中的一批数据一起发送,然后应答最后一个确认序号即可。当接收到1001的确认应答时,说明前面1-1000的报文都收到了。
超时重传机制
一个主机在给另一个主机发送数据时可能因为某些原因另外一个主机没有收到,也就没有返回确认应答,一段时间以后主机会再次重复上一次的数据直到另一个主机返回确认应答确认收到了数据。需要注意的是,如果接收方主机的确认应答在返回的时候丢包了,发送主机在一定时间接受不到也会进行重发,这就是为什么TCP协议是可靠的。因此接受方主机有可能会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉. 这时候我们可以利⽤前⾯提到的序列号, 可以很容易做到去重的效果,并且使报文可以按序到达。
需要注意的是,我们一般通信双方返回的确认应答可能是一个只有ACK标志位的报头,也可能在发送数据的时候将确认序号带上,这叫做捎带应答机制。
连接管理机制
正常情况下TCP需要通过三次握手建立连接,四次挥手断开连接。
三次握手连接:当客户端发起connet请求时会进入阻塞等待服务器应答,接着系统就会自动进行三次挥手。客户端发送标志位SYN的同步报⽂段,服务器收到以后会返回一个SYN和ACK标志位的报头,表示自己收到了你的连接请求,同时也要和你发起连接请求,客户端在收到以后会给服务端发出确认应答,此时客户端就认为自己已经建立好了连接,服务器端需要收ACK以后才能建立成功连接。需要注意的时,最后一个ACK有可能丢包,所以建立连接的过程是存在失败的可能性的。
四次挥手端开连接:双方都可以发起断开连接的请求,断开连接需要对方的同意,所以双方都需要发送FIN并且接受来自对方的ACK,才可以确定自己断开了连接。
服务端状态转化:
[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状态
我们有些时候在服务器关闭再马上重启以后会出现bind erro,这是因为虽然上一次服务器终止了,但是TCP协议层的连接没有完全断开,端口还被占用着,所以无法重新bind同一个端口。
我们可以做一个测试来说明这种情况,然后启动client,然后⽤Ctrl-C使server终⽌,这时⻢上再运⾏server, 结果是:

服务器在上一次关闭以后会与客户端断开连接然后进入FIN_WAIT2,然后经过一段时间的等待没有接受到来自客户端的断开请求,就会进入TIME_WAIT。此时服务器需要等待两个MSL(maximum segmentlifetime)的时间后才能回到CLOSED状态,在TIME_WAIT期间仍然不能再次监听同样的server端⼝。
想⼀想, 为什么是 TIME_WAIT 的时间是 2MSL ?
MSL 是 TCP 报⽂的最⼤⽣存时间, 因此 TIME_WAIT 持续存在 2MSL 的话就能保证在两个传输⽅向上的尚未被接收或迟到的报⽂段都已经消失(否则服务器⽴刻重启, 可能会收到来⾃⼀个进程的迟到的数据, 但是这种数据很可能是错误的);同时也是在理论上保证最后⼀个报⽂可靠到达(假设最后⼀个ACK丢失, 那么服务器会再重发⼀个FIN. 这时虽然客⼾端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK);
解决TIME_WAIT状态引起的bind失败的⽅法
在 server 的 TCP 连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的
服务器需要处理⾮常⼤量的客⼾端的连接(每个连接的⽣存时间可能很短, 但是每秒都有很⼤数量的客⼾端来请求).这个时候如果由服务器端主动关闭连接(⽐如某些客⼾端不活跃, 就需要被服务器端主动清理掉), 就会产⽣⼤量TIME_WAIT连接.由于我们的请求量很⼤, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占⽤⼀个通信五元组(源ip, 源端⼝, ⽬的ip, ⽬的端⼝, 协议). 其中服务器的ip和端⼝和协议是固定的. 如果新来的客⼾端连接的ip和端⼝号和TIME_WAIT占⽤的链接重复了, 就会出现问题.

理解 CLOSE_WAIT 状态
如果客户端在退出以后而服务器不退出,那么服务器就会进入CLOSE_WAIT。一个多线程的服务器出现大量的CLOSE_WAIT意味着出现了Bug,会导致系统的可用资源变少,系统也会越来越卡。原因就是服务器没有正确的关闭socket , 导致四次挥⼿没有正确完成. 这是⼀个 BUG . 只需要加上对应的 close 即可解决问题。
滑动窗口
在系统的收缓冲区中有一个以数组下标为起始的区域称为滑动窗口。一个是start,一个是end,end=start+滑动窗口大小,start是上一次的确认序号。
在系统中收发报文并不是一收一发的,而是⼀次发送多条数据, 就可以⼤的提⾼性能(其实是将多个段的等待时间重叠在⼀起了)。
窗⼝⼤⼩指的是⽆需等待确认应答⽽可以继续发送数据的最⼤值. 上图的窗⼝⼤⼩就是4000个字 节(四个段),即一次性可以发4000条报文。
发送前四个段的时候, 不需要等待任何ACK, 直接发送;
收到第⼀个ACK后, 滑动窗⼝向后移动, 继续发送第五个段的数据; 依次类推;
操作系统内核为了维护这个滑动窗⼝, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉,这里的删除可以是后来的数据直接覆盖。


流量控制
接收端将⾃⼰可以接收的缓冲区剩余空间⼤⼩放⼊ TCP ⾸部中的 "窗⼝⼤⼩" 字段, 通过ACK端通知发送端。窗⼝⼤⼩字段越⼤, 说明⽹络的吞吐量越⾼。接收端⼀旦发现⾃⼰的缓冲区快满了, 就会将窗⼝⼤⼩设置成⼀个更⼩的值通知给发送端。发送端接受到这个窗⼝之后, 就会减慢⾃⼰的发送速度。如果接收端缓冲区满了, 就会将窗⼝置为0; 这时发送⽅不再发送数据, 但是需要定期发送⼀个窗⼝探测数据段, 使接收端把窗⼝⼤⼩告诉发送端。发送放也会发送一个窗口探测来获取接收方的缓冲区是否刷新。

拥塞控制
TCP拥有了滑动窗口和流量控制已经能够高效可靠的传输数据了,可是在复杂的网络情况中,网络上可能有很多计算机同时发送了很多数据,此时就会导致网络比较拥堵,在不知道当前网络状态的情况下贸然发送大量数据可能会使网络更加的拥堵。
所以TCP引入了慢启动机制,先发少量的数据探路,摸清了网络拥堵情况,再决定按照多大的速度传输数据。
此处引⼊⼀个概念称为拥塞窗⼝发送开始的时候, 定义拥塞窗⼝⼤⼩为1;每次收到⼀个ACK应答, 拥塞窗⼝加1;每次发送数据包的时候, 将拥塞窗⼝和接收端主机反馈的窗⼝⼤⼩做⽐较, 取较⼩的值作为实际发送的窗⼝;
为了不增⻓的那么快, 因此不能使拥塞窗⼝单纯的加倍.此处引⼊⼀个叫做慢启动的阈值当拥塞窗⼝超过这个阈值的时候, 不再按照指数⽅式增⻓, ⽽是按照线性⽅式增⻓

延迟应答
假设接收端缓冲区为1M. ⼀次收到了500K的数据; 如果⽴刻应答, 返回的窗⼝就是500K;但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;在这种情况下, 接收端处理还远没有达到⾃⼰的极限, 即使窗⼝再放⼤⼀些, 也能处理过来;如果接收端稍微等⼀会再应答, ⽐如等待200ms再应答, 那么这个时候返回的窗⼝⼤⼩就是1M;
数量限制: 每隔N个包就应答⼀次;时间限制: 超过最⼤延迟时间就应答⼀次;
面向字节流
创建一个TCP的socket,同时会在内核中创建一个发送缓冲区和接受缓冲区。
1.调用write时,数据先会写入发送缓冲区
2.如果发送的字节数太长,会被拆分成多个TCP的数据包发出,这里利用的是滑动窗口和流量控制
3.如果发送的数据太短,就会在缓冲区中等待,等待缓冲区中长度差不多了,或者其他合适的时机就会发送出去
4.接受数据的时候,数据是从网卡的驱动程序到达内核的接受缓冲区
5.然后应用程序可以调用read从缓冲区读取数据
6.TCP的连接既有发送缓冲区也有接受缓冲区,那么对于这样的一个连接可以读数据也可以写数据,这样的概念我们称为全双工
由于有缓冲区的存在,TCP的程序读写不需要一一匹配:
写一百个字节数据时可以一次性写入,也可以多次调用write进行写入
读一百个字节的时候也是如此,可以一次性读取一百个字节,也可以多次读取
粘包问题
TCP异常情况
进程终止:进程终止会自动释放文件描述符,仍然可以发送FIN,与正常关闭没有区别
机器重启:与进程终止情况一致
机器掉电或者网线断开:接收端认为连接还在,一旦接收端有写入的操作,接收端发送链接已经不在了,就会进入reset。即使没有写入的操作,TCP内部也保活的定时器,会定时询问对方是否还在,如果对方没有回复即会把连接释放。