[C#] How to Use the lock Statement Correctly to Prevent Data Races

When performing parallel processing (multi-threading), if multiple threads try to modify a single variable or resource at the same time, a “race condition” can occur. This can destroy data integrity.

In C#, the lock statement is the most basic way to restrict a block of code so that only one thread can execute it at a time (exclusive control).

This article explains an implementation example of calculating a maximum value in a thread-safe manner using PLINQ.

目次

Implementing Exclusive Control with the lock Statement

The following sample code simulates processing a list of server operation data in parallel. It calculates the average response time for each server while identifying the overall “slowest response time (maximum value).”

Since the process of updating the maximum value might be accessed by multiple threads simultaneously, we protect it using lock.

Sample Code

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

public class Program
{
    public static void Main()
    {
        // 1. Data Source: Access statistics for each server
        var serverLogs = new List<ServerMetric>
        {
            new ServerMetric { ServerId = "SV-001", RequestCount = 500, TotalDurationMs = 25000 },
            new ServerMetric { ServerId = "SV-002", RequestCount = 450, TotalDurationMs = 13500 }, // Fast
            new ServerMetric { ServerId = "SV-003", RequestCount = 600, TotalDurationMs = 42000 }, // Slow
            new ServerMetric { ServerId = "SV-004", RequestCount = 300, TotalDurationMs = 15000 },
            new ServerMetric { ServerId = "SV-005", RequestCount = 550, TotalDurationMs = 22000 },
        };

        // Instance of the analysis class
        var analyzer = new LogAnalyzer();

        Console.WriteLine("Starting parallel analysis...");

        // 2. Parallel processing with PLINQ
        // Use ForAll to execute the analysis method for each element
        serverLogs.AsParallel()
                  .ForAll(log => analyzer.CalculateAndCompare(log));

        // Output results
        Console.WriteLine("\n--- Individual Server Results ---");
        foreach (var log in serverLogs)
        {
            Console.WriteLine($"[{log.ServerId}] Avg Duration: {log.AverageDuration:F2} ms");
        }

        Console.WriteLine("\n--- Overall Statistics ---");
        Console.WriteLine($"Max Avg Time (Worst): {analyzer.MaxAverageTime:F2} ms");
    }
}

// Server statistics data class
public class ServerMetric
{
    public string ServerId { get; set; }
    public int RequestCount { get; set; }       // Total requests
    public double TotalDurationMs { get; set; } // Total duration
    public double AverageDuration { get; set; } // Average (stored after calculation)
}

// Analysis logic class
public class LogAnalyzer
{
    // Lock object for exclusive control
    // Using a private readonly reference type is standard practice
    private readonly object _syncRoot = new object();

    // Shared resource: Maximum value
    public double MaxAverageTime { get; private set; } = 0.0;

    public void CalculateAndCompare(ServerMetric metric)
    {
        // 1. Local calculation
        // This part is per-instance and does not conflict with other threads, so no lock is needed
        if (metric.RequestCount > 0)
        {
            metric.AverageDuration = metric.TotalDurationMs / metric.RequestCount;
        }

        // 2. Critical Section (Accessing shared resources)
        // Lock this because reading and updating MaxAverageTime simultaneously leads to incorrect values
        lock (_syncRoot)
        {
            if (metric.AverageDuration > MaxAverageTime)
            {
                MaxAverageTime = metric.AverageDuration;
            }
        }
    }
}

Explanation and Important Points

Selecting the Lock Object

It is best practice to use a dedicated private readonly object variable for the lock. If you lock on this, a Type object, or a publicly accessible string, there is a risk of lock contention (such as deadlocks) occurring in unintended places.

Minimizing the Critical Section

While code inside the lock { ... } block (critical section) is running, other threads must wait.

As shown in the sample code, it is important to keep local calculations (like calculating individual averages) outside the lock. Only lock the minimum necessary parts (like updating shared variables) to maintain parallel processing performance.

Memory Barrier and Visibility

The lock statement performs exclusive control and also acts as a memory barrier. This ensures that variable values updated by one thread are visible as the latest state to other threads.

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

この記事を書いた人

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

目次