(本文内容仅针对Linux环境,内核版本4.15,thrift版本0.11.0,glibc版本2.27)
有江湖传言称,“使用localhost访问本机服务比使用127.0.0.1要快”,这有没有道理呢?
自然是没有道理的,除了个别的特例(如https://www.php.net/mysql_connect)因为会对localhost做特殊处理(使用UNIX domain socket替代TCP socket)所以可能快一点(存疑,未验证)外,其他情况使用localhost替代127.0.0.1并不会带来速度的提升。
如果要较真的话,localhost是要慢的,毕竟localhost是一个域名而不是地址,struct sockaddr里面可没有放域名的地方,只能放地址,必须要对localhost多做一次域名查询才能连接,因此localhost反而会慢。
那到底是用localhost还是127.0.0.1呢?我的建议是使用127.0.0.1,之前我认为两者区别不大,可以相互替代,但是最近遇到的一个bug改变了我的看法。
在机器中有一对C/S结构的进程,使用thrift通信,由于是在一台机器上,所以客户进程使用了localhost连接服务进程。
stdcxx::shared_ptr<TTransport> socket(new TSocket("localhost", 9090));
最近有这样一个问题:机器启动时使用启动脚本拉起来两个进程时,服务进程响应正常。但是如果把客户进程杀掉,手动启动客户进程,有时就会出现服务进程无法响应客户进程的问题。如果把服务端杀掉重启,则问题消失。进一步排查发现:如果机器启动时插着网线,这个问题不会出现;而如果机器启动时没有插网线,则这个问题必现。由于机器大部分时间都插着网线,所以这个问题看起来像是一个偶发问题一样。
当问题出现后,重启客户进程,提示“Connection refused”。
$ ./client
Thrift: Sun Jul 26 14:16:59 2020 TSocket::open() connect() Connection refused
ERROR: connect() failed: Connection refused
使用netstat查看服务进程监听的端口,没有问题,正常监听9090端口。
$ netstat -antp | grep 9090
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 2284/./server
对比一下没有这个bug时进程的监听状态:
$ netstat -antp | grep 9090
tcp6 0 0 :::9090 :::* LISTEN 3509/./server
嗯?好像和刚才的输出不太一样。刚才出bug时服务进程监听的是一个IPv4地址0.0.0.0,而现在监听的是一个IPv6地址[::]。那么现在有两个问题:
下面来解答一下这两个问题。
thrift客户端在TSocket::local_open打开socket,连接服务端,使用了getaddrinfo来解析域名:
struct addrinfo hints, *res, *res0;
res = NULL;
res0 = NULL;
int error;
char port[sizeof("65535")];
std::memset(&hints, 0, sizeof(hints));
hints.ai_family = PF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG;
sprintf(port, "%d", port_);
error = getaddrinfo(host_.c_str(), port, &hints, &res0);
对解析得到的地址逐个进行连接,如果有一个地址连接成功,则不再尝试后面的地址;如果所有的地址都连接失败,则抛出异常:
for (res = res0; res; res = res->ai_next) {
try {
openConnection(res);
break;
} catch (TTransportException&) {
if (res->ai_next) {
close();
} else {
close();
freeaddrinfo(res0); // cleanup on failure
throw;
}
}
}
使用和thrift一样的参数调用getaddrinfo解析localhost会得到什么呢?我期望是127.0.0.1和[::1],但事实并非如此,我将getaddrinfo包装后进行了测试:
$ ./getaddrinfo localhost 9090
Host: localhost Port: 9090
addrinfo(0x559872825320) >>>>>>>>>>
ai_flags: 33
ai_family: AF_INET6
ai_socktype: SOCK_STREAM
ai_protocol: IPPROTO_TCP
ai_addrlen: 28
ai_addr: [::1]:9090
ai_canonname: (null)
ai_next: (nil)
addrinfo(0x559872825320) <<<<<<<<<<
只返回了一个IPv6地址[::1],而服务进程并没有监听[::]:9090这个套接字,TCP/IP协议栈也不会自动把[::1]映射到127.0.0.1,所以连接会失败。
查看/etc/hosts后本案宣布告破,因为localhost竟然只有一个IPv6的条目:
::1 localhost
假如情况反过来:服务进程监听了[::]:9090套接字,而客户进程连接127.0.0.1:9090会发生什么情况呢?这要看服务进程监听套接字是否设置了IPV6_V6ONLY选项,如果设置了该选项,客户进程连接127.0.0.1:9090会失败,如果未设置,则能够成功连接。
IPV6_V6ONLY (since Linux 2.4.21 and 2.6)
If this flag is set to true (nonzero), then the socket is restricted to sending and receiving IPv6 packets only. In this case, an IPv4 and an IPv6 application can bind to a single port at the same time.
If this flag is set to false (zero), then the socket can be used to send and receive packets to and from an IPv6 address or an IPv4-mapped IPv6 address.
thrift服务端在TServerSocket::listen打开监听套接字:
const struct addrinfo *res;
int error;
char port[sizeof("65535")];
THRIFT_SNPRINTF(port, sizeof(port), "%d", port_);
struct addrinfo hints;
std::memset(&hints, 0, sizeof(hints));
hints.ai_family = PF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG;
// If address is not specified use wildcard address (NULL)
TGetAddrInfoWrapper info(address_.empty() ? NULL : &address_[0], port, &hints);
类似客户端,也使用了getaddrinfo获取struct addrinfo,并且hints参数也完全一致,ai_flags都设置了AI_PASSIVE和AI_ADDRCONFIG。
我们看一下getaddrinfo手册中AI_ADDRCONFIG的含义:
If hints.ai_flags includes the AI_ADDRCONFIG flag, then IPv4 addresses are returned in the list pointed to by res only if the local system has at least one IPv4 address configured, and IPv6 addresses are returned only if the local system has at least one IPv6 address configured. The loopback address is not considered for this case as valid as a configured address. This flag is useful on, for example, IPv4-only systems, to ensure that getaddrinfo() does not return IPv6 socket addresses that would always fail in connect(2) or bind(2).
大致意思就是,如果设置了这个flag,那么只有本地网卡配置了至少一个IPv4地址的时候,getaddrinfo返回的地址列表中才会含有IPv4地址;只有本地网卡配置了至少一个IPv6地址的时候,getaddrinfo返回的地址列表中才会含有IPv6地址;loopback地址不被考虑在内。
继续查看thrift源码,我们可以发现,thrift在挑选监听套接字绑定的地址时,会优先绑定IPv6地址:
// Pick the ipv6 address first since ipv4 addresses can be mapped
// into ipv6 space.
for (res = info.res(); res; res = res->ai_next) {
if (res->ai_family == AF_INET6 || res->ai_next == NULL)
break;
}
这时我们可以推测,机器使用启动脚本启动服务端的时候,网卡并没有配置IPv6地址,只有IPv4地址。IPv4地址是静态配置的,那么IPv6地址是如何获取的呢?
ip addr命令的输出如下,enp0s3网卡配置有一个IPv6地址“fe80::52ca:a4fb:1e87:8e1f/64”,后面的“scope link”表示这是一个本地链路地址,这个本地链路地址是如何配置的呢?
$ ip addr
1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp0s3: mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 08:00:27:ec:05:82 brd ff:ff:ff:ff:ff:ff
inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3
valid_lft 85450sec preferred_lft 85450sec
inet6 fe80::52ca:a4fb:1e87:8e1f/64 scope link noprefixroute
valid_lft forever preferred_lft forever
使用IPv6字符串对dmesg进行搜索,,可以有所发现:
$ dmesg | grep IPv6
[ 2.067635] Segment Routing with IPv6
[ 43.289730] IPv6: ADDRCONF(NETDEV_UP): enp0s3: link is not ready
[ 43.318792] IPv6: ADDRCONF(NETDEV_UP): enp0s3: link is not ready
[ 43.318806] IPv6: ADDRCONF(NETDEV_CHANGE): enp0s3: link becomes ready
在内核代码中搜索“link becomes ready”,会找到addrconf_notify函数,进而可以发现以下调用链:
addrconf_notify -> addrconf_dev_config -> addrconf_addr_gen
在addrconf_addr_gen中,首先会调用ipv6_generate_eui64生成一个IPv6地址,最后调用addrconf_add_linklocal将这个地址配置到网卡:
if (ipv6_generate_eui64(addr.s6_addr + 8, idev->dev) == 0)
addrconf_add_linklocal(idev, &addr, 0);
按照我的理解,在网卡连上网线,准备就绪的的时候,内核就会收到NETDEV_CHANGE的通知,进而为网卡配置本地链路IPv6地址。
这时我们可以得出结论:如果服务端在IPv6地址配置完成之前启动,那么会出现上面的bug,因为服务端此时只能监听IPv4地址;如果服务端在IPv6地址配置完成之后启动,就不会出bug,因为此时服务端监听了IPv6地址。问题1已经解释了为什么在监听IPv4地址时会出bug。如果机器插着网线,内核可以在很早的时刻配置好IPv6地址;如果机器没有插着网线,服务端在被开机脚本启动时内核并没有为网卡配置好IPv6地址。
从问题1的分析中可以看出来,域名解析这一步是存在不确定性的。有些我们认为理所应当的行为只是因为默认的配置文件如此,而如果配置文件被修改,我们所依赖的前提条件可能就不成立了。所以我认为应当尽量使用127.0.0.1来替代localhost。