Handling Static Objects and Managing Deadlocks in Multithreaded .NET Applications

Working with Static Objects

When dealing with static data in .NET, there are important considerations for managed threading.

Static Data and Constructors

A key aspect of accessing static data from managed threads involves constructors. The runtime ensures that a class's static constructor completes before any static member is accessed. Threads are blocked until the static constructor finishes, guaranteeing all required initialization is done.

If you use static objects in your codebase, you know which classes have static constructors and can control their complexity. However, when static data is outside your control, in third-party libraries or .NET itself, things become less clear. Let's illustrate potential delays with a simple example.

  1. First, create a new .NET console application called ThreadingStaticDataExample in Visual Studio.
  2. Add a class WorkstationState with the following static members:
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;

namespace ThreadingStaticDataExample
{
    internal class WorkstationState
    {
        internal static string Name { get; set; }
        internal static string IpAddress { get; set; }
        internal static bool IsNetworkAvailable { get; set; }

        [ThreadStatic]
        internal static DateTime? NetworkConnectivityLastUpdated;

        static WorkstationState()
        {
            Name = Dns.GetHostName();
            IpAddress = GetLocalIPAddress(Name);
            IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
            NetworkConnectivityLastUpdated = DateTime.UtcNow;
            Thread.Sleep(2000);
        }

        private static string GetLocalIPAddress(string hostName)
        {
            var hostEntry = Dns.GetHostEntry(hostName);
            foreach (var address in hostEntry.AddressList
                                    .Where(a => a.AddressFamily == AddressFamily.InterNetwork))
            {
                return address.ToString();
            }
            return string.Empty;
        }
    }
}

This class holds workstation information: hostname, local IP, and network availability. The static constructor initializes properties and injects a two-second delay via Thread.Sleep to simulate slow network operations.

  1. Next, add a WorkstationHelper class with an async method to update WorkstationState properties and return IsNetworkAvailable:
internal async Task<bool> GetNetworkAvailability()
{
    await Task.Delay(100);

    WorkstationState.IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
    WorkstationState.NetworkConnectivityLastUpdated = DateTime.UtcNow;

    return WorkstationState.IsNetworkAvailable;
}
  1. Finally, update Program.cs:
using ThreadingStaticDataExample;

Console.WriteLine("Hello, World!");
Console.WriteLine($"Current datetime:{DateTime.UtcNow}");

var helper = new WorkstationHelper();
await helper.GetNetworkAvailability();

Console.WriteLine($"Network availability last updated{WorkstationState.NetworkConnectivityLastUpdated} for computer {WorkstationState.Name} at IP {WorkstationState.IpAddress}");
  1. Run the program and observe the output. There's a two-second delay between the two Console.WriteLine calls due to the static constructor:
Hello, World!
Current datetime: 2/12/2022 4:07:13 PM
Network availability last updated 2/12/2022 4:07:15 PM for
computer ALVINASHCRABC3A at IP 10.211.55.3

Static constructors are one aspect to remember when using managed threads. A more common issue is controlling concurrent read/write access to static objects across threads.

Controlling Shared Access to Static Objects

The best practice for static data is to avoid it whenever possible. It reduces testability, scalability, and increases the risk of unexpected behavior with concurrency. However, sometimes static data is unavoidable—for instance, in legacy codebases where refactoring is risky. Static classes are also useful for rarely changing data or stateless classes.

When static objects are inevitable, take precautions. Let's review some, starting with locks.

Locks

Locks become even more critical with static variables due to increased chance of concurrent access. The simplest approach is to enclose any code accessing a static object within a lock. Let's modify WorkstationHelper to prevent concurrent writes:

internal class WorkstationHelper
{
    private static object _workstationLock = new object();

    internal async Task<bool> GetNetworkAvailability()
    {
        await Task.Delay(100);

        lock(_workstationLock)
        {
            WorkstationState.IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
            WorkstationState.NetworkConnectivityLastUpdated = DateTime.UtcNow;
        }

        return WorkstationState.IsNetworkAvailable;
    }
}

We added a private static _workstationLock object within the lock block. Now, if GetNetworkAvailability is used in Parallel.ForEach or other concurrrent operations, only one thread enters the lock at a time.

You can use any locking mechanism discussed previously. Another .NET feature worth noting is the ThreadStatic attribute.

Fields marked ThreadStatic should not be initialized in constructors, as initialization applies only to the current thread. All other threads see null or the type's default value. If you apply ThreadStatic to NetworkConnectivityLastUpdated and call GetNetworkAvailability thirty times in a Parallel.For loop, the final value read in Program.cs might not be the last value written by any thread. The variable in Program.cs will hold the value written by the main thread at the time of reading.

  1. To experiment, add ThreadStatic to NetworkConnectivityLastUpdated and make it a field instead of a property (attributes cannot be applied to properties):
[ThreadStatic]
internal static DateTime? NetworkConnectivityLastUpdated;
  1. Then update Program.cs with a Parallel.For loop:
using ThreadingStaticDataExample;

Console.WriteLine("Hello, World!");
Console.WriteLine($"Current datetime:{ DateTime.UtcNow}");

var helper = new WorkstationHelper();
Parallel.For(1, 30, async (x) =>
{
    await helper.GetNetworkAvailability();
});

Console.WriteLine($"Network availability last updated { WorkstationState.NetworkConnectivityLastUpdated} for computer { WorkstationState.Name} at IP{ WorkstationState.IpAddress }");

Now the date/time value in the output varies each run because the final value might not be from the last thread.

While ThreadStatic should only be used when each thread needs its own instance, a similar pattern is the singleton. Let's discuss singletons in multithreaded applications.

The Singleton Pattern in Practice

The singleton pattern restricts a class to a single instance. It's well-known and each major DI framework supports registering types as singletons. The container creates one instance per type, providing the same instance on every request.

We can manually create a singleton for WorkstationState using locks. Here's WorkstationStateSingleton:

public class WorkstationStateSingleton
{
    private static WorkstationStateSingleton? _singleton = null;
    private static readonly object _lock = new();

    WorkstationStateSingleton()
    {
        Name = Dns.GetHostName();
        IpAddress = GetLocalIPAddress(Name);
        IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
        NetworkConnectivityLastUpdated = DateTime.UtcNow;
    }

    public static WorkstationStateSingleton Instance
    {
        get
        {
            lock (_lock)
            {
                if (_singleton == null)
                {
                    _singleton = new WorkstationStateSingleton();
                }
                return _singleton;
            }
        }
    }

    public string Name { get; set; }
    public string IpAddress { get; set; }
    public bool IsNetworkAvailable { get; set; }
    public DateTime? NetworkConnectivityLastUpdated { get; set; }

    private string GetLocalIPAddress(string hostName)
    {
        var hostEntry = Dns.GetHostEntry(hostName);
        foreach (var address in hostEntry.AddressList
                                .Where(a => a.AddressFamily == AddressFamily.InterNetwork))
        {
            return address.ToString();
        }
        return string.Empty;
    }
}

To make it a singleton: first, the constructor is private so only WorkstationStateSingleton can create an instance. Second, a static Instance method returns the _singleton if not null, otherwise creates it. The lock ensures thread safety during creation.

Singletons face challenges similar to static classes. If shared data is accessed concurrently by managed threads, locks should protect it. An extra challenge with DI container singletons is that the lock object must be in the same scope as the container to ensure all consumers enforce the same lock.

Note: Using singletons is often not considered good practice today; many developers consider them an anti-pattern. However, it's important to understand them and how existing singletons might behave with multithreaded code.

Deadlocks are a pitfall of aggressive locking. Aggressive locking refers to using locks in many parts of code that could run in parallel. In the next section, we discuss deadlocks and race conditions in managed threading.

Managing Deadlocks and Race Conditions

As with many tools, abusing managed threading features can negatively impact application runtime. Deadlocks and race conditions are two outcomes of multithreaded programming:

  • Deadlock: Occurs when multiple threads try to lock the same resource, causing none to proceed.
  • Race condition: Occurs when multiple threads update a routine concurrently, and the correct result depends on execution order.

Avoiding Deadlocks

Avoiding deadlocks is crucial. If a deadlocked thread is the UI thread, it freezes the application. Non-UI thread deadlocks make diagnostics harder. Deadlocked thread pool threads prevent shutdown, but deadlocked background threads do not.

Well-instrumented code is vital for debugging production issues. If you can reproduce the problem in a development environment, stepping through the code with the Visual Studio debugger is the fastest way to find the root cause.

One simple way to create a deadlock is through recursive or nested methods trying to acquire the same lock. Consider:

private object _lock = new object();
private List<string> _data;
public DeadlockSample()
{
    _data = new List<string> { "First", "Second", "Third" };
}

public async Task ProcessData()
{
    lock (_lock)
    {
        foreach(var item in _data)
        {
            Console.WriteLine(item);
        }
        await AddData();
    }
}

private async Task AddData()
{
    lock (_lock)
    {
        _data.AddRange(GetMoreData());
        await Task.Delay(100);
    }
}

ProcessData locks _lock and processes _data, then calls AddData which tries to acquire the same lock. The lock never becomes available, causing a deadlock. The issue is obvious here. But if AddData is called from multiple places or parent code involves Parallel.ForEach loops where some parts acquire the lock and others do not, it's subtler. In such cases, a non-blocking read lock from ReaderWriterLockSlim can help prevent deadlocks.

Another prevention technique is adding a timeout to lock attempts using Monitor.TryEnter. This example times out if the lock is not acquired within one second:

private void AddDataWithMonitor()
{
    if (Monitor.TryEnter(_lock, 1000))
    {
        try
        {
            _data.AddRange(GetMoreData());
        }
        finally
        {
            Monitor.Exit(_lock);
        }
    }
    else
    {
        Console.WriteLine($"AddData: Unable to acquire lock. Stack trace: {Environment.StackTrace}");
    }
}

Logging lock acquisition failures helps locate potential deadlock sources so you can rewrite code to avoid them.

Avoiding Race Conditions

Race conditions occur when multiple threads read and write the same variable simultaneously without locking. Results become unpredictable. Operations may be overwritten by other parallel threads. Even with locks, the order of operations can change results. Here's a simple example without locks, performing addition and multiplication in parallel:

private int _runningTotal;

public void PerformCalculationsRace()
{
    _runningTotal = 3;
    Parallel.Invoke(() => {
        AddValue().Wait();
    }, () => {
        MultiplyValue().Wait();
    });
    Console.WriteLine($"Running total is {_runningTotal}");
}

private async Task AddValue()
{
    await Task.Delay(100);
    _runningTotal += 15;
}

private async Task MultiplyValue()
{
    await Task.Delay(100);
    _runningTotal = _runningTotal * 10;
}

Order matters. If processed sequentially, results could be 180 or 45. But if both AddValue and MultiplyValue read the initial value 3 before executing their operations, the last method to complete writes either 18 or 30 as _runningTotal.

To ensure multiplication occurs before addition, rewrite PerformCalculations using ContinueWith:

public async Task PerformCalculations()
{
    _runningTotal = 3;

    await MultiplyValue().ContinueWith(async (Task) => {
        await AddValue();
    });

    Console.WriteLine($"Running total is {_runningTotal}");
}

This always multiplies before adding, resulting in _runningTotal always being 45. Using async and await throughout keeps the UI or service process responsive while using thread pool threads as needed.

The Interlocked class, discussed earlier, can also perform thread-safe mathematical operations on shared resources. Here's the original Parallel.Invoke example modified to use Interlocked methods with _runningTotal:

public class InterlockedSample
{
    private long _runningTotal;

    public void PerformCalculations()
    {
        _runningTotal = 3;
        Parallel.Invoke(() => {
            AddValue().Wait();
        }, () => {
            MultiplyValue().Wait();
        });
        Console.WriteLine($"Running total is {_runningTotal}");
    }

    private async Task AddValue()
    {
        await Task.Delay(100);
        Interlocked.Add(ref _runningTotal, 15);
    }

    private async Task MultiplyValue()
    {
        await Task.Delay(100);
        var currentTotal = Interlocked.Read(ref _runningTotal);
        Interlocked.Exchange(ref _runningTotal, currentTotal * 10);
    }
}

The two operations may still execute in different orders, but _runningTotal is now protected and thread-safe. Interlocked is more efficient than lock statements for simple changes and yields better performance.

When performing concurrent operations, protect all shared resources. A well-designed locking strategy gives best performance while maintaining thread safety. Let's conclude with guidance on thread throttling.

Thread Throttling and Other Recommendations

Multithreading can improve performance, but it's not without limits. Should you replace all foreach loops with Parallel.ForEach and invoke all services on thread pool threads? Not exactly. There are constraints.

The number of threads that can execute simultaneously is limited by the number of processors and processor cores on the system. Hardware limits are unavoidable. Additionally, your application shares these CPUs with other running processes. If your CPU has four cores and is actively running five other applications, the system likely won't handle multiple threads from your program at once.

The .NET thread pool is optimized to handle various scenarios based on available threads, but you can help prevent overloading. Some parallel operations, like Parallel.ForEach, allow limiting the degree of parallelism via ParallelOptions.MaxDegreeOfParallelism. By default, the loop uses as many threads as the scheduler provides.

You can ensure the maximum does not exceed half the available cores:

public void ProcessParallelForEachWithLimits(List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? Environment.ProcessorCount / 2 : 1;

    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = max
    };

    Parallel.ForEach(items, options, y => {
        // Process items
    });
}

PLINQ operations can also limit parallelism using WithDegreeOfParallelism:

public bool ProcessPlinqWithLimits(List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? Environment.ProcessorCount / 2 : 1;

    return items.AsParallel()
        .WithDegreeOfParallelism(max)
        .Any(i => CheckString(i));
}

private bool CheckString(string item)
{
    return !string.IsNullOrWhiteSpace(item);
}

Applications can also adjust thread pool maximums using ThreadPool.SetMaxThreads for worker and completion port threads. Complesion port threads handle async I/O. Changing these values is usually unnecessary and has restrictions. The maximum cannot be set below the number of cores or below the current minimum on the thread pool. Query the current minimum with ThreadPool.GetMinThreads. Example of safely setting max thread values:

private void UpdateThreadPoolMax()
{
    ThreadPool.GetMinThreads(out int workerMin, out int completionMin);
    int workerMax = GetProcessingMax(workerMin);
    int completionMax = GetProcessingMax(completionMin);
    ThreadPool.SetMaxThreads(workerMax, completionMax);
}

private int GetProcessingMax(int min)
{
    return min < Environment.ProcessorCount ? Environment.ProcessorCount * 2 : min * 2;
}

Follow some general guidelines. Avoid assigning multiple threads to operations that share resources. For example, if a service logs activity to a file, don't assign multiple background workers for logging. Blocking file I/O prevents the second thread from writing until the first completes, gaining no efficiency.

If you find yourself adding extensive locks to objects, you might be using too many threads or need to change task allocation to reduce resource contention. Try dividing thread task responsibilities based on data types. You may have many parallel tasks calling services for data, but only one or two threads processing the returned data.

You may have heard of "thread starvation," which often occurs when too many threads block or wait for resources. Common scenarios:

  • Locks: Too many threads competing for the same locked resource. Analyze your code to reduce contention.
  • No async/await: In ASP.NET Core, all controller methods should be marked async. This allows the web server to handle other requests while your request waits for operations to complete.
  • Too much threading: Creating too many thread pool threads leads to more idle threads waiting. It also increases the chance of thread contention and starvation.

Avoid these practices, and .NET will manage the thread pool optimally for your application and others on the system.

Finally, do not use Thread.Suspend and Thread.Resume to control the order of operations across threads. Instead, use techniques discussed in this section, including locking mechanisms and Task.ContinueWith.

Tags: .NET multithreading static objects deadlock race condition

Posted on Mon, 18 May 2026 07:20:17 +0000 by Naez