深入解析应用程序I/O模型:从基础知识到多线程与无阻塞技术的全面指南
了解应用程序的输入/输出(I/O)模型,意味着要理解应用程序处理其数据的载入差异,并且要揭示其在真实环境中的表现。也许你的应用程序规模较小,在负载不大的情况下,这不是一个严重的问题;然而,随着应用程序的流量负载不断增加,可能会因为使用了低效的 I/O 模型而无法承受,从而导致崩溃。

-- Brad Peabody
本文导航
I/O 基础知识: 快速复习04%
系统调用05%
阻塞与非阻塞09%
调度18%
“保持简单”方法:PHP28%
多线程方法: Java37%
无阻塞 I/O 作为一等公民: Node47%
最自然的非阻塞:Go62%
谎言,可恶的谎言和基准73%
总结88%
编译自|
作者|Brad Peabody
译者|MonkeyDEcho
了解应用程序的输入/输出(I/O)模型意味着理解应用程序处理其数据的载入差异,并揭示其在真实环境中表现。或许你的应用程序很小,在不承受很大的负载时,这并不是个严重的问题;但随着应用程序的流量负载增加,可能因为使用了低效的 I/O 模型导致承受不了而崩溃。
和多数情况类似,处理这类问题存在多种办法,这并非仅仅是一个挑选的问题,而是对权衡有所理解的问题。接着我们来瞧一瞧 I/O 究竟是什么。
在本文里,我们要对 Node、Java、Go 以及 PHP + Apache 展开对比。我们会探讨不同语言是怎样构建其 I/O 的,每个模型所具有的优缺点,并且总结出一些基本的规律。倘若你担忧你的下一个 Web 应用程序的 I/O 性能,那么本文将会给你最为合适的解答。
I/O 基础知识: 快速复习
要了解 I/O 所涉及的因素,首先我们深入到操作系统层面去复习这些概念。虽然看似不与这些概念直接打交道,但实际上你会一直通过应用程序的运行时环境与它们产生间接接触。了解细节是很重要的。
系统调用
首先是系统调用,其被描述如下:
程序(“用户端 user land”)要请求操作系统内核为其执行 I/O 操作。
系统调用 syscall 是你的程序促使内核执行某些操作的途径。这些实现的细节在不同操作系统中存在差异,然而基本概念是一致的。存在一些特定的指令,能够将控制权从你的程序转交到内核(类似于函数调用,不过是运用专门用于处理此类情况的特殊方式)。一般而言,系统调用会处于被阻塞的状态。这也就意味着,你的程序需要等待内核将控制权返还给你的代码。
内核在所需的物理设备,比如磁盘、网卡等上面执行底层的 I/O 操作,并且会回应系统调用。在实际的情形里,内核或许需要去做很多事情以满足你的需求,其中包括等待设备准备好,以及更新它的内部状态等。然而,作为应用程序的开发人员,你无需对这些事情予以关心。这些都是内核的工作。
Syscalls Diagram
阻塞与非阻塞
我们之前提到过,系统调用通常是阻塞的。不过,有一些调用被划分为“非阻塞”的。这意味着内核会接收你的请求,把它放置在队列或缓冲区等地方,接着立即返回,而不会等待实际的 I/O 发生。所以,它只是在极短的时间内处于“阻塞”状态,只需对你的请求进行排队即可。
举一些 Linux 系统调用的例子可能有助于理解:
read()是一个会阻塞的调用。你需要传递一个句柄,以指明是哪个文件以及缓冲区在何处传送它所读取的数据。当数据准备好时,这个调用就会返回。这种方式的优点在于它简单且友好。
调用 epoll_create(),可以创建一组句柄。调用 epoll_ctl(),能够添加或删除该组中的处理程序。调用 epoll_wait(),则会阻塞直到有任何事件发生。这样你就能通过单个线程有效地控制大量的 I/O 操作,不过现在谈这个为时尚早。如果你需要这个功能当然好,但须知道它使用起来是比较复杂的。
了解这里时间差异的量级很重要。若 CPU 内核运行在 3GHz,在未进行 CPU 优化时,它每秒能执行 30 亿次周期,也就是每纳秒 3 个周期。非阻塞系统调用或许需要几十个周期才能完成,即需要“相对较少的纳秒”时间来完成。一个被跨网络接收信息所阻塞的系统调用可能需要较长时间,比如 200 毫秒(1/5 秒)。这意味着,若非阻塞调用需 20 纳秒,那么阻塞调用就需要 2 亿纳秒。你的进程因阻塞调用而等待的时长是 1000 万倍于非阻塞调用所需的时长!
内核提供了阻塞 I/O ,这种 I/O 是“从网络连接读取并给出数据”;同时也提供了非阻塞 I/O ,这种 I/O 是“告知我何时这些网络连接具有新数据”。调用进程的阻塞时长会因使用的是哪种机制而有截然不同的影响。
调度
关键的第三件事是当你有很多线程或进程开始阻塞时会发生什么。
我们的理解是,线程和进程之间差异不大。在现实生活中,性能相关方面最显著的差异在于,线程能共享相同的内存,而进程各自有自己的内存空间,这就导致单独的进程通常会占用更多的内存。但是当谈论调度 Scheduling 时,它实际上归结为一类事情,线程和进程类似。每个都需要在可用的 CPU 内核上获得一段执行时间。如果有 300 个线程在 8 个内核上运行,就必须把时间分成几份。这样每个线程和进程都能分享它,每个线程运行一段时间后,再交给下一个。这是通过“上下文切换”来完成的。它能够让 CPU 从正在运行的一个线程或进程切换到下一个。
这些上下文切换存在相关成本,并且需要一定时间。在某些快速的情形下,所需时间可能小于 100 纳秒。然而,依据实际情况、处理器速度与体系结构、CPU 缓存等因素,偶尔也会花费 1000 纳秒甚至更长时间。
线程越多的话,上下文切换也就会越多。当我们所涉及的线程数量达到数以千计时,如果每个线程都要花费数百纳秒,那么就会变得很慢。
然而,非阻塞调用的实质是告知内核“只有在这些连接中的某一个有新的数据或事件时才叫我”。这些非阻塞调用的目的是能够有效地应对大量的 I/O 负载,并且可以减少上下文的交换。
你明白了这些吗?现在进入到真正有趣的部分。我们来看看一些流行的语言对于那些工具的使用情况,接着得出关于易用性和性能之间权衡的结论,同时也看看一些其他有趣的小东西。
声明,本文中显示的示例是零碎的,只是体现了相关信息;数据库访问、外部缓存系统(如 memcache 等)以及任何需要 I/O 的东西,都会执行某种类型的 I/O 调用,其本质与上面所示的简单示例的效果是一样的。此外,在 PHP 和 Java 中,将 I/O 描述为“阻塞”。HTTP 请求和响应的读取与写入本身就是阻塞调用。系统中隐藏着更多的 I/O 以及其伴生的性能问题需要被考虑。
为一个项目选择编程语言需考虑诸多因素。即便仅考虑效率这一方面,也存在诸多因素。然而,倘若你担忧你的程序主要会受 I/O 限制,且 I/O 性能会对项目的成败产生影响,那么这些就是你需要了解的内容。
“保持简单”方法:PHP
90 年代的时候,有很多人都穿着 Converse[1]鞋。他们还用 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);
还有一些阻塞性的网络输入输出。
$result = $db->query(从 examples 表中选择 id 和 data 字段,按照 id 降序排列,然后限制结果为 100 条。);
?>
关于如何与系统集成,就像这样:
I/O Model PHP
很简单,每个请求对应一个进程。当进行 I/O 调用时就会阻塞。这种方式的优点是简单且能工作,然而缺点在于,如果同时与 20,000 个客户端连接,服务器将会崩溃。并且这种方法不能很好地进行扩展,因为内核所提供的用于处理大容量 I/O(如 epoll 等)的工具未被使用。雪上加霜的是,每个请求运行一个单独的进程会使用大量系统资源。其中,特别会用到大量内存。而这通常是在这样的场景中最先遇到的问题。
Ruby 使用的方法跟 PHP 很相似,在大致的一些方面,它们能够被看作是一样的。
多线程方法: Java
当你购买第一个域名的时候,在某个句子之后很随意地说出“dot com”,此时 Java 到来了。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 也有重要升级。在这些版本中,Java 获得了执行非阻塞 I/O 调用的能力。大多数应用程序、web 应用以及其他用途不会使用这一能力,但至少它是可以使用的。一些 Java Web 服务器尝试以各种方式利用这一点;然而,绝大多数部署的 Java 应用程序依然按照上述情况工作。
I/O Model Java
有一些很好的开箱即用的 I/O 功能。Java 让我们离这些功能更近了。但它仍未真正解决这样的问题,即当有一个大量 I/O 绑定的应用程序时,它会被数千个阻塞线程所压垮。
无阻塞 I/O 作为一等公民: Node
当 Node.js 迎来更好的 I/O 模式时,阻塞问题才得以真正解决。曾听过 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 去处理它,之后获取回调,这种机制被称作“事件循环”。它的工作状态良好。
I/O Model Node.js
然而,这个模型存在一个陷阱。究其原因,很多方面都与 V8 JavaScript 引擎(Node 所使用的是 Chrome 浏览器的 JS 引擎)的实现方式有关。你所编写的所有 JS 代码都在单个线程中运行。你可以思考一下,这意味着在执行 I/O 时采用高效的非阻塞技术,那么你的 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 代码中,看到回调嵌套 4 层、5 层甚至更多层的情况并不少见。
我们来重新权衡一下。倘若你的主要性能问题是 I/O,那么 Node 模型能够正常工作。但关键在于,你可以在处理 HTTP 请求的函数内部放置 CPU 密集型的代码,要是不小心的话,就会致使每个连接都变得很慢。
最自然的非阻塞:Go
在进入 Go 部分之前,我要披露自己是 Go 的粉丝。我曾在很多项目中使用过它,我公开支持它的生产力优势,并且在工作中会使用它。
那么,让我们来看看它对于 I/O 的处理方式。Go 语言有一个关键特征,那就是它拥有自己的调度程序。在 Go 语言中,并非每个执行线程都对应着一个单独的 OS 线程,而是通过一种被称作“协程 goroutine”的概念来进行工作。Go 的运行时能够把一个协程分配给一个 OS 线程,从而让其执行或者暂停。并且,它不会与一个 OS 线程相联系,这取决于那个协程正在进行的操作。Go 的 HTTP 服务器的每个请求都是在单独的协程中进行处理的。
调度程序的工作原理如图所示:
I/O Model Go
在底层,这是由 Go 运行时中的各个部分来达成的。它借助对请求的写入操作、读取操作以及连接操作等,来实现 I/O 调用。同时,它会让当前协程进入休眠状态,并且在采取进一步动作时,将该协程唤醒。
从效果方面来看,Go 运行时所做的一些事情与 Node 所做的事情差异不大,只是回调机制被内置到了 I/O 调用的实现当中,并且会自动与调度程序进行交互。同时,Go 不会受到必须让所有处理程序代码在同一个线程中运行的这种限制,它会根据自身调度程序中的逻辑,自动将协程映射到它认为合适的 OS 线程上。这样的代码就呈现出来了。
func ServeHTTP(w http.ResponseWriter, r *http.Request) {
这里的底层网络调用是非阻塞的。
rows, err := db.Query("SELECT ...")
for _, row := range rows {
对这些行进行一些操作。
每个请求都在它自己的协程中
}
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 使用率的极为简单的办法。
有关使用的环境的更多细节,请参阅基准说明[2]。
我们首先来看一些低并发的例子。进行 2000 次迭代,同时有 300 个并发请求,并且每个请求仅包含一个散列(N = 1),其结果如下:
时间是在所有并发请求中完成请求的平均毫秒数。越低越好。
仅从一张图难以得出结论。然而,在我看来,在大量的连接和计算量方面,我们看到时间更多地与语言本身的一般执行相关,对于 I/O 而言更是如此。需要注意的是,那些被视为“脚本语言”的语言(具有松散类型且为动态解释),其执行速度是最慢的。
但是,若把 N 提升至 1000 ,此时依然存在 300 个并发请求,是相同的任务,不过哈希迭代变为了 1000 倍,这显著增加了 CPU 负载。
时间是在所有并发请求中完成请求的平均毫秒数。越低越好。
Node 的性能突然显著下降。因为在每个请求中,CPU 密集型操作相互阻塞。有趣的是,在此次测试中,PHP 的性能比其他的要好很多,并且击败了 Java。值得注意的是,在 PHP 里,SHA-256 的实现是用 C 来编写的。在那个循环当中,执行路径花费了更多的时间,因为现在我们要进行 1000 个哈希迭代。
现在让我们尝试 5000 个并发连接,这里的 N 为 1,也就是我能够发起的最大连接数量。很不幸的是,在大多数这样的环境中,故障率并不是很明显。对于这个图表,我们来看一下每秒的请求总数,这个数值是越高越好的。
每秒请求数。越高越好。
这个图看起来和之前有很大差别。我进行了猜测,看起来在高连接量的情况下,产生新进程所涉及的每连接开销以及与 PHP 和 Apache 相关联的附加内存,似乎成为了主要因素,并且阻止了 PHP 的性能。很明显,Go 在其中是获胜者,其次是 Java 和 Node,最后是 PHP。
你的整体吞吐量相关因素众多,且在应用程序间差异很大。你若对底层发生的事以及所涉及的权衡了解更多,就会得到更好的结果。
总结
以上这些情况,很明显,随着语言不断发展,对于处理大量输入输出(I/O)的大型应用程序,其解决方案也在不断发展。
为了保证公平,PHP 和 Java 实现了[3]在 web 应用程序[4]中可使用的[5]非阻塞 I/O[6],这与文章中的描述相符。然而,这些方法不像上述方法那样常见,并且在使用这种方法维护服务器时,需要考虑随之而来的操作开销。你的代码需要以适应这些环境的方式进行结构化,更不必说这一点了;通常情况下,你的“正常”PHP 或 Java Web 应用程序不会在这样的环境中进行重大修改。
如果我们考虑影响性能和易用性的几个重要因素,进行比较后,我们得出以下结论:
< 如显示不全,请左右滑动 >
语言线程与进程非阻塞 I/O使用便捷性
PHP
进程
否
Java
线程
可用
需要回调
Node.js
线程
是
需要回调
Go
线程 (协程)
是
不需要回调
线程通常比进程具有更高的内存效率,因为它们共享相同的内存空间,而进程不共享。结合与非阻塞 I/O 相关的因素,我们能看到,至少考虑到这些因素,当从列表往下看时,与 I/O 相关的一般设置会得到改善。所以如果要在上述比赛中选择一个赢家,那肯定是 Go。
在实践中,即使如此,选择构建应用程序的环境与团队对该环境的熟悉程度以及可实现的总体生产力紧密相关。所以,每个团队都深入并开始在 Node 或 Go 中开发 Web 应用程序和服务并非有意义。实际上,寻找开发人员或内部团队的熟悉度通常被视为不使用不同语言和/或环境的主要原因。也就是说,过去十五年来,时代已经发生了变化。
希望这些内容能让你更明晰底层的状况,还能给你提供关于如何处理应用程序实际可扩展性的一些思路。
via:
作者是 BRAD PEABODY[8],译者是 MonkeyDEcho,校对者是 wxy。
本文由LCTT原创编译,Linux中国荣誉推出
LCTT 译者
MonkeyDEcho
共计翻译:6篇
贡献时间:77 天