有過微信小程式開發經驗的朋友應該都知道“雙執行緒模型”這個概念,本文簡單梳理一下雙執行緒模型的一些科普知識,學識淺薄,若有錯誤歡迎指正。
我以前就職於「小程式·雲開發」團隊,在對外的一些培訓和技術分享裡經常被人問到這樣一個問題:“微信小程式與 Web 網站在技術層面的主要區別是什麼?”,在程式語言和正規化上,小程式開發與 Web 前端開發非常相似(比如都用 JavaScript 語言、與 HTML/CSS 非常相似的 WXML/WXSS 等),可它卻沒有直接用原生的前端技術。
與 Web 網站相比,以微信為宿主的小程式更需要考慮安全、效能等因素,保障小程式不會對微信App本身產生安全隱患,同時要儘量達到接近原生應用的效能和使用者體驗。這是為什麼小程式不直接用瀏覽器的執行緒模型,非要自己弄一套雙執行緒模型最主要的兩個原因。
那什麼是小程式的雙執行緒模型呢?
理解一個新概念或技術的最好的方法就是給它一個參照物,所以要搞清楚小程式的執行緒模型,首先要對瀏覽器的執行緒模型有一定的瞭解。
瀏覽器是多程式的
可能每個前端工程師在剛入行的時候都不止一次地被面試官問到“怎麼理解前端的單執行緒?”,因為前端核心技能之一的 JavaScript 語言是單執行緒的,充分理解並掌握JS單執行緒的運作方式對一個前端工程師來說是最基本的要求。但是很多初學者容易走入的一個誤區:錯誤地把 “JavaScript 單執行緒”理解為“瀏覽器單執行緒”。
事實上,瀏覽器內部架構很複雜,只不過在處理 GUI 渲染執行緒和 JavaScript 邏輯指令碼執行緒上用了互斥、阻塞的管理模式,讓一些開發者產生了誤解。
以 Chrome 瀏覽器為例,點選右上角的設定按鈕然後進入“更多工具”->“工作管理員”會看到這樣的彈窗:
能看到Chrome 開啟了多個程式,包括瀏覽器程式、網路程式、GPU 程式等,這些都是通用的程式。請注意,上圖裡有兩個標籤頁程式,Chrome 為每個標籤頁開啟了一個獨立的渲染程式( Renderer Process ),每個程式之間的資源( CPU、記憶體等)和行為( UI、邏輯等)互不共享,所以即便某個標籤頁崩潰了也不會影響其他標籤頁。
而在每個標籤頁程式中,瀏覽器會把不同的工作交給對應的執行緒,比如 GUI 渲染執行緒負責把 HTML 渲染成視覺化的 UI;JavaScript 引擎執行緒負責解析和執行 JavaScript 程式碼邏輯;定時觸發器執行緒負責處理 setTimeout/setInterval 定時器等。
多說一句,這裡有一個很容易搞混的地方,其實setTimeout/setInterval 並不是 JavaScript 語言的一部分,而是執行時(最初是瀏覽器,後來 Node.js 也支援)提供的能力。
GUI 渲染執行緒和 JavaScript 引擎執行緒是互斥的,JavaScript 在執行期間會阻塞 UI 的渲染,甚至如果指令碼執行時間太長會由於頁面長時間無響應然後崩潰,正是 GUI 渲染執行緒和 JavaScript 引擎執行緒之間的這種互斥、阻塞的執行緒管理方式,讓一部分前端開發者以為瀏覽器是單執行緒的。
那為什麼 JavaScript 被設計成單執行緒的呢?
JavaScript 祖師爺只用了 10 天就創造了這門語言,最初他的想法只是在瀏覽器中提供一些簡單的指令碼邏輯用來處理使用者互動、DOM 操作等,所以從設計上必須遵循兩點:
-
語法簡單;
-
執行機制簡單。
在語法上,JavaScript 借鑑了 Java,但是去除了很多複雜的設定,比如型別宣告、模組體系(後來加入)等。
在執行機制上,JavaScript 並沒有像 Java 那樣提供多執行緒能力,最主要就是為了避免多執行緒操作 DOM 造成 UI 衝突。比如存在多個執行緒同時操作同一個 DOM,瀏覽器該如何判斷最終的 UI 效果是採用哪個執行緒的結果?這是經典的執行緒安全(也稱為執行緒同步)問題,在多執行緒程式設計領域有很多解決方案,比如加入鎖機制,但這樣卻又帶來了更多的複雜性,與 JavaScript 簡單易用的設計初衷相違背。
這同時也解釋了為什麼 GUI 渲染執行緒與 JavaScript 引擎執行緒是互斥的:JavaScript 程式碼有修改DOM 的許可權。
當 JavaScript 程式碼被執行時,GUI 渲染執行緒會被掛起,等待 JavaScript 引擎執行緒空閒時再被執行,以免在渲染期間被 JavaScript 重複地修改 DOM 造成不必要的渲染壓力。採用互斥的模式等待 JavaScript 程式碼執行完畢後,可以保證渲染是最終的執行結果。所以瀏覽器的空閒(Idle)時長也成了衡量網站效能的重要指標之一,空閒時長多代表 JavaScript 邏輯不密集以及 DOM改動頻率低,這種情況下瀏覽器可以更快速順暢地響應使用者的互動行為,如下圖:
React Fiber就是利用idle時間進行分片任務處理。
後來,HTML5 引入了 Web Worker,提供多執行緒執行 JavaScript 程式碼的能力,但是與其他程式語言不同的是,Worker 執行緒與主執行緒並不是平行的,而是一種主從( Master-Slave)多執行緒模型。
Worker 內的 JavaScript 程式碼不能操作 DOM,可以將其理解為執行緒安全的。要記住這一點,這是後面講小程式雙執行緒模型一個重要的基礎。
那麼為什麼微信小程式不直接使用瀏覽器的執行緒模型呢?這需要從產品和技術兩個角度對比小程式與 Web 網站的差異。
為什麼小程式不使用瀏覽器的執行緒模型
我剛接觸小程式開發時,經常“嫌棄”它跟 Web 相比閹割弱化的能力、跟 Vue 相比簡單到過分的語法等。當時,我幾乎覺得小程式就是微信仗著自己龐大的使用者量搞技術壟斷。
但是,隨著對技術和產品的不斷深入理解,我對小程式的態度也有了轉變,由“嫌棄”變成了敬佩,因為在充分理解了小程式的產品定位後,我發現雙執行緒模型是在小程式這類產品場景下的最優解。那小程式是一款什麼樣的產品呢?
小程式的宿主是微信,但是小程式版本的迭代是獨立的,升級更新不依賴宿主,這一點跟 Web 網站是相同的。也就是說,小程式沿襲了 Web 的某些優勢,但它並不是 Web,目前 Web 相關的技術已經相當全面,能夠承載一些非常龐大的應用程式,比如 3D 地圖、遊戲等。
而小程式的定位是小而美、用完就走,不追求在微信中實現全部的 Web 能力,所以和 Web 來比能力上肯定差一些,同時具備一些微信提供的原生能力,比如原生元件、系統級別和微信生態的 API 等等。
另外,“小程式-微信”的關係跟“網站-瀏覽器”的關係不同,前者更接近 CodePen、JSFiddler 這類線上程式設計平臺(課裡簡稱平臺)中每個程式案例(簡稱案例)與平臺的關係。
從技術的角度上,平臺最核心的一個考量點是為案例提供足夠能力的前提下,保證案例的邏輯不會危及平臺的安全。想象一下,假如你能夠在 CodePen 上編寫一個程式來獲取 CodePen 的私密資訊,可能第二天 CodePen 就崩潰然後炒掉所有員工。
在這樣的產品基調下進行技術選型,接下來就是架構師和程式設計師的工作了。
還是以 CodePen 為例,假如讓你來設計這樣的程式設計平臺,你會用什麼技術呢?可能你第一個想到的是用 iframe,因為可以在 iframe 內使用全部 Web 能力。事實上 CodePen 確實用 iframe 來呈現程式的效果,但是並不會把輸入的 JavaScript 程式碼完全拷貝到 iframe 內執行,而是程式碼會經過一次編譯流程之後才會被注入 iframe 內。這樣做的出發點主要是基於安全的考慮,在編譯過程中將一些危險的程式碼剔除;其次這樣做還能在平臺中支援更多語言,比如typescript。當然,還有效能,效能問題是 iframe 老生常談的問題了,我就不多說了。
所以,不僅要使用 iframe,還需要引入額外的 JavaScript 編譯器。CodePen 一定要保證每個案例的 JavaScript 程式碼是執行緒安全的,最基本的就是要禁止程式操作CodePen 網站的 DOM ,實現這一點有兩個方法:
-
一個是 Web Worker;
-
另一個是使用 Shadow DOM。
Web Worker 是執行緒安全的,Worker 內的 JavaScript 程式碼無法獲取 Window 和 Document 物件,也就無法操作 DOM。除此之外,由於 Worker 的執行緒安全特性,Worker 內的程式碼執行過程中不會阻塞外層的 GUI 渲染執行緒,兩者可以並行。
Shadow DOM 是 Web Components 規範的一部分,將 ShadowRoot 的模式設定為 closed
就可以禁止獲取到 ShadowRoot 節點,從而也無法操作其內部的 DOM。
兩者相比,Shadow DOM 的相容性比 Web Worker 更差,距大規模使用的日期還很遙遠,所以 Web Worker 的方案更現實一點。
這樣就形成了一個簡易的雙執行緒模型:Worker 執行緒負責計算,將結果通過 postMessage 傳遞給主執行緒,主執行緒負責渲染。
但是這個模型存在比較嚴重的效能問題,Web Worker 非常耗費資源,除去計算消耗以外,與主執行緒的通訊過程對效能的損耗也非常嚴重。
那有沒有辦法實現跟 Web Worker 一樣的執行緒安全,同時又兼顧效能保證良好的使用者體驗呢?這便是微信小程式採用雙執行緒模型的主要目的。
安全高效的雙執行緒模型
雖然前面用了 CodePen 這類程式設計平臺做類比,但小程式與 CodePen 的技術需求並不完全相同,主要區別在於小程式並不需要支援所有的 HTML 標籤,只提供有限的幾類 UI 元件,根據小程式產品定位,我們可以歸納出小程式的主要技術需求可以歸納為下面這樣幾點。(任何新技術或架構都是為了解決特定的問題,所以有必要了解小程式的主要技術需求。)
-
限制 UI 元件型別,只允許宣告指定的幾個元件
小程式在宣告元件時並不是使用原生的 HTML 標籤,而是隻能夠通過微信提供的幾種內建基礎元件,當然你也可以自定義元件,但也是通過對內建基礎元件的組合來實現。
-
保證邏輯執行緒安全,不允許直接操作 UI 元件
小程式更新 UI 的方式與 Vue/React 等 MVVM 框架類似,JavaScript 程式碼不能直接操作 DOM(僅做類比,事實上小程式中沒有DOM的概念),而是通過更新狀態( setState )的方式非同步更新 UI ,這個過程中會用到 VDOM 和高效的 diff 演算法(這兩點並不是我們要討論的內容,你課下可以自己搜尋相關資料)。
-
能夠線上更新,不依賴微信
小程式的宿主是微信,如果使用純 Native 實現,那麼小程式的版本更新必須依賴微信,跟微信的程式碼一起發版,這樣肯定是不行的。如果是純 Web 實現,安全和效能就很難得到保障。
小程式需要既能夠像 Web 一樣將資源託管在雲端,更新獨立;同時又能夠保證足夠好的安全性和效能。所以最終小程式採用了一種混合的架構模式:使用 Webview 渲染 UI、使用類似Web Worker 的獨立執行緒執行邏輯,這就是雙執行緒模型。
-
效能需儘量提升,保證使用者體驗
前面提到的基於 Web Worker 的簡易雙執行緒模型效能是很大的問題,小程式的雙執行緒模型並不是使用 Web Worker 子執行緒,而是一個獨立的“主執行緒”,這樣能夠保證相對較好的效能。
渲染執行緒和邏輯執行緒
小程式的雙執行緒指的就是渲染執行緒和邏輯執行緒,這兩個執行緒分別承擔UI的渲染和執行 JavaScript 程式碼的工作。如下圖所示:
渲染執行緒使用 Webview 進行 UI 的渲染呈現。Webview 是一個完整的類瀏覽器執行環境,本身具備執行 JavaScript 的能力,但是小程式並不是將邏輯指令碼放到 Webview 中執行,而是將邏輯層獨立為一個與 Webview 平行的執行緒,使用客戶端提供的 JavaScript 引擎執行程式碼,iOS 的JavaScriptCore、安卓是騰訊 X5 核心提供的 JsCore 環境以及 IDE 工具的 nwjs 。
邏輯執行緒是一個只能夠執行 JavaScript 的沙箱環境,不提供 DOM 操作相關的 API,所以不能直接操作 UI,只能夠通過 setData 更新資料的方式非同步更新 UI。
事件驅動的通訊方式
注意上圖渲染執行緒和邏輯執行緒之間的通訊方式,與 Vue/React 不同的是,小程式的渲染層與邏輯層之間的通訊並不是在兩者之間直接傳遞資料或事件,而是由 Native 作為中間媒介進行轉發。
整個過程是典型的事件驅動模式:
-
渲染層(也可以稱為檢視層)通過與使用者的互動觸發特定的事件 event;
-
然後 event 被傳遞給邏輯層;
-
邏輯層繼而通過一系列的邏輯處理、資料請求、介面呼叫等行為將加工好的資料 data 傳遞給渲染層;
-
最後渲染層將 data 渲染為視覺化的 UI。
這種資料驅動 UI 的模式是現在前端程式設計領域較為推崇的程式設計正規化,如果你是一個超過 5 年開發經驗的前端開發者的話,那麼我相信在最初接觸到這種模式的時候肯定有一些不適應,因為在此之前 JavaScript 操作 DOM 幾乎是一種“業內規則”,甚至有不少針對前端入門的圖書、部落格和教材都是先從 DOM 操作講起,現在看來這些確實有些不合時宜了。
而這樣邏輯與渲染分離的執行緒分工模式一方面能夠保證執行在邏輯執行緒沙箱內的 JavaScript 程式碼是執行緒安全的,另一方面由於渲染執行緒的計算量非常小從而保證了對使用者互動行為的快速響應,提高了使用者體驗。
總的來說,跟瀏覽器的執行緒模型相比,小程式的雙執行緒模型解決了或者說規避了 Web Worker 堪憂的效能同時又實現了與 Web Worker 相同的執行緒安全,從效能和安全兩個角度實現了提升。可以概括地說,雙執行緒模式是受限於瀏覽器現有的程式和執行緒管理模式之下,在小程式這一具體場景之內的一種改進的架構方案。
總結
在我看來,程式設計師的核心能力和競爭力並不是充分了解某種語言或框架的 API ,而是這些語言和框架底層的原理知識。對一個小程式的開發者來說,在工作中遇到技術難題時的解決方案往往是基於底層原理的(甚至更直白一點,當你找工作面試時,沒人會問你小程式的語法)。
通過了解小程式雙執行緒模型的背景、設計、通訊,希望能夠讓大家更深入地理解小程式的底層架構,如果在後續工作中有類似場景的需求也可以作為借鑑。當然,瞭解小程式的雙執行緒模型並不是唯一的目標,這些知識在一定程度上能對日常開發工作產生一些啟示,主要是效能方面:
-
在保證功能的前提下儘量使用結構簡單的 UI;
-
儘量降低 JavaScript 邏輯的複雜度;
-
儘量減少 setData 的呼叫頻次和攜帶的資料體量。