单线程?
我们常常听到 “Node.js 是单线程的”,那么 Node 确实是只有一个线程在运行吗?
node process.js启动 process.js 查看这个进程对应的线程数:
const http = require('http');
const server = http.createServer();
server.listen(3000,()=>{
process.title='test process';
console.log('进程id',process.pid)
})
我们看到此进程中包含 7 个线程,说明 Node 进程中并非只有一个线程。事实上一个 Node 进程通常包含:1 个 Javascript 执行主线程;1 个 监控线程用于处理调试信息;1 个 v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行;4 个 v8 线程,主要用来执行代码调优与 GC 等后台任务,以及用于异步 I/O 的 libuv 线程池。
// v8 初始化线程
const int thread_pool_size = 4; // 默认 4 个线程
default_platform = v8::platform::CreateDefaultPlatform(thread_pool_size);
V8::InitializePlatform(default_platform);
V8::Initialize();
其中异步 I/O 线程池,如果执行程序中不包含 I/O 操作如文件读写等,则默认线程池大小为 0,否则 Node 会初始化大小为 4 的异步 I/O 线程池,当然我们也可以通过 process.env.UV_THREADPOOL_SIZE 自己设定线程池大小。
下图为 Node 的进程结构图:
Node 严格意义讲并非只有一个线程,通常说的 “Node 是单线程” 其实是指 JS 的执行主线程只有一个。
既然 JS 执行线程只有一个,那么 Node 为什么还能支持较高的并发?
Node 进程中通过 libuv 实现了一个事件循环机制(uv_event_loop),当执行主线程发生阻塞事件,如 I/O 操作时,主线程会将耗时的操作放入事件队列中,然后继续执行后续程序。
uv_event_loop 尝试从 libuv 的线程池(uv_thread_pool)中取出一个空闲线程去执行队列中的操作,执行完毕获得结果后,通知主线程,主线程执行相关回调,并且将线程实例归还给线程池。通过此模式循环往复,来保证非阻塞 I/O,以及主线程的高效执行。
什么是事件循环?
事件循环是指Node.js可以通过将操作系统转移到系统内核中来执行非阻塞I/O(I/O操作在发出后不必等待结果,可以直接进行其他操作)操作,尽管JavaScript是单线程的,但由于大多数内核都是多线程的。因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉Node.js,以便Node.js可以将相应的回调添加到轮询队列中以最终执行。
据 nodejs 官方文档,nodejs 中的事件循环是依赖于名为 libuv 的 C 语言库实现。本质上 libuv 的执行方式决定了 nodejs 中的事件循环的执行方式。
那么libuv是什么呢?
libuv 是使用 C 语言实现的单线程非阻塞异步 I/O 解决方案,本质上它是对常见操作系统底层异步 I/O 操作的封装,并对外暴露功能一致的 API, 首要目的是尽可能的为 nodejs 在不同系统平台上提供统一的事件循环模型。
事件循环详情
Node启动的时候,会先进行初始化事件循环,处理输入的脚本,这些脚本中可能包含定时器、nextTick以及异步API的调用等。
在事件循环的每个阶段都要执行回调的FIFO队列。当事件循环进入到一个阶段时,他会执行这个阶段的特定操作,然后在该阶段的FIFO队列中执行回调,直到队列耗尽或达到回调的限制为止。然后进入到下一个阶段。
具体阶段如下:
- timers 阶段: 这个阶段执行 **setTimeout 和 setInterval **设置的回调;
- pending callbacks 阶段: 此阶段执行延迟到下一个循环迭代的I/O回调;
- idle prepare 阶段: 仅node内部使用;
- poll 阶段: 获取新的I/O事件, 例如操作读取文件等等,执行除了关闭回调、计时器回调以及setImmdiate之外的回调,适当的条件下node将阻塞在这里;
- check 阶段: 执行 setImmediate() 设定的回调;
- close callbacks 阶段: 关闭回调,比如
socket.on('close', callback)的callback会在这个阶段执行;
主线程
-
main:启动入口文件,运行主函数 -
event loop:检查是否要进入事件循环,进入条件- 检查其他线程里是否还有待处理事项
- 检查其他任务是否还在进行中(比如计时器、文件读取操作等任务是否完成)
-
over:所有的事情都完毕。事件循环的过程:沿着从timers到close callbacks这个流程,走一圈。到event loop看是否结束,没结束再走一圈。
timers阶段
timers内部存放的是计时器。 每次到达这个队列,会检查计时器线程内的所有计时器,计时器线程内部多个计时器按照时间顺序排序。
定时器可以在回调后面指定时间阈值,但是并不是执行的确切时间,而是指在经过指定的时间之后尽早地执行。操作系统调度或者其他回调地运行可能会延迟定时器回调地执行。—执行的实际时间不确定。(可参与poll中的例子)
检查过程:将每一个计时器按顺序分别计算一遍,计算该计时器开始计时的时间到当前时间是否满足计时器的间隔参数设定(比如1000ms,计算计时器开始计时到现在是否有1s)。当某个计时器检查通过,则执行其回调函数。
Pending CallBacks 阶段
此阶段执行某些系统操作的回调,例如 TCP 错误。 举个例子,如果 TCP 套接字在尝试连接时收到 CONNREFUSED,则某些系统希望等待报告错误。 这将会在 pending callbacks 阶段排队执行。
poll阶段
轮询阶段主要有两个功能:
- 计算应该阻塞I/O轮询的时间
- 处理轮询队列(poll queue)的时间
如果poll中有回调函数需要执行,依次执行回调,直到清空队列。
如果poll中没有回调函数需要执行,已经是空队列了。则会在这里等待,等待其他队列中出现回调,
- 如果其他队列中出现回调,则从poll向下到over,结束该阶段,进入下一阶段。
- 如果其他队列也都没有回调,则持续在poll队列等待**,直到任何一个队列出现回调或时间上限**达到后再进行工作。
具体的流程如下:
以定时器举例:
setTimeout(() => {
console.log('1');
}, 5000)
console.log('node');
// NODE
// 1
-
进入主线程,执行setTimeout(),回调函数作为异步任务被放入异步队列timers队列中,暂时不执行。
-
继续向下,执行定时器后边的console,打印“node”。
-
判断是否有事件循环。是,走一圈轮询:从timers - pending callback - idle prepare……
-
到poll队列停下循环并等待。
- 由于这时候没到5秒,timers队列无任务,所以一直在poll队列卡着,同时轮询检查其他队列是否有任务。
-
等5秒到达,setTimeout的回调塞到timers内,例行轮询检查到timers队列有任务,则向下走,经过check、close callbacks后到达timers。将timers队列清空。
-
继续轮询到poll等待,询问是否还需要event loop,不需要,则到达over结束
下面我们再看一个例子,如下
const startTime=new Date();
setTimeout(function f1() {
console.log('setTimeout',new Date(), new Date()-startTime);
}, 200)
console.log('start-log', startTime);
const fs = require('fs')
fs.readFile('./read.js', 'utf-8', function fsFunc(err, data){
const fsTime=new Date()
console.log('fs',fsTime);
while(new Date()-fsTime<300) {
}
console.log('结束死循环',new Date());
});
执行流程:
- 执行全局上下文,打印"start-log"
- 查询是否有event loop
- 有,进入timers队列,检查没有计时器,这时还没到200ms。
- 轮询进入到poll,读文件还没读完,因此poll队列是空的,也没有任务回调
- 在poll队列等待……不断轮询看有没有回调
- 文件读完,poll队列有了fsFunc回调函数,并且被执行,输出「fs + 时间」
- 在while死循环那里卡300毫秒,
- 死循环卡到200ms的时候,f1回调进入timers队列。但此时poll队列很忙,占用了线程,不会向下执行。
- 直到300ms后poll队列清空,输出「结束while循环 + 时间」
- event loop赶紧向下走
- 再来一轮到timers,执行timers队列里的f1回调。于是看到「setTimeout + 时间」
- timers队列清空,回到poll队列,没有任务,等待一会。
- 等待时间够长后,向下回到event loop。
- event loop检查没有其他异步任务了,结束线程,整个程序over退出。
注意setTimeout的时间就是超过200ms的。
check阶段
check阶段轮询阶段完成之后立即执行回调。如果轮询阶段处于空闲且脚本已使用setImmediate进入check队列,则事件循环可能会进入check阶段,而不是在poll阶段等待。
setImmediate是个特殊的计时器。他会在事件循环的单独阶段运行。 它使用 libuv API,该 API 计划在轮询阶段完成后执行回调。
通常情况下,我们进入到轮询阶段时,会等待请求等,但是如果使用了setImmediate设置回调且此时轮询阶段空闲,就会直接进入check阶段。
setImmidiate与setTimeout的区别
想异步执行一段代码,但尽可能快地执行时,一个选择是使用 Node.js 提供的 setImmediate() 函数.
setImmediate 为轮询阶段完成之后执行,setTimeout 以毫秒为单位的最小阈值之后执行的脚本。
计时器的执行顺序会根据其上下文的不同而有所不同。举例,
- 如果运行在主模块(不在I/O回调内)中,执行顺序是不确定的
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
原因:
这是因为setTimeout的间隔数最小填1,虽然代码填了0。但实际计算机执行当1ms算。
以上代码,主线程运行的时候,setTimeout函数调用,计时器线程增加一个定时器任务。setImmediate函数调用后,其回调函数立即push到check队列。
主线程执行完毕,eventloop判断时,发现timers和check队列有内容,进入异步轮询:
第一种情况:等到了timers里这段时间,可能还没有1ms的时间,定时器任务间隔时间的条件不成立所以timers里还没有回调函数。继续向下到了check队列里,这时候setImmediate的回调函数早已等候多时,直接执行。而再下次eventloop到达timers队列,定时器也早已成熟,才会执行setTimeout的回调任务。于是顺序就是「setImmediate -> setTimeout」。
第二种情况:但也有可能到了timers阶段时,超过了1ms。于是计算定时器条件成立,setTimeout的回调函数被直接执行。eventloop再向下到达check队列执行setImmediate的回调。最终顺序就是「setTimeout -> setImmediate」了。
所以,只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度。
- 如果运行在同一I/O回调之中。(setTimeout.js)
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout I/O');
}, 0);
setImmediate(() => {
console.log('immediate I/O');
});
});
在I/O周期内的setImmediate总是比任何timer快。原因是:
fs函数的回调是放在poll阶段的队列里,当holding在poll队列之后,检查到其他队列(check、timers)有任务,直接向下一个阶段执行。在向下的过程中,首先到了check阶段,也就是先打印setImmediate。到下一轮循环,到达timers队列,检查setTimeout计时器符合条件,则定时器回调被执行。
类似的,在timers阶段写入check队列,而setTimeout会在下一个滴答中timers阶段执行。所以总是会先执行Immediate的回调。
setTimeout(() => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
close callback阶段
如果套接字或句柄突然关闭(例如 socket.destroy),则在此阶段将发出 close事件。 否则它将通过 process.nextTick 发出。
宏任务和微任务
为了解决同步问题,js引入异步的概念,除了主线程的同步操作外,js还有个任务队列。大开销的任务可以通过异步函数包裹通过回调函数的方式去触发。当主线程的任务执行完毕之后,轮询任务队列中的事件,将满足条件的放在主线程之上。
这时出现了一个问题:异步的任务都在任务队列中,而队列遵循先进先出原则,假如队列里有很多个满足条件的异步操作,其中有一个异步任务很重要,但它排在队尾,暂时还轮不到它,我们要让它插队怎么办???
由此出现了微任务和宏任务。
Node的微任务和宏任务:
- 微任务:nextTick、Promise.then.catch.final
- 宏任务:I/O、setTimeout、SetInterval、setImmediate
process.nextTick
每次事件循环完成一次完整的行程,我们称之为滴答。
process.nextTick从技术上来说不是事件循环的一部分,但是无论事件循环的当前阶段如何,都会在当前操作完成之后,下一个滴答开始之前处理nextTick队列。
调用 setTimeout(() => {}, 0) 将在下一个滴答结束时执行该函数,比使用 nextTick() 时要晚得多,后者优先调用并在下一个滴答开始之前执行它。
举例说明:(nextTick.js)
console.log("Hello => number 1");
setImmediate(() => {
console.log("Running before the timeout => number 3");
});
setTimeout(() => {
console.log("The timeout running last => number 4");
}, 0);
process.nextTick(() => {
console.log("Running at next tick => number 2");
});
微任务、宏任务执行流程:
- 所有的函数执行都是在主线程中,从主线程中切会任务队列时,总会优先遍历一遍微任务队列。
- 微任务队列优先执行process.nextTick
- 从主线程中切回宏任务队列继续事件轮询时,会承接上阶段继续轮询,比如上一阶段是timers,那么继续I/O阶段轮询,依此类推。
const fs = require('fs');
const startTime = Date.now();
setTimeout(() => {
const delay = Date.now() - startTime -100;
console.log(`setTimeout 100 延迟 ${delay}ms`);
}, 100);
fs.readFile('./file.txt', () => {
const startCallback = Date.now();
console.log(`${startCallback - startTime} ms`, '文件读取完成');
while (Date.now() - startCallback < 500) {
// 什么也不做
}
});
new Promise((resolve, reject) => {
resolve()
console.log(1);
}).then(data => { // 微任务1:.then
console.log(2);
})
setImmediate(() => {
console.log(3);
})
process.nextTick(() => {
console.log(4);
})
setTimeout(() => {
console.log(6);
}, 0)
console.log(5);
执行顺序:同步任务(1,5)——> 微任务(4,2)——>宏任务(先timers再poll)(6)——>setImmdiate直接跳过poll等待,进入check(3)——> poll(文件读取)
可能产生的问题:
由于nextTick具有插队的机制,nextTick的递归会让事件循环机制无法进入下一个阶段. 导致I/O处理完成或者定时任务超时后仍然无法执行, 导致了其它事件处理程序处于饥饿状态. 为了防止递归产生的问题, Node.js 提供了一个 process.maxTickDepth (默认 1000)。
如下所示,永远无法跳到timer阶段去执行setTimeout里面的回调方法, 因为在进入timers阶段前有不断的nextTick插入执行. 除非执行了1000次到了执行上限,所以上面这个案例会不断地打印出nextTick字符串
function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}
function nextTick () {
process.nextTick(() => {
wait(20);
console.log('nextTick');
nextTick();
});
}
setTimeout(() => {
console.log('timers');
}, 0);
nextTick();
最后我们总结一下这些定时器以及微任务的调用规则:
setImmediate vs setTimeout vs process.nextTick vs Promise.then
在当前操作结束后,传递给 process.nextTick() 的函数将在事件循环的当前迭代中执行。 这意味着它将始终在 setTimeout 和 setImmediate 之前执行。且nestTick优先级高于promise,所以他最先执行。
延迟为 0 毫秒的 setTimeout() 回调与 setImmediate() 非常相似。 执行顺序将取决于各种因素,但它们都将在事件循环的下一次迭代中运行。
process.nextTick 回调添加到 process.nextTick queue。 Promise.then() 回调添加到微任务队列。宏任务队列添加了 setTimeout、setImmediate 回调。
事件循环先执行 process.nextTick queue 中的任务,然后执行其他微任务,将所有微任务执行完,再执行宏任务。
setTimeout(() => {
console.log('setTimeout 100');
setTimeout(() => {
console.log('setTimeout 100 - 0');
process.nextTick(() => {
console.log('nextTick in setTimeout 100 - 0');
})
}, 0)
setImmediate(() => {
console.log('setImmediate in setTimeout 100');
process.nextTick(() => {
console.log('nextTick in setImmediate in setTimeout 100');
})
});
process.nextTick(() => {
console.log('nextTick in setTimeout100');
})
Promise.resolve().then(() => {
console.log('promise in setTimeout100');
})
}, 100)
const fs = require('fs')
fs.readFile('./1.poll.js', () => {
console.log('poll 1');
process.nextTick(() => {
console.log('nextTick in poll ======');
})
})
setTimeout(() => {
console.log('setTimeout 0');
process.nextTick(() => {
console.log('nextTick in setTimeout');
})
}, 0)
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('promise in setTimeout1');
})
process.nextTick(() => {
console.log('nextTick in setTimeout1');
})
}, 1)
setImmediate(() => {
console.log('setImmediate');
process.nextTick(() => {
console.log('nextTick in setImmediate');
})
});
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => {
console.log('nextTick 2');
})
})
console.log('global ------');
Promise.resolve().then(() => {
console.log('promise 1');
process.nextTick(() => {
console.log('nextTick in promise');
})
})
理解:
先清空同步任务global - - -
执行完所有的微任务:
- 执行nextTick队列: nextTick 1 、nextTick 2
- 执行promise队列:promise 1,同时nextTick队列插入nextTick in promise
- 执行nextTick队列:nextTick in promise
执行宏任务:(timers阶段:队列中存在setTimeout 0 和setTimeout 1)
先执行setTimeout 0 ,nextTick queue插入 nextTick in setTimeout执行微任务:nextTick in setTimeout,接着上一个阶段Times执行宏任务,发现timers队列里还有任务setTimeout 1
再 setTimeout 1,同时在微任务中中添加“nextTick in setTimeout1”、“promise in setTimeout1”。执行微任务:nextTick in setTimeout1、nextTick in setTimeout1。
执行宏任务:接着timers阶段执行,当前poll空闲,进入check阶段执行:setImmediate,微任务中添加nextTick in setImmediate。执行微任务:nextTick in setImmediate。
执行宏任务:到了poll阶段发现文件去读完成 执行poll 1 以及对应微任务 nexttick in poll ===
poll等待100ms,timers加入了setTimout(100)的回调。到timers阶段,先执行完微任务,setImmidate放入check队列,因此先执行check中setImmediate in setTimeout 100
child_process
通过事件循环机制,Node 实现了在 I/O 密集型场景下的高并发,但是如果代码中遇到 CPU 密集场景的场景,那么主线程将长时间阻塞,无法处理额外的请求。为了应对 CPU密集的场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process 模块进行进程的创建、通信、销毁等等。
Node.js 的事件循环机制使得在单个主线程上能够高效地处理大量的并发请求。开启多进程解决了单线程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。
进程
进程是操作系统进行资源调度和管理的基本单位。操作系统通过创建和管理进程来分配和控制系统资源,以便程序能够在计算机上运行。我们启动一个服务、运行一个实例,就是开一个服务进程。Node.js 里通过 node xxx.js 开启一个服务进程。
- Node.js开启服务进程例子
const http = require('http');
const server = http.createServer();
server.listen(3000,()=>{
process.title='test process';
console.log('进程id',process.pid)
})
开启进程:
node process.js
查看进程信息:
tasklist /FI "PID eq 13492
线程
单线程就是一个进程只开一个线程
node 就是属于单线程,程序顺序执行,在使用单线程语言编码时切勿有过多耗时的同步操作,否则线程会造成阻塞,导致后续响应无法处理
- 计算耗时造成线程阻塞的例子
调用127.0.0.1:3000/compute 的时候,会执行一个很耗时的计算,这时如果用户请求另一个接口,就需要等待计算完成后,才能请求成功,这对于用户来说是极其不友好的。
可以通过创建多进程的方式child_process.fork 和cluster 来解决解决这个问题。
const http = require('http');
const longComputation = () => {
let sum = 0;
for (let i = 0; i < 1e10; i++) {
sum += i;
};
return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
if (req.url === '/compute') {
console.info('计算开始',new Date());
const sum = longComputation();
console.info('计算结束',new Date());
return res.end(`Sum is ${sum}`);
} else {
res.end('Ok')
}
});
server.listen(3000);
在多核 CPU 系统之上,可以通过 child_process.fork 开启多个进程(Node.js 在 v0.8 版本之后新增了Cluster 来实现多进程架构) ,即 多进程 + 单线程 模式。
process 模块
Node.js 中的进程 Process 是一个全局对象,无需 require 直接使用,给我们提供了当前进程中的相关信息,下面列举了部分常用到功能点:
process.env:环境变量,例如通过process.env.NODE_ENV获取不同环境项目配置信息process.nextTick:这个在谈及Event Loop时经常为会提到process.pid:获取当前进程idprocess.ppid:当前进程对应的父进程process.cwd():获取当前进程工作目录,process.platform:获取当前进程运行的操作系统平台process.uptime():当前进程已运行时间,例如:pm2 守护进程的 uptime 值- 进程事件:
process.on(‘uncaughtException’, cb)捕获异常信息、process.on(‘exit’, cb)进程推出监听 - 三个标准流:
process.stdout标准输出、process.stdin标准输入、process.stderr标准错误输出 process.title指定进程名称,有的时候需要给进程指定一个名称
fork开启子进程 Demo
fork开启子进程可以解决计算耗时造成线程阻塞。 在进行 compute 计算时创建子进程,子进程计算完成通过 send 方法将结果发送给主进程,主进程通过 message 监听到信息后处理并退出。
fork_app
const http = require('http');
const fork = require('child_process').fork;
const server = http.createServer((req, res) => {
if(req.url == '/compute'){
const compute = fork('./fork_compute.js');
compute.send('开启一个新的子进程');
// 当一个子进程使用 process.send() 发送消息时会触发 'message' 事件
compute.on('message', sum => {
res.end(`Sum is ${sum}`);
compute.kill();
});
// 子进程监听到一些错误消息退出
compute.on('close', (code, signal) => {
console.log(`收到close事件,子进程收到信号 ${signal} 而终止,退出码 ${code}`);
compute.kill();
})
}else{
res.end(`ok`);
}
});
server.listen(3000, '127.0.0.1', () => {
console.log(`server started at http://127.0.0.1:3000`);
});
fork_compute
const computation = () => {
let sum = 0;
console.info('计算开始');
console.time('计算耗时');
for (let i = 0; i < 1e10; i++) {
sum += i
};
console.info('计算结束');
console.timeEnd('计算耗时');
return sum;
};
process.on('message', msg => {
console.log(msg, 'process.pid', process.pid); // 子进程id
const sum = computation();
// 如果Node.js进程是通过进程间通信产生的,那么,process.send()方法可以用来给父进程发送消息
process.send(sum);
})
node fork_app.js
与使用child_process类似的,也可以使用cluster模块来开启子进程
无论是 child_process 模块还是 cluster 模块,为了解决 Node.js 实例单线程运行,无法利用多核 CPU 的问题而出现的。核心就是父进程(即 master 进程)负责监听端口,接收到新的请求后将其分发给下面的 worker 进程。
cluster不像child_process那样灵活,因为cluster的一个主进程只能管理一组相同的工作进程,而自行通过child_process来创建工作进程,一个主进程可以控制多种进程。