目录

  • 前言
  • 正文
  • time_wait和rst
  • fin与连接关闭
  • nagel和ack延迟算法
  • 滑动窗口与拥塞控制
  • 文末
  • 总结
  • 测试代码

前言

  网上已经有大量关于tcp的文章,感觉作为一名技术人员,不写一篇tcp相关的文章,对职业生涯来说是一种遗憾,但是又不想单纯造一篇轮子,带着这种矛盾的心态,一拖再拖,最后还是造了一篇轮子。本文不适合完全的小白,适合有一定开发经验的初级开发。

  文中一,二两节主要对rst(异常情况)及fin(关闭信号)及相关的一些socket操作,以及各种情况时,调用相关的函数会得到什么返回值等进行了阐述,连接建立成功的方式只有一种,关闭的方式却很多样,这对我们进行socket编程时还是有帮助的。

  三四两节的内容属于老生常谈的话题,但是也加入了一些自己所学过程中的思考,对一些问题进行了追根究底。最后,文末有测试时使用的测试代码,建议读者阅读到相应内容时,简单看下测试代码,再亲自操作一发,看看各种状况下的报文沟通情况。写这篇文章时使用的测试环境:

  内核版本:Linux version 3.13.0-32-generic

  gcc版本:4.6.3

正文

time_wait和rst

  time_wait不必多说,正常情况下,主动关闭连接的一方会进入长达2MSL的time_wait状态,即端口一直被占着,因此过多的此状态可能面临端口耗尽无法主动建立新连接的窘境。

  上面有一点值得注意:可能面临端口耗尽和无法主动建立新连接。被动建立连接无须再分配端口号,主动发起连接的一方才需要分配端口号,因此由time_wait引起的端口耗尽问题只会发生在主动建立连接和主动关闭的那一端。

  MSL:最大报文生存时间。其实数据报并没有生存时间这一概念,而是有一个8bit(IP首部的TTL字段)的跳数限制,即最大跳数为255,每经过一个ip节点就减1,减到0的时候就丢弃掉了,即MSL是一个假设时间:具有最大跳数的数据包在网络中存在的时间不会超过此值。实际上MSL是可以进行设置的,当然也就可能存在超过设置的MSL值的数据报。

  关于time_wait状态存在的必要性:

  1 为了保证全双工这一点,如果没有此状态,那么被动关闭一方调用close函数,发来fin报文的时候,此时主动关闭方将会回复rst报文(这种状况在设置了so_linger选项的时候是可能出现的,稍后将讨论),而rst报文是以连接出错进行处理。

  2 为了保证可靠这一点,设想一种情况,假设不存在该状态,当连接关闭后,通信两端恰好又建立了一条同样的连接(即tcp四元组相同),而上条连接恰好出现了重传报文,那么老连接的报文是可能传输到新连接上的。

  不过time_wait状态的确是可以避免掉的,通过设置socket的SO_LINGER选项,我们看一下设置该选项时需要传递的linger结构体

struct linger {
int l_onoff; /*0 off,nonzero on*/
int l_linger; /*linger time,seconds*/
};

  见名思意,分别是设置开关和延滞时间,开关关闭时走正常的四次挥手流程。打开时,l_linger的值有0和非0(设置负数的时候会如何呢?有兴趣的读者可以拿测试代码试一下),下面分别讨论设置0和大于0时的情况

设置0时

  调用close函数(如果说发送缓冲区有数据的话,将直接丢弃掉,那么接收缓冲区有数据呢?在下面关于延伸部分会讨论到这种情况),将直接发送rst报文给对端,对端调用read函数时,将返回错误。

大于0时

  大于0,调用close函数将会阻塞住(如果是非阻塞socket的话,还是会直接返回),直到缓冲区数据和调用close函数发送的fin均被确认,或者延滞时间到才返回。当发送缓冲区数据及fin均被确认时,对端也可以正常的关闭连接,但是如果延滞时间先到,还有数据没被确认呢?因为对于本端来说连接是非正常关闭的,因为这种情况相当于丢了部分(甚至全部)数据(包括fin),此时对端进行关闭,发送fin报文过来,将回复rst报文。

  说到底,就是留了这么点时间去处理后事。没处理完就直接rst了事。

关于延伸

  我们在上面提出了一个问题,调用了close函数,如果接收缓冲区还有数据时,会发生什么呢?这种情况下,不论有没有设置SO_LINGER选项,都将直接发送rst报文给对端,直接关闭掉该连接。另一种情况,如果程序异常终止,没来得及调用close函数,会怎样呢?答案是内核会帮我们给对端回复一个rst报文,可以通过测试代码,取消掉客户端程序的getchar()函数的注释,在程序阻塞的时候kill掉进程,通过tcpdump查看详情。

  当然还有一种情况,如果客户端或者服务端死机了呢?或者中间某个必经节点断网了呢?所谓的全双工可靠连接,只是逻辑上的全双工可靠连接而已,并没有物理上的,因此这种情况是感知不到的,通过设置SO_KEEPALIVE选项可以检测出这种情况,设置该选项后,tcp将定期发送一些探测报文,如果发现对方已经无响应了,将会关闭该连接。这种情况当然也可以通过应用层来处理,比如向对端发送数据,如果响应超时则关闭掉该连接。

  小结一下,要避免掉time_wait状态,只能走非正常关闭的方式了,如一言不合就丢一个rst报文。值得一提的是,不管是正常关闭(即通过fin报文来关闭),还是非正常关闭(通过rst报文来关闭),都能通过select,poll,epoll(经测试,收到fin或者rst时,epoll的写事件也能触发,包括边缘触发的条件下。select,poll未测试)的读事件检测到,从而唤醒进程,通过read函数识别出来。

  等等,又有一个新问题,对端正常关闭时能通过write函数检测出来吗?老实说其实也能,不过方式很极端,需要调用两次write函数,这时候第二次调用将会收到内核发送的信号:SIGPIPE,然而该信号的默认处理方式是退出进程,因此还需要忽略掉该信号,此时第二次write调用将会返回-1,errno置为32,Broken pipe。

  那么非正常关闭时,write函数能检测出来吗?答案当然是可以。当tcp收到rst报文时,此时调用write函数,将返回-1,并将errno置为104,Connection reset by peer,如果收到-1还调用write的话,那么将收到信号SIGPIPE。

  也就是说对于一个已关闭的连接(不管是正常关闭,还是非正常关闭)连续调用两次send或者write,第一次调用时程序还是安全的(如果是正常关闭,即通过FIN关闭,那么返回值是发送的数据的大小,如果是非常关闭,即通过RST关闭,那么返回值是-1),在第二次调用时将收到信号SIGPIPE,默认处理是进程退出。

  这些都可以通过对测试程序小修小补之后测试出来。

fin与连接关闭

  这一节写的有点乱,单独拎出来,一部分原因是我在网上看到过不少“通过md5加密”这种话,这给人的感觉仿佛md5是加密算法一般。另一部分是希望以此能建立一种理解:

  1 linux内核帮我们实现了协议栈,以及是它的执行者,socket编程时,我们的所有操作都会落实到协议栈中去(这像是句废话)

  2 是希望能跳出来看tcp协议的东西,而不是:哦,应该这么做,这就是全双工通信之类。而是思考全双工可靠连接会遇到什么问题,tcp是这样解决的。

  最后是因为见过太多次如下这张图(用字符拼出来的),这的确是常规的关闭连接时的报文交换情况,但是这很容易让人的思维形成一种惯性。

主动关闭方                         被动关闭方
| Fin |
fin_wait1| --------------------> |
| Ack |close_wait
fin_wait2| <-------------------- |
| |
| Fin |
| <-------------------- |last_ack
time_wait| Ack |
| --------------------> |closed

  ok,先说说close函数,对某个socket调用close函数后,此时如果再对它调用read或者write函数都将返回错误。正常情况下,调用close函数后,将进入四次挥手的流程,四次挥手靠的就是fin标志位来起作用,这一点可能会给我们一种误解,当发送了fin报文之后,连接就不可用了,因为我们不可再调用read或者write函数了。但其实调用close之后,只是内核会将该socket标记为已关闭,所以我们的read/write操作才会返回错误(Bad file descriptor)。

  虽然用户态的程序没法操作了,不过内核还会继续工作,此时如果发送缓冲区还有数据的话,将会把它们发送出去,待数据都被确认后进入四次挥手流程,这一切操作都是内核帮我们完成的。

  接下来看一个我们用的没close多的函数shutdown:

/*
*sockfd:要操作的socket描述符
*SHUT_RD:关闭读功能,此选项将不允许sockfd进行读操作。
*SHUT_WR:关闭写功能,此选项将不允许sockfd进行写操作。
*SHUT_RDWR:关闭读写功能。
*/
int shutdown(int sockfd,int how);

  以下对close和shutdown的分析都是建立在单进程编程的基础上(即没有多个进程操作同一个sockfd)。

  很明显,主动关闭方,调用close函数之后,函数立刻就返回了,剩下来的操作都是内核帮我们完成,也就是说,我们其实不知道对端到底有没有收到我们发出去的fin报文。那有没有办法让我们能知道对端已经收到fin报文了呢?当然是有的,read函数返回0的时候,就意味着收到了fin报文且数据均已读完,还记得读文件的时候读到末尾时返回的EOF吗?fin就是socket通信中的EOF。(rst报文是出错专用,这时的read操作将会返回-1)。

  我们把client的代码简单修改下,把收包和关闭的代码改为如下:

    ...
shutdown(sockfd,SHUT_WR);
do{
n = recv(sockfd,recvBuf,1024,0);
if(n == 0)
printf("Closed\n");
else if(n < 0)
printf("Recv err:%s\n",strerror(errno));
else
printf("Recv data:%s\n",recvBuf);
}while(n > 0);
getchar();
close(sockfd);//close还是必须的,不然资源泄漏了,不过此处的close对连接已经已经没有影响了
...

  同时服务端的代码也要简单修改下,只需要在回包(write调用之前)之前加个getchar()函数,用于控制回包的时机。

  操作步骤如下:

  1 运行服务端程序

  2 运行客户端程序

  3 服务端程序阻塞在getchar函数,对其敲入enter,回包

  4 客户端程序此时阻塞在getchar函数,对其敲入enter,调用close函数 通过tcpdump,获取到的通信报文如下:

08:43:54.600680 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [S], seq 1919054414, win 43690, options [mss 65495,sackOK,TS val 14805222 ecr 0,nop,wscale 7], length 0

08:43:54.600701 IP 127.0.0.1.5678 > 127.0.0.1.41325: Flags [S.], seq 263042546, ack 1919054415, win 43690, options [mss 65495,sackOK,TS val 14805222 ecr 14805222,nop,wscale 7], length 0

08:43:54.600722 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [.], ack 1, win 342, options [nop,nop,TS val 14805222 ecr 14805222], length 0

08:43:54.602144 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [P.], seq 1:11, ack 1, win 342, options [nop,nop,TS val 14805222 ecr 14805222], length 10

08:43:54.602403 IP 127.0.0.1.5678 > 127.0.0.1.41325: Flags [.], ack 11, win 342, options [nop,nop,TS val 14805223 ecr 14805222], length 0

08:43:54.603922 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [F.], seq 11, ack 1, win 342, options [nop,nop,TS val 14805223 ecr 14805223], length 0

08:43:54.642376 IP 127.0.0.1.5678 > 127.0.0.1.41325: Flags [.], ack 12, win 342, options [nop,nop,TS val 14805233 ecr 14805223], length 0

08:44:04.635521 IP 127.0.0.1.5678 > 127.0.0.1.41325: Flags [P.], seq 1:12, ack 12, win 342, options [nop,nop,TS val 14807731 ecr 14805223], length 11

08:44:04.635620 IP 127.0.0.1.5678 > 127.0.0.1.41325: Flags [F.], seq 12, ack 12, win 342, options [nop,nop,TS val 14807731 ecr 14805223], length 0

08:44:04.635984 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [.], ack 12, win 342, options [nop,nop,TS val 14807731 ecr 14807731], length 0

08:44:04.636007 IP 127.0.0.1.41325 > 127.0.0.1.5678: Flags [.], ack 13, win 342, options [nop,nop,TS val 14807731 ecr 14807731], length 0

  由于篇幅问题,报文信息中的应用层数据删掉了。

  客户端的输出如下:

Send 10 byte to Server
Recv data:I'm Server!
Closed

  可以看到,当主动关闭方发送fin报文给对端时,对端收到fin报文后依然可以向主动关闭方发送数据,且主动关闭端依然可以调用read函数读取数据,在客户端测试程序中中read成功执行了两次,第一次读到了服务端返回的数据,第二次读到了fin,即返回0。此时客户端测试程序中的close函数,除了释放资源,对连接已经没有任何影响了,通过抓包也可以看到执行close函数后,没有任何数据报发出。另外我们可以看到,fin和rst报文总是会被单独read出来,好让应用层判断连接状态,内核的确很贴心。那回到本节标题,fin报文是干啥的呢?其实就相当于文件读取里的EOF,换在这里,就是告诉对方,我不会给你发数据了,跟连接关闭不关闭其实没有因果,更何况连接本来就是不存在的,需要理解的是这是TCP协议对于实现全双工可靠连接在连接关闭时的做法,同时这个报文恰好叫做FIN报文。希望此节能够帮助读者加强对FIN,对TCP连接关闭过程的理解。

nagel与ACK延迟算法

  这也是一个老生常谈的话题,这里也免不了俗,就简单过一下。

  一句话来形容nagel算法:至多存在一个未被确认的小分组(小于mss的分组)。

  一句话形容ack延迟算法:等待有限时间直到ack被其它需要发送的分组捎带回去或者超时单独发送。

  两者都是为了减少小分组在网络上横行,尽量利用好带宽。

  由于一种特殊的编程方式:write-write-read(以下简称wwr), nagel和ack延迟算法常被拿来一起讨论。wwr的意思是指本端先发送头部数据给对端,然后再发送请求正文(假设请求正文数据小于mss)给对端,对端收到请求正文之后对其进行回复。如果这里的socket操作全部采用默认设置的话,那么在本端发送第二次这样的请求时,将会完美的踩中所有的坑。简要分析一下第二次wwr请求时的情况,当本端要发送头部数据时,由于发送缓冲区为空,数据立马就被发送了,对端收到数据时,由于ack延迟算法(我们假设等待时间为X ms),服务端如果没有数据要回复的话将等待至少40ms,而本端此时需要发送请求正文,由于请求正文小于mss,而头部数据又还没有被确认,于是将放入缓冲区,直到头部数据被确认或者等待超时(假设为Y ms)才被发送到服务端,即需要等待min(X,Y)ms(忽略传输时间),程序性能一下就被拖慢了。避免方式当然有,如果不从通信方式上改变的话,直接通过setsockopt函数设置socket选项也能解决,分别是TCP_NODELAY和TCP_QUICKACK,将分别关闭对应算法。

  这节的目的不是为了简要介绍这两个算法。网上很多文章已经介绍过这两个算法了,然而在我使用socket编程的生涯中,一直没碰到过ack的延迟发送,收到包之后内核都是立刻就回复ack的,另外我们很多内部服务的调用形式也是Write-Read的模型(其中Write的数据大于一个mss),如果ack延迟算法生效的话,岂不是调用某项服务的耗时至少为:40ms+服务处理耗时+往返时延,现实的经验并没有这么大的耗时,为此我也十分困惑,后来在网上看到一篇文章,文中阐述了linux内核对delay ack的实现方案,详细见链接:TCP之Delay ACK在Linux和Windows上实现的异同-Linux的自适应ACK。 所谓的自适应ACK,即内核将会识别当前通信模式是否是Ping-Pong(Read-Write)模式(该模式下能完美的利用捎带ack这一点),如果不是的话,将会立刻回复ack报文,让我们动手写代码来测试一下。由于lo接口不出网卡,内核协议栈的处理可能有所不同。我们这里用下面的代码来进行测试

服务端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h> #define MAX_LINE 1024
#define SERVER_IP "0.0.0.0"
#define SERVER_PORT 5678 int main(int argc,char** argv){
int lsock,connfd;
struct sockaddr_in server;
char recvBuf[MAX_LINE];
char sendBuf[MAX_LINE] = "I'm Server!";
memset(&server,0,sizeof(server));
server.sin_port = htons(SERVER_PORT);
inet_aton(SERVER_IP,&server.sin_addr);
lsock = socket(AF_INET,SOCK_STREAM,0);
bind(lsock,(struct sockaddr*)&server,sizeof(server));
listen(lsock,1024);
connfd = accept(lsock,NULL,NULL);
getchar(); //获取client两次write的数据
read(connfd,recvBuf,sizeof(recvBuf));
//待client两次write之后再进行回包,进入RW模式
write(connfd,sendBuf,strlen(sendBuf)); //RW模式进行通信
read(connfd,recvBuf,sizeof(recvBuf));
write(connfd,sendBuf,strlen(sendBuf)); //测试是否会延迟发送ack
read(connfd,recvBuf,sizeof(recvBuf));
getchar();
close(connfd);
close(lsock);
return 0;
}

客户端:

#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <netinet/in.h> #define SERVER_IP "192.168.118.129"
#define SERVER_PORT 5678 int main(int argc , char** argv){
int sockfd,ret,n;
sockfd=socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in server;
char sendBuf[1024]="I'm Client";
char recvBuf[1024];
memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
inet_aton(SERVER_IP,&server.sin_addr);
server.sin_port=htons(SERVER_PORT);
connect(sockfd,(struct sockaddr*)&server,sizeof(server)); //此阶段仍然处于非RW模式,
write(sockfd,sendBuf,strlen(sendBuf));
write(sockfd,sendBuf,strlen(sendBuf));
//进入RW模式
read(sockfd,recvBuf,1024);
getchar(); //RW模式进行通信
write(sockfd,sendBuf,strlen(sendBuf));
read(sockfd,recvBuf,1024); //测试延时
write(sockfd,sendBuf,strlen(sendBuf));
getchar();
close(sockfd);
return 0;
}

  操作步骤如下:

  1 运行服务端程序

  2 运行客户端程序

  3 服务端程序阻塞在getchar函数,对其敲入enter,回包

  4 客户端端程序阻塞在getchar函数,对其敲入enter,发送数据

  5 待通信结束,客户端和服务端都阻塞在getchar函数,分别敲入enter,关闭连接

  在服务端通过tcpdump执行如下命令:

sudo tcpdump -ieth0 -Xns0 port 5678

  获取到的通信报文如下:

09:19:16.185504 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [S], seq 2875162990, win 29200, options [mss 1460,sackOK,TS val 40730972 ecr 0,nop,wscale 7], length 0

09:19:16.185644 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [S.], seq 704183563, ack 2875162991, win 28960, options [mss 1460,sackOK,TS val 5434702 ecr 40730972,nop,wscale 7], length 0

09:19:16.189914 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [.], ack 1, win 229, options [nop,nop,TS val 40730973 ecr 5434702], length 0

09:19:16.190038 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [P.], seq 1:11, ack 1, win 229, options [nop,nop,TS val 40730973 ecr 5434702], length 10

09:19:16.190057 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [.], ack 11, win 227, options [nop,nop,TS val 5434703 ecr 40730973], length 0

09:19:16.192252 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [P.], seq 11:21, ack 1, win 229, options [nop,nop,TS val 40730974 ecr 5434703], length 10

09:19:16.192279 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [.], ack 21, win 227, options [nop,nop,TS val 5434703 ecr 40730974], length 0

09:19:21.869812 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [P.], seq 1:12, ack 21, win 227, options [nop,nop,TS val 5436123 ecr 40730974], length 11

09:19:21.870773 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [.], ack 12, win 229, options [nop,nop,TS val 40732393 ecr 5436123], length 0

09:19:24.005305 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [P.], seq 21:31, ack 12, win 229, options [nop,nop,TS val 40732927 ecr 5436123], length 10

09:19:24.005542 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [.], ack 31, win 227, options [nop,nop,TS val 5436657 ecr 40732927], length 0

09:19:24.005701 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [P.], seq 12:23, ack 31, win 227, options [nop,nop,TS val 5436657 ecr 40732927], length 11

09:19:24.006321 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [.], ack 23, win 229, options [nop,nop,TS val 40732927 ecr 5436657], length 0

09:19:24.006345 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [P.], seq 31:41, ack 23, win 229, options [nop,nop,TS val 40732927 ecr 5436657], length 10

09:19:24.045973 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [.], ack 41, win 227, options [nop,nop,TS val 5436667 ecr 40732927], length 0

09:19:29.462413 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [F.], seq 23, ack 41, win 227, options [nop,nop,TS val 5438021 ecr 40732927], length 0

09:19:29.500530 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [.], ack 24, win 229, options [nop,nop,TS val 40734301 ecr 5438021], length 0

09:19:30.933548 IP 192.168.118.128.47427 > 192.168.118.129.5678: Flags [F.], seq 41, ack 24, win 229, options [nop,nop,TS val 40734659 ecr 5438021], length 0

09:19:30.933621 IP 192.168.118.129.5678 > 192.168.118.128.47427: Flags [.], ack 42, win 227, options [nop,nop,TS val 5438389 ecr 40734659], length 0

  可以清楚的看到,头两次client发送的数据都迅速就被确认了,因此server端内核还没检测是Ping-Pong模式,当server端回复了数据之后,内核检测到是Ping-Pong模式,ack延迟算法开始生效,我们以Ping-Pong模式交换一次数据后,最后client发给server的数据,由于此时ack延迟算法已生效,大约经过了40ms才回复ack。

  这一节的主要目的是讲清楚linux 协议栈对ack确认的实现,因为在网上看到很多的文章都只是讲解一下延迟ack算法(很怀疑是不是互相拷贝的),却没有讲清楚延迟ack算法是如何生效的。就算我们是WWR模式的调用,只要每次都是使用一条新建的连接,其实也是不会碰到ack被延迟发送的问题的。

接收窗口和拥塞窗口

  在网上看到过不少这样的描述:接收窗口的大小就是socket接收缓冲区的大小,这一点我在我的虚拟机中抓包查看了一下建立连接时的win参数,以及连接建立的win参数与连接建立窗口扩大选项的值,以及打印连接建立后通过getsockopt获取的SO_RCVBUF值,计算之后窗口大小和接收缓冲区的值并不相等,因此网上很多文章直接就下结论:接收窗口大小就是socket接收缓冲区大小,这一点的正确性值得怀疑。socket缓冲区的内存分配会受tcp内核参数设置,当前机器内存状况等因素影响,它的大小并不会直接等于通过setsockopt的SO_RCVBUFF选项设置的值,我们设置的值其实是该socket缓冲区的上限,不过通过setsockopt的SO_RCVBUFF选项设置的值的确能直接改变建立连接时的接收窗口值,经验证,此时窗口值为 设置值-TCP选项长度,当然设置的窗口值也会受到系统参数的影响。与接收窗口比肩的还有拥塞窗口,拥塞窗口并不是TCP协议头里的参数,它是内核实现的协议栈里的变量,作用于自己,用于控制发送数据包的速度(即流量控制),对这两个窗口的理解一定要区分开,简单说接收窗口是作用于对端的流量控制,而拥塞窗口则是作用于本端的流量控制。等等!那对于本端来说,岂不是对端的接收窗口值和本端的拥塞窗口会同时作用于本端的流量控制么?的确是的!两者的最小值决定了当前的发送上限。

  先简单过一下拥塞控制,传统的tcp拥塞控制算法有如下几个:

1 慢启动

  谈到慢启动的时候,很容易想到每过一个RTT时间,它就增长一倍。从初始大小1,变成2,再到4,8,16...,增长的来源主要在于此阶段收到一个ack便加一,起始阶段,窗口为1,发送一个数据包过去,经过一个RTT后,收到ack,拥塞窗口变成2了,第二阶段,连续发送两个数据包出去,同理RTT后变成4,以此类推。

  进行流量控制时,对端的接收速度自然需要考虑(毕竟对端处理不过来,缓冲区满就直接丢包了),还有一点:本端和对端之间的链路的网络情况,同样需要考虑。可是这一点不像对端的接收窗口大小,有一个准确的数值能直接告诉我们,因此我们需要一种合理的机制来探测这条链路的承受能力,也就是ack反馈机制(顺口编的,但感觉很眼熟,貌似在哪见过,编着挺顺口就随便用了)。两个关键点:1是探测,2是合理的机制。关于探测很好理解,因为这不像对端的接收窗口是一个明确的数值,因此我们需要通过试探来得出链路的承受容量。关于合理的机制,反证法,如果现在网络很拥堵,给对端发一个数据包,那大概率是会触发超时的,既然对方能在RTO内给我回复,那就说明,链路容量还没到上限,因此收到一个ack,我们就增加拥塞窗口。那么为什么不是收到一个ack增加一百?其实也可以这么做,linux是开源的,我们可以改改内核参数然后给它取个名字,比如叫xlinux,不过我们是为了探测出以一个怎样的发送速率能达到一个理想值,慢启动以倍数增长去逼近一个合适值,这样就算超时了那我们也可以得出理想值位于当前窗口大小W/2到W之间,而以100来增长的话,超时的时候将在一个更大的区间内去逼近一个合适值,显然更慢。

2 拥塞避免

  慢启动一点也不慢,如果以此方法一直进行下去,很快超过链路容量,进而超时,丢包,因此有拥塞避免一说,当慢启动到达阈值时,线性增长速度降为1/w,w为拥塞窗口。即每过一个RTT时间加一

3 RTO超时

  RTO即超时重传时间,根据RTT时间计算而来。当一个数据包经过RTO还没有收到ack时,就认为丢包了,此时,拥塞窗口被置为1,阈值也降为原先一半,再次从慢启动开始

4 快速重传和快速恢复

  增加了一个拥塞判断的方法及处理方案,快速重传指收到三个冗余ack即认为此时网络比较拥塞,拥塞窗口置1,阈值也降为一半,再次从慢启动开始(Tahoes算法)。

  快速恢复是建立在快速重传基础之上,收到三个冗余ack后,将阈值降为原先一半,将拥塞窗口调整为阈值加三,从丢失的分组开始重传,此阶段如果还收到重复ack的话,拥塞窗口加一,直到收到新的ack为止(即原先丢失的数据包被确认了),然后拥塞窗口调整为阈值进入拥塞避免阶段,或者发生超时进入慢启动(Reno算法)。

  相信不少读者看到快速恢复是都会疑惑,为什么收到3个冗余ack就进入快速恢复,为什么收到三个冗余ack时,是调整为阈值加三,而不是阈值。为什么收到新ack后要进入拥塞避免阶段?下面谈谈我的理解,无关正确性。首先要确立的一点是:拥塞控制机制的目标是为了更好的利用带宽。更直白一点的话就是为了单位时间里能让更多的数据传达到目的地,而不是为了限制发送速度!

  上面还少了一个问题,那就是为什么需要快速恢复。

  其实也很简单,因为能收到3个冗余ack,其实网络应该也不是很差,如果我们直接将拥塞窗口降为1,由于要重传未被ack的那个分组,此时将无法发送新数据了,很显然快速恢复有利于提高吞吐量。

  第一个问题,为什么收到3个冗余ack就进入快速恢复,其实收到2个就进入也可以,甚至收到一个冗余ack就进入都可以,只是一个冗余ack的话,很可能只是某两个相邻分段乱序了,序号小一点的分组并没有被丢掉,只是晚一点到达而已,这种时候会造成不必要的重传,而重复收到3个冗余ack时,分组丢失的可能性就很大了,即这时候网络拥塞的可能性是很大的,没有必要等到RTO为止。

  第二个问题,为什么收到3个冗余ack,拥塞窗口调整为阈值加三。等等,什么是拥塞窗口?上面我们提过拥塞窗口用于控制本端的发送速度,不过这并不是一个定义。拥塞窗口是指本端单次可发送的最大分段数(需要跟滑动窗口里已发送但未被确认的分组数结合起来一起理解,下文会提到),上面说了接收窗口会作用于对端的流量控制,拥塞窗口正是通过其定义所描述的方式来作用于本端的流量控制。好的,回到问题上来,为什么加三。要解释这个问题不如解释为什么快速恢复阶段收到一个重复ack就会加一。这个得跟滑动窗口联系起来才能理解,收到一个ack,说明有一个分组已经从网络上消失,此时我们可以再发一个分组(一出一进,并不增加拥堵),但是本端由于有一个未被确认的分组,滑动窗口没法移动了,这时候如果滑动窗口中已发送但未被确认的分组数目大于等于拥塞窗口的话,那么快速恢复阶段,就没法发送新的数据了,因此需要增大拥塞窗口,使得还能发送新的分组,进而提高吞吐量。

  最后是为什么收到新ack后要进入拥塞避免阶段?这个问题在理解了慢启动和滑动窗口后,其实已经迎刃而解了,因为我们在逐步逼近理想值。不过为什么不直接以当前值进入拥塞避免阶段,而要调整为阈值呢?这个问题我在初学TCP协议的时候,就有疑问了,但是在网上找了很久也没有找到与之相关的解答,后来对滑动窗口和拥塞窗口有了一定理解才想明白,这里说一下我的思考结果,其实也很简单:因为快速恢复阶段时的发送速率是等于恢复完毕调整为阈值后的拥塞避免阶段的发送速率的(这话不对,但是暂且先这么理解)。为什么相等呢?因为快速恢复阶段拥塞窗口收到一个冗余ack就加1,而滑动窗口此时无法移动,而恢复后,拥塞窗口虽然变回增加前的初始值,但是滑动窗口向右移动了等同距离,因此其实快速恢复阶段与恢复刚完成时的速率是相等的。也因此我们不能直接以当前的拥塞窗口值进入拥塞避免阶段(因为以这个值进入拥塞避免阶段将直接拔高发送速率,但上面提慢启动时已经说过了我们需要在W/2到W之间去逼近一个合适值,那为什么我们还要在快速恢复阶段增大拥塞窗口呢?上面也已经提了是因为此时滑动窗口没法移动了,通过增加它的值可以让新的数据发送出去,此时其实已经是一出一进的模式在发送数据了,并没有增加发送速率)。因此我们得从头进入拥塞避免。那如果快速恢复前发送出去的分组有多个丢失了呢?由于收到非重复ack,快速恢复退出进入拥塞避免,很有可能再次收到三个冗余ack,此时阈值再次减小。以此类推,阈值是可能出现指数减小的情况的。为了避免这种情况,快速恢复算法有了新的改进,也就产生了NewReno算法(此处不再讲解,网上很多阐述)。

  最后关于tcp协议的端对端的流量控制再简单总结一下。网络数据传输中的流量控制有两个点需要考虑:

  1 是对端的处理速度,对端接收处理速度小于本端的发送速度,缓冲区满的话,自然接下来就会丢包,针对此情况,tcp协议包头提供接收窗口参数用于指明对端的接收缓冲区还剩多少,本端收到后可以调整自己的发包速度。

  2 是本端到对端之间的链路的整体网络拥塞情况,如果网络情况很差,还大量发送数据的话,必然给网络造成更大的拥堵,本端发出去的分组也逃不了超时的命运。因此我们需要有一个能测量出网络拥塞情况的方法,这个方法就是上面提的滑动窗口和拥塞窗口。对于拥塞窗口而言,如果发出去的数据包都在RTO内收到了,说明这条链路的网络情况良好,我们就增加它的值,进而增大网络压力,如果超时或者收到3个冗余ack则说明这条链路目前网络情况不佳,减小它的值,从而减小网络压力。

  最后tcp的拥塞控制算法其实也是在不断改进,不断有新的算法出来,包括应用开发者其实也可以自己去设计拥塞算法,站在巨人的肩膀上思考问题还是会轻松许多。本节只是借此将拥塞控制这一点抛出来。

文末

总结

  前言里也提过了,tcp的文章网上已经有很多了,写这篇文章的初衷,其实是希望能写出一些新意来的,全文也尽量在把我自己的理解写出来。另外水平有限,文中难免有纰漏,欢迎指出修正。

测试代码

  下面是测试时使用的代码,动手操作,实际观察才能印象深刻,理解到位。

  服务端epoll小程序:

#include <unistd.h>
#include <stdio.h>
#include <sys/epoll.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <sys/types.h>
#include <string.h>
#include <arpa/inet.h>
#include <stdlib.h> #define MAX_MSGSIZE 1024
#define MAX_CONN 2000
#define SERVER_PORT 5678
#define SERVER_IP "127.0.0.1" void setNonBlock(int sock){
int opts;
opts = fcntl(sock,F_GETFL);
if(opts < 0){
perror("fcntl(sock) O_GETFL error\n");
exit(1);
}
opts |= O_NONBLOCK;
if (fcntl(sock,F_SETFL,opts)<0){
perror("fcntl(sock,F_SETFL,opts) error\n");
exit(1);
}
} int main(int argc,char** argv){
int connfd,nfds,i,n;
socklen_t sockLen;
char recvBuf[MAX_MSGSIZE];
char sendBuf[MAX_MSGSIZE]="I'm Server!";
//创建监听socket,0表示由系统自动选择协议
int lsock =socket(AF_INET,SOCK_STREAM,0);
setNonBlock(lsock); // 创建epoll fd,参数的意思是最多添加2000个fd
int epfd =epoll_create(MAX_CONN);
struct epoll_event ev,events[MAX_CONN];
struct sockaddr_in server,client;
//将监听fd添加进epoll的红黑树结构中
ev.events = EPOLLIN | EPOLLET;
ev.data.fd=lsock;
epoll_ctl(epfd,EPOLL_CTL_ADD,lsock,&ev);
//初始化监听fd
memset(&server,0,sizeof(server));
server.sin_family= AF_INET;
inet_aton(SERVER_IP,&server.sin_addr);
server.sin_port=htons(SERVER_PORT);
bind(lsock,(struct sockaddr*)&server,sizeof(server));
//1024 表示连接已建立的队列size
listen(lsock,1024);
for(;;){
nfds=epoll_wait(epfd,events,20,-1);
//处理
printf("Now wait over,nfds is:%d\n",nfds);
for(i=0;i<nfds;i++){
printf("Cur fd is:%d\n",events[i].data.fd);
if(events[i].data.fd == lsock){
connfd=accept(lsock,(struct sockaddr*)&client,&sockLen);
if(connfd<0){
perror("connfd < 0\n");
exit(1);
}
setNonBlock(connfd);
char *str=inet_ntoa(client.sin_addr);
printf("Accept tcp from client:[%s]\n",str);
ev.data.fd=connfd;
ev.events= EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
}else if(events[i].events&EPOLLIN){
if( (connfd=events[i].data.fd)<0) {printf("The connfd:%d not del\n",connfd);continue;}
memset(recvBuf,0,sizeof(recvBuf));
if( (n= read(connfd,recvBuf,MAX_MSGSIZE))<0){
close(connfd);
events[i].data.fd=-1;
epoll_ctl(epfd,EPOLL_CTL_DEL,connfd,&events[i]);
printf("Read Error,ret:%d,err:%s\n",n,strerror(errno));
}
else if(n==0){
close(connfd);
events[i].data.fd=-1;
epoll_ctl(epfd,EPOLL_CTL_DEL,connfd,&events[i]);
printf("The tcp:%d is Closed\n",connfd);
}else{
printf("Recv msg from client:%s\n",recvBuf);
ev.data.fd=connfd;
ev.events=EPOLLOUT | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,connfd,&ev);
}
}else if(events[i].events & EPOLLOUT){
if((connfd = events[i].data.fd)<0) {printf("The connfd:%d not del\n",connfd);continue;}
write(connfd,sendBuf,strlen(sendBuf));
printf("Send Msg:%s\n",sendBuf);
ev.data.fd = connfd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,connfd,&ev);
}
}
}
}

  客户端测试程序

#include <stdio.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <time.h>
#include <fcntl.h> #define SERVER_IP "127.0.0.1"
#define SERVER_PORT 5678 #define DIFF_TIME(tStart,tEnd)\
((tEnd.tv_sec - tStart.tv_sec)*1000000 + tEnd.tv_usec - tStart.tv_usec) void setNonBlock(int sock){
int opt;
opt = fcntl(sock,F_GETFL);
if(opt < 0){
perror("fcntl(sock) O_GETFL error\n");
exit(1);
}
opt |= O_NONBLOCK;
if(fcntl(sock,F_SETFL,opt) < 0){
perror("fcntl(sock,F_SETFL,opts) error\n");
exit(1);
}
} int main(int argc , char** argv){
int sockfd,ret,n;
sockfd=socket(AF_INET,SOCK_STREAM,0);
if(sockfd<0){
printf("Socket Create Error\n");
exit(1);
}
struct sockaddr_in server;
char sendBuf[1024]="I'm Client";
char recvBuf[1024]={0};
memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
inet_aton(SERVER_IP,&server.sin_addr);
server.sin_port=htons(SERVER_PORT);
if((ret=connect(sockfd,(struct sockaddr*)&server,sizeof(server)))<0){
printf("Connect error, ret:%d,err:%s\n",ret,strerror(errno));
exit(1);
}
//for(;;){
n=write(sockfd,sendBuf,strlen(sendBuf));
printf("Send %d byte to Server\n",n);
memset(recvBuf,0,sizeof(recvBuf));
read(sockfd,recvBuf,1024);
printf("Recv data:%s\n",recvBuf);
//sleep(2);
//}
//设置linger struct linger optLinger;
optLinger.l_onoff = 1;
optLinger.l_linger = 3;
if((ret = setsockopt(sockfd,SOL_SOCKET,SO_LINGER,&optLinger,sizeof(optLinger))) < 0){
printf("setsockopt err,ret:%d,err:%s\n",ret,strerror(errno));
//exit(1);
}
struct timeval tStart,tEnd;
gettimeofday(&tStart,NULL);
//getchar();
close(sockfd);
gettimeofday(&tEnd,NULL);
printf("Diff Time:%ld\n",DIFF_TIME(tStart,tEnd));
return 0;
}

  简单的抓包命令:

sudo tcpdump -iany -Xns0 port 5678

TCP随笔的更多相关文章

  1. nginx http模块开发入门

    导语 本文对nginx http模块开发需要掌握的一些关键点进行了提炼,同时以开发一个简单的日志模块进行讲解,让nginx的初学者也能看完之后做到心里有谱.本文只是一个用作入门的概述. 目录 背景 主 ...

  2. TCP协议随笔

    传输控制协议TCP是面向连接.保证高可靠性(数据无丢失.数据无失序.数据无错误.数据无重复到达)传输层协议.TCP/IP结构对应OSITCP/IP                           ...

  3. TCP/IP协议随笔

    今天翻博客的时候看到了TCP/IP协议相关的几篇文章,写的非常好,LZ打算把其中的重点整理一下,虽然都是一些概念性的东西,平时编码的时候可能用不到,但是起码我们应该知道自己是在哪一层编码,又有哪些协议 ...

  4. TCP/IP协议组随笔

    原文:https://my.oschina.net/xianggao/blog/654677 IP层负责网络主机的定位,数据传输的路由,由IP地址可以唯一的确定Internet上的一台主机. TCP层 ...

  5. TCP/IP协议(一)网络基础知识

    参考书籍为<图解tcp/ip>-第五版.这篇随笔,主要内容还是TCP/IP所必备的基础知识,包括计算机与网络发展的历史及标准化过程(简述).OSI参考模型.网络概念的本质.网络构建的设备等 ...

  6. Tcp方式采集CNC兄弟设备数据

    先说下为了采集CNC兄弟设备的数据可谓是一波三折. 因为首次接触brother设备(CNC)是直接在设备上设置IP.用户名.密码,然后直连PC,用Ftp可以查看和下载CNC brother设备里的数据 ...

  7. android TCP 客户端(仅接收数据)

    配合log4net使用,用来接收调试信息.因此,此客户端只管通过TCP接收字符串数据,然后显示在界面上. 接收TCP数据 try { Socket s = new Socket("192.1 ...

  8. 《构建高性能web站点》随笔 无处不在的性能问题

    前言– 追寻大牛的足迹,无处不在的“性能”问题. 最近在读郭欣大牛的<构建高性能Web站点>,读完收益颇多.作者从HTTP.多级缓存.服务器并发策略.数据库.负载均衡.分布式文件系统多个方 ...

  9. socket(TCP)发送文件

    一:由于在上一个随笔的基础之上拓展的所以直接上代码,客户端: using System; using System.Collections.Generic; using System.Componen ...

随机推荐

  1. Linux(Centos7)安装、使用 Docker

    一.Linux(CentOS7) 上安装 docker 1.docker 是什么? docker 是一种 虚拟化容器技术,一个开源的应用容器引擎. 基于镜像,可以秒级启动各种容器(运行一次镜像就生成一 ...

  2. 后台查询出来的list结果 在后台查询字典表切换 某些字段的内容

    list=listEFormat(list, "Class_type", "611");//list查询数据库得到的结果Class_type /** * @Ti ...

  3. rtmp向IR601移植过程(无功能步骤,只有移植步骤)

    1.main.c中添加头文件: #include "rtmp_sys.h" #include "log.h" #include "rtmp.h&quo ...

  4. libzip开发笔记(一):libzip库介绍、编译和工程模板

      前言   Qt使用一些压缩解压功能,选择libzip库,libzip库比较原始,也是很多其他库的基础支撑库.   libzip   libzip是一个C库,用于读取,创建和修改zip档案.可以从数 ...

  5. Mac电脑 Android Studio连接小米手机

    1.设置>关于本机>点击5下MIUI版本>激活开发者模式 2.设置>更多设置>开发者选项>开启开发者选项>开启USB调试>开启USB安装>开启显示 ...

  6. java字符统计+字符串压缩

    要实习了.突然发现自己好像什么都不会,就去看看题吧.在网上看到一个字符串压缩的题.看了一眼,感觉用python很简单.一个for循环+字典就可以搞定. 但是呢,主要还是java.下面就用java来实现 ...

  7. 庐山真面目之十微服务架构 Net Core 基于 Docker 容器部署 Nginx 集群

    庐山真面目之十微服务架构 Net Core 基于 Docker 容器部署 Nginx 集群 一.简介      前面的两篇文章,我们已经介绍了Net Core项目基于Docker容器部署在Linux服 ...

  8. jQuery EasyUI学习二

    1.   课程介绍 1.  Datagrid组件(掌握) 2.  Dialog.form组件(掌握) 3. Layout.Tabs;(掌握) Datagrid组件 2.1.  部署运行pss启动无错 ...

  9. Scaled-YOLOv4 快速开始,训练自定义数据集

    代码: https://github.com/ikuokuo/start-scaled-yolov4 Scaled-YOLOv4 代码: https://github.com/WongKinYiu/S ...

  10. 多年经验总结,写出最惊艳的 Markdown 高级用法

    点赞再看,养成习惯,微信搜索[高级前端进阶]关注我. 本文 GitHub https://github.com/yygmind 已收录,有一线大厂面试完整考点和系列文章,欢迎 Star. 最近在学习的 ...