服务器端 I/O 性能:Node vs. PHP vs. Java vs. Go

前言:看到国外一篇讲几种语言 I/O 特性特别好的文章,于是就翻译出来,供大家参考学习。

原文地址:https://www.toptal.com/back-end/server-side-io-performance-node-php-java-go

服务器端 I/O 性能:Node vs. PHP vs. Java vs. Go

对于你的程序所采用的 I/O 模型的理解程度,决定了你的程序是能得当处理它的应对的负载,还是在面对真实世界情况时崩溃。
当你的程序规模很小并且负载不高时,这方面的问题并不突出。但当程序的访问量陡增时,选用了错误的 I/O 模型可能会让你举步维艰。

大多数情况下,似乎很多种方法都可行,但哪种方法更好,这是一个需要懂得权衡的问题。让我们一起回顾一下 I/O 的知识,看是否可以找到线索。

这里写图片描述

在这篇文章里,我们将会比较 Node,Java, Go 和运行在 Apache 环境下的 PHP, 讨论每种语言分别采用何种I/O模型,每种模型的优劣所在,得出几个粗略的指标。如果你关注你的下一个Web应用的 I/O 性能,这篇文章适合你。

I/O 基础:快速回顾

为理解影响I/O的诸多因素, 我们首先要复习一下操作系统层面的一些概念。虽然很多时候不可能直接接触到这些概念,但事实上,你间接地通过程序的运行环境来与它们接触。所以这些细节非常重要。

系统调用

首先,我们有系统调用,描述如下:

  • 你的程序(用户态)需要请求操作系统内核来代表自己进行 I/O 操作。

  • 你的程序通过”系统调用”这种方式操作内核。虽然这项操作在不同的操作系统上实现机制不同,但基本概念都是一样的。会有某个特定的指令将控制权从你的程序转移到内核(类似于函数调用,但是加了点别的东西)。一般来说,系统调用是阻塞的(blocking),意味着你的程序将一直等着内核返回控制器。

  • 内核在物理设备(磁盘、网卡等)上执行底层 I/O 操作并向调用返回结果,在实际工作中,内核可能需要做一系列工作来完成你的请求, 这些工作包括等待设备就绪,更新内部状态等,但是作为一个应用开发者,你不需要关心这些,这些都是内核的工作。

这里写图片描述

阻塞 vs. 非阻塞调用

现在,我上文说系统调用时阻塞的,这在一般的常识中是正确的。然而,一些系统调用被归类为“非阻塞”,这意味着内核接受你的请求,将它放在某处的队列或者缓冲里,然后立即返回,并不等待实际的 I/O 发生。因此,调用只会阻塞一小会儿,这一小会儿足够将你的请求放入队列。

一些例子(属于 Linux 系统调用)可能帮助解释:read() 是一个阻塞的调用,你传递给它一个句柄,告诉它哪个文件以及将读取到的数据传送到哪块缓冲区,当数据传输完成后,调用返回。注意,这种阻塞行为有着友好、简单的好处,epll_createepll_ctl()epoll_wait() ,这三个调用分别可以让你创建一个句柄组来监听,为该句柄组增加/移除处理程序,然后在有新进展之前阻塞。这允许你在一个线程中高效地控制大量的 I/O 操作,我说得有点过头了。如果你需要这个功能,这个方式很好,但正如你看到的那样,它用起来更加地复杂。

在这,理解时间消耗上的巨大差异非常重要。一个3GHz且没有经过优化的CPU,一秒钟可以运行30亿次。一个非阻塞的系统调用可能会花费10个周期来完成,也就是说只需要几纳秒。一个阻塞并且等待通过网络收到信息的调用可能会花费更长的时间,比如200ms。也就是说,非阻塞调用只花费20纳秒,阻塞调用花费2亿纳秒。你的程序使用阻塞调用,将多等待1000万倍的时间。

这里写图片描述

内核同时提供了两种方式。一种是阻塞 I/O,即从当前网络连接读取并返回数据。另外一种是非阻塞 I/O,当某个网络连接上有新数据的时候再通知我。采用何种方式将在阻塞时间长短上有巨大的差距。

调度

第三个需要考虑的关键问题是当有很多线程或进程开始阻塞时的情况。

对于我们讨论的目的,线程和进程之间并没有太大差别。在实际情况下,有一点是和性能相关的,值得注意:线程共享内存,进程则有他们自己独立的内存空间,因此独立的进程往往消耗更多的内存。但说到调度,归根结底不过是许多线程或进程需要在可用的 CPU 核上获得执行时间片。如果在一个8核心计算机上运行300个线程,你需要把时间划分,每个线程只能获取到一份。在每个线程上运行一段时间后,CPU便会移动到下一个线程上去。这项操作是通过”上下文切换”实现的,它将把 CPU 从运行这一个线程(进程)的状态切换到运行下一个(进程)的状态。

上下文切换也是有开销的。快则100纳秒,用到1000纳秒以上也是很常见的,这个时间和具体实现、处理器速度/架构和 CPU 缓存有关。

线程越多(或者进程),上下文切换越频繁。如果有成千上万的线程,每个切换都需要花费上百纳秒,运行速度会变得很慢。

然而,非阻塞调用告诉内核:只当你在这些连接中的任一个上有新数据或事件时才来调用我。这些非阻塞调用是专门用来解决大 I/O 负载以及解决上下文切换问题的。

总算说完理论部分了,接下来开始说点有趣的:看一下几种流行语言这方面的实现机制,并且得出如何在性能和易用性之间做出权衡,当然也会有一些趣闻分享。

有一点需要说明一下, 下文中举的例子都比较简单(只是展示了必要的部分);数据库访问,外部缓存系统(memcache 等),还有其他最终会在底层进行类似的 I/O 调用的操作,都和例子中的情况类似。并且,在I/O 被以阻塞方式呈现的场景(PHP,Java),或者是 HTTP 请求和响应的读写请求自我阻塞的情况下,系统背后的 I/O 所带来的性能问题都值得考虑。

在考虑选用何种编程语言时,有许多因素要考虑在内。即便是只考虑性能问题,也会有很多要考虑的因素。但如果你的项目首先考虑的是 I/O,如果 I/O 性能决定你项目的成败,这些问题是你需要了解清楚的。

“简单为王” 的方法: PHP

回到 90 年代,有着大量穿着匡威、用着 Perl 写 CGI 脚本的人。然后 PHP 出现了, 尽管一些人在批评它,但它使得写动态 WEB 页面更加的简单。

PHP 所使用的方式非常简单。虽然会有所不同,但一半的 PHP 服务器是下面这样的:

用户的浏览器发出 HTTP 请求,到达 Apache 服务器。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 请求是阻塞的。优势是什么?简单好用。劣势是什么?当2万客户端同事访问服务器时,服务器会崩的。这种方式的可拓展性不好,因为没有用到内核提供的专门用来处理大容量 I/O 的工具(epoll等)。并且雪上加霜的是,为每个请求启动一个隔离的进程会消耗掉很多系统资源,尤其是内存,在这种场景下往往是第一个被耗尽的。

注意:Ruby 所采用的方式和 PHP 类似,针对我们讲的这个话题,可以说是一样的。

多线程方法:Java

Java 大概是你买第一个域名的时候出现的。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("http://example.com/example-microservice")).openConnection();
    InputStream netIs = urlConnection.getInputStream();

    // 更多的阻塞网络I/O
out.println("...");
}

因为 doGet 方法对应一个请求,并运行在自己的线程中。和每个请求分散在一个进程中并且需要拥有专属的内存不同,Java 拥有独立的线程。这样做的好处很多,比如可以共享状态、缓存的数据等等。但在调度方面和前面说到的 PHP 是基本类似的。每个请求获得一个新的线程,各种 I/O 操作在线程内阻塞,直到请求被处理完毕。线程会以线程池的方式工作,以此将创建和销毁进程的开销降到最低。但问题依然存在,成千上万的链接意味着成千上万的线程,这对于调度器来说非常不利。

Java 的1.4版本是一个重要的里程碑(在1.7版本中也有一个显著的升级),增加了对非阻塞 I/O 调用的支持。大部分应用,不管是 WEB 还是别的,都可以用上这个功能了。一些 Java Web 服务器尝试从多种途径利用这个特性;然而,大量已经部署的 Java 应用还是按上面描述的旧模式运行。

这里写图片描述

Java 提供了更好的方案,也有一些现成的非常好的特性。但它还是不能解决高强度 I/O 应用面临的问题,原因是应用会陷入大量阻塞的线程的境地。

非阻塞 I/O 里的一等公民: Node

当谈到更好的 I/O 性能时,最受欢迎的是 Node.js 。随便哪个人,用最简洁的话语介绍 Node 时,都会说它是『非阻塞』的,可以高效地处理 I/O。通常,这样说是没问题的。但是,魔鬼藏在细节中,这个黑魔法在带来性能提升的同时,也带来了麻烦。

从根本上讲,范式从『在这里写下代码,也在这里处理请求』变成了『在这里写下代码,从这里开始处理请求』。每次你要实现和 I/O 相关的功能,就需要发起请求并且传入一个回掉函数,当任务完成之后,这个回掉函数会被调用。

典型的网络请求中处理 I/O 的 Node 代码如下:

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

正如你所看到的那样,这有两个回调函数。第一个在请求开始时被调用,另一个当文件数据读取到之后被调用。

基本上这就给了 Node 一个机会来高效地在这些回调之间处理 I/O。进行数据库调用的场景更加典型,但我不会举那个例子,以免引入过多的复杂的东西:启动一个数据库调用,传给Node一个回调函数,它使用非阻塞系统调用执行 I/O 操作,当请求的数据到达之后,便调用回调函数。这种把 I/O 请求放入队列,让 Node.js 处理它,然后调用回调函数的方式叫做『事件循环』。它运行得非常好。

这里写图片描述

在底层,V8引擎(被 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密集型的代码的话,它会拖慢你的整个应用,这一点可以算是 Node.js 的唯一致命的缺点。

天然的非阻塞:Go

在开始Go的章节之前,我要先坦诚我是Go的粉丝。我已经在很多项目上使用过它,并且非常认同它的高生产力,当我在实际工作中使用它时就已经感受到了。

让我们现在来看一下它是怎样处理 I/O 的。Go 这门语言的关键特性之一便是它拥有自己的调度器(scheduler)。相对于每个执行线程对应一个操作系统线程的模式,它是基于 goroutines 这个概念工作的。Go 运行环境可以把某个 goroutines 赋给某个 操作系统线程来使其执行,或者将它挂起,解除其和操作系统线程的关联,这取决于这个 Go 程正在执行的操作是什么。每个来自于 Go 的 HTTP 服务器的请求都会在一个单独的 goroutines 中执行。

解释调度器如何工作的图表如下:

这里写图片描述

在底层,这些是通过 Go 执行期间不同的 I/O 调用以进行请求的写/读/连接等,使当前的 goroutine 休眠,并在下一步动作可以进行时,重新将其唤醒。

事实上,Go 运行环境做的事情和 Node.js 做的事情差不了多少,除了 Go 回调机制是内置于 I/O 调用中的,并且和调度器的交互也是自动的。同时也不受同一个线程中必须运行所有的处理程序这一限制,Go 会自动地映射 Goroutines 到合适数量的系统线程上,数量多少由调度器根据内部逻辑自行斟酌。示例代码如下:

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 调度器和操作系统系统调度器的通力合作把剩下的工作都完成了。这并不完全是魔法,如果你正在构建一个大型的系统,花点时间多了解一些它的细节还是非常值得的;但与此同时,开箱即用的环境的运行和延展效果非常好。

Go 语言也有其弊端,但通常来说,它处理 I/O 的方式并没有什么问题。

谎言,该死的谎言以及基准测试

对于这几种模型,很难给出他们关于上下文切换的准确结论。同时我也觉得这些对你并没有什么用处。相反,我将给出一些基本的基准测试,比较整体的HTTP服务器性能。请记住,从HTTP的请求/响应的这一端到另一端的沿途,有很多影响因素存在,这里展示的数据只是基于我找来的例子,以求给出一些基本的比较。

对于每一个环境,我写了代码从一个 64Kb 的文件中随机读取字节,对其执行 N 次 SHA-256 哈希运算。其中 N 通过 URL 的query string 来指定。然后以 hex 格式打印哈希运算的结果。我选择这个例子是因为用它来运行基准测试非常简单,可以进行一致的 I/O,可以以一种有效的方式增加 CPU 利用率。

基准测试的记录里有针对环境的介绍。

首先看一下低并发的例子,以300的并发数运行2000次请求,每个请求只进行一次哈希操作,结果如下:

这里写图片描述

用时是指完成请求用时的平均值。越低越好。

仅从这一个图很难得出结论;在这个量级的连接与计算下,用时更多地取决于语言自身的运行而非 I/O 。注意,我们一般说的『脚本语言』(弱类型,动态解释)本身运行得非常慢。

那如果我们把 N 变成1000,依旧是300个并发请求,负载相同,但是哈希操作变成了原来的100多倍。结果如下:

这里写图片描述

时间指的是完成一次并发请求需要的最少毫秒数,越低越好。

非常令人震惊,Node 性能显著下降,因为每个请求上CPU密集型的操作会相互阻塞。有趣的是,PHP的性能变得更好了(相比于其他语言),并且击败了Java。(这并不能说明什么问题,因为PHP 的 SHA-256 运算是用 C 语言写的)

现在我们试一下5000个并发连接、一次哈希运算,或者是接近于这个数值。不行的是,大部分环境都有不同程度的失败率。下图所示,我们来看一下每秒处理的请求数。越高越好:

这里写图片描述

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

这幅图看起来不同于以往。我猜是因为高连接数情况下,每个连接都需要进行创建新进程,会用到更多的内存,这可能是导致 PHP 变慢的主要原因。显然,在这里 Go 是赢家,接下来是 Java 和 Node,最后是 PHP 。

影响每个应用容量大小的因素不尽相同,你越理解你任务的核心,也就是底层发生的操作,以及要做出何种权衡,你就能把程序做的越好。

基于上述所有内容,我们可以得出清晰的结论,随着语言的演进,对于大规模应用的大量 I/O 问题的解决方案也在演进。

公平地讲,对于 PHP 还是 Java,除了文中的描述,确实有在非阻塞 IO 的实现来用在 Web 应用中。但是这些方案并不如上面说到的常见,而且使用这些方案带来的运维成本也需要考虑。更不用说你的代码要改变结构来适应这种环境;你的普通 PHP 或者 Java 应用如果不大改在这些环境里根本运行不起来。

如果考虑影响性能的几个显著特性已经易用性,我们会得出下面这张表:

这里写图片描述

线程一般比进程内存使用效率更高,原因便是线程可以共享内存,进程不能。除了这一点还有非阻塞 I/O 以及上面的其它因素综合考虑,如果要选择一个获胜者的话,那肯定是Go。

即便如此,在实际情况下,开发环境的选取还取决于团队对该环境的熟悉程度,在这个平台上的整体效率。因此,并不是所有的团队都开始用 Node 或 Go 来开发程序。事实上,开发人员的招募或者团队成员对于技术的熟悉程度经常作为是否选择某个语言的决定性因素。即便是如此,时间在之前的15年中还是改变了很多东西。

希望本文的内容可以为你描绘一幅清晰的蓝图,使你可以知晓底层发生了什么,是你可以应对现实中的可拓展性问题。高兴地进行 I/O 操作!

猜你喜欢

转载自blog.csdn.net/stfphp/article/details/72617749