再说socket的SO_REUSEPORT选项

  上次扯epoll的时候连带提到SO_REUSEPORT这个socket标志,虽说标志比较的简单,说来就是允许在一台主机上的一个IP:Port上可以创建多个socket,而且这些socket可以分布在相同主机的同一个线程、多个线程、乃至多个进程中去,内核会自动把这个端口的请求自动分派到各个socket上面去,而且这个过程没有用户惊群、互斥等问题。正如前面一篇文章所描述的那样,事件驱动框架epoll为利用多核优势运行在多线程、多进程环境下,伴随而来的各种毛病问题多多,而这个标志看似就是解决这些毛病的良药。
reuseport

1
2
3
int optval = 1;
setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
bind(sfd, (struct sockaddr *) &addr, addrlen);

  在使用的时候,只要第一个服务设置了这个选项(为了保护端口,否则恶意程序可以偷取任意端口的数据),那么后续的服务设置这个选项然后进行bind(为了安全,后续程序必须和第一个程序具有相同的effective-uid)。除了常用的TCP服务端外,UDP也可以使用该选项,比如常见的DNS服务器,传统上是多个线程竞争执行recv()操作接收数据报,而使用SO_REUSEPORT选项后,内核负责将请求分配在这些socket上面。
  之所以要把这个东西单独拿出来研究一下,是这个特性可能会影响甚至改变高性能服务器的设计和实现,以后的服务不叫多线程服务、多进程服务,而称之为多socket服务了。
  在传统模式上开发高性能服务器,主要的方式有:
  (1) 多线程模式
  最常见的可以使用一个线程专职accept()所有的连接请求,然后将这些套接字传递给其他的工作线程进行处理,比如memcached就是这么干的。这种方式没有太大的弊病,而且后端任务的分派可以使用自定义的派发算法,而其主要问题是当服务连接的请求量巨大的时候,这个专职线程的accept()能力会成为整个系统的性能瓶颈。
  另外一种多线程服务端的工作就是让所有的线程都共享listen socket,然后各自在自己的循环中阻塞等待(而非基于事件驱动)连接的请求。这种方式最主要的问题是各个线程的唤醒可能是很不平衡的,其最终可能导致CPU不能平均的利用、服务响应时间差异大等各种问题。
  (2) 多进程模式
  这总模式下又可以分两种情况:
  在fork process模式下,主进程创建一个listen socket并且调用accept(),当一个客户端链接到达的时候,主进程创建子进程,而子进程自动继承了客户端连接创建的新socket,子进程负责该socket上的所有业务处理。
  在pre-fork multi-process模式下,主进程创建一个listen socket,同时创建N个子进程,所有的子进程调用accept()等待客户端的连接,由操作系统负责将连接请求分配在这些子进程中。这种模式相比上种模式减少频繁创建和删除进程的开销,而且可以根据负载情况伸缩子进程的数目,也不会像上面模式下连接多了之后导致子进程无限制的创建。

  这么看来的话,SO_REUSEPORT的一大特性就是可以在多个socket实例上均匀的分派任务,这对多个工作者的负载均衡、充分利用CPU是十分重要的。在以Nginx这类事件驱动的服务端来说,SO_REUSEPORT还意味着避免惊群效应,以及使用accept_mutex避免上述情况所带来的性能影响,当然现在的epoll都支持EPOLLEXCLUSIVE,所以默认Nginx已经禁用accept_mutex了。此外,在分派任务的时候还进行了4元组的一致性hash算法,这样相同的客户端就会被分派到同一个socket上面去,显然简化了有状态服务的开发工作,不过为此付出的代价就是负载不会绝对的均衡,不过Linux在实现上确保UDP是绝对平均负载均衡的。而且当前的实现也有缺点,就是当listen socket的数目发生变化(比如新服务上线、已存在服务终止)的时候,根据SO_REUSEPORT的路由算法,客户端和服务端正在进行三次握手的阶段最终的ACK可能不能正确送达到对应的socket,导致客户端连接发生Connection Reset,不知道新版内核有没有修复这个问题。

  当然,说到这个标志,另外一个十分相似的SO_REUSEADDR标志也不免需要在此澄清一下。在Stackoverfollow上看到一个讲述这个东西比较好的回答,因此也拿来了。
  在网络通信中,其实也可以看做五元组来决定一个TCP/UDP连接:

1
{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}

  其中的protocol代表协议,比如TCP、UDP等,在创建socket的时候就被指定了;src addr和src port在调用bind()的时候被指定了;dest addr和dest port通过客户端connect()的时候被指定。我们在bind的时候src port可以是0,这样就让操作系统自动选择一个可用端口,但是服务端一般都是指定绑定端口才有意义,而src addr可以使用”0.0.0.0”或者”::”指定,这样操作系统就会认为该程序绑定本地接口的所有IP地址,后续在程序收到连接的时候,根据目标地址和路由规则表,操作系统会自动选择一个合适的本地IP作为src addr完成一个连接所有要素的确定。如果操作系统所在的机器具有多个接口和IP地址,那么同一台机器不同接口绑定到相同端口是合法的,这可以允许在不同接口上运行多个服务实例,不过一旦一个程序绑定到”0.0.0.0:X”地址,那么相当于这个程序绑定了所有本地接口的指定X端口上面,即使别的程序显式使用某个本地地址绑定到X端口也是非法的。
  在BSD中,如果使用了SO_REUSEADDR情况就不一样了,除非多个程序同时绑定到”0.0.0.0:X”,否则”0.0.0.0:X”不再排斥指定更具体类型的X端口绑定。不过对于Linux实现,这个功能是不支持的,Linux不允许INADDR_ANY地址和任意其他地址共享指定端口号,即在Linux绑定中如果指定了INADDR_ANY地址,那么他就会自动绑定到所有的本地接口上去,自然也就不会存在UNIX中这种安全隐患:如果一个服务器绑定到统配地址INADDR_ANY上去,另外一台设置SO_REUSEADDR然后绑定到相同端口但是更具体的地址上去,那么就可以从第一个服务器那里偷取一些连接过来。
  除此之外,SO_REUSEADDR还有另外的一个作用。对于网络发送数据,上层应用程序只是将数据放到发送缓冲区就结束了,那么此时当关闭socket的时候很可能发送缓冲区还有数据没有发送出去,但是上层send()调用却返回成功了,所以为了保证可靠传输,关闭socket的时候如果还有数据待发送其将进入一个TIME_WAIT的状态,这个状态一直持续到所有待发数据发送完或者Linger Time(通常操作系统全局配置,通常2min,也可以使用SO_LINGER进行socket级别的设置)超时,才会强制关闭这个socket。所以此时如果没有使用SO_REUSEADDR,那么操作系统还是会认为处于TIME_WAIT状态的socket占用对应的地址和端口,此时尝试绑定就会失败,这种情况在服务短时间重启的时候比较容易触发。而如果添加SO_REUSEADDR后进行绑定,操作系统就会忽略TIME_WAIT这种半关闭状态socket对地址和端口的占用,虽然新socket和那个半关闭的旧socket共存可能会产生意想不到的情况发生,不过实践上十分罕见。
  这里再度解释一下:其实服务端这种行为比较的让人诡异,只有四元组完全相同的连接才可能导致问题,才需要TIME_WAIT来规避相关的问题,为什么会对服务端的TIME_WAIT进行一票否决呢?主要是因为socket API的温蒂导致的,socket API需要两次调用才能完整确定四元组,当服务端调用bind的时候不知道将来会有哪些客户端连接过来,所以就只能这么武断的做出拒绝了。其实,我们应该总是在服务端设置SO_REUSEADDR,因为大多数情况下都很少概率会碰到四元组的问题,而且如果真的碰巧之前的那个客户端链接过来撞到了,TCP就会拒绝连接,虽然此时客户端看起来会有些困惑。

  
  其实的话,这个标志和功能也不是Linux首创,它在BSD系统上面早就支持了(同时macOS也因此得利),由此不得不再次膜拜BSD操作系统在计算机网络界无可撼动的鼻主地位。即便现在如火如荼的互联网世界中,其市场份额几乎可以被忽略不计,不过他仍然对互联网时代的进步不断地贡献自己的余热!

本文完!

参考