What it is
Concurrency is several things running at once and sharing data. The trouble is that "read a value, change it, write it back" isn't one step. Two threads can both read 5, both add 1, and both write 6, and you've lost an update. That's a race condition.
How it works
| Tool | What it does | Use it for |
|---|---|---|
| Lock (mutex) | Only one thread inside at a time | Protecting a check-then-update |
| Condition variable | Sleep until another thread says something changed | Waiting for space or for work |
| Semaphore | Lets up to n threads in | Limiting how many run at once |
| Thread-safe queue | Handles all of the above for you | Passing work between threads |
A bounded queue is the classic interview build. Producers wait when it's full, consumers wait when it's empty, and a lock protects the list itself:
import threading
from collections import deque
class BoundedQueue:
def __init__(self, capacity):
self.items = deque()
self.capacity = capacity
self.lock = threading.Lock()
self.not_full = threading.Condition(self.lock)
self.not_empty = threading.Condition(self.lock)
def put(self, item):
with self.not_full:
while len(self.items) >= self.capacity:
self.not_full.wait()
self.items.append(item)
self.not_empty.notify()
def get(self):
with self.not_empty:
while not self.items:
self.not_empty.wait()
item = self.items.popleft()
self.not_full.notify()
return itemWaits are inside a while loop, not an if. A thread can wake up and find another thread got there first, so it has to check again.
Trade-offs
One big lock is simple and correct, but every thread waits on it. Finer locks, like one per key, let more run at once, but if two threads take two locks in opposite orders, each waits for the other forever. That's a deadlock. Always taking locks in the same order prevents it.
In an interview
- Point out exactly which steps need to happen together.
- Start with one lock, then make it finer only if you need to.
- Mention deadlocks and how your lock order avoids them.
Worked example
A thread-safe log writer
Many threads in a service call log(message). The messages should end up in a file, in the order they were logged, without slowing down the threads that call log.
- Writing to the file directly from every thread means they all fight over it, and two writes can interleave halfway through a line.
- A lock around the write fixes interleaving, but now every caller waits for disk I/O. That's the 'slows down callers' problem.
- Better: log() just puts the message on a thread-safe queue and returns. A single writer thread takes messages off and writes them. Only one thread touches the file, so the file itself needs no lock.
- Order is preserved because the queue is first in, first out, and there's one writer.
- If the disk is slow and the queue grows forever, you'll run out of memory. Make it a bounded queue. When it's full, either block the caller or drop messages and count the drops, and say which you chose.
- The writer can batch: take everything waiting, write it in one go, and flush. That's far fewer disk writes when things are busy.
- On shutdown, put a special 'stop' message on the queue. The writer drains everything before it, flushes, and exits, so nothing is lost.
Follow-up questions
Things interviewers tend to ask once you've brought this up.
- How do you make a counter thread-safe?
- Put the read-add-write inside a lock, or use an atomic operation if your language has one. For very hot counters, give each thread its own count and add them up when asked.
- What's the difference between a lock and a semaphore?
- A lock lets one thread in at a time. A semaphore lets up to n in, which is useful for limiting how many requests hit a database at once.
- How do you avoid deadlocks?
- Always take locks in the same order, hold them as briefly as you can, and don't call code you don't control while holding one.
- When would you use a read-write lock?
- When reads are far more common than writes. Many readers can hold it at once, and a writer waits for them to finish.
Common mistakes
- Checking a condition and acting on it in separate steps, without a lock around both.
- Using 'if' instead of 'while' around a wait.
- Holding a lock while doing slow I/O.
- Taking two locks in different orders in different places.
- Assuming Python's GIL makes code thread-safe.