这是上一篇文章的姊妹篇,也是由于 OOM 导致不健壮的 Netty 一系列怪异的行为,这次的问题剖析会比前次那个更有意思一点。(备注:本文 Netty 版别是上古时代的 3.7.0.Final)

现象描绘

开发的同学反馈 dubbo 客户端无法调用远程的服务,抓包来看,客户端一直在建连,每次建连成功 3 秒今后就自动断开衔接。

一次 Netty 不健壮导致的无限重连分析

这个现象就很奇怪了,默许状况下 dubbo 消费端对属于同一个 provider 的不同 service 只会共享一条 tcp 衔接进行通讯,此处便是为了跟 provider 端树立这个衔接。

为什么这儿三次握手成功今后会断开衔接呢?这个现象其实挺怪异的,所以想到用 strace 看一下背后究竟发生了什么。

strace -f -T -p 238289 -o strace-new.238289.out

在 strace 中找 connect 相关的调用,依据线程号过滤对应的日志,能够看到发生了哪些体系调用:

一次 Netty 不健壮导致的无限重连分析

一开始就创立一个 socket,将该套接字设置为非堵塞,随后调用 connect 建议树立,由于是非堵塞套接字,connect 这儿不堵塞直接返回 -1,随后开始等待 3s,如果 3s 内没有能树立成功,futex 超时退出。

可是这个跟抓包的行为就不一致了,从包上看,duboo 服务端有回复 SYN+ACK,可是 java 运用认为我没有收到,3s 超时。

一起,这儿整个 strace 日志中没有看到对应 fd 相关 epoll_ctl 调用,也便是没有人把这个 fd 加入到 epoll 的事情监听中。

正常来说,咱们的一个非堵塞的 connect 编程是这样的。(以下代码来自 ChatGPT,错了别赖我)

// 设置 socket 为非堵塞形式
int set_nonblocking(int fd) {
    // 省略
}
// 衔接服务器
int connect_to_server(const char *hostname, int port) {
  int sockfd;
  struct sockaddr_in serv_addr;
  struct hostent *server;
  // 创立 socket
  sockfd = socket(AF_INET, SOCK_STREAM, 0);
  // 设置 socket 为非堵塞形式
  set_nonblocking(sockfd)
  // 测验衔接服务器
  if (connect(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
    if (errno != EINPROGRESS) { // 非堵塞下会返回这个错误码
      return -1;
    }
  }
  return sockfd;
}
// 运用 epoll 监听 socket 衔接的状况
int wait_for_connection(int sockfd) {
  int epfd, nfds;
  struct epoll_event ev, events[1];
  // 创立 epoll 实例
  epfd = epoll_create(1);
  // 将 socket 添加到 epoll 的事情监听集合中
  memset(&ev, 0, sizeof(ev));
  ev.events = EPOLLOUT | EPOLLET;
  ev.data.fd = sockfd;
  if (epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev) < 0) {
    return -1;
  }
  // 监听事情
  for (;;) {
    nfds = epoll_wait(epfd, events, 1, -1);
    if (nfds == -1) {
      return -1;
    }
    // 查看衔接是否成功
    if (events[0].events & EPOLLOUT) {
      return sockfd;
    }
  }
  return -1;
}
int main(int argc, char *argv[]) {
  const char *hostname = "localhost";
  int port = 8080;
  // 创立并衔接到服务器
  int sockfd = connect_to_server(hostname, port);
  if (sockfd < 0) {
    return 1;
  }
  // 运用 epoll 监听 socket 衔接的状况
  sockfd = wait_for_connection(sockfd);
  if (sockfd < 0) {
    return 1;
  }
  // 衔接成功,在这儿履行你想要的操作
printf("Connection established!\n");
// 封闭 socket
close(sockfd);
return 0;
}

现在的思路大约就清楚了:没有人调用 epoll 相关的函数去注册事情,导致内核收到 SYN+ACK 包今后,没有程序感兴趣去处理。

为什么没有向 epoll 注册事情

上面是建连是 Dubbo 的重连线程来完成的,重连线程的主要作用是检测和办理网络衔接的状况,如果发现衔接断开或反常,就会测验从头树立衔接。先来看一下重连线程做了什么,重连线程的创立坐落 com.alibaba.dubbo.remoting.transport.AbstractClient类中。

一次 Netty 不健壮导致的无限重连分析

Dubbo 内部用 ScheduledThreadPoolExecutor 线程池运行 reconnect 线程。 这个重连线程会调用 com.alibaba.dubbo.remoting.transport.netty.NettyClient.doConnect 建议建连。

一次 Netty 不健壮导致的无限重连分析

ClientBootstrap.connect 不会直接为 channel 注册事情,而是生成了一个 RegisterTask 放入了 NioClientBosstaskQueue 中,等待被处理。

一次 Netty 不健壮导致的无限重连分析

经过注入 stack java.util.concurrent.ConcurrentLinkedQueue offer -n 1 就能够发现,的确如此。

一次 Netty 不健壮导致的无限重连分析

如果 RegisterTask 的 run 办法被履行时,才是真正的注册事情。

一次 Netty 不健壮导致的无限重连分析

现在能够推断出 RegisterTask 的 run 没有被调用。

持续看 taskqueue 是怎么消费的,就知道 run 为什么没有被履行了。这个行列是在 org.jboss.netty.channel.socket.nio.AbstractNioSelector#processTaskQueue 中消费的

一次 Netty 不健壮导致的无限重连分析

这个办法是被 org.jboss.netty.channel.socket.nio.AbstractNioSelector#run 调用的,实际是完成类 org.jboss.netty.channel.socket.nio.NioClientBoss,这个类也是一个 runnable,发动后生成一个名为 New I/O boss #N 的线程,内部是一个无限循环消费 taskqueue 以及处理安排妥当事情。

一次 Netty 不健壮导致的无限重连分析

下一步便是进一步承认 taskqueue 是不是的确没有消费,这个能够经过 dump 内存的方法来验证,看看 taskqueue 里面的数据有没有变化。

一次 Netty 不健壮导致的无限重连分析

这下实锤了,接下往来不断 dump 线程堆栈,看看 New I/O boss 线程还在不在。

经过 jstack 比照承认,无限重连的服务的确没有 New I/O boss 线程。

一次 Netty 不健壮导致的无限重连分析

结合服务在深夜守时使命时堆内存 OOM 的日志,能够合理置疑由于 OOM 导致 New I/O boss 线程退出,没有能持续履行 run 办法消费行列,导致非堵塞建连 connect 今后没有用 epoll_ctl 注册感兴趣事情。

经过剖析,run 办法是有捕获 Throwable 反常的,如果有 OutOfMemoryError 会进入 catch 中,理论上线程不会挂掉。可是好死不死 catch 块还有逻辑,有 logger 去打印 warn 日志,这儿如果再次抛出 OutOfMemoryError,那就凉凉。

一次 Netty 不健壮导致的无限重连分析

怎么修改

  1. 优化代码,根绝 OOM
  2. 完善 Netty 对 OOM 的处理逻辑,核心线程退出今后重建
  3. 升版别。。。

跋文

只要能复现的基本上都能够被处理,安稳复现的那就更容易了。这个问题出现的概率比前次那个大量 CLOSE_WAIT 状况更低,可是好在开发的同学没改 bug,昨日又出现了。

Dubbo 版别真难升啊,不好用。。。