Node为Javascript引入了一个复杂的概念,这在浏览器端从未有过:共享状态的并发。事实上,这种复杂度在像Apache与mod_php或者Nginx与FastCGI这样的Web应用开发模型下都从未有过。
通俗讲,Node中,你需要对回调函数如何修改当前内存中的变量(状态)特别小心。除此之外,你还要注意对错误的处理是否会潜在的修改这些状态,从而导致了整个进程不可用。
未来更好地掌握这个开年,我们来看如下函数,该函数在每次请求/books URL时候都会被执行。假设这里的“状态“就是存放图书的数组,该数组用来将图书列表以HTML的形式返回给客户端。
var books = [
"Metamorphosis",
"Crime and punishment"
];
function serveBooks() {
// 给客户端返回HTML代码
var html = "<b>" + books.join("</b><br><b>") + "</b>";
// 这里将状态修改了
books = [];
return html;
};
等价的PHP代码为:
$books = array(
"Metamorphosis",
"Crime and punishment"
);
![IMG_20180812_220631.jpg](https://upload-images.jianshu.io/upload_images/1666407-be5f07cac2f806ed.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
function serveBooks() {
$html = "<b>" . join($books,"</b><br><b>") . "</b>";
$books = array();
return $html;
}
上述两例serveBooks函数中,都将books数组重置了。
现在假设一个用户分别像Node服务器和PHP服务器各同时发起两次对/books的请求,试着预测下,结果会如何?
- Node会将完整的图书列表返回给第一个请求,而第二个请求则返回一个空的图书列表。
- PHP都能将完整的图书列表返回给两个请求。
两者的区别就在于基础架构上。
-
Apache会产生多个线程(每个请求一个线程),每次都会刷新状态。在PHP中,当解释器再次执行时,变量
$books
会被重新赋值。(所以每次请求都会返回列表,因为被再次赋值了) -
Node采用一个长期运行的进程,
serveBooks
函数会再次被调用,且作用域中的变量不受影响(此时$books
数组仍为空)。
由于我没有写过PHP,浅显的理解:
- PHP因为每次的请求,解释器都会重新执行赋值操作。
- Node长期保有状态,第一次执行完serveBooks函数,books变量依旧为空。
始终牢记这点对书写出健壮的Node.js程序,避免运行时错误是非常重要的。
另外还有重要的一点是要弄清除阻塞和非阻塞IO。
阻塞
尝试区分下面PHP代码和Node代码有什么不同。
// PHP
print("Hello")
sleep(5);
print("World");
// Node
console.log("Hello")
setTimeout(function() {
console.log("World");
},5000);
个人理解:PHP虽然不知道,但是JS还是可以解读一下的,我们都知道会先弹出Hello
,是因为JS特殊的Event loop机制。
上述两段代码不仅仅是语义上的区别(Node.js使用了回调函数),两者区别集中体现在阻塞和非阻塞的区别上。在第一个例子中,PHP的sleep()阻塞了线程的执行。当程序进入睡眠时,就什么事情都不做了。
而Node.js使用了事件轮询,因此这里setTimeout是非阻塞的。
换句话说,如果在setTimeout后再加入console.log语句的话,该语句会被立刻执行。
console.log("Hello");
setTimeout(function() {
console.log("World");
},5000);
console.log("Bye");
// Hello
// Bye
// World
采用了事件轮询意味着什么呢?从本质上说,Node会先注册事件,随后不停的轮询这些事件是否已经分发。当事件已经分发时,对应的回调函数就会被触发,然后继续执行下去。如果没有事件触发,则继续执行其他代码,直到有新事件时,再去执行对应的回调函数。
相反,在PHP中,sleep()
一旦执行,执行会被阻塞一段指定的时间,并且在阻塞时间未达到设定时间时,不会有任何操作,也就是说这是同步的。和阻塞相反,setTimeout
仅仅只是注册了一个事件,而程序继续执行,所以,这是异步的。
Node并发实现也采用了事件轮询。与timeout所采用的技术一样,所有像http
、net
这样的原生模块中的IO部分也都采用了事件轮询技术。和timeout
机制中Node内部会不停的等待,并当超时完成时,触发一个消息通知一样,Node使用事件轮询,触发一个和文件描述符相关的通知。
文件描述符是抽象的句柄,存有对打开的文件、socket、管道等的引用。本质上说,当Node接收到从浏览器发来的HTTP请求时,底层的TCP连接会分配一个文件描述符。随后,如果客户端向服务器发送数据,Node就会收到该文件描述符上的通知。然后触发即ava说唱的回调函数。
单线程的世界
有一点很重要,Node是单线程的。在没有第三方模块的帮助下是无法改变这一事实的。
为了证明这一点,以及展示它和事件轮询之间的关系,来看如下例子:
var start = Date.now();
setTimeout(function() {
console.log(Date.now() - start);
for (var i=0;i<5000000000;i++) {}
},1000);
setTimeout(function() {
console.log(Date.now() - start);
},2000);
上述两端setTimeout带啊吗,会打印出timeout设置与最终回调函数执行时,两者的时间差,以毫秒为单位。
程序显示了每个setTimeout执行的时间间隔,其结果和代码中设定的值并不相同。
为什么会这样呢?究其原因,是事件轮询被JavaScript代码阻塞了。当第一个事件分发时,会执行JavaScript回调函数。由于回调函数需要执行很长一段时间(循环次数很多),所以下一个事件轮询执行的时间就远远超过了2秒。因此,JavaScript并不能严格遵守时钟设置。
当然了,这样的行为方式不理想。事件轮询是Node IO的基础核心。既然超时可以延迟,那HTTP请求以及其他形式的IO均可如此,也就意味着,HTTP服务器每秒处理的请求数量减少了,效率也就降低了。
正因如此,许多优秀的Node模块都是非阻塞的,执行任务也都采用了异步的方式。
既然执行时只有一个线程,也就是说,当一个函数执行时,同一时间不可能有第二个函数也在执行,那Node.js是如何做到高并发的呢?
为了搞清楚这个问题,首先要明白调用堆栈的概念。
当v8首次调用一个函数时,会创建一个众所周知的调用堆栈,或者称为执行堆栈。
如果该函数调用又去调用另一个函数 的话,v8就会把它添加到调用堆栈上。
function a() {
b();
}
function b() {};
调用堆栈是a
后面跟着b
。当b
执行完,v8就不再执任何代码了。
回到HTTP服务器例子:
http.createServer(function() {
a();
});
function a() {
b();
};
function b() {};
上述例子中,一旦HTTP请求到达服务器,Node就会分发一个通知。最终,回调函数会被执行,并且调用堆栈变为a
>b
。
由于Node是运行在单线程环境中,所以,当调用堆栈展开时,Node就无法处理其他的客户端或者HTTP请求l
那么,照这样看来,Node的最大并发量不就是1吗?是的,Node并不提供真正的并行操作,因为那样需要引入更多的并行执行线程。
关键在于,在调用堆栈执行非常快的情况下,同一时刻你无须处理多个请求。这也是说v8搭配非阻塞IO是最好的组合:v8执行JavaScript的速度非常快,非阻塞IO确保了单线程执行时,不会有数据库访问或者硬盘访问等操作而导致被挂起。
一个真实世界的运用非阻塞IO的例子是云。在绝大多数如亚马逊云(AWS)这样的云部署系统中,操作系统是虚拟出来的,硬件也是由租用者之间互相共享的(所以你是在”租硬件“)。也就是说,假设硬盘正在为另外的租用者搜索文件,而你也要进行文件搜索,那么延迟就会变长。由于硬盘的IO效率是非常难预测的,所以,读文件时,如果把执行线程阻塞住,那么程序运行起来会非常不稳定,而且很慢。
在我们的应用中,常见的IO例子就是从数据库中获取数据,假设我们需要为某个请求响应数据库获取数据。
http.createServer(function(req,res) {
database.getInformation(function(data) {
res.writeHead(200);
res.end(data);
});
});
上述例子中,当请求到达时,调用堆栈中只有数据库调用。由于调用是非阻塞的,当数据库IO完成时,就完全取决于事件轮询何时在初始化新的调用堆栈。不过,在告诉Node”当你获取数据库响应时记得通知我“之后,Node就可以继续处理其他事情了。也就是说,Node可以去处理更多请求了!
错误处理
之前介绍中我们知道了,Node应用依托在一个拥有大量共享状态的大进程中。
举例来说,在一个HTTP请求中,如果某个回调函数发生了错误,整个进程都会遭殃:
var http = require("http");
http.createServer(function() {
throw new Error("错误不会被捕获");
}).listen(3000);
因为错误未被捕获,若访问Web服务器,进程就会崩溃。
Node之所以这样处理是因为,在发生未被捕获的错误时,进程的状态就不确定了。之后就可能无法正常工作了,并且如果错误始终不处理的话,就会一直抛出意料之外的错误,这样很难调试。
如果添加了uncatchException处理器,这个时候,进程就不会退出,并且之后的事情都在你的掌控中。
process.on("uncaughtException",function(err) {
console.error(err);
process.exit(1); // 手动退出
})
在上述例子中,行为方式和分发error事件的API行为方式一致。比如,考虑如下例子,创建一个TCP服务器,并用telnet工具发起连接:
var net = require("net");
net.createServer(function(connection) {
connection.on("error",function(err) {
// err是一个错误对象
})
}).listen(400);
Node中,许多像http、net这样的原生模块都会分发error事件。如果该事件未处理,就会抛出未捕获的异常。
除了uncaughtException和error事件外,绝大部分Node异步API接收的回调函数,第一个参数都是错误对象或者null。
var fs = require("fs");
fs.readFile("/etc/passwd" , function(err,data) {
if (err) return console.error(err);
console.log(data);
});
堆栈追踪
在JavaScript,当错误发生时,在错误信息中可以看到一系列的函数调用,这称为堆栈追踪。
function c() {
b();
};
function b() {
a();
};
function a() {
throw new Error("here");
};
c();
在上图中,你能清晰地看到错误发生的函数调用路径。如果引入事件轮询后会怎么样?
function c() {
b();
};
function b() {
a();
};
function a() {
setTimeout(function () {
throw new Error("here");
}, 10)
};
c();
而这里,堆栈显示的信息是从事件轮询开始的。
同理,捕获一个未来才会执行到的函数所抛出的错误是不可能的,这会直接抛出未捕获的异常,并且catch代码块永远都不会执行;
try {
setTimeout(function() {
throw new Error("here");
},10);
} catch (e) {};
这就是为什么在Node.js中,每步都要正确的进行错误处理的原因了。一旦遗漏,你就会发现发生了错误后很难追踪,因为上下文信息都丢失了。