您的位置  > 互联网

理解应用程序的输入/输出(I/O)模型

了解应用程序的输入/输出 (I/O) 模型意味着其计划的处理负载与残酷的现实使用场景之间的差异。 如果应用程序相对较小并且不提供高负载,则可能影响不大。 但随着应用程序的负载逐渐增加,采用错误的 I/O 模型可能会给您带来许多陷阱和伤痕。

与大多数解决方案有多种路径的场景一样,重点不在于哪条路径更好,而是了解如何进行权衡。 让我们浏览一下 I/O 景观,看看我们可以从中窃取什么。

在本文中,我们将分别比较 Node、Java、Go 和 PHP,讨论这些不同语言如何对其 I/O 进行建模、每种模型的优缺点,并得出一些初步基准。 综上所述。 如果您关心下一个 Web 应用程序的 I/O 性能,那么您找到了正确的文章。

I/O 基础知识:快速回顾

为了理解与I/O密切相关的因素,我们必须首先回顾操作系统底层的概念。 尽管您不会直接处理其中大部分概念,但您已经通过应用程序的运行时环境间接处理了它们。 细节决定成败。

系统调用

首先,我们有系统调用,可以这样描述:

阻塞调用和非阻塞调用

好吧,上面我刚刚说了系统调用是阻塞的,一般来说确实如此。 然而,有些调用被归类为“非阻塞”,这意味着内核接收您的请求,将其放入队列或缓冲区中的某处,然后立即返回,而不等待实际的 I/O 调用。 因此它只会“阻塞”很短的一段时间,足以将您的请求排队。

这里有一些例子(Linux系统调用)来帮助解释: - read() 是一个阻塞调用 - 你向它传递一个文件句柄和一个缓冲区来存储要读取的数据,调用将等待,直到数据准备好返回之后。 请注意,这种方法具有优雅和简单的优点。 -()、() 和 () 是允许您创建一组要侦听的句柄、从该组中添加/删除句柄,然后阻塞直到有活动为止的调用。 这使您可以通过线程高效地控制一系列 I/O 操作。 如果您需要这些功能,这非常棒,但正如您所看到的,它的使用起来肯定相当复杂。

了解此处时间差异的数量级非常重要。 如果 CPU 核心以 3GHz 运行,未经优化,它每秒执行 30 亿次循环(或每纳秒 3 个循环)。 非阻塞系统调用可能需要大约 10 纳秒的时间才能完成,或者“相对较少的纳秒”。 阻止通过网络接收信息的呼叫可能需要更多时间 - 例如 200 毫秒(0.2 秒)。 例如,假设非阻塞调用花费了 20 纳秒,那么阻塞调用花费了 200,000,000 纳秒。 对于阻塞调用,您的程序等待的时间要长 1000 万倍。

内核提供阻塞 I/O(“从网络连接读取并给我数据”)和非阻塞 I/O(“告诉我这些网络连接上何时有新数据”)。 根据使用哪种机制,对应的调用进程的阻塞时间明显不同。

调度

第三件关键的事情是当大量线程或进程开始阻塞时该怎么办。

就我们的目的而言,线程和进程之间没有太大区别。 事实上,与执行相关的最明显的区别是线程共享相同的内存,而每个进程都有自己的内存空间,使得单独的进程经常占用大量内存。 但当我们谈论调度时,它最终归结为一系列事件(线程和进程等),其中每个事件都需要在可用的 CPU 核心上获取一段执行时间。 如果您有 300 个线程正在运行并且在 8 个内核上运行,则必须分散该时间,以便每个线程通过运行每个内核一小段时间然后切换到下一个线程来获得某些内容。 它的时间共享。 这是通过“上下文切换”实现的,它允许 CPU 从一个正在运行的线程/进程切换到下一个。

这些上下文切换是有代价的——它们会消耗一些时间。 当速度很快时,可能不到 100 纳秒,但根据实现细节、处理器速度/架构、CPU 缓存等,需要 1000 纳秒或更多的情况并不罕见。

线程(或进程)越多,上下文切换就越多。 当我们谈论数千个线程并且每次切换需要数百纳秒时,速度会非常慢。

然而,非阻塞调用本质上告诉内核“仅当您有一些新数据或这些连接之一上有事件时才给我打电话”。 这些非阻塞调用旨在有效处理大型 I/O 负载并减少上下文切换。

到目前为止您还在阅读这篇文章吗? 因为现在有趣的部分来了:让我们看看一些流畅的语言如何使用这些工具,并得出一些关于易用性和性能之间的权衡的结论......以及其他有趣的评论。

请注意,虽然本文中显示的示例很琐碎(并且不完整,仅显示代码的相关部分)、数据库访问、外部缓存系统(等等)以及任何需要 I/O 的内容,但最终都会执行一些底层 I/O 操作。 /O 操作,与所示示例具有相同的影响。 同样,对于 I/O 被描述为“阻塞”的情况(PHP、Java),HTTP 请求和响应的读写本身就是阻塞调用:更多的 I/O 再次隐藏在系统 O 及其伴随的中。需要考虑性能问题。

为您的项目选择编程语言时需要考虑很多因素。 当您只考虑性能时,还有更多因素需要考虑。 但是,如果您担心您的程序主要受 I/O 限制,并且 I/O 性能对您的项目至关重要,那么您需要了解以下内容。 “保持简单”的方法:PHP。

早在 90 年代,很多人都穿着 鞋并用 Perl 编写 CGI 脚本。 然后PHP出现了,很多人都喜欢使用它,这使得创建动态网页变得更加容易。

PHP 使用的模型非常简单。 有一些变化,但基本上 PHP 服务器看起来像:

HTTP 请求来自用户的浏览器并访问您的网站服务器。 为每个请求创建一个单独的进程,并通过一些优化来重用它们,以最大限度地减少需要执行的次数(创建进程相对较慢)。 调用 PHP 并告诉它运行磁盘上相应的 .php 文件。 PHP 代码执行并进行一些阻塞 I/O 调用。 如果在PHP中调用(),则会在后台触发read()系统调用并等待结果返回。

当然,实际的代码只是嵌入到您的页面中,并且操作是阻塞的:

<?php
// 阻塞的文件I/O
$file_data = file_get_contents('/path/to/file.dat');
// 阻塞的网络I/O
$curl = curl_init('http://example.com/example-microservice');
$result = curl_exec($curl);
// 更多阻塞的网络I/O
$result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100');
?>

关于它如何与系统集成,是这样的:

非常简单:一个请求,一个过程。 I/O 阻塞。 有什么优点? 简单可行。 那么有哪些缺点呢? 如果同时连接 20,000 个客户端,您的服务器就会死掉。 这种方法不能很好地扩展,因为没有使用内核提供的用于处理批量 I/O 的工具(epoll 等)。 更糟糕的是,为每个请求运行单独的进程往往会使用大量系统资源,尤其是内存,这通常是在此类场景中首先遇到的问题。

注意:Ruby 使用的方法与 PHP 非常相似,从广泛和一般的角度来看,我们可以认为它们是相同的。

多线程方法:Java

因此,当您购买第一个域名时,Java 就出现了,在一句话后随意地说“dot com”很酷。 Java 语言中内置了多线程(尤其是在创建时),这非常棒。

大多数 Java Web 服务器的工作方式是为每个传入请求启动一个新的执行线程,然后最终在该线程中调用由您(应用程序开发人员)编写的函数。

在 Java 中执行 I/O 操作通常如下所示:

public void doGet(HttpServletRequest request,  
    HttpServletResponse response) throws ServletException, IOException
{
    // 阻塞的文件I/O
    InputStream fileIs = new FileInputStream("/path/to/file");
    // 阻塞的网络I/O
    URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();
    InputStream netIs = urlConnection.getInputStream();
    // 更多阻塞的网络I/O
    out.println("...");
}

由于上面的 doGet 方法对应一个请求并在其自己的线程中运行,而不是每个请求对应一个需要自己专用内存的单独进程,因此我们将拥有一个单独的线程。 这有一些很好的优点,例如线程之间共享状态,共享缓存数据等,因为它们可以访问彼此各自的内存,但是它与调度交互的方式的影响仍然与之前的 PHP 中所做的相同示例 内容几乎相同。 每个请求都会生成一个新的线程,该线程中的各种I/O操作都会被阻塞,直到请求处理完毕。 线程被集中在一起以最小化创建和销毁它们的成本,但是,数千个连接仍然意味着数千个线程,这对调度程序来说是不利的。

一个重要的里程碑是在 Java 1.4 中执行非阻塞 I/O 调用的能力(并再次显着升级到 1.7)。 大多数应用程序、网站和其他程序都不使用它,但至少它是可用的。 一些 Java Web 服务器尝试以各种方式利用这一点; 然而,绝大多数已部署的 Java 应用程序仍然按上述方式工作。

Java 使我们更进一步,并且确实具有一些很好的“开箱即用”I/O 功能,但它仍然没有真正解决问题:当您有一个严重 I/O 绑定的应用程序时,该怎么办?当数千个被阻塞的线程即将落地时执行此操作。

非阻塞 I/O 作为一等公民:Node

当谈到更好的 I/O 时,Node.js 无疑是新宠。 任何拥有 Node 最基本知识的人都会被告知,它是“非阻塞”的,并且可以高效地处理 I/O。 从一般意义上来说,这是事实。 但细节决定成败,如何实现这种魔力对于性能而言至关重要。

本质上,Node 实现的范式转变为“在这里编写代码以开始处理请求”,而不是基本上说“在此处编写代码来处理请求”。 每次需要执行涉及 I/O 的操作时,请发出请求或提供回调函数,Node 将在完成时调用该回调函数。

在查询中执行 I/O 操作的典型 Node 代码如下:

http.createServer(function(request, response) {  
    fs.readFile('/path/to/file', 'utf8', function(err, data) {
        response.end(data);
    });
});

可以看到,这里有两个回调函数。 第一个将在请求开始时调用,第二个将在文件数据可用时调用。

其作用基本上是让 Node 有机会在这些回调函数之间有效地处理 I/O。 一个更相关的场景是在 Node 中进行数据库调用,但我不想再列出这个烦人的例子,因为它的原理完全相同:发起数据库调用并向 Node 提供回调函数,它单独使用非阻塞调用执行 I/O 操作,然后在请求的数据可用时调用回调函数。 这种I/O调用队列,让Node处理,然后获取回调函数的机制称为“事件循环”。 它运作得很好。

然而,这个模型有一个障碍。 在幕后,原因更多的是关于 V8 引擎(Node 的 JS 引擎)是如何实现的。 您编写的所有 JS 代码都在线程中运行。 想一想。 这意味着,当使用高效的非阻塞技术执行 I/O 时,执行 CPU 密集型操作的 JS 可以在单个线程中运行,每个代码块都会阻塞下一个代码块。 一个常见的示例是循环数据库记录并在输出到客户端之前以某种方式处理它们。 下面是一个演示其工作原理的示例:

var handler = function(request, response) {
    connection.query('SELECT ...', function (err, rows) {
        if (err) { throw err };
        for (var i = 0; i < rows.length; i++) {
            // 对每一行纪录进行处理
        }
        response.end(...); // 输出结果
    })
};

虽然 Node 确实有效地处理 I/O,但上例中的 for 循环使用主线程中的 CPU 周期。 这意味着,如果您有 10,000 个连接,则该循环有可能使整个应用程序变慢,具体取决于每个循环所需的时间。 每个请求必须在主线程上共享其时间,一次一个。

整个概念的前提是 I/O 操作是最慢的部分,因此最重要的是高效地处理它们,即使这意味着要串行执行其他处理。 在某些情况下确实如此,但并非全部。

还有一点是,虽然这只是一个观点,但编写一堆嵌套的回调可能会很烦人,而且有些人认为这会让代码明显变得无组织。 在 Node 代码深处,四层、五层甚至更多层的嵌套并不罕见。

我们再次回到权衡。 如果您的主要性能问题是 I/O,则 Node 模型效果很好。 然而它的致命弱点(译者注:来自希腊神话,意为致命弱点)是,如果你不小心的话,你可以在处理 HTTP 请求的时候,将 CPU 密集型的代码放在一个函数中,最终使得每个连接都慢如一蜗牛。

真正的非阻塞:Go

在进入 Go 章节之前,我应该透露一下我是 Go 粉丝。 我在许多项目中使用过 Go,是其生产力优势的坚定支持者,并且在使用它时在工作中看到了它们。

也就是说,让我们看一下它是如何处理 I/O 的。 Go 语言的一个关键特性是它包含自己的调度程序。 并不是每个执行线程都对应一个操作系统线程,Go 使用“”的概念。 Go 运行时可以将一个线程分配给操作系统线程并让它执行,或者挂起它而不与操作系统线程关联,具体取决于它正在做什么。 来自 Go 的 HTTP 服务器的每个请求都是单独处理的。

该调度器的工作原理图如下所示:

这是通过在 Go 运行时的各个点进行 I/O 调用来完成的,方法是发出写入/读取/连接等请求,将当前请求置于睡眠状态,并在可以采取进一步操作时用信息重新唤醒它。 。

实际上,Go 运行时的作用与 Node 的作用并没有太大不同,只是回调机制内置于 I/O 调用的实现中并自动与调度程序交互。 它也不受必须在同一线程中运行所有处理程序代码的限制。 Go 会根据调度程序的逻辑自动将其映射到它认为合适的操作系统线程。 最终代码如下所示:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 这里底层的网络调用是非阻塞的
    rows, err := db.Query("SELECT ...")
    for _, row := range rows {
        // 处理rows
        // 每个请求在它自己的goroutine中
    }
    w.Write(...) // 输出响应结果,也是非阻塞的
}

正如您在上面看到的,我们的基本代码以更简单的方式构建,并在底层实现了非阻塞 I/O。

在大多数情况下,这最终是“两全其美”。 非阻塞 I/O 用于所有重要的事情,但您的代码看起来像是阻塞的,因此通常更容易理解和维护。 Go 调度器和 OS 调度器之间的交互处理剩下的事情。 这并不完全是魔法,如果您正在构建一个大型系统,那么值得花更多时间来了解它如何工作的更详细的细节; 但与此同时,“开箱即用”的环境可以很好地工作并且可以很好地扩展。

Go 可能有它的缺点,但一般来说,它处理 I/O 的方式并不在其中。

谎言,该死的谎言和基准

这些不同模式的上下文切换的准确定时是很困难的。 也可以说,这对你来说不会有太大的影响。 因此,我将提供一些基准来比较这些服务器环境的 HTTP 服务器性能。 请记住,整个端到端 HTTP 请求/响应路径的性能取决于许多因素,我在这里汇总的数据只是一个示例,以便可以进行基本比较。

对于每个环境,我编写了适当的代码来读取具有随机字节的 64k 大小的文件,运行 SHA-256 哈希 N 次(N 在 URL 的查询字符串中指定,例如.. /test.php?n =100)并以十六进制形式打印生成的哈希值。 我选择这个示例是因为它是一种非常简单的方法,可以使用一些一致的 I/O 来运行相同的基准测试,并以受控的方式增加 CPU 使用率。

关于环境使用,请参阅这些基准点以了解更多详细信息。

首先,我们来看一些低并发的例子。 运行 2000 次迭代、300 个并发请求,并且每个请求仅哈希一次 (N = 1),我们得到:

时间是在所有并发请求中完成一个请求所需的平均毫秒数。 越低越好。

仅从一张图很难得出结论,但对我来说,它似乎与连接和计算的那些方面有关,我们看到时间更多地与语言本身的一般执行有关,所以它更多地与 I/O 有关。 请注意,被视为“脚本语言”(任意输入、动态解释)的语言执行速度最慢。

但是,如果将 N 增加到 1000 并且仍然执行 300 个并发请求 - 相同的负载,但哈希迭代次数增加了 100 倍(显着增加了 CPU 负载),会发生什么情况:

时间是在所有并发请求中完成一个请求所需的平均毫秒数。 越低越好。

突然之间,由于每个请求中的 CPU 密集型操作相互阻塞,Node 的性能显着下降。 有趣的是,在这次测试中,PHP 的表现要好得多(相对于其他语言)并且击败了 Java。 (值得注意的是,在 PHP 中,SHA-256 实现是用 C 编写的,执行路径在此循环中需要更多时间,因为这次我们进行了 1000 次哈希迭代)。

现在让我们尝试 5000 个并发连接(且 N = 1) - 或接近的连接。 不幸的是,对于大多数这些环境,故障率并不高。 对于此图表,我们将重点关注每秒的请求总数。 越高越好:

每秒的请求总数。 越高越好。

这张照片看起来完全不同。 这是一个猜测,但看起来对于高连接量、与每个连接生成新进程相关的开销以及与 PHP+ 相关的额外内存似乎是限制 PHP 性能的主要因素。 显然,Go 是这里的赢家,其次是 Java 和 Node,最后是 PHP。

综上所述

综上所述,很明显,随着语言的发展,处理大量 I/O 的大型应用程序的解决方案也在不断发展。

为了公平起见,暂时抛开本文的描述,PHP 和 Java 确实有可在 Web 应用程序中使用的非阻塞 I/O 实现。 但这些方法并不像上述方法那么常见,并且需要考虑使用这种方法维护服务器所带来的操作开销。 更不用说您的代码必须以适合这些环境的方式构建; “普通”PHP 或 Java Web 应用程序在此类环境中通常不会发生重大变化。

作为比较,如果只考虑影响性能和易用性的几个重要因素,可以得到:

语言线程或进程非阻塞 I/O 易用性

PHP

过程

爪哇

线

可用的

需要回电

Node.js

线

是的

需要回电

线()

是的

无需回拨

线程通常比进程具有更高的内存效率,因为它们共享相同的内存空间,而进程则不然。 结合与非阻塞 I/O 相关的因素,当我们将列表向下移动到与改进 I/O 相关的一般启动时,我们至少可以看到与上面考虑的因素相同的因素。 如果我必须在以上比赛中选出一个胜利者,那肯定是围棋。

即便如此,在实践中,您选择构建应用程序的环境与您的团队对所述环境的熟悉程度以及可以实现的整体生产力有很大关系。 因此,对于每个团队来说,直接开始使用 Node 或 Go 开发 Web 应用程序和服务可能没有意义。 事实上,与开发人员或内部团队的熟悉程度经常被认为是不使用不同语言和/或不同环境的主要原因。 换句话说,过去十五年来,时代发生了巨大的变化。

希望上述内容可以帮助您更清楚地了解幕后发生的事情,并为您提供一些有关如何处理应用程序的实际可扩展性的想法。 快乐输入,快乐输出!

原来的:

翻译: