《新时期的Node.js入门》学习日记-基础知识

0xGeekCat · 2020-8-26 · 次阅读


前言

为此时后期学习的知识储备,值得复习以及反复查阅

Node是什么

Node和JavaScript的关系

Node是JavaScript(严格来说是ECMAScript)的运行时组件(runtime)

runtime

一个X语言的runtime表示开发者可以在这个runtime上运行X语言编写的代码

  • VC++
  • Chrome
  • Node

runtime可能会对编译语言做扩展

  • fs模型
  • Buffer类型

VM

可以认为是硬件和二进制文件的中间层;C++编译好的二进制文件直接被OS调用,Java编译好的字节码交由虚拟机运行;这样做的好处是对开发者屏蔽OS差异

Node的内部机制

OS相关概念

  • 在任务完成之前,CPU在任何情况下都不会暂停或者停止执行,CPU如何执行和同步或是异步、阻塞或者非阻塞都没有必然关系
  • 操作系统始终保证CPU处在运行状态,这是通过系统调度来实现的,具体一点就是通过在不同进程/线程间切换实现的

回调

回调的定义

通俗理解就是将一个函数作为参数传递给另一个函数,并且作为参数的函数可以被执行,其本质上是一个高阶函数

❗️高阶函是至少满足下列一个条件

  • 接受一个或多个函数作为输入
  • 输出一个函数

map方法接受一个函数作为参数,依次作用于的数组的每一个元素

[1, 2, 3].map(function (value) {
    console.log(value)
})

关于回调函数在何时执行并没有具体的要求,回调函数的调用既可以是同步(map方法),也可以是异步(setTimeout方法中的匿名函数)

👇回调的过程

截屏2020-08-26 上午11.18.53

回调方法和主线程处于同一层级,假设主线程发起了一个底层的系统调用,那么操作系统转而去执行这个系统调用;当调用结束后,又回到主线程上调用其后方法

异步过程中的回调

单线程运行的语言在设计时要考虑👇的问题

❓如果遇到一个耗时的操作,例如磁盘IO,要不要等待操作完成了再执行下一步操作

有的语言选择在完成之前继续等待,例如PHP;Node选择另一种方式,当遇到IO操作时,Node代码在发起一个调用后继续向下执行,IO操作完成后,再执行对应的回调函数(异步);虽然代码运行在单线程环境下,但依靠异步+回调的方式,也能实现对高并发的支持

let fs = require("fs")
let callback = function (err, data) {
    if (err)
        return
    console.log(data.toString())
}

fs.readFile("foo.txt", callback)

readFile发起一个系统调用,随后执行结束,当系统调用完成后,再通过回调函数获得文件的内容

同步/异步和阻塞/非阻塞

同步与异步

🔔同步和异步描述的是进程/线程的调用方式

  • 同步调用指的是进程/线程发起调用后,一直等待调用返回后才继续执行下一步操作,这并不代表CPU在这段时间内也会一直等待,操作系统多半会切换到另一个进程/线程上去,等到调用返回后再切换回原来的进程/线程

  • 异步相反,发起调用后,进程/线程继续向下执行,当调用返回后,通过某种手段来通知调用者

❗️同步和异步中的调用返回,是指内核进程将数据复制到调用进程(Linux环境下)

JavaScript的异步更多是依靠浏览器runtime内部其他线程来实现,并非JavaScript本身的功能,是浏览器提供的支持让JavaScript看起来像是一个异步的语言

阻塞与非阻塞

🔔阻塞与非阻塞的概念是针对IO状态而言

阻塞/非阻塞和同步/异步完全是两组概念,它们之间没有任何的必然联系。很多人认为,阻塞=同步,非阻塞=异步,这种观念是不正确的

IO编程模型中,除了纯粹的AIO之外,阻塞和非阻塞IO都是同步的

❓在介绍IO编程模型之前,先回答两个问题

  1. 什么是IO操作

    🔔输入/输出(I/O)是在内存和外部设备(如磁盘、终端和网络)之间复制数据的过程

    在实践中IO操作几乎无处不在,因为大多数程序都要产生输出结果才有意义(往往是输出到磁盘或者屏幕)

    在Node中,IO特指Node程序在Libuv支持下与系统磁盘和网络交互的过程

  2. IO调用的结果怎么返回给调用的进程/线程

    通过内核进程复制给调用进程,在Linux下,用户进程没办法直接访问内核空间,通常是内核调用copy_to_user方法来传递数据的,大致的流程就是IO的数据会先被内核空间读取,然后内核将数据复制给用户进程。还有一种零复制技术,大致是内核进程和用户进程共享一块内存地址,这避免了内存的复制

IO编程模型

编程模型是指操作系统在处理IO时所采用的方式,这通常是为了解决IO速度比较慢的问题而诞生的

  1. 阻塞IO(blocking I/O)

    IO通常可以分为两个阶段,准备数据和返回结果;阻塞型IO在进程发出系统调用请求后,进程就一直等待上述两个阶段完成,等拿到返回结果后再重新运行

  2. 非阻塞IO(nonblocking I/O)

    和👆过程相似,不同之处是当进程发起一个调用后,如果数据还没有就绪,就会马上返回一个结果告诉进程现在还没有就绪,和阻塞IO的区别是用户进程会不断查询内核状态。这个过程依旧是同步

  3. IO multiplexing/Event Driven

    事件驱动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阻塞

单线程和多线程

其他语言(Java、C++等)都有多线程的语言特性,即开发者可以派生出多个线程来协同工作,在这种情况下,用户的代码是运行在多线程环境下的

Node并没有提供多线程的支持,这代表用户编写的代码只能运行在当前线程中,用于运行代码的事件循环也是单线程运行的。开发者无法在一个独立进程中增加新的线程,但是可以派生出多个进程来达到并行完成工作的目的;另一方面,Node的底层实现并非是单线程的,libuv会通过类似线程池的实现来模拟不同操作系统下的异步调用,这对开发者来说是不可见的

Libuv中的多线程

开发者编写的代码确实运行在单线程环境中,但如果说整个Node都是依靠单线程运行的,那就不正确了,因为libuv中是有线程池的概念存在的

Libuv是一个跨平台的异步IO库,它结合了UNIX下的libev和Windows下的IOCP特性,最早由Node的作者开发,专门为Node提供多平台下的异步IO支持。libuv本身是由C/C++语言实现,Node中的非阻塞IO以及事件循环的底层机制,都是由libuv来实现的

👇libuv的架构

截屏2020-08-26 下午2.22.25

在Windows环境下,libuv直接使用Windows的IOCP(I/O Completion Port)实现异步IO。在非Windows环境下,libuv使用多线程来模拟异步IO

🔔Node的异步调用是由libuv来支持的

并行和并发

并行(Parallel)与并发(Concurrent)是两个很常见的概念;对应了两种需求,一个是希望计算机做更多的事(处理多个队列),另一个是希望计算机能更快地完成任务(让队列以更快的速度向前移动)

Node中的并发

单线程支持高并发,通常都是依靠异步+事件驱动(循环)来实现的,异步使得代码在面临多个请求时不会发生阻塞,事件循环提供IO调用结束后调用回调函数的能力

Java可以依靠多线程实现并发,Node本身不支持开发者书写多线程的代码,事件循环也是单线程运行的,但是通过异步和事件驱动能够很好地实现并发

Event loop事件循环

Node代码虽然运行在单线程中,但仍然能支持高并发,就是依靠事件循环实现的

事件与循环

在可交互的用户页面上,用户会产生一系列的事件,包括单击按钮、拖动元素等,这些事件会按照顺序被加载到一个队列中去。除了页面事件之外,还有一些例如Ajax执行成功、文件读取完毕等事件

在GUI程序中,代码本身就处在一个循环的包裹中;这个循环通常对开发者来说是不可见的,只有当开发者单击了窗体的关闭按钮,该循环才会结束。当用户单击了页面上的按钮或者进行其他操作时,就会产生相应的事件,这些事件会被加入到一个队列中,然后主循环会逐个处理它们。JavaScript也是一样,用户在前台不断产生事件,背后的循环(由浏览器实现)会逐个地处理它们

而JavaScript是单线程的,为了避免一个过于耗时的操作阻塞了其他操作的执行,就要通过异步加回调的方式解决问题

Ajax请求为例,当JavaScript执行到对应的代码时,就为这句代码注册了一个事件,在发出请求后该语句就执行完毕,后续的操作会交给回调函数来处理

此时浏览器背后的循环正在不断遍历事件队列,在Ajax操作完成之前,事件队列里还是空的(并不是发出请求这一动作被加入事件队列,而是请求完成这一事件才会加入队列);如果Ajax操作完成,这个队列中就会增加一个事件,随后被循环遍历到,如果这个事件绑定了一个回调方法,那么循环就会去调用这个方法

Node中的事件循环

Node中的事件循环比起浏览器中的JavaScript还是有一些区别的,事件循环同样运行在单线程环境下,JavaScript的事件循环是依靠浏览器实现的,而Node作为另一种运行时(runtime),事件循环由底层的libuv实现

👇Node事件循环的具体流程

截屏2020-08-26 下午3.04.23

Node事件循环分成了6个不同的阶段,其中每个阶段都维护着一个回调函数的队列;在不同的阶段,事件循环会处理不同类型的事

  1. Timers:用来处理setTimeOut()setInterval()的回调
  2. I/O callbacks:大多数的回调方法在这个阶段执行,除了timersclosesetImmediate事件的回调
  3. idle, prepare:仅仅在内部使用,不管它
  4. Poll:轮询,不断检查有没有新的IO事件,事件循环可能会在这里阻塞
  5. Check:处理setImmediate()事件的回调
  6. close callbacks:处理一些close相关的事件,例如socket.on('close', ...)

👇Node事件循环的代码结构

while (true) {
    // ...
    nv_run_timers()

    nv_run_pending(loop)

    nv_run_idle()

    nv_io_poll()

    nv_run_check()

    nv_run_closing_handles()
    // ...
}

假设事件循环现在进入了某个阶段(即开始执行上面其中一个方法),即使在这期间有其他队列中的事件就绪,也会先将当前阶段队列里的全部回调方法执行完毕后,再进入到下个阶段,结合代码这点很好理解

  1. Timers

    主要用来处理定时器相关的回调,当一个定时器超时后,一个事件就会加入到队列中,事件循环会跳转至这个阶段执行对应的回调函数;定时器的回调会在触发后尽可能早地被调用,这表示实际的延时可能会比定时器规定的时间要长。如果事件循环,此时正在执行一个比较耗时的callback,那么定时器的回调只能等当前回调执行结束了才能被执行,即被阻塞。事实上timers阶段的执行受到poll阶段控制

  2. I/O callbacks

    官方称除timerssetImmediate,以及close操作之外的大多数的回调方法都位于这个阶段执行。事实上从源码来看,该阶段只用来执行pending callback,例如一个TCP socket执行出现了错误,在一些*nix系统下可能希望稍后再处理这里的错误,那么这个回调就会放在IO callback阶段来执行。一些常见的回调,例如fs.readFile的回调是放在poll阶段来执行的

  3. Poll

    主要任务是等待新的事件出现(该阶段使用epoll来获取新的事件),如果没有,事件循环可能会在此阻塞(关于是否在poll阶段阻塞以及阻塞多长时间,libuv有一些复杂的判定方法,不作深究)。这些事件对应的回调方法可能位于timers阶段(如果定义了定时器),也可能是check阶段(设置了setImmediate方法)

  4. Check

    setImmediate是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个check阶段就是为setImmediate方法而设置的。一般情况下,当事件循环到达poll阶段后,就会检查当前代码是否调用了setImmediate,但如果一个回调函数是被setImmediate方法调用的,事件循环就会跳出poll阶段进而进入check阶段

  5. Close

    如果socket或者句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。close阶段执行完毕后,本轮事件循环结束,循环进入到下一轮

Node中的事件循环是分阶段处理的,对于每一阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一阶段的事件循环都对应着不同的队列

👇为了更好地理解Node中的事件循环,以一段代码为例来配合说明

let fs = require("fs")

let timeoutScheduled = Date.now()

setTimeout(function () {
    let delay = Date.now() - timeoutScheduled
    console.log(delay + "ms have passed since I was scheduled")
}, 100)

// 假设readFile耗费的时间为95ms
fs.readFile('foo.txt', function (err, data) {
    let startCallback = Date.now()
    // 使while循环阻塞10ms
    while (Date.now() - startCallback < 10) {}
})

👆代码改编自官方文档中的一个例子,讲述事件循环不同过程的处理步骤。这段代码的逻辑很简单,包含了readfile和timer两个异步操作

  1. 代码开始运行后,事件循环也开始运作了。首先检查timers,然而timers对应的事件队列目前还为空(100ms后才会有事件产生),事件循环向后执行到了poll阶段,到目前为止还没有事件出现,由于代码中没有定义setImmediate操作,事件循环便在此一直等待新的事件出现
  2. 直到95ms后,readFile读取文件完毕,产生了一个事件,加入到了poll这一队列中,此时事件循环将该队列中的事件取出,准备执行之后的callback(此时err和data的值已经就绪),readFile的回调方法什么都没做,只是暂停了10ms
  3. 事件循环本身也被阻塞10ms,按照通常的思维,95ms+10ms=105ms﹥100ms,timers队列中的事件已经就绪,应该先执行对应的回调方法才是,然而由于事件循环单线程运行,因此也会停止10ms,如果readFile的回调函数中包含了一个死循环,那么整个事件循环都会被阻塞,setTimeout的回调永远不会执行
  4. readFile的回调完成后,事件循环切换到timers阶段,接着取出timers队列中的事件执行对应的回调方法

process.nextTick

定义一个异步动作,并且让这个动作在事件循环当前阶段结束后执行

process.nextTick(function () {
    console.log('next')
})

console.log('first')
// first
// next

process.nextTick并不是事件循环的一部分,但它的回调方法也是由事件循环调用,该方法定义的回调方法会被加入到名为nextTickQueue的队列中。在事件循环的任何阶段,如果nextTickQueue不为空,都会在当前阶段操作结束后优先执行nextTickQueue中的回调函数,当nextTickQueue中的回调方法被执行完毕后,事件循环才会继续向下执行

Node限制了nextTickQueue的大小,如果递归调用了process.nextTick,那么当nextTickQueue达到最大限制后会抛出一个错误

function recurse(i) {
    while (i < 1)
    process.nextTick(recurse(i))
}

recurse(0)

截屏2020-08-26 下午5.18.25

nextTickQueue显然是一个队列,先被加入队列的回调会先执行

process.nextTick(function () {
    console.log('second')
})

process.nextTick(function () {
    console.log('third')
})

console.log('first')

// first
// second
// third

和其他回调函数一样,nextTick定义的回调也是由事件循环执行的,如果nextTick的回调方法中出现了阻塞操作,后面的要执行的回调同样会被阻塞

process.nextTick(function () {
    console.log('second')
    while (true) {} 👈
})

process.nextTick(function () {
    console.log('third')
})

console.log('first')

// first
// second

nextTick与setImmediate

setImmediate方法不属于ECMAScript标准,而是Node提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeout和setInterval,setImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环末尾(check阶段)执行

setImmediate方法和process.nextTick方法很相似,由于process.nextTick会在当前操作完成后立刻执行,因此总会在setImmediate之前执行

🔎关于这两个方法有个笑话:nextTick和setImmediate的行为和名称含义是反过来的

setImmediate(function (arg) {
    console.log('executing immediate:', arg)
}, 'so immediate')

process.nextTick(function () {
    console.log('next tick')
})

// next tick
// executing immediate: so immediate

❗️当有递归的异步操作时只能使用setImmediate,不能使用process.nextTick,setImmediate不会生成call stack

function recurse( i, end) {
    if (i > end) {
        console.log("Done!")
    } else {
        console.log(i)
        setImmediate(recurse, i + 1, end)
    }
}

recurse(0, 9999)

setImmediate和 setTimeout

setImmediate方法会在poll阶段结束后执行,而setTimeout会在规定的时间到期后执行,由于无法预测执行代码时事件循环当前处于哪个阶段,因此当代码中同时存在这两个方法时,回调函数的执行顺序不是固定的

setTimeout(function () {
    console.log('timeout')
}, 0)

setImmediate(function () {
    console.log('immediate')
})

👆但代码本人在实际测试中一直是timeout优先

但如果将二者放在一个IO操作的callback中,则永远是setImmediate先执行

require('fs').readFile('foo.txt', function () {
    setTimeout(function () {
        console.log('timeout')
    }, 0)

    setImmediate(function () {
        console.log('immediate')
    })
})

因为readFile回调执行时,事件循环位于poll阶段,因此事件循环会先进入check阶段执行setImmediate的回调,然后再进入timers阶段执行setTimeout的回调

reference

《新时期的Node.js入门》 李锴