个性化阅读
专注于IT技术分析

服务器端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操作, 并回复syscall。在现实世界中, 内核可能需要做很多事情来满足你的请求, 包括等待设备准备就绪, 更新其内部状态等, 但是作为应用程序开发人员, 你不必担心。那是内核的工作。
系统调用图

阻塞与非阻塞呼叫

现在, 我在上面刚刚说过, 系统调用正在阻塞, 这在一般意义上是正确的。但是, 某些调用被归类为”非阻塞”, 这意味着内核接受你的请求, 将其放入队列或缓冲区中的某个位置, 然后立即返回而无需等待实际的I/O发生。因此, 它仅在很短的时间段内”阻塞”, 足够长的时间来排队你的请求。

(Linux syscalls)的一些示例可能有助于阐明:-read()是阻塞调用-向其传递一个句柄, 该句柄说明哪个文件以及将读取的数据传递到何处的缓冲区, 当数据在那里时, 该调用返回。请注意, 这样做的好处是简洁明了。 -epoll_create(), epoll_ctl()和epoll_wait()分别是调用, 可让你创建一组要监听的句柄, 从该组中添加/删除处理程序, 然后进行阻塞直到有任何活动为止。这样一来, 你就可以通过一个线程有效地控制大量I/O操作, 但是我已经超越了自己。如果你需要此功能, 那就太好了, 但是正如你所看到的, 使用起来肯定更复杂。

请务必在此处了解时序差异的数量级。如果CPU内核以3 GHz的频率运行, 而没有进行CPU可以进行的优化, 则它每秒执行30亿个周期(或每纳秒3个周期)。一个非阻塞系统调用可能需要大约10个周期才能完成-或”相对较短的纳秒”。阻止通过网络接收信息的呼叫可能会花费更长的时间-例如200毫秒(1/5秒)。举例来说, 非阻塞呼叫耗时20纳秒, 阻塞呼叫耗时200, 000, 000纳秒。你的进程仅等待了1000万次以上才能进行阻塞调用。

阻塞与非阻塞系统调用

内核提供了一种手段来执行阻塞I/O(“从该网络连接读取并给我数据”)和非阻塞I/O(“当这些网络连接中的任何一个具有新数据时告诉我”)。而所使用的机制将在极大不同的时间长度内阻止调用过程。

排程

紧随其后的第三件事是当你有很多线程或进程开始阻塞时会发生什么。

就我们的目的而言, 线程和进程之间没有太大的区别。在现实生活中, 与性能最明显的区别是, 由于线程共享相同的内存, 并且每个进程都有自己的内存空间, 因此使单独的进程倾向于占用更多的内存。但是, 当我们谈论调度时, 它的真正含义可以归结为一系列事情(线程和进程), 每个事情都需要在可用的CPU内核上获得一定的执行时间。如果你有300个线程在运行并且有8个内核在上面运行, 则你必须分配时间, 以便每个线程都能获得自己的份额, 每个内核都运行一小段时间, 然后转移到下一个线程。这是通过”上下文切换”完成的, 从而使CPU从运行一个线程/进程切换到下一个线程/进程。

这些上下文切换要付出一定的代价-它们需要一些时间。在某些快速的情况下, 它可能少于100纳秒, 但通常需要1000纳秒或更长的时间, 具体取决于实现细节, 处理器速度/架构, CPU缓存等。

线程(或进程)越多, 上下文切换就越多。当我们谈论数千个线程, 而每个线程都需要数百纳秒时, 事情可能会变得很慢。

但是, 从本质上来说, 非阻塞调用告诉内核”仅在这些连接中的任何一个上有一些新数据或事件时才打电话给我。”这些非阻塞调用旨在有效处理大量I/O负载并减少上下文切换。

到目前为止和我在一起?现在是有趣的部分:让我们看看一些流行的语言如何使用这些工具, 并就易用性和性能……以及其他有趣的花样之间的取舍得出一些结论。

需要注意的是, 尽管本文中显示的示例是微不足道的(并且是局部的, 仅显示了相关的位);数据库访问, 外部缓存系统(memcache等)以及所有需要I/O的东西最终将在幕后执行某种I/O调用, 其效果将与所示的简单示例相同。同样, 对于I/O被描述为”阻塞”(PHP, Java)的场景, HTTP请求和响应的读取和写入本身就是阻塞调用:再次, 更多的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()系统调用并等待结果。

当然, 实际的代码只是直接嵌入到你的页面中, 并且操作正在阻塞:

<?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模型PHP

非常简单:每个请求一个流程。 I/O调用只是阻塞。优点?简单且有效。坏处?同时击中20, 000个客户端, 你的服务器将大放异彩。由于未使用内核提供的用于处理大量I/O(epoll等)的工具, 因此该方法无法很好地扩展。为了增加伤害, 对每个请求运行单独的进程往往会占用大量系统资源, 尤其是内存, 在这种情况下, 这通常是你首先用光的东西。

注意:用于Ruby的方法与PHP非常相似, 并且在广泛, 通用, 手工的方式下, 可以将它们视为与我们的目的相同的方法。

多线程方法:Java

因此Java出现在你购买第一个域名的那一刻, 在句子后面随机说” .com”很酷。而且Java在语言中内置了多线程功能(特别是在创建时)非常棒。

大多数Java Web服务器的工作方式是为每个传入的请求启动一个新的执行线程, 然后在该线程中最终调用你作为应用程序开发人员编写的函数。

在Java Servlet中执行I/O的过程通常类似于:

public void doGet(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方法对应一个请求并在其自己的线程中运行, 而不是为每个需要其自身内存的请求提供单独的进程, 因此我们有一个单独的线程。这具有一些不错的特权, 例如能够在线程之间共享状态, 缓存的数据等, 因为它们可以访问彼此的内存, 但是对线程与调度的交互方式的影响仍然几乎与PHP中的操作相同。前面的例子。每个请求都会获得一个新线程, 并在该线程内阻塞各种I/O操作, 直到完全处理该请求为止。线程被池化以最小化创建和销毁它们的成本, 但是, 成千上万的连接意味着成千上万的线程, 这对调度程序不利。

一个重要的里程碑是Java 1.4版(以及1.7版中的又一次重大升级)在Java中获得了执行非阻塞I/O调用的能力。大多数应用程序, 无论是网络应用程序还是其他应用程序, 都不会使用, 但至少可以使用。一些Java Web服务器尝试以各种方式利用这一点。但是, 绝大多数已部署的Java应用程序仍然可以如上所述运行。

I/O Java模型

Java让我们更加接近, 当然它具有一些很好的I/O开箱即用功能, 但是它仍然无法真正解决当你遇到大量受I/O约束的应用程序时会发生什么的问题。有成千上万的阻塞线程。

作为一流公民的非阻塞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处理然后再进行回调的机制称为”事件循环”。而且效果很好。

I/O模型Node.js

但是, 此模型存在一个问题。在幕后, 其原因很大程度上与V8 JavaScript引擎(Node使用的Chrome的JS引擎)的实现方式有关1。你编写的JS代码都在单个线程中运行。考虑一下。这意味着, 虽然使用高效的非阻塞技术执行I/O, 但你执行JavaScript绑定操作的JS可以在单个线程中运行, 每个代码块都会阻塞下一个线程。一个常见的例子可能是在将数据库记录输出到客户端之前, 遍历数据库记录以某种方式对其进行处理。这是一个显示其工作原理的示例:

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周期。这意味着, 如果你有10, 000个连接, 则该循环可能会使整个应用程序进行爬网, 具体取决于所需的时间。每个请求必须在主线程中共享一个时间段, 一次共享一个时间段。

整个概念所基于的前提是, I/O操作是最慢的部分, 因此, 即使要串行执行其他处理, 有效地处理这些也是最重要的。在某些情况下, 但并非在所有情况下都是如此。

另一点是, 尽管这只是一个意见, 但编写一堆嵌套的回调可能会很累人, 有人认为这会使代码难于遵循。回调在Node代码内部嵌套了四, 五甚至更多级别的情况并不少见。

我们再次权衡取舍。如果你的主要性能问题是I/O, 则Node模型可以很好地工作。但是, 它的致命弱点是你可以进入一个处理HTTP请求的函数, 并放入CPU密集型代码, 并在不注意的情况下将每个连接都进行爬网。

自然不阻塞:继续

在进入” Go”部分之前, 我应该先披露自己是Go的狂热爱好者。我已经在许多项目中使用了它, 并且公开支持它的生产率优势, 并且在使用它的过程中会看到它们。

也就是说, 让我们看看它如何处理I/O。 Go语言的一项主要功能是它包含自己的调度程序。它代替了与单个OS线程对应的每个执行线程, 而是使用” goroutines”的概念来工作。 Go运行时可以根据goroutine的工作将goroutine分配给OS线程并使其执行, 暂停或使其不与OS线程相关联。来自Go的HTTP服务器的每个请求都在单独的Goroutine中进行处理。

调度程序的工作原理图如下所示:

输入/输出模型

在后台, 这是通过Go运行时中的各个点实现的, 这些点通过发出写入/读取/连接/请求等来实现I/O调用, 使当前goroutine进入睡眠状态, 并带有唤醒goroutine的信息当可以采取进一步措施时。

实际上, Go运行时所做的事情与Node所做的事情并没有很大不同, 只是在I/O调用的实现中内置了回调机制并自动与调度程序进行交互。它也不受必须在同一线程中运行所有处理程序代码的限制, Go会根据其调度程序中的逻辑自动将Goroutines映射到它认为合适的尽可能多的OS线程。结果是这样的代码:

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文件, 并对它运行SHA-256哈希N次(在URL的查询字符串中指定N, 例如… / test (.php?n = 100), 并以十六进制打印结果哈希值。我之所以选择它, 是因为它是通过相同的I/O运行相同基准测试的一种非常简单的方法, 并且是一种增加CPU使用率的受控方法。

有关使用的环境的更多详细信息, 请参见这些基准说明。

首先, 让我们看一些低并发的例子。使用300个并发请求运行2000次迭代, 每个请求只有一个哈希(N = 1), 这给我们带来了:

所有并发请求中完成一个请求的平均毫秒数,N = 1

时间是在所有并发请求中完成一个请求的平均毫秒数。越低越好。

仅从这一张图很难得出结论, 但是在我看来, 在如此大量的连接和计算下, 我们看到的时间更多地与语言本身的一般执行有关, 因此, I/O。请注意, 被认为是”脚本语言”(松散键入, 动态解释)的语言执行速度最慢。

但是如果我们将N增加到1000, 仍然有300个并发请求, 会发生什么情况-相同的负载, 但哈希迭代次数增加了100倍(CPU负载明显增加):

所有并发请求中完成一个请求的平均毫秒数,N = 1000

时间是在所有并发请求中完成一个请求的平均毫秒数。越低越好。

突然之间, 由于每个请求中占用大量CPU的操作相互阻塞, Node性能显着下降。有趣的是, PHP的性能变得更好(相对于其他), 并且在此测试中胜过Java。 (值得注意的是, 在PHP中, SHA-256实现是用C编写的, 并且执行路径在该循环中花费了更多时间, 因为我们现在要进行1000次哈希迭代)。

现在, 让我们尝试5000个并发连接(N = 1)-或尽可能接近我。不幸的是, 对于这些环境中的大多数, 故障率并非微不足道。对于此图表, 我们将查看每秒的请求总数。越高越好:

每秒请求总数,N = 1,5000请求/秒

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

而且图片看起来完全不同。这是一个猜测, 但看起来在高连接数量下, 产生新进程以及在PHP + Apache中与之相关的额外内存所引起的每个连接开销似乎已成为主导因素, 并削弱了PHP的性能。显然, Go是赢家, 其次是Java, Node, 最后是PHP。

虽然影响整体吞吐量的因素很多, 并且因应用程序而异, 但你越了解幕后情况和所涉及的取舍, 你的利益就会越多。

综上所述

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

公平地说, 尽管有本文的描述, PHP和Java都具有可在Web应用程序中使用的非阻塞I/O的实现。但是这些并不像上述方法那样普遍, 因此需要考虑使用这种方法维护服务器所伴随的操作开销。更不用说你的代码必须以适合此类环境的方式进行结构化;在这种环境下, 如果不进行重大修改, 通常将无法运行”常规” PHP或Java Web应用程序。

作为比较, 如果我们考虑一些影响性能以及易用性的重要因素, 我们可以得出:

语言 线程与进程 非阻塞I/O 使用方便
的PHP 工艺流程 No
Java 线程数 可用的 需要回调
Node.js 线程数 需要回调
Go Threads (Goroutines) 无需回调

线程通常比进程具有更高的内存效率, 因为它们共享相同的内存空间, 而进程却没有。将其与与非阻塞I/O相关的因素结合在一起, 我们可以看到至少与上述因素有关, 当我们在列表中向下移动时, 与I/O相关的常规设置有所改善。因此, 如果我必须在上述比赛中选择一个获胜者, 那肯定是Go。

即使这样, 在实践中, 选择一个用于构建应用程序的环境与你的团队对该环境的熟悉程度以及使用该环境可以实现的总体生产率紧密相关。因此, 让每个团队仅仅涉足并开始在Node或Go中开发Web应用程序和服务可能没有意义。确实, 寻找开发人员或内部团队的熟悉度经常被认为是不使用其他语言和/或环境的主要原因。也就是说, 在过去的十五年左右, 时代发生了很大变化。

希望以上内容可以帮助你更清楚地了解实际情况, 并为你提供一些有关如何应对应用程序实际扩展性的想法。输入和输出愉快!

赞(0)
未经允许不得转载:srcmini » 服务器端I/O性能:Node,PHP,Java,Go

评论 抢沙发

评论前必须登录!