摘要:調用棧被清空,消息隊列中并無任務,線程停止,事件循環結束。不確定的時間點請求返回,將設定好的回調函數放入消息隊列。調用棧執行完畢執行消息隊列任務。請求并發回調函數執行順序無法確定。
異步編程
JavaScript中異步編程問題可以說是基礎中的重點,也是比較難理解的地方。首先要弄懂的是什么叫異步?
我們的代碼在執行的時候是從上到下按順序執行,一段代碼執行了之后才會執行下一段代碼,這種方式叫同步(synchronous)執行,也是我們最容易理解的方式。但是在某些場景下:
網絡請求:常見的ajax
IO操作:比如readFile
定時器:setTimeout
上面這些場景可能非常耗時,而且時間不定長,這時候這些代碼就不應該同步執行了,先執行可以執行的代碼,在未來的某個時間再來執行他們的handler,這就是異步。
通過這篇文章我們來了解幾個知識點:
進程線程區別
消息隊列與事件循環
JavaScript處理異步的幾種方法
generator與async/await的關系
基礎知識先做些準備工作,補一補一些非常重要的前置的概念。
進程與線程一個程序(program)至少包含一個進程(process),一個進程至少包含一個線程(thread)。
進程有以下特點:
一個進程可以包含一個或多個線程。
進程在執行過程中擁有獨立的內存單元。
一個進程可以創建和撤銷另一個進程,這個進程是父進程,被創建的進程稱為子進程。
線程有以下特點:
線程不能獨立運行,必須依賴進程空間。
線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源。
一個線程可以創建和撤銷另一個線程;同一個進程中的多個線程之間可以并發執行。
從邏輯角度來看,多線程的意義在于一個應用程序中,有多個執行部分可以同時執行。但操作系統并沒有將多個線程看做多個獨立的應用,來實現進程的調度和管理以及資源分配。這就是進程和線程的重要區別。
畫張圖來簡單描述下:
所有的程序都要交給CPU實現計算任務,但是CPU一個時間點只能處理一個任務。這時如果多個程序在運行,就涉及到了《操作系統原理》中重要的線程調度算法,線程是CPU輪轉的最小單位,其他上下文信息用所在進程中的。
進程是資源的分配單位,線程是CPU在進程內切換的單位。JavaScript單線程
瀏覽器內核是多線程,在內核控制下各線程相互配合以保持同步,一個瀏覽器通常由以下常駐線程組成:
GUI 渲染線程
JavaScript引擎線程
定時觸發器線程
事件觸發線程
異步http請求線程
Javascript是單線程的,那么為什么Javascript要是單線程的?
這是因為Javascript這門腳本語言誕生的使命所致:JavaScript為處理頁面中用戶的交互,以及操作DOM樹、CSS樣式樹來給用戶呈現一份動態而豐富的交互體驗和服務器邏輯的交互處理。如果JavaScript是多線程的方式來操作這些UI DOM,則可能出現UI操作的沖突; 如果Javascript是多線程的話,在多線程的交互下,處于UI中的DOM節點就可能成為一個臨界資源,假設存在兩個線程同時操作一個DOM,一個負責修改一個負責刪除,那么這個時候就需要瀏覽器來裁決如何生效哪個線程的執行結果。當然我們可以通過鎖來解決上面的問題。但為了避免因為引入了鎖而帶來更大的復雜性,Javascript在最初就選擇了單線程執行。阻塞和非阻塞
這時候再理解阻塞非阻塞就好理解了,對于異步任務,單線程的JavaScript如果什么也不干等待異步任務結束,這種狀態就是阻塞的;如果將異步消息放到一邊,過會再處理,就是非阻塞的。
請求不能立即得到應答,需要等待,那就是阻塞;否則可以理解為非阻塞。
生活中這種場景太常見了,上廁所排隊就是阻塞,沒人直接上就是非阻塞。
事件循環(event-loop)因為JavaScript是單線程的,每個時刻都只能一個事件,所以JavaScript中的同步和異步事件就有了一個奇妙的執行順序。
JavaScript在運行時(runtime)會產生一個函數調用棧,先入棧的函數先被執行。但是有一些任務是不需要進入調用棧的,這些任務被加入到消息隊列中。當函數調用棧被清空時候,就會執行消息隊列中的任務(任務總會關聯一個函數,并加入到調用棧),依次執行直至所有任務被清空。由于JavaScript是事件驅動,當用戶觸發事件JavaScript再次運行直至清空所有任務,這就是事件循環。
函數調用棧中的任務永遠優先執行,調用棧無任務時候,遍歷消息隊列中的任務。消息隊列中的任務關聯的函數(一般就是callback)放入調用棧中執行。
舉兩個例子:異步請求
function ajax (url, callback){ var req = new XMLHttpRequest(); req.onloadend = callback; req.open("GET", url, true); req.send(); }; console.log(1); ajax("/api/xxxx", function(res){ console.log(res); }); console.log(2);
一個開發經常遇到的業務場景,異步請求一個數據,上述過程用圖表示:
圖中三條線分別表示函數執行的調用棧,異步消息隊列,以及請求所依賴的網絡請求線程(瀏覽器自帶)。執行順序:
調用棧執行console.log(1);。
調用棧執行ajax方法,方法里面配置XMLHttpRequest的回調函數,并交由線程執行異步請求。
調用棧繼續執行console.log(2);。
調用棧被清空,消息隊列中并無任務,JavaScript線程停止,事件循環結束。
不確定的時間點請求返回,將設定好的回調函數放入消息隊列。
事件循環再次啟動,調用棧中無函數,執行消息隊列中的任務function(res){console.log(res);}。
定時器任務:
console.log(1); setTimeout(function(){ console.log(2); }, 100); setTimeout(function(){ console.log(3); }, 10); console.log(4); // 1 // 4 // 3 // 2
跟上面的例子很像,只不過異步請求變成了定時器,上述代碼的指向過程圖:
執行順序如下:
調用棧執行console.log(1);。
執行setTimeout向消息隊列添加一個定時器任務1。
執行setTimeout向消息隊列添加一個定時器任務2。
調用棧執行console.log(4);。
調用棧執行完畢執行消息隊列任務1。
調用棧執行完畢執行消息隊列任務2。
消息隊列任務2執行完畢調用回調函數console.log(3);。
消息隊列任務1執行完畢調用回調函數console.log(2);。
通過上面例子可以很好理解,就像工作中你正在做一件事情,這時候領導給你安排一個不著急的任務,你停下來跟領導說"等我忙完手里的活就去干",然后把手里的活干完去干領導安排的任務。所有任務完成相當于完成了一個事件循環。
macrotasks 和 microtasksmacrotask 和 microtask 都是屬于上述的異步任務中的一種,分別是一下 API :
macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
microtasks: process.nextTick(node), Promises, Object.observe(廢棄), MutationObserver
setTimeout 的 macrotask ,和 Promise 的 microtask 有什么不同呢:
console.log("script start"); setTimeout(function() { console.log("setTimeout"); }, 0); Promise.resolve().then(function() { console.log("promise1"); }).then(function() { console.log("promise2"); }); console.log("script end"); // "script start" // "script end" // "promise1" // "promise2" // "setTimeout"
這里的運行結果是Promise的立即返回的異步任務會優先于setTimeout延時為0的任務執行。
原因是任務隊列分為 macrotasks 和 microtasks,而Promise中的then方法的函數會被推入 microtasks 隊列,而setTimeout的任務會被推入 macrotasks 隊列。在每一次事件循環中,macrotask 只會提取一個執行,而 microtask 會一直提取,直到 microtasks 隊列清空。
所以上面實現循環的順序:
執行函數調用棧中的任務。
函數調用棧清空之后,執行microtasks隊列任務至清空。
執行microtask隊列任務至清空。
并發(Concurrency)并發我們應該經常聽過,跟他類似的一個詞叫并行。
并發:多個進程在一臺處理機上同時運行,一個時間段內處理多件事情,宏觀上好比一個人邊唱邊跳,微觀上這個人唱一句跳一步。(可以類比時間片輪轉法,多個線程同時占用一個CPU,外部看來可以并發處理多個線程)
并行:多態擁有相同處理能力的處理機在同時處理不同的任務,好比廣場上多個大媽同時再調廣場舞。(多個CPU同時處理多個線程任務)
在JavaScript中,因為其是單線程的原因,所以決定了其每時刻只能干一件事情,事件循環是并發在JavaScript單線程中的一種處理方式。
但是在日常開發中我們肯定見過,同時發送多個請求。這種情況下多個網絡線程和js線程共同占用一個CPU,就是并發。
異步解決方法雖然已經理解了JavaScript中運行異步任務的過程,但是這樣顯然對開發不友好,因為我們通常并不知道異步任務在何時結束。所以前人開發了多種處理異步的方法。每種方法我們都從三個角度考慮其優缺點:
單個異步寫法是否簡便。
多個異步按順序執行。
多個異步并發執行。
回調函數 (callback)一種最常見的處理異步問題的方法,將異步任務結束時候要干的事情(回調函數)作為參數傳給他,等任務結束時候運行回調函數。我們常用的$.ajax()和setTimeout都屬于這種方式,但是這樣的問題很明顯:多個異步任務按順序執行非常恐怖。
// 著名的回調金字塔 asyncEvent1(()=>{ asyncEvent2(()=>{ asyncEvent3(()=>{ asyncEvent4(()=>{ .... }); }); }); });
上面這種情況非常難以維護,在早期Node項目中經常出現這種情況,有人對上面小改動:
function asyncEvent1CB (){ asyncEvent2(asyncEvent2CB); } function asyncEvent2CB (){ asyncEvent3(asyncEvent3CB); } function asyncEvent3CB (){ asyncEvent4(asyncEvent4CB); } function asyncEvent4CB () { // ... } asyncEvent1(asyncEvent1CB);
這樣講回調函數分離出來,邏輯清晰了一些,但是還是很明顯:方法調用順序是硬編碼,耦合性還是很高。而且一旦同時發送多個請求,這多個請求的回調函數執行順序很難保證,維護起來非常麻煩。
這就是回調函數的弊端:
雖然簡單,但是不利于閱讀維護。
多層回調順序執行耦合性很高。
請求并發回調函數執行順序無法確定。
每次只能指定一個回調函數,出現錯誤程序中斷易崩潰。
雖然回調函數這種方式問題很多,但是不可否認的是在ES6之前,他就是處理異步問題普遍較好的方式,而且后面很多方式仍然基于回調函數。
事件監聽(litenter)JavaScript是事件驅動,任務的執行不取決代碼的順序,而取決于某一個事件是否發生。DOM中有大量事件如onclick,onload,onerror等等。
$(".element1").on("click", function(){ console.log(1); }); $("#element2").on("click", function(){ console.log(2); }); document.getElementById("#element3").addEventListener("click", function(){ console.log(3); }, false);
例如上面這段代碼 你無法預知輸出結果,因為事件觸發無法被預知。跟這個很像的還有訂閱者發布者模式:
github上有個有意思的小demo。注冊在發布者里面的回調函數何時被觸發取決于發布者何時發布事件,這個很多時候也是不可預知的。
回調函數與事件監聽的區別:
回調函數多是一對一的關系,事件監聽可以是多對一。
運行異步函數,在一個不確定的時間段之后運行回調函數;不確定何時觸發事件,但是觸發事件同步響應事件的回調。
事件監聽相對于回調函數,可配置的監聽(可增可減)關系減少了耦合性。
不過事件監聽也存在問題:
多對多的監聽組成了一個復雜的事件網絡,單個節點通常監聽了多個事件,維護成本很大。
多個異步事件仍然還是回調的形式。
Promisepromise出場了,當年理解promise花了我不少功夫。Promise確實跟前兩者很不一樣,簡單說下promise。
Promise中文可以翻譯成承諾,現在與未來的一種關系,我承諾我會調用你的函數。
Promise三種狀態:pending(進行中),fulfilled(已成功),rejected(已失敗),其狀態只能從進行中到成功或者是失敗,不可逆。
已成功和已失敗可以承接不同的回調函數。
支持.then鏈式調用,將異步的寫法改成同步。
原生支持了race, all等方法,方便適用常見開發場景。
promise更詳細的內容可以看阮一峰老師的文章。
Promise對于異步處理已經十分友好,大多生產環境已經在使用,不過仍有些缺點:
Promise一旦運行,不能終止掉。
利用Promise處理一個異步的后續處理十分簡便,但是處理多個請求按順序執行仍然很不方便。
Generator中文翻譯成"生成器",ES6中提供的一種異步編程解決方案,語法行為與傳統函數完全不同。簡單來說,我可以聲明一個生成器,生成器可以在執行的時候暫停,交出函數執行權給其他函數,然后其他函數可以在需要的時候讓該函數再次運行。這與之前的JavaScript聽起來完全不同。
詳細的內容參考阮一峰老師的文章,這里我們來據幾個例子,正常的ajax調用寫法看起來如下:
// 使用setTimeout模擬異步 function ajax (url, cb){ setTimeout(function(){ cb("result"); }, 100); } ajax("/api/a", function(result){ console.log(result); }); // "result"
一旦我們想要多個異步按順序執行,簡直是噩夢。這里使用generator處理異步函數利用了一個特點:調用next()函數就會繼續執行下去,所以利用這個特點我們處理異步原理:
將異步邏輯封裝成一個生成器。
將生成器的異步部分yield出去。
在異步的回調部分調用next()將生成器繼續進行下去。
這樣同步,異步,回調分離,處理異步寫起來非常簡便。
我們對上面的例子加以改進:
// 使用setTimeout模擬異步 function ajax (url, cb){ setTimeout(function(){ cb(url + " result."); }, 100); } function ajaxCallback(result){ console.log(result); it.next(result); } function* ajaxGen (){ var aResult = yield ajax("/api/a", ajaxCallback); console.log("aResult: " + aResult); var bResult = yield ajax("/api/b", ajaxCallback); console.log("bResult: " + bResult); } var it = ajaxGen(); it.next(); // /api/a result. // aResult: /api/a result. // /api/b result. // bResult: /api/b result.
運行下上面代碼,可以看到控制臺輸出結果居然跟我們書寫的順序一樣!我們稍加改動:
// 使用setTimeout模擬異步 function ajax (url, cb){ setTimeout(function(){ cb(url + " result."); }, 100); } function run (generator) { var it = generator(ajaxCallback); function ajaxCallback(result){ console.log(result); it.next(result); } it.next(); }; run(function* (cb){ var aResult = yield ajax("/api/a", cb); console.log("aResult: " + aResult); var bResult = yield ajax("/api/b", cb); console.log("bResult: " + bResult); });
簡單幾下改造便可以生成一個自執行的生成器函數,同時也完成了異步場景同步化寫法。generator的核心在于:同步,異步,回調三者分離,遇到異步交出函數執行權,再利用回調控制程序生成器繼續進行。上面的run函數只是一個簡單的實現,業界已經有CO這樣成熟的工具。實際上開發過程中通常使用generator搭配Promise實現,再來修改上面的例子:
// 使用setTimeout模擬異步 function ajax (url){ return new Promise(function(resolve, reject){ setTimeout(function(){ resolve(url + " result."); }, 100); }); } function run (generator) { var it = generator(); function next(result){ var result = it.next(result); if (result.done) return result.value; result.value.then(function(data){ console.log(data); next(data); }); } next(); }; run(function* (){ var aResult = yield ajax("/api/a"); console.log("aResult: " + aResult); var bResult = yield ajax("/api/b"); console.log("bResult: " + bResult); });
使用Promise來代替callback,理解上花費點時間,大大提高了效率。上面是一種常見,之前我用過generator實現多張圖片并發上傳,這種情況下利用generator控制上傳上傳數量,達到斷斷續續上傳的效果。
進化到generator這一步可以說是相當智能了,無論是單個異步,多個按順序異步,并發異步處理都十分友好,但是也有幾個問題:
ES6瀏覽器支持問題,需要polyfill和babel的支持。
需要借助CO這樣的工具來完成,流程上理解起來需要一定時間。
有沒有更簡便的方法?
async/await理解了上面的generator,再來理解async/await就簡單多了。
ES2017 標準引入了 async 函數,使得異步操作變得更加方便。async 函數是什么?一句話,它就是 Generator 函數的語法糖。
再看一遍上面的例子,然后修改上面的例子用async/await:
// 使用setTimeout模擬異步 function ajax (url){ return new Promise(function(resolve, reject){ setTimeout(function(){ console.log(url + " result."); resolve(url + " result."); }, 100); }); } async function ajaxAsync () { var aResult = await ajax("/api/a"); console.log("aResult: " + aResult); var bResult = await ajax("/api/b"); console.log("bResult: " + bResult); } ajaxAsync();
可以明顯的看到,async/await寫法跟generator最后一個例子很像,基本上就是使用async/await關鍵字封裝了一個自執行的run方法。
async函數對 Generator 函數的改進,體現在以下四點。
內置執行器:Generator 函數的執行必須靠執行器,所以才有了co模塊,而async函數自帶執行器。也就是說,async函數的執行,與普通函數一模一樣,只要一行。
更好的語義:async和await,比起星號和yield,語義更清楚了。async表示函數里有異步操作,await表示緊跟在后面的表達式需要等待結果。
更廣的適用性:co模塊約定,yield命令后面只能是 Thunk 函數或 Promise 對象,而async函數的await命令后面,可以是 Promise 對象和原始類型的值(數值、字符串和布爾值,但這時等同于同步操作)。
返回值是 Promise:async函數的返回值是 Promise 對象,這比 Generator 函數的返回值是 Iterator 對象方便多了。你可以用then方法指定下一步的操作。
這里async/await不做深入介紹,詳情移步阮一峰老師的博客。
Web worker一個很不常用的api,但是是一個異步編程的方法,跟以上幾種又不太一樣。
你可能會遇到一個非常耗時的計算任務,如果在js線程里運行會造成頁面卡頓,這時使用web worker,將計算任務丟到里面去,等計算完成再以事件監聽的方式通知主線程處理,這是一個web work的應用場景。在這時候,瀏覽器中是有多個線程在處理js的,worker同時可以在創建子線程,實現js"多線程"。web worker的文檔。實戰的話看這篇。
與前面幾種方法不同的是,我們絞盡腦汁想把異步事件同步化,但是web worker卻反其道而行,將同步的代碼放到異步的線程中。
目前,web worker通常用于頁面優化的一種手段,使用場景:
使用專用線程進行數學運算:Web Worker最簡單的應用就是用來做后臺計算,而這種計算并不會中斷前臺用戶的操作。
圖像處理:通過使用從或者元素中獲取的數據,可以把圖像分割成幾個不同的區域并且把它們推送給并行的不同Workers來做計算。
大量數據的檢索:當需要在調用 ajax后處理大量的數據,如果處理這些數據所需的時間長短非常重要,可以在Web Worker中來做這些,避免凍結UI線程。
背景數據分析:由于在使用Web Worker的時候,我們有更多潛在的CPU可用時間,我們現在可以考慮一下JavaScript中的新應用場景。例如,我們可以想像在不影響UI體驗的情況下實時處理用戶輸入。利用這樣一種可能,我們可以想像一個像Word(Office Web Apps 套裝)一樣的應用:當用戶打字時后臺在詞典中進行查找,幫助用戶自動糾錯等等。
總結JavaScript中的異步編程方式目前來說大致這些,其中回調函數這種方式是最簡單最常見的,Promise是目前最受歡迎的方式。前四種方式讓異步編碼模式使我們能夠編寫更高效的代碼,而最后一種web worker則讓性能更優。這里主要是對異步編程流程梳理,前提知識點的補充,而對于真正的異步編程方式則是以思考分析為主,使用沒有過多介紹。最后補充一個連接:JavaScript異步編程常見面試題,幫助理解。
參考《你所不知道JavaScript》
《JavaScript高級程序設計》
瀏覽器進程?線程?傻傻分不清楚!
線程和進程的區別是什么?
并發模型與事件循環
理解 JavaScript 中的 macrotask 和 microtask
【轉向Javascript系列】深入理解Web Worker
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/95014.html
摘要:作用域分類作用域共有兩種主要的工作模型。換句話說,作用域鏈是基于調用棧的,而不是代碼中的作用域嵌套。詞法作用域詞法作用域中,又可分為全局作用域,函數作用域和塊級作用域。 一篇鞏固基礎的文章,也可能是一系列的文章,梳理知識的遺漏點,同時也探究很多理所當然的事情背后的原理。 為什么探究基礎?因為你不去面試你就不知道基礎有多重要,或者是說當你的工作經歷沒有亮點的時候,基礎就是檢驗你好壞的一項...
摘要:很多高級編程語言都給新創建的對象分配一個引用自身的指針比如中的指針,中的,也有指針,雖然它的指向可能相對復雜些,但是指向的,永遠只可能是對象。 很多高級編程語言都給新創建的對象分配一個引用自身的指針,比如JAVA、C++中的this指針,python中的self,JavaScript也有this指針,雖然它的指向可能相對復雜些,但是this指向的,永遠只可能是對象。 一、在一般函數方法...
摘要:很多高級編程語言都給新創建的對象分配一個引用自身的指針比如中的指針,中的,也有指針,雖然它的指向可能相對復雜些,但是指向的,永遠只可能是對象。 很多高級編程語言都給新創建的對象分配一個引用自身的指針,比如JAVA、C++中的this指針,python中的self,JavaScript也有this指針,雖然它的指向可能相對復雜些,但是this指向的,永遠只可能是對象。 一、在一般函數方法...
閱讀 2983·2021-11-23 09:51
閱讀 2996·2021-11-02 14:46
閱讀 862·2021-11-02 14:45
閱讀 2738·2021-09-23 11:57
閱讀 2492·2021-09-23 11:22
閱讀 1923·2019-08-29 16:29
閱讀 740·2019-08-29 16:16
閱讀 937·2019-08-26 13:44