服务端 I/O 性能大比拼:Node、PHP、Java、Go

澳门新葡亰网址 5

作为一等公民的非阻塞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来处理,然后获取回调函数的机制称为“事件循环”。它工作得非常好。

澳门新葡亰网址 1

然而,这个模型中有一道关卡。在幕后,究其原因,更多是如何实现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密集型代码,最后使得每个连接慢得如蜗牛。

“保持简单”: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');

?>

与系统的集成示意图是这样的:

4.jpg

很简单:每个请求一个进程。 I/O
调用是阻塞的。那么优点呢?简单而又有效。缺点呢?如果有 20000
个客户端并发,服务器将会瘫痪。这种方法扩展起来比较难,因为内核提供的用于处理大量
I/O(epoll
等)的工具并没有充分利用起来。更糟糕的是,为每个请求运行一个单独的进程往往会占用大量的系统资源,尤其是内存,这通常是第一个耗尽的。

  • 注意:在这一点上,Ruby 的情况与 PHP 非常相似。

调度

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

出于我们的目的,线程和进程之间没有太大的区别。实际上,最显而易见的执行相关的区别是,线程共享相同的内存,而每个进程则拥有他们独自的内存空间,使得分离的进程往往占据了大量的内存。但当我们讨论调度时,它最终可归结为一个事件清单(线程和进程类似),其中每个事件需要在有效的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');

?>

关于它如何与系统集成,就像这样:

澳门新葡亰网址 2

相当简单:一个请求,一个进程。I/O是阻塞的。优点是什么呢?简单,可行。那缺点是什么呢?同时与20,000个客户端连接,你的服务器就挂了。由于内核提供的用于处理大容量I/O(epoll等)的工具没有被使用,所以这种方法不能很好地扩展。更糟糕的是,为每个请求运行一个单独的过程往往会使用大量的系统资源,尤其是内存,这通常是在这样的场景中遇到的第一件事情。

注意:Ruby使用的方法与PHP非常相似,在广泛而普遍的方式下,我们可以将其视为是相同的。

性能评测

对于这些不同模型的上下文切换,很难进行准确的计时。当然,我也可以说这对你并没有多大的用处。这里,我将对这些服务器环境下的
HTTP 服务进行基本的性能评测比较。请记住,端到端的 HTTP 请求 /
响应性能涉及到的因素有很多。

我针对每一个环境都写了一段代码来读取 64k 文件中的随机字节,然后对其运行
N 次 SHA-256 散列(在 URL 的查询字符串中指定
N,例如.../test.php?n=100)并以十六进制打印结果。我之所以选择这个,是因为它可以很容易运行一些持续的
I/O 操作,并且可以通过受控的方式来增加 CPU 使用率。

首先,我们来看一些低并发性的例子。使用 300 个并发请求运行 2000
次迭代,每个请求哈希一次(N=1),结果如下:

8.jpg

Times 是完成所有并发请求的平均毫秒数。越低越好。

从单单这一张图中很难得到结论,但我个人认为,在这种存在大量连接和计算的情况下,我们看到的结果更多的是与语言本身的执行有关。请注意,“脚本语言”
的执行速度最慢。

但是如果我们将 N 增加到 1000,但仍然是 300
个并发请求,即在相同的负载的情况下将散列的迭代次数增加了 1000 倍(CPU
负载明显更高),会发生什么情况呢:

9.jpg

Times 是完成所有并发请求的平均毫秒数。越低越好。

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

现在,让我们试试 5000 个并发连接(N=1)
。不幸的是,对于大多数的环境来说,失败率并不明显。我们来看看这个图表中每秒处理的请求数,越高越好

10.jpg

每秒处理的请求数,越高越好。

这个图看起来跟上面的不太一样。我猜测,在较高的连接数量下,PHP + Apache
中产生新进程和内存的申请似乎成为了影响 PHP 性能的主要因素。 很显然,Go
是这次的赢家,其次是 Java,Node,最后是 PHP。

虽然涉及到整体吞吐量的因素很多,而且应用程序和应用程序之间也存在着很大的差异,但是,越是了解底层的原理和所涉及的权衡问题,应用程序的表现就会越好。

澳门新葡亰网址 3

调度

第三个非常关键的事情是当有很多线程或进程开始出现阻塞时会发生什么问题。

对我们而言,线程和进程之间并没有太大的区别。而在现实中,与性能相关的最显著的区别是,由于线程共享相同的内存,并且每个进程都有自己的内存空间,所以单个进程往往会占用更多的内存。但是,在我们谈论调度的时候,实际上讲的是完成一系列的事情,并且每个事情都需要在可用的
CPU 内核上获得一定的执行时间。如果你有 8 个内核来运行 300
个线程,那么你必须把时间分片,这样,每个线程才能获得属于它的时间片,每一个内核运行很短的时间,然后切换到下一个线程。这是通过
“上下文切换” 完成的,可以让 CPU 从一个线程 / 进程切换到下一个线程 /
进程。

这种上下文切换有一定的成本,即需要一定的时间。快的时候可能会小于 100
纳秒,但如果实现细节、处理器速度 / 架构、CPU 缓存等软硬件的不同,花个
1000 纳秒或更长的时间也很正常。

线程(或进程)数量越多,则上下文切换的次数也越多。如果存在成千上万的线程,每个线程都要耗费几百纳秒的切换时间的时候,系统就会变得非常慢。

然而,非阻塞调用实质上告诉内核
“只有在这些连接上有新的数据或事件到来时才调用我”。这些非阻塞调用可有效地处理大
I/O 负载并减少上下文切换。

值得注意的是,虽然本文举得例子很小,但数据库访问、外部缓存系统(memcache
之类的)以及任何需要 I/O 的东西最终都会执行某种类型的 I/O
调用,这跟示例的原理是一样的。

影响项目中编程语言选择的因素有很多,即使你只考虑性能方面,也存在很多的因素。但是,如果你担心自己的程序主要受
I/O
的限制,并且性能是决定项目成功或者失败的重要因素,那么,下文提到的几点建议就是你需要重点考虑的。

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

为了理解与I/O密切相关的因素,必须先来回顾在操作系统底层的概念。虽然不会直接处理这些概念的大部分,但通过应用程序的运行时环境你一直在间接地处理他们。而关键在于细节。

把非阻塞 I/O 作为头等大事:Node

在 I/O 方面表现比较好的、比较受用户欢迎的是 Node.js。任何一个对 Node
有简单了解的人都知道,它是 “非阻塞” 的,并且能够高效地处理
I/O。这在一般意义上是正确的。但是细节和实现的方式至关重要。

在需要做一些涉及 I/O
的操作的时候,你需要发出请求,并给出一个回调函数,Node
会在处理完请求之后调用这个函数。

在请求中执行 I/O 操作的典型代码如下所示:

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

如上所示,这里有两个回调函数。当请求开始时,第一个函数会被调用,而第二个函数是在文件数据可用时被调用。

这样,Node 就能更有效地处理这些回调函数的
I/O。有一个更能说明问题的例子:在 Node
中调用数据库操作。首先,你的程序开始调用数据库操作,并给 Node
一个回调函数,Node 会使用非阻塞调用来单独执行 I/O
操作,然后在请求的数据可用时调用你的回调函数。这种对 I/O
调用进行排队并让 Node 处理 I/O 调用然后得到一个回调的机制称为
“事件循环”。这个机制非常不错。

6.jpg

然而,这个模型有一个问题。在底层,这个问题出现的原因跟 V8 JavaScript
引擎(Node 使用的是 Chrome 的 JS 引擎)的实现有关,即:你写的 JS
代码都运行在一个线程中。请思考一下。这意味着,尽管使用高效的非阻塞技术来执行
I/O,但是 JS 代码在单个线程操作中运行基于 CPU
的操作,每个代码块都会阻塞下一个代码块的运行。有一个常见的例子:在数据库记录上循环,以某种方式处理记录,然后将它们输出到客户端。下面这段代码展示了这个例子的原理:

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
周期。这意味着如果你有 10000
个连接,那么这个循环就可能会占用整个应用程序的时间。每个请求都必须要在主线程中占用一小段时间。

这整个概念的前提是 I/O
操作是最慢的部分,因此,即使串行处理是不得已的,但对它们进行有效处理也是非常重要的。这在某些情况下是成立的,但并非一成不变。

另一点观点是,写一堆嵌套的回调很麻烦,有些人认为这样的代码很丑陋。在
Node 代码中嵌入四个、五个甚至更多层的回调并不罕见。

又到了权衡利弊的时候了。如果你的主要性能问题是 I/O 的话,那么这个 Node
模型能帮到你。但是,它的缺点在于,如果你在一个处理 HTTP
请求的函数中放入了 CPU
处理密集型代码的话,一不小心就会让每个连接都出现拥堵。

系统调用

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

  • 你的程序(在“用户区域”,正如他们所说的)必须让操作系统内核在它自身执行I/O操作。
  • “系统调用”(syscall)意味着你的程序要求内核做某事。不同的操作系统,实现系统调用的细节有所不同,但基本的概念是一样的。这将会有一些特定的指令,把控制权从你的程序转交到内核(类似函数调用但有一些专门用于处理这种场景的特殊sauce)。通常来说,系统调用是阻塞的,意味着你的程序需要等待内核返回到你的代码。
  • 内核在我们所说的物理设备(硬盘、网卡等)上执行底层的I/O操作,并回复给系统调用。在现实世界中,内核可能需要做很多事情才能完成你的请求,包括等待设备准备就绪,更新它的内部状态等,但作为一名应用程序开发人员,你可以不用关心这些。以下是内核的工作情况。

澳门新葡亰网址 4

总结

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

公平地说,PHP 和 Java 在 web
应用方面都有可用的非阻塞
I/O
的实现。但是这些实现并不像上面描述的方法那么使用广泛,并且还需要考虑维护上的开销。更不用说应用程序的代码必须以适合这种环境的方式来构建。

我们来比较一下几个影响性能和易用性的重要因素:

语言 线程与进程 非阻塞 I/O 易于使用

| PHP | 进程 | 否 | – |
| Java | 线程 | 有效 | 需要回调 |
| Node.js | 线程 | 是 | 需要回调 |
| Go | 线程 (Goroutines) | 是 | 无需回调 |

因为线程会共享相同的内存空间,而进程不会,所以线程通常要比进程的内存效率高得多。在上面的列表中,从上往下看,与
I/O
相关的因素一个比一个好。所以,如果我不得不在上面的比较中选择一个赢家,那肯定选
Go。

即便如此,在实践中,选择构建应用程序的环境与你团队对环境的熟悉程度以及团队可以实现的整体生产力密切相关。所以,对于团队来说,使用
Node 或 Go 来开发 Web 应用程序和服务可能并不是最好的选择。

希望以上这些内容能够帮助你更清楚地了解底层发生的事情,并为你提供一些关于如何处理应用程序伸缩性的建议。strong
text


原文 :Server-side I/O Performance: Node vs. PHP vs. Java vs.
Go
作者:BRAD PEABODY
翻译:雁惊寒

阻塞调用与非阻塞调用

好了,我刚刚在上面说系统调用是阻塞的,通常来说这是对的。然而,有些调用被分类为“非阻塞”,意味着内核接收了你的请求后,把它放进了队列或者缓冲的某个地方,然后立即返回而并没有等待实际的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万倍的时间。

澳门新葡亰网址 5

内核提供了阻塞I/O(“从网络连接中读取并把数据给我”)和非阻塞I/O(“当这些网络连接有新数据时就告诉我”)这两种方法。而使用何种机制,对应调用过程的阻塞时间明显长度不同。

在本文中,我们将把 Node、Java、Go 和 PHP 与 Apache
配套进行比较,讨论不同语言如何对 I/O
进行建模、每个模型的优缺点,以及一些基本的性能评测。如果你比较关心自己下一个
Web 应用程序的 I/O 性能,本文将为你提供帮助。

澳门新葡亰网址,正如大部分存在多种解决途径的场景一样,重点不在于哪一种途径更好,而是在于理解如何进行权衡。让我们来参观下I/O的景观,看下可以从中窃取点什么。

1.jpg

发表评论

电子邮件地址不会被公开。 必填项已用*标注

相关文章

网站地图xml地图