來源:映維網
多線程(Multithreading)是指從軟件或者硬件實現多個線程并發執行的技術。具有多線程能力的計算機因有硬件支持而能夠在同一時間執行多于一個線程,進而提升整體處理性能。
微軟混合現實技術專家賈里德·拜恩茲(Jared Bienz)是一位著名的軟件架構師,有著20多年的從業經驗。日前,拜恩茲撰文分享了自己在AR/VR/MR多線程處理方面的八年經驗和技巧。下面是映維網的具體整理:
要正確實現多線程并不容易,但它對于資源受限的移動設備流暢運行模擬至關重要。在供職于微軟的生涯中,我有機會在四年多的時間里幫助合作伙伴為HoloLens編寫高性能的應用程序。我另外有4年多的時間幫助合作伙伴為智能手機和平板電腦編寫高性能應用程序。
我早已有意撰寫這篇文章。這基本上是我對AR/VR/MR模擬的多線程處理的8年經驗分享。盡管本文主要關注Unity和C#,但我希望其中介紹的概念依然能夠為所有語言和運行時的模擬開發者帶來價值。
1. 什么是線程?
我知道這是一個基礎性的問題,但我從它開始寫起是有一個重要原因。這個理由會在本章后面變得清晰起來。
維基百科將線程描述為:可以與其它指令并發執行的一系列指令。
我強調并發執行是因為它對這次討論至關重要。并發運行多個任務的能力使得線程對于模擬至關重要。
2. 關于內核與線程的簡要說明
一個CPU可以有多個內核,而有些內核可以運行多個線程。例如,Ryzen Threadripper最多有64個內核,每個內核可以運行2個線程。這意味著,如果你編寫的模擬屬于高度多線程,你可能會有多達128個不同的任務同時發生。你可以用這些線程來運行NPC的人工智能大腦,或者在物理模擬中制造碰撞。
但請記住,大多數實際場景不會接近128個線程。即使是英特爾的旗艦i9 10900k都只是提供20個并發線程。不過,編寫多線程代碼意味著提供多個內核的設備可以同時發生多個任務。
3. 線程如何影響應用程序
即使你不依賴先進的人工智能,但幾乎所有的MR應用都在某種程度上使用物理。例如,Hand Menu菜單中的按鈕會使用物理來檢測指尖何時接觸按鈕的表面。
但遠比物理更重要的是渲染。
幾乎所有的游戲引擎(包括Unity)都依然依賴于單線程進行渲染。沒錯,只有一個線程可以在屏幕上繪制。即使是超底層的Directx API都只支持在輔助線程上排隊命令。相關命令依然需要發送到渲染線程進行繪制。這是一個特別的線程。
正如你可以想象的那樣,從渲染線程獲取代碼可以釋放引擎以繪制內容。你將獲得更高的幀速率,看到更少的卡頓和頻閃。你的應用程序會感覺更加高響(高響應速度)和穩定。
4. 好吧,所以不要在Render Thread運行代碼嗎?
這聽起來顯然像是在逃避,不是嗎?但事實證明,Render Thread是所有代碼運行的默認位置。不僅如此,在Render Thread運行代碼是不可避免的事情。為了說明原因,我們下面來看看一個基本的Unity立方體。
使得立方體成為立方體的主要原因之一是稱為網格渲染器(Mesh Renderer)的行為。網格渲染器做什么?當然,它繪制立方體。換句話說,為了使一個Unity立方體成為一個立方體,它必須存在于Render Thread之上。
Unity通常將Render Thread稱為主線程、應用線程、以及UI線程。請注意,它們都是同一個意思。
5. coroutine(協程)與線程
當Unity開發者發現coroutine時,大多數人認為他們已經發現了多線程。遺憾的是,事實遠非如此。
Unity負責一位coroutine的博士指出:coroutine就像一個函數,它可以暫停執行并將控制權返回給Unity,但然后會在下一個幀中繼續執行。
重要的是要意識到coroutine依然是在Render Thread上運行。
想象一下一個簡單的Unity應用程序在這樣的循環中運行:
如果行為A啟動兩個coroutine,則循環將簡單地更改為:
coroutine和正則函數的唯一區別在于,coroutine的一部分可以在幀之間掛起。掛起時會包含存在關鍵字yield的任何行。盡管這可能會騰出時間讓其他任務運行,但編寫糟糕的coroutine依然非常容易給Render Thread造成巨大的負載。
coroutine異常:你知道在coroutine出現異常會發生什么嗎?可能不是你想象的那樣。異常不會停止應用程序,甚至不會禁用Behavior。唯一發生的事情是,coroutine從更新循環中unscheduled。Behavior不會注意到錯誤,甚至不知道coroutine已經被unscheduled。
由于coroutine不是并發運行,所以最好把它看作是一個時間切片機制。它們不是真正的多線程。
6. Thread.Start又如何?
我們終于聊到多線程的第一個實際選擇。System.Threading.Thread實際上代表一個線程,而調用Thread.Start將導致任務在所述線程并發運行。
但對于Thread類,你需要理解Thread類的實例表示一個能夠執行工作的對象,而不是請求完成工作。許多函數可以安排在Thread上運行,而等待Thread完成并不一定意味著函數成功完成。例如,異常可能會發生。
正是由于這些原因,通用Windows Platform(HoloLens運行的平臺)甚至不包括System.Threading.Thread。相反,UWP提供了一種名為ThreadPool的元素,其中各個工作項可以進行scheduled。
在本文中,我不打算討論Thread或ThreadPool,因為我希望重點討論另一種方法。不過,我還是想簡單地講講這些問題,因為過去使用Thread的Unity開發者會由于代碼無法為HoloLens編譯感到困惑或沮喪。Thread類可能會被添加到UWP的未來版本中,但我希望證明即使它可用,我們仍然有更好的模式可以遵循。
7. 回調中的“貓膩”
什么是回調?維基百科將回調定義為:作為參數傳遞給其他代碼的任何可執行代碼…這個執行…可能會在稍后的異步回調中發生。
編寫“經典”多線程代碼的開發者非常熟悉回調,因為一旦你開始并發運行代碼,不知何故你需要知道它是于何時完成。
下面是一些關于回調如何工作的偽代碼:
但如果代碼永遠都沒有完成呢?如果因為文件被鎖定或數據損壞而在第9行引發異常怎么辦呢?
回調永遠不會被調用。
如果沒有額外的編碼,應用程序將永遠不會知道發生了錯誤。就應用程序所知,LoadData已成功運行。這是因為異常沒有發生在LoadData中,而是發生在LoadData創建的線程中。
對于嘗試編寫和調試多線程代碼的開發者來說,不停止(orphan)的回調一直是痛苦的根源。簡而言之,這是因為請求、工作和結果是完全分離的。
回調與事件:請注意,回調模式有時可以作為事件實現。Azure Spatial Anchors在搜索錨點時會執行這一操作。應用程序調用CreateWatcher開始搜索,當找到錨定時,結果將通過AnchorLocated事件傳回。這有時會導致意想不到的情況。例如,如果在服務器上撤銷了錨定,則AnchorLocated事件將以NotLocateDanchordesNotExist的狀態觸發。另外,如果發生網絡錯誤,應用程序不會知道,除非它同時訂閱了Error事件。我并不是說這是一個糟糕的設計(見下文),但顯然,成功地使用基于事件的回調系統需要了解哪些情況會導致哪些事件。
8. 什么是跨線程調度(scheduling)?
讓我們再看看之前的偽代碼:
你注意到第12行對loadCompleted的調用實際上是在worker線程中執行的嗎?如果我們想在數據加載后可視化,這會成為一個問題。請記住,loadCompleted是在worker線程上運行的,但我們只能在Render Thread創建GameObject。這就需要跨線程scheduling。
在Azure Spatial Anchors for Unity示例中,你可以找到一個名為UnityDispatcher的腳本。UnityDispatcher允許在任何線程上運行的代碼請求該代碼在Render Thread上運行。你甚至可能在沒有意識到的情況下看到了這一點。
以下是OnCloudAnchorLocated handler的代碼片段:
每當ASA定位到一個錨時,AnchorLocated事件將在worker線程上觸發。如果應用程序只需將消息寫入日志,則可以接受這個worker線程。事實上這是更好的選擇。但這個應用程序需要生成一個GameObject或移動一個現有的GameObject,這兩個操作只能在Render Thread上進行。InvokeOnAppThread表示“我知道我已經在一個worker線程,但我需要調用在Render Thread運行的代碼”。
9. Unity的跨線程調度
盡管所有多線程系統都有自己的scheduling方式,但Unity的方法有點不尋常。據我所知,Unity沒有提供直接的API來調度渲染線程上的工作。他們提供的是一種間接的方式。
UnityDispatcher保留了需要在Render Thread上運行的命令的列表。當一個worker線程調用InvokeOnAppThread時,這只會將代碼添加到列表中。當應用程序啟動時,UnityDispatcher將自己注冊為coroutine。然后在每個幀上,UnityDispatcher檢查列表中是否有任何內容。如果是這樣,所述代碼將作為UnityDispatcher的Update例程的一部分執行。
UnityDispatcher沒有綁定到Azure空間錨,因此你可以復制該類并在任何項目中使用它。如果沒有ASA,你也可以從GitHub上的ThreadUtils項目中獲取這個類的副本。
10. Task-based Programming
針對C++開發者的說明:我將要開始討論Task-based Programming。我將介紹一個名為Task的C#,但你不會在C++ / WinRT中找到Task。相反,C++開發者使用IasyccAct之類的接口,而當從C#調用時,這些接口會自動轉換為Task。更多信息請參閱這里。
如上所述,調試多線程代碼非常困難,因為請求、工作和結果都是相互分離的。但我向你承諾過一個更好的方法,我想現在是時候討論它了。
許多開發者都知道Task-based Programming,但很少有人真正了解它在幕后的工作原理。Task-based Programming統一了我們前面討論過的概念,大大減少了多線程代碼中出錯的機會。下面我們來看看Task-based Programming是如何簡化線程、回調和跨線程scheduling。
11. Auto Threading
在C#中,每當一個函數被async關鍵字修飾時,我們告訴編譯器的是“這個代碼可以在另一個線程上運行”
讓我們來看看將數據保存到文件中的一些偽代碼:
在本例中,打開文件、寫入字節和關閉文件都將在worker線程上執行。
這個神奇的worker線程是什么時候創造出來的呢?它是在使用await操作符時創建的。
如果我們的示例應用程序具有以下代碼行:
這相當于:
異步函數中的代碼確實在新線程上運行。但你的應用程序不需要知道這些細節,也不需要太多地關注。
更妙的是,正如斯蒂芬·圖布(Stephen Toub)常常說的:“等待的一個美妙之處就是它能把你帶回原來的地方”。讓我們看看這句話在另一個代碼示例中的含義吧:
我們知道這段代碼是從Render Thread開始的,因為它是對按鈕點擊的響應。所以在第4行與GameObject交互是有意義的。但我們同時知道,第7行的await關鍵字啟動了一個新線程。所以,如何才能與第10行和第11行的GameObjects交互呢?
答案是一個叫做SynchronizationContext的元素。簡而言之,無論何時使用await,編譯器都會記住在worker線程啟動之前有哪個線程正在運行。編譯器同時會在worker線程完成后立即處理返回Starting Thread的操作。是的,await自動處理跨線程scheduling。
重要提示:await永不加塞。看起來像是await妨礙了Starting Thread,但這只是編譯器的錯覺。await之前的所有內容都是內聯運行,而且await之后的所有內容都由調度程序運行。當Task在另一個線程中運行時,Render Thread就是這樣保持繪制的。
12. 跟蹤工作
正如我在Thread.Start一節指出地那樣:線程表示一個能夠執行工作的對象,而不是請求完成工作。這是Task-based Programming的另一個亮點。任何Task實例實際上都表示一個要完成的工作的請求。這正是Task類擁有IsCompleted和IsFaulted之類的屬性。
13. 數據與異常
我在上面提到回調和事件可以在worker線程完成時返回數據。我同時提到了worker線程上的異常通常意味著回調不運行或事件不觸發。Task-based Programming通過將數據作為請求本身的一部分來解決這個問題。
讓我們來看看相同的LoadData函數,但我們將其作為Task而不是回調來實現:
讓我們假設第7行執行一些數據反序列化。大多數時候,這一切都很好,但偶爾我們的應用程序會打開一個損壞的文件,第7行會出現一個異常。請記住,這個異常是在worker線程上引發的。那么,Starting Thread如何處理這個異常呢?比你想象的要簡單:
當我們等待一個Task并且該Task成功時,來自該Task的任何數據都將返回到Starting Thread。但是,如果我們等待一個Task,并且在Task內部發生了異常,該異常將傳播回Starting Thread,就像它是內聯發生一樣。換句話說,在Task中處理異常和在任何普通函數中處理異常都是一樣的。
希望大家能夠開始明白為什么Task-based Programming會使多線程變得更容易。Task-based Programming提供了一個單一的統一模型。在這個模型中,請求、工作和結果都真正地相互關聯。
14. 當撤銷(Cancellation)非常重要的時候
在某些情況下,Task可能會運行很長時間。例如,在慢速網絡上下載大文件時。在這些場景中,使Task變得可撤銷通常會很有幫助。可以通過將CancellationToken傳遞到異步函數來實現。然后,在運行一會后,所述函數可以在執行更多操作之前檢查Token是否已撤銷。
下面是所述函數的可能樣子:
盡可能頻繁地檢查CancellationToken非常重要,這樣可以快速撤銷Task。調用ThrowIfCancellationRequested時,如果Token已被撤銷,則整個Task以OperationCanceledException結束。
既然我們已經看到Task可以撤銷,下面我們來想象一下使用Task的Azure Spatial Anchors:
我并不是建議ASA應該停止使用事件而開始使用Task。ASA可以同時搜索多個錨,而ASA從不知道何時(甚至是否)定位錨。事件在這種情況下的效果很好,只要你知道什么時候觸發了哪些事件。但是,除了事件之外,添加對Task的支持可以幫助簡化許多常見的場景。
15. coroutine還是有一席之地的
既然我們已經知道Task的作用,有人可能會問為什么我們要用其他方式編寫代碼。但請記住,Task在worker線程上運行,而GameObject只存在于Render Thread上。這就是coroutine的意義所在。
coroutine在Render Thread上運行,但可以將時間返回到渲染器。訣竅是在yielding之前確定工作量。太少會需要很長時間,而太多則會導致應用程序沒有響應。
讓我們想象一個能夠接收數據并用GameObject可視化的coroutine吧:
為了保持60 FPS,應用程序需要在大約16毫秒內渲染幀。我們假設我們的應用程序需要4毫秒來渲染。剩下的12毫秒可以用來創建GameObject。
如果第10行需要2毫秒,我們就會剩下6毫秒的容量。不僅如此,我們的應用程序每幀只能創建一個GameObject。
在本例中,更好的實現可能如下所示:
在C#中,%運算符計算余數。所以這里我們說的是“每6個對象之后,把時間還給渲染器。”6個對象x每個對象2毫秒=12毫秒(正好是我們的預算)。
顯然,這個數字對于每個應用程序而言都是獨一無二,并且會隨著時間的推移而變化。應用程序可能會變得更加復雜,需要更長的時間來渲染。或者每個單獨的GameObject可能會變得更復雜,需要更長的時間來創建。沒有神奇的數字。要達到正確的平衡,你需要花時間分析性能。
16. 將coroutine視作Task
所以coroutine有自己的用武之地,但現在我們有兩種不同的方法來處理長時間運行的代碼。不僅如此,除非我們實現某種回調,否則應用程序將不知道VisualizeRoutine何時完成(我們已經知道回調中的“貓膩”)。如果我們能把coroutine當作Task來對待,那不是很好嗎?
有一個名為TaskCompletionSource的類允許你將任何長時間運行的進程表示為一個Task。具體如下:
在一個長時間運行的流程開始時,創建TaskCompletionSource。使用TaskCompletionSource.Task表示長時間運行的過程。完成后,使用TaskCompletionSource.SetResult返回數據。如果進程遇到錯誤,請使用TaskCompletionSource.SetException來傳播異常。
我們可以很容易地修改VisualizationRoutine以接收TaskCompletionSource,并在完成后返回一些數據:
剩下的只是啟動coroutine并返回Task的helper函數:
17. coroutine中的異常處理
如果你仔細觀察,你可能已經注意到上面的coroutine中有一個非常重要的遺漏。如果在第26行之前產生異常會發生什么事情呢?
遺憾的是,coroutine不能提供與async相同的編譯器效果。coroutine中沒有自動異常傳播,這意味著如果我們不處理異常,我們將以產生一個不停止的Task。任何等待Task的代碼將永遠不會恢復。如果你認為這聽起來很像是一個不停止的回調,你絕對正確。
你說:“沒問題。我把所有一切都打包到一個try/catch block中。”
可能看起來像這樣:
這正是你要做的事情,除了現在第24行生成了一個CS1626編譯器錯誤。
錯誤CS1626無法在帶有catch clause的try block中生成值。
CS1626出現的原因非常復雜,但你只需知道你不能將try/catch放在任何使用yield的行中。這給我們留下了兩個可能的選擇:
在任何非yield行周圍放置多個try/catch block。在IEnumerator周圍放置try/catch
選項1最簡單,但并非所有情況下都有效。例如,你不能將try/catch放在foreach語句周圍,因為foreach語句包含一個yield。
但我們如何實現選項2?通常,IEnumerator直接傳遞到startRoutine。
遺憾的是,事情變得麻煩起來。IEnumerator接口有一個屬性和兩個函數。我們必須確保,若任何part-IEnumerator產生異常,我們就將結束Task。
為了幫助解決這個問題,我創建了ExceptionSafeRoutine。你可以在GitHub的AsyncUtils.cs中找到它。ExceptionSafeRoutine接受一個IEnumerator和一個TaskCompletionSource。如果在IEnumerator中引發任何異常,則在TaskCompletionSource設置該異常。還有一個擴展方法可以將任何IEnumerator轉換為ExceptionSafeRoutine。
最后,我們更新Visualization Async以確保Task始終完成:
這種方法的酷炫之處在于,任何異常都會被傳播。即使協程沒有try/catch block。這使得coroutine的工作方式就像async一樣。我們唯一要記住的是,在開始一個coroutine時添加.WithExceptionHandling。
18. 總結
如果你看到最后,希望你能夠向我分享你的想法。你是否學到什么呢?有什么我需要補充或者遺漏的嗎?或者你有什么其他更好的方案嗎?
原文鏈接:https://yivian.com/news/80052.html