网络编程学习笔记(三)--数据传输、报文读取

TIME_WAIT相关理解

TIME_WAIT发生的场景

  应用服务需要通过发起TCP连接对外提供服务。每个连接会占用一个本地端口,当在高并发的情况下,TIME_WAIT状态的连接过多,多到把本机可用的端口耗尽,应用服务对外表现的症状,就是不能正常工作了。当过了一段时间之后,处于TIME_WAIT的连接被系统回收并关闭后,释放出本地端口可供使用,应用服务对外表现为可以正常工作。这样周而复始,便会出现了一会儿不可以,过一两分钟又可以正常工作的现象。
  至于为什么会产生这么多的TIME_WAIT连接,这要从TCP的四次挥手说起。先来回顾一下四次挥手的过程,如下图。

  TCP连接终止时,主机1先发送FIN报文,主机2进入CLOSE_WAIT状态,并发送一个ACK应答,同时,主机2通过read调用获得EOF,并将此结果通知应用程序进行主动关闭操作,发送FIN报文。主机1在接收到FIN报文后发送ACK应答,此时主机1进入TIME_WAIT状态。主机1在TIME_WAIT停留持续时间是固定的,是最长分节生命期MSL(maximum segment lifetime)的两倍,一般称之为2MSL。和大多数BSD派生的系统一样,Linux系统里有一个硬编码的字段,名称为TCP_TIMEWAIT_LEN,其值为60秒。也就是说,Linux系统停留在TIME_WAIT的时间为固定的60秒。一定要记住,只有发起连接终止的一方会进入TIME_WAIT状态。

TIME_WAIT的作用

  为什么不直接进入CLOSED状态,而要停留在TIME_WAIT这个状态?这要从两个方面来说。
  首先,这样做是为了确保最后的ACK能让被动关闭方接收,从而帮助其正常关闭。TCP在设计的时候,做了充分的容错性设计,比如,TCP假设报文会出错,需要重传。在这里,如果图中主机1的ACK报文没有传输成功,那么主机2就会重新发送FIN报文。如果主机1没有维护TIME_WAIT状态,而直接进入CLOSED状态,它就失去了当前状态的上下文,只能回复一个RST操作,从而导致被动关闭方出现错误。现在主机1知道自己处于TIME_WAIT的状态,就可以在接收到FIN报文之后,重新发出一个ACK报文,使得主机2可以进入正常的CLOSED状态。
  第二个理由和连接“化身”和报文迷走有关系,为了让旧连接的重复分节在网络中自然消失。我们知道,在网络中,经常会发生报文经过一段时间才能到达目的地的情况,产生的原因是多种多样的,如路由器重启,链路突然出现故障等。如果迷走报文到达时,发现TCP连接四元组(源IP,源端口,目的IP,目的端口)所代表的连接不复存在,那么很简单,这个报文自然丢弃。我们考虑这样一个场景,在原连接中断后,又重新创建了一个原连接的“化身”,说是化身,其实是因为这个连接和原先的连接四元组完全相同,如果迷失报文经过一段时间也到达,那么这个报文会被误认为是新连接的一个TCP分节,这样就会对TCP通信产生影响。所以,TCP就设计出了这么一个机制,在一个连接之内,经过2MSL这个时间,足以让两个方向上的所有分组都被丢弃,使得原来连接的分组在网络中都自然消失,再出现的分组一定都是新化身所产生的。
  要注意,2MSL的时间是从主机1接收到FIN后发送ACK开始计时的,如果在TIME_WAIT时间内,因为主机1的ACK没有传输到主机2,主机1又接收到了主机2重发的FIN报文,那么2MSL时间将重新计时。道理很简单,因为2MSL的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机1重新发送了ACK报文,自然需要重新计时,以便防止这个ACK报文对新可能的连接化身造成干扰。

TIME_WAIT的危害与优化

  过多的TIME_WAIT的主要危害有两种。第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。第二是对端口资源的占用,一个TCP连接至少消耗一个本地端口。要知道,端口资源也是有限的,一般可以开启的端口为32768~61000,也可以通过net.ipv4.ip_local_po rt_range指定,如果TIME_WAIT状态过多,会导致无法创建新连接。
  那么如何优化TIME_WAIT呢?可以通过net.ipv4.tcp_tw_reuse选项,主要有两点:(1)只适用于连接发起方(C/S模型中的客户端);(2)对应的TIME_WAIT状态的连接创建时间超过1秒才可以被复用。使用这个选项,还有一个前提,需要打开对TCP时间戳的支持,即net.ipv4.tcp_time stamps=1(默认即为1)。
  要知道,TCP协议也在与时俱进,RFC1323中实现了TCP拓展规范,以便保证TCP的高可用,并引入了新的TCP选项,两个4字节的时间戳字段,用于记录TCP发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,前面提到的2MSL问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

TCP的一些机制

TCP的保持活跃机制

  TCP有一个保持活跃的机制叫做Keep-Alive。这个机制的原理是这样的:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的TCP连接已经死亡,系统内核将错误信息通知给上层应用程序。上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。在Linux系统中,这些变量分别对应sysctl变量net.ipv4.tcp_keepalive_time、net.ipv4.tcp_keepalive_intvl、net.ipv4.tcp_keepalve_probes,默认设置是7200秒(2小时)、75秒和9次探测。
  如果开启了TCP保活,需要考虑以下几种情况:第一种,对端程序是正常工作的。当TCP保活的探测报文发送给对端,对端会正常响应,这样TCP保活时间会被重置,等待下一个TCP保活时间的到来。第二种,对端程序崩溃并重启。当TCP保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个RST报文,这样很快就会发现TCP连接已经被重置。第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当TCP保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP会报告该TCP连接已经死亡。
  TCP保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启。如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
  如果使用TCP自身的keep-Alive机制,在Linux系统中,最少需要经过2小时11分15秒才可以发现一个“死亡”连接。这个时间是怎么计算出来的呢?其实是通过2小时,加上75秒乘以9的总和。实际上,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。所以,必须在应用程序这一层来寻找更好的解决方案。我们可以通过在应用程序中模拟TCP的Keep-Alive机制,来完成在应用层的连接探活。我们可以设计一个PING-PONG的机制,需要保活的一方,比如客户端,在保活时间达到后,发起对连接的PING操作,如果服务器端对PING操作有回应,则重新设置保活时间,否则对探测次数进行计数,如果最终探测次数达到了保活探测次数预先设置的值之后,则认为连接已经无效。这里有两个比较关键的点:第一个是需要使用定时器,这可以通过使用I/O复用自身的机制来实现;第二个是需要设计一个PING-PONG的协议。

理解TCP协议中的动态数据传输

从TCP角度看待数据流的发送和接收

  我们已经熟悉如何通过套接字发送数据,比如使用write或者send方法来进行数据流的发送。我们已经知道,调用这些接口并不意味着数据被真正发送到网络上,其实,这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中,或者说是发送缓冲区中,等待协议栈的处理。至于这些数据是什么时候被发送出去的,对应用程序来说,是无法预知的。对这件事情真正负责的,是运行于操作系统内核的TCP协议栈实现模块。   

流量控制和生产者-消费者模型

  我们可以把理想中的TCP协议可以想象成一队运输货物的货车,运送的货物就是TCP数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那20车你给我等等,等我这里腾出地方你再继续发货。”其实这就是发送窗口和接收窗口的本质,我们把这个叫做“TCP的生产者-消费者”模型。发送窗口和接收窗口是TCP连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产-消费速率、而产生的算法模型实现。说白了,作为TCP发送端,也就是生产者,不能忽略TCP的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。如果都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
  TCP的生产者-消费者模型,只是在考虑单个连接的数据传递,但是,TCP数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样,TCP就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。举个形象一点的例子,有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个TCP连接,形成了高速公路上的多队运送货车,高速公路上开始变得熙熙攘攘,这个时候,就需要拥塞控制的接入了。在TCP协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。拥塞控制常用的算法有“慢启动”,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后,慢启动就结束了,另一个叫做“拥塞避免”的算法登场。在这个阶段,TCP会不断地探测网络状况,并随之不断调整拥塞窗口的大小。
  现在我们可以发现,在任何一个时刻,TCP发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而TCP协议中总是取两者中最小值作为判断依据。比如当前发送的字节为100,发送窗口的大小是200,拥塞窗口的大小是80,那么取200和80中的最小值,就是80,当前发送的字节数显然是大于拥塞窗口的,结论就是不能发送出去。这里千万要分清楚发送窗口和拥塞窗口的区别。发送窗口反应了作为单TCP连接、点对点之间的流量控制模型,它是需要和接收端一起共同协调来调整大小的;而拥塞窗口则是反应了作为多个TCP连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。

服务器端程序重启时,地址被占用的原因和解决方法。

  我们已经知道,网络编程中,服务器程序需要绑定本地地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。在实战中,可能会经常碰到一个问题,当服务器端程序重启之后,总是碰到“Address in use”的报错信息,服务器程序不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?我们从一个TCP服务器端程序开始说起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static int count; 
static void sig_int(int signo)
{
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv)
{
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if(rt1 < 0)
{
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0)
{
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd; struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0)
{
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for(;;)
{
int n = read(connfd, message, MAXLINE);
if(n < 0)
{
error(1, errno, "error read");
}
else if(n == 0)
{
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
}
}

  这个服务器端程序绑定到一个本地端口,使用的是通配地址ANY,当连接建立之后,从该连接中读取输入的字符流。启动服务器,之后我们使用Telnet登录这个服务器,并在屏幕上输入一些字符,例如:network,good。和我们期望的一样,服务器端打印出Telnet客户端的输入。在Telnet端关闭连接之后,服务器端接收到EOF,也顺利地关闭了连接。服务器端也可以很快重启,等待新的连接到来。

1
2
3
4
5
$./addressused 
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused

  接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符。注意接下来的不同,不在Telnet端关闭连接,而是直接使用Ctrl+C的方式在服务器端关闭连接。

1
2
3
4
$telneet 127.0.0.1 9527 
network
bad
Connection closed by foreign host.

  我们看到,连接已经被关闭,Telnet客户端也感知连接关闭并退出了。接下来,我们尝试重启服务器端程序。你会发现,这个时候服务端程序重启失败,报错信息为:bind failed: Address already in use。

1
2
3
4
5
6
$./addressused 
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused
bind faied: Address already in use(98)

  此时我们想到了TIME_WAIT,当连接的一方主动关闭连接,在接收到对端的FIN报文之后,主动关闭连接的一方会在TIME_WAIT这个状态里停留一段时间,这个时间大约为2MSL。如果我们此时使用netstat去查看服务器程序所在主机的TIME_WAIT的状态连接,你会发现有一个服务器程序生成的TCP连接,当前正处于TIME_WAIT状态。通过服务器端发起的关闭连接操作,引起了一个已有的TCP连接处于TME_WAIT状态,正是这个TIME_WAIT的连接,使得服务器重启时,继续绑定在127.0.0.1地址和9527端口上的操作,返回了Address already in use的错误。

重用套接字选项

  我们知道,一个TCP连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的,如果每次Telnet客户端使用的本地端口都不同,就不会和已有的四元组冲突,也就不会有TIME_WAIT的新旧连接化身冲突的问题。事实上,即使在很小的概率下,客户端Telnet使用了相同的端口,从而造成了新连接和旧连接的四元组相同,在现代Linux操作系统下,也不会有什么大的问题,原因是现代Linux操作系统对此进行了一些优化。第一种优化是新连接SYN告知的初始序列号,一定比TIME_WAIT老连接的末序列号大,这样通过序列号就可以区别出新老连接。第二种优化是开启了tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。在这样的优化之下,一个TIME_WAIT的TCP连接可以忽略掉旧连接,重新被新的连接所使用。这就是重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的TCP连接完全可以复用TIME_WAIT状态的连接。代码片段如下:

1
2
int on = 1; 
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

  SO_REUSEADDR套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已经表明,在默认情况下,服务器端历经创建socket、bind和listen。重启时,如果试图绑定到一个现有连接上的端口,bind操作会失败,但是如果我们在创建socket和bind之间,使用上面的代码片段设置SO_REUSEADDR套接字选项,情况就会不同。
  SO_REUSEADDR套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。比如,一台服务器有192.168.1.101和10.10.2.102两个地址,我们可以在这台机器上启动三个不同的HTTP服务,第一个以本地通配地址ANY和端口80启动;第二个以192.168.101和端口80启动;第三个以10.10.2.102和端口80启动。这样目的地址为192.168.101,目的端口为80的连接请求会被发往第二个服务;目的地址为10.10.2.102,目的端口为80的连接请求会被发往第三个服务;目的端口为80的所有其他连接请求被发往第一个服务。我们必须给这三个服务设置SO_REUSEADDR套接字选项,否则第二个和第三个服务调用bind绑定到80端口时会出错。总之,可以总结成一句话:服务器端程序,都应该设置SO_REUSEADDR套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
  有人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我们已经知道,TCP连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的,即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。而且,TCP的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置SO_REUSEADDR套接字选项,也不可能在ANY通配符地址下和端口9527上重复启动两个服务器实例。如果我们启动第二个服务器实例,不出所料会得到Address already in use的报错,即使当前还没有任何一条有效TCP连接产生。
  那么tcp_tw_reuse的内核配置选项和SO_REUSEADDR套接字选项有什么区别呢?其实,这两个东西一点关系也没有。tcp_tw_reuse是内核选项,主要用在连接的发起方。TIME_WAIT状态的连接创建时间超过1秒后,新的连接才可以被复用,注意,这里是连接的发起方;SO_REUSEADDR是用户态的选项,SO_REUSEADDR选项用来告诉操作系统内核,如果端口已被占用,但是TCP连接状态位于TIME_WAIT,可以重用端口。如果端口忙,而TCP处于其他状态,重用端口时依旧得到“Address already in use”的错误信息。注意,这里一般都是连接的服务方。

报文读取和解析

  我们知道TCP的报文是以字节流的形式呈现给应用程序的,那么随之而来的一个问题就是,应用程序如何解读字节流呢?这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
  报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,一种是发送端把要发送的报文长度预先通过报文告知给接收端;另一种是通过一些特殊的字符来进行边界的划分。
  显式编码报文长度,就是把要发送的报文长度预先通过报文告知接收端,如下图。

  由图可以看出,这个报文的格式很简单,首先4个字节大小的消息长度,其目的是将真正发送的字节流的大小显式通过报文告知接收端,接下来是4个字节大小的消息类型,而真正需要发送的数据则紧随其后。
  另外一种报文格式就是通过设置特殊字符作为报文边界。HTTP是一个非常好的例子。

  HTTP通过设置回车符、换行符做为HTTP报文协议的边界。
  由此看来,TCP数据流特性决定了字节流本身是没有边界的,一般我们通过显式编码报文长度的方式,以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下,有效地对报文信息进行还原。