Python GIL: Threads vs Processes vs asyncio, Measured

Threads, processes and asyncio timed on a Mac: CPU and I/O work with 1-8 workers, a counter race fixed with a Lock, and a blocked event loop measured.

Series: Python lecture 5/6 (advanced). For developers who already write functions, generators and decorators comfortably and now need to make a program do several things at once. After this lecture you will be able to pick threads, processes or asyncio from measurements instead of folklore.

If you have ever added threading to a slow Python script and watched it get no faster, this lecture explains why, with numbers. It is also for the person whose asyncio service "randomly" freezes for half a second, and for anyone whose multiprocessing script works on Linux but crashes on a Mac. Short answer: threads help when you wait, processes help when you compute, asyncio helps when you wait on thousands of things at once, and one blocking call can ruin asyncio.

Everything below was run on an Apple Silicon Mac (M1 Pro, 8 cores: 6 performance + 2 efficiency). Timings are on Python 3.9. A few features that need 3.11 (asyncio.TaskGroup, except*) were run on Python 3.11, but that build runs under Rosetta 2 translation, so we did not use it for any timing. Other jobs were running on the machine, so every timing is a median of 5 runs.

The GIL in one paragraph

CPython has a Global Interpreter Lock: only one thread at a time executes Python bytecode. A thread that waits on I/O (a socket, a file, time.sleep) releases the lock, so other threads can run while it waits. A thread that runs a pure-Python loop holds it, and the interpreter only asks it to hand the lock over every few milliseconds:

>>> import sys; sys.getswitchinterval()
0.005

That 5 ms is the "switch interval". Threads take turns; they do not run Python code in parallel. Processes each have their own interpreter and their own GIL, so they do run in parallel, at the cost of starting processes and copying (pickling) arguments and results between them.

Decision diagram. Start: what is slow? If the program is computing in pure Python, use a process pool; if the work is under about 0.1 seconds in total, stay sequential. If the program is waiting on I/O, ask how many waits run at once and whether the libraries are async. Tens to hundreds with blocking libraries: a thread pool with max_workers set. Thousands with async libraries: asyncio, moving any remaining blocking call to asyncio.to_thread.
Choosing between threads, processes and asyncio. Start from what the program is waiting for, not from which API looks modern.

CPU-bound work: threads vs processes, 1/2/4/8 workers

Eight equal jobs, each a pure-Python loop of 3 million iterations. The same jobs run sequentially, in a ThreadPoolExecutor and in a ProcessPoolExecutor. The pool is created inside the timed block, so process start-up is included in the numbers.

# cpu_bound.py
import time, sys, multiprocessing as mp
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(n):
    total = 0
    for i in range(n):
        total += i * i % 7
    return total

JOBS = [3_000_000] * 8   # 8 equal jobs

def timed(label, fn):
    t = time.perf_counter()
    res = fn()
    print(f"{label:<22}{time.perf_counter() - t:6.2f} s")
    return res

if __name__ == "__main__":
    print(sys.version.split()[0], "start method:", mp.get_start_method())
    expected = timed("sequential", lambda: [work(n) for n in JOBS])
    for w in (1, 2, 4, 8):
        with ThreadPoolExecutor(max_workers=w) as ex:
            assert timed(f"threads  x{w}", lambda: list(ex.map(work, JOBS))) == expected
    for w in (1, 2, 4, 8):
        with ProcessPoolExecutor(max_workers=w) as ex:
            assert timed(f"processes x{w}", lambda: list(ex.map(work, JOBS))) == expected

First line of output: 3.9.6 start method: spawn. Medians of 5 runs:

Mode 1 worker 2 workers 4 workers 8 workers
Sequential 1.70 s — — —
Threads 1.73 s 1.81 s 1.80 s 1.85 s
Processes 1.77 s 0.92 s 0.52 s 0.47 s
Bar chart of wall time in seconds. Sequential 1.70. Threads with 1, 2, 4 and 8 workers: 1.73, 1.81, 1.80, 1.85. Processes with 1, 2, 4 and 8 workers: 1.77, 0.92, 0.52, 0.47.
Eight equal pure-Python jobs (3 million loop iterations each). Median of 5 runs, Python 3.9, Apple M1 Pro (6P + 2E cores), pool start-up included. Threads never beat sequential; processes did.

Three things a summary would not tell you:

  1. Threads were never faster than sequential. More threads made it slightly slower, because the threads spend time handing the GIL back and forth. Individual thread runs were also much noisier (one 4-thread run took 2.84 s).
  2. Processes scaled almost linearly to 4 workers, then flattened. 4 → 8 workers only went from 0.52 s to 0.47 s. This Mac has 6 performance cores and 2 slower efficiency cores, and with 8 jobs the last ones wait on the slowest core.
  3. A process pool has a fixed cost. Starting 8 spawn workers and sending them a no-op took a median of 0.099 s. For a job that takes 50 ms in total, a process pool makes things slower.

The macOS trap: spawn and if __name__ == "__main__"

On Linux, Python (up to 3.13) starts workers with fork: the child is a copy of the parent's memory. On macOS the default has been spawn since Python 3.8: the child is a fresh interpreter that imports your main module again to find work. Anything at module top level runs again in every child.

Here is the mistake, a script without the guard:

# noguard.py — common mistake
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

with ProcessPoolExecutor(max_workers=2) as ex:
    print(list(ex.map(square, range(5))))

What actually happens on Python 3.9, macOS (125 lines of output, trimmed):

RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...
Traceback (most recent call last):
  File "~/demo/noguard.py", line 8, in <module>
    print(list(ex.map(square, range(5))))
  ...
concurrent.futures.process.BrokenProcessPool: A process in the process pool was terminated abruptly while the future was running or pending.

Each child re-ran the with ProcessPoolExecutor(...) line during import, tried to start its own pool, and died; the parent then saw a broken pool. Fix: put everything that starts processes under if __name__ == "__main__":, and keep worker functions at module top level so the child can import them (a lambda or a nested function cannot be pickled). The same script that works on a Linux server can crash on a developer's Mac, which is why this one matters.

Python 3.14 changes the Linux default from fork to forkserver; we did not run 3.14, but the guard makes your code correct under every start method.

I/O-bound work: threads vs asyncio at 100 and 1,000 tasks

Here the work is waiting. We simulate a network call with a 100 ms sleep (no external network involved), once with time.sleep in threads and once with asyncio.sleep in coroutines.

# io_bound.py
import asyncio, time
from concurrent.futures import ThreadPoolExecutor

DELAY = 0.1  # each "request" waits 100 ms

def fetch_blocking(i):
    time.sleep(DELAY)
    return i

async def fetch_async(i):
    await asyncio.sleep(DELAY)
    return i

def with_threads(n, workers):
    with ThreadPoolExecutor(max_workers=workers) as ex:
        return list(ex.map(fetch_blocking, range(n)))

async def with_asyncio(n):
    return await asyncio.gather(*(fetch_async(i) for i in range(n)))

def timed(label, fn):
    t = time.perf_counter()
    fn()
    print(f"{label:<34}{time.perf_counter() - t:7.3f} s")

if __name__ == "__main__":
    for n in (100, 1000):
        print(f"--- {n} tasks ---")
        timed("threads, default max_workers", lambda: with_threads(n, None))
        timed(f"threads, max_workers={n}", lambda: with_threads(n, n))
        timed("asyncio.gather", lambda: asyncio.run(with_asyncio(n)))
100 ms tasks Sequential Threads, default pool Threads, one per task asyncio
100 10.355 s 0.932 s 0.113 s 0.103 s
1,000 (≈100 s, not run) 8.684 s 0.210 s 0.110 s
10,000 — — 0.703 s, peak RSS 90 MB 0.196 s, peak RSS 39 MB
Bar chart of wall time for 1,000 tasks that each wait 100 milliseconds. Thread pool with the default 12 workers: 8.684 seconds. Thread pool with 1,000 workers: 0.210 seconds. asyncio.gather: 0.110 seconds.
1,000 simulated requests of 100 ms each, Python 3.9. The default ThreadPoolExecutor has only min(32, cpu_count + 4) = 12 workers on this Mac, so it ran in about 84 rounds.

The surprise is the second column. ThreadPoolExecutor() without max_workers uses min(32, os.cpu_count() + 4), which is 12 on this Mac. 1,000 sleeps in batches of 12 is about 84 rounds of 100 ms, hence 8.7 s. The default is sized for mixed work, not for a thousand open sockets. With one thread per task, threads were close to asyncio at 100 tasks, still fine at 1,000, and at 10,000 they used more than twice the memory (peak RSS from resource.getrusage, each mode in a fresh process) and took 3.6× longer; the extra time goes into creating and scheduling 10,000 OS threads.

Conclusion from the numbers: for a few hundred concurrent waits, threads with a sized pool are perfectly good and work with blocking libraries. Asyncio wins when the count reaches thousands, and only if every library in the path is async.

Race condition: a counter that loses updates

The GIL does not make your code thread-safe. counter += 1 is several bytecode steps (on 3.9: LOAD_GLOBAL, LOAD_CONST, INPLACE_ADD, STORE_GLOBAL), and a switch between the load and the store loses an update.

# race.py
import sys, threading

N, THREADS = 1_000_000, 4
counter = 0
lock = threading.Lock()

def unsafe():
    global counter
    for _ in range(N):
        counter += 1          # read, add, write: three steps

def safe():
    global counter
    for _ in range(N):
        with lock:
            counter += 1

def run(target):
    global counter
    counter = 0
    ts = [threading.Thread(target=target) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    return counter

if __name__ == "__main__":
    print(sys.version.split()[0], "expected", N * THREADS)
    for i in range(3):
        print("unsafe:", run(unsafe))
    print("locked:", run(safe))
3.9.6 expected 4000000
unsafe: 1492419
unsafe: 2606045
unsafe: 2137778
locked: 4000000

More than half the increments vanished, and the result was different on every run. Now the part that catches experienced developers: the same file on Python 3.11 printed 4000000 three times. Newer interpreters check for thread switches at fewer points, so this particular two-line loop happened not to be interrupted mid-update. It is still not safe. Put a function call between the read and the write and 3.11 loses updates too:

def plus_one(x):
    return x + 1
...
        counter = plus_one(counter)   # a function call between read and write
3.9.6 1919199 of 4000000
3.11.4 2616204 of 4000000

So "it worked on my machine" with 3.11 proves nothing. The fix is the Lock. It has a price we measured on 3.9: 4 threads × 1M increments took 0.23–0.26 s unsafe and 1.00–1.14 s locked. In real code, lock a bigger unit of work, or give each thread its own counter and add them up at the end.

The asyncio pitfall: a blocking call inside a coroutine

Asyncio runs every coroutine on one thread. When a coroutine calls something that blocks, nothing else runs until it returns. To measure the stall we ran a heartbeat task that wants to wake every 10 ms and recorded the longest gap while a handler "waits" 0.5 s three different ways:

# stall.py
import asyncio, time

async def heartbeat(stop, gaps):
    last = time.perf_counter()
    while not stop.is_set():
        await asyncio.sleep(0.01)            # wants to wake every 10 ms
        now = time.perf_counter()
        gaps.append(now - last)
        last = now

async def handler(mode):
    if mode == "time.sleep":
        time.sleep(0.5)                      # blocks the whole event loop
    elif mode == "asyncio.sleep":
        await asyncio.sleep(0.5)
    elif mode == "to_thread":
        await asyncio.to_thread(time.sleep, 0.5)   # 3.9+

async def main(mode):
    stop, gaps = asyncio.Event(), []
    hb = asyncio.create_task(heartbeat(stop, gaps))
    await asyncio.sleep(0.05)
    t = time.perf_counter()
    await handler(mode)
    took = time.perf_counter() - t
    stop.set(); await hb
    print(f"{mode:<14} handler {took:.3f} s | heartbeat max gap {max(gaps)*1000:6.1f} ms, ticks {len(gaps)}")

if __name__ == "__main__":
    for mode in ("time.sleep", "asyncio.sleep", "to_thread"):
        asyncio.run(main(mode))
time.sleep     handler 0.505 s | heartbeat max gap  510.1 ms, ticks 5
asyncio.sleep  handler 0.501 s | heartbeat max gap   11.4 ms, ticks 51
to_thread      handler 0.508 s | heartbeat max gap   30.7 ms, ticks 46

The handler took 0.5 s in all three cases, so a test that only times the handler sees nothing wrong. The heartbeat tells the truth: with time.sleep every other task on the loop froze for 510 ms. In a web server that means every other client's request stalls. requests.get, a synchronous database driver, open().read() on a slow disk and a CPU-heavy loop all behave like time.sleep here.

To find these in a real project, turn on debug mode. With asyncio.run(handler(), debug=True) and a 0.3 s time.sleep inside, Python 3.9 logged:

Executing <Task finished name='Task-1' coro=<handler() done, defined at ~/demo/debugwarn.py:2> result=None created at .../asyncio/base_events.py:621> took 0.306 seconds

Any callback slower than loop.slow_callback_duration (0.1 s by default) is reported. A related mistake is calling a coroutine without await: nothing runs, and you only get RuntimeWarning: coroutine 'f' was never awaited.

asyncio.TaskGroup (3.11+): failing together

asyncio.gather has an awkward default: when one task fails, the exception reaches you, but the other tasks keep running in the background. On Python 3.9:

  a done
caught: b: bad response after 0.20 s
  c done
main exits

Task c finished after you had already handled the error. asyncio.TaskGroup, added in 3.11, cancels the siblings when one fails and raises an ExceptionGroup, which you catch with the new except*:

# taskgroup.py — needs Python 3.11+
import asyncio, sys, time

async def fetch(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ValueError(f"{name}: bad response")
        print(f"  {name} done")
        return name
    except asyncio.CancelledError:
        print(f"  {name} cancelled")
        raise

async def main():
    t = time.perf_counter()
    try:
        async with asyncio.TaskGroup() as tg:
            a = tg.create_task(fetch("a", 0.1))
            b = tg.create_task(fetch("b", 0.2, fail=True))
            c = tg.create_task(fetch("c", 1.0))
    except* ValueError as eg:
        print("caught:", [str(e) for e in eg.exceptions])
    print(f"elapsed {time.perf_counter() - t:.2f} s, a={a.result()!r}, c.cancelled()={c.cancelled()}")

print(sys.version.split()[0])
asyncio.run(main())
3.11.4
  a done
  c cancelled
caught: ['b: bad response']
elapsed 0.20 s, a='a', c.cancelled()=True

On Python 3.9 the same file does not even compile, because except* is new syntax:

  File "~/demo/taskgroup.py", line 22
    except* ValueError as eg:
          ^
SyntaxError: invalid syntax

and without the except* block you would get AttributeError: module 'asyncio' has no attribute 'TaskGroup'. If your code must run on 3.9 or 3.10, keep gather and cancel the leftovers yourself.

Decision table

Your situation Use Why (from the measurements above)
Pure-Python CPU work, seconds or more ProcessPoolExecutor 1.70 s → 0.52 s with 4 workers; threads did nothing
CPU work under 0.1 s in total Plain sequential code Starting 8 spawn workers alone cost 0.099 s
CPU work inside NumPy, hashlib, zlib and other C code that releases the GIL Threads can help The GIL is released inside those calls (check each library)
Tens to hundreds of blocking I/O calls (requests, DB driver) ThreadPoolExecutor(max_workers=N) 100 tasks: 0.113 s, close to asyncio; set max_workers, the default is 12 here
Thousands of concurrent connections, async libraries everywhere asyncio 10,000 tasks: 0.196 s and 39 MB vs 0.703 s and 90 MB with threads
One blocking call inside async code asyncio.to_thread (3.9+) Loop gap 30.7 ms instead of 510 ms
Shared mutable state across threads Lock, or per-thread results merged at the end Lost 35–63% of updates without it
Several tasks that should fail together TaskGroup on 3.11+, gather + manual cancel before Siblings cancelled at 0.20 s

Honest limits / version notes

  • Timed on Python 3.9.6 only. Python 3.11.4 was used to run TaskGroup/except* and the race demo, but it is an x86_64 build under Rosetta 2, so its speed says nothing about native 3.11.
  • Free-threaded Python (3.13+), described, not run. PEP 703 added an optional build without the GIL (python3.13t); in 3.13 it was experimental, and sys._is_gil_enabled() tells you at runtime which mode you are in. On that build the CPU-bound thread table above would look very different, and the race in race.py would matter even more, because there is no switch interval hiding it. We had no such build here and did not install one.
  • asyncio.to_thread is 3.9+. TaskGroup and except* are 3.11+ (checked: 3.9 gives the SyntaxError and AttributeError shown).
  • The Linux forkserver default arrives in Python 3.14; we did not run 3.14.
  • Machine: Apple M1 Pro, macOS, with other jobs running; medians of 5 runs. Your absolute numbers will differ; the ratios are the point.

Previous: Lecture 4: Decorators and context managers · Next: Lecture 6: Profiling and optimization

Related: Generators and memory (lecture 3) · Java virtual threads, measured · VB.NET Async/Await

FAQ

Do Python threads make CPU-bound code faster?

Not on a regular (GIL) build. Eight equal pure-Python jobs took 1.70 s sequentially and 1.73-1.85 s with 1, 2, 4 or 8 threads on Python 3.9 (Apple M1 Pro). The same jobs with a process pool took 0.92 s with 2 workers and 0.47 s with 8.

Why does my multiprocessing script crash with 'An attempt has been made to start a new process before the current process has finished its bootstrapping phase'?

macOS (and Windows) start worker processes with the spawn method, which re-imports your main module in every child. Without an if __name__ == '__main__': guard, each child tries to create its own pool, and Python raises this RuntimeError followed by BrokenProcessPool. Put the pool creation under the guard.

Why does one slow request freeze my whole asyncio server?

A blocking call such as time.sleep or requests.get inside a coroutine stops the event loop. In our test a 0.5 s time.sleep produced a 510 ms gap in a task that wanted to run every 10 ms. Use the awaitable version or move the call to a thread with asyncio.to_thread.

  • #Python
  • #GIL
  • #threading
  • #multiprocessing
  • #asyncio
  • #concurrency
  • #advanced

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑