我们如何实现非阻塞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] loopdo
puts"(Re)Starting the Event loop"
readable, _writeable = IO.select files_to_look_after, [], [],0
.01
if
readable readable.eachdo
|ready_io|
read_data = ready_io.read_nonblock(4096
) process_partial_file_input read_dataif
read_datarescue
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].eachdo
|callback|
yield
callbackend
end
def
push
(callback, type)
@callbacks[type] << callbackend
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 loopdo
puts"(Re)Starting the Event loop"
readable, _writeable = IO.select files_to_look_after, [], [],0
.01
if
readable readable.eachdo
|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)