After trying it, here is the full map. Whatever you didn't suffer through, read it twice.
Exercise 1 — The counter race
- 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).
- With
threading.Lock()aroundcounter += 1, the result is always 200,000: the critical section (read-add-write) becomes atomic with respect to other threads. - 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
- 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.
- I/O (sleep): two threads take ~1 s total, not 2. It did scale:
sleepreleases the GIL (just like a socket does while waiting for the DB). - "The GIL prevents CPU parallelism between threads, not I/O concurrency; for CPU, use processes".
Exercise 3 — The check-then-act window
- 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.
- (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 withIntegrityError). Process memory doesn't scale; shared state is defended where it lives: the database.
Exercise 4 — I1 with real requests
- 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
IntegrityErrorbecomes a 500 "server bug" when in fact the server worked perfectly: it protected the business. - The diff should include something like:
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
- With
op2()in reverse order and thesleepwindow, the program hangs:op1holds A and waits for B;op2holds 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.) - 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 idor ascending IDs when touching a reservation's seats).
Exercise 6 — Async or sync
- Sync. The query is I/O but short and not parallelizable within the request; async adds nothing here and complicates the stack (drivers, testing).
- 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.
- 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. - 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 PythonJustification: 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.