# 吱不吱
# Node开发注意点
# 避免阻塞
在 Node.js 中,有两种类型的线程:一个事件循环线程(也被称为主循环,主线程,事件线程等)。另外一个是在工作线程池里的 k 个工作线程(也被称为线程池)。
哪种代码运行在事件轮询线程上?
当 Node.js 程序运行时,程序首先完成初始化部分,即处理 require 加载的模块和注册事件回调。 然后,Node.js 应用程序进入事件循环阶段,通过执行对应回调函数来对客户端请求做出回应。 此回调将同步执行,并且可能在完成之后继续注册新的异步请求。 这些异步请求的回调也会在事件轮询线程中被处理。
事件循环中同样也包含很多非阻塞异步请求的回调,如网络 I/O。
总体来说,事件轮询线程执行事件的回调函数,并且负责对处理类似网络 I/O 的非阻塞异步请求
哪种代码运行在工作线程池?
Node.js 使用工作线程池来处理“高成本”的任务。 这包括一些操作系统并没有提供非阻塞版本的 I/O 操作,以及一些 CPU 密集型的任务。
Node.js 模块中有如下这些 API 用到了工作线程池:
I/O 密集型任务:
-DNS:dns.lookup(),dns.lookupService()。
- 文件系统:所有的文件系统 API。除 fs.FSWatcher() 和那些显式同步调用的 API 之外,都使用 libuv 的线程池。
CPU 密集型任务:
Crypto:crypto.pbkdf2()、crypto.scrypt()、crypto.randomBytes()、crypto.randomFill()、crypto.generateKeyPair()。
Zlib:所有 Zlib 相关函数,除那些显式同步调用的 API 之外,都适用 libuv 的线程池
虽然有 Node 有线程池加持提高 I/O 操作的性能,但是仍然只一个主线程(事件循环线程)来统一执行回调。所以如果主线程在处理某一个客户端请求时或回调时被阻塞了,它就无法处理其它客户端的请求了。因为不要阻塞你的事件轮询线程
# 不要阻塞你的事件轮询线程
事件轮询线程关注着每个新的客户端连接,协调产生一个回应。 所有这些进入的请求和输出的应答都要通过事件轮询线程。 这意味着如果你的事件轮询线程在某个地方花费太多的时间,所有当前和未来新的客户端请求都得不到处理机会了
避免发生的阻塞的基本原则就是:尽量不要产生复杂的计算或循环,比如下面示例随着 n
的增大,运行时间也会越久
for (let i = 0; i < n; i++)
sum += i;
let avg = sum / n;
console.log('avg: ' + avg);
如果无避免耗时计算使用的时候可以使用一些的优化手段:
任务拆分
可以把你的复杂计算拆分开,然后让每个计算分别运行在事件循环中
function asyncAvg(n, avgCB) {
// Save ongoing sum in JS closure.
var sum = 0;
function help(i, cb) {
sum += i;
if (i == n) {
cb(sum);
return;
}
// "Asynchronous recursion".
// Schedule next operation asynchronously.
setImmediate(help.bind(null, i+1, cb));
}
// Start the helper, with CB to call avgCB.
help(1, function(sum){
var avg = sum/n;
avgCB(avg);
});
}
asyncAvg(n, function(avg){
console.log('avg of 1-n: ' + avg);
});
这种方案的缺点是:当前线程都将增加空间和时间开销
任务分流
通过开发 C++ 插件 的方式使用内置的 Node.js 工作池。使用 N-API。 node-webworker-threads 提供了一个仅用 JavaScript 就可以访问 Node.js 的工作池的方式
使用
Child Process
将分配一个进程执行任务,之后通信进程通信获取结果
这类方法的缺点是它增大了通信开销
# 总结
Node.js 擅长于 I/O 密集型任务,但对于昂贵的计算,它可能不是最好的选择
# 错误捕获
与普通页面开发的区别
开发页面时,每一个用户的浏览器上都有一份JS代码。如果代码在某种情况下崩了,只会对当前用户产生影响,并不会影响其他用户,用户刷新一下即可恢复。而在Node.js中,在不开启多进程的情况下,所有用户的请求,都会走进同一份JS代码,并且只有一个线程在执行这份JS代码。如果某个用户的请求,导致发生错误,Node.js进程挂掉,server端直接就挂了。尽管可能有进程守护,挂掉的进程会被重启,但是在用户请求量大的情况下,错误会被频繁触发,可能就会出现server端不停挂掉,不停重启的情况,对用户体验造成影响
因此server端的目标,就是要 快速、可靠 地返回数据
错误捕获
除了使用 try...catch
, 还可以使用 process
来捕获全局错误,防止进程直接退出,导致后面的请求挂掉。示例代码:
process.on('uncaughtException', (err) => {
console.error(`${err.message}\n${err.stack}`);
});
process.on('unhandledRejection', (reason, p) => {
console.error(`Unhandled Rejection at: Promise ${p} reason: `, reason);
});
uncaughtException:是如果程序中没有正常处理到错误,通过监听
process.on(‘uncaughtException’)
来捕捉错误unhandledRejection:在Node中,Promise中的错误同样不能被
try...catch
和uncaughtException
捕获。这时候我们就需要unhandledRejection
来帮我们捕获这部分错误。
# 在node上处理OPTIONS请求
学习 cors 跨域问题种,非简单请求时,浏览器会发送一个预请求询问服务端,能否通过同源策略检查,这个预请求是 OPTIONS 类型的。 在使用原生node构建的服务中,需要对 OPTIONS 请求方法进行处理
const http = require('http');
http
.createServer(function (request, response) {
if (request.method == 'OPTIONS') {
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
});
response.end('');
}
response.writeHead(200, {
'Content-Type': 'application/json;charset=utf-8',
'Access-Control-Allow-Origin': '*',
});
response.end(JSON.stringify({ value: 'hello node' }));
})
.listen(8888);
注意。在处理 OPTIONS
请求时,也需要设置头部的 Access-Control-Allow-Origin
属性,浏览器也会对 OPTIONS
请求进行同源策略检查。相应的 Access-Control-Allow-Headers
也需要设置非简单请求的头部字段。
# 什么是错误优先的回调函数?
错误优先的回调函数用于传递错误和数据。第一个参数始终应该是一个错误对象, 用于检查程序是否发生了错误。其余的参数用于传递数据。例如:
fs.readFile(filePath, function(err, data) {
if (err) {
//handle the error
}
// use the data object
});
# 如何避免回调地狱
你可以有如下几个方法:
模块化:将回调函数分割为独立的函数
使用Promises
使用
yield
# 什么是孤儿进程?
父进程创建子进程之后,父进程退出了,但是父进程对应的一个或多个子进程还在运行,这些子进程会被系统的 init 进程收养,对应的进程 ppid 为 1,这就是孤儿进程。通过以下代码示例说明。
// master.js
const fork = require('child_process').fork;
const server = require('net').createServer();
server.listen(3000);
const worker = fork('worker.js');
worker.send('server', server);
console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
process.exit(0); // 创建子进程之后,主进程退出,此时创建的 worker 进程会成为孤儿进程
// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid); // 记录当前工作进程 pid 及父进程 ppid
});
let worker;
process.on('message', function (message, sendHandle) {
if (message === 'server') {
worker = sendHandle;
worker.on('connection', function(socket) {
server.emit('connection', socket);
});
}
});
控制台进行测试,输出当前工作进程 pid 和 父进程 ppid
worker process created, pid: 32971 ppid: 32970
由于在 master.js 里退出了父进程,活动监视器所显示的也就只有工作进程
再次验证,打开控制台调用接口,可以看到工作进程 32971 对应的 ppid 为 1(为 init 进程),此时已经成为了孤儿进程
curl http://127.0.0.1:3000
I am worker, pid: 32971, ppid: 1
# 什么是僵尸进程
一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。任何一个子进程(init除外)在 exit()
之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。一旦有很多只处理少量任务的子进程完成任务后就退出,然后父进程又不管子进程的退出,然后就会产生很多的僵死进程,这样会对程序产生一定的危害
# IPC 通信
什么是 IPC 通信,如何建立 IPC 通信?什么场景下需要用到 IPC 通信?
IPC (Inter-process communication) ,即进程间通信技术,由于每个进程创建之后都有自己的独立地址空间,实现 IPC 的目的就是为了进程之间资源共享访问,实现 IPC 的方式有多种:管道、消息队列、信号量、Domain Socket,Node.js 通过 pipe
来实现。
看一下 Demo,未使用 IPC 的情况
// pipe.js
const spawn = require('child_process').spawn;
const child = spawn('node', ['worker.js'])
console.log(process.pid, child.pid); // 主进程id3243 子进程3244
// worker.js
console.log('I am worker, PID: ', process.pid);
控制台执行 node pipe.js
,输出主进程 id、子进程 id,但是子进程 worker.js
的信息并没有在控制台打印,原因是新创建的子进程有自己的 stdio
流
创建一个父进程和子进程之间传递消息的 IPC 通道实现输出信息
修改 pipe.js
让子进程的 stdio
和当前进程的 stdio
之间建立管道链接,还可以通过 spawn()
方法的 stdio
选项建立 IPC 机制,
const spawn = require('child_process').spawn;
const child = spawn('node', ['worker.js'])
child.stdout.pipe(process.stdout);
console.log(process.pid, child.pid);
再次验证,控制台执行 node pipe.js
,worker.js
的信息也打印了出来
$ 42473 42474
I am worker, PID: 42474
# 关于父进程与子进程是如何通信的
父进程在创建子进程之前会先去创建 IPC 通道并一直监听该通道,之后开始创建子进程并通过环境变量(NODECHANNELFD)的方式将 IPC 通道的文件描述符传递给子进程,子进程启动时根据传递的文件描述符去链接 IPC 通道,从而建立父子进程之间的通信机制
# Node为什么是单线程
Javascript 为什么是单线程?这个问题需要从浏览器说起,在浏览器环境中对于 DOM 的操作,试想如果多个线程来对同一个 DOM 操作是不是就乱了呢,那也就意味着对于DOM的操作只能是单线程,避免 DOM 渲染冲突。在浏览器环境中 UI 渲染线程和 JS 执行引擎是互斥的,一方在执行时都会导致另一方被挂起,这是由 JS 引擎所决定的
# 如何让一个 js 文件在 Linux 下成为一个可执行命令程序
新建 hello.js 文件,头部须加上
#!/usr/bin/env node
,表示当前脚本使用 Node.js 进行解析赋予文件可执行权限
chmod +x chmod +x /${dir}/hello.js
,目录自定义在
/usr/local/bin
目录下创建一个软链文件sudo ln-s/${dir}/hello.js/usr/local/bin/hello
,文件名就是我们在终端使用的名字终端执行 hello 相当于输入
node hello.js
#!/usr/bin/env node
console.log('hello world!')
终端测试
$ hello
hello world
# 进程的当前工作目录是什么? 有什么作用?
进程的当前工作目录可以通过 process.cwd()
命令获取,默认为当前启动的目录,如果是创建子进程则继承于父进程的目录,可通过 process.chdir()
命令重置,例如通过 spawn
命令创建的子进程可以指定 cwd
选项设置子进程的工作目录。
有什么作用?例如,通过 fs 读取文件,如果设置为相对路径则相对于当前进程启动的目录进行查找,所以,启动目录设置有误的情况下将无法得到正确的结果。还有一种情况程序里引用第三方模块也是根据当前进程启动的目录来进行查找的。
process.chdir('/Users/may/Documents/test/') // 设置当前进程目录
console.log(process.cwd()); // 获取当前进程目录
# 死锁
死锁是指两个或多个进程在执行的过程中,因为竞争资源而造成互相等待的现象,若无外力作用,它们都无法推进下去
如下以下几种场景:
互相等待对方正在占有的资源
举个例子,假设有P1,P2两个进程,都需要A和B两个资源,两个都等待另一个资源而不肯释放资源,就这样无限等待中,这就形成死锁。这只是死锁的一种情况,就是在等待对方时占有不可抢占的资源
竞争可消耗资源引起死锁
有P1,P2,P3三个进程,P1向P2发送消息并接受P3消息,P2向P3发送消息并接受P2消息,P3向P1发送消息并接受P2消息,如果设置是先接到消息后发送消息,则所有的消息都不能发送,也造成了死锁
进程推进顺序不当引起死锁
有进程P1,P2,都需要资源A,B,本来可以P1运行A,P1运行B,P2运行B,P2运行A,P2运行B,但顺序换了,P1运行A时P2运行B,容易引发死锁,属于第一种的资源抢占问题
# 产生死锁的四个必要条件
互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所使用。此时如果有其他进程请求该资源,则请求进程只能等待。
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
不可剥夺条件:进程未使用完的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放
循环等待条件:若干进程间形成首尾相接循环等待资源的关系。在发生死锁时必然存在一个进程等待队列{P1,P2,…,Pn},其中P1等待P2占有的资源,P2等待P3占有的资源,…,Pn等待P1占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个申请
这四个条件是死锁的必然条件,只要系统发生死锁,这些条件必然成立。只要有上述条件有一条不满足,就不会发生死锁
# 死锁的预防
我们可以通过破坏产生死锁的四个必要条件来预防死锁,由于资源互斥是固有特性无法改变的
破坏“请求与保持”条件
方法一:静态分配,每个进程在开始执行时就申请他所需要的全部资源
方法二:动态分配,每个进程在申请所需要的资源时他本身不占用系统资源
破坏“不可剥夺”条件
一个进程不可获得其所需要的全部资源便处于等待状态,等待期间他占用的资源将被隐式的释放重新加入到系统的资源列表中,可以被其他进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行
破坏“循环等待”条件
采用资源有序分配的基本思想。将系统中的资源顺序进行编号,将紧缺的、稀少的资源采用较大的编号,申请资源时必须按照编号的顺序执行,一个进程只有较小编号的进程才能申请较大编号的进程
# 死锁的避免
基本思想:系统对进程发出每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则分配。这是一种动态策略。典型的避免死锁的算法试银行家算法。
# console.log 是同步还是异步? 如何实现一个 console.log?
console.log
内部实现是 process.stdout
,将输入的内容打印到 stdout,异步同步取决于 stdout 连接的数据流的类型(需要写入的位置)以及不同的操作系统。
文件:在 Windows 和 POSIX 上是同步的;
TTY(终端):在 Windows 上是异步的,在 POSIX 上是同步;
管道(和 socket):在 Windows 上是同步的,在 POSIX 上是异步的;
造成这种差异的原因是因为一些历史遗留问题,不过这个问题并不会影响正常的输出结果。
# 什么是守护进程?Node 如何实现守护进程?
守护进程是不依赖终端(tty)的进程,不会因为用户退出终端而停止运行的进程。
Node 实现守护进程的思路:
创建一个进程 A;
在进程 A 中创建进程 B,可以使用 child_process.fork 或者其他方法;
对进程B执行
setsid
方法进程A退出,进程B由init进程接管。此时进程B为守护进程
← 守护服务