Zerlinda's Blog

浅谈JavaScript运行机制Event Loop

众所周知,Javascript语言的执行环境是"单线程"(single thread)。单线程意味着,所有任务需要“排队”,前一个任务结束了,后一个任务才会开始执行。

那么JavaScript为什么要单线程呢?

作为浏览器脚本语言,JavaScript的主要用途是与用户互动以及操作DOM。不妨假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器就不知道以哪个线程为准。因此JavaScript只能是单线程,这已成为JavaScript的核心特征,将来也不会改变。

如前面所讲,单线程的好处是实现起来比较简单;坏处是只要有一个任务很耗时,比如涉及很多I/O(输入/输出)操作的任务,由于I/O操作很慢,单线程的大部分运行时间都在空等I/O操作的返回结果,这样导致后面的任务都必须一直排队等待执行。

这样实在是浪费很多时间。这时候的JavaScript线程完全可以不去管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再去执行刚刚挂起的任务,这样就会避免线程因为空等IO设备返回结果而浪费时间了。那么问题来了,js线程怎么才能知道何时去执行挂起的任务呢?

Event Loop

"Event Loop是一个程序结构,用于等待和发送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)"

简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")。

同时Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。

  1. 同步任务是指在主线程上排队执行的任务,只有在前一个任务执行完毕,才能执行后一个任务;
  2. 异步任务则完全不同,它不进入主线程、而进入"消息线程",只有在"消息线程"通知主线程,这个异步任务可以执行了,该任务才会进入主线程执行。

具体来说,Event Loop的运行机制如下。

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个"消息线程"。只要异步任务有了运行结果,就在"消息线程"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"消息线程",看看里面有哪些事件。于是对应的异步任务结束等待状态,进入执行栈开始执行。
  4. 主线程不断重复上面的第三步。

只要主线程空了,就会去读取"消息线程",这就是JavaScript的运行机制。这个过程会不断重复。所以整个的这种运行机制又称为Event Loop(事件循环)。

异步任务事件和回调

"消息线程"是一个事件的队列,每当异步任务有了结果,就在队列中添加一个事件,我们姑且称之为异步任务事件。添加完异步任务事件表示相关的异步任务可以进入"执行栈"了。主线程读取"消息线程",就是读取里面有哪些事件。

"消息线程"中的异步任务事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)以及定时器(定时器比较特殊,后面再讲)。只要指定过回调函数,这些事件发生时就会进入"消息线程",等待主线程读取。

回调函数(callback)是指那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

需要指出,"消息线程"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取,类似于主线程上的同步事件。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程,下面的图比较直观:

主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API, 同时"消息线程"中加入各种事件(click,load,done)。执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。只要栈中的代码执行完毕,主线程就会去读取"消息线程",依次执行那些事件所对应的回调函数(包括各种API)。

定时器setTimeout

上文提到除了异步任务的事件,"消息线程"还可以放置定时事件。

定时器功能主要由setTimeout()和setInterval()这两个函数来完成,现在主要讨论setTimeout()。

setTimeout()接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。

setTimeout(function(){console.log(1);}, 0);
console.log(2);

上面代码的执行结果总是2,1,因为定时器是处于消息线程中的任务,只有在执行完主线程的任务,系统才会去执行"消息线程"中的回调函数。

另外,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"消息线程"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行,可以说是优先级最低。

需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。

最后看下面一段代码:

 function sleep(duration) {
    var endDate = new Date().getTime() + duration;
    while (new Date().getTime() < endDate) {
    }
  }
  setTimeout(function(){
    console.log("setTimeout 0");
  }, 0);
  setTimeout(function(){
    console.log("delayed1");
  }, 4000);
  console.log("start sleep");
  sleep(3000);
  console.log("sleep 3000ms");
  setTimeout(function(){
    console.log("delayed2");
  }, 1000);
  document.body.onload = function(){
    console.log("load");
  }
//start sleep
  //sleep 3000ms   
  //load           
  //setTimeout 0
  //delayed1       
  //delayed2
考虑一下上面代码的执行顺序是什么?

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注