Eventloop不可怕,可怕的是遇上Promise

小美娜娜發表於2019-03-27

有關Eventloop+Promise的面試題大約分以下幾個版本——得心應手版、遊刃有餘版、爐火純青版、登峰造極版和究極變態版。假設小夥伴們戰到最後一題,以後遇到此類問題,都是所向披靡。當然如果面試官們還能想出更變態的版本,算我輸。

版本一:得心應手版

考點:eventloop中的執行順序,巨集任務微任務的區別。

吐槽:這個不懂,沒得救了,回家重新學習吧。

setTimeout(()=>{
   console.log(1) 
},0)
Promise.resolve().then(()=>{
   console.log(2) 
})
console.log(3) 
複製程式碼

這個版本的面試官們就特別友善,僅僅考你一個概念理解,瞭解巨集任務(marcotask)微任務(microtask),這題就是送分題。

筆者答案:這個是屬於Eventloop的問題。main script執行結束後,會有微任務佇列和巨集任務佇列。微任務先執行,之後是巨集任務。

PS:概念問題

有時候會有版本是巨集任務>微任務>巨集任務,在這裡筆者需要講清楚一個概念,以免混淆。這裡有個main script的概念,就是一開始執行的程式碼(程式碼總要有開始執行的時候對吧,不然巨集任務和微任務的佇列哪裡來的),這裡被定義為了巨集任務(筆者喜歡將main script的概念單獨拎出來,不和兩個任務佇列混在一起),然後根據main script中產生的微任務佇列和巨集任務佇列,分別清空,這個時候是先清空微任務的佇列,再去清空巨集任務的佇列。

版本二:遊刃有餘版

這一個版本,面試官們為了考驗一下對於Promise的理解,會給題目加點料:

考點:Promise的executor以及then的執行方式

吐槽:這是個小坑,promise掌握的熟練的,這就是人生的小插曲。

setTimeout(()=>{
   console.log(1) 
},0)
let a=new Promise((resolve)=>{
    console.log(2)
    resolve()
}).then(()=>{
   console.log(3) 
}).then(()=>{
   console.log(4) 
})
console.log(5) 
複製程式碼

此題看似在考Eventloop,實則考的是對於Promise的掌握程度。Promise的then是微任務大家都懂,但是這個then的執行方式是如何的呢,以及Promise的executor是非同步的還是同步的?

錯誤示範:Promise的then是一個非同步的過程,每個then執行完畢之後,就是一個新的迴圈的,所以第二個then會在setTimeout之後執行。(沒錯,這就是某年某月某日筆者的一個回答。請給我一把槍,真想打死當時的自己。)

正確示範:這個要從Promise的實現來說,Promise的executor是一個同步函式,即非非同步,立即執行的一個函式,因此他應該是和當前的任務一起執行的。而Promise的鏈式呼叫then,每次都會在內部生成一個新的Promise,然後執行then,在執行的過程中不斷向微任務(microtask)推入新的函式,因此直至微任務(microtask)的佇列清空後才會執行下一波的macrotask。

詳細解析

(如果大家不嫌棄,可以參考我的另一篇文章,從零實現一個Promise,裡面的解釋淺顯易懂。) 我們以babel的core-js中的promise實現為例,看一眼promise的執行規範:

程式碼位置:promise-polyfill

PromiseConstructor = function Promise(executor) {
    //...
    try {
      executor(bind(internalResolve, this, state), bind(internalReject, this, state));
    } catch (err) {
      internalReject(this, state, err);
    }
};
複製程式碼

這裡可以很清除地看到Promise中的executor是一個立即執行的函式。

then: function then(onFulfilled, onRejected) {
    var state = getInternalPromiseState(this);
    var reaction = newPromiseCapability(speciesConstructor(this, PromiseConstructor));
    reaction.ok = typeof onFulfilled == 'function' ? onFulfilled : true;
    reaction.fail = typeof onRejected == 'function' && onRejected;
    reaction.domain = IS_NODE ? process.domain : undefined;
    state.parent = true;
    state.reactions.push(reaction);
    if (state.state != PENDING) notify(this, state, false);
    return reaction.promise;
},
複製程式碼

接著是Promise的then函式,很清晰地看到reaction.promise,也就是每次then執行完畢後會返回一個新的Promise。也就是當前的微任務(microtask)佇列清空了,但是之後又開始新增了,直至微任務(microtask)佇列清空才會執行下一波巨集任務(marcotask)。

//state.reactions就是每次then傳入的函式
 var chain = state.reactions;
  microtask(function () {
    var value = state.value;
    var ok = state.state == FULFILLED;
    var i = 0;
    var run = function (reaction) {
        //...
    };
    while (chain.length > i) run(chain[i++]);
    //...
  });
複製程式碼

最後是Promise的任務resolve之後,開始執行then,可以看到此時會批量執行then中的函式,而且還給這些then中回撥函式放入了一個microtask這個很顯眼的函式之中,表示這些回撥函式是在微任務中執行的。

那麼在沒有Promise的瀏覽器中,微任務這個佇列是如何實現的呢?

小知識:babel中對於微任務的polyfill,如果是擁有setImmediate函式平臺,則使用之,若沒有則自定義則利用各種比如nodejs中的process.nextTick,瀏覽器中支援postMessage的,或者是通過create一個script來實現微任務(microtask)。最終的最終,是使用setTimeout,不過這個就和微任務無關了,promise變成了巨集任務的一員。

擴充思考:

為什麼有時候,then中的函式是一個陣列?有時候就是一個函式?

我們稍稍修改一下上述題目,將鏈式呼叫的函式,變成下方的,分別呼叫then。且不說這和鏈式呼叫之間的不同用法,這邊只從實踐角度辨別兩者的不同。鏈式呼叫是每次都生成一個新的Promise,也就是說每個then中回撥方法屬於一個microtask,而這種分別呼叫,會將then中的回撥函式push到一個陣列之中,然後批量執行。再換句話說,鏈式呼叫可能會被Evenloop中其他的函式插隊,而分別呼叫則不會(僅針對最普通的情況,then中無其他非同步操作。)。

let a=new Promise((resolve)=>{
     console.log(2)
     resolve()
})
a.then(()=>{
    console.log(3) 
})
a.then(()=>{
    console.log(4) 
})
 
複製程式碼

下一模組會對此微任務(microtask)中的“插隊”行為進行詳解。

版本三:爐火純青版

這一個版本是上一個版本的進化版本,上一個版本的promise的then函式並未返回一個promise,如果在promise的then中建立一個promise,那麼結果該如何呢?

考點:promise的進階用法,對於then中return一個promise的掌握

吐槽:promise也可以是地獄……

new Promise((resolve,reject)=>{
    console.log("promise1")
    resolve()
}).then(()=>{
    console.log("then11")
    new Promise((resolve,reject)=>{
        console.log("promise2")
        resolve()
    }).then(()=>{
        console.log("then21")
    }).then(()=>{
        console.log("then23")
    })
}).then(()=>{
    console.log("then12")
})
複製程式碼

按照上一節最後一個microtask的實現過程,也就是說一個Promise所有的then的回撥函式是在一個microtask函式中執行的,但是每一個回撥函式的執行,又按照情況分為立即執行,微任務(microtask)和巨集任務(macrotask)。

遇到這種巢狀式的Promise不要慌,首先要心中有一個佇列,能夠將這些函式放到相對應的佇列之中。

Ready GO

第一輪

  • current task: promise1是當之無愧的立即執行的一個函式,參考上一章節的executor,立即執行輸出[promise1]
  • micro task queue: [promise1的第一個then]

第二輪

  • current task: then1執行中,立即輸出了then11以及新promise2的promise2
  • micro task queue: [新promise2的then函式,以及promise1的第二個then函式]

第三輪

  • current task: 新promise2的then函式輸出then21和promise1的第二個then函式輸出then12
  • micro task queue: [新promise2的第二then函式]

第四輪

  • current task: 新promise2的第二then函式輸出then23
  • micro task queue: []

END

最終結果[promise1,then11,promise2,then21,then12,then23]

變異版本1:如果說這邊的Promise中then返回一個Promise呢??

new Promise((resolve,reject)=>{
    console.log("promise1")
    resolve()
}).then(()=>{
    console.log("then11")
    return new Promise((resolve,reject)=>{
        console.log("promise2")
        resolve()
    }).then(()=>{
        console.log("then21")
    }).then(()=>{
        console.log("then23")
    })
}).then(()=>{
    console.log("then12")
})
複製程式碼

這裡就是Promise中的then返回一個promise的狀況了,這個考的重點在於Promise而非Eventloop了。這裡就很好理解為何then12會在then23之後執行,這裡Promise的第二個then相當於是掛在新Promise的最後一個then的返回值上。

變異版本2:如果說這邊不止一個Promise呢,再加一個new Promise是否會影響結果??

new Promise((resolve,reject)=>{
    console.log("promise1")
    resolve()
}).then(()=>{
    console.log("then11")
    new Promise((resolve,reject)=>{
        console.log("promise2")
        resolve()
    }).then(()=>{
        console.log("then21")
    }).then(()=>{
        console.log("then23")
    })
}).then(()=>{
    console.log("then12")
})
new Promise((resolve,reject)=>{
    console.log("promise3")
    resolve()
}).then(()=>{
    console.log("then31")
})
複製程式碼

笑容逐漸變態,同樣這個我們可以自己心中排一個佇列:

第一輪

  • current task: promise1,promise3
  • micro task queue: [promise2的第一個thenpromise3的第一個then]

第二輪

  • current task: then11,promise2,then31
  • micro task queue: [promise2的第一個thenpromise1的第二個then]

第三輪

  • current task: then21,then12
  • micro task queue: [promise2的第二個then]

第四輪

  • current task: then23
  • micro task queue: []

最終輸出:[promise1,promise3,then11,promise2,then31,then21,then12,then23]

版本四:登峰造極版

考點:在async/await之下,對Eventloop的影響。

槽點:別被async/await給騙了,這題不難。

相信大家也看到過此類的題目,我這裡有個相當簡易的解釋,不知大家是否有興趣。

async function async1() {
    console.log("async1 start");
    await  async2();
    console.log("async1 end");
}

async  function async2() {
    console.log( 'async2');
}

console.log("script start");

setTimeout(function () {
    console.log("settimeout");
},0);

async1();

new Promise(function (resolve) {
    console.log("promise1");
    resolve();
}).then(function () {
    console.log("promise2");
});
console.log('script end'); 
複製程式碼

async/await僅僅影響的是函式內的執行,而不會影響到函式體外的執行順序。也就是說async1()並不會阻塞後續程式的執行,await async2()相當於一個Promise,console.log("async1 end");相當於前方Promise的then之後執行的函式。

按照上章節的解法,最終輸出結果:[script start,async1 start,async2,promise1,script end,async1 end,promise2,settimeout]

如果瞭解async/await的用法,則並不會覺得這題是困難的,但若是不瞭解或者一知半解,那麼這題就是災難啊。

  • 此處唯一有爭議的就是async的then和promise的then的優先順序的問題,請看下方詳解。*

async/await與promise的優先順序詳解

async function async1() {
    console.log("async1 start");
    await  async2();
    console.log("async1 end");
}
async  function async2() {
    console.log( 'async2');
}
// 用於test的promise,看看await究竟在何時執行
new Promise(function (resolve) {
    console.log("promise1");
    resolve();
}).then(function () {
    console.log("promise2");
}).then(function () {
    console.log("promise3");
}).then(function () {
    console.log("promise4");
}).then(function () {
    console.log("promise5");
});
複製程式碼

先給大家出個題,如果讓你polyfill一下async/await,大家會怎麼polyfill上述程式碼?下方先給出筆者的版本:

function promise1(){
    return new Promise((resolve)=>{
        console.log("async1 start");
        promise2().then(()=>{
            console.log("async1 end");
            resolve()
        })
    })
}
function promise2(){
    return new Promise((resolve)=>{
        console.log( 'async2'); 
        resolve() 
    })
}
複製程式碼

在筆者看來,async本身是一個Promise,然後await肯定也跟著一個Promise,那麼新建兩個function,各自返回一個Promise。接著function promise1中需要等待function promise2中Promise完成後才執行,那麼就then一下咯~。

根據這個版本得出的結果:[async1 start,async2,promise1,async1 end,promise2,...],async的await在test的promise.then之前,其實也能夠從筆者的polifill中得出這個結果。

然後讓筆者驚訝的是用原生的async/await,得出的結果與上述polyfill不一致!得出的結果是:[async1 start,async2,promise1,promise2,promise3,async1 end,...],由於promise.then每次都是一輪新的microtask,所以async是在2輪microtask之後,第三輪microtask才得以輸出(關於then請看版本三的解釋)。

/* 突如其來的沉默 */

這裡插播一條,async/await因為要經過3輪的microtask才能完成await,被認為開銷很大,因此之後V8和Nodejs12開始對此進行了修復,詳情可以看github上面這一條pull

那麼,筆者換一種方式來polyfill,相信大家都已經充分了解await後面是一個Promise,但是假設這個Promise不是好Promise怎麼辦?非同步是好非同步,Promise不是好Promise。V8就很凶殘,加了額外兩個Promise用於解決這個問題,簡化了下原始碼,大概是下面這個樣子:

// 不太準確的一個描述
function promise1(){
    console.log("async1 start");
    // 暗中存在的promise,筆者認為是為了保證async返回的是一個promise
    const implicit_promise=Promise.resolve()
    // 包含了await的promise,這裡直接執行promise2,為了保證promise2的executor是同步的感覺
    const promise=promise2()
    // https://tc39.github.io/ecma262/#sec-performpromisethen
    // 25.6.5.4.1
    // throwaway,為了規範而存在的,為了保證執行的promise是一個promise
    const throwaway= Promise.resolve()
    //console.log(throwaway.then((d)=>{console.log(d)}))
    return implicit_promise.then(()=>{
        throwaway.then(()=>{
            promise.then(()=>{
                console.log('async1 end');
            })
        }) 
    })
}
複製程式碼

ps:為了強行推遲兩個microtask執行,筆者也是煞費苦心。

總結一下:async/await有時候會推遲兩輪microtask,在第三輪microtask執行,主要原因是瀏覽器對於此方法的一個解析,由於為了解析一個await,要額外建立兩個promise,因此消耗很大。後來V8為了降低損耗,所以剔除了一個Promise,並且減少了2輪microtask,所以現在最新版本的應該是“零成本”的一個非同步。

版本五:究極變態版

饕餮大餐,什麼變態的內容都往裡面加,想想就很豐盛。能考到這份上,只能說面試官人狠話也多。

考點:nodejs事件+Promise+async/await+佛系setImmediate

槽點:筆者都不知道那個可能先出現

async function async1() {
    console.log("async1 start");
    await  async2();
    console.log("async1 end");
}
async  function async2() {
    console.log( 'async2');
}
console.log("script start");
setTimeout(function () {
    console.log("settimeout");
});
async1()
new Promise(function (resolve) {
    console.log("promise1");
    resolve();
}).then(function () {
    console.log("promise2");
});
setImmediate(()=>{
    console.log("setImmediate")
})
process.nextTick(()=>{
    console.log("process")
})
console.log('script end'); 
複製程式碼

佇列執行start

第一輪:

  • current task:"script start","async1 start",'async2',"promise1",“script end”
  • micro task queue:[async,promise.then,process]
  • macro task queue:[setTimeout,setImmediate]

第二輪

  • current task:process,async1 end ,promise.then
  • micro task queue:[]
  • macro task queue:[setTimeout,setImmediate]

第三輪

  • current task:setTimeout,setImmediate
  • micro task queue:[]
  • macro task queue:[]

最終結果:[script startasync1 startasync2promise1script end,process,async1 end,promise2,setTimeout,setImmediate]

同樣"async1 end","promise2"之間的優先順序,因平臺而異。

筆者乾貨總結

在處理一段evenloop執行順序的時候:

  • 第一步確認巨集任務,微任務

    • 巨集任務:script,setTimeout,setImmediate,promise中的executor
    • 微任務:promise.then,process.nextTick
  • 第二步解析“攔路虎”,出現async/await不要慌,他們只在標記的函式中能夠作威作福,出了這個函式還是跟著大部隊的潮流。

  • 第三步,根據Promise中then使用方式的不同做出不同的判斷,是鏈式還是分別呼叫。

  • 最後一步記住一些特別事件

    • 比如,process.nextTick優先順序高於Promise.then

參考網址,推薦閱讀:

有關V8中如何實現async/await的,更快的非同步函式和 Promise

有關async/await規範的,ecma262

還有babel-polyfill的原始碼,promise

後記

Hello~Anybody here?

本來筆者是不想寫這篇文章的,因為有種5年高考3年模擬的既視感,奈何面試官們都太凶殘了,為了“折磨”面試者無所不用其極,怎麼變態怎麼來。不過因此筆者算是徹底掌握了Eventloop的用法,因禍得福吧~

有小夥伴看到最後嘛?來和筆者聊聊你遇到過的的Eventloop+Promise的變態題目。

歡迎轉載~但請註明出處~首發於掘金~Eventloop不可怕,可怕的是遇上Promise

相關文章