一.TCP首部
TCP首部结构如下图所示:
TCP协议中用序号来标识每一个字节,连接的每一端都维护着自己的序号,起始序号是主机选择的,称为ISN(随时间变化,每个连接都具有不同的ISN)。除每个数据字节消耗序号外,SYN和FIN也会消耗序号。确认序号用于表示发送确认的一端所期望收到的下一个序号,即以成功接收的最后一个字节的序号加1,要注意TCP从不会选择确认(即不会间隔确认),比如,先成功收到1~1024字节,可下一个报文段为2048 ~ 3072字节,此时接收端并不会回复确认序号为3073的ack,而是回复确认序号为1025的ack。也不会进行否认,即不否认错误数据,只回复最后一个成功接收字节之后的一个字节的序号,比如:先成功收到1~1024字节,接着收到序号为1025~2048字节的报文段,可是其检验和错误,此时TCP仍之回复确认序号为1025的ack。关于其它的一些字段将在后面提到时说明。
二.TCP连接的建立
1.正常情况下的连接建立(三次握手)
三次握手的过程是:客户端先发送SYN,服务器端收到后回复SYN + ACK(即一个报文段中的SYN和ACK标志都置位,且序号和确认序号都有效),当客户端收到来自服务器的SYN+ACK后进行最后确认,回复一个ACK,当服务器接收到后则三次握手完成。下面给出一个示意图以及对应的tcpdunm程序输出(之后将讲解tcpdump命令的使用)。
2.MSS最大报文长度
MSS表示TCP可以传往另一端的最大数据块长度,这个值是根据外出接口上的MTU(最大传输单元)减去固定的IP首部和TCP首部长度得出的。当建立一个连接时,都要在SYN报文中(也只能在SYN报文中)通告自己的MSS值,一般双方都会选择较小的MSS值。
3.同时打开(每端都进行两次握手)
当每一方都使用对方所熟知的端口作为本地端口,且彼此同时执行主动打开,此时便会发生同时打开的情况。对于同时打开仅建立一条连接,而非两条连接。其过程如下图所示:
【注】:TCP连接是全双工的,当连接建立后,其实两端(两个socket)是对等的,也就是说对于一个TCP连接的两端而言,事实上是没有服务器与客户端之别的,我们常说的服务端客户端其实是根据应用的功能而言的。理解了这一点也就能理解其实那一边发起主动连接都是可行的,也是符合TCP逻辑的(只是不一定符合应用的逻辑)。
4.连接建立超时
当发送第一个SYN分节后若在6秒(不一定准确)后还未收到对端的ACK回复,则会发送第二个SYN分节,若第二个分节发送后的24秒内还未收到回复,则继续发送第三个SYN,之后一直以24秒循环。当距第一个SYN分组发送后的75秒内仍未收到ACK回复,则放弃连接。
【注】:当使用非阻塞套接字时也应该自己实现相应的重连策略,muduo网络库中便采用了这种连接策略
三.TCP连接的终止
1.连接的正常终止(四次挥手)
TCP连接是全双工的,因此每一方的杜凯因应该可以独立的进行。TCP也正是这样做的,当一方想要断开时,会进行两次挥手来断开己方至对方的连接,即己方此时无法向对方发送数据,但此时仍可接收(使用shutdown函数关闭写便是通过两次回收这个原理实现的,在说明完四次挥手后接着讨论shutdown函数)。其实我们要是明白了四次挥手是由两次挥手组成的便了解了四次挥手的含义,四次挥手过程的示意图如下所示:
任意一端都可以先进行主动断开,所以对于上图无需在意哪一端是客户端,哪一端是服务端。
2.shutdown系统调用(半关闭)
我们都知道shutdown可以选择关闭写于关闭读,且在关闭写时会等待将套接字缓存中的数据全部发送完毕之后,再发送终止序列FIN。进行上一小节所说的两次挥手,断开己方到对方的通道,即不能向对方发送数据。
那么关闭读是怎么做到的呢?难道是发出某种消息让对面进行主动关闭?显然这么做是不好的。TCP采用了下面的做法,当我们调用shutdown关闭读时,会转而调用一个名为sorflush的函数,该函数会在socket中的状态字段上加上SS_CANTRCVMORE位(so->so_state |= SS_CANTRCVMORE),该标志位表示插口不能再从对方接收数据(即TCP协议层不会再将下层传入的数据放入该socket的接收缓存了)。随后清空套接字接收缓存中的所有现有数据。至此关闭读成功。
此外还要注意的一点是shutdown可以不管引用计数就启动TCP的正常终止序列。而close仅当该套接字对应的套接字描述符的引用计数变为0(UNP上写的是“把描述符的引用计数减一”,我的理解是在文件对象结构中记录的引用计数,该引用计数记录的是引用该文件对象的描述符的数量,文件对象与文件描述符的关系可以参见博文《TCP/IP实现(一) 系统文件结构及mbuf》)时才会关闭该套接字。
3.同时关闭(同时进行的两次挥手)
同时关闭依然各端各自进行两次挥手,不过不是先后进行,而是同时进行。其步骤如下图所示,要注意其状态变化不同于四次挥手。
4.异常终止
1)出现错误时的异常终止
TCP首部中的RST用于进行复位操作,在之后会解释复位。无论何时一个报文段发往由四元组指明的连接出现错误,TCP都会发出一个RST,比如:socket读缓冲仍有未读数据,但却调用了close,则将发送RST分节,端口不可达错误等。对于端口不可达,UDP会产生一个ICMP端口不可达消息,TCP会使用复位。测试如下:一个TCP客户端向本地服务器请求连接,但服务器位运行,tcpdump结果如下:
可以看到TCP回复了一个RST分节。
若客户发出的SYN在中间某个路由器上便引发了一个“destination unreachable”(目的地不可达)ICMP错误,则客户主机会在内核中保存该消息,并继续以间隔的方式发送SYN(同超时处理),在规定时间内(75s)若还未收到,则将保存的消息返回给进程(EHOSTUNREACH或ENETUNREACH)。
当收到RST分组后不会回复任何响应,收到RST的一方将终止连接,并通知应用层。
2)主动异常终止
除了前面提到的发送FIN进行正常释放连接外,还可以主动发送RST进行异常释放。异常释放可以不必等待套接字发送缓存中的数据全部发送完毕,而是丢弃任何待发送的数据并立即发送RST报文段(使用SO_LINGER选项)。RST的接收端可以区分除对方执行的是正常关闭还是异常关闭。
5.close系统调用(SO_LINGER延迟关闭)
当调用close函数时,如前所说会查看套接字描述符所对应文件对象中的引用计数,当该文件描述符是引用该文件对象的最后一个描述符时才会执行相应的关闭操作。默认情况下,close函数会将该套接字标记为关闭(内核中会断开socket与协议的关联,并将该socket标记为与任何描述符无关so->so_state |= SS_NOFDREF,随后释放sockt结构),并立即返回,之后进程便不能通过该套接字描述符便不可使用。
但是我们可以通过setsockopt函数设置SO_LINGER选项来改变这种默认行为。SO_LINGER选项的参数为一个结构体:
struct linger{
int l_onoff; // 该位表示是否关闭本选项,为0则关闭,并忽略l_linger的值
int l_linger; // 该选项为延时时间,只有在l_onoff为1时有效
};
该情况分为以下三种:
1.关闭该选项,即l_onoff为0,l_linger的值是被忽略的。此时close函数进行的是默认情况下的关闭,即close立即返回(此时关闭连接并不一定已经完成)、
2.开启该选项(即l_onoff为1)
1)l_linger值为0
此时close函数将丢弃套接字发送缓存中的所有数据,并发送RST报文。
2)l_linger值为非0
当调用close时内核可能将会延时一段时间后再退出。即当套接字发送缓存中仍有数据,则 将进程投入睡眠,直到数据发送完毕并被确认或者延时时间到才返回(但若套接字为非阻塞则不会投入睡眠,并立即返回EWOULDBLOCK(EAGAIN))。使用SO_LINGER选项时应该检查close的返回值,因为当数据未发送完毕而延时时间到才返回时会返回EWOULDBLOCK(EAGAIN),并且丢弃套接字发送缓存中的全部数据,此时应该再次。
【注】:对端回复对ACK只能说明对端以将该数据放入了套接字接收缓存,但不能保证应用层进行了读取,若要确认需要应用层确认机制。
四.交互式数据与Nagle算法
1.交互式数据与延时确认
交互式数据指的是那些数据长度较小的数据报(不能将一个TCP报文填满)。而对这些每一份很小的数据报,都需要两个报文来完成(一个数据报文及一个确认报文)。因此TCP采用延时确认的方式来进行ACK的回复,即当数据收到时不立即回复ACK,而是等待一定的时间,以便可以与需要延该方向发送的数据一同发送,或是有更新的数据到来,从而发送更新确认序号的ACK。TCP采用最大200ms的时延等待。
2.Nagle算法
当网络中出现大量小分组时会降低网络利用率,造成网络拥塞。对此可以开启Nagle算法,该算法要求:在一个TCP连接上最多只能有一个未被确认的未完成的小分组,在该分组的确认到达前不能发送其它小分组。这样在等待确认的这段时间便可以收集更多的小分组进行发送。单对于那些要求低时延的系统我们应该关闭Nagle算法(默认是开启的)。
五.滑动窗口(通告窗口)
每端都会在发送至对方的数据报中包含本端当前的窗口大小。在建立连接时,先会将己方的窗口大小告知对方,这个大小的默认值对不同系统而言会有所不同,如:4096,2048等。滑动窗口的大小在数据传送过程中会不断变化,但不会超过最大大小(即对端的默认大小或对端使用插口API设置的大小)。滑动窗口大小在数据的交互过程中变化过程如下所述。
当发送端收到对端对一段数据的ACK确认报文中后,说明对端已成功接收了滑动窗口前一部分的数据,此时便可以将滑动窗口的左边沿向右移动。并且从收到的ACK确认报文中可以知道对端此时的接收窗口大小,从而可以进一步确认出滑动窗口的右边沿(左边沿+被通告的接收窗口大小)。
【注】:综上可知通告窗口是接收方进行的流量控制
六.超时与重传机制
1.采用往返测量与指数退避的重传时间(RTO)
当一个数据报(不是SYN,连接超时已在前面的小节进行了说明)在发送之后的一段时间内若仍未收到对端的确认,则认为该数据报未被对端正确接收,此时会重新发送一个数据报(数据大小不一定相同),而该段等待确认的时间就被称为超时时间。超时重传时间RTO的初始值是由TCP跟踪数据报的往返时间RTT,再带入公式计算出来的,由于路由器和网络流量均会变化,因此该时间值也会不断变化。
若进行重传后还未收到则将重传时间进行指数增加,即每次在前一次超时重传时间的基础上乘2,这个过程便称为指数退避,当当超时时间增加到一定限度后将不再增加,而是一直保持这个值,比如64秒。当一定时间之后TCP将会放弃重传,并向对端发送一个RST报文,这个时间一般为9分钟。
【注】:大多TCP实现在任意时刻对每个连接只启动一个定时器计算RTT,即在发送一个报文段时,如果该连接的定时器已经被使用,则不测量该报文段的RTT
七.拥塞窗口及是相关算法
1.慢启动
慢启动算法用于在连接成功后快速探测网络容量,初始时将拥塞窗口大小置位1,从后每收到一个ACK确认报文就将拥塞窗口加1,即以指数方式增长:第一次发送1个报文,然后2个,4,8......。直到超过慢启动门限值ssthresh后启动后面所说的拥塞避免算法。
2.拥塞避免算法
当拥塞窗口cwnd大小超过慢启动门限后就将其增长速度用拥塞算法中的公式进行计算,其增长速度由指数增长将为线性增加。此外拥塞算法还会在链路中出现拥塞状态时调整cwnd窗口大小,和慢启动门限。其分为以下两种情况:
1)收到重复ACK报文(3个以上)
此时认为网络中可能发生了数据报的丢失,但网络拥塞并未十分严重。因此将慢启动门限ssthresh设置为当前拥塞窗口的一半,拥塞窗口设置为原先大小的一般并加上3个报文大小。接着重传丢失的报文段(因为连着收到3次以上的重复确认报文,确认序号之后的那些已发数据可能已经丢失,也有可能是由于报文段乱序到达导致的,但TCP进行了相应的处理,即延时回复ACK,所有当收到3个以上确认报文时很可能是报文发生了丢失)。当收到重传报文的确认时将设置cwnd为ssthresh,重新进行拥塞避免算法的增长。以上步骤也可称为快速重传与快速回复机制,当发生3个以上的重复报文时便认为丢失,不必等超时重传(快速重传)。因为并未发生超时,因此网络状况或许不太糟糕,便不进行慢启动算法,而是直接进行拥塞避免算法(快速恢复)。
2)超时引发的拥塞
此时发生超时重传,网路中拥塞状况可能比较严重,因此,将sstreach置为当前窗口(拥塞窗口与通告窗口的较小者)的一半(最小为2个报文大小,以MSS为单位),将cwnd置为一个报文段(以mss计算)。之后进行慢启动,到达sstreach后开始拥塞避免。
【注】:拥塞窗口及相关算法用于在发送端进行流量控制。
八.坚持定时器
1.定期查询
当对端通搞窗口变为0时将停止向其发送数据,当对端的进程将数据从缓冲队列读走后,此时对端将发送新的ack报文来通知本端通告窗口的大小。可是若该ack丢失了呢?那么通信双方岂不是陷入了死锁。对此,TCP中设置了一个坚定定时器,当收到通告窗口为0时将引起客户端设置其坚定定时器,使发送方定期向对端发送一个字节的数据进行查询,以便发现窗口是否增大。
2.糊涂窗口综合症
对端通告窗口大小时可能很小,导致发送端发送一个很小的数据,如此往复下去。两端可以采取了一些措施来避免,比如:接收方对通告窗口的大小做一些限制条件;发送方对发送数据的大小也可以采用一些限制条件(不知道TCP中是否如此做了)。
九.TCP保活定时器
当两端不进行任何数据交换时,若中间的网络出现了故障(如路由损坏,网线断开等),则TCP的两端不会知道,且两端将一直处于连接状态,对此可设置TCP保活定时器(默认不启动)。当连接两端在2个小时之内都没有任何动作,则会向对端发送一个探查报文,若这2个小时内两端有进行通信则保活定时器会重新复位。若在发出探查报文后的75秒内未收到任何响应,则会继续发送,总共发送10条。若都未响应则终止连接。此外,保活定时器的间隔时间是可以改变的,可其是系统级的(windows在注册表中修改),因此会影响到所有的连接。保活探查报文是恢复一个不正确序号的报文(即是对端希望收到的下一个字节序号减1),这样会导致对端回应一个正确的ack报文(确认序号为下一个希望收到的字节序号)。