查看源代码 端口信号

问题

Erlang 端口在概念上与 Erlang 进程非常相似。Erlang 进程在虚拟机中执行 Erlang 代码,而 Erlang 端口执行通常用于与外部世界通信的本机代码。例如,当一个 Erlang 进程想要通过网络使用 TCP 通信时,它会通过一个用本机代码实现 TCP 套接字接口的 Erlang 端口进行通信。Erlang 进程和端口都使用异步信号进行通信。Erlang 端口执行的本机代码是一组回调函数,称为驱动程序。每个回调函数或多或少地实现了发送到端口或从端口发送的信号的代码。

尽管进程和端口在概念上一直非常相似,但实现方式却截然不同。最初,或多或少所有的端口信号都是在发生时同步处理的。在运行时系统 SMP 支持开发的早期,我们就意识到这对于端口和外部世界之间的信号来说是一个巨大的问题。也就是说,与外部世界的 I/O 事件,或者说 I/O 信号。这是为了能够并行执行 I/O 而必须重写的第一件事。解决方案是实现这些信号的调度。然后,对应于不同端口的 I/O 信号可以在不同的调度器线程上并行执行。从进程到端口的信号不如 I/O 信号那么大的问题,因此这些信号的实现方式保持不变。

每个端口都由其自身的锁保护,以防止在多个线程中同时执行。以前,当在调度器线程上执行的进程向端口发送信号时,它会锁定端口锁并同步执行与信号对应的代码。如果锁处于忙碌状态,调度器线程将阻塞,等待直到它可以锁定锁。如果多个进程同时在不同的调度器线程上执行,向同一个端口发送信号,调度器将遭受严重的锁争用。这种争用也可能发生在在一个调度器线程上执行的端口的 I/O 信号与在另一个调度器线程上执行的来自进程的信号之间。除了争用问题之外,我们还会失去在不同的调度器线程上并行执行的潜在工作。这是因为发送异步信号的进程在执行实现信号的代码时会被阻塞。

解决方案

为了防止多个调度器同时尝试执行到/来自同一端口的信号,我们需要确保所有到/来自端口的信号都在一个调度器上按顺序执行。或多或少地,唯一的方法是调度所有类型的信号。然后,一个调度器线程可以按顺序执行对应于端口的信号。如果只有一个线程尝试执行端口,则端口锁上不会出现争用。除了消除争用之外,发送信号到端口的进程也可以在其他调度器上同时继续执行自己的 Erlang 代码,而信号代码则在另一个调度器上执行。

在实现此功能时,我们需要或想要保留一些重要的属性

  • 信号顺序保证。从进程 X 到端口 Y 的信号,必须按照从 X 发送的相同顺序传递到 Y

  • 信号延迟。由于之前的同步实现,从进程发送到端口的信号延迟通常非常低。在发生争用期间,延迟当然会增加。用户期望这些信号的延迟较低,用户不会喜欢延迟突然增加。

  • 兼容的流量控制。端口长期以来都有在实现流量控制时使用繁忙端口功能的可能性。有人可能会认为此功能与概念上完全异步的信号传递非常不符,但是该功能已经存在了很长时间,并且预计会一直存在。当端口将自身设置为繁忙状态时,不应传递 command 信号,并且此类信号的发送者应暂停,直到端口将自身设置为非繁忙状态。

端口信号的调度

一个运行队列有四个用于不同优先级的进程的队列和一个用于端口的队列。当队列中同时存在进程和端口时,与运行队列关联的调度器线程在执行进程和执行端口之间均匀切换。这并非完全如此,但这对于本次讨论并不重要。运行队列中的端口也有一个要执行的任务队列。每个任务对应一个传入或传出的信号。当选择端口执行时,将按顺序执行每个任务。运行队列锁不仅保护了端口队列,还保护了端口任务队列。

由于我们从仅调度 I/O 信号的状态转变为可能调度所有端口相关信号的状态,因此我们可能会大大增加运行队列锁的负载。调度的端口任务数量在很大程度上取决于正在执行的 Erlang 应用程序,我们无法控制该应用程序,并且我们不希望增加运行队列锁的争用。因此,我们需要另一种方法来保护端口任务队列。

任务队列

我们选择了一种“半锁定”方法,其中包含一个公共的锁定任务队列和一个私有的无锁队列式任务数据结构。这种“半锁定”方法类似于管理进程的消息框的方式。该锁是特定于端口的,仅用于保护端口任务,因此端口的运行队列锁现在在很大程度上与进程的运行队列锁相同。这确保我们不会因为重写端口功能而导致运行队列锁上的锁争用增加。

当执行端口在私有任务数据结构中用完要执行的工作时,它会在保持锁定的同时将公共任务队列移动到私有任务数据结构中。一旦任务被移动到私有数据结构中,就没有锁保护它们。这样,端口可以继续处理私有数据结构中的任务,而无需争夺锁。

但是,I/O 信号可能会被中止。可以通过让特定于端口的调度锁也保护私有任务数据结构来解决此问题,但是这样端口将不得不经常与其他排队新任务的人员争夺。为了在保持私有任务数据结构无锁的情况下处理此问题,我们使用了类似于处理在运行队列中被暂停的进程时所使用的“非侵略性”方法。我们没有删除中止的端口任务,而是使用原子内存操作将其标记为已中止。当选择任务执行时,我们首先验证它是否已被中止。如果中止,我们只是丢弃该任务。

可以通过系统中其他部分的其他数据结构引用可以中止的任务,以便需要中止任务的线程可以访问它。为了确保安全地释放不再使用的任务,我们首先清除此引用,然后使用线程进度功能以确保不存在对该任务的引用。不幸的是,非托管线程也可能中止任务。这种情况很少发生,但可能会发生。可以在本地为每个端口处理这种情况,但这需要在每个端口结构中添加很少使用的额外信息。我们没有在每个端口中实现此功能,而是实现了一般功能,非托管线程可以使用该功能来延迟线程进度。

如果不是为了繁忙端口功能,私有“类队列”任务数据结构本来可以是一个普通的队列。当端口将自身标记为繁忙时,不允许传递 command 信号,需要阻止它们。从同一发送方发送的、跟随已被阻止的 command 信号的其他信号也必须被阻止;否则,我们将违反顺序保证。同时,预计将传递与其他被阻止的 command 信号没有依赖关系的其他信号。

以上要求使私有任务数据结构成为一个相当复杂的数据结构。它具有一个未处理任务队列和一个繁忙队列。繁忙队列包含对应于 command 信号的被阻止任务以及与此类任务有依赖关系的任务。繁忙队列还附带一个基于发送方的被阻止任务表,其中包含对来自特定发送方的繁忙队列中最后一个任务的引用。这是因为我们需要在未处理任务队列中处理新任务时检查依赖项。当处理需要阻止的新任务时,它不会在繁忙队列的末尾排队,而是直接排在具有相同发送方的最后一个任务之后。这是为了能够轻松检测到何时有不再依赖于与 command 信号对应的任务的任务,应该将这些任务移出繁忙队列。当端口执行时,它会根据其繁忙状态在处理繁忙队列中的任务和直接处理未处理队列中的任务之间切换。当直接从未处理队列中处理时,当然可能必须将任务移动到繁忙队列而不是执行它。

繁忙端口队列

由于端口本身决定何时进入繁忙状态,因此它需要执行才能进入繁忙状态。由于调度了 command 信号,我们可能会遇到这样的情况:端口在有机会将自身设置为繁忙状态之前就被大量 command 信号淹没。这是因为它尚未被安排执行。也就是说,在这些情况下,繁忙端口功能会失去其旨在提供的流量控制属性。

为了解决这个问题,我们引入了一个新的繁忙特性,即“繁忙端口队列”。端口允许在任务队列中排队的 command 数据量有一个限制。当达到此限制时,端口将自动进入繁忙端口队列状态。在这种状态下,command 信号的发送者将被挂起,但 command 信号仍将传递到端口,除非端口也处于繁忙端口状态。此限制称为高限制。

还有一个低限制。当排队的 command 数据量低于此限制且端口处于繁忙端口队列状态时,繁忙端口队列状态将自动禁用。低限制通常应明显低于高限制,以防止围绕繁忙端口队列状态频繁振荡。

通过引入这种新的繁忙状态,我们仍然可以提供流量控制。旧驱动程序甚至不必更改。但是,端口可以配置甚至禁用这些限制。默认情况下,高限制为 8 KB,低限制为 4 KB。

信号发送的准备

以前,所有向端口发送信号的操作都以获取端口锁开始,然后执行发送信号的准备工作,最后发送信号。准备工作通常包括检查端口的状态,以及准备要与信号一起传递的数据。数据准备通常非常耗时,并且实际上不依赖于端口。也就是说,我们希望在不锁定端口锁的情况下执行此操作。

为了改进这一点,我们在端口结构中重新组织了状态信息,以便我们可以使用原子内存操作来访问它。这与新的端口表实现一起,使我们能够在获取端口锁之前查找端口并检查其状态,从而使我们可以在获取端口锁之前执行信号数据的准备工作。

保持低延迟

如果我们忽略争用情况,那么与立即执行信号相比,将信号调度为稍后执行时,不可避免地会获得更高的延迟。为了保持低延迟,我们现在首先检查这是否是争用情况。如果是,我们将信号调度为稍后执行;否则,我们立即执行信号。如果端口上已经调度了其他信号,或者我们无法获取端口锁,则属于争用情况。也就是说,我们不会阻塞等待锁。

通过这种方式,我们将以牺牲信号与发送信号进程中的其他代码并行执行的潜在可能性为代价来保持低延迟。但是,可以在端口或系统范围内更改此默认行为,强制将来自进程的所有信号调度到不属于同步通信的端口。也就是说,异步信号的无条件请求/响应对。在这种情况下,不存在并行执行的潜力,因此没有必要强制调度请求信号。

立即执行信号还可能导致即将执行调度任务的调度程序阻塞等待端口锁。然而,这或多或少是调度程序需要等待端口锁的唯一场景。它必须等待的最长时间是执行一个信号所需的时间,因为我们总是在发生争用时调度信号。

信号操作

除了实现启用调度、在没有端口锁的情况下准备信号数据等功能外,每个向端口发送信号的操作都必须进行相当广泛的重写。这是为了将所有可以在没有锁的情况下完成的子操作移到我们获取锁之前的位置,并且还因为信号现在有时会立即执行,有时会调度为稍后执行,这对要与信号一起传递的数据提出了不同的要求。

一些基准测试结果

当运行一些简单的基准测试时,争用仅发生在 I/O 信号与来自单个进程的信号争用时,我们获得了 5-15% 的加速。当多个进程向单个端口发送信号时,改进可能会更大,但是一个进程与 I/O 争用的情况是最常见的。

这些基准测试是在一台相对较新的机器上运行的,该机器配备了 Intel i7 四核处理器,支持超线程,并使用了 8 个调度程序。