阻塞与非阻塞IO

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变量为空后,再次请求无非再次执行一次函数,但赋值操作不会执行,所以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所采用的技术一样,所有像httpnet这样的原生模块中的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();
针对上述代码,v8显示的堆栈追踪信息

在上图中,你能清晰地看到错误发生的函数调用路径。如果引入事件轮询后会怎么样?

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中,每步都要正确的进行错误处理的原因了。一旦遗漏,你就会发现发生了错误后很难追踪,因为上下文信息都丢失了。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容