leetcode124 二叉樹中的最大路徑和(Hard)
初看非同步
說到非同步大家應該都很熟悉了,2012 年 C# 5 引入了新的非同步機制:Task
,並且還有兩個新的關鍵字await
和async
,這已經不是什麼新鮮事了,而且如今這個非同步機制已經被各大語言借鑑,如 JavaScript、TypeScript、Rust、C++ 等等。
下面給出一個簡單的對照:
語言 | 排程單位 | 關鍵字/方法 |
---|---|---|
C# | Task<> 、ValueTask<> |
async 、await |
C++ | std::future<> |
co_await |
Rust | std::future::Future<> |
.await |
JavaScript、TypeScript | Promise<> |
async 、await |
當然,這裡這並不是本文的重點,只是提一下,方便大家在有其他語言經驗的情況下(如果有),可以認識到 C# 中Task
和async
/await
究竟是一個和什麼可以相提並論的東西。
多執行緒程式設計
在該非同步程式設計模型誕生之前,多執行緒程式設計模型是很多人所熟知的。一般來說,開發者會使用Thread
、std::thread
之類的東西作為執行緒的排程單位來進行多執行緒開發,每一個這樣的結構表示一個對等執行緒,執行緒之間採用互斥或者訊號量等方式進行同步。
多執行緒對於科學計算速度提升等方面效果顯著,但是對於 IO 負荷的任務,例如從讀取檔案或者 TCP 流,大多數方案只是分配一個執行緒進行讀取,讀取過程中阻塞該執行緒:
|
上述程式碼中,Main
函式在接收客戶端之後即分配了一個新的使用者執行緒用於處理該客戶端,從客戶端接收資料。client.Read()
另外,如果執行緒數量多起來,頻繁在不同執行緒之間輪轉切換上下文,執行緒的上下文也不小,會浪費掉大量的效能。
非同步程式設計
因此對於此工作內容(IO),我們在 Linux 上有了 epoll/io_uring 技術,在 Windows 上有了 IOCP 技術用以實現非同步 IO 操作。
(這裡插句題外話,吐槽一句,Linux 終於知道從 Windows 抄作業了。先前的 epoll 對比 IOCP 簡直不能打,被 IOCP 全面打壓,io_uring 出來了才好不容易能追上 IOCP,不過 IOCP 從 Windows Vista 時代開始每一代都有很大的優化,io_uring 能不能追得上還有待商榷)
這類 API 有一個共同的特性就是,在操作 IO 的時候,呼叫方控制權被讓出,等待 IO 操作完成之後恢復先前的上下文,重新被排程繼續執行。
所以表現就是這樣的:
假設我現在需要從某裝置中讀取 1024 個位元組長度的資料,於是我們將緩衝區的地址和內容長度等資訊封裝好傳遞給作業系統之後我們就不管了,讀取什麼的讓作業系統去做就好了。
作業系統在核心態下利用 DMA 等方式將資料讀取了 1024 個位元組並寫入到我們先前的 buffer 地址下,然後切換到使用者態將從我們先前讓出控制權的位置,對其進行排程使其繼續執行。
你可以發現這麼一來,在讀取資料期間就沒有任何的執行緒被阻塞,也不存在被頻繁排程和切換上下文的情況,只有當 IO 操作完成之後才會被重新排程並恢復先前讓出控制權時的上下文,使得後面的程式碼繼續執行。
當然,這裡說的是作業系統的非同步 IO 實現方式,以便於讀者對非同步這個行為本身進行理解,和 .NET 中的非同步還是有區別,Task
本身和作業系統也沒什麼關係。
Task (ValueTask)
說了這麼久還是沒有解釋Task
到底是個什麼東西,從上面的分析就可以得出,Task
其實就是一個所謂的排程單位,每個非同步任務被封裝為一個Task
在 CLR 中被排程,而Task
本身會執行在 CLR 中的預先分配好的執行緒池中。
總有很多人因為Task
藉助執行緒池執行而把Task
歸結為多執行緒模型,這是完全錯誤的。
這個時候有人跳出來了,說:你看下面這個程式碼
1 2 3 4 5 6 7 8 |
|
輸出的執行緒 ID 不一樣欸,你騙人,這明明就是多執行緒!對於這種言論,我也只能說這些人從原理上理解的就是錯誤的。
當代碼執行到await
的時候,此時當前的控制權就已經被讓出了,當前執行緒並沒有在阻塞地等待延時結束;待Task.Delay()
完畢後,CLR 從執行緒池當中挑起了一個先前分配好的已有的但是空閒的執行緒,將讓出控制權前的上下文資訊恢復,使得該執行緒恰好可以從先前讓出的位置繼續執行下去。這個時候,可能挑到了先前讓出前所在的那個執行緒,導致前後執行緒 ID 一致;也有可能挑到了另外一個和之前不一樣的執行緒執行下面的程式碼,使得前後的執行緒 ID 不一致。在此過程中並沒有任何的新執行緒被分配了出去。
在 .NET 中由於採用 stackless 的做法,這裡需要用到 CPS 變換,大概是這麼個流程:
|
編譯後:
|
可以看到,原來的變數a
被塞到了<a>5__2
裡面去(相當於備份上下文),Task 狀態的轉換後也是靠著呼叫MoveNext
(相當於狀態轉換後被重新排程)來接著驅動程式碼執行的,裡面的num
就表示當前的狀態,num
如果為 0 表示 Task 完成了,於是接著執行下面的程式碼Console.WriteLine(<a>5__2);
。
當然,在 WPF 等地方,因為利用了SynchronizationContext
對排程行為進行了控制,所以可以得到和上述不同的結論,和這個相關的還有.ConfigureAwait()
的用法,但是這裡不是本文重點,因此就不做展開。
但是上面和經典的多執行緒程式設計的那一套一樣嗎?不一樣。
至於ValueTask
是個什麼玩意,官方發現,Task
由於本身是一個class
,在執行時如果頻繁反覆的分配和回收會給 GC 造成不小的壓力,因此出了一個ValueTask
,這個東西是struct
,分配在棧上,這樣的話就不會給 GC 造成壓力了,減輕了開銷。不過也正因為ValueTask
是會在棧上分配的值型別結構,因此提供的功能也不如Task
全面。
Task.Run
由於 .NET 是允許有多個執行緒的,因此也提供了Task.Run
這個方法,允許我們將 CPU bound 的任務放在上述的執行緒池之中的某個執行緒上執行,並且允許我們將該負載作為一個Task
進行管理,僅在這一點才和多執行緒的採用執行緒池的程式設計比較像。
對於瀏覽器環境(v8),這個時候是完全沒有多執行緒這一說的,因此你開的新的Promise
其實是後面利用事件迴圈機制,將該微任務以非同步的方式執行。
想一想在 JavaScript 中,Promise
是怎麼用的:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
然後呼叫:
1 2 |
|
你只需要把這一套背後的驅動器:事件迴圈佇列,替換成 CLR 的執行緒池,就差不多是 .NET 的Task
相對 JavaScript 的Promise
的工作方式了。
如果你把 CLR 執行緒池執行緒數量設定為 1,那就和 JavaScript 這套幾乎差不多了(雖然實現上還是有差異)。
這時有人要問了:“我在 Task.Run 裡面套了好幾層 Task.Run,可是為什麼層數深了之後裡面的不執行了呢?” 這是因為上面所說的執行緒池被耗盡了,後面的Task
還在排著隊等待被排程。
自己封裝非同步邏輯
瞭解了上面的東西之後,相信對 .NET 中的非同步機制應該理解得差不多了,可以看出來這一套是名副其實的 coroutine,並且在實現上是 stackless 的。至於有的人說的什麼狀態機什麼的,只是實現過程中利用的手段而已,並不是什麼重要的東西。
那我們要怎麼樣使用Task
來編寫我們自己的非同步程式碼呢?
事件驅動其實也可以算是一種非同步模型,例如以下情景:
A
函式呼叫B
函式,呼叫發起後就直接返回不管了(BeginInvoke
),B
函式執行完成後觸發事件執行C
函式。
|
那麼我們現在想要做一件事,就是把上面的事件驅動改造為利用async
/await
的非同步程式設計模型,改造後的程式碼就是簡單的:
|
你可以看到,原本C
函式的內容被放到了A
呼叫B
的下面,為什麼呢?其實很簡單,因為這裡await B();
這一行以後的內容,本身就可以理解為B
函式的回調了,只不過在內部實現上,不是直接從B
進行呼叫的回撥,而是A
先讓出控制權,B
執行完成後,CLR 切換上下文,將A
排程回來繼續執行剩下的程式碼。
如果事件相關的程式碼已經確定不可改動(即不能改動 B 函式),我們想將其封裝為非同步呼叫的模式,那隻需要利用TaskCompletionSource
即可:
|
順便提一句,這個TaskCompletionSource<T>
其實和 JavaScript 中的Promise<T>
更像。SetResult()
方法對應resolve()
,SetException()
方法對應reject()
。.NET 比 JavaScript 還多了一個取消狀態,因此還可以SetCancelled()
表示任務被取消了。
同步方式呼叫非同步程式碼
說句真的,一般能有這個需求,都說明你的程式碼寫的有問題,但是如果你無論如何都想以阻塞的方式去等待一個非同步任務完成的話:
1 2 |
|
祝你好運,這相當於,t
中的非同步任務開始執行後,你將當前執行緒阻塞,然後等到t
完成之後再喚醒,可以說是:毫無意義,而且很有可能因為程式碼編寫不當而導致死鎖的發生。
void async 是什麼?
最後有人會問了,函式可以寫async Task Foo()
,還可以寫async void Bar()
,這有什麼區別呢?
對於上述程式碼,我們一般呼叫的時候,分別這麼寫:
1 2 |
|
可以發現,誒這個Bar
函式不需要await
誒。為什麼呢?
其實這和用以下方式呼叫Foo
是一樣的:
1 |
|
換句話說就是呼叫後瞬間就直接拋掉不管了,不過這樣你也就沒法知道這個非同步任務的狀態和結果了。
await 必須配合 Task/ValueTask 才能用嗎?
當然不是。
在 C# 中只要你的類中包含GetAwaiter()
方法和bool IsCompleted
屬性,並且GetAwaiter()
返回的東西包含一個GetResult()
方法、一個bool IsCompleted
屬性和實現了INotifyCompletion
,那麼這個類的物件就是可以await
的。
|