Chapter 46, Advanced
Concurrency: GIL, threads, and processes
Choosing the wrong concurrency model is the most expensive performance mistake in Python. The decision depends on one question: is your bottleneck waiting or computing?
Code for this chapter: avancado/cap46_concorrencia.py
The GIL
In CPython, a global lock (the GIL) allows only one thread execute bytecode Python at a time. Two practical consequences:
- For work that waits (network, disk, database), threads work well because the GIL is released while the thread waits.
- For work that calculates (loops, calculations), threads do not speed anything up because only one runs at a time. Here you need processes.
There is an optional build of the interpreter without GIL (the executable python3.14t in 3.14). I treat it as something to test with your workload, and not as standard: the extension ecosystem is still adapting.
| The bottleneck is... | Use |
|---|---|
| I/O wait, a few dozen tasks | ThreadPoolExecutor |
| I/O wait, thousands of connections | asyncio (next chapter) |
| Heavy computation in pure Python | ProcessPoolExecutor |
| Numerical computation in arrays | C libraries, like NumPy |
Protect execution with __name__ == "__main__"
On Windows and macOS, child processes are created by importing the main file again. Without the protection
if __name__ == "__main__", each child would execute the entire program and create more children. Therefore, in this chapter, all code that does something is within this condition. The functions that the processes execute need to be at the module level, and the arguments and results need to be serializable (picklable).
Threads for I/O
The concurrent.futures provides a unique interface for threads and processes. Here, five "downloads" of 0.2 seconds each. In sequence, they take one second. In five threads, about 0.2:
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def baixar(identificador):
time.sleep(0.2)
return f"recurso {identificador}"
def contar_primos(limite):
total = 0
for n in range(2, limite):
for divisor in range(2, int(n ** 0.5) + 1):
if n % divisor == 0:
break
else:
total += 1
return total
if __name__ == "__main__":
inicio = time.perf_counter()
sequencial = [baixar(i) for i in range(5)]
t_seq = time.perf_counter() - inicio
inicio = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
concorrente = list(pool.map(baixar, range(5)))
t_conc = time.perf_counter() - inicio
print(sequencial == concorrente)
print("threads foram mais rápidas:", t_conc < t_seq)
True
threads foram mais rápidas: True
Processes for CPU
Counting primes is pure computation. Each process has its own interpreter and its own GIL, so they run on different cores for real. The cost is creating the processes and copying data between them, so it only pays off when each task is heavy:
if __name__ == "__main__":
limites = [20_000, 20_000, 20_000, 20_000]
with ProcessPoolExecutor() as pool:
resultados = list(pool.map(contar_primos, limites))
print(resultados)
[2262, 2262, 2262, 2262]
Shared state requires locking
Threads share memory. When two modify the same data, the result can be lost because contador += 1 is not an indivisible operation (reading, adding, and writing are separate steps). The Lock ensures that only one thread enters the critical region at a time:
import threading
contador = 0
trava = threading.Lock()
def incrementar(vezes):
global contador
for _ in range(vezes):
with trava:
contador += 1
if __name__ == "__main__":
threads = [threading.Thread(target=incrementar, args=(10_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(contador)
40000
Communicate via queue, instead of sharing
The safest way to coordinate threads is not to share state: a queue.Queue delivers items from a producer to a consumer, and already comes with built-in synchronization:
import queue
def produtor(fila, n):
for i in range(n):
fila.put(i)
fila.put(None)
def consumidor(fila, resultados):
while (item := fila.get()) is not None:
resultados.append(item * item)
if __name__ == "__main__":
fila = queue.Queue()
resultados = []
t1 = threading.Thread(target=produtor, args=(fila, 5))
t2 = threading.Thread(target=consumidor, args=(fila, resultados))
t1.start()
t2.start()
t1.join()
t2.join()
print(resultados)
[0, 1, 4, 9, 16]
The None at the end is a sentinel: the producer notifies the consumer that it is done. It is the simplest pattern to close a queue.
Exercise 1
Download multiple resources with a thread limit
Write baixar_todos(ids, max_workers=4) that uses ThreadPoolExecutor and returns the results in the order of the ids.