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
事件监听XMLHttpRequest
setTimeout
&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
末尾)
通过各种猜想和实验,我得到了上面的结果
我不确认自己的想法是对的,所以这篇博客先放在这里,等我对源码有了更深的理解在回来证实和修改