套接字连接更新



启动 PSOCK 集群并不快。在仅使用几年前的笔记本电脑上使用 R 3.6,使用 8 个逻辑内核,运行 Windows,启动一个包含 8 个节点的集群大约需要 1.7 秒

library(parallel); system.time(cl <- makePSOCKcluster(8))

一个好的设计是在 R 会话期间仅启动一次集群,然后将其传递给可以利用它的计算。需要这样做,以便最终用户始终可以完全控制总共使用了多少个内核。在用户无法直接控制的情况下,在程序包代码中启动集群通常会通过使机器过载来导致大幅减速,从而导致性能远比顺序执行差。

因此,1.7 秒可能看起来可以接受,但如果我们在具有许多内核的服务器机器上启动一个更大的集群,每个逻辑内核一个节点,则启动时间会变得非常长。在最近的具有 64 个逻辑内核的 Fedora 服务器上,大约需要 14 秒。在具有 64 个逻辑内核的旧 Solaris 服务器上,大约需要 211 秒!

在 R-devel 中,我们扩展了套接字 API 并重新设计了 PSOCK 集群的启动。上面提到的 Windows 笔记本电脑现在在 R-devel 中以 0.5 秒启动集群。其他几台具有更多内核的机器的时序为

R 3.6 R-devel
Fedora 服务器(64 个内核) 14 秒 0.4 秒
Ubuntu 服务器(40 个内核) 6.6 秒 0.4 秒
Windows 服务器(48 个内核) 9.3 秒 0.5 秒
Solaris 服务器(64 个内核) 211 秒 7 秒
macOS 台式机(12 个内核) 4.2 秒 0.7 秒

当内核数量多但各个内核速度慢时,加速幅度很大。

兼容性

本帖的其余部分将详细描述如何实现这些性能改进。套接字层改进不会更改现有 API 的已记录行为,但会更改可观察到的行为(有时会在以前没有超时时强制执行超时,在连接的服务器端会尊重 blocking = FALSE)。通过比较程序包检查结果来测试 R 更改的通常方式在这里帮助不大,因为 CRAN 策略将检查的内核数量限制为 2(以防止检查机器过载)。因此,邀请并鼓励依赖并行化/套接字通信的用户在大型系统上运行他们拥有的任何测试,并报告任何新错误。

在 R 3.6 中启动 PSOCK 集群

R 3.6 及更早版本按顺序启动集群。对于每个节点,服务器都会发出一个 system() 命令来启动节点,然后通过 socketConnection(server = TRUE) 等待节点连接,然后对第二个节点执行相同操作,依此类推。启动集群几乎所有时间都花在启动所有 R 会话上。

尽管在服务器上很简单,但在节点中并不简单:当一个节点启动时,它会尝试通过 socketConnection(server = FALSE) 连接到服务器,但连接可能会由于竞争条件而失败:操作系统可能会决定在服务器中的 socketConnection 之前在节点中运行 socketConnection,然后连接将失败。因此,即使在 R 3.6 集群设置中,节点在发生故障时也必须重试,并以指数退避的方式进行。

没有办法避免这种竞争条件,因为 socketConnection(server = TRUE) 执行所有三个服务器套接字操作:bind()listen()accept()。它总是创建一个临时服务器套接字并将其绑定,使其处于侦听状态,通过 accept() 等待连接,然后销毁服务器套接字。在服务器上给定端口上没有侦听服务器套接字的时间间隔内,来自节点的连接将失败。

在实践中,这工作得很好,因为在典型的操作系统上,服务器将很快被调度,并且重试次数将很少。但是,如果我们并行启动节点,那么重试次数可能会随着节点数的增加而急剧增加,这将损害性能。

服务器套接字连接

相反,我们扩展了 R 连接 API,以便可以直接使用服务器套接字连接,重新使用它们来接受多个套接字连接

serverSocket(port)
     
socketAccept(socket, blocking = FALSE, open = "a+",
             encoding = getOption("encoding"),
             timeout = getOption("timeout"))

serverSocket 创建一个侦听服务器套接字连接,该连接属于一个新类 "servsockconn"socketAccept 接受到给定服务器套接字的传入连接。socketSelect 可用于侦听服务器套接字,以等待连接准备好被接受。服务器套接字连接可以通过 close 照常关闭。

侦听积压

操作系统有一个部分和已建立的传入连接队列。已建立的连接已准备好通过 accept() (socketAccept()) 提供给应用程序。此队列的长度有限,可以通过 listen() 的参数对其施加影响,但这只是一个提示,无法通过编程方式找出队列的实际大小。操作系统可能会施加限制并缩短该队列。我们已经修改了 R-devel 以使用每个系统支持的最大长度,但不能保证它足以满足所有节点。因此,当队列太短时,集群设置需要表现得正确。

TCP 在建立连接时使用 3 次握手。在队列未满的正常成功情况下,节点发送 SYN 数据包,服务器将连接放入队列并使用 SYN+ACK 响应,然后节点通过 connect()/socketConnection(server=FALSE) 将连接作为已建立的连接提供给应用程序,并向服务器发送 ACK。然后,服务器将连接标记为已建立(某些系统会将其放入不同的队列)并将其提供给应用程序(通过 accept()/socketAccept())。

当队列已满时,可能会发生多种情况。Linux 实际上有两个队列,一个用于已建立的连接,另一个用于部分建立的连接。只有已建立队列的大小受 listen() 的 backlog 参数影响,当该队列已满时,Linux 已降低将连接添加到部分建立队列的速率。它可能只是丢弃一个 SYN 数据包,而不将传入连接放入部分建立队列。节点上的 TCP 层将重试几次,重新发送 SYN,但最终放弃,发送 RST(重置),连接将失败。因此,即使使用新的服务器套接字 API,也必须在节点中保留失败后的重试代码。

此外,即使在 Linux 上,也可能仍然发生以下情况:服务器收到来自节点的 ACK,但已建立队列已满。然后,服务器可能会向节点发送 RST,并且节点将失败,因为它已阻塞,等待服务器在它认为已建立的连接上发送命令。当服务器丢弃 ACK 数据包但将连接保留在部分建立队列中时,也会出现类似的情况。然后,它可能会在超时后向节点重新发送 SYN+ACK,节点重新发送其 ACK,最终连接可能在服务器上真正成功,或者服务器可能会发送 RST 并将其从部分建立队列中移除。有关 Linux 如何实现 backlog 的更多信息,请参阅 此处

另一个复杂之处在于服务器可能会丢弃来自节点的 ACK,并将连接从部分建立队列中移除。根据经验,我们在压力测试期间看到了这种情况,当时来自客户端的连接已旧,并且出现次数随着连接的增加而增加,因此这可能是超时后发生的。因此,节点不会收到 RST,并且将无限期地等待服务器的命令。

这种情况称为半打开连接,在 TCP 通信中可能以多种方式出现。如果节点开始通过连接进行通信,则 TCP 层将解决此问题,但在 PSOCK 协议中,服务器是开始通信的一方,因此需要专门处理此问题。

服务器发起的握手

因此,我们更改了集群设置过程,以便服务器在从节点获得连接后立即向节点发送初始命令作为握手。节点在设置阶段等待此类初始命令,如果在一段时间内未收到命令(服务器上关闭的半打开连接)或等待失败(从服务器收到RST),则节点会重试建立与服务器的连接并等待新的握手。这不会更改网络协议:握手只是一个常规命令,节点会运行它并发送响应。

连接超时

R 3.6 套接字 API 允许在创建时为套接字连接定义超时。然后,超时会影响套接字上的大多数操作(单独应用于低级操作,但 R 级别的函数总共可能等待更长时间)。PSOCK 集群使用 30 天的连接超时:如果在该时间内没有来自服务器(例如服务器崩溃或连接丢失)的命令发送到节点,则节点会自行退出。对于集群设置期间的握手,我们需要更短的超时。因此,我们扩展了 API 以允许修改套接字连接的超时

socketTimeout(socket, timeout = -1)

此新函数允许在握手期间和节点上的socketConnection调用中使用较短的超时,而这在以前实际上是阻塞的(在 Linux 上由于 select() 错误而阻塞,在其他系统上超时为 30 天)。在握手之后,将调用socketTimeout以再次将超时增加到 30 天。

服务器发起的握手除了通过(超时)帮助仅在客户端打开的半打开连接之外,还消除了仅在服务器上打开的任何半打开连接。TCP 层将检测到这些连接并在服务器开始通信时失败。在我们的初始压力测试期间,我们也观察到了这些情况。

并行 PSOCK 集群设置

在 R-devel 中,当所有节点都在localhost上运行时,对于所有节点自动启动的同构集群,默认情况下使用新的并行集群设置。对于其他情况,仍然支持原始顺序启动代码。有一个新的集群选项setup_strategy,其值包括"parallel""sequential""parallel"是默认值,它告诉 R 在所有支持的集群上使用并行策略。

R 套接字层改进

作为这项工作的一部分,修复了 R 套接字层实现的几个问题。

现在在 Linux 上对 socketConnection(server = FALSE) 的连接超时进行了强制执行。之前,由于 select() 的 Linux 特定行为,该调用意外地被阻塞。

当连接失败时,现在 socketConnection(server = FALSE) 会在 Windows 上立即返回。之前,由于在等待连接时 select() 的 Windows 特定行为,必须等待超时到期。

现在 socketConnection(server = FALSE) 会检测到何时连接立即可用而不必等待(可能不太可能,并且仅在 localhost 上可能),然后返回该连接。之前,R 会无论如何等待并尝试再次连接,可能泄露连接。

现在 socketConnection(server = TRUE)(和 socketAccept())强制执行连接超时。之前,由于当连接似乎可被 select() 接受,但当调用 accept() 时被客户端重新设置,从而导致竞争条件,它们可能会无限期地阻塞。在某些系统上,accept() 随后会阻塞。在其他系统上(我们在 Solaris 上触发了这种情况),accept() 随后会失败;在更改之后,R 将继续等待良好连接,同时遵守超时。

现在套接字的读取操作对套接字的虚假可读性具有鲁棒性(select() 将其报告为可读,但随后例如由于无效的校验和 recv() 会阻塞)。此问题可能发生在 Linux 上。

现在套接字的写入操作对套接字的虚假可写性具有鲁棒性。之前,此情况可能导致不可预测的行为。但是,据我们所知,尚未在任何系统上报告过此类 select() 错误。

现在套接字连接(socketConnection())上的 blocking = FALSE 参数在套接字的服务器端也受到尊重。之前,即使使用 blocking = FALSE,套接字服务器端的读/写操作也会意外地阻塞。

已针对 WinSock2 更新了 Windows 上套接字操作的状态代码的内部处理。