go并发临时

理解应用程序的输入/输出(I/O)模型,意味着其在计划处理负载与残酷的实际使用场景之间的差异。若应用程序比较小,也没有服务于很高的负载,也许它影响甚微。但随着应用程序的负载逐渐上涨,采用错误的I/O模型有可能会让你到处踩坑,伤痕累累。

GO并发
使用goroutine运行程序,检测并修正状态,利用通道共享数据。
通常程序会被编写为一个顺序执行并完成一个独立任务的代码。
web服务器需要在各自独立的套接字(Socket)上通过接受多个数据请求。
每个套接字请求是独立的,独立于其他套接字进行处理。并行执行多个请求的能力可显著提高这类系统的性能。
Go语言中的并发指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为goroutine时,Go会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。
Go语言运行时的调度器,管理创建的所有goroutine并为其分配执行时间。
这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行
goroutine。调度器在任何给定的时间,都会全面控制哪个goroutine
要在哪个逻辑处理器上运行。
Go语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential
Processes,CSP)
的范型(paradigm)。CSP 是一种消息传递模型,通过在goroutine
之间传递数据来传递消息,而不是
对数据进行加锁来实现同步访问。用于在goroutine
之间同步和传递数据的关键数据类型叫作通道
(channel)。
使用通道可以使编写并发程序更容易,也能够让并发程序出错更少。

本文作者: 伯乐在线 –
bestswifter
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

正如大部分存在多种解决途径的场景一样,重点不在于哪一种途径更好,而是在于理解如何进行权衡。让我们来参观下I/O的景观,看下可以从中窃取点什么。

什么是操作系统的线程(thread)和进程(process)
便于理解Go语言运行时调度器如何利用操作系统来并发运行goroutine。
当运行一个应用程序的时候,操作系统会为这个应用程序启动一个进程。可以将这个进程看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。
一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代码。
每个进程至少包含一个线程,每个进程的初始线程被称作主线程。
因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。
操作系统将线程调度到某个处理器上运行,这个处理器并不一定是进程所在的处理器。
不同操作系统使用的线程调度算法一般都不一样,但是这种不同会被操作系统屏蔽,并不会展示给程序员。
进程维护了应用程序运行时的内存地址空间、文件和设备的句柄以及线程。操作系统的调度器决定哪个线程会获得给定的CPU的运行时间。

这是一个移动端工程师涉足前端和后端开发的学习笔记,如有错误或理解不到位的地方,万望指正。

图片 1

操作系统会在物理处理器上调度线程来运行,而Go
语言的运行时会在逻辑处理器上调度goroutine来运行。
每个逻辑处理器都分别绑定到单个操作系统线程。操作系统线程、逻辑处理器和本地运行队列之间的关系。
如果创建一个goroutine 并准备运行,这个goroutine
就会被放到调度器的全局运行队列中。之后,调度器就
将这些队列中的goroutine
分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列上,Go语言的
运行时默认会为每个可用的物理处理器分配一个逻辑处理器。
本地运行队列中的goroutine会一直等待直到自己被分配的逻辑处理器执行。
go调度器如何管理goroutine:
Go语言运行时会把goroutine调度到逻辑处理器上运行。这个逻辑处理器绑定到唯一的操作系统线程。当goroutine可以运行的时候,会被放入逻辑处理器的执行队列中。
当goroutine执行了一个阻塞的系统调用时,调度器会将这个线程与处理器分类,并创建一个新线程来运行这个处理器上提供的服务。

Node.js 是什么

传统意义上的 JavaScript
运行在浏览器上,这是因为浏览器内核实际上分为两个部分:渲染引擎和
JavaScript 引擎。前者负责渲染 HTML + CSS,后者则负责运行
JavaScript。Chrome 使用的 JavaScript 引擎是 V8,它的速度非常快。

Node.js 是一个运行在服务端的框架,它的底层就使用了 V8 引擎。我们知道
Apache + PHP 以及 Java 的 Servlet 都可以用来开发动态网页,Node.js
的作用与他们类似,只不过是使用 JavaScript 来开发。

从定义上介绍完后,举一个简单的例子,新建一个 app.js 文件并输入以下内容:

var http = require(‘http’); http.createServer(function (request,
response) { response.writeHead(200, {‘Content-Type’: ‘text/plain’}); //
HTTP Response 头部 response.end(‘Hello Worldn’); // 返回数据 “Hello
World” }).listen(8888); // 监听 8888 端口 // 终端打印如下信息
console.log(‘Server running at ‘);

1
2
3
4
5
6
7
var http = require(‘http’);  
http.createServer(function (request, response) {  
    response.writeHead(200, {‘Content-Type’: ‘text/plain’}); // HTTP Response 头部
    response.end(‘Hello Worldn’); // 返回数据 “Hello World”
}).listen(8888); // 监听 8888 端口
// 终端打印如下信息
console.log(‘Server running at http://127.0.0.1:8888/’);  

这样,一个简单的 HTTP Server 就算是写完了,输入 node app.js
即可运行,随后访问 便会看到输出结果。

在这篇文章,我们将会结合Apache分别比较Node,Java,Go,和PHP,讨论这些不同的语言如何对他们的I/O进行建模,各个模型的优点和缺点,并得出一些初步基准的结论。如果关心你下一个Web应用的I/O性能,那你就找对文章了。

有时,正在运行的goroutine
需要执行一个阻塞的系统调用,如打开一个文件。当这类调用
发生时,线程和goroutine
会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。
与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其
绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个goroutine
来运行。一旦
被阻塞的系统调用执行完成并返回,对应的goroutine
会放回到本地运行队列,而之前的线程会
保存好,以便之后可以继续使用。
如果一个 goroutine 需要做一个网络I/O
调用,流程上会有些不一样。在这种情况下,goroutine
会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写
操作已经就绪,对应的goroutine
就会重新分配到逻辑处理器上来完成操作。调度器对可以创建
的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建10 000
个线程。这个
限制值可以通过调用runtime/debug 包的SetMaxThreads
方法来更改。如果程序试图使用
更多的线程,就会崩溃。
并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处
理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做
了一半就被暂停去做别的事情了。在很多情况下,并发的效果比并行好,因为操作系统和硬件的
总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,
也是指导Go 语言设计的哲学。
如果希望让goroutine
并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器
会将goroutine 平等分配到每个逻辑处理器上。这会让goroutine
在不同的线程上运行。不过要想真
的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕Go

言运行时使用多个线程,goroutine
依然会在同一个物理处理器上并发运行,达不到并行的效果。
在一个逻辑处理器上并发运行goroutine 和在两个逻辑处理器上并行运行两个并
发的goroutine 之间的区别。

为什么要用 Node.js

面对一个新技术,多问几个为什么总是好的。既然 PHP、Python、Java
都可以用来进行后端开发,为什么还要去学习
Node.js?至少我们应该知道在什么场景下,选择 Node.js 更合适。

总的来说,Node.js 适合以下场景:

  1. 实时性应用,比如在线多人协作工具,网页聊天应用等。
  2. 以 I/O 为主的高并发应用,比如为客户端提供 API,读取数据库。
  3. 流式应用,比如客户端经常上传文件。
  4. 前后端分离。

实际上前两者可以归结为一种,即客户端广泛使用长连接,虽然并发数较高,但其中大部分是空闲连接。

Node.js 也有它的局限性,它并不适合 CPU
密集型的任务,比如人工智能方面的计算,视频、图片的处理等。

当然,以上缺点不是信口开河,或者死记硬背,更不是人云亦云,需要我们对
Node.js 的原理有一定的了解,才能做出正确的判断。

I/O基础知识:快速回顾

为了理解与I/O密切相关的因素,必须先来回顾在操作系统底层的概念。虽然不会直接处理这些概念的大部分,但通过应用程序的运行时环境你一直在间接地处理他们。而关键在于细节。

基础概念

在介绍 Node.js 之前,理清楚一些基本概念有助于更深入的理解 Node.js 。

系统调用

首先,我们有系统调用,它可以描述成这样:

  • 你的程序(在“用户区域”,正如他们所说的)必须让操作系统内核在它自身执行I/O操作。
  • “系统调用”(syscall)意味着你的程序要求内核做某事。不同的操作系统,实现系统调用的细节有所不同,但基本的概念是一样的。这将会有一些特定的指令,把控制权从你的程序转交到内核(类似函数调用但有一些专门用于处理这种场景的特殊sauce)。通常来说,系统调用是阻塞的,意味着你的程序需要等待内核返回到你的代码。
  • 内核在我们所说的物理设备(硬盘、网卡等)上执行底层的I/O操作,并回复给系统调用。在现实世界中,内核可能需要做很多事情才能完成你的请求,包括等待设备准备就绪,更新它的内部状态等,但作为一名应用程序开发人员,你可以不用关心这些。以下是内核的工作情况。

图片 2

并发

与客户端不同,服务端开发者非常关心的一项数据是并发数,也就是这台服务器最多能支持多少个客户端的并发请求。早年的
C10K 问题就是讨论如何利用单台服务器支持 10K
并发数。当然随着软硬件性能的提高,目前 C10K
已经不再是问题,我们开始尝试解决 C10M
问题,即单台服务器如何处理百万级的并发。

在 C10K 提出时,我们还在使用 Apache
服务器,它的工作原理是每当有一个网络请求到达,就 fork
出一个子进程并在子进程中运行 PHP 脚本。执行完脚本后再把结果发回客户端。

这样可以确保不同进程之间互不干扰,即使一个进程出问题也不影响整个服务器,但是缺点也很明显:进程是一个比较重的概念,拥有自己的堆和栈,占用内存较多,一台服务器能运行的进程数量有上限,大约也就在几千左右。

虽然 Apache 后来使用了
FastCGI,但本质上只是一个进程池,它减少了创建进程的开销,但无法有效提高并发数。

Java 的 Servlet 使用了线程池,即每个 Servlet
运行在一个线程上。线程虽然比进程轻量,但也是相对的。有人测试过,每个线程独享的栈的大小是
1M,依然不够高效。除此以外,多线程编程会带来各种麻烦,这一点想必程序员们都深有体会。

如果不使用线程,还有两种解决方案,分别是使用协程(coroutine)和非阻塞
I/O。协程比线程更加轻量,多个协程可以运行在同一个线程中,并由程序员自己负责调度,这种技术在
Go 语言中被广泛使用。而非阻塞 I/O 则被 Node.js 用来处理高并发的场景。

阻塞调用与非阻塞调用

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

这里有一些有助于解释清楚的(Linux系统调用)例子:-read()是阻塞调用——你传给它一个文件句柄和一个存放所读到数据的缓冲,然后此调用会在当数据好后返回。注意这种方式有着优雅和简单的优点。-epoll_create()epoll_ctl(),和epoll_wait()这些调用分别是,让你创建一组用于侦听的句柄,从该组添加/删除句柄,和然后直到有活动时才阻塞。这使得你可以通过一个线程有效地控制一系列I/O操作。如果需要这些功能,这非常棒,但也正如你所看到的,使用起来当然也相当复杂。

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

图片 3

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

非阻塞 I/O

这里所说的 I/O 可以分为两种: 网络 I/O 和文件 I/O,实际上两者高度类似。
I/O
可以分为两个步骤,首先把文件(网络)中的内容拷贝到缓冲区,这个缓冲区位于操作系统独占的内存区域中。随后再把缓冲区中的内容拷贝到用户程序的内存区域中。

对于阻塞 I/O
来说,从发起读请求,到缓冲区就绪,再到用户进程获取数据,这两个步骤都是阻塞的。

非阻塞 I/O
实际上是向内核轮询,缓冲区是否就绪,如果没有则继续执行其他操作。当缓冲区就绪时,讲缓冲区内容拷贝到用户进程,这一步实际上还是阻塞的。

I/O 多路复用技术是指利用单个线程处理多个网络 I/O,我们常说的
selectepoll 就是用来轮询所有 socket 的函数。比如 Apache
采用了前者,而 Nginx 和 Node.js
使用了后者,区别在于后者效率更高。由于
I/O 多路复用实际上还是单线程的轮询,因此它也是一种非阻塞 I/O 的方案。

异步 I/O 是最理想的 I/O 模型,然而可惜的是真正的异步 I/O 并不存在。
Linux 上的 AIO 通过信号和回调来传递数据,但是存在缺陷。现有的 libeio
以及 Windows 上的 IOCP,本质上都是利用线程池与阻塞 I/O 来模拟异步 I/O。

调度

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

出于我们的目的,线程和进程之间没有太大的区别。实际上,最显而易见的执行相关的区别是,线程共享相同的内存,而每个进程则拥有他们独自的内存空间,使得分离的进程往往占据了大量的内存。但当我们讨论调度时,它最终可归结为一个事件清单(线程和进程类似),其中每个事件需要在有效的CPU内核上获得一片执行时间。如果你有300个线程正在运行并且运行在8核上,那么你得通过每个内核运行一段很短的时间然后切换到下一个线程的方式,把这些时间划分开来以便每个线程都能获得它的分时。这是通过“上下文切换”来实现的,使得CPU可以从正在运行的某个线程/进程切换到下一个。

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

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

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

到目前为止你还在看这篇文章吗?因为现在来到了有趣的部分:让我们来看下一些流利的语言如何使用这些工具,并就在易用性和性能之间的权衡作出一些结论……以及其他有趣的点评。

请注意,虽然在这篇文章中展示的示例是琐碎的(并且是不完整的,只是显示了相关部分的代码),但数据库访问,外部缓存系统(memcache等全部)和需要I/O的任何东西,都以执行某些背后的I/O操作而结束,这些和展示的示例一样有着同样的影响。同样地,对于I/O被描述为“阻塞”(PHP,Java)这样的情节,HTTP请求与响应的读取与写入本身是阻塞的调用:再一次,更多隐藏在系统中的I/O及其伴随的性能问题需要考虑。

为项目选择编程语言要考虑的因素有很多。当你只考虑性能时,要考虑的因素甚至有更多。但是,如果你关注的是程序主要受限于I/O,如果I/O性能对于你的项目至关重要,那这些都是你需要了解的。“保持简单”的方法:PHP。

回到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');

?>

关于它如何与系统集成,就像这样:

图片 4

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

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

Node.js 线程模型

很多文章都提到 Node.js
是单线程的,然而这样的说法并不严谨,甚至可以说很不负责,因为我们至少会想到以下几个问题:

  1. Node.js 在一个线程中如何处理并发请求?
  2. Node.js 在一个线程中如何进行文件的异步 I/O?
  3. Node.js 如何重复利用服务器上的多个 CPU 的处理能力?

多线程的方式:Java

所以就在你买了你的第一个域名的时候,Java来了,并且在一个句子之后随便说一句“dot
com”是很酷的。而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方法对应于一个请求并且在自己的线程中运行,而不是每次请求都对应需要有自己专属内存的单独进程,所以我们会有一个单独的线程。这样会有一些不错的优点,例如可以在线程之间共享状态、共享缓存的数据等,因为它们可以相互访问各自的内存,但是它如何与调度进行交互的影响,仍然与前面PHP例子中所做的内容几乎一模一样。每个请求都会产生一个新的线程,而在这个线程中的各种I/O操作会一直阻塞,直到这个请求被完全处理为止。为了最小化创建和销毁它们的成本,线程会被汇集在一起,但是依然,有成千上万个连接就意味着成千上万个线程,这对于调度器是不利的。

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

图片 5

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

网络 I/O

Node.js
确实可以在单线程中处理大量的并发请求,但这需要一定的编程技巧。我们回顾一下文章开头的代码,执行了
app.js 文件后控制台立刻就会有输出,而在我们访问网页时才会看到
“Hello,World”。

这是因为 Node.js
是事件驱动的,也就是说只有网络请求这一事件发生时,它的回调函数才会执行。当有多个请求到来时,他们会排成一个队列,依次等待执行。

这看上去理所当然,然而如果没有深刻认识到 Node.js
运行在单线程上,而且回调函数是同步执行,同时还按照传统的模式来开发程序,就会导致严重的问题。举个简单的例子,这里的
“Hello World” 字符串可能是其他某个模块的运行结果。假设 “Hello World”
的生成非常耗时,就会阻塞当前网络请求的回调,导致下一次网络请求也无法被响应。

解决方法很简单,采用异步回调机制即可。我们可以把用来产生输出结果的
response
参数传递给其他模块,并用异步的方式生成输出结果,最后在回调函数中执行真正的输出。这样的好处是,http.createServer
的回调函数不会阻塞,因此不会出现请求无响应的情况。

举个例子,我们改造一下 server
的入口,实际上如果要自己完成路由,大约也是这个思路:

var http = require(‘http’); var output = require(‘./string’) //
一个第三方模块 http.createServer(function (request, response) {
output.output(response); // 调用第三方模块进行输出 }).listen(8888);

1
2
3
4
5
var http = require(‘http’);  
var output = require(‘./string’) // 一个第三方模块  
http.createServer(function (request, response) {  
    output.output(response); // 调用第三方模块进行输出
}).listen(8888);

第三方模块:

function sleep(milliSeconds) { // 模拟卡顿 var startTime = new
Date().getTime(); while (new Date().getTime() < startTime +
milliSeconds); } function outputString(response) { sleep(10000); // 阻塞
10s response.end(‘Hello Worldn’); // 先执行耗时操作,再输出 }
exports.output = outputString;

1
2
3
4
5
6
7
8
9
10
11
function sleep(milliSeconds) {  // 模拟卡顿  
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
}
 
function outputString(response) {  
    sleep(10000);  // 阻塞 10s    
    response.end(‘Hello Worldn’); // 先执行耗时操作,再输出
}
 
exports.output = outputString;  

总之,在利用 Node.js
编程时,任何耗时操作一定要使用异步来完成,避免阻塞当前函数。因为你在为客户端提供服务,而所有代码总是单线程、顺序执行。

如果初学者看到这里还是无法理解,建议阅读 “Nodejs 入门”
这本书,或者阅读下文关于事件循环的章节。

作为一等公民的非阻塞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来处理,然后获取回调函数的机制称为“事件循环”。它工作得非常好。

图片 6

然而,这个模型中有一道关卡。在幕后,究其原因,更多是如何实现JavaScript
V8
引擎(Chrome的JS引擎,用于Node)1,而不是其他任何事情。你所编写的JS代码全部都运行在一个线程中。思考一下。这意味着当使用有效的非阻塞技术执行I/O时,正在进行CPU绑定操作的JS可以在运行在单线程中,每个代码块阻塞下一个。
一个常见的例子是循环数据库记录,在输出到客户端前以某种方式处理它们。以下是一个例子,演示了它如何工作:

var handler = function(request, response) {

    connection.query('SELECT ...', function (err, rows) {

        if (err) { throw err };

        for (var i = 0; i < rows.length; i++) {
            // 对每一行纪录进行处理
        }

        response.end(...); // 输出结果

    })

};

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

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

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

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

文件 I/O

我在之前的文章中也强调过,异步是为了优化体验,避免卡顿。而真正节省处理时间,利用
CPU 多核性能,还是要靠多线程并行处理。

实际上 Node.js
在底层维护了一个线程池。之前在基础概念部分也提到过,不存在真正的异步文件
I/O,通常是通过线程池来模拟。线程池中默认有四个线程,用来进行文件 I/O。

需要注意的是,我们无法直接操作底层的线程池,实际上也不需要关心它们的存在。线程池的作用仅仅是完成
I/O 操作,而非用来执行 CPU
密集型的操作,比如图像、视频处理,大规模计算等。

如果有少量 CPU 密集型的任务需要处理,我们可以启动多个 Node.js 进程并利用
IPC 机制进行进程间通讯,或者调用外部的 C++/Java 程序。如果有大量 CPU
密集型任务,那只能说明选择 Node.js 是一个错误的决定。

真正的非阻塞:Go

在进入Go这一章节之前,我应该披露我是一名Go粉丝。我已经在许多项目中使用Go,是其生产力优势的公开支持者,并且在使用时我在工作中看到了他们。

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

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

图片 7

这是通过在Go运行时的各个点来实现的,通过将请求写入/读取/连接/等实现I/O调用,让当前的goroutine进入睡眠状态,当可采取进一步行动时用信息把goroutine重新唤醒。

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

func ServeHTTP(w http.ResponseWriter, r *http.Request) {

    // 这里底层的网络调用是非阻塞的
    rows, err := db.Query("SELECT ...")

    for _, row := range rows {
        // 处理rows
        // 每个请求在它自己的goroutine中
    }

    w.Write(...) // 输出响应结果,也是非阻塞的

}

正如你在上面见到的,我们的基本代码结构像是更简单的方式,并且在背后实现了非阻塞I/O。

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

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

榨干 CPU

到目前为止,我们知道了 Node.js 采用 I/O 多路复用技术,利用单线程处理网络
I/O,利用线程池和少量线程模拟异步文件 I/O。那在一个 32 核 CPU
上,Node.js 的单线程是否显得鸡肋呢?

答案是否定的,我们可以启动多个 Node.js
进程。不同于上一节的是,进程之间不需要通讯,它们各自监听一个端口,同时在最外层利用
Nginx 做负载均衡。

Nginx 负载均衡非常容易实现,只要编辑配置文件即可:

http{ upstream sampleapp { // 可选配置项,如 least_conn,ip_hash
server 127.0.0.1:3000; server 127.0.0.1:3001; // … 监听更多端口 } ….
server{ listen 80; … location / { proxy_pass ; //
监听 80 端口,然后转发 } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http{  
    upstream sampleapp {
        // 可选配置项,如 least_conn,ip_hash
        server 127.0.0.1:3000;
        server 127.0.0.1:3001;
        // … 监听更多端口
    }
    ….
    server{
       listen 80;
       …
       location / {
          proxy_pass http://sampleapp; // 监听 80 端口,然后转发
       }
    }

默认的负载均衡规则是把网络请求依次分配到不同的端口,我们可以用
least_conn 标志把网络请求转发到连接数最少的 Node.js 进程,也可以用
ip_hash 保证同一个 ip 的请求一定由同一个 Node.js 进程处理。

多个 Node.js 进程可以充分发挥多核 CPU
的处理能力,也具有很强大的拓展能力。

谎言,诅咒的谎言和基准

对这些各种模式的上下文切换进行准确的定时是很困难的。也可以说这对你来没有太大作用。所以取而代之,我会给出一些比较这些服务器环境的HTTP服务器性能的基准。请记住,整个端对端的HTTP请求/响应路径的性能与很多因素有关,而这里我放在一起所提供的数据只是一些样本,以便可以进行基本的比较。

对于这些环境中的每一个,我编写了适当的代码以随机字节读取一个64k大小的文件,运行一个SHA-256哈希N次(N在URL的查询字符串中指定,例如.../test.php?n=100),并以十六进制形式打印生成的散列。我选择了这个示例,是因为使用一些一致的I/O和一个受控的方式增加CPU使用率来运行相同的基准测试是一个非常简单的方式。

关于环境使用,更多细节请参考这些基准要点。

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

图片 8

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

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

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

图片 9

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

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

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

图片 10

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

这张照片看起来截然不同。这是一个猜测,但是看起来像是对于高连接量,每次连接的开销与产生新进程有关,而与PHP
+
Apache相关联的额外内存似乎成为主要的因素并制约了PHP的性能。显然,Go是这里的冠军,其次是Java和Node,最后是PHP。

事件循环

在 Node.js 中存在一个事件循环(Event Loop),有过 iOS
开发经验的同学可能会觉得眼熟。没错,它和 Runloop 在一定程度上是类似的。

一次完整的 Event Loop 也可以分为多个阶段(phase),依次是
poll、check、close callbacks、timers、I/O callbacks 、Idle。

由于 Node.js 是事件驱动的,每个事件的回调函数会被注册到 Event Loop
的不同阶段。比如fs.readFile 的回调函数被添加到 I/O
callbacks,setImmediate 的回调被添加到下一次 Loop 的 poll
阶段结束后,process.nextTick() 的回调被添加到当前 phase 结束后,下一个
phase 开始前。

不同异步方法的回调会在不同的 phase
被执行,掌握这一点很重要,否则就会因为调用顺序问题产生逻辑错误。

Event Loop
不断的循环,每一个阶段内都会同步执行所有在该阶段注册的回调函数。这也正是为什么我在网络
I/O
部分提到,不要在回调函数中调用阻塞方法,总是用异步的思想来进行耗时操作。一个耗时太久的回调函数可能会让
Event Loop 卡在某个阶段很久,新来的网络请求就无法被及时响应。

由于本文的目的是对 Node.js 有一个初步的,全面的认识。就不详细介绍 Event
Loop
的每个阶段了,具体细节可以查看官方文档。

可以看出 Event Loop
还是比较偏底层的,为了方便的使用事件驱动的思想,Node.js
封装了EventEmitter 这个类:

var EventEmitter = require(‘events’); var util = require(‘util’);
function MyThing() { EventEmitter.call(this); setImmediate(function
(self) { self.emit(‘thing1’); }, this); process.nextTick(function (self)
{ self.emit(‘thing2’); }, this); } util.inherits(MyThing, EventEmitter);
var mt = new MyThing(); mt.on(‘thing1’, function onThing1() {
console.log(“Thing1 emitted”); }); mt.on(‘thing2’, function onThing1() {
console.log(“Thing2 emitted”); });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var EventEmitter = require(‘events’);  
var util = require(‘util’);
 
function MyThing() {  
    EventEmitter.call(this);
 
    setImmediate(function (self) {
        self.emit(‘thing1’);
    }, this);
    process.nextTick(function (self) {
        self.emit(‘thing2’);
    }, this);
}
util.inherits(MyThing, EventEmitter);
 
var mt = new MyThing();
 
mt.on(‘thing1’, function onThing1() {  
    console.log("Thing1 emitted");
});
 
mt.on(‘thing2’, function onThing1() {  
    console.log("Thing2 emitted");
});

根据输出结果可知,self.emit(thing2)
虽然后定义,但先被执行,这也完全符合 Event Loop 的调用规则。

Node.js 中很多模块都继承自 EventEmitter,比如下一节中提到的
fs.readStream,它用来创建一个可读文件流,
打开文件、读取数据、读取完成时都会抛出相应的事件。

结论

综上所述,很显然,随着语言的演进,处理大量I/O的大型应用程序的解决方案也随之不断演进。

为了公平起见,暂且抛开本文的描述,PHP和Java确实有可用于Web应用程序的非阻塞I/O的实现。
但是这些方法并不像上述方法那么常见,并且需要考虑使用这种方法来维护服务器的伴随的操作开销。更不用说你的代码必须以与这些环境相适应的方式进行结构化;
“正常”的PHP或Java Web应用程序通常不会在这样的环境中进行重大改动。

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

语言 线程或进程 非阻塞I/O 易用性
PHP 进程
Java 线程 可用
Node.js 线程
Go 线程(Goroutine)

线程通常要比进程有更高的内存效率,因为它们共享相同的内存空间,而进程则没有。结合与非阻塞I/O相关的因素,当我们向下移动列表到一般的启动时,因为它与改善I/O有关,可以看到至少与上面考虑的因素一样。如果我不得不在上面的比赛中选出一个冠军,那肯定会是Go。

即便这样,在实践中,选择构建应用程序的环境与你的团队对于所述环境的熟悉程度以及可以实现的总体生产力密切相关。因此,每个团队只是一味地扎进去并开始用Node或Go开发Web应用程序和服务可能没有意义。事实上,寻找开发人员或内部团队的熟悉度通常被认为是不使用不同的语言和/或不同的环境的主要原因。也就是说,过去的十五年来,时代已经发生了巨大的变化。

希望以上内容可以帮助你更清楚地了解幕后所发生的事件,并就如何处理应用程序现实世界中的可扩展性为你提供的一些想法。快乐输入,快乐输出!

数据流

使用数据流的好处很明显,生活中也有真实写照。举个例子,老师布置了暑假作业,如果学生每天都做一点(作业流),就可以比较轻松的完成任务。如果积压在一起,到了最后一天,面对堆成小山的作业本,就会感到力不从心。

Server 开发也是这样,假设用户上传 1G 文件,或者读取本地 1G
的文件。如果没有数据流的概念,我们需要开辟 1G
大小的缓冲区,然后在缓冲区满后一次性集中处理。

如果是采用数据流的方式,我们可以定义很小的一块缓冲区,比如大小是
1Mb。当缓冲区满后就执行回调函数,对这一小块数据进行处理,从而避免出现积压。

实际上 requestfs 模块的文件读取都是一个可读数据流:

var fs = require(‘fs’); var readableStream =
fs.createReadStream(‘file.txt’); var data = ”;
readableStream.setEncoding(‘utf8’); // 每次缓冲区满,处理一小块数据
chunk readableStream.on(‘data’, function(chunk) { data+=chunk; }); //
文件流全部读取完成 readableStream.on(‘end’, function() {
console.log(data); });

1
2
3
4
5
6
7
8
9
10
11
12
13
var fs = require(‘fs’);  
var readableStream = fs.createReadStream(‘file.txt’);  
var data = ”;
 
readableStream.setEncoding(‘utf8’);  
// 每次缓冲区满,处理一小块数据 chunk
readableStream.on(‘data’, function(chunk) {  
    data+=chunk;
});
// 文件流全部读取完成
readableStream.on(‘end’, function() {  
    console.log(data);
});

利用管道技术,可以把一个流中的内容写入到另一个流中:

var fs = require(‘fs’); var readableStream =
fs.createReadStream(‘file1.txt’); var writableStream =
fs.createWriteStream(‘file2.txt’); readableStream.pipe(writableStream);

1
2
3
4
5
var fs = require(‘fs’);  
var readableStream = fs.createReadStream(‘file1.txt’);  
var writableStream = fs.createWriteStream(‘file2.txt’);
 
readableStream.pipe(writableStream);  

不同的流还可以串联(Chain)起来,比如读取一个压缩文件,一边读取一边解压,并把解压内容写入到文件中:

var fs = require(‘fs’); var zlib = require(‘zlib’);
fs.createReadStream(‘input.txt.gz’) .pipe(zlib.createGunzip())
.pipe(fs.createWriteStream(‘output.txt’));

1
2
3
4
5
6
var fs = require(‘fs’);  
var zlib = require(‘zlib’);
 
fs.createReadStream(‘input.txt.gz’)  
  .pipe(zlib.createGunzip())
  .pipe(fs.createWriteStream(‘output.txt’));

Node.js 提供了非常简洁的数据流操作,以上就是简单的使用介绍。

总结

对于高并发的长连接,事件驱动模型比线程轻量得多,多个 Node.js
进程配合负载均衡可以方便的进行拓展。因此 Node.js 非常适合为 I/O
密集型应用提供服务。但这种方式的缺陷就是不擅长处理 CPU 密集型任务。

Node.js 中通常以流的方式来描述数据,也对此提供了很好的封装。

Node.js 使用前端语言(JavaScript)
开发,同时也是一个后端服务器,因此为前后端分离提供了一个良好的思路。我会在下一篇文章中对此进行分析。

参考资料

  1. Concurrent tasks on
    node.js
  2. 利用 Nginx 为 Nodejs
    添加负载均衡
  3. Understanding the node.js event
    loop
  4. The Node.js Event
    Loop
  5. The Basics of Node.js
    Streams

打赏支持我写出更多好文章,谢谢!

打赏作者

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

图片 11
图片 12

4 赞 16 收藏 2
评论

关于作者:bestswifter

图片 13

一枚新手程序员,热爱 iOS
开发,业余时间会研究一些底层原理并翻译优秀文章。目前在百度打杂。

个人主页 ·
我的文章 ·
20 ·
   

发表评论

电子邮件地址不会被公开。 必填项已用*标注