极客时间已完结课程限时免费阅读

15 | 怎么老是出现“地址已经被使用”?

15 | 怎么老是出现“地址已经被使用”?-极客时间

15 | 怎么老是出现“地址已经被使用”?

讲述:冯永吉

时长09:45大小8.94M

你好,我是盛延敏,这里是网络编程实战的第 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。如果你对此有点淡忘,没有关系,我在下面放了一张图,希望会唤起你的记忆。
如果我们此时使用 netstat 去查看服务器程序所在主机的 TIME_WAIT 的状态连接,你会发现有一个服务器程序生成的 TCP 连接,当前正处于 TIME_WAIT 状态。这里 9527 是本地监听端口,36650 是 telnet 客户端端口。当然了,Telnet 客户端端口每次也会不尽相同。
通过服务器端发起的关闭连接操作,引起了一个已有的 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 讲中,我们提到过一个叫做 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 函数之前对监听字进行设置,想一想,为什么不是对已连接的套接字进行设置呢?
欢迎你在评论区写下你的思考,我会和你一起讨论交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 18

提建议

上一篇
14丨UDP也可以是“已连接”?
下一篇
16 | 如何理解TCP的“流”?
unpreview
 写留言

精选留言(34)

  • J.M.Liu
    2019-09-07
    关于tcp_tw_reuse和SO_REUSEADDR的区别,可以概括为:tcp_tw_reuse是为了缩短time_wait的时间,避免出现大量的time_wait链接而占用系统资源,解决的是accept后的问题;SO_REUSEADDR是为了解决time_wait状态带来的端口占用问题,以及支持同一个port对应多个ip,解决的是bind时的问题。

    作者回复: 总结得不错。

    共 4 条评论
    81
  • xupeng1644
    2020-01-15
    老师 思考题第二题的答案是什么啊

    作者回复: 因为SO_REUSEADDR是针对新建立的连接才起作用,对已建立的连接设置是无效的。

    24
  • G先生
    2019-10-13
    UDP的SO_REUSEADDR使用场景比较多的是组播网络,好处是,如我们在接收组播流的时候,比如用ffmpeg拉取了一个组播流,但是还想用ffmpeg拉取相同的组播流,这个时候就需要地址重用了

    作者回复: 👍

    共 2 条评论
    19
  • 惘 闻
    2020-09-11
    老师我有个疑问,这里的服务器直接关闭了连接,在关闭连接之前发送了FIN报文,此时就已经关闭了吧?所以就收不到客户端回复的ack以及客户端的fin了,连接关闭发起方还未走到接收对端发送的fin的那一步,此时也会进入timewait阶段吗?

    作者回复: 有几个需要澄清的问题,所谓FIN-ACK这些都是协议栈在帮我们处理,虽然应用服务器已经关闭,这些处理能力还会在内核得以执行的,只不过我们应用程序进程已经退出,没有办法收到内核对这些事件的传递。 所以,连接关闭发起方,还是会进入TIME_WAIT状态。

    共 4 条评论
    13
  • HerofH
    2019-12-26
    老师您好,我有个疑问,根据我的理解,TIME_WAIT是主动关闭方才会存在的状态,而服务端很多时候都是被动关闭方,为什么也会有TIME_WAIT状态呢?还是说服务端套接字设置SO_REUSEADDR只是用于服务端主动关闭的情况(比如快速重启)呢?

    作者回复: 你的理解是对的。

    8
  • 海盗船长
    2020-04-10
    老师 是不是Address already in use。我最长等待2msl时间后 重启就不会有问题啦?

    作者回复: 你如果等也是可以的.......

    共 3 条评论
    7
  • vv_test
    2021-06-12
    TCP 的机制绝对不允许在相同的地址和端口上绑定不同的服务器。 老师您好请问一下,Nginx 的master跟woker都是监听80端口,他们都有各自的进程号。那他们这种为什么可以多次绑定的

    作者回复: 不是这样的哦,Nginx在80端口上监听,worker只是开启的内部干活的线程,并不真的监听在80端口上

    共 2 条评论
    4
  • pc
    2020-06-03
    老师 有几个疑问~ 1、对于客户端理论上也会发生Address already in use 的错误吧?(当没有SO_REUSEADDR、端口也重复了时)是在connect的时候报错吗? 2、“一个 TCP 连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定”--这句话不是很理解,对于服务端bind的时候不是没有目的地址吗?难道是在accept的时候报错吗?可是accept时候不是阻塞等待客户端连接吗?没有很理清楚....
    展开

    作者回复: 1.我觉得是的; 2.bind的时候还没有建立TCP连接哦,只有accept成功返回才真正的建立了一个TCP连接,这个时候才有四元组可以描述这个TCP连接。

    3
  • 凌空飞起的剪刀腿
    2021-05-20
    第一道,之前我们看到的例子,都是对 TCP 套接字设置 SO_REUSEADDR 套接字选项,你知道吗,我们也可以对 UDP 设置 SO_REUSEADDR 套接字选项。那么问题来了,对 UDP 来说,设置 SO_REUSEADDR 套接字选项有哪些场景和好处呢? UDP的SO_REUSEADDR使用场景比较多的是组播网络,好处是,如我们在接收组播流的时候,比如用ffmpeg拉取了一个组播流,但是还想用ffmpeg拉取相同的组播流,这个时候就需要地址重用了 第二道,在服务器端程序中,设置 SO_REUSEADDR 套接字选项时,需要在 bind 函数之前对监听字进行设置,想一想,为什么不是对已连接的套接字进行设置呢? 因为SO_REUSEADDR是针对新建立的连接才起作用,对已建立的连接设置是无效的。
    展开

    作者回复: 👍

    2
  • linker
    2020-03-21
    听完之后,程序也做了调试,但是一周又忘记了,看来还是得反复听,反复练习啊!

    作者回复: 嗯,反复练习最重要。

    2
  • 林林
    2019-11-01
    老师,我们项目也存在服务器无法快速重启的问题,然而底层代码不开放,无法加上so_reuseaddr,不知道有没有其他办法可以解决?

    作者回复: 这个,给他们提issue吧。

    共 4 条评论
    2
  • 不动声色满心澎湃
    2019-09-04
    老师 有个疑问想问下:如果我的服务器是双网卡。一个192.168.1.220 一个是192.68.1.221 然后我让220和端口8010 处于time_wait状态, 这个时候再用221和8010去启动一个程序,那会报addr in use吗

    作者回复: 当然不会,TCP四元组,里面就有你这里提到的server_port。它改变了,四元组就不唯一了呀。

    2
  • nil
    2019-09-04
    学生时代写网络编程作业,调试的时候经常有遇到这个问题,然后通过每次改变端口号绕过这个问题。想想当时遇到问题一知半解,也不知道去寻找根本原因,哈哈哈,估计心思都在完成作业上,而根本不是想要掌握这个技术底层的原理

    作者回复: 哈哈,好在我们有大把的端口可以使用的。

    2
  • 传说中的成大大
    2019-09-04
    我竟然是沙发 第一问: 百度出来的 针对udp是允许完全的重复的捆绑 就是是udp允许把ip地址绑定到多个套接口上,大概是为了在同一机器上运行多个多播程序的情况下,具体的实例却想不出来 2. 因为我觉得bind函数时告诉内核我要监听这个ip地址和端口是在内核层的事情, 如果bind过后再进行设置套接字选项的话虽然是在应用层对套接字进行了修改,但是没告诉内核,这个地址需要避开timewait状态直接重用,大意就是没有影响到内核的处理
    展开

    作者回复: UDP用的比较少,实例确实比较难以想到。

    2
  • test
    2020-08-12
    所以现在等待2MSL都是没用的了是么?

    作者回复: 不是哦,2MSL还是一个很重要的值。

    共 2 条评论
    1
  • Geek_68d3d2
    2019-12-07
    没明白复用socket,如果复用了socket是不是就是相当于旧有的socket连接没有了time_wait时间了?也就是说对端可能收不到fin而一直处于等待状态?

    作者回复: 应该这样说是将原来处于TIME_WAIT状态的连接变成可利用的,而FIN是在TIME_WAIT状态之前已经发送出去了,不会引起对端一直处于等待状态。

    共 3 条评论
    1
  • 2019-11-23
    最佳实践: 服务器端程序,都应该设置 SO_REUSEADDR 套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。 在所有 TCP 服务器程序中,调用 bind 之前请设置 SO_REUSEADDR 套接字选项。 实际开发,这个遇到的确实多一点,自己都是找到是谁占用的端口,然后弄死它,再重启应用。 最早时都不知道怎么找,直接重启机器,再重启应用。
    展开

    作者回复: 太暴力了吧

    共 2 条评论
    1
  • 不动声色满心澎湃
    2019-09-04
    老师, 希望可以多讲一点。 感觉听了很爽 但是觉得不够 哈哈哈

    作者回复: 哈哈,我写得很累的说。

    1
  • dll
    2022-07-30 来自北京
    TCP 的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置 SO_REUSEADDR 套接字选项,也不可能在 ANY 通配符地址下和端口 9527 上重复启动两个服务器实例。 —————————————————— 什么叫服务器实例?
  • smiler
    2022-07-15
    老师,我问下问什么客户端主动关闭,就不会出现这样的问题