作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
布拉德·皮博迪的头像

布拉德·皮博迪

拥有近二十年的商业软件开发经验, 布拉德领导过网络团队, 必须是Linux系统管理员, 并在Go开发了一个店面.

专业知识

工作经验

25

分享

理解应用程序的输入/输出(I/O)模型可能意味着处理负载的应用程序之间的差异, 面对现实世界的用例,它会崩溃. 也许当您的应用程序很小并且不能提供高负载时,它的影响就小得多. 但是随着应用程序的流量负载的增加, 使用错误的I/O模型可能会让您陷入困境.

就像大多数可能有多种方法的情况一样, 这不仅仅是哪个更好的问题, 这是一个理解权衡的问题. 让我们浏览一下I/O领域,看看我们能发现什么.

在本文中,我们将进行后端语言性能比较. 我们将使用Apache检查节点、Java、Go和PHP (Go与Java性能、节点.js与Java性能的对比,等等.), 讨论不同的语言如何建模它们的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, 而不涉及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负载并减少上下文切换.

到目前为止? 因为现在到了有趣的部分:让我们来看看一些流行的语言是如何使用这些工具的,并得出一些关于易用性和性能之间权衡的结论……以及其他有趣的花边新闻.

值得一提的是, 虽然本文中展示的示例很简单(而且是部分的), with only the relevant bits shown); database access, 外部缓存系统(memcache), et. 所有)和任何需要I/O的东西最终都会在底层执行某种I/O调用,这将具有与所示的简单示例相同的效果. 也, 对于将I/O描述为“阻塞”的场景(PHP, Java), HTTP请求和响应的读写本身也阻塞了调用, 系统中隐藏的更多I/O以及随之而来的性能问题需要考虑.

为项目选择编程语言有很多因素. 当你只考虑性能时,还有很多因素. 但, 如果您担心您的程序将主要受到I/O的限制, 如果I/O性能决定了项目的成败, 这些都是你需要知道的.

“保持简单”的方法:PHP

90年代的时候,很多人都穿 匡威 用Perl编写CGI脚本. 然后PHP出现了, 尽管有些人喜欢拿这件事开玩笑, 它使制作动态网页变得更加容易.

PHP使用的模型相当简单. 有一些变化,但一般的PHP服务器是这样的:

HTTP请求来自用户的浏览器并访问您的Apache web服务器. Apache为每个请求创建一个单独的进程, 通过一些优化来重用它们,以尽量减少需要做的事情(创建进程), 相对而言, 慢). Apache调用PHP并告诉它运行适当的 .php 磁盘上的文件. PHP代码执行和执行阻塞I/O调用. 你叫 file_get_contents () 在PHP中,它在底层制作 read () 系统调用并等待结果.

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

查询(“选择 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往往看起来像这样:

doGet(HttpServletRequest)
	HttpServletResponse(响应)抛出ServletException, IOException
{

	//阻塞文件I/O
	InputStream fileIs = new FileInputStream("/path/to/file");

	//阻塞网络I/O
	URLConnection =(新URL("http://example . net ").com/example-microservice”)).openConnection ();
	InputStream netIs = urlConnection.getInputStream ();

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

因为我们的 doGet 方法对应于一个请求,并在其自己的线程中运行, 而不是每个请求都有一个单独的进程,每个请求都需要自己的内存, 我们有一个单独的线程. 这有一些不错的好处,比如能够共享状态、缓存数据等. 在线程之间,因为它们可以访问彼此的内存, 但是它与调度的交互方式的影响与之前的PHP示例几乎相同. 每个请求获得一个新线程,各种I/O操作在该线程内阻塞,直到请求完全处理. 线程池是为了最小化创建和销毁它们的成本, 但仍, 数千个连接意味着数千个线程,这对调度器来说很糟糕.

版本1是一个重要的里程碑.4 Java(在1中再次进行了重大升级).7)获得了进行非阻塞I/O调用的能力. 大多数应用程序,无论是web还是其他,都不使用它,但至少它是可用的. Some Java web servers try to take advantage of this in various ways; however, 绝大多数部署的Java应用程序仍然像上面描述的那样工作.

I/O模型Java

Java让我们离目标更近了一步,它当然有一些很好的I/O开箱即用的功能, 但它仍然不能真正解决当I/O绑定严重的应用程序被成千上万的阻塞线程压得不堪重负时所发生的问题.

作为一级公民的非阻塞I/O:节点

当谈到更好的I/O时,最受欢迎的是节点.js. 任何对节点有过简单介绍的人都会被告知它是“非阻塞”的,并且它有效地处理I/O. 一般来说这是正确的. 但魔鬼在于细节,而实现这一巫术的手段在性能方面很重要.

节点实现的范式转变是不再说"在这里写代码处理请求", 相反,他们会说“在这里编写代码以开始处理请求”.“每次你需要做一些涉及I/O的事情, 发出请求并给出一个回调函数,节点将在完成请求后调用该函数.

在请求中执行I/O操作的典型节点代码如下所示:

http.createServer(函数(请求,响应){
	fs.readFile('/path/to/file', 'utf8', function(err, data) {
		响应.结束(数据);
	});
});

如您所见,这里有两个回调函数. 第一个在请求开始时被调用, 第二个函数在文件数据可用时调用.

这样做基本上是给节点一个机会来有效地处理这些回调之间的I/O. 更相关的场景是在节点中执行数据库调用, 但我不会为这个例子而烦恼,因为它是完全相同的原则:您启动数据库调用, 并给节点一个回调函数, 它使用非阻塞调用分别执行I/O操作,然后在您请求的数据可用时调用回调函数. 这种将I/O调用排队并让节点处理它然后获得回调的机制称为“事件循环”.“而且效果很好.

I/O Model节点.js

然而,这种模式有一个问题. 在引擎盖下, 其原因与V8 JavaScript引擎(节点使用的Chrome JS引擎)的实现方式有很大关系 1 最重要的是. 你写的JS代码都在一个线程中运行. 想想看. 这意味着当I/O使用高效的非阻塞技术执行时, 你的JS可以在一个线程中执行cpu绑定操作, 每个代码块都会阻塞下一个代码块. 可能出现这种情况的一个常见示例是,在将数据库记录输出到客户机之前,循环遍历数据库记录,以某种方式处理它们. 这里有一个例子来说明它是如何工作的:

Var h和ler =函数(请求,响应){

	连接.查询(“选择 ...', function (err, rows) {

		If (err){抛出err};

		为 (var i = 0; i < rows.length; i++) {
			//对每一行执行处理
		}

		响应.结束(...); // write 出 the results
		
	})

};

虽然节点确实有效地处理了I/O,但是 在上面的例子中,循环在你唯一的主线程中使用CPU周期. 这意味着如果你有10,000个连接, 这个循环可能会使您的整个应用程序陷入瘫痪, 取决于需要多长时间. 每个请求必须在主线程中共享一小段时间,一次一个.

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

另一点是, 虽然这只是一种观点, 编写一堆嵌套的回调函数可能会很烦人,有些人认为这会使代码更加难以理解. 在节点代码中嵌套四层、五层甚至更多层的回调并不罕见.

我们又回到了权衡的问题. 如果您的主要性能问题是I/O,那么节点模型可以很好地工作. 然而, 它的致命弱点是,如果不小心,您可以进入处理HTTP请求的函数并放入cpu密集型代码,从而使每个连接都处于爬行状态.

自然无阻塞:开始

在我进入围棋的部分之前,我应该透露一下我是一个围棋迷. 我在很多项目中使用过它,并且我是它的生产力优势的公开支持者, 当我使用它时,我在我的工作中看到了它们.

也就是说,让我们看看它是如何处理I/O的. Go语言的一个关键特性是它包含自己的调度器. 而不是每个执行线程对应一个操作系统线程, 它与“gor出ines”的概念一起工作.Go运行时可以为操作系统线程分配一个go出ine,并让它执行, 或者挂起它,让它不与操作系统线程相关联, 基于这个程序在做什么. 来自Go的HTTP服务器的每个请求都在单独的Go例程中处理.

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

I/O Model Go

在引擎盖下, 这是由Go运行时中的各个点实现的,这些点通过发出写/读/连接等请求来实现I/O调用., 将当前程序置于睡眠状态, 当可以采取进一步的操作时,使用该信息唤醒该例程.

实际上, Go运行时所做的事情与节点所做的事情并没有太大的不同, 只不过回调机制内置于I/O调用的实现中,并自动与调度器交互. 它也不会受到必须在同一线程中运行所有处理程序代码的限制, Go会根据它的调度程序中的逻辑自动将你的Gor出ines映射到它认为合适的操作系统线程. 结果是这样的代码:

函数ServeHTTP.ResponseWriter, r *http.请求){

	//底层网络调用是非阻塞的
	行,err:= db.查询(“选择 ...")
	
	对于_,row:= range rows {
		//对行执行操作
//每个请求在自己的例程中
	}

	w.写(...) //写入响应,同样是非阻塞的

}

正如你在上面看到的, 我们正在做的事情的基本代码结构类似于那些更简单的方法, 并在底层实现非阻塞I/O.

在大多数情况下,这最终会是“两全其美”.“非阻塞I/O用于所有重要的事情, 但是您的代码看起来像是阻塞的,因此易于理解和维护. Go调度器和OS调度器之间的交互处理其余的工作. 这不是完全的魔法, 如果你建立一个大系统, it’s worth putting in the time to underst和 more detail ab出 how it works; but at the same time, 你所获得的“开箱即用”的环境能够很好地工作和扩展.

Go可能有它的缺点,但一般来说,它处理I/O的方式不在其中.

谎言,该死的谎言和基准

很难给出与这些不同模型相关的上下文切换的确切时间. 我也可以说,它对你没那么有用. 所以, 我将给出一些基本的基准测试,比较这些服务器环境下HTTP服务器的总体性能. 请记住,整个端到端HTTP请求/响应路径的性能涉及许多因素, 这里展示的数字只是我收集的一些样本,用来做一个基本的比较.

对于每一个环境, 我编写了适当的代码来读取随机字节的64k文件, 对其运行了N次SHA-256哈希(N在URL的查询字符串中指定), e.g., .../测试.php?n=100),并以十六进制的形式输出结果哈希值. 我之所以选择这种方法,是因为它是一种非常简单的方法,可以运行相同的基准测试,同时具有一致的I/O和增加CPU使用的可控方法.

看到 这些基准票据 有关所使用环境的更多细节.

首先,让我们看一些低并发性的例子. 运行2000次迭代,300个并发请求,每个请求只有一个哈希(N=1),得到如下结果:

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

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

仅从这一张图表很难得出结论, 但这对我来说似乎, 在这样的连接和计算量下, 我们看到时间更多地与语言本身的一般执行有关, 更多的是这样的I/O. 请注意,被认为是“脚本语言”的语言(松散类型), 动态解释)执行最慢.

但是如果我们把N增加到1000会怎样呢, 仍然有300个并发请求——相同的负载,但是多了100倍的哈希迭代(CPU负载明显增加):

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

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

突然之间, 节点性能显著下降, 因为每个请求中的cpu密集型操作会相互阻塞. 有趣的是, 比较PHP和Java的性能, PHP的性能要好得多(相对于其他语言),在这个测试中胜过Java. (值得注意的是,在PHP中,SHA-256实现是用C编写的,执行路径在该循环中花费的时间要多得多, 因为我们现在做了1000次哈希迭代).

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

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

每秒请求总数. 越高越好.

这幅图看起来很不一样. 这只是猜测, 但是在高连接量的情况下,在PHP+Apache中产生新进程的每连接开销和与之相关的额外内存似乎成为了一个主要因素,并降低了PHP的性能. 显然,Go是赢家,其次是Java、节点,最后是PHP.

虽然与总体吞吐量相关的因素很多,并且因应用程序而异, 你对幕后发生的事情以及所涉及的权衡了解得越多, 你就会过得越好.

总之

以上都有, 很明显,随着语言的进化, 处理大量I/O的大规模应用程序的解决方案也随之发展.

公平地说,PHP和Java(尽管本文中有描述)都有 实现 of 非阻塞I / O 可供使用 in web应用程序. 但是这些方法并不像上面描述的方法那样常见, 此外,还需要考虑使用这种方法维护服务器所带来的操作开销. Not to mention that your code must be structured in a way that works with such environments; your “normal” PHP or Java web application usually will not run with出 significant modifications in such an environment.

作为比较, 如果我们考虑几个影响性能和易用性的重要因素, 我们得到这个:

语言线程对. 流程非阻塞I / O易用性
PHP流程No
Java线程可用需要回调
节点.js线程是的需要回调
Go线程(了gor出ine)是的不需要回调


线程通常比进程的内存效率高得多, 因为它们共享相同的内存空间,而进程没有. 将其与非阻塞I/O相关的因素结合起来, 我们至少可以从上述因素中看出这一点, 当我们向下移动列表时,与I/O相关的一般设置得到了改进. 因此,如果我必须在上面的比赛中选择一个赢家,那肯定是围棋.

即便如此, 在实践中, 选择在其中构建应用程序的环境与团队对该环境的熟悉程度密切相关, 以及你能用它实现的整体生产力. 因此,对于每个团队来说,只是潜入并开始使用节点或Go开发web应用程序和服务可能没有意义. 事实上, 寻找开发人员或内部团队的熟悉程度通常被认为是不使用不同语言和/或环境的主要原因. 也就是说,在过去15年左右的时间里,时代发生了很大的变化.

希望以上内容能够帮助您更清楚地了解幕后发生的事情,并为您提供一些关于如何处理应用程序的实际可伸缩性的想法. 愉快的输入和输出!

就这一主题咨询作者或专家.
预约电话
布拉德·皮博迪的头像
布拉德·皮博迪

位于 洛杉矶,加州,美国

成员自 2017年2月9日

作者简介

拥有近二十年的商业软件开发经验, 布拉德领导过网络团队, 必须是Linux系统管理员, 并在Go开发了一个店面.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

工作经验

25

世界级的文章,每周发一次.

<为m aria-label="Sticky subscribe 为m" class="-Ulx1zbi P7bQLARO _2ABsLCza">

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

<为m aria-label="Bottom subscribe 为m" class="-Ulx1zbi P7bQLARO _2ABsLCza">

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.