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

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

开篇词

上一讲我们讲到 UDP 也可以像 TCP 一样,使用 connect 方法,以快速获取异步错误的信息。

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

网络编程中,服务器程序需要绑定本地地址和一个端口,然后就监听在这个地址和端口上等待客户端连接的到来。

在实战中,你可能会经常碰到一个问题,当服务器端程序重启之后,总是碰到“Address in use”的报错信息,服务器程序不能很快地重启。

那么这个问题是如何产生的?我们又该如何避免呢?

今天我们就来讲一讲这个“地址已经被使用”的问题。

1.1、从例子开始

为了引入讨论,我们从之前讲过的一个 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 的方式在服务器端关闭连接


$telneet 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)

1.2、复习 TIME_WAIT

那么,这个错误到底是怎么发生的呢?

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 的错误

1.3、重用套接字选项

一个 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 端口时会出错。

1.4、最佳实践

总结成一句话: 服务器端程序,都应该设置 SO_REUSEADDR 套接字选项, 以便服务端程序可以在极短时间内复用同一个端口启动。

有些人可能觉得这不是安全的。

其实,单独重用一个套接字不会有任何问题。

我在前面已经讲过,TCP 连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的;

即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。

而且,TCP 的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置 SO_REUSEADDR 套接字选项,也不可能在 ANY 通配符地址下和端口 9527 上重复启动两个服务器实例。

如果我们启动第二个服务器实例,不出所料会得到 Address already in use 的报错,即使当前还没有任何一条有效 TCP 连接产生。


 $./addressused2
 bind faied: Address already in use(98)

前面提到过一个叫做 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 套接字选项。

这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。

思考题

1、之前我们看到的例子,都是对 TCP 套接字设置 SO_REUSEADDR 套接字选项,你知道吗,我们也可以对 UDP 设置 SO_REUSEADDR 套接字选项。那么问题来了,对 UDP 来说,设置 SO_REUSEADDR 套接字选项有哪些场景和好处呢?

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

2、在服务器端程序中,设置 SO_REUSEADDR 套接字选项时,需要在 bind 函数之前对监听字进行设置,想一想,为什么不是对已连接的套接字进行设置呢?

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

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

liufeng2023

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值