【js重学系列】执行机制
js异步
学习js开发,无论是前端开发还是node.js,都避免不了要接触异步编程这个问题,就和其它大多数以多线程同步为主的编程语言不同,js的主要设计是单线程异步模型。正因为js天生的与众不同,才使得它拥有一种独特的魅力,也给学习者带来了很多探索的道路。本文就从js的最初设计开始,整理一下js异步编程的发展历程。
什么是异步
在研究js异步之前,先弄清楚异步是什么。异步是和同步相对的概念,同步,指的是一个调用发起后要等待结果返回,返回时候必须拿到返回结果。而异步的调用,发起之后直接返回,返回的时候还没有结果,也不用等待结果,而调用结果是产生结果后通过被调用者通知调用者来传递的。
举个例子,A想找C,但是不知道C的电话号码,但是他有B的电话号码,于是A给B打电话询问C的电话号码,B需要查找才能知道C的电话号码,之后会出现两种场景看下面两个场景:
A不挂电话,等到B找到号码之后直接告诉A
A挂电话,B找到后再给A打电话告诉A
能感受到这两种情况是不同的吧,前一种就是同步,后一种就是异步。
为什么是异步的
先来看js的诞生,JavaScript诞生于1995年,由Brendan Eich设计,最早是在Netscape公司的浏览器上实现,用来实现在浏览器中处理简单的表单验证等用户交互。至于后来提交到ECMA,形成规范,种种历史不是这篇文章的重点,提到这些就是想说一点,js的最初设计就是为了浏览器的GUI交互。对于图形化界面处理,引入多线程势必会带来各种各样的同步问题,因此浏览器中的js被设计成单线程,还是很容易理解的。但是单线程有一个问题:一旦这个唯一的线程被阻塞就没办法工作了--这肯定是不行的。由于异步编程可以实现“非阻塞”的调用效果,引入异步编程自然就是顺理成章的事情了。
现在,js的运行环境不限于浏览器,还有node.js,node.js设计的最初想法就是设计一个完全由事件驱动,非阻塞式IO实现的服务器运行环境,因为网络IO请求是一个非常大的性能瓶颈,前期使用其他编程语言都失败了,就是因为人们固有的同步编程思想,人们更倾向于使用同步设计的API。而js由于最初设计就是全异步的,人们不会有很多不适应,加上V8高性能引擎的出现,才造就了node.js技术的产生。node.js擅长处理IO密集型业务,就得益于事件驱动,非阻塞IO的设计,而这一切都与异步编程密不可分。
线程和进程
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源
一个进程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行。
相对进程而言,线程是一个更加接近于执行体的概念,它可以与同进程中的其他线程共享数据,但拥有自己的栈空间,拥有独立的执行序列。
进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
-
简而言之,一个程序至少有一个进程,一个进程至少有一个线程.
-
线程的划分尺度小于进程,使得多线程程序的并发性高。
-
另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
-
线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
-
从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。
引入同步和异步
Javascript语言的执行环境是"单线程"(single thread,就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推)。
这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。
“同步模式" 就是上一段的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;"异步模式"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
“异步模式" 非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。
js异步原理
栈 堆 队列 事件循环
这个队列就是异步队列,它是处理异步事件的核心,整个js调用时候,同步任务和其他编程语言一样,在栈中调用,一旦遇上异步任务,不立刻执行,直接把它放到异步队列里面,这样就形成了两种不同的任务。由于主线程中没有阻塞,很快就完成,栈中任务边空之后,就会有一个事件循环,把队列里面的任务一个一个取出来执行。只要主线程空闲,异步队列有任务,事件循环就会从队列中取出任务执行。
说的比较简单,js执行引擎设计比这复杂的多得多,但是在js的异步实现原理中,事件循环和异步队列是核心的内容。
异步编程实现
回调函数(callback)
A callback is a function that is passed as an argument to another function and is executed after its parent function has completed.
翻译:回调是一个函数被作为一个参数传递到另一个函数里,在那个函数执行完后再执行。( 也即:B函数被作为参数传递到A函数里,在A函数执行完后再执行B )
callback方式利用了函数式编程的特点,把要执行的函数作为参数传入,由被调用者控制执行时机,确保能够拿到正确的结果。这种方式初看可能会有点难懂,但是熟悉函数式编程其实很简单,很好地解决了最基本的异步问题,早期异步编程只能通过这种方式。
然而这种方式会有一个致命的问题,在实际开发中,模型总不会这样简单,下面的场景是常有的事:
fun1(data => {
// ...
fun2(data, result => {
// ...
fun3(result, () => {
// ...
});
});
});
整个随着系统越来越复杂,整个回调函数的层次会逐渐加深,里面再加上复杂的逻辑,代码编写维护都将变得十分困难,可读性几乎没有。这被称为毁掉地狱,一度困扰着开发者,甚至是曾经异步编程最为人诟病的地方
promise
使用回调函数来编程很简单,但是回调地狱实在是太可怕了,嵌套层级足够深之后绝对是维护的噩梦,而promise的出现就是解决这一问题的。promise是按照规范实现的一个对象,ES6提供了原生的实现,早期的三方实现也有很多。在此不会去讨论promise规范和实现原理,重点来看promise是如何解决异步编程的问题的。
Promise对象代表一个未完成、但预计将来会完成的操作,有三种状态:
pending:初始值,不是fulfilled,也不是rejected
resolved(也叫fulfilled):代表操作成功
rejected:代表操作失败
整个promise的状态只支持两种转换:从pending转变为resolved,或从pending转变为rejected,一旦转化发生就会保持这种状态,不可以再发生变化,状态发生变化后会触发then方法。这里比较抽象,我们直接来改造上面的例子:
new Promise((reso, rej) => {
setTimeout(() => {
reso('hello')
}, 2000)
}).then(res => {
console.log(res);
return new Promise(reso => {
setTimeout(() => {
reso('world')
}, 2000)
})
}).then(res => {
console.log(res);
})
Promise是一个构造函数,它创建一个promise对象,接收一个回调函数作为参数,而回调函数又接收两个函数做参数,分别代表promise的两种状态转化。resolve回调会使promise由pending转变为resolved,而reject 回调会使promise由pending转变为rejected。
当promise变为resolved时候,then方法就会被触发,在里面可以获取到resolve的内容,then方法。而一旦promise变为rejected,就会产生一个error。无论是resolve还是reject,都会返回一个新的Promise实例,返回值将作为参数传入这个新Promise的resolve函数,这样就可以实现链式调用,对于错误的处理,系统提供了catch方法,错误会一直向后传递,总是能被下一个catch捕获。用promise可以有效地避免回调嵌套的问题,代码会变成下面的样子:
fun1().then(data => {
// ...
return fun2(data);
}).then(result => {
// ...
return fun3(result);
}).then(() => {
// ...
});
generator
async,await
总结
- js的异步:我们从最开头就说javascript是一门单线程语言,不管是什么新框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的,牢牢把握住单线程这点非常重要
- javascript是一门单线程语言,在最新的HTML5中提出了Web-Worker,但javascript是单线程这一核心仍未改变。所以一切javascript版的"多线程"都是用单线程模拟出来的,一切javascript多线程都是纸老虎!
- 事件循环Event Loop:事件循环是js实现异步的一种方法,也是js的执行机制。
- javascript的执行和运行:执行和运行有很大的区别,javascript在不同的环境下,比如node,浏览器,Ringo等等,执行方式是不同的。而运行大多指javascript解析引擎,是统一的。
- setImmediate:微任务和宏任务还有很多种类,比如
setImmediate
等等,执行都是有共同点的,有兴趣的同学可以自行了解。 - 最后的最后:牢牢把握两个基本点,以认真学习javascript为中心,早日实现成为前端高手的伟大梦想!
- javascript是一门单线程语言
- Event Loop是javascript的执行机制
JS为什么是单线程的
- 最初设计JS是用来在浏览器验证表单操控DOM元素的是一门脚本语言,如果js是多线程的,那么两个线程同时对一个DOM元素进行了相互冲突的操作,那么浏览器的解析器是无法执行的。
- 单线程就是同一个时间只能做一件事。多线程就是同一个时间可以做很多事情。
- JavaScript是单线程的。举个很简单的例子你就明白了,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,那浏览器要怎么显示,是不是乱套了。所以JavaScript只能是单线程的。
- 也许会有人说再HTML5中可以用
new Worker(xxx.js)
在JavaScript中创建多个线程。但是子线程完全受主线程控制,且不得操作DOM。所以JavaScript还是单线程的。在最新的HTML5中提出了Web-Worker,但javascript是单线程这一核心仍未改变。所以一切javascript版的"多线程"都是用单线程模拟出来的。
javascript的同步和异步
- 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
- 如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
- JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
- 于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行
- 怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数
js为什么需要异步
- 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。这就是JavaScript中的同步任务。
- 如果js中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。 对于用户而言,阻塞就以为着“卡死”,这样就导致了很差的用户体验。比如在进行ajax请求的时候如果没有返回数据后面的代码就没办法执行
- 但是同步任务有个很大的缺点,如果前一个任务执行了很长时间还没结束,那下一个任务就不能执行,举个简单的例子,页面某个区域渲染过程中需要用Ajax去请求数据,如这个请求很长时间都请求不到数据,那下个任务就不能执行,也就说页面其他区域不能渲染。于是就有了JavaScript异步任务来解决这个缺点。
- 异步任务可以单独执行,不要等前一个任务结束后再执行。但是异步任务执行结束后就会在那边等待,直到线程里面没有任务了。才会喊异步任务的回调函数过来执行。
js单线程又是如何实现异步的呢
- js中的异步以及多线程都可以理解成为一种“假象”,就拿h5的WebWorker来说,子线程有诸多限制,不能控制DOM,不能修改全局对象等等,通常只用来做计算做数据处理。
- 这些限制并没有违背我们之前的观点,所以说是“假象”。JS异步的执行机制其实就是事件循环(eventloop),理解了eventloop机制,就理解了js异步的执行机制。
JS的事件循环(eventloop)(运行机制)是怎么运作的
- 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入事件列表(Event Table)并注册回调函数。
- 当满足触发条件后,(触发条件可能是延时也可能是ajax回调),会将这个回调函数添加事件队列(Event Queue)。
- 主线程内的任务执行完毕后,会去事件队列(Event Queue)中询问有没有要执行的任务,如果有,那就按先添加先执行的顺序进入任务执行栈,然后按之前步骤继续执行。
- 上述过程会不断重复,也就是常说的事件循环(Event Loop)。
JavaScript中的宏任务和微任务
-
宏任务(macro-task):整体代码script、setTimeout、setInterval、setImmediate
-
微任务(micro-task):Promise、process.nextTick
-
宏任务和微任务是对JavaScript异步任务再次细分。异步事件队列分为宏任务事件队列和微任务事件队列。
4.
-
同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入事件列表并注册回调函数。
当异步任务执行结束后,判断该异步任务是宏任务还是微任务,将宏任务的回调函数添加宏任务事件队列,将微任务的回调函数添加到微任务事件队列。
主线程内的任务执行完毕后。
- 先去微任务事件队列中询问有没有要执行的任务,如果有,那就按先添加先执行的顺序进入任务执行栈。
- 如果没有,再去宏任务事件队列中询问有没有要执行的任务。如果有,那就按先添加先执行的顺序进入任务执行栈。
- 如果没有,那任务都执行完毕。
- 上述过程会不断重复,也就是常说的事件循环(Event Loop)。
代码练习:
-
/* 以下这段代码的执行结果是什么? 如果依照:js是按照语句出现的顺序执行这个理念, 那么代码执行的结果应该是: //"定时器开始啦" //"马上执行for循环啦" //"执行then函数啦" //"代码执行结束" 但结果并不是这样的,得到的结果是: //"马上执行for循环啦" //"代码执行结束" //"执行then函数啦" //"定时器开始啦" */ setTimeout(function(){ console.log('定时器开始啦') }); new Promise(function(resolve){ console.log('马上执行for循环啦'); for(var i = 0; i < 10000; i++){ i == 99 && resolve(); } }).then(function(){ console.log('执行then函数啦') }); console.log('代码执行结束');
-
let data = []; $.ajax({ url:www.javascript.com, data:data, success:() => { console.log('发送成功!'); } }) console.log('代码执行结束'); 复制代码 上面是一段简易的`ajax`请求代码: //- ajax进入Event Table,注册回调函数`success`。 //- 执行`console.log('代码执行结束')`。 //- ajax事件完成,回调函数`success`进入Event Queue。 //- 主线程从Event Queue读取回调函数`success`并执行。
-
setTimeout(function() { console.log('setTimeout'); }) new Promise(function(resolve) { console.log('promise'); }).then(function() { console.log('then'); }) console.log('console'); //这段代码作为宏任务,进入主线程。 //先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述) //接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue。 //遇到console.log(),立即执行。 //好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。 //ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。 //结束。
-
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5') }) }) process.nextTick(function() { console.log('6'); }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8') }) setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12') }) }) // 第一轮事件循环流程分析如下: // 整体script作为第一个宏任务进入主线程, // 遇到console.log,输出1。 // 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1。 // 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1。 // 遇到Promise,new Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1。 // 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2。 // 宏任务Event Queue 微任务Event Queue // setTimeout1 process1 // setTimeout2 then1 // 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。 // 我们发现了process1和then1两个微任务。 // 执行process1,输出6。 // 执行then1,输出8。 // 好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮事件循环从setTimeout1宏任务开始: // 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2。new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2。 // 宏任务Event Queue 微任务Event Queue // setTimeout2 process2 // then2 // 第二轮事件循环宏任务结束,我们发现有process2和then2两个微任务可以执行。 // 输出3。 // 输出5。 // 第二轮事件循环结束,第二轮输出2,4,3,5。 // 第三轮事件循环开始,此时只剩setTimeout2了,执行。 // 直接输出9。 // 将process.nextTick()分发到微任务Event Queue中。记为process3。 // 直接执行new Promise,输出11。 // 将then分发到微任务Event Queue中,记为then3。 // 宏任务Event Queue 微任务Event Queue // process3 // then3 // 第三轮事件循环宏任务执行结束,执行两个微任务process3和then3。 // 输出10。 // 输出12。 // 第三轮事件循环结束,第三轮输出9,11,10,12。 // 整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。 // (请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)