node java php_Node、PHP、Java 和 Go 服务端 I/O 性能比较-Go语言中文社区

最近在纠结自己的口琴曲谱库网站的后端用什么语言。索性找了几篇文章读了读,以下是自己做的笔记。

38e61a9c6637d4b9b7e765101ba38c47.png

系统调用

你的程序必须让操作系统内核在它自身执行I/O操作。“系统调用”(syscall)意味着你的程序要求内核做某事。不同的操作系统,实现系统调用的细节有所不同,但基本的概念是一样的。有一些特定的指令,把控制权从你的程序转交到内核。通常来说,系统调用是阻塞的,意味着你的程序需要等待内核返回到你的代码。

内核在我们所说的物理设备(硬盘、网卡等)上执行底层的I/O操作,并反馈给系统调用。在现实世界中,内核可能需要做很多事情才能完成你的请求,包括等待设备准备就绪,更新它的内部状态等,但作为一名应用程序开发人员,你可以不用关心这些。以下是内核的工作情况。

e0b13313696233177f00ec63782aceae.png

阻塞调用与非阻塞调用

刚刚在上面说系统调用是阻塞的,通常来说这是对的。然而,有些调用被分类为“非阻塞”,意味着内核接收了你的请求后,把它放进了队列或者缓冲的某个地方,然后立即返回而并没有等待实际的I/O调用。所以它只是“阻塞”了一段非常短的时间,短到只是把你的请求入列而已。

理解这里分时差异的数量级是很重要的。如果一个CPU内核运行在3GHz,在没有优化的情况下,它每秒执行30亿次循环(或者每纳秒3次循环)。非阻塞系统调用可能需要10纳秒这样数量级的周期才能完成——或者“相对较少的纳秒”。对于正在通过网络接收信息的阻塞调用可能需要更多的时间——例如200毫秒(0.2秒)。例如,假设非阻塞调用消耗了20纳秒,那么阻塞调用消耗了200,000,000纳秒。对于阻塞调用,你的程序多等待了1000万倍的时间。

58acb0432ccd422d23b1f30280384e8d.png

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

调度

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

出于我们的目的,线程和进程之间没有太大的区别。实际上,最显而易见的执行相关的区别是,线程共享相同的内存,而每个进程则拥有他们独自的内存空间,这使得分离的进程往往占据了大量的内存。

但当我们讨论调度时,它最终可归结为一个事件清单(线程和进程类似),其中每个事件需要在有效的CPU内核上获得一片执行时间。如果你有300个线程正在运行并且运行在8核上,那么你得通过每个内核运行一段很短的时间然后切换到下一个线程的方式,把这些时间划分开来以便每个线程都能获得它的分时。这是通过“上下文切换”来实现的,使得CPU可以从正在运行的某个线程/进程切换到下一个。

这些上下文切换有一定的成本——它们消耗了一些时间。在快的时候,可能少于100纳秒,但是根据实现的细节,处理器速度/架构,CPU缓存等,消耗1000纳秒甚至更长的时间也并不罕见。

线程(或者进程)越多,上下文切换就越多。当我们谈论成千上万的线程,并且每一次切换需要数百纳秒时,速度将会变得非常慢。

然而,非阻塞调用本质上是告诉内核“当你有一些新的数据或者这些连接中的任意一个有事件时才调用我”。这些非阻塞调用设计能够高效地处理大量的I/O负载,以及减少上下文切换。

评测

对于I/O被描述为“阻塞”(PHP,Java)这样的情节,HTTP请求与响应的读取与写入本身是阻塞的调用。

PHP

多进程的方式

PHP使用的模型相当简单,基本上PHP服务器看起来像:

HTTP请求来自用户的浏览器,并且访问了你的Apache网站服务器。Apache为每个请求创建一个单独的进程,通过一些优化来重用它们,以便最大程度地减少其需要执行的次数(创建进程相对来说较慢)。Apache调用PHP并告诉它在磁盘上运行相应的.php文件。PHP代码执行并做一些阻塞的I/O调用。若在PHP中调用了file_get_contents(),那在背后它会触发read()系统调用并等待结果返回。

当然,实际的代码只是简单地嵌在你的页面中,并且操作是阻塞的:

01f6fe7ed03366e5f2cf67a5c1b71d5a.png

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

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

Java

多线程的方式

Java具有语言内置的多线程(特别是在创建时),这一点非常棒。

大多数Java网站服务器通过为每个进来的请求启动一个新的执行线程,然后在该线程中最终调用作为应用程序开发人员的你所编写的函数。

这样会有一些不错的优点,例如可以在线程之间共享状态、共享缓存的数据等,因为它们可以相互访问各自的内存,但是它如何与调度进行交互的影响,仍然与前面PHP例子中所做的内容几乎一模一样。每个请求都会产生一个新的线程,而在这个线程中的各种I/O操作会一直阻塞,直到这个请求被完全处理为止。为了最小化创建和销毁它们的成本,线程会被汇集在一起,但是依然,有成千上万个连接就意味着成千上万个线程,这对于调度器是不利的。

一个重要的里程碑是,在Java 1.4 版本(和再次显著升级的1.7 版本)中,获得了执行非阻塞I/O调用的能力。大多数应用程序,网站和其他程序,并没有使用它,但至少它是可获得的。一些Java网站服务器尝试以各种方式利用这一点; 然而,绝大多数已经部署的Java应用程序仍然如上所述那样工作。

dc8485d74e0d0c86a2b0d869229258b4.png

Java让我们更进了一步,当然对于I/O也有一些很好的“开箱即用”的功能,但它仍然没有真正解决问题:当你有一个严重I/O绑定的应用程序正在被数千个阻塞线程狂拽着快要坠落至地面时怎么办。

Node

作为一等公民的非阻塞I/O

当谈到更好的I/O时,Node.js无疑是新宠。任何曾经对Node有过最简单了解的人都被告知它是“非阻塞”的,并且它能有效地处理I/O。在一般意义上,这是正确的。但魔鬼藏在细节中,当谈及性能时这个巫术的实现方式至关重要。

本质上,Node实现的范式不是说“在这里编写代码来处理请求”,而是转变成“在这里写代码开始处理请求”。每次你都需要做一些涉及I/O的事情,发出请求或者提供一个当完成时Node会调用的回调函数。

04233cb7ae5409b6fc3a7fdb52906d4e.png

你所编写的JS代码全部都运行在一个线程中。思考一下。这意味着当使用有效的非阻塞技术执行I/O时,正在进行CPU绑定操作的JS可以在运行在单线程中,每个代码块阻塞下一个。

虽然Node确实可以有效地处理I/O,但上面的例子中的for循环使用的是在你主线程中的CPU周期。这意味着,如果你有10,000个连接,该循环有可能会让你整个应用程序慢如蜗牛,具体取决于每次循环需要多长时间。每个请求必须分享在主线程中的一段时间,一次一个。

这个整体概念的前提是I/O操作是最慢的部分,因此最重要是有效地处理这些操作,即使意味着串行进行其他处理。这在某些情况下是正确的,但不是全都正确。

另一点是,虽然这只是一个意见,但是写一堆嵌套的回调可能会令人相当讨厌,有些人认为它使得代码明显无章可循。在Node代码的深处,看到嵌套四层、嵌套五层、甚至更多层级的嵌套并不罕见。

我们再次回到了权衡。如果你主要的性能问题在于I/O,那么Node模型能很好地工作。然而,它的阿喀琉斯之踵(译者注:来自希腊神话,表示致命的弱点)是如果不小心的话,你可能会在某个函数里处理HTTP请求并放置CPU密集型代码,最后使得每个连接慢得如蜗牛。

Go

真正的非阻塞

Go语言的一个关键特性是它包含自己的调度器。并不是每个线程的执行对应于一个单一的OS线程,Go采用的是“goroutines”这一概念。Go运行时可以将一个goroutine分配给一个OS线程并使其执行,或者把它挂起而不与OS线程关联,这取决于goroutine做的是什么。来自Go的HTTP服务器的每个请求都在单独的Goroutine中处理。

此调度器工作的示意图,如下所示:

62d4d466f96e6e8f6f88ab640d9f1487.png

实际上,除了回调机制内置到I/O调用的实现中并自动与调度器交互外,Go运行时做的事情与Node做的事情并没有太多不同。它也不受必须把所有的处理程序代码都运行在同一个线程中这一限制,Go将会根据其调度器的逻辑自动将Goroutine映射到其认为合适的OS线程上。

非阻塞I/O用于全部重要的事情,但是你的代码看起来像是阻塞,因此往往更容易理解和维护。Go调度器和OS调度器之间的交互处理了剩下的部分。这不是完整的魔法,如果你建立的是一个大型的系统,那么花更多的时间去理解它工作原理的更多细节是值得的; 但与此同时,“开箱即用”的环境可以很好地工作和很好地进行扩展。

Goroutine是类似线程的概念(但Goroutine并不是线程)。线程属于系统层面,通常来说创建一个新的线程会消耗较多的资源且管理不易。而 Goroutine就像轻量级的线程,但我们称其为并发,一个Go程序可以运行超过数万个 Goroutine,并且这些性能都是原生级的,随时都能够关闭、结束。一个核心里面可以有多个Goroutine,通过GOMAXPROCS参数你能够限制Gorotuine可以占用几个系统线程来避免失控。

对比实验

首先,来看一些低并发的例子。运行2000次迭代,并发300个请求,并且每次请求只做一次散列(N = 1),可以得到:

6db69e6b149c076c12173cbc4955fa31.png

时间是在全部并发请求中完成请求的平均毫秒数。越低越好。

很难从一个图表就得出结论,但对于我来说,似乎与连接和计算量这些方面有关,我们看到时间更多地与语言本身的一般执行有关,因此更多在于I/O。请注意,被认为是“脚本语言”(输入随意,动态解释)的语言执行速度最慢。

但是如果将N增加到1000,仍然并发300个请求,会发生什么呢 —— 相同的负载,但是hash迭代是之前的100倍(显着增加了CPU负载):

c8a7288e3952a2ff7e465f9c23c427e0.png

时间是在全部并发请求中完成请求的平均毫秒数。越低越好。

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

现在让我们尝试5000个并发连接(并且N = 1)—— 或者接近于此。不幸的是,对于这些环境的大多数,失败率并不明显。对于这个图表,我们会关注每秒的请求总数。越高越好:

1aa3650e75bfe83232fd5974cdfa7142.png

每秒的请求总数。越高越好。

对于高连接量,每次连接的开销与产生新进程有关,而与PHP + Apache相关联的额外内存似乎成为主要的因素并制约了PHP的性能。显然,Go是这里的冠军,其次是Java和Node,最后是PHP。

总结一下就是:

PHP: 进程、阻塞I/O

Java: 线程、可用非阻塞I/O、需要回调

Node.js: 线程 、非阻塞I/O、需要回调、执行CPU密集型操作时会相互阻塞

Go: 线程(Goroutine)、非阻塞I/O、不需要回调

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值