Threads, Processes, asyncio and the GIL
60 min · Advanced
Need help? Ask a tutor on WhatsApp (opens in a new tab)Concurrency questions test judgement more than syntax: which tool fits the job, and why. This lesson gives a small working program for threads, a queue, processes and asyncio, then explains the GIL in plain words, including the optional free-threaded builds.
Threads and the need for a Lock
Thread is a separate path of execution inside one process. Threads share the same memory, so they can read and change the same objects.
Threads suit programs that wait a lot, for example for a network. Because they share memory, two threads can update one variable at the same time and lose an update.
A Lock lets one thread at a time into a section.
Example 1 · Four threads and a Lock
import threading
counter = 0lock = threading.Lock()
def add_many(): global counter for _ in range(10_000): with lock: counter += 1
threads = [threading.Thread(target=add_many) for _ in range(4)]for t in threads: t.start()for t in threads: t.join()print(counter)40000
What this code does
- Four threads each add 1 to the shared
counter10000 times. Thewith lockblock lets only one thread update at a time. join()makes the main thread wait for all of them, so the final total is always 40000.
Each thread adds 10000, so the total must be 40000. counter += 1 is read, add, write: three steps, and a thread switch can happen between them. The lock makes the update safe. Always start the threads, then join() them, so the program waits for the result.
Queues and thread pools
Thread pool is a fixed group of worker threads that take tasks from a queue. concurrent.futures.ThreadPoolExecutor manages one for you.
Sharing a list between threads needs locks. A queue.Queue is already thread-safe, so workers can take jobs from it and the main thread can wait for them.
For a batch of jobs, ThreadPoolExecutor is shorter.
Example 2 · A thread pool keeps the order of results
import timefrom concurrent.futures import ThreadPoolExecutor
def fetch(n): time.sleep(0.05 * (3 - n)) return f"page-{n}"
with ThreadPoolExecutor(max_workers=3) as pool: print(list(pool.map(fetch, [1, 2, 3])))['page-1', 'page-2', 'page-3']
What this code does
- Page 3 sleeps the shortest and finishes first, but
mapreturns results in the order of the inputs. - The three waits overlap, so the whole batch takes about as long as the slowest one.
Example 3 · A worker thread reading from a Queue
import queueimport threading
jobs = queue.Queue()results = []
def worker(): while True: item = jobs.get() if item is None: break results.append(item * 2)
t = threading.Thread(target=worker)t.start()for n in [1, 2, 3]: jobs.put(n)jobs.put(None)t.join()print(results)[2, 4, 6]
What this code does
- The main thread puts three jobs and then
None. The worker doubles each job until it seesNoneand stops. Queuedoes its own locking, so there is no shared-list race, and one worker keeps the output order fixed.
pool.map returns results in the order of the input, even though page 3 finished first. The queue example uses None as a stop signal. Prefer a queue or an executor over sharing plain lists and dictionaries by hand.
Processes for CPU-bound work
Process is a running program with its own memory. Separate processes can use separate CPU cores at the same time, and they talk through pipes, queues or return values.
Pure Python calculations in threads take turns in the standard build, because of the GIL. For CPU-heavy work, use separate processes.
The price is that arguments and results are copied between processes, and starting one costs time.
Example 4 · A process pool for CPU work
from concurrent.futures import ProcessPoolExecutor
def square_sum(n): return sum(i * i for i in range(n))
if __name__ == "__main__": with ProcessPoolExecutor(max_workers=2) as pool: print(list(pool.map(square_sum, [10, 100, 1000])))[285, 328350, 332833500]
What this code does
- Each call runs in a worker process with its own interpreter and its own GIL, so heavy tasks can use different cores.
- The guard keeps child processes from running the pool code again when they import the file.
The if __name__ == "__main__": guard is required, because on Windows and macOS each worker starts by importing your file. Arguments and return values must be picklable. Processes pay off when each task is big enough to cover the startup and copying cost.
asyncio for many waiting tasks
asyncio is Python's library for running many tasks in one thread. A task runs until it reaches await, then hands control back to the event loop so another task can run.
asyncio handles thousands of network waits with one thread and no locks between awaits. Tasks must be polite: they await instead of blocking.
A blocking call such as time.sleep freezes every task.
Example 5 · asyncio.gather runs coroutines together
import asyncio
async def fetch(name, delay): await asyncio.sleep(delay) print("done", name) return name.upper()
async def main(): results = await asyncio.gather(fetch("a", 0.2), fetch("b", 0.1)) print(results)
asyncio.run(main())done b done a ['A', 'B']
What this code does
- Both coroutines start together and wait on
asyncio.sleep. The shorter wait ends first, sodone bprints beforedone a. gatherreturns the results in the order the coroutines were given:['A', 'B'].
Example 6 · A blocking call freezes the event loop
import asyncioimport time
async def ticker(log): for i in range(3): log.append(f"tick {i}") await asyncio.sleep(0)
async def blocker(log): log.append("block start") time.sleep(0.05) log.append("block end")
async def main(): log = [] await asyncio.gather(blocker(log), ticker(log)) print(log)
asyncio.run(main())['block start', 'block end', 'tick 0', 'tick 1', 'tick 2']
What this code does
blockernever awaits, so once it starts, nothing else can run untiltime.sleepreturns.- The log shows
block startandblock endtogether, and only then the ticks. Withawait asyncio.sleep(0.05)the ticks would run in between.
gather starts both coroutines, so b (0.1 s) finishes before a (0.2 s), yet the results keep the order of the arguments. In the second example, time.sleep blocked the loop, so the ticker did not run until the blocker ended. Use await asyncio.sleep and async libraries, or run blocking code in a thread with asyncio.to_thread.
The GIL and free-threaded builds
GIL is the Global Interpreter Lock in the standard CPython build. It lets only one thread run Python bytecode at a time, which keeps the interpreter simple and its reference counts safe.
The GIL is released while a thread waits for I/O or runs many C libraries, so threads still help with network and file waits. They do not speed up pure Python loops.
import sys
# Python 3.13+ has this function; older versions do notcheck = getattr(sys, "_is_gil_enabled", None)print(check() if check else True)It does not make your own code thread-safe.
Rules of thumb: waiting on I/O means threads or asyncio, heavy Python computation means processes or a native library such as NumPy, and shared data needs a lock either way. Newer CPython versions can be built without the GIL (a free-threaded build). It is optional and not the default, and extension libraries need to support it, so the standard build with the GIL is still what most people run.
Next step
Check your understanding
12 quick questions