如何使用 WebAssembly 擴展後端應用
1.WebAssembly 簡介
爲了解決這些問題,Mozilla 的工程師 Alon Zakai 在 2012 年提出了 Asm.js。之後,經過幾年的發展,最終在 2015 年演變成了 WebAssembly。
WebAssembly(簡寫爲 Wasm)是一種用於堆棧式虛擬機的二進制指令格式。它的設計目的,是成爲其它編程語言的一個可移植的編譯目標,以便在 Web 上佈署客戶端和服務端應用。
這是 WebAssembly 官網上的定義,從這個定義中我們可以知道,WebAssembly 是一種二進制指令格式。但在日常的討論中,我們也常常把 WebAssembly Text Format 稱爲 WebAssembly,而這種文本格式實際上是一種編程語言。
正式發佈後,WebAssembly 迎來了迅猛的發展,到 2017 年 11 月,Mozilla 宣佈包括 Chrome、Firefox、Safari 等在內的所有主流瀏覽器都已經支持 WebAssembly,而根據 2021 年 7 月的數據,用戶正在使用的瀏覽器中,已經有 94% 支持了 WebAssembly。
在得到了瀏覽器的廣泛支持之後,一些重量級的應用也被逐漸被移植到了 Web 端,其中包括:
-
Google Earth - 一個 3D 的地圖應用
-
AutoCAD - 一個工業製圖的應用
-
Doom - 一個經典的第一人稱的射擊遊戲
-
TensorFlow - Google 開源的機器學習框架
-
……
這些案例也說明 WebAssembly 達到了自己的設計目標——在 Web 上部署桌面上的原生應用。而 WebAssembly 能夠獲得如此快速的發展,得益於它的幾個特點:
-
性能好。接近機器代碼的運行速度,評測表明,WebAssembly 只比原生代碼慢大約 10%。
-
體積小。加載速度快,WebAssembly 是一種緊湊的二進制格式,體積通常遠小於完成同樣功能的 Javascript 代碼。
-
安全高。WebAssembly 代碼運行在沙箱內,默認無法進行任何外部訪問。
-
多語言。WebAssembly 並不限制用戶使用何種語言開發,只要有相應的編譯器,任何語言都可以被編譯成 WebAssembly。
2.WebAssembly 在後端的應用
在 WebAssembly 的官方定義中,“用於堆棧式虛擬機的”這個定語也非常值得關注,因爲它導致 WebAssembly 這項最初以 Web 端爲應用場景,以至於名字中都包含 “Web” 這個詞的技術,慢慢進入了後端應用的領域。
這是因爲,從早期的 VMWare WorkStation、VirtualBox,到今天的 Docker,虛擬化技術一直是雲計算的重要基礎。所以,WebAssembly 作爲一種具有很多特點的虛擬機代碼格式,進入後端應用領域是必然趨勢。Docker 的創始人 Solomon Hykes 在 2019 年說 “如果 2008 年就有 WASM 和 WASI,我們就不用發明 Docker 了”,對其在後端應用的前景之看好,可見一斑。
當然,Solon Hykes 後來也說他的意思不是 “WebAssembly 會取代 Docker”,這也是當今業界普遍的觀點:WebAssembly 和 Docker 各有優勢,互爲補充。具體來說:
-
WebAssembly 程序的體積通常只有 1M 左右,而 Docker 鏡像則動輒超過 100M,所以 WebAssembly 具有快得多的加載速度。
-
WebAssembly 程序的冷啓動速度大約是 Docker 容器的 100 倍。
-
WebAssembly 默認運行在沙箱內,任何與外部的交互,都只有獲得明確的許可之後才能進行,具有極佳的安全性。
-
WebAssembly 模塊僅僅是二進制的程序代碼,不包含操作系統環境,所以不可能像在 Docker 中那樣簡單的編譯下就能執行。
下面,通過實際的示例介紹下如何在後端應用中使用 WebAssembly。
2.1. 在應用中嵌入 WebAssembly
如下圖所示,不管是 Web 應用還是非 Web 應用,要使用 WebAssembly,都需要在宿主程序中嵌入 WebAssembly 運行時引擎,區別只在於,在 Web 應用中,這個宿主程序是瀏覽器,而在非 Web 應用中,宿主程序是我們自己開發的,具體到本文關注的後端應用,宿主程序則是我們的後端服務程序。
目前可選的 WebAssembly 運行時引擎有 Wasmtime、WasmEdge、WAVM、Wasmer 等很多種,各有自己的優勢和缺陷。本文將以 Wasmtime 爲例,介紹如何在以 Go 語言開發的宿主程序中嵌入 WebAssembly。
嵌入 WebAssembly 運行時引擎並實例化 WebAssembly 模塊本身非常簡單,在省略錯誤處理的情況下,只要下面幾行代碼即可:
其中涉及了實際開發中需要了解的幾個重要概念,簡單介紹如下:
-
引擎(engine):用於編譯和管理模塊的全局上下文。
-
模塊(module):編譯後的 WebAssembly 模塊。
-
倉庫(store):WebAssembly 程序和宿主之間的紐帶,維護各種關聯信息。
-
實例(instance):實例化的模塊,是真正可以執行的程序。
-
鏈接器(linker):wasmtime 中的一個工具對象,用於實例化模塊。
雖然上面的代碼創建了 WebAssembly 模塊的實例,並且根據 WebAssembly 的規範,模塊實例化時就可以執行 WebAssembly 代碼,但由於安全性的限制,其執行結果無法對外輸出,所以這種 “執行” 毫無意義。因此,我們需要實現宿主程序和 WebAssembly 程序的互操作,爲 WebAssembly 程序提供輸入 / 輸出接口。
2.2. 宿主調用 WebAssembly
假設我們的 WebAssembly 程序中有一個名爲 sum 的函數,接收兩個整形變量作爲參數,返回它們的和,則宿主程序可以使用下面的代碼來調用這個函數:
雖然不同的宿主開發語言和 WebAssembly 運行時引擎具體的調用方式有區別,但運行時引擎的文檔一般都有相關說明,所以這一步照着文檔做就好,沒有難度。
這裏的難點在於,如何才能在 WebAssembly 程序中暴露出這個函數,以便宿主程序能找到並調用它。前面說過,只要有相應的編譯器,各種語言都可以編譯成 WebAssembly,但大多數語言設計時並沒有考慮 WebAssembly 的需要,也就沒有提供暴露函數的方法。所以這個問題只能通過特定編譯器的非標準擴展來解決。也就是說,找到這個非標準擴展是解決問題最關鍵的一步。但也正由於 “非標準”,所以相關的資料有時並不容易找到。
作爲示例,下面給出的是使用 C/C++(編譯器是 emscripten)和 AssemblyScript 時對外暴露函數的方法:
2.3. WebAssembly 調用宿主
與宿主調用 WebAssembly 類似,運行時引擎的文檔中一般會介紹宿主端如何暴露一個函數給 WebAssembly 程序。
問題的難度同樣在於 WebAssembly 程序中如何使用語言的非標準擴展來導入這個函數,下面是 C/C++ 和 AssemblyScript 中的具體方法:
2.4. 複雜參數的傳遞
宿主和 WebAssembly 程序相互調用對方的函數時,也需要傳遞參數和返回值,如果是整數等簡單數據類型,直接傳遞即可。但當數據類型是字符串等複雜類型時,就會遇到新的問題,具體有兩點:
-
宿主程序和 WebAssembly 程序的開發語言一般並不相同,所以複雜參數的內存佈局也不同,直接傳遞的話,接收方根本理解不了,也就沒有辦法使用。
-
由於安全性設計,宿主程序和 WebAssembly 程序的內存是隔離開的,WebAssembly 程序訪問不了宿主的內存。
因爲宿主程序可以訪問 WebAssembly 的內存,所以第二個問題的解決方法是 WebAssembly 程序暴露內存管理的相關函數,讓宿主來操作 WebAssembly 的內存,例如:
之後,我們可以藉助這些內存管理函數,並通過序列化 / 反序列化相關數據類型來傳遞它們,比如在下面 WebAssembly 調用宿主函數的代碼中,參數和返回值本來各是一個字符串,但通過序列化 / 反序列化之後,只要傳遞它們在 WebAssembly 內存中的地址(整數)即可:
2.5. SDK
看了上面宿主程序和 WebAssembly 程序互操作的過程,相信大家已經發現它和 RPC 調用的過程非常像。不同的是,在 RPC 調用中,一系列非常繁瑣的序列化 / 反序列化操作,都由工具自動生成代碼實現,使用者根本無需關心。
而在 WebAssembly 應用的開發中,用戶也不希望每次都處理那麼多的細節。所以,作爲宿主程序的開發者,我們需要爲用戶提供相關 SDK,屏蔽掉底層細節,讓用戶能夠專注於業務邏輯的開發。
由於用戶可以使用多種語言開發 WebAssembly 應用,所以我們需要提供針對不同語言的 SDK,或者至少也需要覆蓋目標用戶使用的主流語言。
2.6. 錯誤處理
像普通程序一樣,WebAssembly 程序也會有各種錯誤,雖然作爲宿主程序的開發者,我們無法預知具體的錯誤,但我們必須把這些錯誤的影響範圍限制在 WebAssembly 虛擬機內部,不能讓它們影響宿主程序的功能。
宿主程序要防範的第一類錯誤是 WebAssembly 程序中的死循環。實際場景中,其實宿主程序並沒有辦法判斷是否真的出現了死循環,所以,折衷的解決辦法是給 WebAssembly 程序設置一個最大運行時間,一旦超過了這個時間還沒有結束,就認爲裏面出現了死循環,並終止它的執行。終止執行的示例代碼如下:
要防範的第二類錯誤是 WebAssembly 程序中的非法內存訪問等導致的崩潰,WebAssembly 運行時引擎一般會將這類錯誤轉換爲宿主程序中的異常(在 Go 語言中是一個 panic),我們只要處理這個異常即可。由於宿主程序並不知道具體的錯誤原因,也不知道 WebAssembly 程序出現錯誤後的具體狀態,所以宿主程序能進行的處理一般只能是釋放出問題的 WebAssembly 虛擬機佔用的資源並重新創建一個虛擬機來替代它。
- Easegress 中的 WebAssembly ===========================
Easegress 是 MegaEase 開發的下一代流量型網關,具有云原生、高可用、可觀測、可擴展等特點。在 Easegress 之前,市場上已經有包括 nginx 在內的多個成熟網關產品。但 MegaEase 認爲,流量調度型網關並不僅僅是一個反向代理,還需要能夠動態的進行流量編排和調度。此外,在具體應用場景中,還涉及各種各樣的業務邏輯侵入,因此,必須高度可擴展。
基於以上觀點,Easegress 從誕生之日就將可擴展性放到了重要位置,並在多個層面進行了針對性的設計。
首先,在開發語言的選擇上,我們有 C/C++/Java/Rust/Go 這些主流的靜態語言的選項
-
使用 C/C++ 或 Rust 肯定會給 Easegress 帶來最好的性能,但這些語言門檻太高,一般用戶很難掌握,尤其在寫業務邏輯的代碼上效率實在是太低。也就談不上通過修改代碼來擴展業務邏輯了
-
Java 易學易用,也非常適合寫業務邏輯,但是體積大,而且性能不能滿足要求;
-
相對而言,Go 簡單易學,性能也比較好,特別是在 Easegress 所處的網絡應用領域,由於語言本身的特殊設計,很多場景下與 C/C++ 的性能差距基本可以忽略。所以,Easegress 最終選擇了 Go 作爲開發語言。但不管使用什麼語言,源碼級的擴展都不可避免的將用戶限制到一門特定的語言,而且會涉及重新編譯、重新佈署、重新啓動,造成服務的中斷。
第二,通過外部調用的方式擴展,如 FaaS。通過雲原生的 FaaS 方式的好處是不限制用戶的開發語言,而且可以讓整個架構具有很好的可伸縮性,但其缺點是需要引用 Kubernetes 等的比較重的外部依賴,導致整個運維非常複雜(注:Easegress 目前已經支持了 Knative)
第三,通過嵌入其它語言的解釋器進行擴展。這方面,Lua 本身就是爲嵌入其它程序而設計的,具有相應的優勢。但我們認爲 Lua 也有兩個缺點,一是本身表現力不夠,不適合寫複雜的業務邏輯;二是太小衆,不是主流語言,有相關經驗的程序員太少。所以,經過權衡,Easegress 最終選擇了嵌入 WebAssembly,主要基於兩點考慮:一是接近於原生代碼的高性能;二是不限制用戶的開發語言,用戶可以使用自己喜歡或熟悉的語言開發業務邏輯。
作爲使用 WebAssembly 擴展業務邏輯的樣例,我們之前已經發布了《使用 Easegress + WebAssembly 做秒殺》,歡迎大家閱讀並向我們反饋更多的實際案例。
當然,選擇了 WebAssembly 意味着我們需要開發多種語言的 SDK,目前已經完成了 AssemblyScript SDK 的開發,相信在 MegaEase 和整個開源社區的共同努力下,我們支持的語言會越來越多。歡迎大家關注我們的開源社區:https://github.com/megaease/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/BZfqLTUlnEEUKqrMAI7X5A