网络编程学习12--Address already in use问题(SO_REUSEADDR选项)

2,096 阅读7分钟

服务器端程序需要绑定一个本地地址和端口,然后监听在这个地址和端口上。但是当服务器端程序重启后,总是碰到“Address in use”的报错信息,导致服务器程序不能很快地重启。

当我们启动服务器程序,然后启动客户端程序,向服务器端发送数据,并在客户端通过"Ctrl + C"关闭程序,此时会发现服务器端收到EOF,也会关闭连接,如果此时将服务器重启的话,并不会出现错误:

image-20211217174204300

可是如果连接的关闭顺序被改变,即先关闭服务器(Ctrl + C),此时在客户端会感知到服务器关闭从而退出:

image-20211217185554674

此时,如果此时马上尝试重启服务器程序的话,就会失败,并报错bind fail: Address already in use:

image-20211217185720656

导致以上错误的原因:

由于主动关闭连接的一方,在接收到对端的FIN报文后,会在 TIME_WAIT 状态停留一段时间(2MSL)。通过服务器端发起关闭连接的操作后,会让TCP连接处于 TIME_WAIT 状态, 而如果此时将服务器程序重启的话,由于原先的地址和端口已经被这个TIME_WAIT状态的连接占用,所以就会返回 Address already in use 的错误。

setsockopt函数

setsockopt是专门用来设置socket文件描述符属性的方法:

 #include <sys/socket.h>
 int setsockopt(int sockfd, int level, int option_name, const void* option_value, socklen_t option_len);

sockfd参数指定被操作的目标socket。level参数指定要操作哪个协议的选项(即属性),比如IPv4、IPv6、TCP等(一般设置成SOL_SOCKET以存取socket 层)。option_name参数指定选项的名字。option_value和option_len参数分别是被操作选项的值和长度。不同的选项具有不同类型的值。

setsockopt函数成功时返回0,失败时返回-1并设置errno。

SO_REUSEADDR套接字选项

一个TCP连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的。

对于客户端来说,如果每次客户端使用的本地端口都不同,就不会和已有的四元组冲突,也就不会出现上述的TIME_WAIT新旧连接化身冲突的问题。即使在极小的概率下(因为客户端一般都是随机端口,所以端口大概率和之前的连接不同),客户端使用了相同的端口,从而造成了新连接和旧连接的四元组相同,也不会造成什么大问题,这是因为Linux操作系统对此进行了一些优化:

  1. 第一个优化是:新连接的SYN序列号,一定比老连接TIME_WAIT的末序列号大,这样通过序列号即可分辨出新老连接。
  2. 第二个优化是:开启了时间戳tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。

在上述优化下,一个 TIME_WAIT 的 TCP 连接可以忽略掉旧连接,重新被新的连接所使用。

通过SO_REUSEADDR套接字选项,可以达到上述目的,通过给套接字配置可重用属性,告诉操作系统内核,这样的 TCP 连接可以复用 TIME_WAIT 状态的连接,代码如下:

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

SO_REUSEADDR的功能

  1. SO_REUSEADDR套接字选项,允许绑定在一个端口,即使之前存在一个和该端口一样的连接。

    前面的例子表明,在服务器重启时,如果试图 bind 到一个现有连接上的端口,会报错,所以我们应该在创建socket和bind之间,使用上面的代码设置SO_REUSEADDR选项,这样就不会出错了。

  2. SO_REUSEADDR套接字选项,允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的IP地址即可。也就是说本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务

    举例来说,假设本地主机的主IP地址为198.69.10.2,此外它还有两个别名:198.69.10.128和198.69.10.129。在此主机上启动三个HTTP服务器。第一个HTTP服务器以本地通配地址INADDR_ANY和本地端口号80调用bind,第二个HTTP服务器以本地IP地址198.69.10.128和本地端口号80调用bind。这次bind调用将会失败,除非在调用前设置了SO_REUSEADDR套接字选项。第三个HTTP服务器以本地IP地址198.69.10.129和本地端口号80调用bind,其成功的先决条件同样是预先设置SO_REUSEADDR。

    假设SO_REUSEADDR均已设置,三个服务器已经启动,一个目的IP地址为198.69.10.128、目的端口号为80的外来TCP连接请求将被传递给第二个服务器,目的IP地址为198.69.10.129、目的端口号为80的外来TCP连接请求将被传递给第三个服务器,目的端口号为80的所有其他TCP连接请求将都传递给第一个服务器。即第一个服务器("默认"服务器)处理目的地址为198.69.10.2或该主机已经配置的任何其他IP别名的请求。这里的通配地址的意思就是"没有更好的匹配的任何地址"。

    但是,对于TCP,绝不可能启动捆绑相同IP地址和相同端口号的多个服务器:这是完全重复的捆绑。也就是说,我们不可能在启动绑定198.69.10.2和端口80的服务器后,再启动同样捆绑198.69.10.2和端口80的另一个服务器,即使给第二个服务器设置了SO_REUSEADDR套接字选项也不行。(只有在第一个服务器的TCP连接处于TIME_WAIT状态时,才可以启动第二个服务器)

注意:SO_REUSEADDR是针对新建立的连接才起作用,对已建立的连接设置是无效的。 所以我们需要在bind前设置SO_REUSEADDR选项。

最佳做法

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

有些人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我在前面已经讲过,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_tw_reuse和SO_REUSEADDR的区别,可以概括为:tcp_tw_reuse是为了缩短time_wait的时间,避免出现大量的time_wait链接而占用系统资源;SO_REUSEADDR是为了解决time_wait状态带来的端口占用问题,以及支持同一个port对应多个ip,解决的是bind时的问题。

UDP使用SO_REUSEADDR

对于UDP来说,SO_REUSEADDR允许完全重复的捆绑:即当一个IP地址和端口已经绑定到某个套接字上时,同样的IP地址和端口还可以捆绑到另一个套接字上。

本特性用于多播(组播)时, 允许在同一个主机上同时运行同一个应用程序的多个副本。当一个UDP数据报需要由这些重复捆绑套接字中的一个接收时,所用规则为:如果该数据报的目的地址是一个广播地址或多播地址,那就给每个匹配的套接字发送一个该数据报的副本;如果该数据报的目的地址是一个单播地址,那么它只发送给单个套接字。