JS同步、异步、阻塞、非阻塞、事件循环、消息队列

一、单线程

  • 主线程:JavaScript是单线程的,所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个,叫它主线程;
  • 工作线程:实际上浏览器还存在其他的线程,例如:处理AJAX请求的线程、处理DOM事件的线程、定时器线程、读写文件的线程(例如在Node.js中)等等,这些线程可能存在于JS引擎之内,也可能存在于JS引擎之外,在此我们不作区分,统一叫它们工作线程;
  • 总结:
     ① JavaScript引擎是单线程运行的,浏览器无论在什么时候都有且只有一个线程在运行JavaScript程序;
     ② JavaScript引擎用单线程运行也是有意义的,单线程不必理会线程同步这些复杂的问题,问题得到简化;

二、同步和异步、阻塞和非阻塞

  • 区别:在于程序中的各个任务是否按顺序执行,异步操作可以改变程序的正常执行顺序;
console.log("1");
  setTimeout(function() {
    console.log("2")
  }, 0);
  setTimeout(function() {
    console.log("3")
  }, 0);
  setTimeout(function() {
    console.log("4")
  }, 0);
  console.log("5");
// 1
// 5
// 2
// 3
// 4
  • 什么是异步任务?上述代码中,尽管setTimeout的time延迟时间为0,其中的function也会被放入任务队列中等待下一个机会到来时执行,而不需要加入任务队列中的程序必须在任务队列的程序之前完成,因此程序的执行顺序可能和代码中的顺序不一致;

  • 任务队列的执行时机:任务队列中的回调函数必须等待任务队列之外的所有代码执行完毕之后再执行,这是因为执行程序的时候,浏览器默认setTimeout以及ajax请求这一类的方法为耗时程序(尽管有时候并不耗时),将其加入一个队列,该队列是一个存储耗时程序的队列,在所有不耗时程序执行完后,再来依次执行任务队列中的程序;

  • 任务排队:因为javascript是单线程的,这意味着所有的任务需要排队处理,前一个任务结束,才会执行后一个任务,如果前一个任务耗时很长,后一个任务就不得不一直等着,于是就有了任务队列这个概念;如果排队是因为计算量大,CPU忙不过来倒也还好,很多时候CPU是闲着的,因为IO设备很慢(比如AJAX操作从网络读取数据),不得不等着结果出来,再往下执行,于是JS语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务,等到IO设备返回了结果,再回来把挂起的任务继续执行下去;

  • 两种任务:一种是同步任务(synchronous),是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;二是异步任务(asynchronous):是指不进入主线程、而进入“任务队列”(task queue)的任务,只有等主线程任务执行完毕,任务队列才开始通知主线程请求执行任务,该任务才会进入主线程执行;

  • 具体的异步运行机制:
    1)所有的同步任务都在主线程上执行,形成一个执行栈(execution context stack);
    2)主线程之外,还存在一个“任务队列”(task queue),只要异步任务有了运行结果,就在“任务队列”之中放置一个事件;
    3)一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”,看看里面有哪些事件,那些对应的异步任务,于是结束等待状态,开始执行;
    4)主线程不断重复上面的第三步进行事件循环,只要主线程空了,就会去读取“任务队列”,这就是JS的运行机制,这个过程会不断重复;

  • 任务队列中的事件:任务队列是一个事件的队列,也可以理解成消息的队列,IO设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入“执行栈”了,主线程读取“任务队列”,就是读里面有哪些事件;任务队列中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等),比如$(selectot).click(function),这些都是相对耗时的操作,只要指定过这些事件的回调函数,这些事件发生时就会进入任务队列等待主线程读取;

  • 回调函数(callback):就是那些会被主线程挂起来的代码,前面所说的点击事件$(selectot).click(function)中的function就是一个回调函数,异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数,例如ajax的success,complete,error也都指定了各自的回调函数,这些函数就会加入任务队列中,等待执行;

  • 下面以AJAX请求为例,来看一下同步和异步的区别:

异步AJAX:

主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程:“好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”
主线程::“谢谢,你拿到响应后告诉我一声啊。”
(接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)

同步AJAX:

主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程:“......”
主线程::“喂,AJAX线程,你怎么不说话?”
AJAX线程:“......”
主线程::“喂!喂喂喂!”
AJAX线程:“......”
(一炷香的时间后)
主线程::“喂!求你说句话吧!”
AJAX线程:“主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”

      正是由于JavaScript是单线程的,而异步容易实现非阻塞,所以在JavaScript中对于耗时的操作或者时间不确定的操作,使用异步就成了必然的选择;

  • 同步阻塞案例:
// 这是一个阻塞式函数, 将一个文件复制到另一个文件上
// 调用这个”copyBigFile()”函数,将一个大文件复制到另一个文件上,将耗时1小时,意味着这个函数的将在一个小时之后返回
function copyBigFile(afile, bfile){
    var result = copyFileSync(afile,bfile);
    return result;
}

//这是一段程序
console.log("start copying ... ");    
var a = copyBigFile('A.txt', 'B.txt');  //这行程序将耗时1小时
console.log("Finished");   // 这行程序将在一小时后执行
console.log("处理一下别的事情");  // 这行程序将在一小时后执行
console.log("Hello World, 整个程序已加载完毕,请享用"); // 这行程序将在一小时后执行
  • 同步非阻塞案例:
// 这是一个非阻塞式函数
// 如果复制已完成,则返回 true, 如果未完成则返回 false
// 调用这个函数将立刻返回结果
function copyBigFile(afile,bfile){
    var copying = copyFileAsync(afile, bfile);
    var isFinished = !copying;
    return !isFinished; 
}

console.log("start copying ... ");    
// 同步的程序需要在一个循环中轮询结果
while( a = copyBigFile('A.txt', 'B.txt')){
  console.log("在这之间还可以处理别的事情");
} ;  
console.log("Finished");   // 这行程序将在一小时后执行
console.log("Hello World, 整个程序已加载完毕,请享用"); // 这行程序将在一小时后执行

// 非阻塞式的函数给编程带来了更多的便利,在长IO操作的同时,可以写点其他的程序,提高效率,执行结果如下:

// start copying ...
// 在这之间还可以处理别的事情
// 在这之间还可以处理别的事情
// 在这之间还可以处理别的事情
// ...
// Finished
// Hello World, 整个程序已加载完毕,请享用
  • 异步非阻塞案例:
          同步的程序需要在一个循环中轮询结果,循环里面的程序会被执行好多遍,所以并不好控制来写一些正常的程序,很难再利用起来,更为合理的方式是对非阻塞式的函数进行利用,也就是主线程不会主动地去询问结果,而是当任务有了结果的时候再来通知主线程;
//非阻塞式的有异步通知能力的函数
//以下不需要看懂,只用知道这个函数会在完成copy操作之后,执行success
function copyBigFile(afile,bfile, callback){
    var copying = copyFileAsync(afile, bfile, function(){ callback();});
    var isFinished = !copying;
    return !isFinished; 
}

// 不同于上一个同步非阻塞函数的地方在于它具有通知功能,能够在完成操作之后主动地通知程序,“我完成了”
console.log("start copying ... ");    
copyBigFile("A.txt","B.txt", function(){
          console.log("Finished");   //一个小时后被执行
          console.log("Hello World, 整个程序已加载完毕,请享用"); //一个小时后被执行
          })
console.log("干别的事情"); 
console.log("做一些别的处理"); 

// 程序在调用copyBigFile函数之后,可以立即获得返回值,线程没有被阻塞住,于是还可以去干些别的事情,然后当copyBigFile完成之后,会执行指定的函数
// start copying ...
// 干别的事情
// 做一些别的处理
// Finished
// Hello World, 整个程序已加载完毕,请享用

三、异步过程的构成要素
      从上文可以看出,异步函数实际上很快就调用完成了,但是后面还有工作线程执行异步任务、通知主线程、主线程调用回调函数等很多步骤,我们把整个过程叫做异步过程,异步函数的调用在整个异步过程中,只是一小部分;
      一个异步过程通常是这样的:主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数);
      异步调用一般分为两个阶段,提交请求和处理结果,这两个阶段之间有事件循环的调用,它们属于两个不同的事件循环(tick),彼此没有关联,异步调用一般以传入callback的方式来指定异步操作完成后要执行的动作,而异步调用本体和callback属于不同的事件循环;

try/catch语句只能捕获当次事件循环的异常,对callback无能为力

// 异步函数通常具有以下的形式:
A(args...,callbackFn)
// 它可以叫做异步过程的发起函数,或者叫做异步任务注册函数,args是这个函数需要的参数,callbackFn也是这个函数的参数,但是它比较特殊所以单独列出来;

从主线程的角度看,一个异步过程包括下面两个要素:
1)发起函数(或叫注册函数)A提交请求
2)回调函数callbackFn
它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果;

// 举个具体的例子:
setTimeout(fn, 1000);
// 其中的setTimeout就是异步过程的发起函数,fn是回调函数。
// 注意:前面说的形式A(args..., callbackFn)只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:

var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回调函数
xhr.open('GET', url);
xhr.send(); // 发起函数
// 发起函数和回调函数就是分离的。

四、消息队列和事件循环

  • JS是单线程的,但却能执行异步任务,这主要是因为JS中存在事件循环(Event Loop)和任务队列(Task Queue);

  • 事件循环:JS会创建一个类似于while(true)的循环,每执行一次循环体的过程称为Tick,每次Tick的过程就是查看是否有待处理事件,如果有则取出相关事件及回调函数放入执行栈中由主线程执行,待处理的事件会存储在一个任务队列中,也就是每次Tick都会查看任务队列中是否有需要执行的任务;实际上事件循环是指主线程重复从消息队列取出消息、执行的过程;

  • 任务队列:也称为消息队列,是一个先进先出的队列,它里面存放着各种消息,即异步操作的回调函数,异步操作会将相关回调添加到任务队列中,而不同的异步操作添加到任务队列的时机也不同,如onclick,setTimeout,ajax处理的方式都不同,这些异步操作都是由浏览器内核的不同模块来执行的:
    1)onclick由浏览器内核的DOM Binding模块来处理,当事件触发的时候,回调函数会立即添加到任务队列中;
    2)setTimeout会由浏览器内核的timer模块来进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中;
    3)ajax会由浏览器内核的network模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中;

  • 主线程:JS只有一个线程,称之为主线程,而事件循环是主线程中执行栈里的代码执行完毕之后,才开始执行的,因此主线程中要执行的代码时间过长,会阻塞事件循环的执行,也就会阻塞异步操作的执行,只有当主线程中执行栈为空的时候,即同步代码执行完后,才会进行事件循环来观察要执行的事件回调,当事件循环检测到任务队列中有事件就取出相关回调放入执行栈中由主线程执行;

  • ES6 新增的任务队列:ES6 中新增的任务队列是在事件循环之上的,事件循环每次 tick 后会查看 ES6 的任务队列中是否有任务要执行,也就是 ES6 的任务队列比事件循环中的任务队列优先级更高,如 Promise 就使用了 ES6 的任务队列特性;

  • AJAX异步:JS是单线程运行的,XMLHttpRequest在连接后是异步的,请求是由浏览器新开一个线程请求的,当请求的状态变更时,如果先前已设置回调,这异步线程就产生状态变更事件放到JS引擎的处理队列中等待处理,当任务被处理时,JS引擎始终是单线程运行回调函数,即onreadystatechange所设置的函数;

  • 异步过程中,工作线程在异步操作完成后需要通知主线程,那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环;

  • 工作线程将消息放到消息队列,主线程通过事件循环过程去取消息;

  • 实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行,当消息队列为空时,就会等待直到消息队列变成非空,而且主线程只有在将当前的消息执行完成后,才会去取下一个消息,这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环;

// 事件循环用代码表示大概是这样的:

while(true) {
    var message = queue.get();
    execute(message);
}
  • 那么消息队列中放的消息具体是什么东西呢?消息的具体结构当然跟具体的实现有关,但是为了简单起见,我们可以认为:消息就是注册异步任务时添加的回调函数;
// 再次以异步AJAX为例,假设存在如下的代码:

$.ajax('http://segmentfault.com', function(resp) {
    console.log('我是响应:', resp);
});

// 其他代码
...
...
...
  • 主线程在发起AJAX请求后,会继续执行其他代码,AJAX线程负责请求segmentfault.com,拿到响应后,它会把响应封装成一个JavaScript对象,然后构造一条消息:
// 消息队列中的消息就长这个样子
var message = function () {
    callbackFn(response);
}
  • 其中的callbackFn就是前面代码中得到成功响应时的回调函数,主线程在执行完当前循环中的所有代码后,就会到消息队列取出这条消息(也就是message函数),并执行它,到此为止,就完成了工作线程对主线程的通知,回调函数也就得到了执行,如果一开始主线程就没有提供回调函数,AJAX线程在收到HTTP响应后,也就没必要通知主线程,从而也没必要往消息队列放消息,用图表示这个过程就是:


    屏幕快照 2018-08-20 下午6.57.24.png
  • 异步过程的回调函数,一定不在当前这一轮事件循环中执行;
  • 还有一点需要注意的是:触发和执行并不是同一概念,计时器的回调函数一定会在指定delay的时间后被触发,但并不一定立即执行,可能需要等待,所有的js代码都是在同一个线程里执行的,但像鼠标点击和计时器之类的事件只有在js单线程空闲时才执行;

五、异步与事件

  • 上文中所说的“事件循环”,为什么里面有个事件呢?那是因为:消息队列中的每条消息实际上都对应着一个事件;
  • 上文中一直没有提到一类很重要的异步过程:DOM事件;
// 举例
var button = document.getElement('#btn');
button.addEventListener('click', function(e) {
    console.log();
});
  • 从事件的角度来看,上述代码表示:在按钮上添加了一个鼠标单击事件的事件监听器,当用户点击按钮时,鼠标单击事件触发,事件监听器函数被调用;
  • 从异步过程的角度看,addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数,事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行;
  • 事件的概念实际上并不是必须的,事件机制实际上就是异步过程的通知机制,我觉得它的存在是为了编程接口对开发者更友好
  • 另一方面,所有的异步过程也都可以用事件来描述,例如:setTimeout可以看成对应一个时间到了的事件,前文的setTimeout(fn,1000);可以看成:
timer.addEventListener('timeout', 1000, fn);

六、生产者与消费者

  • 从生产者与消费者的角度看,异步过程是这样的:
  • 工作线程是生产者,主线程是消费者(只有一个消费者)。工作线程执行异步任务,执行完成后把对应的回调函数封装成一条消息放到消息队列中;主线程不断地从消息队列中取消息并执行,当消息队列空时主线程阻塞,直到消息队列再次非空。

七、总结

  • 最后再用一个生活中的例子总结一下同步和异步:在公路上,汽车一辆接一辆,有条不紊的运行,这时,有一辆车坏掉了,假如它停在原地进行修理,那么后面的车就会被堵住没法行驶,交通就乱套了,幸好旁边有应急车道,可以把故障车辆推到应急车道修理,而正常的车流不会受到任何影响。等车修好了,再从应急车道回到正常车道即可。唯一的影响就是,应急车道用多了,原来的车辆之间的顺序会有点乱;
  • 这就是同步和异步的区别,同步可以保证顺序一致,但是容易导致阻塞;异步可以解决阻塞问题,但是会改变顺序性,改变顺序性其实也没有什么大不了的,只不过让程序变得稍微难理解了一些;
  • PS:ECMAScript 262规范中,并没有对异步、事件队列等概念及其实现的描述。这些都是具体的JavaScript运行时环境使用的机制。本文重点是描述异步过程的原理,为了便于理解做了很多简化。所以文中的某些术语的使用可能是不准确的,具体细节也未必是正确的,例如消息队列中消息的结构。请读者注意。

八、Event Loop的其他解释

  • Event Loop是一个程序结构,用于等待和发送消息和事件;
  • 简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程");


    Event Loop

          上图主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。等到I/O程序完成操作,Event Loop线程再把结果返回主线程。主线程就调用事先设定的回调函数,完成整个任务;

      可以看到,由于多出了橙色的空闲时间,所以主线程得以运行更多的任务,这就提高了效率。这种运行方式称为"异步模式"(asynchronous I/O)或"非堵塞模式"(non-blocking mode);

参考链接:
JavaScript:彻底理解同步、异步和事件循环(Event Loop)
js-关于异步原理的理解和总结
js中的同步和异步的个人理解
JS之异步概念
深入解析Javascript异步编程

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

推荐阅读更多精彩内容