很多很多年前,有個叫 DOS 的操作系統。
DOS 通過一行一行命令運行程序。在同一時刻里,你只可能運行一個程序,這就是 單進程系統。
后來出現了 Windows,用戶可以在系統中打開多個程序並使用它們。這就是 多進程系統。
線程 與 進程 的關系,就如同 進程 與 系統 的關系。一個 系統 可以存在多個 進程 ,一個 進程 也可以存在多個 線程 。
今天的主題與 多線程 的原理關系不大,因此就不在其 原理 上進行更多的說明和解釋了。
什么是單線程,什么是多線程
還得記大約五、六年前,我去 KFC 和 McDonald's 就發現了一個有趣的區別。
在 KFC 中,收銀 與 配餐 是同一人。
顧客在點餐后,繼續站在原地等待西餐,造成了 KFC 中常常能見到長長的隊伍在排隊。
在 McDonald's ,這兩件事是由兩個不同的人負責的。
顧客在點餐完成后,帶着號離開點餐的隊伍,直接去等待配餐叫號。點餐的隊伍人數比 KFC 明顯少了許多。
對比這兩種模式,你會發現
- KFC 的模式很容易積壓一長排的顧客,讓他們煩於等待而最終離開。
- McDonald's 的模式不容易產生排長隊的顧客,並且可以讓顧客早早的進入叫號等餐環節。
我們把 線程 視作 員工 ,把 顧客 視作 任務,於是這兩個例子就可以非常形象的解釋什么 單線程 ,什么是 多線程 。
- KFC 這種模式模式就是 單線程 , 一個任務從頭至尾由一 線程 完成,在一個任務完成之前,不接受第二個任務。
- McDonald's 這種模式就是 多線程 , 將一個任務拆分成不同的階段(部分),並交給不同的 線程 分別處理。
什么是同步,什么是異步
當你明白了 KFC 和 McDonald's 后,一切就很簡單了。
線程 是 員工,同步 / 異步 就是顧客點餐的流程了。
- 顧客不能去一下洗手間,只能呆呆地站在那里等待配置的模式就是 同步 。
- 顧客支付以后,利用等待配置的時間去一下洗手間,找個座位的模式就是 異步 。
顯而易見,異步 可以提供更高的效率,它可以利用 等待 的時間去完成一些事情。
在我們的代碼中,一個 顧客 一邊等待配置、一邊做些別的事情,就是 多線程 了。
因此,(單/多線程) 與 (同/異)步 是密不可分的兩個概念。
實現異步
在正常情況下,我們寫出來的代碼都是 同步 的,上一行代碼沒做完就肯定不會執行第二行。
所以 如何實現同步 這個問題的答案與 如何寫一段代碼 是一樣的。
那么,我們自然而然的就把目光放在了 如何實現異步,這一話題上了。
在 .Net 中,我們有幾種 異步 的實現方案 :
- Thread
- BeginInvoke
- Task
- async / await
下面,我會介紹每種方案是如何實現的
Thread
首先,如上面所提到的,異步 的目標就是,先開始 某個任務,然后利用等待的時間去做點 別的事情。
很明顯,這里有兩個線程
- 一個負責 某個任務。
- 另一個負責 別的事情,並在完成 別的事情 后開始等待 某個任務 的完成。
利用這個思想,我們可以自己做一個異步的小例子了。
// 某個任務的結果
int resultOfSomeTask = 0;
Thread someTask = new Thread(new ThreadStart(() =>
{
Thread.Sleep(1000);
resultOfSomeTask = 100;
}));
someTask.Start();
DoSomething(1); // 做一些別的事情
DoSomething(2); // 做一些別的事情
DoSomething(3); // 做一些別的事情
// 每隔一會兒去看看 【某個任務】 是否完成了
while (true)
{
if (someTask.ThreadState == ThreadState.Stopped)
break;
Thread.Sleep(1);
}
Assert.AreEqual(100, resultOfSomeTask);
代碼說明
- 我們利用 Thread 創建一個線程,讓它去完成 某個任務,我們模擬該任務需要 1秒鍾,並會產生一個 100 的結果
- 使用 Thread.Start 開始這個任務
- 在 someTask 執行過程中,我們做作一些 別的事情
- 利用一個輪詢查看 someTask 的狀態,當完成后,我們發現已經得到了 100 這個結果。
上面例子中 while(true) 部分,我們可以使用 Thread.Join() 方法來代替以達到同樣的效果。
// 某個任務的結果
int resultOfSomeTask = 0;
Thread someTask = new Thread(new ThreadStart(() =>
{
Thread.Sleep(1000);
resultOfSomeTask = 100;
}));
someTask.Start();
DoSomeThine(2); // 做一些別的事情
DoSomeThine(3); // 做一些別的事情
DoSomeThine(1); // 做一些別的事情
// 產生與 while(true) 同樣的效果
// 當 someTask 完成后,才會繼續進行
someTask.Join();
Assert.AreEqual(100, resultOfSomeTask);
這種異步的實現方式讓開發者無法只關注在 邏輯 本身,代碼中混入了大量的與線程有關的代碼。
而且最不人性化的是,Thread 要么沒有參數,要么只給一個 object 類型的參數,最草稿的是,它 無法返回結果,我們必須寫一些額外的代碼、邏輯去要主線程中得到子線程中的結果。
題外話
在實際生產環境中,我們往往使用 ThreadPool 來開啟線程。
畢竟每開一個線程,系統都會產生一相應的消耗來支持它。
ThreadPool 可以開啟有限個的線程,並對任務排隊,僅當有線程空閑時,才會繼續處理任務。
BeginInvoke
BeginInvoke 是 Delegate 上的一個成員,它與 EndInvoke 一起使用可以實現異步操作。
- BeginInvoke 相當於上面例子中 Thread.Start() 的功能
- EndInvoke 相當於上面例子中 Thread.Join() 的功能
因為 BeginInvoke 是 Delegate 上的成員,所以我們先聲明一個 Delegate
/// <summary>
/// 這是一個描述了一個使用整形並返回整形的委托類型。
/// 你可以使用直接使用 Func<int,int> 來作為委托的類型。
/// </summary>
/// <param name="i"></param>
/// <returns></returns>
public delegate int TaskGetIntByInt(int i);
BeginInvoke 的入參比較特別,它分為兩個分部。
- 前面的幾個參數,就是委托中定義的參數
- 后面兩個參數,一個是異步任務完成時的回調,一個是可以向回調函數傳入的額外參數,你可以傳遞任何你需要的內容至回調里,而避免了在進程內訪問進程外成員的情況
下面是一個 BeginInvoke 的例子
// 這是一個耗時1秒的任務,會返回入參的平方數
TaskGetIntByInt someTask = new TaskGetIntByInt(i =>
{
Thread.Sleep(1000);
return i * i;
});
// 定義一個函數,用於 someTask 完成時的回調
AsyncCallback callback = new AsyncCallback(ar =>
{
string state = ar.AsyncState as string;
Assert.AreEqual("Hello", state);
});
// 開始平方數運算的任務
// callback, "HelloWorld" 根據需求傳入,你也可以傳 null
IAsyncResult ar = someTask.BeginInvoke(10, callback, "HelloWorld");
// 開始一些別的任務
DoSomeThing(1);
DoSomeThing(2);
DoSomeThing(3);
// 等待 someTask 的運算結果,形如 Thread.Join()
int result = someTask.EndInvoke(ar);
Assert.AreEqual(100, result);
代碼說明
首先 創建委托的實例,你可以使用其它類型上的成員來構造,也可以像示例中那樣直接寫一個內部方法。
接下來 使用 BeginInvoke 開始異步調用。 注意 這里返回了一個 IAsyncResult 類型。
你可以把這個 IAsyncResult 理解為你在 McDonald's 點好餐后的 號 , 每個人的 號 都是不同的,每個顧客都可以用這個 號 領取你的美食。
在代碼中,每次調用 BeginInvoke 都會產生不同的 IAsyncResult,你可以用不同的 IAsyncResult 去獲取它們對應的結果。
BeginInvoke 的時候,你還可以指定一個 回調函數 ,還可以指定一個變量,供 回調函數 使用。
此時,someTask 已經在子線程中運行了。同時,主線程繼續執行了 3 個 DoSomething() 方法。
當你需要 someTask 的運行結果時,你只需要調用 someTask.EndInvoke(IAsyncResult) 。
- 當子線程已經完成后,調用 EndInvoke 你可以立即得到結果。
- 當子線程尚未完成時,調用 EndInvoke 會一直等待,等到子線程執行完成后,才可以得到結果。
題外話
若你的異步任務是一個耗時極長的任務,在主線程使用 EndInvoke 會 傻等 很久。
此時,你可以將 EndInvoke 方法在 Callback 內執行。
將 someTask 作為 回調函數 的參數傳入,就可以在 Callback 內使用 EndInvoke 得到結果。
TaskGetIntByInt someTask = new TaskGetIntByInt(i =>
{
Thread.Sleep(3000);
return i * i;
});
DoSomeThing(1);
DoSomeThing(2);
DoSomeThing(3);
AsyncCallback callback = new AsyncCallback(_ar =>
{
// BeginInvoke 的最后一位參數可以通過 AsyncState 取得
TaskGetIntByInt task = (TaskGetIntByInt)_ar.AsyncState;
int result = task.EndInvoke(_ar);
Assert.AreEqual(100, result);
});
IAsyncResult ar = someTask.BeginInvoke(10, callback, someTask);
對於一個 異步 任務的結果,我們往往有兩種方法處理 :
- 在主線程中等待結果
- 在子線程中處理結果
對於一個耗時較短的任務,我們可以先利用 異步 將該任務放在子線程中執行。
再繼續在主線程中處理其它任務,最后等待 異步 任務的完成。
這種方式就是 在主線程中等待結果 。
對於一個耗時較長的任務,如果在主線程中 等待 會有可能對終端用戶帶來不好的應用體驗。
因此,我們不會在主線程中等待 異步 的完成。
我們在 異步 任務開啟后,就可以早早通知用戶 你的任務正在處理中。同時在子線程中,當任務完成后,你可以利用數據庫等手段,將 正在處理中的任務 標為 已完成,並通知他們。
Task
從 .Net4.0 開始,Task 成為了實現 異步 的主要利器。
Task 的用法與 JavaScript 中的 Promise 非常接近。
Task 表示一個 異步 任務。廢話不多說,我們先寫一個返回 Task 的方法。
// Task<int> 表示這是一個返回 int 類型的 Task
private Task<int> AsyncPower(int i)
{
// 返回一個任務
// 使用 Task.Run 相當於先創建了一個 Task
// 再 Start
return Task<int>.Run(() =>
{
// 該任務會耗時 1 秒鍾
Thread.Sleep(1000);
// 1 秒鍾后會返回參數的平方
return i * i;
});
}
與之前提到的相同,我們有兩種方法處理這個 Task 的結果 :
- 在主線程中等待結果
- 在子線程中處理結果
我們看看兩種模式分別是如何實現的
在主線程中等待結果
直接訪問 Task.Result 屬性,就可以等待並得到 異步 任務的結果。
var task = AsyncPower(10);
// 這里會等 1 秒
int result = task.Result;
// result = 100
怎么樣 ? 是不是超級簡單 ?
在子線程中處理結果
使用方法 ContinueWith 可以添加一個方法,在 Task 完成后被執行。
這個 ContinueWith 和 JavaScript 里的 Promise 的 then 方法有着異曲同工的效果。
var task = AsyncPower(10);
task.ContinueWith(t =>
{
int result = t.Result;
// result = 100
});
怎么樣 ? 是不是依然超級簡單 ?
就像之前說的,Task 用起來就像 Promise,Promise 最大的特點就是可以用一步一步 then 下去。
$.get("someurl")
.then(rlt => foo(rlt))
.then(rlt => bar(rlt));
Task 的 ContinueWith 也支持這樣的編寫方法 :
var task = AsyncPowe(10);
task.ContinueWith(t =>
{
// some code here
}).ContinueWith(t =>
{
// some code here
}).CondinueWith(t => {
// some code here
});
async / await
這個 .Net 4.5 加入的關鍵字,讓 異步代碼 寫起來和 同步代碼 沒什么區別了。
我們先看看下面的同步代碼
int Power(int i)
{
return i * i;
}
void Main()
{
int result = Power(10);
Console.WriteLine(result); // 100
Console.ReadLine();
}
把上面的代碼改成異步代碼,只需要幾個小小的改動 :
- 將 Power 的返回值改為 Task<T>
- 修改返回結果,使用 Task.Run 包裝返回的結果
- 為調用 Power 的代碼前加上 await 關鍵字
- 為有 await 關鍵字的方法加上 async 關鍵字
新的代碼如下
Task<int> Power(int i)
{
return Task<int>.Run(()=>
{
Thread.Sleep(100); // 模擬耗時
return i * i;
});
}
async void Main()
{
Console.WriteLine("Hello");
int result = await Power(10);
Console.WriteLine(result); // 100
Console.ReadLine();
}
運行一下,發現沒什么區別。
如果你向控制台輸出線程ID的話,你會發現 Console.WriteLine("Hello") 和 Console.WriteLine(result) 並不工作在同一個線程同。
為什么會有這樣的效果 ?
因為 編譯器,你會發現,和之前的異步實現不同,async 和 await 不是某個封裝了復雜邏輯的類型,而是兩個關鍵字。
關鍵字的意義就是編譯過程。
在編譯時,遇到 async 就會知道這是一個存在着異步的方法,編譯器向這個類型添加一些額外的成員來支持 await 操作。
當遇到 await 關鍵字時,編譯器會從當前行截斷,並向后面的代碼編譯到 Task.ContinueWith 中去。
這樣一來,看似同步的代碼,經編譯后,就會一拆為二。
前部分運行在主線程中,后部分運行在子線程中,分割點就是 await 所在的代碼行。
慎用異步
幾種在 .Net 平台中使用 異步 的方法都介紹完了,希望大家能夠對 異步 編程有了一定的了解和認識。
但是,在實際生產中,依賴要慎用異步。
異步 在帶來性能提高的同時,還會帶來一些更復雜的問題:
線程安全
線程間的切換並不是有着類似 事務 的特征,它無法保證兩個線程對同一資源的讀寫的完整性。
而且大部分情況下,一個線程操作完,就會被掛機執行另一個線程,所以對於多個線程訪問同一資源,需要考慮線程安全的問題。
換句話說,就是保證一個線程在執行一個最小操作時,另一個線程不允許操作該對象。
調試難
異步 的本質就是 多線程 ,當你嘗試用斷點調試代碼時,由於兩個線程都在你的代碼中運行,因此常常出現從這個線程的斷點進入另一個線程的斷點的情景。
需要依賴 IDE 中更多的工具和設置,才能解決上述的問題。
不統一的上下文
異步 代碼往往在子線程中運行。
子線程 很可能會使用在 主線程 中已經施放的資源。
比如
using(var conn = new SqlConnection("........"))
{
conn.Open();
// 假定一個根據用戶名查詢用戶ID的方法
Tast<int> task = UserService.AsyncGetUserId(conn, "Admin");
task.ContinueWith(t =>
{
// 此時的 conn 已經被主線程釋放了
UserService.DoSomethingWithConn(conn);
});
}
你需要使用一些額外的代碼來解決這些問題。並且這些代碼不一定具備通用性,往往要具體問題具體分析。
因此在實際任務中,到底選擇 同步 還是 異步 要視具體情況而定。
今天本文介紹了幾種實現 異步 的方法,不能說它們之間誰比誰更好一點,各有優劣。
篇幅原因,將不再對幾種方案進行對比,會在以后的文章中詳細地介紹各自優劣。