8

JavaScript单线程机制

JavaScript的一个语言特性(也是这门语言的核心)就是单线程。什么是单线程呢?简单地说就是同一时间只能做一件事,当有多个任务时,只能按照一个顺序一个完成了再执行下一个

为什么JS是单线程的呢?

  • JS最初被设计用在浏览器中,作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM
  • 如果浏览器中的JS是多线程的,会带来很复杂的同步问题
    • 比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
  • 所以为了避免复杂性,JavaScript从诞生起就是单线程

为了提高CPU的利用率,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以这个标准并没有改变JavaScript单线程的本质

任务队列

同步和异步
同步和异步关注的是消息通知机制

  • 同步:发出调用后,没有得到结果之前,该调用不返回,一旦调用返回,就得到返回值了。 简而言之就是调用者主动等待这个调用的结果
  • 异步:调用者在发出调用后这个调用就直接返回了,所以没有返回结果。换句话说当一个异步过程调用发出后,调用者不会立刻得到结果,而是调用发出后,被调用者通过状态、通知或回调函数处理这个调用。

阻塞和非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

单线程意味着同一时间只能进行一件事情,前面的事情结束才能执行后面的事件.当碰到需要时间的IO事件的时候问题就来了,必须等到这些结束后才往下进行,但这时CPU是闲着的.这样浪费了很多计算机的性能.

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去.

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行
(4)主线程不断重复上面的第三步

Event Loop

主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)
图片描述

上图中,主线程运行的时候,产生堆(heap)和栈(stack),堆中可存放对象, 栈中可存放变量,函数,函数指针,代码语句等

栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)
WebAPIs都是单独线程,跟组件中的不一样,不会阻塞主线程执行,比如获取后台数据,若同步就阻塞了,比如HTTP请求又开辟了一个线程

当执行栈中的任务完成后,主线程会去读取事件队列(先进先出),执行相应的回调函数

举个例子,查看以下代码

function read(){
    console.log(1);
    setTimeout(function (){
    console.log(2);
    setTimeout(function (){
    console.log(4)
    });
    });
    setTimeout(function (){
    console.log(5)
    })
    console.log(3);
}
read();
代码执行结果:1 3 2 5 4

先执行同步代码打印1,3,setTimeout异步代码放到事件队列中,先放的先执行,后放的后执行

定时器

"任务队列"可以放置定时事件,即指定某些代码在多少时间之后执行

定时器功能主要由setTimeout()和setInterval()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行,主要以setTimeout举例说明

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

setTimeout(function () {
    console.log(3)
}, 2000);
setTimeout(function () {
    console.log(1);
    setTimeout(function () {
        console.log(2);
    }, 1000);
}, 1000);

执行结果是:1 3 2

setTimeout()将事件放到等待任务队里中,当主任务队列的任务执行完后,再执行等待任务队列,等待任务队里中先返回的先执行

setTimeout()有时候明明写的延时3秒,实际却5,6秒才执行函数,这是怎么回事呢?

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

Promise与process.nextTick(callback)

除了广义的同步任务和异步任务,我们对任务有更精细的定义:

  • macro-task(宏任务):包括整体代码script,setTimeout,setInterval
  • micro-task(微任务):Promise,process.nextTick
- process.nextTick:在事件循环的下一次循环中调用 callback 回调函数。效果是将一个函数推迟到代码书写的下一个同步方法执行完毕时或异步方法的事件回调函数开始执行时;与setTimeout(fn, 0) 函数的功能类似,但它的效率高多了

不同类型的任务会进入对应的Event Queue,比如 setTimeout 和 setInterval 会进入相同的Event Queue

事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

事件循环,宏任务,微任务的关系如下所示:

  • 宏任务=>执行结束=>有可执行的微任务=>执行所有微任务=>开始新的宏任务
  • 宏任务=>执行结束=>没有可执行的微任务=>开始新的宏任务

我们用一段代码说明:

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里面执行

  • 第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中 setTimeout 对应的回调函数,立即执行
  • 结束

我们再看下一段代码说明:

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)
以上代码执行结果:1 2 TIMEOUT FIRED

上面代码中,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行

我们再看下一段代码说明:

function a() {
    setTimeout(function () {
        console.log('a2');
    }, 0);
    process.nextTick(function () {
        console.log('a1')
    });
}
function b() {
    process.nextTick(function () {
        console.log('b1');
    })
}
a();
b();

一个函数执行会形成一个执行栈,任务队列里的回调函数每次只取一个,它执行的时候会形成一个执行栈,当你第一次运行这个脚本的时候,这个脚本的里所有的同步代码都会在一个执行栈里

  • a的执行和b的执行在一个执行栈里,它们共同在第一个宏任务中
  • a执行时候,会把a2放入宏任务队列,把a1放入微任务队列。
  • b执行的时候,把b1放入微任务队列
  • -------------------第一个宏任务执行完毕-------------------------
  • 宏任务执行完毕后会把微任务队列清空,也就是把a1 和b1都执行,输出a1和b1
  • -------------------第一个微任务队列清空--------------------------
  • 然后从宏任务队列中取出下一个宏任务,也就是a2执行.输出a2

为什么一个宏任务要搭配处理一个微任务
因为这样最合理,微任务就是在有空时需要立即执行的任务,宏任务相比微任务可以滞后执行。他们虽然都属于异步任务,但是通过这种优先级的设置达到了控制异步回调执行顺序的目的。值得注意的是:同步代码执行完会先清空微任务,然后取出宏任务队列里的第一个事件对应的回调到执行栈执行,然后再清空一次微任务,如此循环...

通过以上三段代码,您是否对JS的执行顺序有所了解呢

我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制

console.log('main1');
setTimeout(function () {
    console.log('setTimeout');
    process.nextTick(function () {
        console.log('process.nextTick2');
    });
}, 0);
new Promise(function (resolve, reject) {
    console.log('promise');
    resolve();
}).then(function () {
    console.log('promise then');
});
process.nextTick(function () {
    console.log('process.nextTick1');
});
console.log('main2');

以上代码的执行结果是:main1=>promise=>main2=>process.nextTick1=>promise then=>setTimeout=>process.nextTick2

  • 系统启动执行脚本,这个脚本就是一个宏任务,执行代码块中所有的同步代码,输出main1
  • next1放入微任务,setTimeout+nextTick2(下一轮)放入宏任务队列
  • promise构造函数部分是同步的,立刻执行输出promise,promise then放入微任务
  • 下面同步代码输出main2
  • 接下来执行微任务输出nextTick1,promise then
  • 接下来执行宏任务输出setTimeout,将nexttick2放入微任务队列
  • 接下来执行微任务nexttick2
  • nextTick是由node自己定义并实现的概念,它的回调调用入口在event loop过程中MakeCallback函数的末尾,驱动调用清空js层的queue,最后再执行microtasks,适当处理下可能触发的promise,明显 process.nextTick1> promise.then

October
70 声望14 粉丝