摘要:圖片轉(zhuǎn)引自的演講和兩個定時器中回調(diào)的執(zhí)行邏輯便是典型的機制。異步編程關(guān)于異步編程我的理解是,在執(zhí)行環(huán)境所提供的異步機制之上,在應(yīng)用編碼層面上實現(xiàn)整體流程控制的異步風(fēng)格。
問題背景
在一次開發(fā)任務(wù)中,需要實現(xiàn)如下一個餅狀圖動畫,基于canvas進行繪圖,但由于對于JS運行環(huán)境中異步機制的不了解,所以遇到了一個棘手的問題,始終無法解決,之后在與同事交流之后才恍然大悟。問題的根節(jié)在于經(jīng)典的JS定時器異步問題,所以在解決問題之后,又通過了大量的資料閱讀擴展和一段時間的實戰(zhàn)總結(jié),現(xiàn)在對JS運行環(huán)境中異步機制做一個較為深入的分析。
上圖中為最終想要實現(xiàn)的效果,使得各扇形部分可以同時畫出并閉合圓形。點擊此處查看代碼清單。之前遇到的問題是沒有將myLoop作為一個函數(shù)抽離出來,而將其中的所有邏輯,包括定時器都寫在了for循環(huán)中,這樣雖然扇形角度、哨兵變量等的計算均正確,但圓形始終無法閉合,很是郁悶。這里我只是想借此問題來引入JS運行環(huán)境中對于異步機制理解的重要性,大可不必關(guān)心canvas畫圖的實現(xiàn)過程,讓大家明白對異步的理解會牽扯到業(yè)務(wù)邏輯執(zhí)行的準(zhǔn)確性,并非只是用于浮于紙面的面試題之上。至于為什么將定時器的邏輯放在一個函數(shù)中就執(zhí)行正常,而直接寫入for循環(huán)就無法達到預(yù)期,看過下文的詳細分析后,這個問題便會迎刃而解。
深入異步關(guān)于異步的深入,這里基于現(xiàn)有的知識水平做盡可能詳盡準(zhǔn)確的分析。大家可以從一篇博客進一步了解牛人之間對于異步理解的爭論。一位是技術(shù)博客紅人阮一峰老師,一位是國內(nèi)Node技術(shù)的開山鼻祖樸靈老師,都是我持續(xù)關(guān)注的兩位偶像。事情發(fā)生的比較早了,這里只給出一個文章鏈接,其中在阮老師的博文中附帶了大量樸靈老師的批注,讀過之后定會受益匪淺,也會激發(fā)出你對技術(shù)外的一些思考。
同步與異步首先來說明同步與異步兩個概念。
f1() f2()
對于JavaScript語言的執(zhí)行方式,執(zhí)行環(huán)境會支持兩種模式,一種是同步執(zhí)行,一種是異步執(zhí)行。如上面兩個方法,同步執(zhí)行就是調(diào)用f1之后,等待返回結(jié)果,再執(zhí)行f2。異步是調(diào)用f1后,通過一系列其他的操作才可以得到預(yù)期的結(jié)果,比如網(wǎng)絡(luò)IO、磁盤IO等,在線程執(zhí)行這些其他操作的同時,程序還可以往下執(zhí)行,繼續(xù)調(diào)用f2,不用等待f1的結(jié)果返回再執(zhí)行f2。
我們知道,大部分的腳本和編程語言都是同步編程,開發(fā)者對于同步編程的執(zhí)行邏輯也比較容易理解。那么為什么對于JS的執(zhí)行要經(jīng)常用到異步編程,這應(yīng)該要追溯到最初JS適用的宿主環(huán)境--瀏覽器。
由于用于瀏覽器,所以操作DOM的JS只能使用單線程,否則無法保證DOM操作的安全性(比如一個線程將另一個線程正在使用的某個DOM刪掉)。又因為使用單線程,同步執(zhí)行代碼的話,如果遇到耗時較長的操作,那么瀏覽器將會長時間失去響應(yīng),用戶體驗及其不好。但如果將耗時較長的任務(wù),比如ajax請求異步執(zhí)行,那么客戶端的渲染便不會受到耗時任務(wù)的阻塞。
對于服務(wù)器端,JS異步執(zhí)行更為重要,因為執(zhí)行環(huán)境是單線程的,如果同步執(zhí)行所有并發(fā)請求,那么對于客戶端的響應(yīng)將會極其遲鈍,服務(wù)器性能急劇下降,這時必須使用異步模式來處理大量并發(fā)請求,不像Java、PHP等語言是通過多線程來解決并發(fā)問題。這點在現(xiàn)在高并發(fā)司空見慣的網(wǎng)絡(luò)環(huán)境中,反而成為了JS的優(yōu)勢,使得Node在短時間內(nèi)進入主流視野,成為DIRT應(yīng)用1的最佳解決方案。
實現(xiàn)異步的機制在說實現(xiàn)異步的機制之前,首先需要搞清楚兩個概念,分別是JavaScript的執(zhí)行引擎和執(zhí)行環(huán)境。我們常說Google的V8虛擬機便是JavaScript的執(zhí)行引擎,除此之外Safari的JavaScript Core、FireFox的SpiderMonckey都屬于Engine。而上述的瀏覽器和Node等便屬于JavaScript的執(zhí)行環(huán)境,是Runtime。前者Engine是去實現(xiàn)ECMAScript標(biāo)準(zhǔn),后者Runtime是去實現(xiàn)異步的具體機制。所以我們今天講的JS異步機制都是在說JS執(zhí)行環(huán)境的異步機制,與V8這樣的執(zhí)行引擎并無關(guān)系,主要是由各大瀏覽器廠商去做實現(xiàn)。
關(guān)于實現(xiàn)異步的方式,有我們接下來要詳細介紹的Event Loop,還有輪詢、事件等。所謂輪詢,就是你在收銀臺付款之后,不停的問服務(wù)員你的飯菜做好了嗎。所謂事件,就是你在付款之后,不用不停的問服務(wù)員,服務(wù)員在做好飯菜之后會主動告訴你。而大部分的執(zhí)行環(huán)境都是通過Event Loop去實現(xiàn)異步機制,所以下面重點來講解Event Loop。
Event LoopEvent Loop的實現(xiàn)邏輯如下圖。每當(dāng)程序啟動后,內(nèi)存會被分為堆(heap)和棧(stack)兩部分,其中棧中便是主線程的執(zhí)行邏輯所需內(nèi)存,我們根據(jù)這塊內(nèi)存的特殊作用,抽象的將其叫做執(zhí)行棧。在棧中的代碼會調(diào)用各種WebAPI,比如對DOM的操作,ajax請求,創(chuàng)建定時器等。這些操作會產(chǎn)生一些事件,而事件又會關(guān)聯(lián)相應(yīng)的handle(也就是注冊時的callback),將需要執(zhí)行的handle按照隊列的結(jié)構(gòu)放入callback queue(event queue)中。當(dāng)執(zhí)行棧中的代碼執(zhí)行完畢后,主線程會讀取callback queue,依次執(zhí)行其中的回調(diào)函數(shù),然后進入下一輪的事件循環(huán),執(zhí)行清空新產(chǎn)生的事件回調(diào)函數(shù)。由此可見,在執(zhí)行棧中的代碼總是在callback queue之前執(zhí)行。
圖片轉(zhuǎn)引自Philip Roberts的演講《Help, I"m stuck in an event-loop》
setTimeout()和setInterval()兩個定時器中回調(diào)的執(zhí)行邏輯便是典型的Event Loop機制。相似的,程序在跑完執(zhí)行棧中的代碼后,事件循環(huán)會不停的檢查系統(tǒng)時間是否到達預(yù)設(shè)的時間點,每當(dāng)?shù)竭_預(yù)設(shè)的時間點時,就會產(chǎn)生一個timeout事件,并將其放入callback queue,等待下輪Event loop執(zhí)行。但在實際應(yīng)用中,有可能執(zhí)行棧中的代碼耗時過長,這樣在執(zhí)行完執(zhí)行棧中的代碼后,再去執(zhí)行callback queue中由setTimeout()產(chǎn)生的回調(diào)時就不能保證在預(yù)期的時間點執(zhí)行,所以JS中的定時器并不總能保證其精準(zhǔn)性。而在詳細了解其特性原理后,我們可以在編程應(yīng)用層面做一些優(yōu)化,盡量使定時器中回調(diào)函數(shù)的執(zhí)行時間點與我們預(yù)期保持一致。由于setTimeout()與setInterval()在本質(zhì)上是一致的,所以在下面的實例分析一節(jié)中我們將會以setTimeout()來做關(guān)于異步機制的分析。
異步編程關(guān)于異步編程我的理解是,在JS執(zhí)行環(huán)境所提供的異步機制之上,在應(yīng)用編碼層面上實現(xiàn)整體流程控制的異步風(fēng)格。具體地,我們可以用類似setTimeout()中的回調(diào)函數(shù)的形式進行異步編程,或者用類似事件驅(qū)動的發(fā)布/訂閱模式,或者用ES6為我們提供的異步編程的統(tǒng)一接口Promise實現(xiàn),再或者可以嘗試最新最酷的ES7中Async/Await方案,還有一些像Node社區(qū)提供的異步流控庫Step等。這里只是為大家明確異步編程這個概念范疇,具體用法不再深入。
實例分析這一節(jié)中我將會舉出多例來分析,請大家結(jié)合上述理論細細體會JS中的同步與異步。首先我們從一個經(jīng)典的JS異步面試題開始,然后逐漸深入。
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(new Date, i); }, 1000); } console.log(new Date, i);
上述代碼片段的運行結(jié)果應(yīng)該是,先立即輸出一個5,然后在1秒以后同時輸出五個5。程序開始執(zhí)行后,首先執(zhí)行執(zhí)行棧中的同步代碼,幾乎同時創(chuàng)建了5個定時器,然后繼續(xù)執(zhí)行第7行的同步代碼。這樣,首先在控制臺輸出一個5,然后在1s以后,5個定時器同時產(chǎn)生5個timeout事件放入callback queue,Event loop依次執(zhí)行隊列中的回調(diào)函數(shù),這里因為閉包的特性,每一個定時器的回調(diào)都與其定義上下文,for循環(huán)中的i變量做了綁定,而i的值已變?yōu)?,所以同時輸出五個5。
如果現(xiàn)在提出一個新需求,要求程序運行后,先立即輸出一個5,然后在1s以后同時輸出0,1,2,3,4,如何改造上述代碼?
//方法一 for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(new Date, j); }, 1000); })(i); } console.log(new Date, i); //方法二 function output (i) { setTimeout(function() { console.log(new Date, i); }, 1000); }; for (var i = 0; i < 5; i++) { output(i); } console.log(new Date, i);
上面給出的兩種方法其實都是一種思路,都是利用JS中,函數(shù)作用域作為一個獨立的作用域,來保存一個局部的上下文環(huán)境,并通過閉包的特性使其與setTimeout中的回調(diào)函數(shù)做綁定。只不過第一種方法是利用IIFE2來實現(xiàn),第二種方法是通過定義一個函數(shù),再來逐個調(diào)用實現(xiàn)。看到這里,應(yīng)該想到對于篇首問題背景一節(jié)中所提到的問題便與此處如出一轍。
接下來我們進一步深入,提出一個新的需求。如何在代碼執(zhí)行時,立即輸出 0,之后每隔1s依次輸出 1,2,3,4,循環(huán)結(jié)束后在大概第5秒的時候輸出5?
因為前邊每隔1s輸出的0,1,2,3,4是五個定時器輸出的,也就是五個異步操作,那么我們是不是可以把這次的需求抽象為:在一系列異步操作完成(每次循環(huán)都產(chǎn)生了 1 個異步操作)之后,再做其他的事情。現(xiàn)在熟悉ES6的同學(xué)應(yīng)該想到了Promise。
const tasks = []; // 這里存放異步操作的 Promise const output = (i) => new Promise((resolve) => { setTimeout(() => { console.log(new Date, i); resolve(); }, 1000 * i); }); // 生成全部的異步操作 for (var i = 0; i < 5; i++) { tasks.push(output(i)); } // 異步操作完成之后,輸出最后的 i Promise.all(tasks).then(() => { setTimeout(() => { console.log(new Date, i); }, 1000); });
如果你熟悉ES7中的Async/Await,那么也可以嘗試用這種方案解決。
// 模擬其他語言中的 sleep,實際上可以是任何異步操作 const sleep = (timeountMS) => new Promise((resolve) => { setTimeout(resolve, timeountMS); }); (async () => { // 聲明即執(zhí)行的 async 函數(shù)表達式 for (var i = 0; i < 5; i++) { await sleep(1000); console.log(new Date, i); } await sleep(1000); console.log(new Date, i); })();
這里需要著重注意的是瀏覽器對Async/Await標(biāo)準(zhǔn)的支持,如果你的瀏覽器不在以下所支持版本當(dāng)中,那么可以升級瀏覽器或使用babel轉(zhuǎn)譯處理。
能把上邊這一系列的實例理解到位,相信對JS中異步的這個概念會一些新的體會。下面這個實例會更加細化的考察一下異步代碼中回調(diào)的執(zhí)行時機。
let a = new Promise( function(resolve, reject) { console.log(1) setTimeout(() => console.log(2), 0) console.log(3) console.log(4) resolve(true) } ) a.then(v => { console.log(8) }) let b = new Promise( function() { console.log(5) setTimeout(() => console.log(6), 0) } ) console.log(7)
這里首先來明確一點,Promise是ES6中為異步編程所提供的一套API標(biāo)準(zhǔn),其本身是同步的。所以我們在new一個Promise對象的時候,其所執(zhí)行的構(gòu)造器中的邏輯是同步的。由此得知,上述代碼片段先從上到下依次執(zhí)行同步代碼,輸出1,3,4,5,7。然后是先執(zhí)行then中的異步代碼還是先執(zhí)行setTimeout中的回調(diào)代碼?這里需要記住前者要比后者先進入執(zhí)行棧執(zhí)行,所以后邊輸出8,2,6。這是因為立即resolved的Promise是在本輪事件循環(huán)的末尾執(zhí)行,類似于node中的process.nextTick方法,它可以在當(dāng)前"執(zhí)行棧"的尾部,下一次Event Loop(主線程讀取"任務(wù)隊列")之前,觸發(fā)回調(diào)函數(shù)。setTimeout(fn, 0)則是在當(dāng)前"任務(wù)隊列"的尾部添加事件,也就是說,它指定的任務(wù)總是在下一輪次Event Loop時執(zhí)行,這與node中的setImmediate方法很像。
最后我們來說一個關(guān)于setInterval優(yōu)化的例子。我們知道setTimeout中的回調(diào)觸發(fā)是不準(zhǔn)確的,主要原因是由于在需要執(zhí)行回調(diào)時,可能執(zhí)行棧中的代碼還沒有執(zhí)行完,無法將CPU資源及時的調(diào)度給callback queue中的回調(diào)執(zhí)行。而setInterval也會存在一些問題,比如時間間隔可能會跳過,
時間間隔可能小于定時器設(shè)定的時間。發(fā)生這類情況其實也是由于其他的程序占用長時間的CPU時間片引起,以下面代碼片段為例:
function click() { // code block1... setInterval(function() { // process ... }, 200); // code block2 ... }
如果process中的代碼執(zhí)行時間過長,占用了超過400ms,那么此時JS執(zhí)行環(huán)境就會跳過中間一次時間間隔,因為callback queue中只允許有一份process代碼存在,所以也會產(chǎn)生觸發(fā)時機不精準(zhǔn)的情況。
為了避免這種情況的出現(xiàn),我們可以利用遞歸的方式進行優(yōu)化處理,以下提供兩種寫法,但是建議使用第一種寫法。因為第二種寫法中,在嚴(yán)格模式下,第5版 ECMAScript (ES5) 禁止使用 arguments.callee()。當(dāng)一個函數(shù)必須調(diào)用自身的時候, 避免使用 arguments.callee(), 通過要么給函數(shù)表達式一個名字,要么使用一個函數(shù)聲明參見MDN解釋
// 寫法一 setTimeout(function bar (){ // processing foo = setTimeout(bar, 1000); }, 1000); // 寫法二 setTimeout(function(){ // processing foo = setTimeout(arguments.callee, interval); }, interval); clearTimeout(foo) // 停止循環(huán)
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/90516.html
摘要:每個線程的任務(wù)執(zhí)行順序都是先進先出在運行的環(huán)境中,有一個負(fù)責(zé)程序本身的運行,作為主線程另一個負(fù)責(zé)主線程與其他線程的通信,被稱為線程。主線程繼續(xù)執(zhí)行我是第一主線程執(zhí)行完畢,從線程讀取回調(diào)函數(shù)。 前言 上星期面試被問到了事件執(zhí)行順序的問題,想起來之前看《深入淺出Node.js》時看到這一章就忽略了,這次來分析一下JavaScript的事件執(zhí)行順序。廢話少說,正題開始。 單線程JavaScr...
摘要:的單線程,與它的用途有關(guān)。只要指定過回調(diào)函數(shù),這些事件發(fā)生時就會進入任務(wù)隊列,等待主線程讀取。四主線程從任務(wù)隊列中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為事件循環(huán)。令人困惑的是,文檔中稱,指定的回調(diào)函數(shù),總是排在前面。 原文:http://www.cnblogs.com/Master... 一、為什么JavaScript是單線程? JavaScript語言的一大特點...
摘要:主線程從任務(wù)隊列中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為事件循環(huán)。上面也提到,在到達指定時間時,定時器就會將相應(yīng)回調(diào)函數(shù)插入任務(wù)隊列尾部。這就是定時器功能。關(guān)于定時器的重要補充定時器包括與兩個方法。 一、引子 本文介紹JavaScript運行機制,這一部分比較抽象,我們先從一道面試題入手: console.log(1); setTimeout(function()...
摘要:主線程從任務(wù)隊列中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為事件循環(huán)。上面也提到,在到達指定時間時,定時器就會將相應(yīng)回調(diào)函數(shù)插入任務(wù)隊列尾部。這就是定時器功能。關(guān)于定時器的重要補充定時器包括與兩個方法。 一、引子 本文介紹JavaScript運行機制,這一部分比較抽象,我們先從一道面試題入手: console.log(1); setTimeout(function()...
摘要:主線程從任務(wù)隊列中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為事件循環(huán)。上面也提到,在到達指定時間時,定時器就會將相應(yīng)回調(diào)函數(shù)插入任務(wù)隊列尾部。這就是定時器功能。關(guān)于定時器的重要補充定時器包括與兩個方法。 一、引子 本文介紹JavaScript運行機制,這一部分比較抽象,我們先從一道面試題入手: console.log(1); setTimeout(function()...
閱讀 2299·2021-11-24 09:38
閱讀 2121·2021-11-22 14:44
閱讀 1157·2021-07-29 13:48
閱讀 2622·2019-08-29 13:20
閱讀 1120·2019-08-29 11:08
閱讀 2061·2019-08-26 10:58
閱讀 1267·2019-08-26 10:55
閱讀 3163·2019-08-26 10:39