摘要:本文首先简单介绍了 I/O 相关的基础概念,然后横向比较了 Node、PHP、Java、Go 的 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 的频率运行,那么它可以每秒执行 30 亿个周期(即每纳秒 3 个周期)。一个非阻塞的系统调用可能需要大约 10 多个周期,或者说几个纳秒。对从网络接收信息的调用进行阻塞可能需要更长的时间,比如说 200 毫秒(1/5 秒)。比方说,非阻塞调用花了 20 纳秒,阻塞调用花了 200,000,000 纳秒。这样,进程为了阻塞调用可能就要等待 1000 万个周期。
内核提供了阻塞 I/O(“从网络读取数据”)和非阻塞 I/O(“告诉我网络连接上什么时候有新数据”)这两种方法,并且两种机制阻塞调用进程的时间长短完全不同。
调度
第三个非常关键的事情是当有很多线程或进程开始出现阻塞时会发生什么问题。
对我们而言,线程和进程之间并没有太大的区别。而在现实中,与性能相关的最显著的区别是,由于线程共享相同的内存,并且每个进程都有自己的内存空间,所以单个进程往往会占用更多的内存。但是,在我们谈论调度的时候,实际上讲的是完成一系列的事情,并且每个事情都需要在可用的 CPU 内核上获得一定的执行时间。如果你有 8 个内核来运行 300 个线程,那么你必须把时间分片,这样,每个线程才能获得属于它的时间片,每一个内核运行很短的时间,然后切换到下一个线程。这是通过 “上下文切换” 完成的,可以让 CPU 从一个线程 / 进程切换到下一个线程 / 进程。
这种上下文切换有一定的成本,即需要一定的时间。快的时候可能会小于 100 纳秒,但如果实现细节、处理器速度 / 架构、CPU 缓存等软硬件的不同,花个 1000 纳秒或更长的时间也很正常。
线程(或进程)数量越多,则上下文切换的次数也越多。如果存在成千上万的线程,每个线程都要耗费几百纳秒的切换时间的时候,系统就会变得非常慢。
然而,非阻塞调用实质上告诉内核 “只有在这些连接上有新的数据或事件到来时才调用我”。这些非阻塞调用可有效地处理大 I/O 负载并减少上下文切换。
值得注意的是,虽然本文举得例子很小,但数据库访问、外部缓存系统(memcache 之类的)以及任何需要 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()
系统调用并等待返回的结果。
query('SELECT id, data FROM examples ORDER BY id DESC limit 100');
?>
与系统的集成示意图是这样的:
很简单:每个请求一个进程。 I/O 调用是阻塞的。那么优点呢?简单而又有效。缺点呢?如果有 20000 个客户端并发,服务器将会瘫痪。这种方法扩展起来比较难,因为内核提供的用于处理大量 I/O(epoll 等)的工具并没有充分利用起来。更糟糕的是,为每个请求运行一个单独的进程往往会占用大量的系统资源,尤其是内存,这通常是第一个耗尽的。
- 注意:在这一点上,Ruby 的情况与 PHP 非常相似。
多线程:Java
所以,Java 就出现了。而且 Java 在语言中内置了多线程,特别是在创建线程时非常得棒。
大多数的 Java Web 服务器都会为每个请求启动一个新的执行线程,然后在这个线程中调用开发人员编写的函数。
在 Java Servlet 中执行 I/O 往往是这样的:
publicvoiddoGet(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
方法对应于一个请求,并且在自己的线程中运行,而不是在需要有独立内存的单独进程中运行,所以我们将创建一个单独的线程。每个请求都会得到一个新的线程,并在该线程内部阻塞各种 I/O 操作,直到请求处理完成。应用会创建一个线程池以最小化创建和销毁线程的成本,但是,成千上万的连接意味着有成千上万的线程,这对于调度器来说并不件好事情。
值得注意的是,1.4 版本的 Java(1.7 版本中又重新做了升级)增加了非阻塞 I/O 调用的能力。虽然大多数的应用程序都没有使用这个特性,但它至少是可用的。一些 Java Web 服务器正在尝试使用这个特性,但绝大部分已经部署的 Java 应用程序仍然按照上面所述的原理进行工作。
Java 提供了很多在 I/O 方面开箱即用的功能,但如果遇到创建大量阻塞线程执行大量 I/O 操作的情况时,Java 也没有太好的解决方案。
把非阻塞 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 调用然后得到一个回调的机制称为 “事件循环”。这个机制非常不错。
然而,这个模型有一个问题。在底层,这个问题出现的原因跟 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 处理密集型代码的话,一不小心就会让每个连接都出现拥堵。
原生无阻塞:Go
在介绍 Go 之前,我透露一下,我是一个 Go 的粉丝。我已经在许多项目中使用了 Go。
让我们看看它是如何处理 I/O 的吧。 Go 语言的一个关键特性是它包含了自己的调度器。它并不会为每个执行线程对应一个操作系统线程,而是使用了 “goroutines” 这个概念。Go 运行时会为一个 goroutine 分配一个操作系统线程,并控制它执行或暂停。Go HTTP 服务器的每个请求都在一个单独的 Goroutine 中进行处理。
调度程序的工作原理如下所示:
实际上,除了回调机制被内置到 I/O 调用的实现中并自动与调度器交互之外,Go 运行时正在做的事情与 Node 不同。它也不会受到必须让所有的处理代码在同一个线程中运行的限制,Go 会根据其调度程序中的逻辑自动将你的 Goroutine 映射到它认为合适的操作系统线程中。因此,它的代码是这样的:
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 文件中的随机字节,然后对其运行 N 次 SHA-256 散列(在 URL 的查询字符串中指定 N,例如.../test.php?n=100
)并以十六进制打印结果。我之所以选择这个,是因为它可以很容易运行一些持续的 I/O 操作,并且可以通过受控的方式来增加 CPU 使用率。
首先,我们来看一些低并发性的例子。使用 300 个并发请求运行 2000 次迭代,每个请求哈希一次(N=1),结果如下:
Times 是完成所有并发请求的平均毫秒数。越低越好。
从单单这一张图中很难得到结论,但我个人认为,在这种存在大量连接和计算的情况下,我们看到的结果更多的是与语言本身的执行有关。请注意,“脚本语言” 的执行速度最慢。
但是如果我们将 N 增加到 1000,但仍然是 300 个并发请求,即在相同的负载的情况下将散列的迭代次数增加了 1000 倍(CPU 负载明显更高),会发生什么情况呢:
Times 是完成所有并发请求的平均毫秒数。越低越好。
突然之间,由于每个请求中的 CPU 密集型操作相互阻塞,Node 的性能显著下降。有趣的是,在这个测试中,PHP 的性能变得更好了(相对于其他),甚至优于 Java。 (值得注意的是,在 PHP 中,SHA-256 的实现是用 C 语言编写的,但执行路径在这个循环中花费了更多的时间,因为我们这次做了 1000 次哈希迭代)。
现在,让我们试试 5000 个并发连接(N=1) 。不幸的是,对于大多数的环境来说,失败率并不明显。我们来看看这个图表中每秒处理的请求数,越高越好:
每秒处理的请求数,越高越好。
这个图看起来跟上面的不太一样。我猜测,在较高的连接数量下,PHP + Apache 中产生新进程和内存的申请似乎成为了影响 PHP 性能的主要因素。 很显然,Go 是这次的赢家,其次是 Java,Node,最后是 PHP。
虽然涉及到整体吞吐量的因素很多,而且应用程序和应用程序之间也存在着很大的差异,但是,越是了解底层的原理和所涉及的权衡问题,应用程序的表现就会越好。
总结
综上所述,随着语言的发展,处理大量 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
翻译:雁惊寒