1. Introduction
Let's look at a familiar situation when working with threads. Suppose we have a shared success counter in a very simple app.
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // Not atomic!
}
}
// Launch two threads:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
Run this code a few times. You will almost never see 200_000! Why? The two threads constantly interfere with each other: sometimes both read the variable at the same time, increment it — and write back the same result. As a result, some increments get "lost."
This is a race condition. Without following the "queue" rules, threads literally fight over the data.
Critical section: what is it?
Critical section is a piece of code that must be executed by only one thread at a time. Returning to our kitchen analogy: it's like a single faucet — if two people try to wash at the same sink, sweat and toothpaste are guaranteed everywhere. Let's agree to use the bathroom one at a time!
In our example the critical section is the line counter++.
2. The lock keyword
C# has a concise and safe way to create a critical section — the lock keyword. It hides the complex synchronization primitive work from us and ensures that only one thread can enter the protected block at a time.
How to use lock
Syntax:
lock (lockerObject)
{
// Code that only one thread can execute at a time
}
lockerObject is any object that exists for the lifetime of the program. Usually people do:
private static object locker = new object();
Note: never use strings, numbers, or objects that someone else might accidentally access for this! Only private objects that you definitely don't use anywhere else.
Let's fix our example
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // Now this is atomic!
}
}
}
Now two or ten threads will enter that piece of code one by one. The result will be a perfect 200_000. Kitties are happy!
3. How lock works inside? The Monitor class
Under the hood the lock keyword works with the System.Threading.Monitor class. It's like a real secretary who only lets people in with a special pass.
The syntax equivalent to lock (but more "undressed"):
Monitor.Enter(locker);
try
{
// Critical section
}
finally
{
Monitor.Exit(locker);
}
The key difference — you are required to guarantee that Monitor.Exit will be called. Usually you use try...finally for that. If you forget to call Exit(), the thread will remain "inside" forever, and later threads will wait forever — the program will hang like old Windows during updates.
Table: lock vs manual Monitor
| Way | Safety from mistakes | Easier to write | Flexibility |
|---|---|---|---|
|
Yes | Yes | No |
|
Only with try/finally | No | Yes |
In 99% of cases use lock. Manual Monitor is needed only if you need maximum flexibility: for example, if you want to make a locking method with a timeout.
4. Arguments for lock: what you can and can't use?
A very common newbie mistake is using a string or other "visible" object for locking. For example:
lock ("mylock") { /*...*/ } // Very bad!
The problem is that strings are interned (unique for the whole app), so you can easily conflict with other libraries and end up with a "dead" program. Always use private objects:
private readonly object myLock = new object();
lock (myLock)
{
// only your code knows about myLock
}
5. lock: console output example
Let's practice! We'll create a mini app where two threads print lines, but access to the console is also synchronized — so the text doesn't get mixed.
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: Message {i + 1}");
Thread.Sleep(50); // Simulate work
}
}
}
Thread t1 = new Thread(() => PrintMessages("Thread 1"));
Thread t2 = new Thread(() => PrintMessages("Thread 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Result: lines appear neatly one after another, no mess. This approach is often used for logging so logs don't contain garbled text.
6. Useful nuances
Manual lock control: advanced Monitor
When the standard lock isn't enough (for example, if you want to try to enter a section without waiting forever), you can use Monitor.TryEnter.
if (Monitor.TryEnter(locker, 100)) // 100 ms wait
{
try
{
// Critical section
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("Failed to acquire lock within 100 milliseconds");
}
This is useful if your program doesn't want to "hang" — for example, you can show the user a message or do something useful while the shared resource is busy.
Visualization: how a lock works (diagram)
flowchart LR
A[Thread 1: wants to enter critical section]
B[Thread 2: wants to enter critical section]
C[locker is free]
D[Thread 1 executes code inside lock]
E[Thread 2 waits]
F[Thread 1 exited lock]
G[Thread 2 gets access]
A -- Check locker --> C
C -- locker free --> D
B -- Check locker --> D
D -- lock occupied --> E
D -- Finished work --> F
F -- Freed locker --> G
E -- locker now free --> G
Locks and performance
Locks work simply: only one thread at a time can execute the code between the braces. This is great for data integrity, but... the more threads "queue up", the slower everything gets. So synchronization isn't a cure-all: try to keep critical sections as small as possible.
Life hack: if the critical section takes fractions of a millisecond — great. If it contains long calculations, I/O, network or file work — it's better to move those out of the lock. First read/compute, then quickly update the shared value inside the protection.
In interviews and real life
In any serious program using threads, employers will definitely ask: "What to do if two threads access the same variable?" Show code with a lock — and your resume won't disappear into the black HR box.
In practice, especially in high-load systems, more advanced synchronization mechanisms are used — but lock and Monitor remain the gold standards for simple cases.
7. Lock usage quirks and common mistakes
The most common mistake is "forgetting" to use the same object as the lock. For example:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
If both methods manage the same variable but objects a and b are different, you've created a fake protection — threads will operate on the variable simultaneously!
Conclusion: always use the same object to protect the same data.
Another case is using too "broad" a lock. For example, doing lock (this) inside a regular class if you're not sure no one outside uses that object for locking. This can also lead to deadlocks and other fun but undesirable bugs.
And finally: DO NOT lock long or external operations (file, network) inside lock. You risk blocking access for other threads for a long time, reducing performance. Critical section = only what absolutely cannot be done in parallel!
GO TO FULL VERSION