乐趣区

socket的SOREUSEADDR参数全面分析

在具体分析之前,我们先看下 socket(7) 的 man 文档对这个参数是怎么介绍的:

SO_REUSEADDR
      Indicates that the rules used in validating addresses supplied
      in a bind(2) call should allow reuse of local addresses.  For
      AF_INET sockets this means that a socket may bind, except when
      there is an active listening socket bound to the address.
      When the listening socket is bound to INADDR_ANY with a spe‐
      cific port then it is not possible to bind to this port for
      any local address.  Argument is an integer boolean flag.

从这段文档中我们可以知道三个事:

  1. 使用这个参数后,bind 操作是可以重复使用 local address 的,注意,这里说的是 local address,即 ip 加端口组成的本地地址,也就是说,两个本地地址,如果有任意 ip 或端口部分不一样,它们本身就是可以共存的,不需要使用这个参数。
  2. 当 local address 被一个处于 listen 状态的 socket 使用时,加上该参数也不能重用这个地址。
  3. 当处于 listen 状态的 socket 监听的本地地址的 ip 部分是 INADDR_ANY,即表示监听本地的所有 ip,即使使用这个参数,也不能再 bind 包含这个端口的任意本地地址,这个和 2 中描述的其实是一样的。

好,接下来我们看几个例子。

上文 1 中说,只要本地地址不一样(ip 或端口不一样),即使没有这个参数,两个地址也是可以同时使用的,我们来看下是不是这样。

下面是客户端的测试代码:

#include <arpa/inet.h>
#include <assert.h>
#include <netinet/in.h>
#include <stdio.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_connect(char *ip, int port) {
  int sfd, err;
  struct sockaddr_in addr;

  sfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(sfd != -1);

  // 先 bind 本地地址
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
  assert(!err);

  // 再连接目标服务器
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  addr.sin_port = htons(7777);

  err = connect(sfd, (struct sockaddr *)&addr, sizeof(addr));
  assert(!err);

  return sfd;
}

int main(int argc, char *argv[]) {
  // bind 本地地址:127.0.0.1:8888
  tcp_connect("127.0.0.1", 8888);

  // bind 本地地址:192.168.3.187:8888
  tcp_connect("192.168.3.187", 8888);

  printf("两个连接同时建立成功 \n");
  sleep(100);
  return 0;
}

该段代码中,会先绑定本地地址,再连接目标服务器,由上可见,两次连接 bind 的本地地址中,ip 部分是不同的,所以这两个 bind 操作应该是成功的。

我们用以下 ncat 命令模拟服务端:

$ ncat -lk4 7777

用 ss 命令查看有关 7777 端口的所有 socket 状态:

$ ss -antp | grep 7777
LISTEN      0        10               0.0.0.0:7777               0.0.0.0:*       users:(("ncat",pid=19208,fd=3))

由上可见,此时只有 ncat 服务端在监听 7777 端口,没有任何其他连接。

我们执行上面的程序,然后再次查看 7777 端口所有 socket 状态:

$ ss -antp | grep 7777
LISTEN      0        10               0.0.0.0:7777               0.0.0.0:*       users:(("ncat",pid=19208,fd=3))
ESTAB       0        0              127.0.0.1:7777         192.168.3.187:8888    users:(("ncat",pid=19208,fd=5))
ESTAB       0        0              127.0.0.1:7777             127.0.0.1:8888    users:(("ncat",pid=19208,fd=4))
ESTAB       0        0          192.168.3.187:8888             127.0.0.1:7777    users:(("a.out",pid=19340,fd=4))
ESTAB       0        0              127.0.0.1:8888             127.0.0.1:7777    users:(("a.out",pid=19340,fd=3))

由上可以看到,这两个连接的确是建立成功了。

上面命令输出中,有 4 个 ESTAB 状态的连接,这是正常的,因为这分别是从服务端角度和客户端的角度得到的输出。

前三行是从服务器角度来看的,后两行是从客户端角度来看的,这个从后面的进程名也可以看出。

对客户端来说,在 connect 之前可以 bind 不同本地地址,然后连同一目标,对服务端来说也是可以的,在 listen 之前,完全可以 bind 不同的本地地址,不需要 SO_REUSEADDR 参数也可以成功,由于程序代码差不多,这里我们就不演示了。

我们下面再来看下 connect 之前,bind 相同地址的情况,下面是测试代码:

#include <arpa/inet.h>
#include <assert.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_connect(char *ip, int port) {
  int sfd, err;
  char buf[1024];
  struct sockaddr_in addr;

  sfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(sfd != -1);

  // 先 bind 本地地址
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  addr.sin_port = htons(8888);

  err = bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "bind(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  // 再连接目标服务器
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = connect(sfd, (struct sockaddr *)&addr, sizeof(addr));
  assert(!err);

  return sfd;
}

int main(int argc, char *argv[]) {
  // 连接的目标地址:127.0.0.1:7777
  tcp_connect("127.0.0.1", 7777);

  // 连接的目标地址:127.0.0.1:7778
  tcp_connect("127.0.0.1", 7778);

  printf("两个连接同时建立成功 \n");
  sleep(100);
  return 0;
}

该程序会在 connect 之前,bind 本地地址到 127.0.0.1:8888,然后再连接目标地址,两次目标地址分别是 127.0.0.1:7777 和 127.0.0.1:7778。

还是用 ncat 模拟服务端,只是这次要开两个。

服务端 7777:

$ ncat -lk4 7777

服务端 7778:

$ ncat -lk4 7778

运行客户端代码:

$ gcc client.c && ./a.out
bind(127.0.0.1:7778): Address already in use

由上可见,第二次连接是失败了的,因为 127.0.0.1:8888 本地地址已经被第一次 connect 使用过了。

此时,加上 SO_REUSEADDR 参数应该是可以解决这个问题的。

#include <arpa/inet.h>
#include <assert.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_connect(char *ip, int port) {
  int sfd, opt, err;
  char buf[1024];
  struct sockaddr_in addr;

  sfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(sfd != -1);

  // 先设置 SO_REUSEADDR
  opt = 1;
  err = setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  assert(!err);

  // 再 bind 本地地址
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  addr.sin_port = htons(8888);

  err = bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "bind(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  // 然后连接目标服务器
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = connect(sfd, (struct sockaddr *)&addr, sizeof(addr));
  assert(!err);

  return sfd;
}

int main(int argc, char *argv[]) {
  // 连接的目标地址:127.0.0.1:7777
  tcp_connect("127.0.0.1", 7777);

  // 连接的目标地址:127.0.0.1:7778
  tcp_connect("127.0.0.1", 7778);

  printf("两个连接同时建立成功 \n");
  sleep(100);
  return 0;
}

再次编译后执行:

$ gcc client.c && ./a.out
两个连接同时建立成功 

由上可以看到,这两次连接都成功了,SO_REUSEADDR 允许我们重复 bind 相同的本地地址。

细心的同学可能会发现,为什么两次连接的目标地址是不同的呢?

我们来把它改成相同的试下:

#include <arpa/inet.h>
#include <assert.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_connect(char *ip, int port) {
  int sfd, opt, err;
  char buf[1024];
  struct sockaddr_in addr;

  sfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(sfd != -1);

  // 先设置 SO_REUSEADDR
  opt = 1;
  err = setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  assert(!err);

  // 再 bind 本地地址
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr("127.0.0.1");
  addr.sin_port = htons(8888);

  err = bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "bind(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  // 然后连接目标服务器
  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = connect(sfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "connect(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  return sfd;
}

int main(int argc, char *argv[]) {
  // 连接的目标地址:127.0.0.1:7777
  tcp_connect("127.0.0.1", 7777);

  // 连接的目标地址:127.0.0.1:7777
  tcp_connect("127.0.0.1", 7777);

  printf("两个连接同时建立成功 \n");
  sleep(100);
  return 0;
}

此时,执行该程序,命令行会有如下输出:

$ gcc client.c && ./a.out
connect(127.0.0.1:7777): Cannot assign requested address

为什么呢?因为这两次连接都是从 127.0.0.1:8888 到 127.0.0.1:7777 的,这个在 tcp 层面是不允许的,即使加了 SO_REUSEADDR 参数也不行。

本地地址和目标地址组成的元组唯一确定一个 tcp 连接,上面程中的两次连接本地地址和目标地址都一样,已经违背了唯一的原则。

对应内核相应检查代码如下:

// net/ipv4/inet_hashtables.c
static int __inet_check_established(struct inet_timewait_death_row *death_row,
                                    struct sock *sk, __u16 lport,
                                    struct inet_timewait_sock **twp)
{
        struct inet_hashinfo *hinfo = death_row->hashinfo;
        struct inet_sock *inet = inet_sk(sk);
        __be32 daddr = inet->inet_rcv_saddr;
        __be32 saddr = inet->inet_daddr;
        ...
        const __portpair ports = INET_COMBINED_PORTS(inet->inet_dport, lport);
        unsigned int hash = inet_ehashfn(net, daddr, lport,
                                         saddr, inet->inet_dport);
        struct inet_ehash_bucket *head = inet_ehash_bucket(hinfo, hash);
        ...
        struct sock *sk2;
        ...
        sk_nulls_for_each(sk2, node, &head->chain) {
                ...
                if (likely(INET_MATCH(sk2, net, acookie,
                                         saddr, daddr, ports, dif, sdif))) {
                        ...
                        goto not_unique;
                }
        }
        ...
not_unique:
        ...
        return -EADDRNOTAVAIL;
}

如果本地地址和目标地址组成的元组之前已经存在了,则返回错误码 EADDRNOTAVAIL,这个错误码对应的解释为:

// include/uapi/asm-generic/errno.h
#define EADDRNOTAVAIL   99      /* Cannot assign requested address */

正好和上面执行程序输出的错误信息一样。

我们再回到对 SO_REUSEADDR 参数的讨论。

上面代码中,两个 connect 使用相同的本地地址,只要加上 SO_REUSEADDR 参数是可以的,那两个 listen 行吗?

看代码:

#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_listen(char *ip, int port) {
  int lfd, opt, err;
  char buf[1024];
  struct sockaddr_in addr;

  lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(lfd != -1);

  opt = 1;
  err = setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  assert(!err);

  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "bind(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  err = listen(lfd, 8);
  assert(!err);

  return lfd;
}

int main(int argc, char *argv[]) {tcp_listen("127.0.0.1", 7777);
  tcp_listen("127.0.0.1", 7777);
  return 0;
}

该代码执行之后输出如下:

$ gcc server.c && ./a.out
bind(127.0.0.1:7777): Address already in use

由上可见,即使加上 SO_REUSEADDR 参数,两个 listen 也是不行的。

其实,这个在最开始的 man 文档中已经说过了,只要有 listen 占了一个本地地址,其他任何操作都不能再使用这个地址了。

我们对应看下内核源码:

// net/ipv4/inet_connection_sock.c
static int inet_csk_bind_conflict(const struct sock *sk,
                                  const struct inet_bind_bucket *tb,
                                  bool relax, bool reuseport_ok)
{
        struct sock *sk2;
        bool reuse = sk->sk_reuse;
        ...
        sk_for_each_bound(sk2, &tb->owners) {if (sk != sk2 && ...) {
                        if ((!reuse || !sk2->sk_reuse ||
                            sk2->sk_state == TCP_LISTEN) && ...) {if (inet_rcv_saddr_equal(sk, sk2, true))
                                        break;
                        }
                        ...
                }
        }
        return sk2 != NULL;
}

该方法就是用来判断本地地址是否可以重复使用的代码。

如果该方法最终 sk2 不为 null,则最终会返回错误码 EADDRINUSE 给用户,即我们上面程序执行之后的错误输出。

我们来看下 sk2 什么时候不为 null。

在我们的新 socket 和 sk2 本地地址相同时,如果新 socket 没有设置 SO_REUSEADDR 参数,或者 sk2 没设置 SO_REUSEADDR 参数,或者 sk2 为 listen 状态,sk2 最终都会不为 null,也就是说,新 socket 的本地地址在这些情况下都不可重复使用。

和 man 文档中说的基本是一样的。

那我们在平时写服务器时,为什么要加上这个参数呢?我们都是先关闭服务器,再开的啊,以前那个 listen 的 socket,以及所有当时正在连接的 socket,应该都已经关闭了啊?应该不会存在相同的本地地址了啊?

为什么呢?

这要再说起 tcp 的 TIME_WAIT 状态。

我们知道,在 tcp 连接中,主动发起关闭请求的那一端会最终进入 TIME_WAIT 状态,被动关闭连接的那一端会直接进入 CLOSE 状态,即 socket 和它占用的资源会直接销毁。

假设,在我们关闭服务器之前,先把客户端都关闭掉,再关闭服务器,此时服务器的所有 socket 都直接进入 CLOSE 状态了,它们占用的本地地址等也都立即可用,此时如果我们马上开服务器,是不会出现 Address already in use 这个错误的。

但当我们在有客户端连接的情况下,直接关闭服务器,也就是说,对所有现有的 tcp 连接,服务端都主动发起了关闭请求,此时,这些连接就会进入 TIME_WAIT 状态,一直占用服务器使用的本地地址,不让后续操作使用。

这种情况下,你再开服务器,就会出现上面那个 Address already in use 错误,这也是我们写服务器时经常会遇到的错误。

解决这个问题的方法就是设置 SO_REUSEADDR 参数。

由上面的 inet_csk_bind_conflict 方法可以看到,如果设置了 SO_REUSEADDR 参数,新 socket 和旧 socket 的 reuse 值都会为 true,而旧 socket 此时处于 TIME_WAIT 状态,所以后续不会调用 inet_rcv_saddr_equal 方法,判断两个地址是否相同。

这样最终 sk2 也会为 null,也就是说,内核允许新 socket 使用这个地址。

用代码验证下:

#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static int tcp_listen(char *ip, int port) {
  int lfd, opt, err;
  char buf[1024];
  struct sockaddr_in addr;

  lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  assert(lfd != -1);

  opt = 1;
  err = setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  assert(!err);

  bzero(&addr, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = inet_addr(ip);
  addr.sin_port = htons(port);

  err = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
  if (err) {sprintf(buf, "bind(%s:%d)", ip, port);
    perror(buf);
    exit(-1);
  }

  err = listen(lfd, 8);
  assert(!err);

  return lfd;
}

int main(int argc, char *argv[]) {
  int lfd, cfd;

  lfd = tcp_listen("127.0.0.1", 7777);
  printf("5 秒钟之后将关闭第一次 listen 的 socket,请于此期间发起一次 tcp 连接 \n");
  sleep(5);

  cfd = accept(lfd, NULL, NULL);
  assert(cfd != -1);

  close(cfd);
  close(lfd);

  tcp_listen("127.0.0.1", 7777);
  printf("第二次 listen 操作成功 \n");
  return 0;
}

按照程序提示,对服务端发起 tcp 连接,最终服务端输出如下:

$ gcc server.c && ./a.out
5 秒钟之后将关闭第一次 listen 的 socket,请于此期间发起一次 tcp 连接
第二次 listen 操作成功 

可见,有了 SO_REUSEADDR 参数,即使我们先关闭的 tcp 连接,也是可以再次 listen 的。

有兴趣的朋友可以把设置 SO_REUSEADDR 参数的代码去掉,然后再执行看下,理论上来说是会报错的。

到此为止,所有有关 SO_REUSEADDR 参数内容都讲完了,希望对大家有所帮助。

完。

更多原创文章,请关注我微信公众号:

退出移动版