Javascript执行机制--单线程,同异步任务,事件循环

总所周知,javascript是一门依赖宿主环境的单线程的弱脚本语言,这意味着什么?

  • javascript的运行环境一般都由宿主环境(如浏览器、Node、Ringo等)和执行环境(Javascript引擎V8,JavaScript Core等)共同构成;
  • 弱类型定义语言:数据类型可以被忽略的语言。例如计算时会在不同类型之间进行隐式转换;
  • 在某一时刻内只能执行特定的一个任务,并且会阻塞其它任务执行;

本文主要讲的就是第三点,从中引出下一个问题

单线程的设计原因?

Javascript当初诞生的目的其实就是因为当年网络技术十分低效,如表单验证等个几十秒才能得到反馈的用户体验十分糟糕,为了给浏览器做些简单处理以前由服务器端负责的一些表单验证。被Netscape公司指派花了十天就负责设计出一门新语言的Javascript之父就是Brendan Eich。尽管他并不喜欢自己设计的这作品,就有了大家都听过的一句话:

"与其说我爱Javascript,不如说我恨它。它是C语言和Self语言一夜情的产物。十八世纪英国文学家约翰逊博士说得好:'它的优秀之处并非原创,它的原创之处并不优秀。'(the part that is good is not original, and the part that is original is not good.)"

作为浏览器脚本语言而诞生的JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只需要是单线程就足以解决目的,否则会带来很复杂的同步问题。但是没想到的是之后的网络越发的发达,这些年来的浏览器大战为了争夺地盘,反而让Javascript被赋予了更多的职责跟可能性,今时今日的Javascript必须想方设法把自身的潜力激发出来,而单线程的弱点就被无限放大了,因为在阻塞任务的过程中不一定是因为CPU被占用了,而可能是因为I/O太慢(如AJAX请求,定时器任务,Dom事件交互等并不消耗CPU的等待造成资源时间浪费)。

浏览器中Javascript执行线程

我们一直都在说Javascript是单线程,但浏览器是多线程的,在内核控制下互相配合以保持同步,主要的常驻线程有:

  • GUI渲染线程:负责渲染界面,解析HTML,CSS,构建DOM和Render树布局绘制等。如果过程中遇到JS引擎执行会被挂起线程,GUI更新保存在一个队列中等待JS引擎空闲才执行;
  • JS引擎线程:负责解析运行Javascript;执行时间过程会导致页面渲染加载阻塞;
  • 事件触发线程,浏览器用以控制事件循环。当JS引擎执行过程中触发的事件(如点击,请求等)会将对应任务添加到事件线程中,而当对应的事件符合触发条件被触发时会把对应任务添加到处理队列的尾部等到JS引擎空闲时处理;
  • 定时器触发线程:因为JS引擎是单线程容易阻塞,所以需要有单独线程为setTimeout和setInterval计时并触发,同样是符合触发条件(记时完毕)被触发时会把对应任务添加到处理队列的尾部等到JS引擎空闲时处理;W3C标准规定时间间隔低于4ms被算为4ms。
  • 异步http请求线程:XMLHttpRequest在连接后浏览器新开线程去请求,检测到状态变化如果有设置回调函数会产生状态变更事件,然后把对应任务添加到处理队列的尾部等到JS引擎空闲时处理;

好像铺垫的有点多,往外偏了,接下来往回拉一点谈谈这些怎么运行的。

什么是堆(heap)和栈(stack)?

自己画了一个丑丑的图,大家将就看着吧。

function addOne(n) {
  var x = n + 1;
  return addTwo(x);
}

function addTwo(n) {
  return n + 2;
}

console.log(addOne(1)) //4;

以这个例子做说明。
当调用addOne时创建一个包含addOne入参和局部变量的帧并添加进去stack,当调用到addTwo时也同样创建一个包含addTwo入参和局部变量的帧并添加进去在首部,执行完addTwo函数并返回时addTwo帧被移出stack,addOne执行完后addOne帧也被移除。
原理:当执行方法时都会建立自己的内存栈,在这个方法内定义的入参变量都会保存在栈内存里,执行结束后该方法的内存栈也将自然销毁了。

一般来说,程序会划分有两种分配内存的空间 -- 堆(heap)栈(stack)

内存空间 分配方式 结构 大小 存取速度 释放机制
stack 静态分配 随方法执行结束而销毁
heap 动态分配 没有 系统的垃圾回收机制销毁

因为栈只能存放下确定大小的简单数据,所以像变量(其实也就是一个记录了指向复杂结构数据的地址指向,所以变量也是保存在栈里的)和基本类型Undefined、Null、Boolean、Number 和 String等是按值传递的都会保存在栈里,随着方法执行完毕而被销毁。
堆负责存放复杂结构的对象,数组,函数等创建成本较高并且可重用数据,即使方法执行完也不会被销毁,直到系统的垃圾回收机制核实了没有任何引用才会回收。
其实这只是栈的含义之一,Stack的三种含义

有时候我们代码有问题导致栈堆溢出原因大概是这种情况:

常见情况 可能情况
栈溢出 无限递归死循环,递归越深层分配内存越多直至超过限制
堆溢出 循环生成复杂结构数据

好了,现在再看回上图,除了heap和stack之外还有一个。。。

什么是Queue(任务队列)?

Javascript里分两种队列:

  • 宏任务队列(macro tasks):事件循环中可以有多个macro tasks,每次循环只会提取一个,包括script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering等.
  • 微任务队列(micro tasks):事件循环中只有一个并且有优先级区别micro tasks,每次循环会提取多次直至队列清空,包括process.nextTick, Promise, Object.observer, MutationObserver等.
console.log('log start!');
setTimeout(function () {
  console.log('setTimeout300');
}, 300)

Promise.resolve().then(function () {
  console.log('promise resolve');
}).then(function () {
  console.log('promise resolve then');
})

new Promise(function (resolve, reject) {
  console.log('promise pending');
  resolve();
}).then(function () {
  console.log('promise pending then');
})

setTimeout(function () {
  console.log('setTimeout0');
  Promise.resolve().then(function () {
    console.log('promise3 in setTimeout');
  })
}, 0)
console.log('log end!');

// log start!
// promise pending
// log end!
// promise resolve
// promise pending then
// promise resolve then
// setTimeout0
// promise3 in setTimeout
// setTimeout300

例子过程,具体分析下面再说。
第一次执行事件打印:log start!, promise pending, log end!, promise resolve,promise pending then,promise resolve then;
第二次执行事件打印:setTimeout0,promise3 in setTimeout;
第三次执行事件打印:setTimeout300;

下面终于开始走到正题了

事件循环(Event Loop)!

我在上面铺垫了这么多东西,大家大概都能有个初步印象,然后所谓的Event Loop就是把这些东西串联起来的一种机制吧,因为这东西各有理解,比如两位前端大牛之间就有分歧。
阮一峰:JavaScript 运行机制详解:再谈Event Loop
朴灵:朴灵评注
我看过他们很多的博客和书籍,对我帮助都很大,我就用自己的看法讲讲我眼中的Event Loop。

1,所有的任务都被放主线程上运行形成一个 执行栈(execution context stack),其中的方法入参变量保存在栈内存中,复杂结构对象被保存在堆内存中;
2,同步任务直接执行并阻塞后续任务等待结束,其中遇到一些异步任务会新开线程去执行该任务(如上面提到的定时器触发线程,异步http请求线程等)然后往下执行,异步任务执行完返回结果之后就把回调事件加入到 任务队列(Queue)
3,当 执行栈(execution context stack)所有任务执行完之后,会到 任务队列(Queue)里提取所有的 微任务队列(micro tasks)事件执行完;
4,一次循环结束,GUI渲染线程接管检查,重新渲染界面;
5, 执行栈(execution context stack)宏任务队列(macro tasks)提取一个事件到执行,接着主线程就一直重复第3步;


大概理解就这样子,当然可能会有点偏差,欢迎指正!

特殊的定时器

我在上面线程说过

定时器触发线程:因为JS引擎是单线程容易阻塞,所以需要有单独线程为 setTimeoutsetInterval计时并触发,同样是符合触发条件(记时完毕)被触发时会把对应任务添加到处理队列的尾部等到JS引擎空闲时处理;W3C标准规定时间间隔低于4ms被算为4ms。

里面有一些需要特别注意的地方:
1,计时完毕只是把对应任务添加到处理队列,依然要等执行栈空闲才会去提取队列执行,这个概念很重要,切记!即使设置0秒也不会立马执行,因为W3C标准规定时间间隔低于4ms被算为4ms,具体看浏览器,我个人认为不管怎样始终都会被放置到处理队列等待处理;
2,setTimeout重复执行过程中每次时间误差会影响后续执行时间,而setInterval是每次精确时间执行,当然这是指他们把对应任务添加到处理队列的精确性;

但是setInterval也有一些问题:

  • 累计效应,如果执行栈阻塞时间足够长以至于队列中已经存在多个setInterval的对应任务的情况,执行时间会远低于开发者期望的结果;
  • 部分浏览器(如Safari等)滚动过程不执行JS,容易造成卡顿和未知错误;
  • 浏览器最小化显示时setInterval会继续执行,但是对应任务会等到浏览器还原再一瞬间全部执行;

结语

坦白讲,我原本时打算写一篇关于异步编程的文章,然后在铺垫前文的路上拉不回来了就变成了一篇梳理Javascript执行机制了,不过没关系,理解这些也是很重要的

你可能感兴趣的:(javascript)