Javascript异步探讨
在图形用户界面编程中(也被称为GUI编程),用单一线程进行GUI控制是通常的做法
如果使用多线程来进行GUI编程,例如,一个线程修改某个UI模块的属性,另一个线程直接删除这个UI模块,那么UI的表现将很难被预测
Javascript运行的环境是单线程的:每一个window或者一个node.js程序只有一个线程
因此,在JS程序正在执行的某个时间,只有特定的某个语句正在执行(此时其它的语句会被阻塞)
我个人很喜欢知乎上面@云澹的回答
运行在浏览器中,是单线程的,每个window一个JS线程,既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。而浏览器是事件驱动的(Event driven),浏览器中很多行为是异步(Asynchronized)的,会创建事件并放入执行队列中。javascript引擎是单线程处理它的任务队列。所以当多个事件触发时,会依次放入队列,然后一个一个响应。
单线程
在Javascript的主线程中,我们有一个堆(heap)和栈(stack)的结构:

其中:
- 堆的结构负责内存的分配
- 栈的结构负责线程中要执行的内容,被称为
访问栈(call stack)
我们可以看看访问栈的实现:
1 | function foo(a) { |
在执行的过程中,我们的访问栈发生以下的变化:
- 主线程进栈
printDouble进栈bar进栈foo进栈foo出栈bar出栈console.log进栈console.log出栈printDouble出栈- 主线程出栈
如果我们在foo函数中发生了错误,我们会得到报错信息:
1 | Uncaught Error: 发生了错误(…) VM1436:1 |
我们得到从上到下的结果就是所谓的访问栈
当我们得到了RangeError: maximum call stack size exceeded的报错信息时,就代表我们的访问栈的大小不够了,去检查你的程序中有没有进行无限循环调用的语句吧 : )
同步与异步
因为Javascript是单线程,一个Javascript程序只有一个访问栈,函数语句按照进入的顺序一个接一个地被执行,前一个结束,才轮到后一个开始
然而有的时候,某些函数语句的主要时间消耗并不在CPU上,而是在输入输出设备(包括网络)上面,比如Ajax,这样的任务消耗了很多时间,从而阻塞了后续任务的执行,同时也浪费了CPU的资源
单线程任务同步执行的优势就是清晰易于理解,其缺陷就是经常会出现这种情况
为了解决这个问题,我们把要执行的任务分为两种:同步(synchronous)和异步(asynchronous)
同步任务就是一般情况下的语句 —— 只有执行完毕之后才会执行后面的语句异步任务则是特殊的语句 —— 先执行特定的语句引发异步操作:调用相关接口,然后等待其他的部分的运转,等时机成熟再执行与运转结果相关的事件
比如我们比较一下采用同步和异步的方式来执行Ajax:
- 同步执行时,我们调用
Ajax的接口,根据地址发起请求,直到响应返回,才执行接下来的任务 - 异步执行时,我们调用
Ajax的接口请求相应地址,然后执行接下来的任务,当响应返回时,特定的函数语句会被加入到访问栈
更加明显的例子当我们使用node.js时,我们会发现很多函数会有同步异步两个版本,比如readFile和readFileSync
- 当你使用
readFile时,最后一个参数为回调函数,响应返回时会被异步执行 - 当你使用
readFileSync时,下一个语句一定是在readFileSync的文件操作结束返回结果后进行的
异步任务
以下几类任务被当做异步任务:
DOM事件监听XMLHttpRequestsetTimeout&setInterval
注意,DOM事件监听 – addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数,事件触发时,表示异步任务完成,会将事件监听器函数封装成一个任务放到任务队列中,等待主线程执行,事件冒泡时,多条任务按照事件冒泡的顺序进入任务队列
而onclick,onchange等的handler函数也是异步执行的,同理
异步任务伴随着回调函数 —— 在任务时机成熟时执行的函数,回调函数是异步调用,也就是所谓事件驱动的
任务队列
Javascript线程中有一个任务队列:保存着等待执行的异步任务的队列
观察一下所谓的事件队列的模型:

我们举个关于setTimeout的例子:
1 | setTimeout(function(){ |
- 主线程进栈
setTimeout进栈- 调用
setTimeout的API,设置一个定时器,5秒钟之后回调函数加入任务队列 setTimeout出栈console.log('end!')进栈console.log('end!')出栈- 主线程出栈
- 事件轮询,任务队列中的匿名函数进栈
console.log('Timeout!')进栈console.log('Timeout!')出栈- 匿名函数出栈
每当访问栈空的时候,栈会执行事件轮询操作,从任务队列里面读取任务并执行
所以,异步过程的回调函数,一定不在当前这一轮事件循环中执行
单线程和异步
所以像setTimeout这样的异步任务不一定会准确执行
我们先看一下回答中的代码段:
1 | function foo() { |
按照我们正常的思维,我们执行1000000次foo函数,每执行一次都会设置一个五毫秒的定时器,然后console.log出"second",但是我们发现控制台在几秒内一直在重复弹出"first"(这段时间远大于五毫秒),并没有second在这段时间弹出,直到所有first全部弹出完毕
现在我们能理解这个问题了,只有访问栈里面的任务全部推出之后,任务队列里面的回调函数才会开始执行,所以在first没有完成输出之前,异步调用的second不会执行
我们同时可以理解foo()和setTimeout(foo,0)的具体区别了
在React出现的一点点问题
在React中,setState函数是异步执行的,如果你在Component的方法里面先调用setState再调用this.state,你会发现后面this.state得到的值与前面setState设置的值不一样,得到的仍然是setState之前的值
看完前面的分析,我想这并不那么难以理解了:setState会进入任务队列,直到访问栈被清空,才会执行事件轮询,从而执行任务队列中的任务
我在React的开发中遇到过如下的问题:
这个问题讲述了同步/异步调用setState的区别
这个jsbin上面存在相关的实例:
Setting value代表render时State里面的内容
Current value代表render时,用getDOMNode得到的DOM里面的属性
我把 @FakeRainBrigand 的答案 以及自己的理解,进行了结合,得到了下面的过程
同步调用:
- 输入
X到input标签里面 input.value不变,触发onchange事件- 执行事件处理函数
onchange - 执行
setState({value: 'HelXlo'}) - 进行虚拟
DOM的Diff操作,识别出input.value应该是'HelXlo',将DOM操作加入任务队列 onchange任务结束,将render加入任务队列- 执行
事件轮询,依次执行DOM操作与render
DOM操作将input.value改为当前state的值为'HelXlo'render,此时state里面value和DOM里面input.value都是'HelXlo'
异步调用:
- 输入
X到input标签里面 input.value不变,触发onchange事件- 执行事件处理函数
onchange,将setState的定时器加入任务队列 onchange任务结束,将render加入任务队列- 进行
事件轮询,执行了任务队列中的定时器,执行setState - 执行
setState({value: 'HelXlo'}) - 进行虚拟
DOM的Diff操作,识别出input.value应该是’Hello’,将DOM操作加入任务队列` render,此时state里面value为'HelXlo',但DOM上input.value为'Hello'- 执行
任务队列后面的任务,进行DOM操作,改变input.value为'HelXlo'(此时DOM更新令光标被置于input末尾)
通过各种猜想和实验,我得到了上面的结果
我不确认自己的想法是对的,所以这篇博客先放在这里,等我对源码有了更深的理解在回来证实和修改
