251 lines
12 KiB
Markdown
251 lines
12 KiB
Markdown
# 15 | 怎么老是出现“地址已经被使用”?
|
||
|
||
你好,我是盛延敏,这里是网络编程实战的第15讲,欢迎回来。
|
||
|
||
上一讲我们讲到UDP也可以像TCP一样,使用connect方法,以快速获取异步错误的信息。在今天的内容里,我们将讨论服务器端程序重启时,地址被占用的原因和解决方法。
|
||
|
||
我们已经知道,网络编程中,服务器程序需要绑定本地地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。在实战中,你可能会经常碰到一个问题,当服务器端程序重启之后,总是碰到“Address in use”的报错信息,服务器程序不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?
|
||
|
||
今天我们就来讲一讲这个“地址已经被使用”的问题。
|
||
|
||
## 从例子开始
|
||
|
||
为了引入讨论,我们从之前讲过的一个TCP服务器端程序开始说起:
|
||
|
||
```
|
||
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,也顺利地关闭了连接。服务器端也可以很快重启,等待新的连接到来。
|
||
|
||
```
|
||
$./addressused
|
||
received 9 bytes: network
|
||
received 6 bytes: good
|
||
client closed
|
||
$./addressused
|
||
|
||
```
|
||
|
||
接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在Telnet端关闭连接,而是直接使用Ctrl+C的方式在服务器端关闭连接。
|
||
|
||
```
|
||
$telnet 127.0.0.1 9527
|
||
network
|
||
bad
|
||
Connection closed by foreign host.
|
||
|
||
```
|
||
|
||
我们看到,连接已经被关闭,Telnet客户端也感知连接关闭并退出了。接下来,我们尝试重启服务器端程序。你会发现,这个时候服务端程序重启失败,报错信息为:**bind failed: Address already in use**。
|
||
|
||
```
|
||
$./addressused
|
||
received 9 bytes: network
|
||
received 6 bytes: good
|
||
client closed
|
||
$./addressused
|
||
bind faied: Address already in use(98)
|
||
|
||
```
|
||
|
||
## 复习TIME\_WAIT
|
||
|
||
那么,这个错误到底是怎么发生的呢?
|
||
|
||
还记得第10篇文章里提到的TIME\_WAIT么?当连接的一方主动关闭连接,在接收到对端的FIN报文之后,主动关闭连接的一方会在TIME\_WAIT这个状态里停留一段时间,这个时间大约为2MSL。如果你对此有点淡忘,没有关系,我在下面放了一张图,希望会唤起你的记忆。
|
||
|
||
![](https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png?wh=856*580)
|
||
如果我们此时使用netstat去查看服务器程序所在主机的TIME\_WAIT的状态连接,你会发现有一个服务器程序生成的TCP连接,当前正处于TIME\_WAIT状态。这里9527是本地监听端口,36650是telnet客户端端口。当然了,Telnet客户端端口每次也会不尽相同。
|
||
|
||
![](https://static001.geekbang.org/resource/image/51/e1/5127adf94e564c13d6be86460d3317e1.png?wh=1684*952)
|
||
通过服务器端发起的关闭连接操作,引起了一个已有的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状态的连接。代码片段已经放在文章中了:
|
||
|
||
```
|
||
int on = 1;
|
||
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||
|
||
```
|
||
|
||
SO\_REUSEADDR套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已经表明,在默认情况下,服务器端历经创建socket、bind和listen重启时,如果试图绑定到一个现有连接上的端口,bind操作会失败,但是如果我们在创建socket和bind之间,使用上面的代码片段设置SO\_REUSEADDR套接字选项,情况就会不同。
|
||
|
||
下面我们对原来的服务器端代码进行升级,升级的部分主要在11-12行,在bind监听套接字之前,调用setsockopt方法,设置重用套接字选项:
|
||
|
||
```
|
||
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 on = 1;
|
||
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||
|
||
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++;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
重新编译过后,重复上面那个例子,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符,使用Ctrl+C的方式在服务器端关闭连接。马上尝试重启服务器,这个时候我们发现,服务器正常启动,没有出现**Address already in use**的错误。这说明我们的修改已经起作用。
|
||
|
||
```
|
||
$./addressused2
|
||
received 9 bytes: network
|
||
received 6 bytes: good
|
||
client closed
|
||
$./addressused2
|
||
|
||
```
|
||
|
||
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连接产生。
|
||
|
||
比如下面就是第二次运行服务器端程序的报错信息:
|
||
|
||
```
|
||
$./addressused2
|
||
bind faied: Address already in use(98)
|
||
|
||
```
|
||
|
||
你可能还记得[第10讲](https://time.geekbang.org/column/article/125806)中,我们提到过一个叫做tcp\_tw\_reuse的内核配置选项,这里又提到了SO\_REUSEADDR套接字选择,你会不会觉得两个有点混淆呢?
|
||
|
||
其实,这两个东西一点关系也没有。
|
||
|
||
* tcp\_tw\_reuse是内核选项,主要用在连接的发起方。TIME\_WAIT状态的连接创建时间超过1秒后,新的连接才可以被复用,注意,这里是连接的发起方;
|
||
* SO\_REUSEADDR是用户态的选项,SO\_REUSEADDR选项用来告诉操作系统内核,如果端口已被占用,但是TCP连接状态位于TIME\_WAIT ,可以重用端口。如果端口忙,而TCP处于其他状态,重用端口时依旧得到“Address already in use”的错误信息。注意,这里一般都是连接的服务方。
|
||
|
||
## 总结
|
||
|
||
今天我们分析了“Address already in use”产生的原因和解决方法。你只要记住一句话,**在所有TCP服务器程序中,调用bind之前请设置SO\_REUSEADDR套接字选项**。这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。
|
||
|
||
## 思考题
|
||
|
||
跟往常一样,给你布置两道思考题:
|
||
|
||
第一道,之前我们看到的例子,都是对TCP套接字设置SO\_REUSEADDR套接字选项,你知道吗,我们也可以对UDP设置SO\_REUSEADDR套接字选项。那么问题来了,对UDP来说,设置SO\_REUSEADDR套接字选项有哪些场景和好处呢?
|
||
|
||
第二道,在服务器端程序中,设置SO\_REUSEADDR套接字选项时,需要在bind函数之前对监听字进行设置,想一想,为什么不是对已连接的套接字进行设置呢?
|
||
|
||
欢迎你在评论区写下你的思考,我会和你一起讨论交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||
|