我们应该使用多个接受器套接字来接受大量连接吗?

Ale*_*lex 12 c sockets linux multithreading tcp

众所周知,SO_REUSEPORT允许多个套接字侦听相同的IP地址和端口组合,它将每秒请求增加2到3倍,并减少延迟(~30%)和延迟标准差(8次):https ://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1/

NGINX版本1.9.1引入了一项新功能,该功能支持使用 SO_REUSEPORT套接字选项,该选项适用于许多操作系统的较新版本,包括DragonFly BSD和Linux(内核版本3.9及更高版本).此套接字选项允许多个套接字侦听相同的IP地址和端口组合.然后内核负载平衡套接字上的传入连接....

如图所示,reuseport将每秒请求数增加了2到3倍,并减少了延迟延迟的标准偏差.

在此输入图像描述

在此输入图像描述

在此输入图像描述


SO_REUSEPORT适用于大多数现代操作系统:Linux(自2013年4月29日起内核> = 3.9),Free/Open/NetBSD,MacOS,iOS/watchOS/tvOS,IBM AIX 7.2,Oracle Solaris 11.1,Windows(仅表现为2个标志)在BSD上一起+ ,可能在Android上:https: //stackoverflow.com/a/14388707/1558037SO_REUSEPORTSO_REUSEPORTSO_REUSEADDR

Linux> = 3.9

  1. 此外,内核为SO_REUSEPORT其他操作系统中找不到的套接字执行一些"特殊魔法" :对于UDP套接字,它尝试均匀分布数据报,对于TCP侦听套接字,它会尝试分发传入的连接请求 (通过调用接受的请求accept())均匀地跨越共享相同地址和端口组合的所有套接字.因此,应用程序可以轻松地在多个子进程中打开相同的端口,然后使用它 SO_REUSEPORT来获得非常便宜的负载平衡.

同样众所周知,为了避免自旋锁定和高性能锁定,不应该有超过1个线程的套接字.即每个线程都应该处理自己的套接字以进行读/写.

POSIX.1-2001/SUSv3 需要accept(),bind(),connect(),listen(),socket(),send(),recv()等作为线程安全函数.标准中可能存在关于它们与线程交互的一些含糊之处,但其意图是它们在多线程程序中的行为受标准控制.

与单线程程序相比,接收性能下降.这是由UDP接收缓冲区的锁争用引起的 .由于两个线程都使用相同的套接字描述符,因此它们花费了不成比例的时间来争夺UDP接收缓冲区周围的锁定.本文更详细地描述了该问题.

V. K ERNEL ISOLATION

....

另一方面, 当应用程序尝试从套接字读取数据时,它会执行类似的过程,如下所示,从右到左表示在图3中:

1)使用相应的自旋锁(绿色)将一个或多个数据包从接收队列中出列.

2)将信息复制到用户空间存储器.

3)释放数据包使用的内存.这可能会改变套接字的状态,因此可以出现两种锁定套接字的方法:快速和慢速.在这两种情况下,数据包都从套接字取消链接,更新内存记帐统计信息并根据所采用的锁定路径释放套接字.

即,当许多线程访问同一个套接字时,由于等待一个自旋锁,性能会下降.


我们有2个Xeon 32 HT-Core服务器,64个总HT核心,2个10 Gbit以太网卡和Linux(内核3.9).

我们使用RFS和XPS - 即在与应用程序线程(用户空间)相同的CPU-Core上处理相同的连接TCP/IP堆栈(内核空间).

至少有3种方法可以接受连接以在多个线程上处理它:

  • 使用在多个线程之间共享的一个接受器套接字,每个线程接受连接并处理它
  • 在1个线程中使用一个acceptor套接字,并且该线程通过使用线程安全队列来推送接收到其他线程工作者的连接的套接字描述符
  • 使用许多在每个线程中监听相同的ip:port接收器套接字的接收器套接字,然后接收连接的线程处理它(recv/send)

什么是更有效的方法,如果我们接受许多新的TCP连接?

Clo*_*oud 10

在生产中不得不处理这样的场合,这是解决这个问题的好方法:

首先,设置一个线程来处理所有传入连接.修改关联性映射,以便此线程具有专用核心,使应用程序(甚至整个系统)中的其他线程不会尝试访问.您还可以修改引导脚本,以便某些内核永远不会自动分配给执行单元,除非明确请求该特定内核(即isolcpus内核引导参数).

将该核心标记为未使用,然后在您的代码中明确请求"侦听套接字"线程cpuset.

接下来,设置一个队列(理想情况下,优先级队列),优先考虑写操作(即"第二个读者 - 编写者问题).现在,设置许多工作线程,你认为合理.

此时,"传入连接"线程的目标应该是:

  • accept() 传入连接.
  • 尽可能快地将这些连接文件描述符(FD)传递给编写器优先级的队列结构.
  • accept()尽快回到自己的状态.

这将允许您尽快委派传入连接.您的工作线程可以在到达时从共享队列中获取项目.可能还值得拥有第二个高优先级线程,该线程从该队列中获取数据,并将其移动到辅助队列,从而使"监听套接字"线程不必花费额外的周期来委派客户端FD.

这也可以防止"监听套接字"线程和工作线程不必同时访问同一个队列,这样可以避免在最糟糕的情况下,比如"侦听套接字"线程时锁定队列的慢工作线程想要删除数据.即

Incoming client connections

 ||
 || Listener thread - accept() connection.
 \/

Listener/Helper queue

 ||
 || Helper thread
 \/

Shared Worker queue

 ||
 || Worker thread #n
 \/

Worker-specific memory space. read() from client.
Run Code Online (Sandbox Code Playgroud)

至于你提出的另外两个选择:

使用在多个线程之间共享的一个接受器套接字,每个线程接受连接并处理它.

乱.线程必须以某种方式轮流发出accept()调用,这样做没有任何好处.您还将有一些额外的排序逻辑来处理哪个线程的"转向".

使用许多接受器套接字,它们在每个线程中监听相同的ip:port,1个单独的接受器套接字,然后接收连接的线程处理它(recv/send)

不是最便携的选择.我会避免它.此外,您可能需要使服务器进程使用多进程(即fork())而不是多线程,具体取决于操作系统,内核版本等.