【C#】マルチスレッド環境でのデータ競合を防ぐlock文の正しい使い方

並列処理(マルチスレッド)を行う際、複数のスレッドが同時に1つの変数やリソースを書き換えようとすると、「競合状態(レースコンディション)」が発生し、データの整合性が破壊される可能性があります。

C#において、特定ブロックの処理を「一度に1つのスレッドだけが実行できる」ように制限(排他制御)するための最も基本的な手段が lock ステートメントです。

ここでは、PLINQを用いた並列計算の中で、スレッドセーフに最大値を算出する実装例を解説します。

目次

lock文による排他制御の実装

以下のサンプルコードは、複数のサーバーの稼働データリストを並列に処理し、各サーバーの平均応答時間を計算すると同時に、全体の「最も遅い応答時間(最大値)」を特定するシミュレーションです。

最大値の更新処理は複数のスレッドから同時に行われる可能性があるため、lock を使用して保護します。

サンプルコード

using System;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        // 1. データソース: 各サーバーのアクセス統計
        var serverLogs = new List<ServerMetric>
        {
            new ServerMetric { ServerId = "SV-001", RequestCount = 500, TotalDurationMs = 25000 },
            new ServerMetric { ServerId = "SV-002", RequestCount = 450, TotalDurationMs = 13500 }, // 高速
            new ServerMetric { ServerId = "SV-003", RequestCount = 600, TotalDurationMs = 42000 }, // 低速
            new ServerMetric { ServerId = "SV-004", RequestCount = 300, TotalDurationMs = 15000 },
            new ServerMetric { ServerId = "SV-005", RequestCount = 550, TotalDurationMs = 22000 },
        };

        // 解析用クラスのインスタンス
        var analyzer = new LogAnalyzer();

        Console.WriteLine("並列解析を開始します...");

        // 2. PLINQによる並列処理
        // ForAllを使用し、各要素に対して解析メソッドを実行
        serverLogs.AsParallel()
                  .ForAll(log => analyzer.CalculateAndCompare(log));

        // 結果の出力
        Console.WriteLine("\n--- 個別サーバーの結果 ---");
        foreach (var log in serverLogs)
        {
            Console.WriteLine($"[{log.ServerId}] 平均応答: {log.AverageDuration:F2} ms");
        }

        Console.WriteLine("\n--- 全体の統計 ---");
        Console.WriteLine($"最大平均応答時間(ワースト): {analyzer.MaxAverageTime:F2} ms");
    }
}

// サーバー統計データクラス
public class ServerMetric
{
    public string ServerId { get; set; }
    public int RequestCount { get; set; }       // リクエスト総数
    public double TotalDurationMs { get; set; } // 合計処理時間
    public double AverageDuration { get; set; } // 平均(計算後に格納)
}

// 解析ロジッククラス
public class LogAnalyzer
{
    // 排他制御のためのロックオブジェクト
    // private readonly な参照型オブジェクトを使用するのが定石
    private readonly object _syncRoot = new object();

    // 共有リソース: 最大値
    public double MaxAverageTime { get; private set; } = 0.0;

    public void CalculateAndCompare(ServerMetric metric)
    {
        // 1. ローカルな計算処理
        // この部分はインスタンスごとの計算であり、他スレッドと競合しないためロック不要
        if (metric.RequestCount > 0)
        {
            metric.AverageDuration = metric.TotalDurationMs / metric.RequestCount;
        }

        // 2. クリティカルセクション(共有リソースへのアクセス)
        // MaxAverageTime の読み取りと更新は同時に行われると値がおかしくなるためロックする
        lock (_syncRoot)
        {
            if (metric.AverageDuration > MaxAverageTime)
            {
                MaxAverageTime = metric.AverageDuration;
            }
        }
    }
}

解説と重要なポイント

ロックオブジェクトの選定

lock に使用するオブジェクトは、private readonly object 型の専用変数を用意するのがベストプラクティスです。 thisType オブジェクト、あるいは外部に公開されている文字列などをロック対象にすると、意図しない場所でロックが競合(デッドロックなど)するリスクがあります。

クリティカルセクションの最小化

lock { ... } ブロック内(クリティカルセクション)の処理中は、他のスレッドが待機状態になります。 サンプルコードのように、「個別の平均値計算」などスレッド間で共有しない処理はロックの外に出し、「共有変数の更新」という必要最小限の箇所だけをロックすることが、並列処理のパフォーマンスを維持する上で重要です。

メモリバリアと可視性

lock ステートメントは排他制御を行うだけでなく、メモリバリアとしての役割も果たします。これにより、あるスレッドで更新された変数の値が、確実に他のスレッドからも最新の状態として見える(可視性が確保される)ようになります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次