翻译:服务器端IO性能对比:Node, PHP, Java和Go ,Server-side I/O Performance: 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操作。
  • 你的程序通过"系统调用"这种方式操作内核。虽然这项操作在不同的操作系统上实现机制不同,但基本概念都是一样的。会有某个特定的指令将控制句柄从你的程序转移到内核上来(和函数调用一样,但是加了点别的东西)。一般来说,系统调用是阻塞的,意味着你的程序将等待直到内核返还控制句柄。
  • 内核驱动物理设备进行操作,并向系统调用返回结果。在实际情况下,内核会做好几件事情来响应你的请求,诸如等待设备就绪、更新内部状态等等,但作为应用开发工程师,你无需关注这一点。这是内核要干的事情。

阻塞与非阻塞调用

上面说到了系统调用是阻塞的,这个说法一般说来是对的。但是,有些调用被认为是"非阻塞"的,意味着内核收到了你的请求,把他放进一个队列或者是缓存中或别的地方,并且无需等待真正的I/O结束就立刻返回。所以只会有非常短时间的阻塞,仅仅是把请求加入队列的时间。

一些Linux系统调用的例子可以帮助你理解: read()是一个阻塞调用,当你告诉它读取完毕后把数据存放到某个文件或缓存,这个调用会把数据放到指定的位置。这种方式的好处在于简便。epoll_create(),epoll_ctl()epoll_wait()这三个调用分别可以让你创建一个监听组,为该组增加/移除处理程序,然后在有新进展之前阻塞。它可以使你得以在一个线程内有效地控制大量的I/O操作, 虽然这样说有点言过其实。如果你需要这个特性的话,这个方式很好。但复杂度明显提升了。

理解时间消耗的巨大差异是关键。一个3GHz且没有经过优化的CPU,一秒钟可以运行30亿次。一个非阻塞的系统调用可能会花费10圈来完成,也就是说只需要几纳秒。一个阻塞并且等待通过网络收到信息的调用可能会花费更长的时间,比如200ms。也就是说,非阻塞调用只花费20纳秒,阻塞调用花费2亿纳秒。采用阻塞调用花费的时间会比非阻塞调用长100亿倍。

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

调度

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

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

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

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

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

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

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

回想上世纪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 Model PHP

非常简单,每个进程处理一个请求。I/O请求是阻塞的。优势是什么?简单好用。劣势是什么?当2万客户端同事访问服务器时,服务器会崩的。这种方式的可拓展性不好,因为没有用到内核提供的专门用来处理大容量I/O的工具(epoll等)。并且雪上加霜的是,为每个请求启动一个隔离的进程会消耗掉很多系统资源,尤其是内存,往往会被先用完。

注意:

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

多线程的方式: Java

Java大概是你买第一个域名的时候出现的。Java有内置的多线程语言支持,这一点非常棒,尤其是在当年它刚被创造的时候。

大部分Java服务器通过为每个请求启动一个线程的方式运行,然后在这个线程中会调用你写的某个函数。

在JavaServlet中进行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引擎的实现对于这种模式起到了决定性的作用。你写的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循环。这意味着如果你有一万个连接,这个循环会让你的整个程序变慢,这取决于它所花费的时间长短。

整个概念的前提是I/O操作是最慢的部分,因此高效地处理这部分功能是最重要的,即便是要把其它操作按顺序执行。这个说法在大部分情况下是对的,但也不绝对。

另一点,当然这只是一个观点,写一大串嵌套的回调是非常烦人的,有些人抱怨代码很难读懂。四五层的嵌套回调在Node里面非常常见。

我们回过头来再讨论一下方案的权衡。如果你的主要性能问题是I/O,用Node非常好。然而,如果你在处理HTTP请求的代码中无意之间增加了CPU密集型的代码的话,它会拖慢你的整个应用,这一点可以算是Node.js的阿喀琉斯之踵。

天生的非阻塞模型: Go

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

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

下图展示了调度器是怎样工作的:自己去原文看图吧

在底层,是通过多项Go运行时的多个特性实现的,如I/O调用以进行请求的写/读/连接等, 使当前的Go程休眠,当进一步的操作可以可以进行的时候再把Go程唤醒。

事实上,Go运行环境做的事情和Node.js做的事情类似,除了callback机制是内置于I/O调用,并且和调度器的交互也是自动的。同时也可以免于同一个线程中运行所有的处理逻辑这一限制,Go会自动地映射Go程到足够多的系统线程上,数量多少有调度器自行斟酌。示例代码如下:

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次请求,每个请求只进行一次哈希操作,结果如下:

PHP用时0.467ms; Java用时0.295ms;Node用时0.499ms;Go用时0.224ms。

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

仅从这一个图很难得出结论;在这个量级,用时更多地取决于语言的运行效率而非I/O。我们一般说的『脚本语言』(动态类型,动态解释)运行比较慢。

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

PHP用时110.97ms; Java用时128.096ms;Node用时206.978ms;Go用时99.658ms。

非常令人震惊,Node性能显著下降,因为每个请求上CPU密集型的操作会相互阻塞。有趣的是,PHP的性能变得更好了(这与其它因素有关),并且击败了Java。(这并不能说明什么问题,因为PHP的SHA-256运算是用C语言写的,因为现在是1000次哈希操作,执行路径反倒会拖慢执行。)

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

PHP约900次,Java约2300次,Node约2200次,Go约4600次。

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

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

总结

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

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

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

语言线程还是进程非阻塞I/O易用性
PHP进程
Java线程可以做到需要回调
Node.js线程需要回调
Go线程(Go程)不需要回调

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

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

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