Node.js基于单线程事件循环架构,JavaScript层的同步计算会阻塞后续请求处理。本文通过示例演示同步sleep导致的阻塞现象,分析formidable库利用事件驱动实现非阻塞的原理,并重点介绍使用child_process.fork创建独立子进程来执行耗时计算的方法,确保主线程事件轮询不被阻塞,实现高并发响应。
Node.js基础服务器构建与阻塞问题
首先,我们使用Node.js构建一个基础服务器。
index.js
代码如下:
var requestHandler = require("./requestHandler");
var server = require("./server");
var route = {
"/hello": requestHandler.hello,
"/upload": requestHandler.upload
};
server.start(route); server.js
代码如下:
server.js
代码如下:
var http = require("http");
var url = require("url");
exports.start = function(route) {
var server = http.createServer(function(req, res) {
var pathName = url.parse(req.url).pathname;
var handler = route[pathName];
if (handler) {
console.log("Through path:" + pathName + ":" + new Date().getTime());
handler(res);
} else {
res.writeHead(404, {"Content-Type": "text/plain"});
res.end();
}
});
server.listen(8088);
}; requestHandler.js
代码如下:
exports.hello = function(res) {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("say hello.");
res.end();
};
exports.upload = function(res) {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("upload");
res.end();
}; 在命令行中,输入node index.js即可启动服务。
然而,上述代码是同步阻塞的。如果在createServer的回调函数中包含耗时较长的计算,将会阻塞Node.js的事件轮询机制。
Node.js的高效性,核心在于能够快速返回事件循环。
我们对requestHandler.js进行如下改造:在此示例中,由于事件循环持续被sleep函数占用,导致createServer的回调无法及时返回。
代码如下:
function sleep(milliSecond) {
var startTime = new Date().getTime();
console.log(startTime);
while(new Date().getTime() <= milliSecond + startTime) {
}
console.log(new Date().getTime());
}
exports.hello = function(res) {
sleep(20000);
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("say hello.");
res.end();
};
exports.upload = function(res) {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("upload");
res.end();
}; 此时,先访问http://localhost:8088/hello,再访问http://localhost:8088/upload。你会发现,尽管upload请求处理时间很短,却必须等待hello请求完成后才能响应。
基于事件驱动的非阻塞实现分析
我们尝试寻找异步调用的方法。例如formidable库中的文件上传功能,经测试是非阻塞的。查看formidable源码,发现关键代码如下:
代码如下:
IncomingForm.prototype.parse = function(req, cb) {
this.pause = function() {
try {
req.pause();
} catch (err) {
// the stream was destroyed
if (!this.ended) {
// before it was completed, crash & burn
this._error(err);
}
return false;
}
return true;
};
this.resume = function() {
try {
req.resume();
} catch (err) {
// the stream was destroyed
if (!this.ended) {
// before it was completed, crash & burn
this._error(err);
}
return false;
}
return true;
};
this.writeHeaders(req.headers);
var self = this;
req
.on('error', function(err) {
self._error(err);
})
.on('aborted', function() {
self.emit('aborted');
})
.on('data', function(buffer) {
self.write(buffer);
})
.on('end', function() {
if (self.error) {
return;
}
var err = self._parser.end();
if (err) {
self._error(err);
}
});
if (cb) {
var fields = {}, files = {};
this
.on('field', function(name, value) {
fields[name] = value;
})
.on('file', function(name, file) {
files[name] = file;
})
.on('error', function(err) {
cb(err, fields, files);
})
.on('end', function() {
cb(null, fields, files);
});
}
return this;
}; 在parse方法中,解析头部信息是同步阻塞的,但实际的文件上传操作在req.on('data')中通过事件驱动完成,这是非阻塞的。也就是说,其非阻塞模型依赖于整个Node.js的事件分发架构。
利用子进程处理耗时计算
那么,如果遇到像sleep那样消耗大量计算资源,且无法依赖Node.js事件分发架构的情况,该如何处理?
现在介绍一种类似HTML5 Web Worker的方法。
将requestHandler.js改造如下:
代码如下:
var childProcess = require("child_process");
exports.hello = function(res) {
var n = childProcess.fork(__dirname + "/subProcess.js");
n.on('message', function() {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("say hello.");
res.end();
});
n.send({});
};
exports.upload = function(res) {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write("upload");
res.end();
}; 并添加subProcess.js文件
代码如下:
function sleep(milliSecond) {
var startTime = new Date().getTime();
console.log(startTime);
while(new Date().getTime() <= milliSecond + startTime) {
}
console.log(new Date().getTime());
}
process.on('message', function() {
sleep(20000);
process.send({});
}); 测试结果显示,当hello请求仍在等待时,upload请求已经返回。
Node.js异步架构深层解析
结语:
最近阅读博客园上的许多Node.js文章,普遍认为Node.js是异步的。但对于异步的具体程度,鲜有文章准确阐述。
实际上,Node.js采用双层架构:C++与JavaScript,且为单线程。这一点至关重要。Node.js通过C++调用V8引擎执行JS命令,为了维护调用顺序,维护了一个事件序列。因此,在一个JS函数内部,其调用绝对会阻塞其他函数的执行。所以,网上提到的process.nextTick和setTimeout等方法,并不能创建新线程以避免阻塞。它们实现的仅是事件序列中元素的顺序问题。相对于setTimeout,process.nextTick的实现更为简单,直接将回调加入事件序列的最顶层(某个特定事件)。而setTimeout则是增加了一个C++线程,在指定时间将回调加入事件序列。
以Node的文件IO为例,readFile等函数的第二个参数是一个回调。Node.js首先记录回调,然后调用底层C++,启动C++过程,这可以视为异步操作,因为此时已进入C++层面而非JS层面。因此,从exec到回调执行是异步的,从http.createServer到触发回调也是异步的。还有许多类似场景,例如MySQL调用方法,若查看源码,它是通过Socket发送命令,相信这个过程速度非常快。然后等待回调的过程由Node.js的C++部分隐藏处理,这也是异步的。
本文旨在说明,如果在JS端存在耗时运算,应使用前述方法,通过JS启动C++线程,即subprocess.js。该子进程不在Node的事件序列内部维护,而是独立线程,因此不会阻塞其他JS函数。