可扩展的并发性—满足非阻塞I / O

我们如何实现非阻塞I / O来提高应用程序的性能

> Photo by Benjamin Voros on Unsplash

为什么非阻塞IO更具可扩展性?

在几乎所有现代Web应用程序中,我们都有很多I / O。 我们与数据库对话并要求记录或插入/更新它们。 通常,我们从硬盘访问一些文件,这又是一个I / O操作。

我们正在讨论不同的第三方Web服务,例如OAuth集成或其他功能。 如今,许多Web应用程序还以微服务的形式运行,它们必须通过HTTP请求与同一应用程序的其他部分进行对话。

如果您使用Ruby,Python或许多其他语言编写Web应用程序,则默认情况下所有这些与I / O相关的任务都处于阻塞状态,这意味着该过程将等待直到收到响应,然后继续执行程序。

另一方面,Node.js [1]默认情况下正在使用非阻塞I / O。 因此,该过程可以继续在其他地方工作,并在请求完成时执行回调或promise。

这使操作系统可以充分利用一个CPU内核。 但是,其他编程语言也可以使用非阻塞编程模型吗?

是的! 在此博客文章中,我们将讨论如何使用(几乎)非阻塞I / O在Ruby中编写本机事件循环,然后了解如何改进此设计。


简单实现

首先,让我们看一个可行的本机实现:

<code>

def

process_partial_file_input

(data)

end

big_file =

"network/path/big_file.xlsx"

file_handler = open(big_file) files_to_look_after = [file_handler] loop

do

puts

"(Re)Starting the Event loop"

readable, _writeable = IO.select files_to_look_after, [], [],

0

.

01

if

readable readable.each

do

|ready_io|

read_data = ready_io.read_nonblock(

4096

) process_partial_file_input read_data

if

read_data

rescue

EOFError => e files_to_look_after.reject! {

|file|

file == ready_io }

end

end

break

if

files_to_look_after.empty?

end

/<code>

在讨论如何改进此设计之前,让我们简短地讨论IO.select方法,因为这是事件循环的核心。

IO.select

如评论中所述,此方法是跨平台的,可在运行程序的任何地方使用。

它采用的第一个参数是程序要读取的I / O描述符数组(文件描述符,Unix套接字或类似的东西)。

第二个数组仍然是I / O描述符的数组,但这一次它用于可写连接。

第三个数组是错误数组。

最后,最后一个参数是超时。 这是该方法阻塞的最长时间。 因此,在上面的示例中,我们可以说一个刻度至少为10毫秒,这取决于数据处理所花费的时间。

简单的事件循环的设计讨论

当我们看一下这段代码时,缺点很明显。 并发引入的复杂性与业务逻辑纠缠在一起,并且分离很困难。

事件循环知道我们的业务逻辑,因为它立即调用了该方法。 我们可以借助可处理所有读/写事件的寄存器来改善此情况。

寄存器可以利用带有两个键的简单哈希来进行读写,然后在其中保存回调。 在Ruby中,回调可以是任何块,proc或lambda。 同样,一个简单的实现可能看起来像这样:

<code>

class

CallbackRegister

def

initialize

@callbacks = {

read:

[],

write:

[]}

end

def

each

(type, &block)

@callbacks[type].each

do

|callback|

yield

callback

end

end

def

push

(callback, type)

@callbacks[type] << callback

end

end

big_file =

"network/path/big_file.xlsx"

file_handler = open(big_file) files_to_look_after = [file_handler] register = get_callback_register_from_container_manager loop

do

puts

"(Re)Starting the Event loop"

readable, _writeable = IO.select files_to_look_after, [], [],

0

.

01

if

readable readable.each

do

|ready_io|

read_data = ready_io.read_nonblock(

4096

) register.each(

:read

)

do

|callback|

callback.call(ready_io, read_data)

end

rescue

EOFError => e files_to_look_after.reject! {

|file|

file == ready_io }

end

end

break

if

files_to_look_after.empty?

end

/<code>

现在,我们已将业务逻辑与并发逻辑分离。 但这仍然会导致回调地狱。

JavaScript曾经有很多这个问题,但是它通过promise以及最近的async await功能解决了这个问题。 这样,您可以编写可同时运行的顺序代码。

尽管如此,我们在此设计中还有其他缺点。 它仍然使用一组固定的描述符来照料,并且我们没有地方在运行时进行配置。 此外,尽管我们可能不希望这样做,但每个回调事件都会收到通知,通知每个回调事件。

我们该如何改善? 符合反应堆模式。

反应堆模式

反应器模式是大多数事件循环的基础。 它将应用程序逻辑与切换实现完全分开,因此使代码更易于维护和重用。

它由两个主要部分组成:一个事件多路复用器和一个调度程序,并与另外两个一起工作-资源和请求处理程序。

反应器使用单线程事件循环,在事件多路复用器中注册资源,并在事件触发后分派给回调。

从我们的示例中可以看出,这种方式不需要阻塞I / O,因此进程可以最大限度地利用CPU内核。

实作

Ruby中著名的实现是EventMachine,Celluloid和async。 Python也至少有一个很好的实现,即Twisted。 PHP具有ReactPHP,我可以肯定几乎所有其他语言也都具有不错的实现。

缺点

与其他所有内容一样,反应堆也有一些缺点,您必须意识到这些缺点,才能做出明智的决定,即使用这种模式是否对您的用例有意义。

主要的缺点是,如果其中一个贪婪并且将花费大量时间直到完成,它将阻止所有回调。

本质上,反应堆是一种协作并发。 如上所述,反应器是单线程的,如果从一个回调中充分利用了CPU,则其他所有操作都必须等待。

另一个限制是,由于逻辑流程不是程序运行的方式,因此难以调试反应堆模式。 这也给开发人员带来了更多的麻烦。

从这里开始

对于并发I / O,反应堆模式是最好的选择吗?

实际上,不,仍然有一些方法可以对此进行改进。 如上所述,传统的反应器使用多路分解器同步调度事件,并且必须等待回调完成。 我们也可以使用前摄器模式使此异步。

如果您仍然需要更高的性能,那就扔硬件吧! 在某些时候,这是您最好的选择。 而且,如果您需要执行此操作,那么微服务体系结构将派上用场,因为您可以独立扩展应用程序的一小部分。

[1] Node.js只是一个例子,因为这是最常用的平台,默认使用非阻塞I / O。

(本文翻译自Gernot Gradwohl的文章《Scalable Concurrency — Meet Non-Blocking I/O》,参考:
https://medium.com/better-programming/scalable-concurrency-meet-non-blocking-i-o-edb6b39c59d7)