多線程和異步有什么關聯和區別?如何實現異步?


很多很多年前,有個叫 DOS 的操作系統。

DOS 通過一行一行命令運行程序。在同一時刻里,你只可能運行一個程序,這就是 單進程系統

后來出現了 Windows,用戶可以在系統中打開多個程序並使用它們。這就是 多進程系統

線程進程 的關系,就如同 進程系統 的關系。一個 系統 可以存在多個 進程 ,一個 進程 也可以存在多個 線程

今天的主題與 多線程 的原理關系不大,因此就不在其 原理 上進行更多的說明和解釋了。

什么是單線程,什么是多線程

還得記大約五、六年前,我去 KFCMcDonald's 就發現了一個有趣的區別。

KFC 中,收銀配餐 是同一人。

顧客在點餐后,繼續站在原地等待西餐,造成了 KFC 中常常能見到長長的隊伍在排隊。

McDonald's ,這兩件事是由兩個不同的人負責的。

顧客在點餐完成后,帶着號離開點餐的隊伍,直接去等待配餐叫號。點餐的隊伍人數比 KFC 明顯少了許多。

對比這兩種模式,你會發現

  • KFC 的模式很容易積壓一長排的顧客,讓他們煩於等待而最終離開。
  • McDonald's 的模式不容易產生排長隊的顧客,並且可以讓顧客早早的進入叫號等餐環節。

我們把 線程 視作 員工 ,把 顧客 視作 任務,於是這兩個例子就可以非常形象的解釋什么 單線程 ,什么是 多線程

  • KFC 這種模式模式就是 單線程 , 一個任務從頭至尾由一 線程 完成,在一個任務完成之前,不接受第二個任務。
  • McDonald's 這種模式就是 多線程 , 將一個任務拆分成不同的階段(部分),並交給不同的 線程 分別處理。

什么是同步,什么是異步

當你明白了 KFCMcDonald'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);

代碼說明

  1. 我們利用 Thread 創建一個線程,讓它去完成 某個任務,我們模擬該任務需要 1秒鍾,並會產生一個 100 的結果
  2. 使用 Thread.Start 開始這個任務
  3. 在 someTask 執行過程中,我們做作一些 別的事情
  4. 利用一個輪詢查看 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

BeginInvokeDelegate 上的一個成員,它與 EndInvoke 一起使用可以實現異步操作。

  • BeginInvoke 相當於上面例子中 Thread.Start() 的功能
  • EndInvoke 相當於上面例子中 Thread.Join() 的功能

因為 BeginInvokeDelegate 上的成員,所以我們先聲明一個 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 完成后被執行。

這個 ContinueWithJavaScript 里的 Promisethen 方法有着異曲同工的效果。

var task = AsyncPower(10);

task.ContinueWith(t => 
{
    int result = t.Result;
    // result = 100
});

怎么樣 ? 是不是依然超級簡單 ?

就像之前說的,Task 用起來就像 PromisePromise 最大的特點就是可以用一步一步 then 下去。

$.get("someurl")
    .then(rlt => foo(rlt))
    .then(rlt => bar(rlt));

TaskContinueWith 也支持這樣的編寫方法 :

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) 並不工作在同一個線程同。

為什么會有這樣的效果 ?

因為 編譯器,你會發現,和之前的異步實現不同,asyncawait 不是某個封裝了復雜邏輯的類型,而是兩個關鍵字。

關鍵字的意義就是編譯過程。

在編譯時,遇到 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);
    });
}

你需要使用一些額外的代碼來解決這些問題。並且這些代碼不一定具備通用性,往往要具體問題具體分析。


因此在實際任務中,到底選擇 同步 還是 異步 要視具體情況而定。

今天本文介紹了幾種實現 異步 的方法,不能說它們之間誰比誰更好一點,各有優劣。

篇幅原因,將不再對幾種方案進行對比,會在以后的文章中詳細地介紹各自優劣。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM