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:

  1. Quando espera I/O: ler arquivo, fazer requisição de rede, time.sleep… Essas funções liberam o GIL antes de bloquear.
  2. 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 pickle para 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() ou cProfile primeiro. 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.
pythongilconcorrênciamultithreadingdesempenho