一、大致原理------全双工通信

所谓的全双工通信,说白了就是 TCP 协议规定了每个进程每建立一个 TCP 连接,就会被分配到两个缓冲区------接收缓冲区和发送缓冲区,如上图所示。 因为接收方和发送方都各有两个缓冲区,因此发送方的发送与发送方的接收互不影响;接收方的发送和接收方的接收也互不影响;所以在客户端发数据给服务器的同时,服务器也可以发数据给客户端。于是这个连接就可以同时双向传数据了,这就构成了全双工通信。
二、面向字节流 & 粘报问题
1、面向字节流
1.1. 什么是面向字节流
都说 TCP 是面向字节流的。这是什么意思呢?意思就是 TCP 是以字节为单位发送缓冲区中的数据的。举个例子,假设我要发一共 30 行的 http 请求正文(设共 240 个字节)。由网络通信的基础可知,在 http 的请求开头会被加一个 http 的报头(设加完后共 300 个字节);然后操作系统会把这个 http 报文传到运输层。但是 由于 TCP 是面向字节流的,因此从 TCP 的角度来看,在 TCP 缓冲区里的这个 HTTP 的报文并不是报文,而是一个 300 字节的数组;然后 TCP 就会基于不同的网络情况分若干次把这 300 个字节按序地发给服务器。而这样 TCP 每次发送的数据都不是一个完整的 HTTP 报文了。
因此,正是因为 TCP 是面向字节流的,TCP 只保证发送缓冲区里的内容被按序地、不丢失地发给对方,但不保证每次发送都是一个完整的上层的数据。
1.2. 面向字节流 VS 面向报文
其实我们可以这么理解:因为 TCP 是有发送缓冲区的,因此 TCP 可以把要发送和已发送的数据都存在这个缓冲区里。因此当新数据进来这个发送缓冲区后,TCP 并不用急着立刻把全部数据都发向对方,可以把数据分成好几份来发。但 UDP 就不同了,因为 UDP 没有发送缓冲区,所以一旦有数据进了运输层,UDP 因为没地方把这些数据存起来,于是就只能把这些数据直接打包成一份,一下子全发给对方了。
2、粘报问题
2.1. 什么是粘报问题
因为 TCP 是面向字节流的,所以 TCP 无法保证每次发送都可以发送完整的上层数据,所以 TCP 就会产生粘报问题。举个例子,当我要发送 4 个 http 请求(即 4 个 http 报文)时,TCP 有可能一次会给我发 0.5 个 http 报文过去;也有可能一次刚好发 1 个 http 报文过去;也有可能发 1.5 个 http 报文过去;而对于如何处理这 0.5 个和 1.5 个 http 报文的问题,就是粘报问题。
2.2. 粘报问题解决方法
1)http 报文与报文之间用特殊字符隔开
2)采用定长的 http 报文
3)采用定长的 http 报头 + http 报头的自描述字段
4)http 报头(不定长)使用自描述字段,http 报文与报文之间用特殊字符隔开
3、粘报问题解决方法
三、首部信息
TCP 报文结构
1、4 位首部长度(单位:4B)
指整个 TCP 首部的大小。但这一字段的值并不直接是 TCP 报头的大小,而是这一字段的值 x 4B 后才是报头的大小。
2、16 位窗口大小
指自己发送缓冲区的剩余空间大小。
3、序号
该机器自己的 TCP 发送缓冲区中要发送的那段字节中第一个字节的编号。
4、确认序号
期望收到对方的第几号报文。等于最近收到的报文序号 + 1,同时也表示了目前到 xxx 号为止的报文,都已确认收到。
5、6 个标记位(报文的 6 种类型)
5.1. ACK(Acknowledge)
该类型为确认报文。当 ACK 位为 1 时,那么 TCP 报头的确认号就是有效的;而如果 ACK 位为 0 时,那么 TCP 报头的确认号就是无效的。
5.2. RST(Reset)
当连接出现异常时,一旦接收到对方发来带有 RST 的报文后,就要重新进行三握手的连接。
举个例子,当三握手中最后的 ACK 报文传丢后,此时客户端认为连接已建立,但服务端因为没收到 ACK 报文,所以认为没有建立连接。而当客户端向服务器发数据后,服务器就会认为:我都没有建立连接,你发什么数据?于是就会对客户端发送 RST 报文,让客户端重新进行三握手。
5.3. SYN(synchronize)
当 SYN 位为 1 时,就代表这个报文是请求建立连接的报文。
5.4. FIN(Finish)
通知对方,本端要关闭了。换句话说,当自己不需要通信时,就会给对方发 FIN 型的报文。
5.5. URG(Urge)
如果 URG 为 1,那么就说明紧急指针有效。
5.6. PSH(Push)
当接收方的缓冲区满了之后,就会丢弃发送方的报文,导致发送方的报文发不出去,就一直堆在发送方的发送缓冲区里。因此,发送方就会发送 PSH 报文,催促接收方快把接收缓冲区读走,好让自己发数据。
三、TCP 有关策略
1、确认应答 ACK 机制
只要验证双向可传输即可,ACK 报文不用回应。因为 TCP 协议的目的就是为了双向传输的可靠性,所以只要验证双向可以通信就行了,并不用对是否收到 ACK 的问题作出回应。

2、超时重传机制(5000ms)
当发送方长时间都没收到回应报文,此时发送方就要考虑重传了。那么多长的时间才算长呢?由于每一刻的网络状态都不一样,因此这个超时的标准也是动态变化的。比如有时网络本来就不好,所以超时重传的时间就要设置得长一点;而有时网络状况很好,那么超时重传的设置就可以设置得短一点。因此,对于超时重传的时间设置,我们可以参考 RTT 的计算。但为了简单起见,我们可以认为超过 5000ms 就算超时了。
3、三握手与四挥手
3.1. 三握手

3.2. 四挥手

3.3. 为什么要三握手和四挥手(挥手:不会发数据)

3.3.1. 为什么要三握手
其实三握手并不是死规定,如果把三握手的 SYN + ACK 分成两次发的话就变成四握手四挥手了,不过是可行的。
而对于上面这个问题,其实原因有两个方面:TCP 的双缓冲区 & 奇数次握手。
a. TCP 双缓冲区
因为 TCP 是全双工的,而全双工的本质就是每个连接都有双缓冲区,然后通过网络传数据。因此,当客户端的发送缓冲区和服务器的接收缓冲区、客户端的接收缓冲区和服务器的发送缓冲区分别建立连接后,双方才能进行 TCP 通信。而让双方的缓冲区建立连接所需的最少次数本来是 4 次握手的,但我们可以仔细想一下,服务器就是用来给客户端通信的,所以客户端的连接请求服务器必须同意,所以服务器对客户端的 ACK 报文和自己的 SYN 报文可以合成一个来发给客户端,所以最少握手次数就变成 3 次。
b. 奇数次握手
如果 TCP 采用一次握手
我们可以从 1 次握手开始研究。当 TCP 只要一次握手就可以建立连接时,就会产生一个问题。即使客户端已经内存不足,无法建立连接了;但就算客户端什么都不做,就只发 SYN,只要客户端每发一次 SYN,服务器就会直接新建一个连接。而当客户端发起很多次 SYN 后,服务器就会建立很多个多余的连接,进而就会导致服务器内存很容易就会被用完(这个现象称为"SYN洪水")。
如果 TCP 采用二次握手
让我们再看看 2 次握手。由于客户端是收到服务器发来的 ACK 后才认为建立了新连接,才会建立缓冲区;而服务器只要收到客户端发来的 SYN 就会建立连接。而这就会引发一个问题:当 ACK 传丢后,此时客户端没有建立连接;但服务器因为收到了客户端的 SYN 而建立了连接,且这个连接是空闲的。因此一旦 ACK 传丢后或客户端发了 SYN 崩了之后,多余的连接就会挂在服务端,而如果服务端有很多客户端访问的话,就会导致服务器因为内存不足而崩掉。所以,通过这个分析,我们可知如果握手奇数次,可以确保在一般情况下握手失败的连接成本是由客户端承担的。
3.3.2. 为什么要四挥手
我们知道,客户端给服务器发 FIN 的意思是关闭从客户端到服务端的通信,但并没有关闭服务端到客户端的通信。因此如果要关闭服务端到客户端的通信,那么服务端也要给客户端发一次 FIN。
但是,你或许会问:既然客户端到服务端的通信被关了,为什么后面还可以给服务器的 FIN 发 ACK 呢?其实客户端到服务端的通信被关的意思是客户端不给服务端发报文的数据部分而已,但像 ACK 这种报文属于管理报文,是只有报头没有数据的,因此是可以发的。
3.4. 肉机(了解)
但是采用三握手就一定安全了吗?其实不是的。如果一个黑客用一台电脑去控制其他多台电脑,让其他多台电脑称为那一台电脑的"傀儡"。然后黑客就可以用一台电脑给这些"傀儡"发任务,让这些"傀儡"不断地给服务器发 SYN,建立连接,那么只要"傀儡"的数量足够多,发出的 SYN 足够多,那么就会让服务器建立很多的连接,最后导致服务器瘫痪。而这些"傀儡"电脑就叫"肉机"。

4、流量控制
我们知道,由于接收方接收数据的能力是时时刻刻在变化的,且为了防止接收方的接收缓冲区里的数据被覆盖,发送方的发数据的速度也要做一定的调整;而发送方调整发送数据速度这一行为就叫做流量控制。
那么发送方如何调整发送速度的呢?**其实就是根据对方发来的报文里的 "16 位窗口大小" 字段来调整自己发送窗口的大小。**因为对方发来的"16 位窗口大小" 字段表示了对方的接收缓冲区的剩余空间大小。
5、滑动窗口
5.1. 什么是滑动窗口
因为 TCP 保证了数据传输的可靠性,因此面对数据在网络中的丢包问题,TCP 的发送缓冲区必须保存数据一段时间,一旦数据丢失了,就要重发这个数据。因此,TCP 的发送缓冲区中的数据总共有 4 种状态:已发送已确认、已发送未确认、允许发送但未确认、不允许发送。而为了给数据分成 4 类,TCP 用 3 个指针把发送缓冲区分成 4 个区间。

5.2. 如何通过滑动窗口发数据
假设数据不会丢失。由上图所示,区间 是可以发送数据的区间。当这一区间的数据发送后,就会收到对应的 ACK 报文(其报头携带对方的接收窗口大小);然后 start 指针会移到 end 处;而 end = start + 对方接收缓冲区的大小;同时
指针也会移到已确认序号的后一个下标。
5.3. 快重传
当发送方连续收到 3 个同样的 ACK 报文,start 指针就会立马移到确认序号处,同时 end = start + 对方接收缓冲区的大小;然后马上重传相应的报文。具体例子如下图:

当然,有了快重传机制,传统的超时重传机制并不应该被丢弃。因为假如要发的就只有 2 个报文,那么快重传就不管用了。于是如果这两个报文丢了,发送方就会在过了一定时间后重新向对方发这两个报文。
5.5. 再谈滑动窗口
你或许会问:如果对方接收缓冲区空间无限大,既然 end = start + 对方接收缓冲区的大小,那么发送方的滑动窗口里的 end 不就越界了吗?其实不会的。其实滑动窗口虽然底层是一个数组,但 TCP 是把它看作一个环形数组来实现的,因此是不会越界的。即其实 end = (start + 对方接收缓冲区的大小) % len,所以不用担心哈。
同时,为了照顾到对方接收缓冲区的大小和网络的拥塞控制,发送窗口的大小 = min(拥塞窗口大小, 对方接收缓冲区的大小)。
5.6. 再谈报文序号
其实报文序号并不一定等于整个报文在缓冲区中的最小下标。那么报文序号是怎么得出的呢?其实就是先取发送方和接收方在进行前两次握手的报文里的随机值的最小值,然后在用下标 + 这个最小值,就是报文序号了。举个例子,在前两次握手中,客户端产生的随机值是 124,服务器产生的随机值是 167,那么两者随机值的最小值就是 124;因此报文序号 = 124 + 报文在缓冲区的下标。因此确认序号 = 124 + 下次发送的报文在缓冲区中的下标。
6、拥塞控制
假设对方接收缓冲区为无限大,此时拥塞窗口大小的变化
6.1. 慢启动
6.1.1. 什么是慢启动
TCP 缓冲区的发送窗口最开始大小被规定为 1。即第一次只能发一个字节。
6.1.2. 关于慢启动阈值------ssthresh
由上图可以看出,当发送方遇到超时重传的情况后,ssthresh 的值就会变为原来的 。那么你或许会问:ssthresh 的初始值又是如何确定的呢?其实 ssthresh 的初始值是由系统自身决定的,windows,MacOS 和 LINUX 的 ssthresh 都是不一样的。
6.2. 拥塞窗口
正常来说,当发送窗口(即拥塞窗口)大小达到 ssthresh 后,就会进入拥塞避免状态------每次发送窗口大小只加一;而如果发送窗口的大小小于 ssthresh,每次发送窗口大小会变为上一次的 2 倍;而如果遇到超时重传的情况,发送窗口的大小就又会重新变为 1.
7、捎带应答
可以参考 TCP 的第二次握手,其实是可以先发 ACK,再发 SYN 的。但因为 ACK 类型的报文是可以额外携带其他信息的,因此可以把 ACK 和 SYN 报文合在一起发。这种合在一起发的就叫捎带应答,可以提升通信效率。
8、延迟应答
由滑动窗口可知,如果发送窗口越大,那么一次发送的数据就越多,因此数据的发送效率就越大。而延迟应答本质上就是 TCP 为了提高效率的一种应答策略。
当接收方收到发送方发来的报文后,发现此时如果在短时间内运行完当前任务后剩下的接收缓冲区大小远大于立即应答时,就会故意延迟一段时间把任务运行完,腾出更多的接收缓冲区空间,再向发送方发出 ACK 报文。
四、状态转换

1、全连接 & 半连接队列
1.0. 先描述,再组织
事实证明,对于一个服务器,肯定会被多个客户端连接。那么既然有那么多的连接,这个服务器要不要对这些连接做管理呢?答案是要的。那怎么管理呢?对连接先描述再组织。因此在服务器,肯定会有一个个 struct connect 的结构体来描述一个个连接,然后再通过某种数据结构把这些 struct connect 连接起来,而这个数据结构就是链表。
1.1. 全连接
处于 established 状态的连接就是全连接。 对于处于全连接状态的连接,服务器会给这些连接用链表维护起来;然后这些连接就等着被 accept 函数拿到应用层。因此我们可以得出:连接的形成只是操作系统内核的事情,与 accept 函数无关, accept 函数只是用来把连接拿到应用层的工具罢了;也就是说,就算我们在代码中不调用 accept 函数,客户端与服务器之间照样能够建立 TCP 连接。 但我们知道,全连接队列的节点数是不可能无限多的,肯定会有个限度,而这个限度就是 listen 函数的 。所以,全连接队列的最大长度就等于
.
全连接队列与 accept 函数的关系
1.2. 半连接
处于 SYN_RECV 状态的连接就是半连接。对处于半连接状态的连接,服务器也会给这些连接用链表维护起来。当全连接队列的节点没那么满之后,才会从半连接队列选一些节点追加到全连接队列里。到了这里,你或许会好奇,什么情况会让连接处于 SYN_RECV 呢?其实就是第三次握手失败(即服务器没有接收到 ACK 的这段时间里)就会使连接处于 SYN_RECV。而使服务器收不到 ACK 的其中一个方式就是当全连接队列满之后,如果还有新连接要建立,那么这个连接就只能加入半连接队列,同时服务器会自动丢掉该连接的第三次握手(ACK)。不过当然,服务器并不会长时间维护半连接队列的节点。

从半连接我们可以得出一个结论。因为在收到第三次握手之前,服务器肯定有一段时间是处于已收到 SYN 但没收到 ACK 的状态的;因此任何 TCP 连接都要先经过半连接再到全连接。
1.3. 为什么全连接队列不能太长又不能没有?
首先全连接队列为什么不能太长呢?如果全连接队列太长的话,就会使得剩下给服务器处理连接的内存就变少,那么服务器处理连接的速度就会变得非常慢。
但为什么不能没有全连接队列呢?如果没有全连接队列,把全部内存给服务器处理连接用,那服务器不就可以变快了吗?但是我们可以想想,如果没有了全连接队列,那么在服务器处理连接时,就没有连接排队等着被 accept 了;所以当服务器处理完连接后,不能立刻处理下一个连接,取而代之的是还要花时间去等新连接的到来,而等新连接的过程会使服务器大幅空闲,因此就会导致服务器没被充分利用。
2、关于 TIME_WAIT 状态
2.1. TIME_WAIT 状态介绍
当我们在测试服务器代码时,对服务器进程 ctrl+c 后,再重启服务器,操作系统就会说此端口无法连接,但如果我们等个 1~2 分钟之后,又可以重新连接了。这是为什么呢?由 TCP 的状态图可知,对服务器 ctrl+c 后,由于服务器是主动退出连接的一方,因此服务器的状态变化就是左侧那列的状态变化,而客户端就对应了右边那列的状态变化;此时服务器由 FIN_WAIT1 转为 FIN_WAIT2 状态;然后因为客户端读不到数据,所以客户端关闭 sockfd 文件,并向服务器发送 FIN 报文;然后服务器转为 TIME_WAIT 状态。此时服务器虽然不运行了,但并没有完全退出,因此它的套接字还处于使用状态。因此如果你用同一个端口重连,那肯定报错啊,因为一个端口只能对应一个进程。
那怎么解决这个问题呢?我们可以参考一下这个函数:
cpp
int opt = 1;
setsockopt(_listensock, SO_SOCKET, SO_REUSEADDR | SO_REUSEPORT,
&opt, sizeof(opt));
2.2. TIME_WAIT 要等多久呢
TCP 规定,TIME_WAIT要等 MSL(报文最大生存时间)的两倍。这是为了让历史报文在网络中消散;同时有了这个等待时间,如果在第三次挥手中的 FIN 报文丢失后,由于服务器还没关闭,还可以接受客户端 FIN 报文的重传;否则当服务器关闭后,客户端就要继续维护服务器这个多余的连接了。