1. The secret to resilient multithreaded code
If you imagine multithreading as a team of several workers fixing a car at the same time, it's clear: as soon as one of them grabs someone else's tool — the repair stalls. In code it's the same: careless handling of shared data leads to subtle bugs that may only show up "in production".
In this lecture — how to write multithreaded code that doesn't collapse like a house of cards. Also: which tools help when something does go wrong.
1. Minimize critical sections (lock)
The less code inside a lock, the better. While one thread holds the lock, others wait.
Example:
// BAD: All business logic inside lock — all threads wait
lock(_locker)
{
// Long operation (not related to shared resource)
Thread.Sleep(500);
counter++;
}
// GOOD: Only the minimal necessary action inside lock
// heavy work is outside the lock
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
Real life: If a network call or a long calculation ends up inside the lock, performance drops sharply.
2. Don’t use universal objects as lock keys
Writing lock(this) or lock(typeof(MyClass)) is a bad idea.
Why? If someone else uses the same object for their lock, you'll get deadlocks or hidden bugs. Always use a dedicated private object:
private readonly object _locker = new object();
lock(_locker)
{
// Your actions
}
Forbidden: strings (string), public fields, value-type objects.
3. Always use try...finally to release acquired resources
Any acquisition of Mutex, a semaphore, ReaderWriterLockSlim — must be released in finally.
_mutex.WaitOne();
try
{
// Critical section
}
finally
{
_mutex.ReleaseMutex();
}
4. Don’t overuse synchronization
Synchronize only access to real shared resources (for example, collections), not every little thing. Excessive locks turn code into a waiting queue.
5. Use thread-safe collections and types
.NET provides special collections for multithreaded scenarios: ConcurrentDictionary, ConcurrentQueue, ConcurrentBag, BlockingCollection and others. They protect themselves internally.
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "Sean");
users[2] = "Bob";
6. Beware of deadlock
A common trap is acquiring multiple locks in different orders.
// Thread 1
lock(obj1)
{
lock(obj2)
{
// Do something
}
}
// Thread 2
lock(obj2)
{
lock(obj1)
{
// Do something
}
}
Tip: Always acquire locks in the same order across all threads.
7. Prefer immutable state when possible
If an object doesn't change state after creation — it's safe to read from any thread. Examples: string, Tuple, DateTime, your own read-only DTOs.
2. Tools for diagnosing multithreading issues
Synchronization bugs are sneaky: they appear rarely and unpredictably. Use tools and approaches that help catch and analyze them.
1. Event and thread logging
Log current Thread.ManagedThreadId and key operations — it's a simple way to understand "who and when" entered/exited a critical section.
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Entered critical section");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Exited critical section");
For real apps use Microsoft.Extensions.Logging, NLog, Serilog.
2. Thread Sanitizer & Race Detector
There isn't a perfect built-in ThreadSanitizer in .NET, but there are useful tools:
- JetBrains ReSharper — inspections catch some dangerous patterns.
- Roslyn Analyzers — static code analysis.
- Concurrency Visualizer — analyzes waits/locks and thread load.
3. Visual Studio Diagnostics Tools
Visual Studio profilers help you see:
- which threads exist in the app;
- where threads are idle (waiting);
- where locks and contention occur;
- when deadlock and contention appear.
Capture traces to get a detailed graph of lock usage.
4. Dump analysis and WinDbg
If the server "hung", take a process dump and open it in WinDbg or dotnet-dump. From call stacks you can see where threads are stuck and who holds which locks.
Example of stack analysis:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(Dumps are usually used by "deployment jedis" — don't be afraid, it's a powerful tool.)
5. Unit testing with stress (stress testing)
Run multithreaded code in parallel for hundreds/thousands of iterations — rare races are easier to find that way.
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. Use asserts and special checks
Add checks that guarantee correct state in debug. For example, Debug.Assert when a resource is attempted to be re-acquired by the same thread.
3. Conclusions and recommendations
Visual diagram: danger zone and safety
graph TD
A[Shared resource] -- without synchronization --> B(Race condition)
A -- lock (lock/Mutex) --> C[Safe access: critical section]
C -- "too many locks" --> D(Performance loss)
A -- ReaderWriterLockSlim --> E{Many readers / One writer}
E -- "Read" --> F[Many threads read concurrently]
E -- "Write" --> G[Only one writes, others wait]
Synchronization primitives and their purpose
| Primitive | What it's for | How many threads it allows | Cross-process | Performance | Where to use |
|---|---|---|---|---|---|
|
Simple critical section | 1 | No | Very high | 99% of cases |
|
The same section, but between processes | 1 | Yes | Medium | Files, IPC |
|
No more than N threads | N | Yes | Medium | Resource pools |
|
Same, but faster, within a process | N | No | High | Pools in code |
|
Many readers, one writer | Many/1 | No | High | Caches, settings |
GO TO FULL VERSION