Threads: concorrência dentro de um processo
Uma thread é uma linha de execução independente dentro de um mesmo programa, que compartilha a memória com as outras threads. Um programa de trading que precisa buscar cotações de dez mercados, gravar um log e manter a interface responsiva usa threads para que esperar uma não pare as demais. O módulo threading do Python e, de forma mais cômoda, o concurrent.futures.ThreadPoolExecutor as iniciam e gerenciam. Como as threads compartilham variáveis, comunicam-se rápido e é perigoso errar.
# Ten slow "network calls" in a thread pool wait at the same time, so the total is about one call, not ten
import time
from concurrent.futures import ThreadPoolExecutor
def fetch_quote(symbol):
time.sleep(0.1) # stands in for waiting on a venue
return symbol, len(symbol) * 10.0
symbols = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "META", "GOOG", "NFLX", "ORCL", "INTC"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
quotes = list(pool.map(fetch_quote, symbols))
elapsed = time.perf_counter() - start
print(quotes[0], len(quotes)) # ('AAPL', 40.0) 10
print(elapsed < 0.5) # True about 0.1 s in total, not 10 x 0.1 sO GIL: threads que esperam e threads que calculam
O CPython tem um bloqueio global do interpretador (GIL): só uma thread executa bytecode Python em cada instante. Uma thread o cede enquanto espera a rede, um arquivo ou time.sleep, então as threads sobrepõem muito bem as esperas: dez threads buscando cotações terminam em cerca do tempo de uma. Mas para código Python limitado pela CPU (um laço Python puro fazendo aritmética) as threads se revezam, então quatro threads não são mais rápidas que uma e muitas vezes um pouco mais lentas. Código que roda fora do Python, como o NumPy ou uma extensão C que libera o GIL, pode rodar em paralelo em threads. Para cálculo pesado em Python puro, use processos separados (próximo tópico) ou a versão sem GIL (free-threaded) do Python 3.13+, onde disponível.
# Splitting a list into chunks for workers: sizes differ by at most one, the first chunks get the extra items
def split_chunks(items, workers):
size, extra = divmod(len(items), workers)
chunks, start = [], 0
for index in range(workers):
stop = start + size + (1 if index < extra else 0)
chunks.append(list(items[start:stop]))
start = stop
return chunks
chunks = split_chunks(list(range(7)), 3)
print(chunks) # [[0, 1, 2], [3, 4], [5, 6]]
print([sum(chunk) for chunk in chunks]) # [3, 7, 11] one partial sum per worker
print(sum(sum(chunk) for chunk in chunks)) # 21Dados compartilhados, condições de corrida e locks
Threads que atualizam a mesma variável podem corrompê-la, porque total += amount são três passos (ler, somar, escrever) e pode haver troca de thread entre eles: duas threads leem 100, somam 5, escrevem 105, e uma atualização se perde. É uma condição de corrida, que aparece raramente e de forma imprevisível, o que a torna o pior tipo de bug. O remédio é um lock: with lock: deixa uma thread por vez executar o bloco, e tanto a leitura quanto a escrita devem estar dentro. Melhor ainda, evite compartilhar: dê a cada trabalhador seu pedaço e seu resultado e combine os resultados no fim, ou passe trabalho e resultados por uma queue.Queue, que já é segura para threads.
# A lock makes the read-modify-write a single step; the total comes out exact
import threading
totals = {"AAPL": 0}
lock = threading.Lock()
def safe_add(shared, key, amount, lock):
with lock:
shared[key] = shared.get(key, 0) + amount
return shared[key]
def worker():
for _ in range(10_000):
safe_add(totals, "AAPL", 1, lock)
threads = [threading.Thread(target=worker) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(totals["AAPL"]) # 40000 nothing lost: four threads x 10,000 additions# A back-of-envelope estimate before writing any threaded code
import math
def estimate_time(n_tasks, seconds, workers, kind):
if kind in ("io", "processes"):
return math.ceil(n_tasks / workers) * seconds
return n_tasks * seconds # "cpu_threads": the GIL makes the threads take turns
print(estimate_time(40, 0.5, 8, "io")) # 2.5 forty slow requests, eight at a time
print(estimate_time(40, 0.5, 8, "cpu_threads")) # 20.0 forty CPU-bound tasks in threads: no gain
print(estimate_time(40, 0.5, 8, "processes")) # 2.5 separate processes use separate coresasyncio (visto antes neste roteiro) costuma escalar melhor e não tem corridas entre await. Para aritmética em arrays, vetorize com NumPy. Para Python limitado pela CPU, use processos. Se compartilhar dados entre threads, mantenha curta a seção travada, nunca chame código lento segurando um lock e pegue vários locks sempre na mesma ordem, ou duas threads podem esperar uma pela outra para sempre (um deadlock).- Threads compartilham memória e sobrepõem bem as esperas (rede, arquivos); um
ThreadPoolExecutorpode buscar de muitos mercados ao mesmo tempo. - O GIL deixa só uma thread executar bytecode Python por vez: sem ganho para Python puro limitado pela CPU, mas NumPy e extensões C podem liberá-lo.
- Proteja atualizações compartilhadas com
with lock:em volta da leitura e da escrita, prefira não compartilhar (pedaços, filas) e fique atento a deadlocks.
A seguir: multiprocessing, usar vários processos para pôr cada núcleo da CPU para trabalhar.
Lab: o que é avaliado
- Repartir o trabalho com
split_chunks25 pts - Aplicar uma função aos pedaços com
run_in_chunks25 pts - Proteger dados compartilhados com
safe_add25 pts - Saber o que as threads ganham com
estimate_time25 pts