Module 1 · Foundations that hold everything up

Lesson 03 — Concurrency and asynchrony

Threads, processes, event loops, race conditions and deadlocks — with the TicketFlow purchase queue.

Published
In this lesson
  1. Exercise 1 — The counter race
  2. Exercise 2 — GIL: CPU vs I/O
  3. Exercise 3 — The check-then-act window
  4. Exercise 4 — I1 with real requests
  5. Exercise 5 — Deadlock in miniature
  6. Exercise 6 — Async or sync
  7. Exercise 7 — Concurrency map
  8. Professor's summary

After trying it, here is the full map. Whatever you didn't suffer through, read it twice.


Exercise 1 — The counter race

  1. Typical results: 100.137, 152.403, 178.922, 199.211... never (or almost never) exactly 200,000. They vary because the scheduler interrupts the threads at different points on each run: the race is non-deterministic, and that is exactly its wickedness (you cannot reproduce it with a breakpoint).
  2. With threading.Lock() around counter += 1, the result is always 200,000: the critical section (read-add-write) becomes atomic with respect to other threads.
  3. With the lock, the loop slows down noticeably (acquisition per iteration + two threads fighting over the lock). Fine note: if you group the lock in batches (1000 increments per acquisition), the cost drops — that is the lock granularity idea that reappears in transactions.

Exercise 2 — GIL: CPU vs I/O

  1. CPU: two threads take roughly the same time (or more, from context-switch overhead) as one thread doing double the work. It did not scale: the GIL serializes bytecode.
  2. I/O (sleep): two threads take ~1 s total, not 2. It did scale: sleep releases the GIL (just like a socket does while waiting for the DB).
  3. "The GIL prevents CPU parallelism between threads, not I/O concurrency; for CPU, use processes".

Exercise 3 — The check-then-act window

  1. With 20 threads and a 1 ms sleep, several reservations get created (typically 10-20: almost all threads slip in at once). With 0.01 there are more; with 0.0001, fewer but still >1. The moral: the odds of disaster grow with the window's duration and the thread count — exactly what happens under real load.
  2. (a) The in-memory lock fixes the experiment... but only within the process: with 4 Gunicorn workers there are 4 "memories" and the lock cannot see the others. (b) The single atomic operation in a DB is a unique constraint (the one you already have on ReservationItem) enforced inside a locking transaction (Lesson 10: select_for_update, or the INSERT failing with IntegrityError). Process memory doesn't scale; shared state is defended where it lives: the database.

Exercise 4 — I1 with real requests

  1. Expected: one request 201 and the other 409 (or a 500 if the view doesn't catch the exception). The 00b constraint made corruption impossible; the question is whether your API answers with the right semantics. An uncaught IntegrityError becomes a 500 "server bug" when in fact the server worked perfectly: it protected the business.
  2. The diff should include something like:
python
from django.db import IntegrityError
from rest_framework import status

@api_view(["POST"])
def reserve(request):
    ...
    try:
        item = ReservationItem.objects.create(...)
    except IntegrityError:
        return Response(
            {"code": "seat_already_reserved", "detail": "The seat was just reserved"},
            status=status.HTTP_409_CONFLICT,
        )
    return Response(..., status=status.HTTP_201_CREATED)

Senior detail: the message doesn't say "error": it says what happened in the domain. And Lesson 01's 409, Exercise 2, materializes here — theory and model embrace.

Exercise 5 — Deadlock in miniature

  1. With op2() in reverse order and the sleep window, the program hangs: op1 holds A and waits for B; op2 holds B and waits for A. Circular wait: Coffman condition 4 fulfilled. (Without the sleep, you might not see it in 100 runs: deadlocks are intermittent by nature — another gift for production.)
  2. With the global A → B order in every function, the circular wait disappears and the program always finishes: the only cheaply breakable condition of Coffman's four is the circular one. In a real DB: update rows always ordered (for example ORDER BY id or ascending IDs when touching a reservation's seats).

Exercise 6 — Async or sync

  1. Sync. The query is I/O but short and not parallelizable within the request; async adds nothing here and complicates the stack (drivers, testing).
  2. Sync. Verifying the signature is cheap CPU and enqueueing is a fast operation (RPUSH on Redis). The heavy work already went to Celery: the view must be fast, and it is.
  3. Async. Three independent external calls with asyncio.gather() drop latency from ~the sum to ~the slowest. This is the async use case: parallelizable external I/O.
  4. Neither sync nor async: not like this. A 2 GB CSV in memory kills the worker and blocks (async included: pure CPU). Senior answer: 202 Accepted + Celery job + download when ready (you designed it in Lesson 01, Exercise 7).

Exercise 7 — Concurrency map

Defensible configuration for 2 vCPU / 4 GB:

Gunicorn --workers 2 --threads 4      # 2 processes (CPU parallelism) × 4 threads (I/O concurrency)
Celery --concurrency 2 ("payments" queue) # serializes gateway/webhooks, isolated from the web
Redis                                 # cache + broker (one more piece to watch)
PostgreSQL (same region)              # invariant I1 lives here, not in Python

Justification: workers should not exceed vCPU for CPU-bound work, and threads cover I/O waits (the real metric is memory per worker and p95 latency under load, not a magic number). The separate queue keeps an email spike from delaying payments. If your proposal differs with arguments (e.g. --workers 4 with 2 threads for high I/O), it is also correct: what gets graded is the why.


Professor's summary

  • Concurrency ≠ parallelism; and the GIL is not the monster of the story: it only bites CPU.
  • Every check-then-act window is a potential race; the defense lives where the state lives: the DB.
  • Global acquisition order kills deadlocks before they are born.
  • Async when there is parallelizable external I/O; sync when not; queues when things must be serialized.

When you submit, we close Lesson 03 and move to Lesson 04 — Data structures and algorithms: TicketFlow's seat cart as an excuse to talk about complexity you can feel in production.