Threads

Part of the free LogicWiz Python course, Module 16: Concurrency. The examples and practice run in the app.

At 1 pm, 40 orders are ready and Canteen Counter texts each student "your order is ready". Each SMS waits half a second for the gateway, so the last student hears after 20 seconds. Meher says: why wait for one SMS before starting the next?

You'll learn to

  • Tell I/O-bound work (waiting) from CPU-bound work (calculating)
  • Start and join threads with `threading.Thread`
  • Explain a race condition on a shared counter and fix it with a `Lock`
  • Pass work between threads safely with `queue.Queue`, and avoid deadlock

Learn

Waiting vs working

Some programs are slow because they wait: for a website, a database, a file, an SMS gateway. That is I/O-bound work. Others are slow because they calculate: counting primes, resizing photos. That is CPU-bound work.

A thread is a second line of execution inside the same program, sharing the same variables. While one thread waits for the SMS gateway, another can send the next SMS. Threads are great for waiting.

Note: the browser can't start real threads, so the thread demos here show the output from a real laptop run (Python 3.12). Copy them into a .py file and run them yourself. The Lock and Queue cards run right here.

Start, then join

import threading
import time


def send_sms(name):
    time.sleep(0.5)            # waiting for the SMS gateway: I/O, not work
    print("  sent to", name)


students = ["Riya", "Kabir", "Sneha", "Aarav"]

start = time.perf_counter()
for s in students:
    send_sms(s)
print(f"One by one:   {time.perf_counter() - start:.1f} s")

start = time.perf_counter()
threads = [threading.Thread(target=send_sms, args=(s,)) for s in students]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"With threads: {time.perf_counter() - start:.1f} s")

target is the function (no brackets), args is a tuple of its arguments. start() begins the thread; join() waits for it to finish. Four waits overlapped, so 2 s became 0.5 s. Notice the threaded order changed: threads finish in whatever order they finish.

A race condition: lost tokens

import threading
import time

tokens_sold = 0


def sell(n):
    global tokens_sold
    for _ in range(n):
        current = tokens_sold        # 1. read
        time.sleep(0)                # another thread may run right here
        tokens_sold = current + 1    # 2. write


threads = [threading.Thread(target=sell, args=(1000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("Expected 4000, got", tokens_sold)

Four counters sold 1000 tokens each, yet the total is about 1000. Thread A reads 41, thread B reads 41, both write 42: one sale is lost. The number changes on every run (we saw 1000, 1007, 1048, 1094); that unpredictability is exactly what makes race bugs so nasty. Even plain tokens_sold += 1 is a read and a write, and can race.

The fix: a Lock

import threading
import time

tokens_sold = 0
lock = threading.Lock()


def sell(n):
    global tokens_sold
    for _ in range(n):
        with lock:                       # only one thread inside at a time
            current = tokens_sold
            time.sleep(0)
            tokens_sold = current + 1


threads = [threading.Thread(target=sell, args=(1000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("Expected 4000, got", tokens_sold)

with lock: acquires the lock and always releases it, even if an error happens inside. The read-and-write now happens as one step. Keep the locked part as small as you can, or threads just queue up behind it.

Try it here: Lock and Queue in one thread

import queue
import threading

lock = threading.Lock()
print("locked?", lock.locked())
with lock:
    print("inside with:", lock.locked())
print("after with:", lock.locked())

orders = queue.Queue()            # first in, first out, safe between threads
for o in ["2 Samosa", "1 Chai", "1 Cold Coffee"]:
    orders.put(o)
print(orders.qsize(), "waiting")
print("next:", orders.get())
print("next:", orders.get())

queue.Queue does its own locking, so many threads can put and get at once without a race.

Producer and consumer with a Queue

import queue
import threading
import time

orders = queue.Queue()


def kitchen():
    while True:
        order = orders.get()          # waits until something arrives
        if order is None:             # the "closing time" signal
            break
        time.sleep(0.1)               # cooking
        print("  ready:", order)


cook = threading.Thread(target=kitchen)
cook.start()
for o in ["2 Samosa", "1 Chai", "1 Cold Coffee"]:
    print("placed:", o)
    orders.put(o)
orders.put(None)
cook.join()
print("Kitchen closed")

The counter thread (producer) puts orders; the kitchen thread (consumer) takes them. A special value like None, called a sentinel, tells the consumer to stop. This pattern needs no Lock at all.

Deadlock in one picture

Two threads, two locks, taken in opposite order:

Thread 1: holds A ----wants B----> (waits)
                ^                  |
                |                  v
Thread 2: (waits) <----wants A---- holds B

Each waits for the other forever. The program freezes, with no error. The fix: always take locks in the same order (A then B, everywhere), hold them briefly, or use one lock. You can see the "would wait" moment safely with lock.acquire(blocking=False), which returns False instead of waiting.

The GIL. In standard Python (CPython), only one thread runs Python code at a time; the Global Interpreter Lock is passed between them. While a thread waits on I/O it lets go, so waiting overlaps nicely. But for CPU-bound work, 4 threads on 4 cores are not faster than 1. For calculations, use processes (next lesson). (Python 3.13 has an experimental "free-threaded" build without the GIL, but the normal one still has it.)

In one line

Threads overlap waiting (I/O), not calculating (GIL); protect shared data with with lock:, pass work with queue.Queue, take locks in one fixed order.

Interview corner

What is the difference between a process and a thread?

A process is a running program with its own memory; a thread is a line of execution inside a process that shares that memory with the other threads. Threads are cheap to start and can share data directly (which is why they need locks), while processes are isolated and must send data to each other (pickling, pipes, queues).

What is a race condition, and how do you prevent one?

A race condition is when the result depends on the timing of threads, typically a read-modify-write like count += 1 where two threads read the same old value and one update is lost. Prevent it by making the critical section atomic with a threading.Lock (with lock:), or avoid shared state by passing messages through a queue.Queue.

What is the GIL? Does Python support true multithreading?

The Global Interpreter Lock in CPython lets only one thread execute Python bytecode at a time. Threads are real OS threads and run concurrently, and they release the GIL while waiting on I/O (and inside many C extensions like NumPy), so I/O-bound programs speed up. CPU-bound pure-Python code does not run in parallel; use multiprocessing for that. Python 3.13 adds an optional free-threaded build without the GIL.

What is a deadlock and how do you avoid it?

A deadlock is when threads wait on each other forever, for example thread 1 holds lock A and wants B while thread 2 holds B and wants A. Avoid it by always acquiring locks in one global order, holding locks briefly, using timeouts (acquire(timeout=...)), or replacing shared locks with a queue.