Practical infra coding

Make a counter safe to update from many threads

easyConcurrency basics

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): add n to the count for key; safe to call from any number of threads at once;
  • snapshot(): return a plain dict copy 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:

Python
LINES = [f"GET /api/{i % 7} {code}" for i, code in
enumerate(["200"] * 15 + ["404"] * 3 + ["500"] * 2)] * 1000

Print 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":

  1. SafeCounter owns a Counter and a threading.Lock, both private. Callers can only change the counts through incr, so there is no way to forget the lock.
  2. incr does with self._lock: self._counts[key] += n. with releases the lock even if the body raises.
  3. snapshot also takes the lock and returns dict(self._counts), a copy. The caller can iterate and print it while other threads keep counting.
  4. 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.

ComplexityTime O(total increments)Space O(k)
Python
import threading
from 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)] * 1000
counter = 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 multiprocessing instead 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.