服务器端 I/O 性能:Node vs. PHP vs. Java vs. Go
已发表: 2022-03-11了解应用程序的输入/输出 (I/O) 模型可能意味着处理其所承受负载的应用程序与面对实际用例时崩溃的应用程序之间的区别。 也许虽然您的应用程序很小并且不提供高负载,但它的重要性可能要小得多。 但是随着应用程序流量负载的增加,使用错误的 I/O 模型会使您陷入痛苦的境地。
和大多数可能有多种方法的情况一样,这不仅仅是哪个更好的问题,而是理解权衡的问题。 让我们来看看 I/O 环境,看看我们能窥探到什么。
在本文中,我们将比较 Node、Java、Go 和 PHP 与 Apache,讨论不同语言如何对其 I/O 建模,每种模型的优缺点,并以一些基本的基准作为结束。 如果您担心下一个 Web 应用程序的 I/O 性能,那么本文适合您。
I/O 基础:快速复习
要了解与 I/O 相关的因素,我们必须首先在操作系统级别回顾这些概念。 虽然不太可能必须直接处理其中许多概念,但您始终通过应用程序的运行时环境间接处理它们。 细节很重要。
系统调用
首先,我们有系统调用,可以描述如下:
- 您的程序(如他们所说,在“用户空间”中)必须要求操作系统内核代表它执行 I/O 操作。
- “系统调用”是您的程序要求内核做某事的方式。 具体实现方式因操作系统而异,但基本概念是相同的。 将有一些特定的指令将控制从您的程序转移到内核(就像一个函数调用,但有一些专门用于处理这种情况的特殊调味料)。 一般来说,系统调用是阻塞的,这意味着您的程序等待内核返回您的代码。
- 内核在有问题的物理设备(磁盘、网卡等)上执行底层 I/O 操作并回复系统调用。 在现实世界中,内核可能需要做很多事情来满足您的请求,包括等待设备准备就绪、更新其内部状态等,但作为应用程序开发人员,您并不关心这些。 那是内核的工作。
阻塞与非阻塞调用
现在,我刚刚在上面说过系统调用是阻塞的,这在一般意义上是正确的。 但是,有些调用被归类为“非阻塞”,这意味着内核接受您的请求,将其放入队列或缓冲区中的某个位置,然后立即返回,而无需等待实际的 I/O 发生。 所以它只“阻塞”了很短的时间,刚好足以让你的请求排队。
一些(Linux 系统调用的)示例可能有助于澄清: - read()
是一个阻塞调用 - 你向它传递一个句柄,说明哪个文件和一个缓冲区,说明它读取的数据传递到哪里,并且当数据存在时调用返回。 请注意,这具有美观和简单的优点。 - epoll_create()
、 epoll_ctl()
和epoll_wait()
是分别让您创建一组句柄以进行侦听、从该组添加/删除处理程序然后阻塞直到有任何活动的调用。 这使您可以使用单个线程有效地控制大量 I/O 操作,但我已经超前了。 如果您需要该功能,这很好,但正如您所见,使用起来肯定更复杂。
在这里了解时间差异的数量级很重要。 如果 CPU 内核以 3GHz 运行,而无需进行 CPU 可以进行的优化,它每秒执行 30 亿个周期(或每纳秒 3 个周期)。 一个非阻塞系统调用可能需要大约 10 秒的周期才能完成 - 或“相对几纳秒”。 阻止通过网络接收信息的调用可能需要更长的时间 - 例如 200 毫秒(1/5 秒)。 例如,非阻塞调用耗时 20 纳秒,阻塞调用耗时 200,000,000 纳秒。 您的进程只是等待阻塞调用的时间长了 1000 万倍。
内核提供了阻塞 I/O(“从这个网络连接读取并给我数据”)和非阻塞 I/O(“告诉我这些网络连接何时有新数据”)的方法。 并且使用哪种机制将阻塞调用过程的时间长度差异很大。
调度
需要遵循的第三件事是当您有很多线程或进程开始阻塞时会发生什么。
就我们的目的而言,线程和进程之间并没有太大的区别。 在现实生活中,与性能相关的最显着差异是,由于线程共享相同的内存,并且每个进程都有自己的内存空间,因此单独的进程往往会占用更多的内存。 但是当我们谈论调度时,它真正归结为一个事物列表(线程和进程等),每个事物都需要在可用的 CPU 内核上获得一段执行时间。 如果你有 300 个线程和 8 个内核在运行它们,你必须将时间分开,以便每个内核都得到它的份额,每个内核运行一小段时间,然后转移到下一个线程。 这是通过“上下文切换”完成的,使 CPU 从运行一个线程/进程切换到下一个。
这些上下文切换有与之相关的成本——它们需要一些时间。 在一些快速的情况下,它可能小于 100 纳秒,但根据实现细节、处理器速度/架构、CPU 缓存等,它需要 1000 纳秒或更长的时间并不少见。
并且线程(或进程)越多,上下文切换就越多。 当我们谈论数千个线程,每个线程数百纳秒时,事情会变得非常缓慢。
然而,非阻塞调用本质上告诉内核“只有当你在这些连接中的任何一个上有一些新数据或事件时才给我打电话。” 这些非阻塞调用旨在有效处理大型 I/O 负载并减少上下文切换。
跟我到现在? 因为现在是有趣的部分:让我们看看一些流行的语言如何使用这些工具,并就易用性和性能之间的权衡得出一些结论……以及其他有趣的花絮。
请注意,虽然本文中显示的示例是微不足道的(并且是部分的,仅显示了相关位); 数据库访问、外部缓存系统(memcache 等)以及任何需要 I/O 的东西最终都会在后台执行某种 I/O 调用,这与所示的简单示例具有相同的效果。 此外,对于 I/O 被描述为“阻塞”的场景(PHP、Java),HTTP 请求和响应的读取和写入本身就是阻塞调用:同样,更多的 I/O 隐藏在系统中并伴随着性能问题考虑到。
为项目选择编程语言有很多因素。 当您只考虑性能时,甚至还有很多因素。 但是,如果您担心您的程序将主要受到 I/O 的限制,如果 I/O 性能对您的项目来说是成败,那么这些都是您需要了解的事情。
“保持简单”的方法:PHP
早在 90 年代,很多人都穿着 Converse 鞋并用 Perl 编写 CGI 脚本。 然后 PHP 出现了,尽管有些人喜欢使用它,但它使动态网页变得更加容易。
PHP 使用的模型相当简单。 它有一些变化,但你的普通 PHP 服务器看起来像:
HTTP 请求来自用户的浏览器并访问您的 Apache Web 服务器。 Apache 为每个请求创建一个单独的进程,并进行一些优化以重用它们,以尽量减少它必须做的事情(相对而言,创建进程很慢)。 Apache 调用 PHP 并告诉它在磁盘上运行适当的.php
文件。 PHP 代码执行并阻塞 I/O 调用。 您在 PHP 中调用file_get_contents()
并在后台调用read()
系统调用并等待结果。
当然,实际代码只是直接嵌入到您的页面中,并且操作是阻塞的:
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network 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 Servlet 中执行 I/O 看起来像这样:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
由于我们上面的doGet
方法对应一个请求并且在它自己的线程中运行,而不是为每个需要自己的内存的请求一个单独的进程,我们有一个单独的线程。 这有一些不错的好处,比如能够在线程之间共享状态、缓存数据等,因为它们可以访问彼此的内存,但是对它如何与调度交互的影响仍然几乎与 PHP 中所做的相同前面的例子。 每个请求都会获得一个新线程,并且该线程内的各种 I/O 操作会阻塞,直到请求被完全处理。 线程被池化以最小化创建和销毁它们的成本,但是,数千个连接意味着数千个线程,这对调度程序不利。
一个重要的里程碑是,Java 在 1.4 版(以及 1.7 版的重大升级)中获得了进行非阻塞 I/O 调用的能力。 大多数应用程序,网络和其他应用程序,不使用它,但至少它是可用的。 一些 Java Web 服务器试图以各种方式利用这一点。 但是,绝大多数已部署的 Java 应用程序仍按上述方式工作。
Java 让我们更接近,当然也有一些很好的开箱即用的 I/O 功能,但是它仍然不能真正解决当你有一个严重 I/O 绑定的应用程序时会发生的问题。有成千上万个阻塞线程的地面。
作为一等公民的非阻塞 I/O:节点
当谈到更好的 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 JavaScript 引擎(Node 使用的 Chrome 的 JS 引擎)的实现方式1有关。 您编写的所有 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++) { // do processing on each row } response.end(...); // write out the results }) };
虽然 Node 确实有效地处理了 I/O,但上面示例中的for
循环是在您的一个且唯一的主线程中使用 CPU 周期。 这意味着,如果您有 10,000 个连接,则该循环可能会使您的整个应用程序陷入困境,具体取决于它需要多长时间。 每个请求都必须在主线程中共享一段时间,一次一个。
整个概念所基于的前提是 I/O 操作是最慢的部分,因此有效地处理这些操作是最重要的,即使这意味着要串行执行其他处理。 在某些情况下确实如此,但并非全部如此。
另一点是,虽然这只是一种观点,但编写一堆嵌套回调可能会很烦人,有些人认为这会使代码更难遵循。 在 Node 代码深处嵌套四、五甚至更多级别的回调并不少见。
我们又回到了权衡。 如果您的主要性能问题是 I/O,则 Node 模型运行良好。 然而,它的致命弱点是你可以进入一个处理 HTTP 请求的函数,并放入 CPU 密集型代码,如果你不小心的话,就会让每个连接都爬起来。
自然无阻塞:Go
在我进入围棋部分之前,我有必要透露一下我是围棋迷。 我已经将它用于许多项目,并且我公开支持它的生产力优势,当我使用它时,我在我的工作中看到了它们。
也就是说,让我们看看它是如何处理 I/O 的。 Go 语言的一个关键特性是它包含自己的调度程序。 它不是每个执行线程都对应一个 OS 线程,而是使用“goroutines”的概念。 Go 运行时可以将一个 goroutine 分配给一个 OS 线程并让它执行,或者挂起它并且让它不与 OS 线程相关联,这取决于该 goroutine 正在做什么。 来自 Go 的 HTTP 服务器的每个请求都在一个单独的 Goroutine 中处理。
调度程序的工作原理图如下所示:
在底层,这是由 Go 运行时中的各个点实现的,这些点通过发出写入/读取/连接/等请求来实现 I/O 调用,使当前 goroutine 进入睡眠状态,并提供唤醒 goroutine 的信息当可以采取进一步行动时。
实际上,Go 运行时所做的事情与 Node 所做的事情并没有太大的不同,只是回调机制内置在 I/O 调用的实现中并自动与调度程序交互。 它也不受必须让你的所有处理程序代码在同一个线程中运行的限制,Go 会根据其调度程序中的逻辑自动将你的 Goroutine 映射到它认为合适的尽可能多的 OS 线程。 结果是这样的代码:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
正如您在上面看到的,我们所做的基本代码结构类似于更简单的方法,但在底层实现了非阻塞 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)给我们这个:
仅从这张图很难得出结论,但在我看来,在这种连接和计算量下,我们看到的时间更多地与语言本身的一般执行有关,更重要的是输入/输出。 请注意,被认为是“脚本语言”的语言(松散类型、动态解释)执行速度最慢。
但是如果我们将 N 增加到 1000 会发生什么,仍然有 300 个并发请求 - 相同的负载但 100 倍以上的哈希迭代(显着更多的 CPU 负载):
突然之间,Node 性能显着下降,因为每个请求中的 CPU 密集型操作相互阻塞。 有趣的是,PHP 的性能变得更好(相对于其他)并且在这个测试中击败了 Java。 (值得注意的是,在 PHP 中,SHA-256 实现是用 C 编写的,并且执行路径在该循环中花费了更多时间,因为我们现在正在进行 1000 次哈希迭代)。
现在让我们尝试 5000 个并发连接(N=1)——或者尽可能接近。 不幸的是,对于这些环境中的大多数,故障率并非微不足道。 对于此图表,我们将查看每秒的请求总数。 越高越好:
而且图片看起来完全不同。 这是一个猜测,但看起来在高连接量下,产生新进程所涉及的每个连接开销以及 PHP+Apache 中与之相关的额外内存似乎成为主要因素,并降低了 PHP 的性能。 显然,Go 是这里的赢家,其次是 Java、Node,最后是 PHP。
虽然与您的整体吞吐量有关的因素很多,并且因应用程序而异,但您对幕后发生的事情和所涉及的权衡了解得越多,您的情况就会越好。
总之
综上所述,很明显,随着语言的发展,处理大量 I/O 的大规模应用程序的解决方案也随之发展。
公平地说,尽管本文中有描述,PHP 和 Java 都提供了可用于 Web 应用程序的非阻塞 I/O 实现。 但是这些并不像上述方法那样普遍,并且需要考虑使用这些方法维护服务器所带来的操作开销。 更不用说您的代码必须以适用于此类环境的方式构建; 您的“普通” PHP 或 Java Web 应用程序通常不会在这样的环境中未经重大修改而运行。
作为比较,如果我们考虑一些影响性能和易用性的重要因素,我们会得到:
语言 | 线程与进程 | 非阻塞 I/O | 便于使用 |
---|---|---|---|
PHP | 流程 | 不 | |
爪哇 | 线程 | 可用的 | 需要回调 |
节点.js | 线程 | 是的 | 需要回调 |
走 | 线程(Goroutines) | 是的 | 不需要回调 |
线程通常比进程的内存效率要高得多,因为它们共享相同的内存空间,而进程则没有。 将其与与非阻塞 I/O 相关的因素结合起来,我们可以看到,至少在考虑到上述因素的情况下,当我们向下移动列表时,与 I/O 相关的一般设置得到了改进。 因此,如果我必须在上述比赛中选出一名获胜者,那肯定是围棋。
尽管如此,在实践中,选择构建应用程序的环境与您的团队对该环境的熟悉程度以及您可以通过它实现的整体生产力密切相关。 因此,每个团队都潜入并开始使用 Node 或 Go 开发 Web 应用程序和服务可能没有意义。 事实上,寻找开发人员或熟悉内部团队通常被认为是不使用不同语言和/或环境的主要原因。 也就是说,在过去十五年左右的时间里,时代已经发生了很大变化。
希望以上内容有助于更清楚地了解幕后发生的事情,并为您提供一些关于如何处理应用程序的实际可伸缩性的想法。 快乐的输入和输出!