热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

JavaScript事件循环及异步原理(完全指北)

引言最近面试被问到,既然是单线程的,为什么可以执行异步操作?当时脑子蒙了,思维一直被困在这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内


引言

最近面试被问到,JS 既然是单线程的,为什么可以执行异步操作?
当时脑子蒙了,思维一直被困在 单线程 这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内容,只不过时间太长给忘了,所以要经常温习啊:(浅谈 Generator 和 Promise 的原理及实现)

  1. JS 是单线程的,只有一个主线程
  2. 函数内的代码从上到下顺序执行,遇到被调用的函数先进入被调用函数执行,待完成后继续执行
  3. 遇到异步事件,浏览器另开一个线程,主线程继续执行,待结果返回后,执行回调函数

其实 JS 这个语言是运行在宿主环境中,比如 浏览器环境nodeJs环境

  • 在浏览器中,浏览器负责提供这个额外的线程
  • Node 中,Node.js 借助 libuv 来作为抽象封装层, 从而屏蔽不同操作系统的差异,Node可以借助libuv来实现多线程。

而这个异步线程又分为 微任务宏任务,本篇文章就来探究一下 JS 的异步原理以及其事件循环机制

为什么 Javascript 是单线程的

Javascript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。这样设计的方案主要源于其语言特性,因为 Javascript 是浏览器脚本语言,它可以操纵 DOM ,可以渲染动画,可以与用户进行互动,如果是多线程的话,执行顺序无法预知,而且操作以哪个线程为准也是个难题。

所以,为了避免复杂性,从一诞生,Javascript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

HTML5 时代,浏览器为了充分发挥 CPU 性能优势,允许 Javascript 创建多个线程,但是即使能额外创建线程,这些子线程仍然是受到主线程控制,而且不得操作 DOM,类似于开辟一个线程来运算复杂性任务,运算好了通知主线程运算完毕,结果给你,这类似于异步的处理方式,所以本质上并没有改变 Javascript 单线程的本质。

函数调用栈与任务队列

函数调用栈

Javascript 只有一个主线程和一个调用栈(call stack),那什么是调用栈呢?

这类似于一个乒乓球桶,第一个放进去的乒乓球会最后一个拿出来。

举个栗子:

function a() {  
  console.log("I'm a!");
};

function b() {  
  a();
  console.log("I'm b!");
};

b();

执行过程如下所示:

  • 第一步,执行这个文件,此文件会被压入调用栈(例如此文件名为 main.js

    call stack

    main.js
  • 第二步,遇到 b() 语法,调用 b() 方法,此时调用栈会压入此方法进行调用:

    call stack

    b()
    main.js
  • 第三步:调用 b() 函数时,内部调用的 a() ,此时 a() 将压入调用栈:

    call stack

    a()
    b()
    main.js
  • 第四步:a() 调用完毕输出 I'm a!,调用栈将 a() 弹出,就变成如下:

    call stack

    b()
    main.js
  • 第五步:b()调用完毕输出I'm b!,调用栈将 b() 弹出,变成如下:

    call stack

    main.js
  • 第六步:main.js 这个文件执行完毕,调用栈将 b() 弹出,变成一个空栈,等待下一个任务执行:

    call stack

这就是一个简单的调用栈,在调用栈中,前一个函数在执行的时候,下面的函数全部需要等待前一个任务执行完毕,才能执行。

但是,有很多任务需要很长时间才能完成,如果一直都在等待的话,调用栈的效率极其低下,这时,Javascript 语言设计者意识到,这些任务主线程根本不需要等待,只要将这些任务挂起,先运算后面的任务,等到执行完毕了,再回头将此任务进行下去,于是就有了 任务队列 的概念。

任务队列

所有任务可以分成两种,一种是 同步任务(synchronous),另一种是 异步任务(asynchronous)

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。

异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有 "任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

所以,当在执行过程中遇到一些类似于 setTimeout 等异步操作的时候,会交给浏览器的其他模块进行处理,当到达 setTimeout 指定的延时执行的时间之后,回调函数会放入到任务队列之中。

当然,一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有任务执行完毕之后,接着去执行任务队列之中的回调函数。

用一张图来表示就是:

image-20181011232324823

上图中,调用栈先进行顺序调用,一旦发现异步操作的时候就会交给浏览器内核的其他模块进行处理,对于 Chrome 浏览器来说,这个模块就是 webcore 模块,上面提到的异步API,webcore 分别提供了 DOM Bindingnetworktimer 模块进行处理。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的任务执行完之后再去执行任务队列之中的回调函数。

我们先来看一个有意思的现象,我运行一段代码,大家觉得输出的顺序是什么:

  setTimeout(() => {
    console.log('setTimeout')
  }, 22)
  for (let i = 0; i++ <2;) {
    i === 1 && console.log('1')
  }
  setTimeout(() => {
    console.log('set2')
  }, 20)
  for (let i = 0; i++ <100000000;) {
    i === 99999999 && console.log('2')
  }

没错!结果很量子化:

QQ20181012-101019-HD

那么这实际上是一个什么过程呢?那我就拿上面的一个过程解析一下:

  • 首先,文件入栈

    image-20181012102607896

  • 开始执行文件,读取到第一行代码,当遇到 setTimeout 的时候,执行引擎将其添加到栈中。(由于字体太细我调粗了一点。。。)

    image-20181012103026018

  • 调用栈发现 setTimeoutWebapis中的 API,因此将其交给浏览器的 timer 模块进行处理,同时处理下一个任务。

image-20181012134531903

  • 第二个 setTimeout 入栈

    image-20181012134755318

  • 同上所示,异步请求被放入 异步API 进行处理,同时进行下一个入栈操作:

    image-20181012135048171

  • 在进行异步的同时,app.js 文件调用完毕,弹出调用栈,异步执行完毕后,会将回调函数放入任务队列:

    image-20181012140221038

  • 任务队列通知调用栈,我这边有任务还没有执行,调用栈则会执行任务队列里的任务:

    image-20181012140632679

    image-20181012140723756

上面的流程解释了浏览器遇到 setTimeout 之后究竟如何执行的,其实总结下来就是以下几点:

  1. 调用栈顺序调用任务
  2. 当调用栈发现异步任务时,将异步任务交给其他模块处理,自己继续进行下面的调用
  3. 异步执行完毕,异步模块将任务推入任务队列,并通知调用栈
  4. 调用栈在执行完当前任务后,将执行任务队列里的任务
  5. 调用栈执行完任务队列里的任务之后,继续执行其他任务

这一整个流程就叫做 事件循环(Event Loop)

那么,了解了这么多,小伙伴们能从事件循环上面来解析下面代码的输出吗?

  for (var i = 0; i <10; i++) {
    setTimeout(() => {
      console.log(i)
    }, 1000)
  }
  console.log(i)

解析:

  • 首先由于 var 的变量提升,i 在全局作用域都有效
  • 再次,代码遇到 setTimeout 之后,将该函数交给其他模块处理,自己继续执行 console.log(i) ,由于变量提升,i 已经循环10次,此时 i 的值为 10 ,即,输出 10
  • 之后,异步模块处理好函数之后,将回调推入任务队列,并通知调用栈
  • 1秒之后,调用栈顺序执行回调函数,由于此时 i 已经变成 10 ,即输出10次 10

用下图示意:

image-20181012152514598

现在小伙伴们是否已经恍然大悟,从底层了解了为什么这个代码会输出这个内容吧:

image-20181012152640173

那么问题又来了,我们看下面的代码:

  setTimeout(() => {
    console.log(4)
  }, 0);
  new Promise((resolve) =>{
    console.log(1);
    for (var i = 0; i <10000000; i++) {
      i === 9999999 && resolve();
    }
    console.log(2);
  }).then(() => {
    console.log(5);
  });
  console.log(3);

大家觉得这个输出是多少呢?

有小伙伴就开始分析了,promise 也是异步,先执行里面函数的内容,输出 12,然后执行下面的函数,输出 3 ,但 Promise 里面需要循环999万次,setTimeout 却是0毫秒执行,setTimeout 应该立即推入执行栈, Promise 后推入执行栈,结果应该是下图:

image-20181012161137385

实际上答案是 1,2,3,5,4 噢,这是为什么呢?这就涉及到任务队列的内部,宏任务和微任务。

宏任务和微任务

什么是宏任务和微任务

任务队列又分为 macro-task(宏任务)micro-task(微任务) ,在最新标准中,它们被分别称为 taskjobs

  • macro-task(宏任务)大概包括:script(整体代码), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  • micro-task(微任务)大概包括: process.nextTick(NodeJs), Promise, Object.observe(已废弃), MutationObserver(html5新特性)
  • 来自不同任务源的任务会进入到不同的任务队列。其中 setTimeoutsetInterval 是同源的。

事实上,事件循环决定了代码的执行顺序,从全局上下文进入函数调用栈开始,直到调用栈清空,然后执行所有的micro-task(微任务),当所有的micro-task(微任务)执行完毕之后,再执行macro-task(宏任务),其中一个macro-task(宏任务)的任务队列执行完毕(例如setTimeout 队列),再次执行所有的micro-task(微任务),一直循环直至执行完毕。

解析

现在我就开始解析上面的代码。

  • 第一步,整体代码 script 入栈,并执行 setTimeout 后,执行 Promise

    image-20181013144141327

  • 第二步,执行时遇到 Promise 实例,Promise 构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,而后续的.then则会被分发到micro-taskPromise队列中去。

    image-20181013144638756

    image-20181013144902587

  • 第三步,调用栈继续执行宏任务 app.js,输出3并弹出调用栈,app.js 执行完毕弹出调用栈:

    image-20181013145222565

    image-20181013145713234

  • 第四步,这时,macro-task(宏任务)中的 script 队列执行完毕,事件循环开始执行所有的 micro-task(微任务)

    image-20181013150040555

  • 第五步,调用栈发现所有的 micro-task(微任务) 都已经执行完毕,又跑去macro-task(宏任务)调用 setTimeout 队列:

    image-20181013150354612

  • 第六步,macro-task(宏任务) setTimeout 队列执行完毕,调用栈又跑去微任务进行查找是否有未执行的微任务,发现没有就跑去宏任务执行下一个队列,发现宏任务也没有队列执行,此次调用结束,输出内容1,2,3,5,4

那么上面这个例子的输出结果就显而易见。大家可以自行尝试体会。

总结

  1. 不同的任务会放进不同的任务队列之中。
  2. 先执行macro-task,等到函数调用栈清空之后再执行所有在队列之中的micro-task
  3. 等到所有micro-task执行完之后再从macro-task中的一个任务队列开始执行,就这样一直循环。
  4. 宏任务和微任务的队列执行顺序排列如下:
  5. macro-task(宏任务)script(整体代码), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  6. micro-task(微任务): process.nextTick(NodeJs), Promise, Object.observe(已废弃), MutationObserver(html5新特性)

进阶举例

那么,我再来一些有意思一点的代码:


这一段代码输出的顺序是什么呢?

其实,看明白上面流程的同学应该知道整个流程,为了防止一些同学不明白,我再简单分析一下:

  • 首先,script1 进入任务队列(为了方便起见,我把两块script 命名为script1script2):

    image-20181013152218883

  • 第二步,script1 进行调用并弹出调用栈:

    image-20181013152506563

  • 第三步,script1执行完毕,调用栈清空后,直接调取所有微任务:

    image-20181013152844991

  • 第四步,所有微任务执行完毕之后,调用栈会继续调用宏任务队列:

    image-20181013153031374

  • 第五步,执行 script2,并弹出:

    image-20181013153426912

  • 第六步,调用栈开始执行微任务:

    image-20181013153503105

  • 第七步,调用栈调用完所有微任务,又跑去执行宏任务:

    image-20181013153654938

至此,所有任务执行完毕,输出 1,2,3,5,6,7,4

了解了上面的内容,我觉得再复杂一点异步调用关系你也能搞定:

setImmediate(() => {
    console.log(1);
},0);
setTimeout(() => {
    console.log(2);
},0);
new Promise((resolve) => {
    console.log(3);
    resolve();
    console.log(4);
}).then(() => {
    console.log(5);
});
console.log(6);
process.nextTick(()=> {
    console.log(7);
});
console.log(8);
//输出结果是3 4 6 8 7 5 2 1

image-20181013163225154

终极测试

setTimeout(() => {
    console.log('to1');
    process.nextTick(() => {
        console.log('to1_nT');
    })
    new Promise((resolve) => {
        console.log('to1_p');
        setTimeout(() => {
          console.log('to1_p_to')
        })
        resolve();
    }).then(() => {
        console.log('to1_then')
    })
})

setImmediate(() => {
    console.log('imm1');
    process.nextTick(() => {
        console.log('imm1_nT');
    })
    new Promise((resolve) => {
        console.log('imm1_p');
        resolve();
    }).then(() => {
        console.log('imm1_then')
    })
})

process.nextTick(() => {
    console.log('nT1');
})
new Promise((resolve) => {
    console.log('p1');
    resolve();
}).then(() => {
    console.log('then1')
})

setTimeout(() => {
    console.log('to2');
    process.nextTick(() => {
        console.log('to2_nT');
    })
    new Promise((resolve) => {
        console.log('to2_p');
        resolve();
    }).then(() => {
        console.log('to2_then')
    })
})

process.nextTick(() => {
    console.log('nT2');
})

new Promise((resolve) => {
    console.log('p2');
    resolve();
}).then(() => {
    console.log('then2')
})

setImmediate(() => {
    console.log('imm2');
    process.nextTick(() => {
        console.log('imm2_nT');
    })
    new Promise((resolve) => {
        console.log('imm2_p');
        resolve();
    }).then(() => {
        console.log('imm2_then')
    })
})
// 输出结果是:?

大家可以在评论里留言结果哟~


推荐阅读
  • 在iOS开发中,多线程技术的应用非常广泛,能够高效地执行多个调度任务。本文将重点介绍GCD(Grand Central Dispatch)在多线程开发中的应用,包括其函数和队列的实现细节。 ... [详细]
  • 本文探讨了如何通过优化 DOM 操作来提升 JavaScript 的性能,包括使用 `createElement` 函数、动画元素、理解重绘事件及处理鼠标滚动事件等关键主题。 ... [详细]
  • C++ 异步编程中获取线程执行结果的方法与技巧及其在前端开发中的应用探讨
    本文探讨了C++异步编程中获取线程执行结果的方法与技巧,并深入分析了这些技术在前端开发中的应用。通过对比不同的异步编程模型,本文详细介绍了如何高效地处理多线程任务,确保程序的稳定性和性能。同时,文章还结合实际案例,展示了这些方法在前端异步编程中的具体实现和优化策略。 ... [详细]
  • 理解浏览器历史记录(2)hashchange、pushState
    阅读目录1.hashchange2.pushState本文也是一篇基础文章。继上文之后,本打算去研究pushState,偶然在一些信息中发现了锚点变 ... [详细]
  • 实践指南:使用Express、Create React App与MongoDB搭建React开发环境
    本文详细介绍了如何利用Express、Create React App和MongoDB构建一个高效的React应用开发环境,旨在为开发者提供一套完整的解决方案,包括环境搭建、数据模拟及前后端交互。 ... [详细]
  • 关于进程的复习:#管道#数据的共享Managerdictlist#进程池#cpu个数1#retmap(func,iterable)#异步自带close和join#所有 ... [详细]
  • 大华股份2013届校园招聘软件算法类试题D卷
    一、填空题(共17题,每题3分,总共51分)1.设有inta5,*b,**c,执行语句c&b,b&a后,**c的值为________答:5 ... [详细]
  • 驱动程序的基本结构1、Windows驱动程序中重要的数据结构1.1、驱动对象(DRIVER_OBJECT)每个驱动程序会有唯一的驱动对象与之对应,并且这个驱动对象是在驱 ... [详细]
  • 我自己做了一个网站图片的抓取,感觉速度有点慢抓取4000张图片可能得用15分钟左右的时间,我百度看用线程可以加快抓取,然后创建了5个线程抓取,但是5个线程是同步执行同样的操作一个图片就 ... [详细]
  • 本文将深入探讨 iOS 中的 Grand Central Dispatch (GCD),并介绍如何利用 GCD 进行高效多线程编程。如果你对线程的基本概念还不熟悉,建议先阅读相关基础资料。 ... [详细]
  • 本文介绍了如何使用 Node.js 和 Express(4.x 及以上版本)构建高效的文件上传功能。通过引入 `multer` 中间件,可以轻松实现文件上传。首先,需要通过 `npm install multer` 安装该中间件。接着,在 Express 应用中配置 `multer`,以处理多部分表单数据。本文详细讲解了 `multer` 的基本用法和高级配置,帮助开发者快速搭建稳定可靠的文件上传服务。 ... [详细]
  • 利用 JavaScript 和 Node.js 验证时间的有效性
    本文探讨了如何使用 JavaScript 和 Node.js 验证时间的有效性。通过编写一个 `isTime` 函数,我们可以确保输入的时间格式正确且有效。该函数利用正则表达式匹配时间字符串,检查其是否符合常见的日期时间格式,如 `YYYY-MM-DD` 或 `HH:MM:SS`。此外,我们还介绍了如何处理不同时间格式的转换和验证,以提高代码的健壮性和可靠性。 ... [详细]
  • 在Eclipse中提升开发效率,推荐使用Google V8插件以增强Node.js的调试体验。安装方法有两种:一是通过Eclipse Marketplace搜索并安装;二是通过“Help”菜单中的“Install New Software”,在名称栏输入“googleV8”。此插件能够显著改善调试过程中的性能和响应速度,提高开发者的生产力。 ... [详细]
  • 本文详细记录了腾讯ABS云平台的一次前端开发岗位面试经历,包括面试过程中遇到的JavaScript相关问题、Vue.js等框架的深入探讨以及算法挑战等内容。 ... [详细]
  • 在处理木偶评估函数时,我发现可以顺利传递本机对象(如字符串、列表和数字),但每当尝试将JSHandle或ElementHandle作为参数传递时,函数会拒绝接受这些对象。这可能是由于这些句柄对象的特殊性质导致的,建议在使用时进行适当的转换或封装,以确保函数能够正确处理。 ... [详细]
author-avatar
phper
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有