当写程序的时候,遇到一个大功能需要很长时间做完,但是突然有一个急需的小功能需要先完成,那么就会暂停大功能,先做小功能,这种方式称为 阻塞。当小功能做完了,再继续做大功能。这就是通常的同步式 (Synchronous)或阻塞式 (Blocking)。
相应地,异步式 (Asynchronous )或非阻塞式 (Non-blocking )则针对所有要完成的功能都不采用阻塞的策略。当线程遇到操作时,不会以阻塞的方式等待 操作的完成或数据的返回,而只是将 请求发送给操作系统,继续执行下一条语句。当操作系统完成 操作时,以事件的形式通知执行 操作的线程,线程会在特定时候处理这个事件。为了处理异步,线程必须有事件循环,不断地检查有没有未处理的事件,依次予以处理。
阻塞模式下,一个线程只能处理一项任务,要想提高吞吐量必须通过多线程。而非阻塞模式下,一个线程永远在执行计算操作,这个线程所使用的 CPU 核心利用率永远是 100%。在阻塞模式下,多线程往往能提高系统吞吐量,因为一个线程阻塞时还有其他线程在工作,多线程可以让 CPU 资源不被阻塞中的线程浪费。
而在非阻塞模式下,线程不会被阻塞,永远在利用 CPU。多线程带来的好处仅仅是在多核 CPU 的情况下利用更多的核,而Node.js 使用了单线程、非阻塞的事件编程模式也能带来同样的好处。
假设我们有一个功能,可以分为一个计算部分和一个逻辑 部分,逻辑部分占的时间比计算多得多。如果我们使用阻塞 ,那么要想获得高并发就必须开启多个线程。而使用异步式时,单线程即可胜任。
单线程事件驱动的异步式 比传统的多线程阻塞式究竟好在哪里呢?简而言之,异步式就是少了多线程的开销。对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度,同时在线程切换的时候还要执行内存换页,CPU 的缓存被清空,切换回来的时候还要重新从内存中读取信息。当然,异步式编程的缺点在于编码和调试都有不小的困难。但是现在已经有了不少专门解决异步式编程问题的库(如async)。
下面看一个例子(对于模块和包的概念就不再讲,不明白看我的第三篇关于node.js的文章)
让我们从让请求处理程序返回需要在浏览器中显示的信息开始。需要写requestHandler.js文件如下形式:
function start() { console.log("Request handler 'start' was called."); return "Hello Start"; } function upload() { console.log("Request handler 'upload' was called."); return "Hello Upload"; } exports.start = start; exports.upload = upload;
同样的,路由需要将请求处理程序返回给它的信息返回给服务器。需要写 router.js 文件如下形式:
function route(handle, pathname) { console.log("About to route a request for " + pathname); if (typeof handle[pathname] === 'function') { return handle[pathname](); } else { console.log("No request handler found for " + pathname); return "404 Not found"; } } exports.route = route;
正如上述代码所示,当请求无“路”的时候,我们也返回了一些相关的错误信息。
最后,我们需要写 server.js 文件使得它能够将请求处理程序通过请求的路由返回的内容响应给浏览器,如下所示:
var http = require("http"); var url = require("url"); function start(route, handle) { function onRequest(request, response) { var pathname = url.parse(request.url).pathname; console.log("Request for " + pathname + " received."); response.writeHead(200, {"Content-Type": "text/plain"}); var content = route(handle, pathname) response.write(content); response.end(); } http.createServer(onRequest).listen(8888); console.log("Server has started."); } exports.start = start;
将对象引入到主文件 index.js 中:
var server = require("./server"); var router = require("./router"); var requestHandlers = require("./requestHandlers"); var handle = {} handle["/"] = requestHandlers.start; handle["/start"] = requestHandlers.start; handle["/upload"] = requestHandlers.upload; server.start(router.route, handle);
如果我们运行重构后的应用,一切都会工作的很好:请求
http://localhost:8888/start,浏览器会输出“Hello Start”,请求
http://localhost:8888/upload 会输出“Hello Upload”,而请求
http://localhost:8888/foo 会输出“404 Not found”。
好,那么问题在哪里呢?简单的说就是: 当未来有请求处理程序需要进行非阻塞的操作的时候,我们的应用就“挂”了。
下面就来详细解释下。
正如此前所提到的,当在请求处理程序中包括非阻塞操作时就会出问题。但是,在说这之前,我们先来看看什么是阻塞操作。
我们直接来看,当在请求处理程序中加入阻塞操作时会发生什么。 这里,我们来修改下 start请求处理程序,我们让它等待 10 秒以后再返回
“Hello Start”。因为,JavaScript中没有类似 sleep()这样的操作,所以这里只能够用小程序来模拟实现。
让我们将requestHandlers.js 修改成如下形式:
function start() { console.log("Request handler 'start' was called."); function sleep(milliSeconds) { var startTime = new Date().getTime(); while (new Date().getTime() < startTime + milliSeconds); } sleep(10000); return "Hello Start"; } function upload() { console.log("Request handler 'upload' was called."); return "Hello Upload"; } exports.start = start; exports.upload = upload;
上述代码中,当函数 start()被调用的时候,Node.js 会先等待 10秒,之后才会返回“Hello Start”。当调用 upload()的时候,会和此前一样立即返回。
(这里只是模拟休眠 10秒。)
接下来就让我们来看看,我们的改动带来了哪些变化。
如往常一样,我们先要重启下服务器。为了看到效果,我们要进行一些相对复杂的操作(跟着我一起做): 首先,打开两个浏览器窗口或者标签
页。在第一个浏览器窗口的地址栏中输入http://localhost:8888/start, 但是先不要打开它!在第二个浏览器窗口的地址栏中输入 http://localhost:8888/upload, 同样的,先不要打开它!接下来,做如下操作:在第一个窗口中(“/start”)按下回车,然后快速切换到第二个窗口中(“/upload”)按下回车。
注意,发生了什么: /start URL 加载花了 10 秒,这和我们预期的一样。但是,/upload URL 居然也花了 10秒,而它在对应的请求处理程序中并
没有类似于 sleep()这样的操作!
这到底是为什么呢?原因就是 start()包含了阻塞操作。形象的说就是“它阻塞了所有其他的处理工作”。
这显然是个问题,因为 Node一向是这样来说自己的:“在 node 中除了代码,所有一切都是并行执行的”。
这句话的意思是说,Node.js 可以在不新增额外线程的情况下,依然可以对任务进行并行处理 ―― Node.js 是单线程的。它通过事件轮询来实现并行操作,对此,我们应该要充分利用这一点 ―― 尽可能的避免阻塞操作,取而代之,多使用非阻塞操作。
然而,要用非阻塞操作,我们需要使用回调,通过将函数作为参数传递给其他需要花时间做处理的函数(比方说,休眠 10秒)。 对于Node.js 来说,它是这样处理的:“Function()(注:这里指的就是需要花时间处理的函数),你继续处理你的事情,我(Node.js 线程)先不等你了,我继续去处理你后面的代码,请你提供一个callbackFunction()(回调函数),等你处理完之后我会去调用该回调函数的
接下来,我们会介绍一种错误的使用非阻塞操作的方式。
这次我们还是拿 start 请求处理程序来试验。将其修改成如下形式:
var exec = require("child_process").exec; function start() { console.log("Request handler 'start' was called."); var content = "empty"; exec("ls -lah", function (error, stdout, stderr) { content = stdout; }); return content; } function upload() { console.log("Request handler 'upload' was called."); return "Hello Upload"; } exports.start = start; exports.upload = upload;
上述代码中,我们引入了一个新的 Node.js 模块,child_process。之所以用它,是为了实现一个既简单又实用的非阻塞操作:exec()。 exec()它从 Node.js 来执行一个 shell 命令。在上述例子中,我们用它来获取当前目录下所有的文件(“ls -lah”),然后,当/startURL请求的时候将文件信息输出到浏览器中。
上述代码是创建了一个新的变量 content(初始值为“empty”),执行“ls -lah”命令,将结果赋值给 content,最后将 content返回。
和往常一样,我们启动服务器,然后访问“http://localhost:8888/start” 。
之后会载入一个漂亮的 web 页面,其内容为“empty”。 这个时候,你可能大致已经猜到了,在非阻塞这块发挥了作用。有了exec(),我们可以执行非常耗时的操作而无需迫使我们的应用停下来等待该操作。 (如果想要证明这一点,可以将“ls -lah”换成比如“find /”这样更耗时的操作来效果)。
然而,针对浏览器显示的结果来看,我们的非阻塞操作并不好。
接下来,我们来修正这个问题。在这过程中,让我们先来看看为什么当前的这种方式不起作用。
问题就在于,为了进行非阻塞工作,exec()使用了回调函数。
在我们的例子中,该回调函数就是作为第二个参数传递给 exec()的匿名函数:
function (error, stdout, stderr) { content = stdout; }
现在就到了问题根源所在了:我们的代码是同步执行的,这就意味着在调用exec()之后, Node.js 会立即执行 return content ;在这个时候, content
仍然是“empty”,因为传递给 exec()的回调函数还未执行到――因为exec()的操作是异步的。
这里“ls -lah”的操作其实是非常快的。这也是为什么回调函数也会很快的执行到 ―― 不过,不管怎么说它还是异步的。
为了让效果更加明显,我们想象一个更耗时的命令: “find /”,它在我机器上需要执行 1分钟左右的时间,然而,尽管在请求处理程序中,我把“ls-lah”换成“find /”,当打开/start URL 的时候,依然能够立即获得 HTTP响应 ―― 很明显,当 exec()在后台执行的时候,Node.js 自身会继续执行后面的代码。并且我们这里假设传递给 exec()的回调函数,只会在“find /”命令执行完成之后才会被调用。
那究竟我们要如何才能实现将当前目录下的文件列表显示给用户呢?
了解了这种不好的实现方式之后,我们接下来来介绍如何以正确的方式让请求处理程序对浏览器请求作出响应。
以非阻塞操作进行请求响应
我刚刚提到了这样一个短语 ―― “正确的方式”。而事实上通常“正确的方式”一般都不简单。
不过,用 Node.js 就有这样一种实现方案: 函数传递。下面就让我们来具体看看如何实现。
到目前为止,我们的应用已经可以通过应用各层之间传递值的方式(请求处理程序 -> 请求路由 -> 服务器)将请求处理程序返回的内容(请求处
理程序最终要显示给用户的内容)传递给 HTTP 服务器。
现在我们采用如下这种新的实现方式:相对采用将内容传递给服务器的方式,我们这次采用将服务器“传递”给内容的方式。 从实践角度来说,就是
将response 对象(从服务器的回调函数 onRequest()获取)通过请求路由传递给请求处理程序。 随后,处理程序就可以采用该对象上的函数来对
请求作出响应。
原理就是如此,接下来让我们来一步步实现这种方案。
先从server.js 开始:
var http = require("http"); var url = require("url"); function start(route, handle) { function onRequest(request, response) { var pathname = url.parse(request.url).pathname; console.log("Request for " + pathname + " received."); route(handle, pathname, response); } http.createServer(onRequest).listen(8888); console.log("Server has started."); } exports.start = start;
相对此前从 route()函数获取返回值的做法,这次我们将 response 对象作为第三个参数传递给 route()函数,并且,我们将 onRequest()处理程序中
所有有关response 的函数调都移除,因为我们希望这部分工作让 route()函数来完成。
下面就来看看我们的 router.js:
function route(handle, pathname, response) { console.log("About to route a request for " + pathname); if (typeof handle[pathname] === 'function') { handle[pathname](response); } else { console.log("No request handler found for " + pathname); response.writeHead(404, {"Content-Type": "text/plain"}); response.write("404 Not found"); response.end(); } } exports.route = route;
同样的模式:相对此前从请求处理程序中获取返回值,这次取而代之的是直接传递response 对象。
如果没有对应的请求处理器处理,我们就直接返回“404”错误。 最后,我们将 requestHandler.js 修改为如下形式:
var exec = require("child_process").exec; function start(response) { console.log("Request handler 'start' was called."); exec("ls -lah", function (error, stdout, stderr) { response.writeHead(200, {"Content-Type": "text/plain"}); response.write(stdout); response.end(); }); } function upload(response) { console.log("Request handler 'upload' was called."); response.writeHead(200, {"Content-Type": "text/plain"}); response.write("Hello Upload"); response.end(); } exports.start = start; exports.upload = upload;
我们的处理程序函数需要接收 response 参数,为了对请求作出直接的响应。 start处理程序在 exec()的匿名回调函数中做请求响应的操作,而 upload
处理程序仍然是简单的回复“Hello World”,只是这次是使用 response 对象而已。
这时再次我们启动应用(node index.js),一切都会工作的很好。 如果想要证明/start 处理程序中耗时的操作不会阻塞对/upload 请求作出
立即响应的话,可以将 requestHandlers.js 修改为如下形式:
var exec = require("child_process").exec; function start(response) { console.log("Request handler 'start' was called."); exec("find /", { timeout: 10000, maxBuffer: 20000*1024 }, function (error, stdout, stderr) { response.writeHead(200, {"Content-Type": "text/plain"}); response.write(stdout); response.end(); }); } function upload(response) { console.log("Request handler 'upload' was called."); response.writeHead(200, {"Content-Type": "text/plain"}); response.write("Hello Upload"); response.end(); } exports.start = start; exports.upload = upload;
这样一来,当请求 http://localhost:8888/start 的时候,会花 10 秒钟的时间才载入,而当请求 http://localhost:8888/upload 的时候,会立即响应,
纵然这个时候/start 响应还在处理中。
同步式 I/O 和异步式 I/O 的特点