白话理解 Node.js 异步单线程
Nginx 和 Node.js 都是多进程单线程的。它通过事件轮询(event loop Node.js 事件循环)来实现并行操作,因此,我们要尽可能的避免阻塞操作,取而代之,多使用非阻塞操作。参考 Understanding the node.js event loop
主线程和子线程,这里说的单线程,是说主线程只有一个,所以要把阻塞的运算处理分拆到子线程中去运行。
- 使用回调函数
- 以非阻塞操作进行请求响应
几种可能的响应网络请求的方式:
- 同步;最简单,但一个访问会阻塞其他访问;
- 分出新进程;还算简单,但大量的访问请求意味着大量的进程,会占用大量内存资源;
- 多线程;比较复杂,有些机器不支持线程,随着线程的增加,编程复杂度的增加也是很可怕的,很快就不知道怎么控制线程间的共享资源了。另外,线程占用内存会随请求数量增加快速增加,这也是为什么 Apache 比 Nginx 响应同样数量请求时占用更多的内存的原因。
- 单线程异步回调;也算简单,通过异步调用将主线程的任务分派到子线程中,通过回调函数来实现任务完成后的通知主线程及执行后续任务。
白话理解
假设我们想做机器人卖米线的生意,我们要给机器人编程序,有煮米线的程序、收钱找钱的程序、擦桌子收碗的程序、端盘上饭的程序等。
同步的程序是最容易编写的,就是一件一件地做,一项一项地完成,一个步骤一个步骤地操作等。
那么同步的方式会带来什么问题呢?就是每来一个顾客请求,我们就开始处理这个请求,不理其他顾客甚至当前顾客的其他请求,如果当前的事情是比较费时的,比如煮米线,那么就会阻塞其他所有请求和顾客,这显然是不能接受的。
那么新进程的方式是什么意思呢?就好比复制多开了很多个米线摊,其他顾客的请求自然可以由其他米线摊来处理,但显然这很浪费资源,只有在需要开分店的时候才比较合适,那就是分布式架构加负载均衡所解决的问题了。但我们不能在一个店还没开好(高效运转)的时候就去搞连锁,那样会亏死的,所以先要把一个店的运转高效起来再说。
那么多线程的方式呢?可以想象就是一个摊儿或店,但雇佣了多个人,有人收钱,有人煮米线,但每个服务仍是同步单线程状态、即初级机器人水平,也就是说只专心做手头的事,做完一件事才做下一件事。
单线程异步回调的方式呢?就好像来了一个灵活的(机器)人(主线程),他来操作其他机器人,他接受请求,再命令其他专业的机器人做,然后他就又接受下一个请求……
这样就需要其他机器人是可以接受命令的,而煮米线机器人原来的方法是同步的,如下:
/**
* 口味: 大/小份,
* 份量: 麻辣/三鲜
*/
煮米线(口味, 份量) {
花 5 分钟煮一碗 此口味 此份量 的米线;
return 米线;
}
新的方法:
/**
* 需求参数:
* 口味: 大/小份,
* 份量: 麻辣/三鲜
* 完事儿干啥:
* callback 回调函数
*/
煮米线({需求参数}, 完事儿干啥) {
花 5 分钟煮一碗 需求参数.此口味 需求参数.此份量 的米线;
call 完事儿干啥();
}
下单米线(某口味, 某份量, 服务员) {
煮米线({口味: 某口味, 份量:某份量}, 服务员.上菜);
}
调用方法:
下单米线(三鲜, 小份, 服务员A);
下单米线(麻辣, 大份, 服务员B);
运行结果:
煮一碗 小份 三鲜 米线
服务员A 端给客人
For 循环也要注意阻塞问题
以下示例证明 for 循环较大时会是个阻塞操作,同时证明 Node.js 是单线程的。
function proveSingleBlock() {
var start = Date.now();//获取当前时间戳
var n = 0
setTimeout(function () {
console.log(Date.now() - start);
for (var i = 0; i < 1000000000; i++){//执行长循环
n = n+1;
}
}, 1000);
setTimeout(function () {
console.log(Date.now() - start);
console.log(n);
}, 2000);
}
我们在 Node.js 中就需要将如下的耗时较长的 for 循环:
function block() {
var n = 0
for (var x = 0 ; x < 1000000000; x++){
//do something crazy.
n = n+x;
}
return n;
}
改写为下面这样:
function noblock(callback) {
setTimeout(function () {
var r = block();
if (callback) { callback(r); }
}, 0);
return 'async';
}
测试代码如下:
var start = Date.now();//获取当前时间戳
// block version
console.log(block());
console.log('Block version:');
console.log(Date.now() - start);
// no block setTimeout version
console.log(noblock(function(r){
console.log(r);
console.log('No block version:');
console.log(Date.now() - start);
}));
console.log('Main end:');
console.log(Date.now() - start);
使用 ES6 的 Promise 实现异步
$('#link').click(function () {
console.log("Enter");
var asyncFunct = new Promise(function(resolve, reject) {
$('#link').animate({ width: 200 }, 2000, function() {
console.log("finished");
resolve();
});
});
asyncFunct.then((result) => {
console.log("Exit");
});
});
还有 async/await, generator/yield 等
多线程方案
用 libuv 库来支持多线程
如果我们非要让 Node.js 支持多线程,还是提倡使用官方的做法,利用 libuv 库来实现。
libuv是一个跨平台的异步I/O库,它主要用于 Node.js 的开发,同时他也被Mozilla’s Rust language, Luvit, Julia, pyuv等使用。它主要包括了Event loops事件循环, Filesystem 文件系统, Networking 网络支持, Threads 线程, Processes 进程, Utilities 其他工具。
tagg2模块 threads_a_gogo 库
threads_a_gogo 实现 nodejs 多线程,真正的非阻塞
多进程方案
在支持 html5 的浏览器里,我们可以使用 webworker 来将一些耗时的计算丢入 worker 进程中执行,这样主进程就不会阻塞,用户也就不会有卡顿的感觉了。在 Node.js 中是否也可以使用这类技术,保证主线程的通畅呢?
cluster
cluster可以用来让 Node.js 充分利用多核 cpu 的性能,同时也可以让 Node.js 程序更加健壮,官网上的 cluster 示例已经告诉我们如何重新启动一个因为异常而崩溃的子进程。
webworker
想要像在浏览器端那样启动 worker 进程,我们需要利用 Node.js 核心 api 里的 child_process 模块。 child_process 模块提供了 fork 的方法,可以启动一个 Node.js 文件,将它作为 worker 进程,当 worker 进程工作完毕,把结果通过 send 方法传递给主进程,然后自动退出,这样我们就利用了多进程来解决主线程阻塞的问题。
多进程和多线程
大部分多线程解决 cpu 密集型任务的方案都可以用我们之前讨论的多进程方案来替代,但是有一些比较特殊的场景多线程的优势就发挥出来了,比如 http web 服务器响应静态文件的例子。
以 express 处理小型静态文件为例,大致的处理流程如下:
- 首先获取文件状态,判断文件的修改时间或者判断 etag 来确定是否响应 304 给客户端,让客户端继续使用本地缓存。
- 如果缓存已经失效或者客户端没有缓存,就需要获取文件的内容到 buffer 中,为响应作准备。
- 然后判断文件的 MIME 类型,如果是类似 html, js, css 等静态资源,还需要 gzip 压缩之后传输给客户端。
- 最后将 gzip 压缩完成的静态文件响应给客户端。
用 ifile 包能得到 10 倍的速度和效率,不过 express 的作者TJ 说:
请牢记你可能不需要这么高等级吞吐率的系统,就算是每月百万级别下载量的 npm 网站,也仅仅每秒处理 17 个请求而已,这样的压力甚至于 PHP 也可以处理掉(又黑了一把 php )。
总结
单线程的 Node.js 给我们编码带来了太多的便利和乐趣,我们应该时刻保持清醒的头脑,在写 Node.js 代码中切不可与 PHP 混淆,任何一个隐藏的问题都可能击溃整个线上正在运行的 Node.js 程序。
单线程异步的 Node.js 不代表不会阻塞,在主线程做过多的任务可能会导致主线程的卡死,影响整个程序的性能,所以我们要非常小心的处理大量的循环,字符串拼接和浮点运算等 cpu 密集型任务,合理的利用各种技术把任务丢给子线程或子进程去完成,保持 Node.js 主线程的畅通。
线程/进程的使用并不是没有开销的,尽可能减少创建和销毁线程/进程的次数,可以提升我们系统整体的性能和出错的概率。
最后请不要一味的追求高性能和高并发,因为我们可能不需要系统具有那么大的吞吐率。高效,敏捷,低成本的开发才是项目所需要的,这也是为什么 Node.js 能够在众多开发语言中脱颖而出的关键。