Make a counter safe to update from many threads
Problem statement
A log-processing tool splits work across threads, and each thread counts HTTP status codes into one shared counter. The totals come out slightly different on every run, and always too low. Write SafeCounter with:
incr(key, n=1): addnto the count forkey; safe to call from any number of threads at once;snapshot(): return a plaindictcopy of the current counts.
Test it with 8 threads, each processing the same 20,000 fake access-log lines, where each block of 20 lines holds 15 200s, 3 404s and 2 500s. The generator is:
LINES = [f"GET /api/{i % 7} {code}" for i, code in enumerate(["200"] * 15 + ["404"] * 3 + ["500"] * 2)] * 1000Print each status with its count (sorted), and the total next to the expected total. Every run must print the same numbers.
Examples
Example 1
Input: python solution.py
Output: 200: 120000
404: 24000
500: 16000
total: 160000 (expected 160000)
Explanation: 8 threads x 1,000 blocks x 15 = 120,000 200s, and so on. With the lock no increment is lost.
Example 2
Input: The unsafe counter (brute approach): 8 threads x 2,000 increments
Output: expected 16000, lost updates: True
Explanation: The exact final value changes from run to run; it is always below 16,000.
Hints
Approach
Wrap the read-modify-write in a lock so only one thread at a time can be between "read" and "store":
SafeCounterowns aCounterand athreading.Lock, both private. Callers can only change the counts throughincr, so there is no way to forget the lock.incrdoeswith self._lock: self._counts[key] += n.withreleases the lock even if the body raises.snapshotalso takes the lock and returnsdict(self._counts), a copy. The caller can iterate and print it while other threads keep counting.- The main thread
joins every worker before reading the final snapshot, so all increments are finished.
The lock is held only for one dictionary update, so contention is small. If it ever became a bottleneck, each thread could count into its own local Counter and the main thread could add them together after join; that needs no lock at all during the work.
k is the number of distinct keys.
O(total increments)Space O(k)import threadingfrom collections import Counter class SafeCounter: # Per-key counter that many threads can update at once. def __init__(self): self._counts = Counter() self._lock = threading.Lock() def incr(self, key, n=1): with self._lock: # read-modify-write happens as one step self._counts[key] += n def snapshot(self): with self._lock: return dict(self._counts) # a copy, safe to iterate after unlocking def worker(counter, lines): for line in lines: status = line.split()[-1] counter.incr(status) # 8 threads each process the same 20,000 fake access-log lines.LINES = [f"GET /api/{i % 7} {code}" for i, code in enumerate(["200"] * 15 + ["404"] * 3 + ["500"] * 2)] * 1000counter = SafeCounter()threads = [threading.Thread(target=worker, args=(counter, LINES)) for _ in range(8)]for t in threads: t.start()for t in threads: t.join() counts = counter.snapshot()for status in sorted(counts): print(f"{status}: {counts[status]}")print(f"total: {sum(counts.values())} (expected {8 * len(LINES)})")Follow-up questions
- Rewrite it so each thread counts locally and results are merged after
join. Compare the speed. - Add
top(n)that returns the n most common keys, consistent with a single point in time. - Use
multiprocessinginstead of threads. Why does the lock no longer help, and what replaces it?
Frequently asked questions
Agents, exporters and log processors are often multi-threaded and keep shared counters or metrics. A lost-update bug gives numbers that are wrong by a small, varying amount, which is hard to spot and makes dashboards untrustworthy. It is the standard warm-up question for concurrency in platform and SRE interviews.
No. The GIL guarantees that one bytecode instruction runs at a time, but x += 1 is several instructions, and a thread switch can happen between them. The GIL also does not exist in free-threaded Python builds. Use a lock whenever threads share mutable state.
Sending results through a queue.Queue to a single consumer thread that does the counting avoids shared mutation entirely, and is a good design for larger pipelines. Relying on the atomicity of specific built-ins like next(itertools.count()) works on CPython but is an implementation detail; a lock states the intent clearly.