细谈Event Loop
前段时间对JavaScript的 Event Loop (事件循环机制)有些感兴趣,就去查阅了很多关于这方面的文章,感觉受益匪浅,以下是笔者个人对 Event Loop 的见解;
首先,我们得知道 JavaScript 为什么是单线程而不是多线程?
Brendan Eich当初在发明 JavaScript
时为什么不把 JavaScript
设置为多线程开发呢,而偏偏要选择单线程呢,多线程还可以提高效率,单线程的话,同一时间只能做一件事情,会不会有点不合理
其实,Brendan Eich当初把 JS
设置为单线程是有原因的,和它的用途有着密切的关系, JS
的主要用途就是与用户互动以及操作 DOM
.导致它生而为单线程,加入它有两个线程的话,一个线程负责在 DOM
节点上添加内容,一个赋值删除这个节点,这样会导致冲突,带来的是更为复杂的同步问题,所以js只能是单线程,也只能是单线程,不过说 JS
是单线程可以是可以,不过 也可以支持多线程,那就是 NodeJs
,在 V10.5.0的 NodeJs
新增了多线程, NodeJs
中可以用 cluster
模块实现多进程,cluster
中 fork()
可以从主进程分裂出子进程.这篇文章主要以单线程为主,暂时就不演示 NodeJs
的多线程操作了.
所以JS
为了避免复杂性,从一诞生就是单线程,所以 js
的单线程已成为这门语言的核心特征了
为了利用充分 CPU
的计算能力, HTML5
提出了 Web Worker
标准,允许脚本创建多个线程,但是子线程完全受子线程控制,且不得操作DOM
,所以,这个标准并没有改变 JS
单线程的本质,既然提到了这一标准,笔直就来演示一下代码操作吧.
- index.js
let woker = new Worker("worker.js");//创建对象
worker.postMessage("worker.js收到请回答");//发送数据
worker.onmessage = function (e){//接收并处理数据
let res = e.data;//e.data就是worker.js文件发送给index.js的数据
alert(res);
}
- worker.js
self.onmessage = function(e){
let res = e.data;
self.postMessage("over!over!已收到");
}
以上就是简单的代码演示.
任务队列
我们继续回到单线程的问题上来,既然是单线程,这就意味着所有任务都需要在这个线程上排队,只有前一个任务结束才会执行下一个任务,如果前一个任务耗时比较长,例如网络请求,再加上还在2G时代的话,又或者是 I/O
操作的话,那岂不是要一直等到当前任务执行完毕才可以执行下一个任务了吗?假如排队的原因是因为任务的业务逻辑过于复杂/数据处理的数据较为庞大,导致 CPU
忙不过来倒是可以谅解,关键是很多时候 CPU
是空闲的,因为 IO
设备很慢,不得不等待起结束后才可以执行下一个任务,设计者在设计之初也意识到这个问题,认为这种情况下完全可以不管IO设备,先把它挂起,运行后面的任务.等其返回了结果,再把挂起的任务继续执行下去.
于是在 JS
的单线程上存在这两种任务,分别是 同步任务(Synchronous)和 异步任务 (Asynchronous)
任务类别 | 内容 |
---|---|
同步任务 | 在主线程上排队执行的任务,只有前一个任务执行完毕才会执行下一个任务 |
异步任务 | 不加入主线程,而是进入 任务队列(task queue)的任务,只有任务队列告知主线程当前队列的某个任务可以执行了,该任务才会进入主线程执行 |
更为具体来说
所有
同步任务
都在主线程
上执行,形成一个执行栈
栈:先进后出/后进先出
主线程
外还有一个任务队列
,只要异步任务
有了运行结果,就会在任务队列
中放置一个事件只有执行栈中的所有同步任务全部执行完毕,系统才会读取任务队列,看看里边有哪一些事件,然后对应的异步任务就会结束等待状态,进入执行栈,开始执行
主线程会不断执行第三步
事件与回调函数
-
任务队列
就是一个事件的队列,每当有异步任务有了运行结果,就会在任务队列
中放置一个与之对应的事件,这个事件就表示放置这个事件的异步任务可以进入执行栈
了,主线程读取任务队列就是读取里面有哪一些事件 -
任务队列
中的事件其实可不止IO
设备的事件,还包括一些用户产生的事件(比如页面点击
,键盘操作
等事件).主要指定过回调函数
,这些事件发生时就会进入任务队列
,等待主线程读取,然后进入执行栈
执行.
所谓的回调函数,就是那一些被主线程挂起来的代码,异步任务必须指定回调函数,但主线程执行异步任务,就是执行这些异步任务对应的回调函数.
-任务队列
是一个先进后出的数据结构,所以排在前面的事件会被主线程优先读取,主线程的读取基本是自动的,主要执行栈一清空,任务队列
里的第一个事件就会进入主线程的,然后在执行栈中执行与第一个事件相对应的异步任务,但是,JS
中还存在着定时器,所以主线程首先要检查下执行的时间,因为有些事件只有到了规定的时间才能返回到主线程的
Event Loop
因为之前提到过,主线程读取任务队列中的事件这一过程是循环不断的,所以这种机制称之为 Event Loop(事件循环机制)
当主线程运行的时候,会产生堆(Heap)和栈(Stack),栈中的代码会调用各种Web API,让他们在任务队列
中加入各种事件(Click
,Load
,Error等
),只要栈中的代码执行完毕,主线程就会去读取任务队列
中那些事件的回调函数,执行栈中的代码(同步任务
)总是在读取任务队列(异步任务)
前执行的,说这么多有点枯燥,举个栗子
//在这里就不考虑XHRHttpRequest的兼容性了
let xhr = new XHRHttpRequest();
xhr.open("get",url)
xhr.onLoad = function(){}
xhr.send()
因为上述代码的send()方法是Ajax往服务器发送数据,所以它属于异步任务,所以也可以有下面这种写法
let xhr = new XHRHttpRequest();
xhr.open("get",url)
xhr.send()
xhr.onLoad = function(){}
也就是说,指定函数的部分onload,在send()方法的前后都无所谓,因为它们属于执行栈的一部分,系统总是执行完它们才会去读取任务队列
ps:以上很绕,要好好理解,笔者我也是搞了很久才有点懂Emmm
定时器
除了放置异步任务
的事件,任务队列
还可以放置定时事件,即指定某些代码在什么时候执行,这叫做"定时器"(timer)功能,就是定时执行的代码
定时器主要分为两种,一种是setTimeout()
和setInterval()
,它们的内部的运行机制是完全一样的,唯一不同的就是前者内部的回调函数是一次性执行的,而后者是周期新反复执行的
setTimeout()普遍接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数,第三个以及第三个后的参数为回调函数的参数
console.log(1);
setTimeout(() => console.log(2),1000);
console.log(3)
[ 结果是 1,3,2 ] 可能有人认为它是因为推迟 1s 才执行的原因导致的
那么我们把推迟的时间调成 0ms 试试看
console.log(1);
setTimeout(() => console.log(2),0);
console.log(3)
[ 结果是 1,3,2 ] 这是为什么呢?
因为setTimeout内部的回调函数属于异步任务,所以执行栈会先把
同步任务
先执行完毕后,才会去执行任务队列
的回调函数
HTML5标准规定setTimerout
的第二个参数即延迟时间的最小值不得低于 4ms ,如果设置的延迟时间低于4ms,就会自动增加. 在此之前,老版本的浏览器都将最短时间设置为 10ms,此外,对于那些DOM操作导致页面重新渲染的部分,通常不会立即执行的,而是每 16ms执行一次,所以同样在操作DOM制作动画的情况下,使用requestAnimationFrame()
(请求动画帧)的效果好于用定时器. 另外还有一点就是定时器只是将回调函数插入到任务队列,所以必须要等到当前代码即 执行栈
执行完毕,主线程才会去执行他的回调函数,要是当前代码执行耗时过于长,有可能会等待很久才可以执行,所以不能保证定时器内部的回调函数一定会在指定的时间执行