--- title: 非同步程式設計通用概念 slug: Learn/JavaScript/Asynchronous/Concepts tags: - JavaScript - Learn - Promises - Threads - asynchronous - blocking - 非同步 - 執行緒 ---
在本篇文章我們會介紹一些關於非同步程式設計的重要觀念,以及在網頁瀏覽器和 JavaScript 中的行為。在閱讀其他文章之前你應該先具備這些觀念。
先備知識: | 基本的電腦概念,理解 JavaScript 的基本原理。 |
---|---|
學習目標: | 了解非同步程式設計背後的基本概念,以及其如何在網頁瀏覽器及 JavaScript 作呈現。 |
一般來說,在程式碼的執行順序當下同一時間只會處理一件事。如果某個函式依賴於其他函式的執行結果,那麼它就需要等待其完成並回傳結果才能執行,直到那樣的情況發生,以使用者的角度而言整個程式才是真正的結束。
Mac 的使用者有時候會發現游標變成彩虹色的旋轉圖案(或經常被稱作為「沙灘球」)。這個游標的出現代表作業系統像是正對著你說:「你在執行的程式目前正在等待其他程式的完成而被暫停,因為花了一點時間所以我擔心你會想知道發生了甚麼事。」
這種經驗通常令人沮喪而且這也並未善加利用電腦處理器的能力——特別是在擁有多核處理器的世代。比起只能毫無意義的坐著乾等,如果能夠安排其他任務交由其他處理器來執行並讓你知道任務何時完成,這樣就有效率多了,像這樣能讓其他工作在同時間完成,這就是非同步程式設計的基礎。這取決於你目前正在使用的程式環境,如同網頁瀏覽器會提供一些 API 能讓你非同步的執行任務。
非同步的技巧非常實用,特別是在網頁程式設計上。當一個網頁應用程式運行在瀏覽器且執行大量的程式碼的當下並未將控制權交還給瀏覽器時,瀏覽器可能會處於凍結狀態。我們稱之為阻塞( blocking ),瀏覽器正在持續處理使用者的輸入並執行其他任務就會被阻塞住,直到應用程式將處理器的控制權歸還後才會解除。
我們來看幾個程式範例來說明阻塞是甚麼意思。
在這個例子 simple-sync.html (線上範例),我們在按鈕增加一個點擊事件監聽器,在點擊時會執行一段相當耗時的操作(執行新增日期物件一千萬次並將最後一次的結果顯示在控制台上)然後再加入一則文字段落到 {{Glossary("DOM")}} 上:
const btn = document.querySelector('button'); btn.addEventListener('click', () => { let myDate; for(let i = 0; i < 10000000; i++) { let date = new Date(); myDate = date } console.log(myDate); let pElem = document.createElement('p'); pElem.textContent = 'This is a newly-added paragraph.'; document.body.appendChild(pElem); });
當執行我們的範例時,打開你的 JavaScript 控制台並點擊按鈕——你會注意到文字並不會馬上就出現在畫面上,而是要等到跑完新增日期物件的迴圈並將結果顯示在控制台上後,新增的文字才會出現。原因在於程式碼依照順序執行,因此某一筆的操作必須等待上一筆執行完成後才會執行。
注意:上述的範例只是用來作為了解原理的基本範例,在現實生活中你不會真的執行一千萬次的新增日期物件。
在我們第二個範例, simple-sync-ui-blocking.html (線上範例),我們模擬了一段比較貼近現實的範例,你或許會在現實的頁面上遇到。我們在渲染畫面的期間阻擋和使用者的互動行為。在本範例,我們有兩個按鈕:
function expensiveOperation() { for(let i = 0; i < 1000000; i++) { ctx.fillStyle = 'rgba(0,0,255, 0.2)'; ctx.beginPath(); ctx.arc(random(0, canvas.width), random(0, canvas.height), 10, degToRad(0), degToRad(360), false); ctx.fill() } } fillBtn.addEventListener('click', expensiveOperation); alertBtn.addEventListener('click', () => alert('You clicked me!') );
當點擊第一個按鈕之後緊接著點擊第二個按鈕,你會發現警告訊息並不會馬上出現,而是要等到圓圈圖案被渲染完成後才會出現警告訊息視窗。第一個操作在完成之前都會阻擋第二個操作的執行。
注意:我們是在偽造一個阻塞且這個例子可能有點醜陋,但這卻是在現實中大多數的開發者試圖避免的常見問題。
為什麼會這樣?事實是因為 JavaScript 在執行上是使用 單一執行緒( single-threaded )。現在,我們需要說明一下執行緒( thread )的概念。
執行緒( thread )基本上是可以用來完成程式碼任務的單一行程( process )。每一條執行緒在同一時間只會執行一項任務:
任務 A --> 任務 B --> 任務 C
每一項任務必須依照順序來執行,下一項任務必須要等到前一項任務完成後才能開始執行。
我們稍早所說,現今許多電腦擁有許多顆核心,所以可以在同一時間做許多事。可以支援多條執行緒的程式語言在同一時間可以利用多顆核心來完成數個任務:
執行緒1:任務 A --> 任務 B 執行緒2:任務 C --> 任務 D
JavaScript 在傳統意義上是跑在一條單執行緒。即便你的電腦有多顆核心,也只能在 JavaScript 上面跑一條執行緒來完成任務,這一條執行緒我們稱為主執行緒( main thread )。我們稍早的第二個例子執行流程就像是底下這樣:
主執行緒:渲染圓圈圖形到 canvas --> 顯示 alert()
在經過一段時間的發展後, JavaScript 取得某種工具來處理上述的問題。 Web worker 允許傳送一些任務到不同的執行緒上,因此呼叫一個 worker 你可以在同一時間跑不同的任務區塊。透過使用 worker 就可以和主執行緒分工合作,因此不會阻塞和使用者的互動。
主執行緒:任務 A --> 任務 C 背景工作執行緒:耗時的任務 B
依照這個想法,我們來改寫之前的例子 simple-sync-worker.html (線上範例),一樣打開你的 JavaScript 控制台吧。我們改寫利用 worker 產生額外的執行緒來執行一千萬次的呼叫新增日期物件的範例。現在你點擊一下按鈕,你會發現新增的文字段落馬上就會顯示在畫面上,而稍後日期才會顯示在 JavaScript 控制台。第一項操作不再阻塞第二項的執行了。
Web worker 相當有用,但還是有它的限制在。最主要的限制是在它不能直接存取 {{Glossary("DOM")}} ——你不能透過 worker 去直接更新你的 UI 。我們不能透過 worker 去渲染一百萬個藍色圈圈,它基本上只能做一些數字運算的工作。
第二個問題是即使我們的程式跑在 worker 上不會阻塞主執行緒的執行,但基本上我們還是處於同步的。當一個函式的執行依賴於多個函式的執行結果就會發生問題。看看底下的執行緒圖表:
主執行緒:任務 A --> 任務 B
在這個例子,任務 A 正在做一些像是從遠端伺服器抓取圖片的工作,任務 B 會將抓取下來的圖片套用一些濾鏡的特效。如果你執行任務 A 後立即執行任務 B ,你會收到錯誤訊息,因為任務 B 當下並沒有圖片可以套用。
主執行緒:任務 A --> 任務 B --> |任務 D | 背景工作執行緒:任務 C -------------> | |
在這個例子,我們說任務 D 必須依賴任務 B 及任務 C 的執行結果。如果我們保證在執行任務 D 的當下都已經取得 B 和 C 的執行結果,這也許沒甚麼問題,但這不太可能。任務 D 執行的當下若有任一個依賴結果尚未完成,勢必會發生錯誤。
為了修正此問題,瀏覽器允許我們非同步的運行某些操作。如 Promise 功能允許你在一項正在執行的操作做設定(例如:從遠端伺服器抓取圖片),然後等待其執行結果回傳之後再進行之後的操作:
主執行緒:任務 A 任務 B Promise : |_____非同步操作_____|
因為操作發生在其他地方,因此主執行緒並不會在執行非同步的工作時被阻塞住。
我們會在下一篇文章來看看我們如何寫出非同步的程式碼,真令人興奮,對吧?讓我們繼續看下去吧!
現代軟體越來越多圍繞在使用非同步的程式技巧來設計,讓程式碼可以在同一時間做越多事。當你使用目前越新越強大的 API 時,你會發現更多情況之下他們處理工作唯一的方法就是使用非同步的處理方式。過去我們很難寫出可以執行非同步的程式碼,但現在簡單多了。在剩餘的單元中,我們將會進一步的探討為何非同步如此重要,以及如何設計出避免上述某些問題的程式碼。