最近我们对自己的服务器进行了一次压测,在测试中出现客户端在调用connect时报错:
Cannot assign requested address(errno=99)
在TCP/IP协议中,一个TCP连接是一个五元组:
客户端IP、客户端PORT、协议、服务端IP、服务端PORT
这五个元素决定一个唯一连接。只要有一个元素不一样,就是不同的连接。
TCP在断开连接时将进行四次握手。谁先主动断开连接,谁就将进入TIME_WAIT状态。
MSL= maximum segment lifetime 最长分节生命周期,是任何IP数据包能够在因特网中存活的最长时间。
TIME_WAIT状态将持续2MSL,不同实现有不同大小,不过一般来说,一个MSL是30秒,两个就是60秒。
一条连接处于TIME_WAIT状态时,不可复用。
1.可靠地实现TCP全双工连接的终止:
仔细思考下四次握手的最后一个ACK分节的发送。
主动关闭连接者发出最后一个ACK确认分节,但是主动关闭连接者怎么知道被动关闭连接者最后是否收到了自己发的ACK呢?
如果主动关闭连接者发现自己发的ACK在网络中阻塞或者丢失,应当及时重新发送ACK到被动关闭连接者。
当主动关闭连接者发送ACK后,连接进入TIME_WAIT状态,在主动关闭连接一方看来,有两种情况:
1)ACK正确到达了被动关闭连接一方,超过2MSL时间后,认定被动关闭连接一方收到最后的ACK分节;
2)ACK由于某些原因没有按时到达被动关闭连接一方,此时,能保证ACK出问题最长的极限是一个MSL时间,而被动关闭连接一方给出重发FIN分节,也应该是在一个MSL周期保证能到达。
2.保证所有老的重复分节在网络中消逝:
假设有以下一个连接:
192.168.144.43:50000 TCP 192.168.144.44:12500
从192.168.144.43:50000给192.168.144.44:12500发的一个分节在某个路由器阻塞(路由循环?)停滞,一段时间后192.168.144.43:50000检测出分节“丢失”并重发了这个被阻塞的分节,并且192.168.144.44:12500收到这个重发的分节且这条连接很快被释放关闭。
假设这条连接立刻能被复用,正常建立连接==》
之前被阻塞的分节慢悠悠的到达了192.168.144.44:12500,且恰好确认序号一致(这种情况完全可能!),于是乎,新的连接被旧有的连接的废弃数据“污染”啦!
如何保证新连接不会被旧的连接的某个“被丢失”的分节所污染呢?==》
好吧,等个2MSL,保证所有旧的连接的分节,不论是正常的不正常的,都已经在网络中消逝了,这个旧连接才可被复用。
这就是TIME_WAIT的意义。这是一个很有价值的机制,不要试图规避,要搞清楚它,利用它。
我们的服务端IP是192.168.144.44,服务端PORT是12500。
客户端发起TCP请求建立连接,读取服务端返回的连接信息并主动关闭连接。
客户端接受TCP请求建立连接,发送连接相关信息给客户端并等待客户端关闭连接,服务端close,完成四次握手。
一个Shell脚本串行调用客户端程序,发起大量(短)连接,直到客户端执行异常。
Code:
服务端:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define BACKLOG 16
int main()
{
// socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
{
printf("create socket error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
// bind
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(12500); // Port
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
printf("socket bind error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
// listen
if (listen(listen_fd, BACKLOG) < 0)
{
printf("socket listen error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
printf("server init ok, start to accept new connect...\n");
int connIdx = 0;
while (1)
{
// accept
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd < 0)
{
printf("socket accept error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
printf("accept one new connect(%d)!!!\n", connIdx);
static char msg[1024] = "";
memset(msg, 0, sizeof(msg));
snprintf(msg, sizeof(msg)-1, "connIdx=%d\n", connIdx);
if (write(client_fd, msg, strlen(msg)) != strlen(msg))
{
printf("send msg to client error!!!\n");
exit(1);
}
static char readBuf[1024] = "";
memset(readBuf, 0, sizeof(readBuf));
if (read(client_fd, readBuf, sizeof(readBuf)-1) != 0)
{
printf("read error!!! server close connection!!!\n");
exit(1);
}
printf("server read return 0, client-FIN\n");
close(client_fd);
connIdx++;
}
// never
close(listen_fd);
return 0;
}
客户端:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
int main()
{
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd < 0)
{
printf("create socket error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(12500);
if (inet_pton(AF_INET, "192.168.44.144", &server_addr.sin_addr) <= 0)
{
printf("inet_pton error!!!\n");
exit(1);
}
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
printf("socket connect error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
printf("connect to server ok!\n");
char msg[1024];
int rbytes = read(client_fd, msg, sizeof(msg)-1);
if (rbytes <= 0)
{
printf("read error=%d(%s)!!!\n", errno, strerror(errno));
exit(1);
}
msg[rbytes] = 0; // null terminate
printf("%s", msg);
close(client_fd);
return 0;
}
压测脚本:
#/bin/bash
PATH=$PATH:$HOME/server:$HOME/client
export PATH
client
while test "x$?" = "x0"
do
client
done
netstat -an | grep TIME_WAIT | wc -l
编译 & 运行 服务端:
[jiang@localhost server]$ gcc -o server server.c
[jiang@localhost server]$ ./server
server init ok, start to accept new connect...
编译 & 运行 脚本:(脚本中循环调用客户端发起短连接)
[jiang@localhost ~]$ ./simu.sh
……
connect to server ok!
connIdx=28231
connect to server ok!
connIdx=28232
socket connect error=99(Cannot assign requested address)!!!
28233
[jiang@localhost ~]$ netstat -an | grep TIME_WAIT
tcp 0 0 192.168.44.144:52632 192.168.44.144:12500 TIME_WAIT
tcp 0 0 192.168.44.144:52711 192.168.44.144:12500 TIME_WAIT
tcp 0 0 192.168.44.144:52315 192.168.44.144:12500 TIME_WAIT
tcp 0 0 192.168.44.144:52532 192.168.44.144:12500 TIME_WAIT
tcp 0 0 192.168.44.144:52261 192.168.44.144:12500 TIME_WAIT
tcp 0 0 192.168.44.144:52623 192.168.44.144:12500 TIME_WAIT
……
我们可以观察到:
服务端bind端口12500,客户端并未bind,但是客户端也是一个socket对象,而一个socket对象是IP+PORT。
客户端的socket的端口哪里来的呢?
是在connect调用中,由内核分配一个临时的PORT给客户端!
(当然,客户端也是可以通过调用bind,给自己一个确定端口!不过一般不这么做。)
我们想一下过程:
客户端发起对某个服务端的连接请求,内核分配一个临时端口给客户端socket,然后发SYN包到服务端…
在五元组中,服务端IP、PORT是确定的,客户端IP也是确定的,协议也是确定的,只有客户端的临时PORT是内核分配的。
当客户端发起大量TCP连接,内核也会分配大量临时端口给这个客户端,并记录每次的连接(客户端断开连接进入TIME_WAIT)。
于是乎,当发起速度和连接断开速度足够快时(比最早的连接的2MSL提前),就会出现,大量连接虽然被四次握手正常释放,但是还处于TIME_WAIT状态。
端口是一个16bit的数值,最大也就是65535,当达到一定上限时,内核无法分配一个可用的临时端口给客户端,即无法建立客户端socket,无法分配一个协议地址(TCP协议地址=IP+PORT)给客户端,自然connect调用报错。
emmm…有点复杂,简单举个栗子。
假设端口是一个2bit的数值,只有0、1、2、3四个值。
当客户端发起请求时,内核首先为客户端分配端口0,组成连接:
192.168.144.X:0 TCP 192.168.144:44:12500
然后客户端断开连接,此连接进入TIME_WAIT状态。
然后客户端再次发起三个请求,内核依次分配1、2、3,组成连接:
192.168.144.X:1 TCP 192.168.144:44:12500
==》连接释放,进入TIME_WAIT
192.168.144.X:2 TCP 192.168.144:44:12500
==》连接释放,进入TIME_WAIT
192.168.144.X:3 TCP 192.168.144:44:12500
==》连接释放,进入TIME_WAIT
注:
第二次请求时为啥内核不能再次分配端口0呢?因为此时那个连接还处于TIME_WAIT状态,如果分配了端口0并连接成功,就不能完全保证TCP的安全可靠传输!一定要等到2MSL后脱离TIME_WAIT状态,然后才可分配端口0,再次复用此连接。
好,到目前为止,4条连接已经都进入TIME_WAIT状态,然后我尝试再次执行客户端connect,还能由内核分配临时端口吗?
不能了!内核没有别的临时端口,让这个临时端口和其他四个元素值组成不重复的连接。自然connect调用失败。
到此为止,我们终于搞清楚,为啥connect时出现“Cannot assign requested address”错误啦!
仔细思考,假设服务端(同一主机多网络接口)的IP有两个,除了192.168.144.44,还有一个192.168.144.45。我们在存在大量TIME_WAIT连接的情况下,能否与144.145建立连接呢?
答案是可以的。
内核只是不能分配可用的临时端口给客户端,让其组成一个不重复的连接。
如果服务端的IP变了,即使存在:
192.168.144.X:12345 TCP 192.168.144.44:12500 【TIME_WAIT】连接,
也可以新建:
192.168.144.X:12345 TCP 192.168.144.45:12500
为啥?因为内核只保证五元组唯一就可以呀!端口不能被使用,仅在与特定连接关联才成立。
假设客户端访问100个IP:PORT服务端,本地socket完全可能有100个相同的PORT被临时分配到100个不同的连接中。