深入分析C#中的非同步和多執行緒

許多開發人員對非同步程式碼和多執行緒以及它們的工作原理和使用方法都有錯誤的認識。在這裡,你將了解這兩個概念之間的區別,並使用c#實現它們。

我:「服務員,這是我第一次來這家餐廳。通常需要4個小時才能拿到食物嗎?」

服務員:「哦,是的,先生。這家餐廳的廚房裡衹有一個廚師。」

我:「……衹有一個廚師嗎?」

服務員:「是的,先生,我們有好幾個廚師,但每次衹有一個在廚房工作。」

我:「所以其他10個穿著廚師服站在廚房裡的人……什麼都不做嗎?廚房太小了嗎?」

服務員:「哦,我們的廚房很大,先生。」

我:「那為什麼他們不同時工作呢?」

服務員:「先生,這倒是個好主意,但我們還沒想好怎麼做。」

我:「好了,奇怪。但是…嘿…現在的主廚在哪裡?我現在沒看見有人在廚房裡。」

服務員:「是的,先生。有一份訂單的廚房用品已經用完了,所以廚師已經停止烹飪,站在外面等著送貨了。」

我:「看起來他可以一邊等一邊做飯,也許送貨員可以直接告訴他們什麼時候到了?」

服務員:「又是一個絕妙的主意,先生。我們在後面有送貨門鈴,但廚師喜歡等。我去給你再拿點水來。」

多糟糕的餐廳,對吧?不幸的是,很多程式都是這樣工作的。

有兩種不同的方法可以讓這家餐廳做得更好。

首先,很明顯,每個單獨的晚餐訂單可以由不同的廚師來處理。每一種都是一個必須按特定順序發生的事情串列(準備原料,然後混合它們,然後烹飪,等等)。因此,如果每個廚師都致力於處理這一清單上的東西,幾份晚餐訂單可以同時做出。

這是一個真實世界中的多執行緒範例。電腦有能力讓多個不同的執行緒同時執行,每個執行緒負責按特定順序執行一系列活動。

然後還有非同步行為。需要明確的是,非同步不是多執行緒的。還記得那個一直在等外賣的廚師嗎?真是浪費時間!在等待的過程中,他沒有做任何有意義的事情,例如做飯。而且,等待也不會讓送貨更快。一旦他打電話訂購供應品,發貨就會隨時發生,所以為什麼要等呢?相反,送貨員只需按門鈴,說一句:「嘿,這是你的供應品!」

有很多I/O活動是由程式碼之外的東西處理的。例如,向遠端伺服器發送一個網路請求。這就像給餐廳點餐一樣。你的程式碼所做的唯一事情就是進行呼叫並接收結果。如果選擇等待結果,在這兩者之間完全不做任何事情,那麼這就是「同步」行為。

然而,如果你更喜歡在結果回傳時被打斷/通知(就像送貨員到達時按門鈴),同時可以處理其他事情,那麼這就是「非同步」行為。

只要工作是由不受當前程式碼直接控制的物件完成的,就可以使用非同步程式碼。例如,當你向硬碟驅動器寫入一堆資料時,你的程式碼並沒有執行實際的寫入操作。它只是請求硬體執行該任務。因此,你可以使用非同步編碼開始撰寫,然後在撰寫完成時得到通知,同時繼續處理其他事情。

非同步的優點在於不需要額外的執行緒,因此非常高效。

「等等!」你說。「如果沒有額外的執行緒,那麼誰或什麼在等待結果?程式碼如何知道回傳的結果?」

還記得那個門鈴嗎?你的電腦里有一個系統叫做「中斷」系統,它的工作原理有點像那個門鈴。當你的程式碼開始一個非同步活動時,它基本上會安裝一個虛擬的門鈴。當其他任務(寫入硬碟驅動器,等待網路回應等)完成時,中斷系統「中斷」當前執行的程式碼並按下門鈴,讓你的應用程式知道有一個任務在等待!不需要執行緒坐在那裡等待!

讓我們快速回顧一下我們的兩種工具:

多執行緒:使用一個額外的執行緒來執行一系列活動/任務。

非同步:使用同一個執行緒和中斷系統,讓執行緒外的其他套件完成一些活動,並在活動結束時得到通知。

UI執行緒
還有一件重要的事情需要知道的是為什麼使用這些工具是好的。在.net中,有一個主執行緒叫做UI執行緒,它負責更新螢幕的所有可視部分。預設情況下,這是一切執行的地方。當你點選一個按鈕,你想看到按鈕被短暫地按下,然後回傳,這是UI執行緒的責任。你的應用中衹有一個UI執行緒,這意味著如果你的UI執行緒忙著做繁重的計算或等待網路請求之類的事情,那麼它不能更新你在螢幕上看到的東西,直到它完成。結果是,你的應用程式看起來像「凍結」——你可以點選一個按鈕,但似乎什麼都不會發生,因為UI執行緒正在忙著做其他事情。

理想情況下,你希望UI執行緒儘可能地空閑,這樣你的應用程式似乎總是在回應使用者的操作。這就是非同步和多執行緒的由來。透過使用這些工具,可以確保在其他地方完成繁重的工作,UI執行緒保持良好和回應性。

現在讓我們看看如何在c#中使用這些工具。

C#的非同步操作
執行非同步操作的程式碼非常簡單。你應該知道兩個主要的關鍵字:「async」和「await」,所以人們通常將其稱為async/await。假設你現在有這樣的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void Loopy()
{<!-- -->
  var hugeFiles = new string[] {<!-- -->
   "Gr8Gonzos_Home_Movie_In_8k_Res.mkv", // 1 GB
   "War_And_Peace_In_150_Languages.rtf", // 1.2 GB
   "Cats_On_Catnip.mpg"         // 0.9 GB
  };
 
  foreach (var hugeFile in hugeFiles)
  {<!-- -->
    ReadAHugeFile(hugeFile);
  }
   
  MessageBox.Show("All done!");
}
 
 
public byte[] ReadAHugeFile(string bigFile)
{<!-- -->
  var fileSize = new FileInfo(bigFile).Length; // Get the file size
  var allData = new byte[fileSize];      // Allocate a byte array as large as our file
  using (var fs = new System.IO.FileStream(bigFile, FileMode.Open))
  {<!-- -->
    fs.Read(allData, 0, (int)fileSize);   // Read the entire file...
  }
  return allData;               // ...and return those bytes!
}

在當前的形式中,這些都是同步執行的。如果你點選一個按鈕從UI執行緒執行Loopy(),那麼應用程式將似乎凍結,直到所有三大檔案閱讀,因為每個「ReadAHugeFile」是要花很長時間在UI執行緒上執行,並將同步閱讀。這可不好!讓我們看看能否將ReadAHugeFile變為非同步的這樣UI執行緒就能繼續處理其他東西。

無論何時,只要有支援非同步的命令,微軟通常會給我們同步和非同步版本的這些命令。在上面的程式碼中,System.IO.FileStream物件同時具有"Read"和"ReadAsync"方法。所以第一步就是將「fs.Read」修改成「fs.ReadAsync」。

1
2
3
4
5
6
7
8
9
10
public byte[] ReadAHugeFile(string bigFile)
{<!-- -->
  var fileSize = new FileInfo(bigFile).Length; // Get the file size
  var allData = new byte[fileSize];      // Allocate a byte array as large as our file
  using (var fs = new System.IO.FileStream(bigFile, FileMode.Open))
  {<!-- -->
    fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously...
  }
  return allData;               // ...and return those bytes!
}

如果現在執行它,它會立即回傳,並且「allData」位元組陣列中不會有任何資料。為什麼?

這是因為ReadAsync是開始讀取並回傳一個任務物件,這有點像一個書籤。這是.net的一個「Promise」,一旦非同步活動完成(例如從硬碟讀取資料),它將回傳結果,任務物件可以用來訪問結果。但如果我們對這個任務不做任何事情,那麼系統就會立即繼續到下一行程式碼,也就是我們的"return allData"行,它會回傳一個尚未填滿資料的陣列。

因此,告訴程式碼等待結果是很有用的(但這樣一來,原始執行緒可以在此期間繼續做其他事情)。為了做到這一點,我們使用了一個"awaiter",它就像在async呼叫之前新增單詞"await"一樣簡單:

1
2
3
4
5
6
7
8
9
10
public byte[] ReadAHugeFile(string bigFile)
{<!-- -->
  var fileSize = new FileInfo(bigFile).Length; // Get the file size
  var allData = new byte[fileSize];      // Allocate a byte array as large as our file
  using (var fs = new System.IO.FileStream(bigFile, FileMode.Open))
  {<!-- -->
    await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously...
  }
  return allData;               // ...and return those bytes!
}

如果現在執行它,它會立即回傳,並且「allData」位元組陣列中不會有任何資料。為什麼?

這是因為ReadAsync是開始讀取並回傳一個任務物件,這有點像一個書籤。這是.net的一個「Promise」,一旦非同步活動完成(例如從硬碟讀取資料),它將回傳結果,任務物件可以用來訪問結果。但如果我們對這個任務不做任何事情,那麼系統就會立即繼續到下一行程式碼,也就是我們的"return allData"行,它會回傳一個尚未填滿資料的陣列。

因此,告訴程式碼等待結果是很有用的(但這樣一來,原始執行緒可以在此期間繼續做其他事情)。為了做到這一點,我們使用了一個"awaiter",它就像在async呼叫之前新增單詞"await"一樣簡單:

1
2
3
4
5
6
7
8
9
10
public byte[] ReadAHugeFile(string bigFile)
{<!-- -->
  var fileSize = new FileInfo(bigFile).Length; // Get the file size
  var allData = new byte[fileSize];      // Allocate a byte array as large as our file
  using (var fs = new System.IO.FileStream(bigFile, FileMode.Open))
  {<!-- -->
    await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously...
  }
  return allData;               // ...and return those bytes!
}

哦。如果你試過,你會發現有一個錯誤。這是因為.net需要知道這個方法是非同步的,它最終會回傳一個位元組陣列。因此,我們做的第一件事是在回傳型別之前新增單詞「async」,然後用Task<…>,是這樣的:

1
2
3
4
5
6
7
8
9
10
public async Task<byte[]> ReadAHugeFile(string bigFile)
{<!-- -->
  var fileSize = new FileInfo(bigFile).Length; // Get the file size
  var allData = new byte[fileSize];      // Allocate a byte array as large as our file
  using (var fs = new System.IO.FileStream(bigFile, FileMode.Open))
  {<!-- -->
    await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously...
  }
  return allData;               // ...and return those bytes!
}

好吧!現在我們烹飪!如果我們現在執行我們的程式碼,它將繼續在UI執行緒上執行,直到我們到達ReadAsync方法的await。此時,. net知道這是一個將由硬碟執行的活動,因此「await」將一個小書籤放在當前位置,然後UI執行緒回傳到它的正常處理(所有的視覺更新等)。

隨後,一旦硬碟驅動器讀取了所有資料,ReadAsync方法將其全部複製到allData位元組陣列中,任務現在就完成了,因此系統按門鈴,讓原始執行緒知道結果已經準備好了。原始執行緒說:「太棒了!讓我回到離開的地方!」一有機會,它就會回到「await fs.ReadSync」,然後繼續下一步,回傳allData陣列,這個陣列現在已經填充了我們的資料。

如果你在一個接一個地看一個範例,並且使用的是最近的Visual Studio版本,你會注意到這一行:

1
ReadAHugeFile(hugeFile);

…現在,它用綠色下劃線表示,如果將滑鼠滑過在它上面,它會說,「因為這個呼叫沒有被等待,所以在呼叫完成之前,當前方法的執行將繼續。」考慮對呼叫的結果應用』await』運算子。"

這是Visual Studio讓你知道它承認ReadAHugeFile()是一個非同步的方法,而不是回傳一個結果,這也是回傳任務,所以如果你想等待結果,然後你就可以新增一個「await」:

1
await ReadAHugeFile(hugeFile);

…但如果我們這樣做了,那麼你還必須更新方法簽名:

1
public async void Loopy()
注意,如果我們在一個不回傳任何東西的方法上(void回傳型別),那麼我們不需要將回傳型別包裝在Task<…>中。

但是,我們不要這樣做。相反,讓我們來了解一下我們可以用非同步做些什麼。

如果你不想等待ReadAHugeFile(hugeFile)的結果,因為你可能不關心最終的結果,但你不喜歡綠色下劃線/警告,你可以使用一個特殊的技巧來告訴.net。只需將結果賦給_字元,就像這樣:

1
_ = ReadAHugeFile(hugeFile);

這就是.net的語法,表示「我不在乎結果,但我不希望用它的警告來打擾我。」

好吧,我們試試別的。如果我們在這一行上使用了await,那麼它將等待第一個檔案被非同步讀取,然後等待第二個檔案被非同步讀取,最後等待第三個檔案被非同步讀取。但是…如果我們想要同時非同步地讀取所有3個檔案,然後在所有3個檔案都完成之後,我們允許程式碼繼續到下一行,該怎麼辦?

有一個叫做Task.WhenAll()的方法,它本身是一個你可以await的非同步方法。傳入其他任務物件的串列,然後等待它,一旦所有任務都完成,它就會完成。所以最簡單的方法就是建立一個List物件:

1
List<Task> readingTasks = new List<Task>();

…然後,當我們將每個ReadAHugeFile()呼叫中的Task新增到串列中時:

1
2
3
foreach (var hugeFile in hugeFiles) {<!-- -->  
   readingTasks.Add(ReadAHugeFile(hugeFile));
}

…最後我們 await Task.WhenAll():

1
await Task.WhenAll(readingTasks);

最終的方法是這樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public async void Loopy()
{<!-- -->
  var hugeFiles = new string[] {<!-- -->
   "Gr8Gonzos_Home_Movie_In_8k_Res.mkv", // 1 GB
   "War_And_Peace_In_150_Languages.rtf", // 1.2 GB
   "Cats_On_Catnip.mpg"         // 0.9 GB
  };
 
 
  List<Task> readingTasks = new List<Task>();
  foreach (var hugeFile in hugeFiles)
  {<!-- -->
    readingTasks.Add(ReadAHugeFile(hugeFile));
  }
  await Task.WhenAll(readingTasks);
 
 
  MessageBox.Show(sb.ToString());
}

當涉及到並行活動時,一些I/O機制比其他機制工作得更好(例如,網路請求通常比硬碟讀取工作得更好,但這取決於硬體),但原理是相同的。

現在,「await」運算子還要做的最後一件事是選取最終結果。所以在上面的範例中,ReadAHugeFile回傳一個任務。await的神奇功能會在完成後自動丟擲Task<>包裝器,並回傳byte[]陣列,所以如果你想訪問Loopy()中的位元組,你可以這樣做:

1
byte[] data = await ReadAHugeFile(hugeFile);
再次強調,await是一個神奇的小命令,它使非同步程式設計變得非常簡單,並為你處理各種各樣的小事情。

現在讓我們轉向多執行緒。

C#中的多執行緒
微軟有時會給你10種不同的方法來做同樣的事情,這就是它如何使用多執行緒。你有BackgroundWorker類、Thread和Task(它們有幾個變體)。最終,它們都做著相同的事情,只是有不同的功能。現在,大多數人都使用Task,因為它們的設定和使用都很簡單,而且如果你想這樣做的話(我們稍後會講到),它們也可以很好地與非同步程式碼互動。如果你好奇的話,關於這些具體區別有很多文章,但是我們在這裡使用任務。

要讓任何方法在單獨的執行緒中執行,只需使用Task.Run()方法來執行它。例如,假設你有這樣一個方法:

1
2
3
4
5
6
7
8
9
10
11
public void DoRandomCalculations(int howMany)
{<!-- -->
  var rng = new Random();
  for (int i = 0; i < howMany; i++)
  {<!-- -->
    int a = rng.Next(1, 1000);
    int b = rng.Next(1, 1000);
    int sum = 0;
    sum = a + b;
  }
}

我們可以像這樣在當前執行緒中呼叫它:

1
DoRandomCalculations(1000000);

或者我們可以讓另一個執行緒來做這個工作:

1
Task.Run(() => DoRandomCalculations(1000000));

當然,有一些不同的版本,但這是總體思路。

Task. run()的一個優點是它回傳一個我們可以等待的任務物件。因此,如果想在一個單獨的執行緒中執行一堆程式碼,然後在進入下一步之前等待它完成,你可以使用await,就像你在前面一節看到的那樣:

1
var finalData = await Task.Run(() => {<!-- -->});

請記住,本文討論的是如何開始,以及這些概c#教學念是如何工作的,但它並不是全面的。但是也許有了這些知識,你將能夠理解其他人關於多執行緒和非同步編碼更高階種類的更複雜的文章。

以上就是深入分析C#中的非同步和多執行緒的詳細內容