併發程式設計-9.在 .NET 中使用併發集合

F(x)_King發表於2024-03-30

BlockingCollection

BlockingCollection<T> 是最有用的併發集合之一。 正如我們在第 7 章中看到的,BlockingCollection<T> 被建立為 .NET 生產者/消費者模式的實現。 在建立不同型別的示例專案之前,讓我們回顧一下該集合的一些細節。

BlockingCollection 細節

對於使用並行程式碼實現的開發人員來說,BlockingCollection<T> 的主要吸引力之一是它可以替換為 List<T>,而無需太多額外的修改。您可以對兩者使用 Add() 方法。 與 BlockingCollection<T> 的區別在於,如果正在進行另一個讀取或寫入操作,則呼叫 Add() 新增專案將阻塞當前執行緒。 如果要指定操作的超時時間,可以使用 TryAdd()TryAdd() 方法可以選擇支援超時和取消令牌。

使用 Take()BlockingCollection<T> 中刪除專案有一個等效的 TryTake(),它允許定時操作和取消。 Take()TryTake() 方法將獲取並刪除新增到集合中的第一個剩餘專案。 這是因為 BlockingCollection<T> 中的預設基礎集合型別是 ConcurrentQueue<T>。 或者,您可以指定集合使用 ConcurrentStack<T>ConcurrentBag<T> 或任何實現 IProducerConsumerCollection<T> 介面的集合。 下面是 BlockingCollection<T> 被初始化為使用 ConcurrentStack 的示例,其容量限制為 100 個專案:

var itemCollection = new BlockingCollection<string>(new ConcurrentStack<string>(), 100);

如果您的應用程式需要迭代 BlockingCollection<T> 中的專案,則可以在 forforeach 迴圈中使用 GetConsumingEnumerable() 方法。 但是,請記住,對集合的這次迭代也會刪除專案,如果繼續列舉直到集合為空,它將完成集合。 這是GetConsumingEnumerable() 方法名稱的使用部分。

如果您需要使用多個相同型別的 BlockingCollection<T> 類,則可以透過將它們新增到陣列中來將它們作為一個整體新增或從中獲取。 BlockingCollection<T> 陣列使 TryAddToAny()TryTakeFromAny() 方法可用。 如果陣列中的任何集合處於正確狀態以接受或向呼叫程式碼提供物件,這些方法將成功。 Microsoft Docs 有一個如何在管道中使用 BlockingCollection<T> 陣列的示例:https://docs.microsoft.com/dotnet/standard/collections/thread-safe/how-to-use-arrays-of-blockingcollections。

將 BlockingCollection 與 Parallel.ForEach 和 PLINQ 結合使用

我們已經在第 7 章中介紹了一個實現生產者/消費者模式的示例,所以讓我們在本節中嘗試一些不同的東西。 我們將建立一個 WPF 應用程式,該應用程式從 1.5 MB 文字檔案載入書籍內容並搜尋以特定字母開頭的單詞:

此示例使用從最初基於 .NET Framework 4.0 構建的 Microsoft 擴充套件示例建立的 .NET Standard NuGet 包。 該副檔名為 ParallelExtensionsExtras,原始來源可在 GitHub 上找到:https://github.com/dotnet/samples/tree/main/csharp/parallel/ParallelExtensionsExtras。 我們將使用包中的擴充套件方法,使 Parallel.ForEach 操作和 PLINQ 查詢透過併發集合更高效地執行。 要了解有關擴充套件的更多資訊,您可以檢視 .NET 並行程式設計部落格上的這篇文章:https://devblogs.microsoft.com/pfxteam/parallelextensionsextras-tour-4-blockingcollectionextensions/。

  1. 首先在 Visual Studio 中建立一個新的 WPF 應用程式。 將專案命名為 ParallelExtras.BlockingCollection

  2. 在 NuGet 包管理器頁面上,搜尋最新穩定版本的 ParallelExtensionsExtras.NetFxStandard 包並將其新增到您的專案中:

圖 9.1 – ParallelExtensionsExtras.NetFxStandard NuGet 包

image

  1. 我們將閱讀詹姆斯·喬伊斯所著的《尤利西斯》一書中的文字。 本書在美國和世界上大多數國家屬於公共領域。 可以從古騰堡專案下載 UTF-8 純文字格式:https://www.gutenberg.org/ebooks/4300。下載副本,將檔案命名為 ulysses.txt,並將其與其他專案檔案放在主資料夾中。
  2. 在 Visual Studio 中,右鍵單擊 ulysses.txt 並選擇“屬性”。 在“屬性”視窗中,將“複製到輸出目錄”屬性更新為“如果較新則複製”。
  3. 開啟 MainWindow.xaml 並新增 Grid.RowDefinitionsGrid。 Grid控制元件的列定義如下:
<Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
    <ColumnDefinition/>
    <ColumnDefinition/>
</Grid.ColumnDefinitions>
  1. Grid.ColumnDefinitions 元素後面的 Grid 定義內新增 ComboBoxButton。 這些控制元件將位於網格的第一行:
<ComboBox x:Name="LettersComboBox" Grid.Row="0" Grid.Column="0" Margin="4">
    <ComboBoxItem Content="A"/>
    <ComboBoxItem Content="D"/>
    <ComboBoxItem Content="F"/>
    <ComboBoxItem Content="G"/>
    <ComboBoxItem Content="M"/>
    <ComboBoxItem Content="O"/>
    <ComboBoxItem Content="A"/>
    <ComboBoxItem Content="T"/>
    <ComboBoxItem Content="W"/>
</ComboBox>
<Button Grid.Row="0" Grid.Column="1" Margin="4" Content="Load Words" Click="Button_Click"/>

ComboBox 將包含九個不同的字母可供選擇。 您可以根據需要新增任意數量的這些內容。 Button 包含一個 Click 事件處理程式,我們將很快將其新增到 MainWindow.xaml.cs 中。

  1. 最後,將名為WordsListViewListView新增到Grid的第二行。 它將跨越兩列:
<ListView x:Name="WordsListView" Margin="4" Grid.Row="1" Grid.ColumnSpan="2"/>
  1. 現在,開啟 MainWIndow.xaml.cs。 我們要做的第一件事是建立一個名為 LoadBookLinesFromFile() 的方法,該方法將 ulysses.txt 中的每一行文字讀取到 BlockingCollection<string> 中。 只有一個執行緒從檔案中讀取,因此最好使用 Add() 方法而不是 TryAdd()
private async Task<BlockingCollection<string>> LoadBookLinesFromFile()
{
    var lines = new BlockingCollection<string>();
    using var reader = File.OpenText(Path.Combine( 
        Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),"ulysses.txt"));
    
    string line;
    while ((line = await reader.ReadLineAsync()) != null)
    {
    	lines.Add(line);
    }
    lines.CompleteAdding();
    return lines;
}

請記住,在方法結束之前呼叫lines.CompleteAdding()非常重要。 否則,該集合的後續查詢將掛起並繼續等待更多專案新增到流中。

  1. 現在,建立一個名為 GetWords() 的方法,該方法從文字檔案中獲取行並使用正規表示式將每一行解析為單獨的單詞。 這些單詞將全部新增到新的 BlockingCollection<string> 中。 在此方法中,我們使用 Parallel.ForEach 迴圈同時解析多行。 ParallelExtentionsExtras.NetFxStandard 包提供了 GetConsumingPartitioner() 擴充套件方法,該方法告訴 Parallel.ForEach 迴圈 BlockingCollection 將執行自己的阻塞,因此迴圈不需要執行任何操作。 這使得整個過程更加高效:
private BlockingCollection<string> GetWords(BlockingCollection<string> lines)
{
    var words = new BlockingCollection<string>();
    Parallel.ForEach(lines.GetConsumingPartitioner(),
        (line) =>
        {
            var matches = Regex.Matches(line, @"\b[\w']*\b");
            foreach (var m in matches.Cast<Match>())
            {
                if (!string.IsNullOrEmpty(m.Value))
                {
                    words.TryAdd(TrimSuffix(m.Value,'\''));
                }
            }
    });
    words.CompleteAdding();
    return words;
}

private string TrimSuffix(string word, char charToTrim)
{
    int charLocation = word.IndexOf(charToTrim);
    if (charLocation != -1)
    {
    	word = word[..charLocation];
    }
    return word;
}

TrimSuffix() 方法將從單詞末尾刪除特定字元; 在本例中,我們傳遞要刪除的撇號字元。

  1. 接下來,建立一個名為 GetWordsByLetter() 的方法來呼叫我們剛剛建立的其他方法。 獲取包含書中所有單詞的 BlockingCollection<string> 後,此方法將使用 PLINQ 和 GetConsumingPartitioner() 查詢以所選字母的大寫或小寫版本開頭的所有單詞:
private async Task<List<string>> GetWordsByLetter(char letter)
{
    BlockingCollection<string> lines = await LoadBookLinesFromFile();
    BlockingCollection<string> words =  GetWords(lines);
    // 275,506 words in total
    return words.GetConsumingPartitioner()
                .AsParallel()
                .Where(w => w.StartsWith(letter) || w.StartsWith(char.ToLower(letter)))
                .ToList();
}
  1. 最後,我們將新增 Button_Click 事件來啟動書籍文字的載入、解析和查詢。 不要忘記將事件處理程式標記為非同步:
private async void Button_Click(object sender, RoutedEventArgs e)
{
    if (LettersComboBox.SelectedIndex < 0)
    {
        MessageBox.Show("Please select a letter.");
        return;
    }
    WordsListView.ItemsSource = await
    	GetWordsByLetter( char.Parse(GetComboBoxValue(LettersComboBox.SelectedValue)));
}

private string GetComboBoxValue(object item)
{
    var comboxItem = item as ComboBoxItem;
    return comboxItem.Content.ToString();
}

GetComboBoxValue() 輔助方法將從 LettersComboBox.SelectedValue 中獲取物件,並查詢其中包含所選字母的字串。

  1. MainWindow.xaml.cs 中需要以下 using 宣告來編譯和執行專案:
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
  1. 現在,執行專案,選擇一個字母,然後單擊“載入單詞”:

圖 9.2 – 顯示 ulysses.txt 中以 T 開頭的單詞

image

考慮到這本書總共包含超過 275,000 字,整個過程執行得非常快。 嘗試向 PLINQ 查詢新增一些排序,看看效能受到怎樣的影響。

ConcurrentBag

ConcurrentBag<T> 是一個無序的物件集合,可以安全地同時新增、檢視或刪除。 請記住,與所有併發集合一樣,ConcurrentBag<T> 公開的方法是執行緒安全的,但不保證任何擴充套件方法都是安全的。 在利用它們時始終實現您自己的同步。 要檢視安全方法列表,您可以檢視此 Microsoft 文件頁面:https://docs.microsoft.com/dotnet/api/system.collections.concurrent.concurrentbag-1#methods。

我們將建立一個示例應用程式來模擬使用物件池。 如果您有一些處理利用記憶體密集型有狀態物件,則此方案可能會很有用。您希望最大限度地減少建立的物件數量,但在上一次迭代完成使用它並將其返回到池中之前無法重用物件。

在我們的示例中,我們將使用一個模擬的 PDF 處理類,該類被假定為記憶體密集型。實際上,文件處理庫可能非常繁重,並且它們通常依賴於每個例項中的文件狀態。 控制檯應用程式將並行迭代 15 次以建立這些假 PDF 物件並向每個物件附加一些文字。 每次迴圈時,我們都會輸出文字內容和池中 PDF 處理器的當前數量。 如果當前計數仍然很低,則應用程式正在按預期工作:

  1. 首先在 Visual Studio 中建立一個名為 ConcurrentBag.PdfProcessor 的新 .NET 控制檯應用程式。
  2. 新增一個新類來表示模擬的 PDF 資料。 將類命名為 ImposterPdfData
public class ImposterPdfData
{
    private string _plainText;
    private byte[] _data;
    
    public ImposterPdfData(string plainText)
    {
        _plainText = plainText;
        _data = System.Text.Encoding.ASCII.GetBytes(plainText);
    }
    
    public string PlainText => _plainText;
    public byte[] PdfData => _data;
}

我們儲存純文字和 ASCII 編碼版本的文字(我們將假裝為 PDF 格式)。 這可以避免在我們的示例應用程式中實現任何第三方庫。如果您有任何熟悉的 PDF 庫,歡迎您調整此示例以使用它們。
3. 接下來,新增一個名為 PdfParser 的新類。 此類將是從 ConcurrentBag<PdfParser> 獲取並返回到ConcurrentBag<PdfParser> 的類。 我們將在接下來的步驟中為該集合建立主機:

public class PdfParser
{
    private ImposterPdfData? _pdf;
    public void SetPdf(ImposterPdfData pdf) =>  _pdf = pdf;
    public ImposterPdfData? GetPdf() => _pdf;
    
    public string GetPdfAsString()
    {
        if (_pdf != null)
        	return _pdf.PlainText;
        else
        	return "";
    }
    
    public byte[] GetPdfBytes()
    {
        if (_pdf != null)
        	return _pdf.PdfData;
        else
        	return new byte[0];
    }
}

該有狀態類儲存 ImposterPdfData 物件的例項,並可以以字串或 ASCII 編碼的位元組陣列形式返回資料。
4. 向 PdfParser 新增一個名為 AppendString 的方法。 此方法將在新行上向 ImposterPdfData 新增一些附加文字:

public void AppendString(string data)
{
    string newData;
    if (_pdf == null)
    {
    	newData = data;
    }
    else
    {
    	newData = _pdf.PlainText + Environment.NewLine + data;
    }
    
    _pdf = new ImposterPdfData(newData);
}
  1. 現在,新增一個名為 PdfWorkerPool 的類:
public class PdfWorkerPool
{
    private ConcurrentBag<PdfParser> _workerPool = new();
    
    public PdfWorkerPool()
    {
        // Add initial worker
        _workerPool.Add(new PdfParser());
    }
    public PdfParser Get() => _workerPool.TryTake(out var parser) ? parser : new PdfParser();
    
    public void Return(PdfParser parser) => _workerPool.Add(parser);
    
    public int WorkerCount => _workerPool.Count();
}

請務必新增 using System.Collections.Concurrent; 對 PdfWorkerPool.cs 的宣告。 該池儲存名為 _workerPoolConcurrentBag<PdfParser>。 當 PdfWorkerPool 初始化時,它會向 _workerPool 新增一個新例項。 Get 方法將透過 TryTake 從池中返回一個現有例項(如果存在)。 如果池為空,則建立一個新例項並將其返回給呼叫者。 當使用者完成時,Return 方法將 PdfParser 新增回池中。 我們將使用 WorkerCount 屬性隨時跟蹤池中的物件數量。

6.最後將Program.cs中的內容替換為以下程式碼:

using ConcurrentBag.PdfProcessor;

Console.WriteLine("Hello, ConcurrentBag!");
var pool = new PdfWorkerPool();

Parallel.For(0, 15, async (i) =>
{
    var parser = pool.Get();
    var data = new ImposterPdfData($"Data index: {i}");
    try
    {
        parser.SetPdf(data);
        parser.AppendString(DateTime.UtcNow .ToShortDateString());
        Console.WriteLine($" {parser.GetPdfAsString()}");
        Console.WriteLine($"Parser count: {pool.WorkerCount}");
        await Task.Delay(100);
    }
    finally
    {
        pool.Return(parser);
        await Task.Delay(250);
    }
});
Console.WriteLine("Press the Enter key to exit.");
Console.ReadLine();

建立新的 PdfWorkerPool 後,我們使用 Parallel.For 迴圈迭代 15 次。 每次透過迴圈,我們都會獲取 PdfParser,設定文字,附加 DateTime.UtcNow,並將內容以及池中解析器的當前計數寫入控制檯。
7. 執行應用程式並檢查輸出:

圖 9.3 – 執行 PdfProcessor 控制檯應用程式

image

就我而言,解析器數量最多達到七。 如果您調整 Task.Delay 間隔或完全刪除它們,您可能會看到計數永遠不會超過 1。 這種池可以配置得非常高效。
此應用程式是一個示例,其中我們不關心返回集合的哪個例項,因此 ConcurrentBag<T> 是一個完美的選擇。 在下一節中,我們將使用 ConcurrentDictionary<TKey, TValue> 建立一個藥物查詢示例。

ConcurrentDictionary

在本節中,我們將建立一個 WinForms 應用程式以同時從兩個檔案載入美國食品和藥物管理局 (FDA) 藥物資料。 載入到 ConcurrentDictionary 後,我們可以使用國家藥品程式碼 (NDC) 值執行快速查詢來獲取名稱。 FDA 藥物資料可以從 NDC 目錄中以多種格式免費下載:https://www.fda.gov/drugs/drug-approvals-and-databases/national-drug-codedirectory。 我們將使用製表符分隔的文字檔案。 我已下載了product.txt 檔案,並將大約一半的記錄移至product2.txt 檔案中,複製了第二個檔案中的標題行。

  1. 首先在 Visual Studio 中建立一個面向 .NET 的新 WinForms 專案 6. 將專案命名為 FdaNdcDrugLookup。
  2. 開啟 Form1.cs 的 WinForm 設計器。 佈置兩個 TextBox 控制元件、兩個 Button 控制元件和 Label:

圖 9.4 – Form1.cs 的佈局

image

載入資料按鈕將設定以下屬性:名稱 - btnLoad 和文字 - loadData。 NDC 程式碼文字欄位將命名為 txtNdc。 “查詢藥物”按鈕將設定以下屬性:名稱 - btnLookup、文字 - 查詢藥物和啟用 - False。 最後,藥物名稱文字欄位將設定以下屬性:Name –txtDrugName 和 ReadOnly – True。
3. 接下來,透過右鍵單擊“解決方案資源管理器”中的專案並選擇“新增”|“新增”,將product.txt 和product2.txt 檔案新增到您的專案中。 現有專案。
4. 在“屬性”皮膚中,將我們剛剛新增的兩個文字檔案的“複製到輸出目錄”更改為“如果較新則複製”。

  1. 向名為 Drug 的專案新增一個新類,並新增以下實現:
public class Drug
{
    public string? Id { get; set; }
    public string? Ndc { get; set; }
    public string? TypeName { get; set; }
    public string? ProprietaryName { get; set; }
    public string? NonProprietaryName { get; set; }
    public string? DosageForm { get; set; }
    public string? Route { get; set; }
    public string? SubstanceName { get; set; }
}

這將包含從 NDC 藥物檔案載入的每條記錄的資料。
6. 接下來,向名為 DrugService 的專案新增一個類,並開始以下實現。 首先,我們只有 private ConcurrentDictionary<string,
Drug>. 我們將在下一步中新增一個載入資料的方法:

using System.Collections.Concurrent;
using System.Data;
using System.Reflection;
namespace FdaNdcDrugLookup
{
    public class DrugService
    {
    	private ConcurrentDictionary<string, Drug> _drugData = new();
    }
}
  1. 接下來,向 DrugService 新增一個名為 LoadData 的公共方法:
public void LoadData(string fileName)
{
    using DataTable dt = new();
    using StreamReader sr = new(Path.Combine(
    Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), fileName));
    
    var del = new char[] { '\t' };
    string[] colheaders = sr.ReadLine().Split(del);
    foreach (string header in colheaders)
    {
    	dt.Columns.Add(header); // add headers
    }
    
    while (sr.Peek() > 0)
    {
        DataRow dr = dt.NewRow(); // add rows
        dr.ItemArray = sr.ReadLine().Split(del);
        dt.Rows.Add(dr);
    }
    foreach (DataRow row in dt.Rows)
    {
        Drug drug = new(); // map to Drug object
        foreach (DataColumn column in dt.Columns)
        {
            switch (column.ColumnName)
            {
            case "PRODUCTID":
            	drug.Id = row[column].ToString();
            break;
            case "PRODUCTNDC":
            	drug.Ndc = row[column].ToString();
            break;
            ...
            // REMAINING CASE STATEMENTS IN GITHUB
        }
    }
    	_drugData.TryAdd(drug.Ndc, drug);
    }
}

在此方法中,我們將資料從提供的 fileName 載入到 StreamReader,將列標題新增到 DataTable,從檔案填充其行,然後迭代 DataTable 的行和列以建立 Drug 物件。 每個 Drug 物件都透過呼叫 TryAdd 來新增到 ConcurrentDictionary,並使用 Ndc 屬性作為鍵。
8. 現在,將 GetDrugByNdc 方法新增到 DrugService 以完成該類。 如果找到,此方法將返回所提供的 ndcCodeDrug

public Drug GetDrugByNdc(string ndcCode)
{
    bool result = _drugData.TryGetValue(ndcCode, out var drug);
    if (result && drug != null)
    	return drug;
    else
    	return new Drug();
}
  1. 開啟 Form1.cs 的程式碼併為 DrugService 新增私有變數:
private DrugService _drugService = new();
  1. 開啟 Form1.cs 的設計器,然後雙擊“載入資料”按鈕以建立 btnLoad_Click 事件處理程式。 新增以下實現。 請注意,我們建立了非同步事件處理程式以允許我們使用await 關鍵字:
private async void btnLoad_Click(object sender,EventArgs e)
{
    var t1 = Task.Run(() => _drugService.LoadData("product.txt"));
    var t2 = Task.Run(() => _drugService.LoadData("product2.txt"));
    
    await Task.WhenAll(t1, t2);
    btnLookup.Enabled = true;
    btnLoad.Enabled = false;
}

為了載入這兩個文字檔案,我們建立兩個並行執行的任務,然後使用 Task.WhenAll 等待它們。 然後,我們可以安全地啟用 btnLookup 按鈕並禁用 btnLoad 按鈕以防止第二次載入。
11. 接下來,切換回 Form1.cs 的設計器檢視,然後雙擊 Lookup Drug 按鈕。這將建立 btnLookup_Click 事件處理程式。 將以下實現新增到該處理程式中,以根據 UI 中輸入的 NDC 程式碼查詢藥物名稱:

private void btnLookup_Click(object sender,EventArgs e)
{
    if (!string.IsNullOrWhiteSpace(txtNdc.Text))
    {
        var drug = _drugService.GetDrugByNdc (txtNdc.Text);
        txtDrugName.Text = drug.ProprietaryName;
    }
}
  1. 現在,執行應用程式並單擊“載入資料”按鈕。 載入過程完成並啟用“查詢藥物”按鈕後,輸入 70518-1120 NDC 程式碼。 點選查詢
    藥品:

圖 9.5 – 透過 NDC 程式碼查詢藥物潑尼松

image

  1. 嘗試其他一些 NDC 程式碼並檢視每個記錄的載入速度。 以下是從每個檔案中獲取的一些隨機 NDC 程式碼。 如果全部成功,您就知道兩個檔案已成功並行載入:0002-0800、0002-4112、43063-825 和 51662-1544。

    就是這樣! 您現在擁有自己的快速且簡單的藥物查詢應用程式。 嘗試自行將藥物名稱 TextBox 替換為 DataGrid 以顯示完整的藥物記錄。

ConcurrentQueue

在本節中,我們將建立一個示例專案,它是實際場景的簡化版本。 我們將使用 ConcurrentQueue<T> 建立一個訂單排隊系統。 該應用程式將是一個控制檯應用程式,它並行地將兩個客戶的訂單排隊。 我們將為每個客戶建立五個訂單,並且為了混合佇列的順序,每個客戶排隊過程將在對 Enqueue 的呼叫之間使用不同的 Task.Delay。 最終輸出應顯示第一個客戶和第二個客戶的出隊訂單組合。 請記住,ConcurrentQueue<T> 採用先進先出 (FIFO) 邏輯:

  1. 首先開啟 Visual Studio 並建立一個名為 ConcurrentOrderQueue 的 .NET 控制檯應用程式。
  2. 在專案中新增一個名為Order的新類:
public class Order
{
    public int Id { get; set; }
    public string? ItemName { get; set; }
    public int ItemQty { get; set; }
    public int CustomerId { get; set; }
    public decimal OrderTotal { get; set; }
}
  1. 現在,建立一個名為 OrderService 的新類,其中包含私有ConcurrentQueue<Order> 名為 _orderQueue。 我們將在此類中為兩個客戶將訂單入隊和出隊:
using System.Collections.Concurrent;
namespace ConcurrentOrderQueue
{
    public class OrderService
    {
    	private ConcurrentQueue<Order> _orderQueue = new();
    }
}
  1. 讓我們從 DequeueOrders 的實現開始。 在此方法中,我們將使用 while 迴圈呼叫 TryDequeue 直到集合為空,然後將每個訂單新增到 List<Order> 以返回給呼叫者:
public List<Order> DequeueOrders()
{
    List<Order> orders = new();
    while (_orderQueue.TryDequeue(out var order))
    {
    	orders.Add(order);
    }
    return orders;
}
  1. 現在,我們將建立公共和私有 EnqueueOrders 方法。 公共無引數方法將呼叫私有方法兩次,每個 customerId 一次。 這兩個呼叫將並行進行,然後呼叫 Task.WhenAll 來等待它們:
public async Task EnqueueOrders()
{
    var t1 = EnqueueOrders(1);
    var t2 = EnqueueOrders(2);
    await Task.WhenAll(t1, t2);
}

private async Task EnqueueOrders(int customerId)
{
    for (int i = 1; i < 6; i++)
    {
        var order = new Order
        {
            Id = i * customerId,
            CustomerId = customerId,
            ItemName = "Widget for customer " +
            customerId,
            ItemQty = 20 - (i * customerId)
        };
        order.OrderTotal = order.ItemQty * 5;
        _orderQueue.Enqueue(order);
        await Task.Delay(100 * customerId);
    }
}

私有 EnqueueOrders 方法迭代五次來為給定的 customerId 建立訂單並將其放入佇列。 這也用於改變 ItemNameItemQtyTask.Delay 的持續時間。
6. 最後,開啟 Program.cs 並新增以下程式碼以將訂單入隊和出隊,並將結果列表輸出到控制檯:

using ConcurrentOrderQueue;
Console.WriteLine("Hello, World!");
var service = new OrderService();
await service.EnqueueOrders();
var orders = service.DequeueOrders();

foreach(var order in orders)
{
	Console.WriteLine(order.ItemName);
}
  1. 執行程式並檢視輸出中的訂單列表。 你的怎麼樣?

圖 9.6 – 檢視訂單佇列的輸出

image

嘗試在 EnqueueOrders 方法中改變延遲因子或更改一個或兩個客戶的 customerId,以檢視輸出順序如何變化。

ConcurrentStack

在本節中,我們將嘗試使用 BlockingCollection<T>ConcurrentStack<T>。 在本章的第一個示例中,我們使用 BlockingCollection<T> 來讀取《尤利西斯》書中以特定字母開頭的單詞。 我們將複製該專案並更改讀取文字行的程式碼以在 BlockingCollection<T> 內使用 ConcurrentStack<T>。 這將使行以相反的順序輸出,因為堆疊使用後進先出 (LIFO) 邏輯。 讓我們開始吧!

  1. 複製本章中的 ParallelExtras.BlockingCollection 專案,或者根據需要修改現有專案。
  2. 開啟 MainWindow.xaml.cs 並修改 LoadBookLinesFromFile 方法,將新的 ConcurrentStack<string> 傳遞給 BlockingCollection<string> 的建構函式:
private async Task<BlockingCollection<string>> LoadBookLinesFromFile()
{
    var lines = new BlockingCollection<string>(new
    ConcurrentStack<string>());
    ...
    return lines;
}
  1. 現在,當您執行應用程式並搜尋與之前相同的字母(在我們的例子中為 T)時,您將在列表的開頭看到一組不同的單詞:

圖 9.7 – 搜尋《尤利西斯》中以 T 開頭的單詞
image

如果滾動到列表底部,您應該會看到本書開頭的單詞。 請注意,該列表並未完全反轉,因為我們在解析每一行的單詞時沒有使用 ConcurrentStack<string>。 您可以自己嘗試這個作為另一個實驗。

總結

在本章中,我們深入研究了 System.Collections.Concurrent 名稱空間中的五個集合。 我們在本章中建立了五個示例應用程式,以獲得 .NET 6 中可用的每種併發集合型別的一些實踐經驗。透過混合使用 WPF、WinForms 和 .NET 控制檯應用程式專案,我們研究了在自己的應用程式中利用這些集合的一些實際方法。

相關文章