.NETにおける並列コレクションの活用

BlockingCollection

`BlockingCollection<T>`は、.NETで最も役立つ並列コレクションの一つです。第7章で見たように、`BlockingCollection<T>`は.NETの生産者/消費者パターンの実装として作成されています。異なる種類のサンプルプロジェクトを作成する前に、このコレクションのいくつかの詳細を確認しましょう。

BlockingCollectionの詳細

並列コードを実装する開発者にとって、`BlockingCollection<T>`の主な魅力の一つは、`List<T>`と交換できることです。両方に`Add()`メソッドを使用できます。`BlockingCollection<T>`との違いは、**別の読み取りまたは書き込み操作が実行されている場合、`Add()`メソッドで項目を追加すると現在のスレッドがブロックされることです。操作のタイムアウト時間を指定する必要がある場合は、`TryAdd()`を使用できます。`TryAdd()`メソッドは、タイムアウトとキャンセルトークンをサポートするオプションがあります。**

`BlockingCollection<T>`から項目を削除する`Take()`には、タイミング操作とキャンセルを可能にする同等の`TryTake()`があります。`Take()`と`TryTake()`メソッドは、**コレクションに追加された最初の残りの項目を取得して削除します**。これは、`BlockingCollection<T>`のデフォルトの基礎となるコレクションの種類が`ConcurrentQueue<T>`であるためです。あるいは、コレクションが`ConcurrentStack<T>`、`ConcurrentBag<T>`、または`IProducerConsumerCollection<T>`インターフェースを実装する任意のコレクションを使用するように指定できます。以下に、`BlockingCollection<T>`が`ConcurrentStack<string>`を使用するように初期化され、100項目の容量制限がある例を示します:

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

アプリケーションで`BlockingCollection<T>`の項目を反復処理する必要がある場合は、`for`または`foreach`ループで`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章で生産者/消費者パターンを実装する例を紹介しましたので、このセクションでは少し異なることを試してみましょう。1.5MBのテキストファイルから書籍の内容を読み込み、特定の文字で始まる単語を検索するWPFアプリケーションを作成します:

この例は、元々.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パッケージ

  1. ジェームズ・ジョイスの『ユリシーズ』のテキストを読み込みます。この本は米国および世界の大多数の国でパブリックドメインです。古腾堡プロジェクトからUTF-8プレーンテキスト形式でダウンロードできます:https://www.gutenberg.org/ebooks/4300。コピーをダウンロードし、ファイル名をulysses.txtにして、プロジェクトファイルと同じメインフォルダーに配置します。
  2. Visual Studioでulysses.txtを右クリックし、「プロパティ」を選択します。「プロパティ」ウィンドウで、「出力ディレクトリにコピー」プロパティを「新しい場合はコピー」に更新します。
  3. MainWindow.xamlを開き、`Grid.RowDefinitions`と`Grid`を追加します。Gridコントロールの列定義は次のとおりです:
<Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
    <ColumnDefinition/>
    <ColumnDefinition/>
</Grid.ColumnDefinitions>
  1. `Grid.ColumnDefinitions`要素の後の`Grid`定義内に`ComboBox`と`Button`を追加します。これらのコントロールはグリッドの最初の行にあります:
<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`には9つの異なる文字が選択できます。必要に応じて任意の数のこれらを追加できます。`Button`にはClickイベントハンドラがあり、すぐに`MainWindow.xaml.cs`に追加します。

  1. 最後に、`WordsListView`という名前の`ListView`を`Grid`の2行目に追加します。これは2列にまたがります:
<ListView x:Name="WordsListView" Margin="4" Grid.Row="1" Grid.ColumnSpan="2"/>
  1. 今度は`MainWindow.xaml.cs`を開きます。最初に行うことは、`ulysses.txt`の各行テキストを`BlockingCollection<string>`に読み込む`LoadBookLinesFromFile()`メソッドを作成することです。ファイルから読み取るのは1つのスレッドのみなので、`TryAdd()`ではなく`Add()`メソッドを使用するのが最適です:
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語
    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("文字を選択してください。");
        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で始まる単語の表示

この本には合計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ライブラリがあれば、この例を調整して使用してください。

  1. 次に、`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エンコードされたバイト配列の形式でデータを返すことができます。

    li>`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);
}
    li>今度は、`PdfWorkerPool`という名前のクラスを追加します:
public class PdfWorkerPool
{
    private ConcurrentBag<PdfParser> _workerPool = new();
    
    public PdfWorkerPool()
    {
        // 初期ワーカーを追加
        _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();
}

`PdfWorkerPool.cs`に`using System.Collections.Concurrent;`の宣言を追加することを忘れないでください。このプールは、`_workerPool`という名前の`ConcurrentBag<PdfParser>`を保存します。`PdfWorkerPool`が初期化されると、`_workerPool`に新しいインスタンスが追加されます。`Get`メソッドは、プールから既存のインスタンスを返します(存在する場合)。プールが空の場合は、新しいインスタンスを作成し、呼び出し元に返します。使用者が完了すると、`Return`メソッドは`PdfParser`をプールに戻します。`WorkerCount`プロパティを使用して、いつでもプール内のオブジェクトの数を追跡します。

    li>最後に、`Program.cs`の内容を次のコードに置き換えます:
using ConcurrentBag.PdfProcessor;

Console.WriteLine("こんにちは、ConcurrentBag!");
var pool = new PdfWorkerPool();

Parallel.For(0, 15, async (i) =>
{
    var parser = pool.Get();
    var data = new ImposterPdfData($"データインデックス: {i}");
    try
    {
        parser.SetPdf(data);
        parser.AppendString(DateTime.UtcNow .ToShortDateString());
        Console.WriteLine($" {parser.GetPdfAsString()}");
        Console.WriteLine($"パーサー数: {pool.WorkerCount}");
        await Task.Delay(100);
    }
    finally
    {
        pool.Return(parser);
        await Task.Delay(250);
    }
});
Console.WriteLine("Enterキーを押して終了します。");
Console.ReadLine();

新しい`PdfWorkerPool`を作成した後、`Parallel.For`ループを使用して15回反復します。ループの各反復で、`PdfParser`を取得し、テキストを設定し、`DateTime.UtcNow`を追加し、内容とプール内のパーサーの現在のカウントをコンソールに出力します。

    li>アプリケーションを実行し、出力を確認します:

図9.3 – `PdfProcessor`コンソールアプリケーションの実行

私の場合、パーサーの数は最大7に達しました。`Task.Delay`間隔を調整するか、完全に削除すると、カウントが1を超えないことがあります。このプールは非常に効率的に構成できます。

このアプリケーションは、返されるコレクションのどのインスタンスを気にしない例です。そのため、`ConcurrentBag<T>`は完璧な選択です。次のセクションでは、`ConcurrentDictionary<TKey, TValue>`を使用して薬品検索の例を作成します。

ConcurrentDictionary

このセクションでは、WinFormsアプリケーションを作成して、2つのファイルから米国食品医薬品局(FDA)の薬品データを同時に読み込みます。ConcurrentDictionaryに読み込んだ後、国家薬品コード(NDC)値を使用して高速検索を実行して名前を取得できます。FDA薬品データは、NDCディレクトリから無料でダウンロードできます:https://www.fda.gov/drugs/drug-approvals-and-databases/national-drug-codedirectory。タブ区切りのテキストファイルを使用します。product.txtファイルをダウンロードし、約半分のレコードをproduct2.txtファイルに移動し、2番目のファイルにヘッダー行をコピーしました。

  1. まずVisual Studioで.NET 6を対象とする新しいWinFormsプロジェクトを作成します。プロジェクト名を`FdaNdcDrugLookup`にします。
  2. Form1.csのWinFormデザイナーを開きます。2つのTextBoxコントロール、2つのButtonコントロール、およびLabelを配置します:

図9.4 – Form1.csのレイアウト

データを読み込むボタンには次のプロパティを設定します:名前 - btnLoad、テキスト - loadData。NDCコードテキストフィールドはtxtNdcと名付けます。「薬品を検索」ボタンには次のプロパティを設定します:名前 - btnLookup、テキスト - 薬品を検索、および有効 - False。最後に、薬品名テキストフィールドには次のプロパティを設定します:Name –txtDrugName、ReadOnly – True。

  1. 次に、「ソリューションエクスプローラー」でプロジェクトを右クリックし、「追加」|「既存の項目」を選択して、product.txtとproduct2.txtファイルをプロジェクトに追加します。
  2. プロパティパネルで、追加した2つのテキストファイルの「出力ディレクトリにコピー」を「新しい場合はコピー」に変更します。
    li>`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薬品ファイルから読み込まれる各レコードのデータを含みます。

    li>次に、`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();
    }
}
    li>次に、`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); // ヘッダーを追加
    }
    
    while (sr.Peek() > 0)
    {
        DataRow dr = dt.NewRow(); // 行を追加
        dr.ItemArray = sr.ReadLine().Split(del);
        dt.Rows.Add(dr);
    }
    foreach (DataRow row in dt.Rows)
    {
        Drug drug = new(); // Drugオブジェクトにマッピング
        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;
            ...
            // GITHUB内の残りのCASEステートメント
        }
    }
    	_drugData.TryAdd(drug.Ndc, drug);
    }
}

このメソッドでは、指定された`fileName`からデータを`StreamReader`に読み込み、列ヘッダーを`DataTable`に追加し、ファイルからその行を埋め込みます。次に、`DataTable`の行と列を反復処理して`Drug`オブジェクトを作成します。各`Drug`オブジェクトは、`TryAdd`を呼び出して`ConcurrentDictionary`に追加され、Ndcプロパティをキーとして使用します。

    li>次に、`GetDrugByNdc`メソッドを`DrugService`に追加してクラスを完成させます。このメソッドは、見つかった場合、指定された`ndcCode`の`Drug`を返します:
public Drug GetDrugByNdc(string ndcCode)
{
    bool result = _drugData.TryGetValue(ndcCode, out var drug);
    if (result && drug != null)
    	return drug;
    else
    	return new Drug();
}
    li>`Form1.cs`のコードを開き、`DrugService`のプライベート変数を追加します:
private DrugService _drugService = new();
    li>`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;
}

この2つのテキストファイルを読み込むために、2つの並行で実行されるタスクを作成し、`Task.WhenAll`を使用してそれらを待ちます。次に、`btnLookup`ボタンを安全に有効にし、2回目の読み込みを防ぐために`btnLoad`ボタンを無効にできます。

    li>次に、`Form1.cs`のデザイビューに戻り、「薬品を検索」ボタンをダブルクリックします。これにより`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;
    }
}
    li>今度はアプリケーションを実行し、「データを読み込む」ボタンをクリックします。読み込みプロセスが完了し、「薬品を検索」ボタンが有効になったら、70518-1120 NDCコードを入力します。薬品を検索をクリックします:

図9.5 – NDCコードで薬品プレドニゾンを検索

    li>他のいくつかのNDCコードを試し、各レコードの読み込み速度を確認します。以下は、各ファイルから取得したいくつかのランダムなNDCコードです。すべてが成功した場合、2つのファイルが正常に並行で読み込まれたことがわかります:0002-0800、0002-4112、43063-825、51662-1544。

以上です!今や高速で簡単な薬品検索アプリケーションがあります。薬品名`TextBox`を`DataGrid`に置き換えて、完全な薬品レコードを表示してみてください。

ConcurrentQueue

このセクションでは、実際のシナリオの簡略化されたバージョンのサンプルプロジェクトを作成します。`ConcurrentQueue<T>`を使用して注文キューングシステムを作成します。このアプリケーションはコンソールアプリケーションで、2つの顧客の注文を並行でキューに登録します。各顧客に5つの注文を作成し、キューの順序を混在させるために、各顧客のキューングプロセスは`Enqueue`の呼び出しの間に異なる`Task.Delay`を使用します。最終的な出力には、最初の顧客と2番目の顧客のデキュー注文の組み合わせが表示されるはずです。`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; }
}
    li>今度は、`OrderService`という名前の新しいクラスを作成し、そこに`ConcurrentQueue<Order>`という名前のプライベート`_orderQueue`を含めます。このクラスでは、2つの顧客の注文をキューに登録し、キューから削除します:
using System.Collections.Concurrent;
namespace ConcurrentOrderQueue
{
    public class OrderService
    {
    	private ConcurrentQueue<Order> _orderQueue = new();
    }
}
    li>`DequeueOrders`の実装から始めましょう。このメソッドでは、`while`ループを使用して`TryDequeue`を呼び出し、コレクションが空になるまで、各注文を`List<Order>`に追加して呼び出し元に返します:
public List<Order> DequeueOrders()
{
    List<Order> orders = new();
    while (_orderQueue.TryDequeue(out var order))
    {
    	orders.Add(order);
    }
    return orders;
}
    li>今度は、パブリックとプライベートの`EnqueueOrders`メソッドを作成します。パブリックのパラメータなしメソッドは、プライベートメソッドを2回呼び出し、各`customerId`に1回ずつ呼び出します。これら2つの呼び出しは並行で実行され、`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 = "顧客" +
            customerId + "用のウィジェット",
            ItemQty = 20 - (i * customerId)
        };
        order.OrderTotal = order.ItemQty * 5;
        _orderQueue.Enqueue(order);
        await Task.Delay(100 * customerId);
    }
}

プライベートの`EnqueueOrders`メソッドは5回反復して、指定された`customerId`の注文を作成しキューに登録します。これはまた、`ItemName`、`ItemQty`、および`Task.Delay`の期間を変更するために使用されます。

    li>最後に、Program.csを開き、注文をキューに登録し、キューから削除し、結果リストをコンソールに出力する次のコードを追加します:
using ConcurrentOrderQueue;
Console.WriteLine("こんにちは、世界!");
var service = new OrderService();
await service.EnqueueOrders();
var orders = service.DequeueOrders();

foreach(var order in orders)
{
	Console.WriteLine(order.ItemName);
}
    li>プログラムを実行し、出力の注文リストを確認します。あなたの結果はどうですか?

図9.6 – 注文キューの出力を表示

`EnqueueOrders`メソッドで遅延因子を変更するか、1つまたは2つの顧客の`customerId`を変更して、出力順序がどのように変化するかを試してみてください。

ConcurrentStack

このセクションでは、`BlockingCollection<T>`と`ConcurrentStack<T>`を使用してみましょう。この章の最初の例では、`BlockingCollection<T>`を使用して『ユリシーズ』の書籍で特定の文字で始まる単語を読み込みました。このプロジェクトをコピーし、テキスト行を読み込むコードを変更して、`BlockingCollection<T>`内で`ConcurrentStack<string>`を使用します。これにより、スタックが後入れ先出し(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;
}
    li>今度は、アプリケーションを実行し、以前と同じ文字(私の場合はT)を検索すると、リストの先頭に一連の異なる単語が表示されます:

図9.7 – 『ユリシーズ』でTで始まる単語を検索

リストの下にスクロールすると、書籍の最初の単語が表示されるはずです。各行の単語を解析する際に`ConcurrentStack<string>`を使用しなかったため、リストは完全に逆になっていないことに注意してください。これを別の実験として自分で試してみてください。

まとめ

この章では、`System.Collections.Concurrent`名前空間にある5つのコレクションについて詳しく調べました。この章では、.NET 6で利用可能な各種の並列コレクションタイプの実用的な経験を得るために、5つのサンプルアプリケーションを作成しました。WPF、WinForms、.NETコンソールアプリケーションプロジェクトを混合することで、これらのコレクションを独自のアプリケーションで活用するいくつかの実際の方法を調査しました。

タグ: .NET並列プログラミング コレクション スレッドセーフ BlockingCollection ConcurrentBag

7月23日 21:52 投稿