共计 8411 个字符,预计需要花费 22 分钟才能阅读完成。
导读 | 理解应用程序的输入 / 输出(I/O)模型,意味着其在计划处理负载与残酷的实际使用场景之间的差异。若应用程序比较小,也没有服务于很高的负载,也许它影响甚微。但随着应用程序的负载逐渐上涨,采用错误的 I / O 模型有可能会让你到处踩坑,伤痕累累。 |
正如大部分存在多种解决途径的场景一样,重点不在于哪一种途径更好,而是在于理解如何进行权衡。让我们来参观下 I / O 的景观,看下可以从中窃取点什么。
在这篇文章,我们将会结合 Apache 分别比较 Node,Java,Go,和 PHP,讨论这些不同的语言如何对他们的 I / O 进行建模,各个模型的优点和缺点,并得出一些初步基准的结论。如果关心你下一个 Web 应用的 I / O 性能,那你就找对文章了。
为了理解与 I / O 密切相关的因素,必须先来回顾在操作系统底层的概念。虽然不会直接处理这些概念的大部分,但通过应用程序的运行时环境你一直在间接地处理他们。而关键在于细节。
首先,我们有系统调用,它可以描述成这样:
- 你的程序(在“用户区域”,正如他们所说的)必须让操作系统内核在它自身执行 I / O 操作。
- “系统调用”(syscall)意味着你的程序要求内核做某事。不同的操作系统,实现系统调用的细节有所不同,但基本的概念是一样的。这将会有一些特定的指令,把控制权从你的程序转交到内核(类似函数调用但有一些专门用于处理这种场景的特殊 sauce)。通常来说,系统调用是阻塞的,意味着你的程序需要等待内核返回到你的代码。
- 内核在我们所说的物理设备(硬盘、网卡等)上执行底层的 I / O 操作,并回复给系统调用。在现实世界中,内核可能需要做很多事情才能完成你的请求,包括等待设备准备就绪,更新它的内部状态等,但作为一名应用程序开发人员,你可以不用关心这些。以下是内核的工作情况。
好了,我刚刚在上面说系统调用是阻塞的,通常来说这是对的。然而,有些调用被分类为“非阻塞”,意味着内核接收了你的请求后,把它放进了队列或者缓冲的某个地方,然后立即返回而并没有等待实际的 I / O 调用。所以它只是“阻塞”了一段非常短的时间,短到只是把你的请求入列而已。
这里有一些有助于解释清楚的(Linux 系统调用)例子:-read() 是阻塞调用——你传给它一个文件句柄和一个存放所读到数据的缓冲,然后此调用会在当数据好后返回。注意这种方式有着优雅和简单的优点。-epoll_create(),epoll_ctl(),和 epoll_wait() 这些调用分别是,让你创建一组用于侦听的句柄,从该组添加 / 删除句柄,和然后直到有活动时才阻塞。这使得你可以通过一个线程有效地控制一系列 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 负载,以及减少上下文切换。
到目前为止你还在看这篇文章吗?因为现在来到了有趣的部分:让我们来看下一些流利的语言如何使用这些工具,并就在易用性和性能之间的权衡作出一些结论……以及其他有趣的点评。
请注意,虽然在这篇文章中展示的示例是琐碎的(并且是不完整的,只是显示了相关部分的代码),但数据库访问,外部缓存系统(memcache 等全部)和需要 I / O 的任何东西,都以执行某些背后的 I / O 操作而结束,这些和展示的示例一样有着同样的影响。同样地,对于 I / O 被描述为“阻塞”(PHP,Java)这样的情节,HTTP 请求与响应的读取与写入本身是阻塞的调用:再一次,更多隐藏在系统中的 I / O 及其伴随的性能问题需要考虑。
为项目选择编程语言要考虑的因素有很多。当你只考虑性能时,要考虑的因素甚至有更多。但是,如果你关注的是程序主要受限于 I /O,如果 I / O 性能对于你的项目至关重要,那这些都是你需要了解的。“保持简单”的方法:PHP。
回到 90 年代的时候,很多人穿着匡威鞋,用 Perl 写着 CGI 脚本。随后出现了 PHP,很多人喜欢使用它,它使得制作动态网页更为容易。
PHP 使用的模型相当简单。虽然有一些变化,但基本上 PHP 服务器看起来像:
HTTP 请求来自用户的浏览器,并且访问了你的 Apache 网站服务器。Apache 为每个请求创建一个单独的进程,通过一些优化来重用它们,以便最大程度地减少其需要执行的次数(创建进程相对来说较慢)。Apache 调用 PHP 并告诉它在磁盘上运行相应的 .php 文件。PHP 代码执行并做一些阻塞的 I / O 调用。若在 PHP 中调用了 file_get_contents(),那在背后它会触发 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 来了,并且在一个句子之后随便说一句“dot com”是很酷的。而 Java 具有语言内置的多线程(特别是在创建时),这一点非常棒。
大多数 Java 网站服务器通过为每个进来的请求启动一个新的执行线程,然后在该线程中最终调用作为应用程序开发人员的你所编写的函数。
在 Java 的 Servlet 中执行 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("https://example.com/example-microservice")).openConnection();
InputStream netIs = urlConnection.getInputStream();
// 更多阻塞的网络 I /O
out.println("...");
}
由于我们上面的 doGet 方法对应于一个请求并且在自己的线程中运行,而不是每次请求都对应需要有自己专属内存的单独进程,所以我们会有一个单独的线程。这样会有一些不错的优点,例如可以在线程之间共享状态、共享缓存的数据等,因为它们可以相互访问各自的内存,但是它如何与调度进行交互的影响,仍然与前面 PHP 例子中所做的内容几乎一模一样。每个请求都会产生一个新的线程,而在这个线程中的各种 I / O 操作会一直阻塞,直到这个请求被完全处理为止。为了最小化创建和销毁它们的成本,线程会被汇集在一起,但是依然,有成千上万个连接就意味着成千上万个线程,这对于调度器是不利的。
一个重要的里程碑是,在 Java 1.4 版本(和再次显著升级的 1.7 版本)中,获得了执行非阻塞 I / O 调用的能力。大多数应用程序,网站和其他程序,并没有使用它,但至少它是可获得的。一些 Java 网站服务器尝试以各种方式利用这一点; 然而,绝大多数已经部署的 Java 应用程序仍然如上所述那样工作。
Java 让我们更进了一步,当然对于 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 来处理,然后获取回调函数的机制称为“事件循环”。它工作得非常好。
然而,这个模型中有一道关卡。在幕后,究其原因,更多是如何实现 JavaScript V8 引擎(Chrome 的 JS 引擎,用于 Node)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++) {// 对每一行纪录进行处理}
response.end(...); // 输出结果
})
};
虽然 Node 确实可以有效地处理 I /O,但上面的例子中的 for 循环使用的是在你主线程中的 CPU 周期。这意味着,如果你有 10,000 个连接,该循环有可能会让你整个应用程序慢如蜗牛,具体取决于每次循环需要多长时间。每个请求必须分享在主线程中的一段时间,一次一个。
这个整体概念的前提是 I / O 操作是最慢的部分,因此最重要是有效地处理这些操作,即使意味着串行进行其他处理。这在某些情况下是正确的,但不是全都正确。
另一点是,虽然这只是一个意见,但是写一堆嵌套的回调可能会令人相当讨厌,有些人认为它使得代码明显无章可循。在 Node 代码的深处,看到嵌套四层、嵌套五层、甚至更多层级的嵌套并不罕见。
我们再次回到了权衡。如果你主要的性能问题在于 I /O,那么 Node 模型能很好地工作。然而,它的阿喀琉斯之踵(译者注:来自希腊神话,表示致命的弱点)是如果不小心的话,你可能会在某个函数里处理 HTTP 请求并放置 CPU 密集型代码,最后使得每个连接慢得如蜗牛。
在进入 Go 这一章节之前,我应该披露我是一名 Go 粉丝。我已经在许多项目中使用 Go,是其生产力优势的公开支持者,并且在使用时我在工作中看到了他们。
也就是说,我们来看看它是如何处理 I / O 的。Go 语言的一个关键特性是它包含自己的调度器。并不是每个线程的执行对应于一个单一的 OS 线程,Go 采用的是“goroutines”这一概念。Go 运行时可以将一个 goroutine 分配给一个 OS 线程并使其执行,或者把它挂起而不与 OS 线程关联,这取决于 goroutine 做的是什么。来自 Go 的 HTTP 服务器的每个请求都在单独的 Goroutine 中处理。
此调度器工作的示意图,如下所示:
这是通过在 Go 运行时的各个点来实现的,通过将请求写入 / 读取 / 连接 / 等实现 I / O 调用,让当前的 goroutine 进入睡眠状态,当可采取进一步行动时用信息把 goroutine 重新唤醒。
实际上,除了回调机制内置到 I / O 调用的实现中并自动与调度器交互外,Go 运行时做的事情与 Node 做的事情并没有太多不同。它也不受必须把所有的处理程序代码都运行在同一个线程中这一限制,Go 将会根据其调度器的逻辑自动将 Goroutine 映射到其认为合适的 OS 线程上。最后代码类似这样:
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 个请求,会发生什么呢 —— 相同的负载,但是 hash 迭代是之前的 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 | 进程 | 否 | ||
Java | 线程 | 可用 | 需要回调 | |
Node.js | 线程 | 是 | 需要回调 | |
Go | 线程(Goroutine) | 是 | 不需要回调 |
线程通常要比进程有更高的内存效率,因为它们共享相同的内存空间,而进程则没有。结合与非阻塞 I / O 相关的因素,当我们向下移动列表到一般的启动时,因为它与改善 I / O 有关,可以看到至少与上面考虑的因素一样。如果我不得不在上面的比赛中选出一个冠军,那肯定会是 Go。
即便这样,在实践中,选择构建应用程序的环境与你的团队对于所述环境的熟悉程度以及可以实现的总体生产力密切相关。因此,每个团队只是一味地扎进去并开始用 Node 或 Go 开发 Web 应用程序和服务可能没有意义。事实上,寻找开发人员或内部团队的熟悉度通常被认为是不使用不同的语言和 / 或不同的环境的主要原因。也就是说,过去的十五年来,时代已经发生了巨大的变化。
希望以上内容可以帮助你更清楚地了解幕后所发生的事件,并就如何处理应用程序现实世界中的可扩展性为你提供的一些想法。快乐输入,快乐输出!