1.2 Node的内部机制
本节的内容会涉及一些操作系统的概念,在开始之前,这里有一些前提,记住这些前提会能让你更好地理解本节的内容:
- 在任务完成之前,CPU在任何情况下都不会暂停或者停止执行,CPU如何执行和同步或是异步、阻塞或者非阻塞都没有必然关系。
- 操作系统始终保证CPU处在运行状态,这是通过系统调度来实现的,具体一点就是通过在不同进程/线程间切换实现的。
1.2.1 何为回调
1.回调的定义
一个回调是指通过函数参数的参数传递到其他代码的,某段可执行代码的引用。
说得通俗一点,就是将一个函数作为参数传递给另一个函数,并且作为参数的函数可以被执行,其本质上是一个高阶函数。在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:
- 接受一个或多个函数作为输入。
- 输出一个函数。
JavaScript中一个很常见的例子就是map方法,该方法接受一个函数作为参数,依次作用于的数组的每一个元素。
可以用如图1-1所示来描述回调的过程。
图1-1
回调方法和主线程处于同一层级,假设主线程发起了一个底层的系统调用,那么操作系统转而去执行这个系统调用,当调用结束后,又回到主线程上调用其后的方法,这也是为什么其会被称为回调(call then back)。
关于回调函数在何时执行并没有具体的要求,回调函数的调用既可以是同步的(例如map方法),也可以是异步的(例如setTimeout方法中的匿名函数)。
2.异步过程中的回调
单线程运行的语言在设计时要考虑这样的问题:如果遇到一个耗时的操作,例如磁盘IO,要不要等待操作完成了再执行下一步操作?
有的语言选择了在完成之前继续等待,例如PHP。
Node选择另一种方式,当遇到IO操作时,Node代码在发起一个调用后继续向下执行,IO操作完成后,再执行对应的回调函数(异步),虽然代码运行在单线程环境下,但依靠异步+回调的方式,也能实现对高并发的支持。
代码1.1 回调函数示意
代码1.1中的callback方法即为一个简单的回调方法。readFile发起一个系统调用,随后执行结束,当系统调用完成后,再通过回调函数获得文件的内容。
1.2.2 同步/异步和阻塞/非阻塞
1.同步与异步
同步和异步描述的是进程/线程的调用方式。
同步调用指的是进程/线程发起调用后,一直等待调用返回后才继续执行下一步操作,这并不代表CPU在这段时间内也会一直等待,操作系统多半会切换到另一个进程/线程上去,等到调用返回后再切换回原来的进程/线程。
异步就相反,发起调用后,进程/线程继续向下执行,当调用返回后,通过某种手段来通知调用者。
注意:同步和异步中的“调用返回”,是指内核进程将数据复制到调用进程(Linux环境下)。
我们常常说JavaScript是一门异步的语言,但ECMAScript里并没有关于异步的规范,JavaScript的异步更多是依靠浏览器runtime内部其他线程来实现,并非JavaScript本身的功能,是浏览器提供的支持让JavaScript看起来像是一个异步的语言。
2.阻塞与非阻塞
阻塞与非阻塞的概念是针对IO状态而言的,关注程序在等待IO调用返回这段时间的状态。
关于Node中的IO,这里依然借用官网的说法:
需要注意的是,Node也没有使用asynchronous(异步)之类的词汇,而是使用了non-blocking(非阻塞)这样的描述。
阻塞/非阻塞和同步/异步完全是两组概念,它们之间没有任何的必然联系。很多人认为,阻塞=同步,非阻塞=异步,这种观念是不正确的。我们下面介绍的IO编程模型中,除了纯粹的AIO之外,阻塞和非阻塞IO都是同步的。
在介绍IO编程模型之前,先回答两个问题。
(1)什么是IO操作
输入/输出(I/O)是在内存和外部设备(如磁盘、终端和网络)之间复制数据的过程。
在实践中IO操作几乎无处不在,因为大多数程序都要产生输出结果才有意义(往往是输出到磁盘或者屏幕),除非你只在内存中计算一个斐波那契数列而且不采取其他任何操作。
在Node中,IO特指Node程序在Libuv支持下与系统磁盘和网络交互的过程。
(2)IO调用的结果怎么返回给调用的进程/线程
通过内核进程复制给调用进程,在Linux下,用户进程没办法直接访问内核空间,通常是内核调用copy_to_user方法来传递数据的,大致的流程就是IO的数据会先被内核空间读取,然后内核将数据复制给用户进程。还有一种零复制技术,大致是内核进程和用户进程共享一块内存地址,这避免了内存的复制,读者可自行搜索相关内容。
3.IO编程模型
编程模型是指操作系统在处理IO时所采用的方式,这通常是为了解决IO速度比较慢的问题而诞生的。
一般来说,编程模型有以下几种:
- blocking I/O
- non-blocking I/O
- I/O multiplexing(select and poll)
- signal driven I/O(SIGIO)
- asynchronous I/O(the POSIX aio_functions)
上面的5种模型中,signal driven I/O模型不常用,我们主要讨论其他4种,它们均特指Linux下的IO模型。
(1)阻塞IO(blocking I/O)
对于IO来说,通常可以分为两个阶段,准备数据和返回结果,阻塞型IO在进程发出一个系统调用请求之后,进程就一直等待上述两个阶段完成,等待拿到返回结果之后再重新运行。
(2)非阻塞IO(nonblocking I/O)
和上面的过程相似,不同之处是当进程发起一个调用后,如果数据还没有就绪,就会马上返回一个结果告诉进程现在还没有就绪,和阻塞IO的区别是用户进程会不断查询内核状态。这个过程依旧是同步的。
(3)IO multiplexing/Event Driven
这种IO通常也被称为事件驱动IO,同样是以轮询的方式来查询内核的执行状态,和非阻塞IO的区别是一个进程可能会管理多个IO请求,当某个IO调用有了结果之后,就返回对应的结果。
注意:select和poll都是IO复用的机制,另外Node使用epoll(改进后的poll),这里不再详细介绍。
(4)Asynchronous I/O
异步IO的概念读者应该很熟悉了,和前面的模型相比,当进程发出调用后,内核会立刻返回结果,进程会继续做其他的事情,直到操作系统返回数据,给用户进程发送一个信号。注意,异步IO并没有涉及任何关于回调函数的概念,此外,这里的异步IO只存在于Linux系统下。
读者可能会感到好奇,那么既然如此,为什么在官网上Node没有标榜自己是异步IO,而是写成非阻塞IO呢?
很简单,因为非阻塞是实打实的,而Node中的“异步I/O”是依靠Libuv模拟出来的。我们会在下一节介绍。
用一句话来概括阻塞/非阻塞和同步/异步:
同步调用会造成调用进程的IO阻塞,异步调用不会造成调用进程的IO阻塞(引用自《Unix网络编程》第三版6.2)。
1.2.3 单线程和多线程
其他语言(例如Java、C++等)都有多线程的语言特性,即开发者可以派生出多个线程来协同工作,在这种情况下,用户的代码是运行在多线程环境下的。
Node并没有提供多线程的支持,这代表用户编写的代码只能运行在当前线程中,用于运行代码的事件循环也是单线程运行的。开发者无法在一个独立进程中增加新的线程,但是可以派生出多个进程来达到并行完成工作的目的。
另一方面,Node的底层实现并非是单线程的,libuv会通过类似线程池的实现来模拟不同操作系统下的异步调用,这对开发者来说是不可见的。
Libuv中的多线程
开发者编写的代码运行在单线程环境中,这句话是没错的,但如果说整个Node都是依靠单线程运行的,那就不正确了,因为libuv中是有线程池的概念存在的。
Libuv是一个跨平台的异步IO库,它结合了UNIX下的libev和Windows下的IOCP的特性,最早由Node的作者开发,专门为Node提供多平台下的异步IO支持。libuv本身是由C/C++语言实现的,Node中的非阻塞IO以及事件循环的底层机制,都是由libuv来实现的。
图1-2讲述了libuv的架构。
图1-2
在Windows环境下,libuv直接使用Windows的IOCP(I/O Completion Port)来实现异步IO。在非Windows环境下,libuv使用多线程来模拟异步IO。
Node的异步调用是由libuv来支持的,以readFile为例,读取文件的系统调用是由libuv来完成的,Node只负责调用libuv的接口,等数据返回后再执行对应的回调方法。
1.2.4 并行和并发
并行(Parallel)与并发(Concurrent)是两个很常见的概念,两者虽然中文译名相似,但实质上差别很大。
在介绍下面的内容之前,有必要对这两概念进行解释,下面是一个简单的比喻:
我们假设业务场景是排队取火车票。
并发是假设有两对人排队,但只有一个取票机,为了公平起见,先由队列一排头的人上前取票,再由队列二的一个人上前取票,两个队列都在向前移动。
并行同样是两队人排队取票,不同的是开放了两个取票机,那么两个队列可以同时向前移动,速度是一个窗口的两倍以上(避免了一个窗口在两个队列间切换)。
并发和并行对应了两种需求,一个是希望计算机做更多的事(处理多个队列),另一个是希望计算机能更快地完成任务(让队列以更快的速度向前移动)。
如图1-3所示给出了并发和并行,以及顺序程序(即串行程序)之间的关系与区别(该图引用自《深入理解计算机系统》12.6的图12-30)。
图1-3
Node中的并发
单线程支持高并发,通常都是依靠异步+事件驱动(循环)来实现的,异步使得代码在面临多个请求时不会发生阻塞,事件循环提供了IO调用结束后调用回调函数的能力。
Java可以依靠多线程实现并发,Node本身不支持开发者书写多线程的代码,事件循环也是单线程运行的,但是通过异步和事件驱动能够很好地实现并发。
有一句话非常出名:“除了你的代码,一切都是并行的”,网络上很多文章都会提到这句话。但稍微思考一下,就会发现这句话里的“并行”值得深究。我们稍后会继续介绍,在那之前,先来看事件循环相关的内容。