Concurrency Basics
Concurrency Basics
Concurrency is overlapping waits. Parallelism is overlapping work. The boring default is a sequential program until a wait is real and measured. Threads help when the desk is idle on I/O (a request, a disk, a sleep). Processes help when the desk is busy on CPU (a long loop in Python). Do not start there.
Mental model
CPython’s default build has a global interpreter lock (GIL): one thread at a time runs Python bytecode. Python 3.14 also ships a free-threading build that can run threads in parallel, but the interpreter uv gives you unless you ask otherwise is still the GIL build. So the boring rule does not change: threads overlap I/O; processes (or a free-threaded interpreter you chose on purpose) overlap CPU.
A sequential for over tickets is easier to test, easier to abort, and often fast enough. The next chapters add threads, asyncio, and processes. This one stays on one stack.
Worked examples
Case 1: One ticket at a time
Save as sequential_rail.py. The rail is a list. The function returns. No queue, no pool.
# sequential_rail.py
def fire(ticket):
return f"fired {ticket}"
def main():
tickets = ["T-11", "T-12", "T-13"]
for ticket in tickets:
print(fire(ticket))
if __name__ == "__main__":
main()Run:
uv run python sequential_rail.pyOutput:
fired T-11
fired T-12
fired T-13
This is the program you keep until fire actually waits on something outside the process.
Case 2: A wait you can see
Save as sequential_wait.py. time.sleep stands in for a slow printer. Three tickets, 0.05 seconds each, on one thread. The clock is the sum.
# sequential_wait.py
import time
def print_ticket(ticket):
time.sleep(0.05)
return f"printed {ticket}"
def main():
tickets = ["T-11", "T-12", "T-13"]
started = time.perf_counter()
for ticket in tickets:
print(print_ticket(ticket))
elapsed = time.perf_counter() - started
print(f"elapsed >= 0.15: {elapsed >= 0.15}")
if __name__ == "__main__":
main()Run:
uv run python sequential_wait.pyOutput:
printed T-11
printed T-12
printed T-13
elapsed >= 0.15: True
The next chapter overlaps those sleeps with threads. The number to remember is: sequential I/O adds. Overlapped I/O does not, up to the number of waiters.
Case 3: CPU work is different
Save as sequential_cpu.py. Counting pence on a long list is CPU. Threads will not magically divide this on the default interpreter. Measure it as itself, then decide.
# sequential_cpu.py
def total_pence(orders):
total = 0
for pence in orders:
total += pence
return total
def main():
tables = [
[1200, 800, 400],
[2000],
[500, 500, 500],
]
print(sum(total_pence(orders) for orders in tables))
if __name__ == "__main__":
main()Run:
uv run python sequential_cpu.pyOutput:
5900
A real hot loop might belong in ProcessPoolExecutor (later) or in a better algorithm. It does not belong in a thread pool “because we have cores” until you have a measurement.
The trap
Save as thread_for_style.py. Four threads, no I/O, no join discipline you can explain, and a result you still had to collect yourself. This is slower to read than Case 1 and no faster on CPU under the GIL.
# thread_for_style.py
from threading import Thread
def fire(ticket, bucket):
bucket.append(f"fired {ticket}")
def main():
tickets = ["T-11", "T-12", "T-13"]
bucket = []
workers = [
Thread(target=fire, args=(ticket, bucket)) for ticket in tickets
]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
for line in bucket:
print(line)
if __name__ == "__main__":
main()Run:
uv run python thread_for_style.pyOutput (order of lines may vary):
fired T-11
fired T-12
fired T-13
You paid for scheduling and a shared list to reprint Case 1. If the order matters, you now have a bug. Stay sequential until a wait or a profile says otherwise.
The boring rule
- Write the sequential version first. Keep it if it is fast enough.
- Threads overlap waiting. Processes overlap CPU on the default CPython build.
- The GIL is still the boring default in 3.14. Free-threading is an opt-in build, not a reason to share mutable state.
joinevery thread you start. A daemon thread that outlivesmainis how shutdown hangs.- Shared lists and counters are the next chapter’s trap, not a design.
Try this
- In
sequential_wait.py, dropsleepto0.0and seeelapsed >= 0.15becomeFalse. - Add a fourth ticket. The sequential wait should stay above
0.20seconds. - Change
sequential_cpu.pyto print each table total, then the grand total. - In
thread_for_style.py, printticketinsidefirebefore append. Run it twice. Note the order.