GIL em Python: o que é e como afeta o desempenho
1. Visão geral
Muita gente que migra de Java ou Go para Python esbarra no mesmo problema. Rodando 4 threads em uma máquina com 8 cores, mas o programa não fica mais rápido e às vezes fica até mais lento. O culpado costuma ser o GIL (Global Interpreter Lock).
Este artigo explica o que é GIL e por que ele existe. Você vai medir o impacto do GIL em código CPU-bound e I/O-bound. No final, você vai saber quando usar threading, quando usar multiprocessing, e o que muda com a build free-threaded do Python 3.13+.
2. O que é GIL?
GIL é um mutex no CPython, a implementação mais comum de Python. A qualquer momento, em um processo, apenas uma thread executa bytecode Python.
Você ainda consegue criar várias threads. Elas são threads reais do sistema operacional. Mas para executar bytecode, a thread precisa segurar o GIL. As outras threads têm de esperar.
2.1. Por que CPython precisa do GIL?
A razão principal é o mecanismo de gerenciamento de memória por reference counting. Cada objeto tem um contador de quantas referências apontam para ele:
import sys
order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))
alias = order
print(sys.getrefcount(order))
2
3
sys.getrefcount retorna o número de referências. O resultado é sempre uma unidade maior porque a própria chamada da função cria uma referência temporária. Quando você faz alias = order, o contador aumenta em 1.
Se duas threads incrementassem ou decrementassem esse contador ao mesmo tempo sem um lock, o contador poderia ficar errado. A consequência é vazamento de memória, ou pior, um objeto ser liberado enquanto ainda está sendo usado. Colocar um lock global é o jeito mais simples de proteger cada objeto. Também facilita escrever extensões C e deixa código single-threaded mais rápido.
2.2. Quando uma thread libera o GIL?
Uma thread que está segurando o GIL vai liberá-lo em dois casos:
- Quando espera I/O: ler arquivo, fazer requisição de rede,
time.sleep… Essas funções liberam o GIL antes de bloquear. - Quando termina sua “vez”: o interpreter periodicamente pede à thread que está rodando para liberar o GIL para que outra tenha oportunidade de rodar.
Você consegue ver o intervalo de tempo de uma vez:
import sys
print(sys.getswitchinterval())
0.005
O padrão é 5 ms. Você pode mudar com sys.setswitchinterval(), mas raramente vai precisar.
3. Impacto em código CPU-bound
Vamos medir. A função abaixo conta números primos, que é cálculo puro:
import time
from concurrent.futures import ThreadPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
def run_sequential(jobs: list[int]) -> float:
start = time.perf_counter()
for job in jobs:
count_primes(job)
return time.perf_counter() - start
def run_threads(jobs: list[int]) -> float:
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(count_primes, jobs))
return time.perf_counter() - start
if __name__ == "__main__":
jobs = [200_000] * 4
print(f"Sequencial: {run_sequential(jobs):.2f}s")
print(f"4 threads : {run_threads(jobs):.2f}s")
run_sequential executa 4 jobs um após o outro. run_threads passa os 4 jobs para 4 threads. O resultado em uma máquina com 8 cores (só para referência):
Sequencial: 2.31s
4 threads : 2.40s
Quatro threads não ficam mais rápidas. Na verdade, até ficam um pouco mais lentas. Por causa do GIL, as threads revezam a execução em um único core. A lentidão extra é o custo de disputa pelo GIL e troca de contexto.
Aviso: Este é o erro mais comum. Adicionar threads a código puramente Python de computação em CPython com GIL não o torna mais rápido.
4. Impacto em código I/O-bound
Com código principalmente à espera de I/O, a situação é bem diferente. Vamos simular chamar um PaymentService remoto que leva 0,5 segundo:
import time
from concurrent.futures import ThreadPoolExecutor
def charge_payment(order_id: int) -> str:
time.sleep(0.5) # simula requisição HTTP para o gateway de pagamento
return f"order-{order_id}: OK"
if __name__ == "__main__":
order_ids = list(range(10))
start = time.perf_counter()
for oid in order_ids:
charge_payment(oid)
print(f"Sequencial : {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
list(pool.map(charge_payment, order_ids))
print(f"10 threads : {time.perf_counter() - start:.2f}s")
Sequencial : 5.01s
10 threads : 0.50s
10 vezes mais rápido. time.sleep, assim como socket ou leitura de arquivo, libera o GIL enquanto espera. Dez threads esperam em paralelo. Elas só precisam do GIL por um instante para processar o resultado.
Conclusão: threads em Python ainda são muito úteis para I/O-bound, por exemplo chamar API, consultar banco de dados ou baixar arquivo. Para I/O em larga escala, asyncio também é uma boa escolha.
5. Contornando o GIL com multiprocessing
Para aproveitar vários cores em cálculos, o padrão é usar vários processos. Cada processo tem seu próprio interpreter e seu próprio GIL.
import time
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
jobs = [200_000] * 4
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(count_primes, jobs))
print(results)
print(f"4 processos: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 processos: 0.68s
Comparado com 2,31 segundos sequenciais, ficamos cerca de 3,4 vezes mais rápido. Não atingimos 4 vezes porque há custo de iniciar um processo.
O bloco if __name__ == "__main__": é obrigatório. No Windows e macOS, processos filhos são criados reimportando o módulo. Sem esse bloco, o processo filho criará um pool novamente e o programa vai dar erro.
Aviso: Multiprocessing tem seu preço. Parâmetros e resultados precisam ser serializados com
picklepara passar entre processos. Se você enviar um objeto grande, como um DataFrame de centenas de MB, para uma tarefa pequena, o custo de cópia pode ser maior que o benefício obtido. Lambdas e funções aninhadas também não conseguem ser serializadas com pickle.
6. Extensões C conseguem liberar o GIL
Muitas bibliotecas escritas em C liberam o GIL proativamente quando executam a parte pesada, por exemplo NumPy, hashlib ou zlib. Quando isso acontece, threads realmente rodam em paralelo:
import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor
payloads = [os.urandom(50_000_000) for _ in range(4)]
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
if __name__ == "__main__":
start = time.perf_counter()
for p in payloads:
digest(p)
print(f"Sequencial: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(digest, payloads))
print(f"4 threads : {time.perf_counter() - start:.2f}s")
Sequencial: 0.98s
4 threads : 0.27s
hashlib libera o GIL ao fazer hash de dados grandes (a partir de cerca de 2 KB). Por isso, 4 threads rodam em 4 cores. Na verdade, o GIL só bloqueia bytecode Python, não bloqueia código de máquina de bibliotecas nativas.
7. GIL não torna seu código thread-safe
Muita gente pensa “já que tem GIL, não preciso de lock”. Isso está errado. O GIL protege o estado interno do interpreter, não protege a lógica do seu código. Uma thread pode ser trocada para fora entre quaisquer duas linhas.
import threading
import time
class BankAccount:
def __init__(self) -> None:
self.balance = 0
self._lock = threading.Lock()
def deposit_unsafe(self, amount: int) -> None:
current = self.balance
time.sleep(0) # libera GIL, simula uma operação de I/O intercalada
self.balance = current + amount
def deposit_safe(self, amount: int) -> None:
with self._lock:
current = self.balance
time.sleep(0)
self.balance = current + amount
def run(method_name: str, threads: int = 10, times: int = 100) -> int:
account = BankAccount()
method = getattr(account, method_name)
def worker() -> None:
for _ in range(times):
method(1)
pool = [threading.Thread(target=worker) for _ in range(threads)]
for t in pool:
t.start()
for t in pool:
t.join()
return account.balance
if __name__ == "__main__":
print("unsafe:", run("deposit_unsafe"))
print("safe :", run("deposit_safe"))
unsafe: 112
safe : 1000
Esperamos 10 × 100 = 1000. Em deposit_unsafe, várias threads leem o mesmo valor antigo de current e sobrescrevem uma à outra, perdendo a maior parte dos depósitos. time.sleep(0) garante que o erro apareça. Em código real, o ponto onde o GIL é liberado pode ser um log ou uma consulta ao banco de dados. deposit_safe usa threading.Lock para tornar a operação de ler-modificar-escrever atômica.
Vamos escrever um teste com pytest para travar esse comportamento. Instale:
pip install pytest
# test_bank_account.py
from bank_account import run
def test_safe_deposit_keeps_correct_balance():
assert run("deposit_safe", threads=8, times=200) == 1600
def test_unsafe_deposit_loses_updates():
assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
.. [100%]
2 passed in 0.41s
O segundo teste prova que uma race condition é real mesmo com GIL no CPython.
8. Python free-threaded: um futuro sem GIL
A partir do Python 3.13, CPython tem uma build free-threaded (PEP 703) que permite desabilitar o GIL. Normalmente você roda essa versão com python3.13t ou python3.14t. No Python 3.14, essa build tem suporte oficial (PEP 779), mas ainda não é o padrão.
Verifique o status do GIL na execução:
import sys
import sysconfig
print("Build suporta free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL está habilitado:", sys._is_gil_enabled())
Rodando com a build free-threaded:
python3.14t check_gil.py
Build suporta free-threading: True
GIL está habilitado: False
Com essa build, o exemplo count_primes usando ThreadPoolExecutor da seção 3 realmente vai aproveitar vários cores.
Mas há alguns pontos a considerar antes de migrar:
- Código single-threaded é um pouco mais lento, porque o interpreter usa mecanismos de sincronização mais sofisticados.
- Extensões C precisam ser compatíveis. Se você importar uma extensão que ainda não declara suporte a free-threading, CPython vai habilitar o GIL novamente e imprimir um aviso. Você pode forçar com
PYTHON_GIL=0, mas faz isso por sua conta e risco. - Race conditions ficam muito mais fáceis de acontecer. O exemplo da seção 7 não fica mais “protegido” pelo fato de só uma thread rodar por vez.
9. Qual ferramenta escolher?
| Tipo de trabalho | Use |
|---|---|
| I/O-bound (HTTP, DB, arquivo) | threading, ThreadPoolExecutor, asyncio |
| CPU-bound puro em Python | ProcessPoolExecutor / multiprocessing |
| CPU-bound em NumPy, hashlib… | Thread funciona, a biblioteca libera o GIL |
| CPU-bound, precisa compartilhar memória, biblioteca já compatível | Considere Python free-threaded (3.13t/3.14t) |
Aviso: Não otimize por palpite. Meça com
time.perf_counter()oucProfileprimeiro. Muitos programas “lentos por causa do GIL” na verdade são lentos por algoritmo ruim ou consultas lentas ao banco de dados.
10. Conclusão
GIL é um lock global do CPython. Garante que apenas uma thread execute bytecode Python por vez, principalmente para proteger o reference counting.
A consequência prática se resume a quatro pontos:
- Threads não aceleram código puramente Python de computação.
- Threads ainda são muito eficientes para I/O-bound e para bibliotecas nativas que liberam o GIL.
- Para aproveitar vários cores em cálculos, use multiprocessing, ou experimente a build free-threaded do Python 3.13+.
- GIL não substitui lock. Dados compartilhados entre threads ainda precisam sincronização correta.