O GIL — o que é de verdade e por que existe

TL;DR

O GIL (Global Interpreter Lock) não existe porque “Python é uma linguagem ruim para concorrência” ou porque o interpretador precisa proteger algum “estado global” vago — ele existe para proteger uma coisa muito específica e muito concreta: o campo ob_refcnt de cada PyObject (visto em 02 e 03) contra condições de corrida entre threads. ob_refcnt++ e ob_refcnt-- não são operações atômicas em C — são leitura, incremento/decremento, escrita, três passos separados — e se duas threads rodarem esses três passos ao mesmo tempo sobre o mesmo objeto, o resultado é um contador de referências corrompido, que leva a use-after-free ou vazamento de memória silencioso. O GIL resolve isso da forma mais simples e brutal possível: só uma thread executa bytecode Python por vez, ponto final — trocando de thread a cada sys.getswitchinterval() segundos (5ms por padrão) ou quando a thread atual bloqueia em I/O. É exatamente esse mecanismo que faz threading em Python não acelerar trabalho CPU-bound (as threads competem pelo mesmo lock, nunca rodam bytecode Python em paralelo de verdade), mas acelerar trabalho I/O-bound de verdade (o GIL é liberado explicitamente durante chamadas bloqueantes — rede, disco — permitindo que outra thread rode enquanto a primeira espera). Extensões C que fazem processamento pesado fora do interpretador (NumPy, regex compilado, hashing) também liberam o GIL deliberadamente via Py_BEGIN_ALLOW_THREADS, e é aí que “threading em Python” ganha paralelismo real de CPU — não pela mágica do threading, mas porque a extensão C escolheu soltar o lock.

O bug que abre esta nota

Um desenvolvedor sênior, vindo de Java, está otimizando um serviço Python que processa imagens em lote. A CPU da máquina tem 16 núcleos, e ele decide paralelizar o processamento com threading, exatamente como faria em Java com um ExecutorService:

import threading
import time
 
def processar_imagem(dados):
    # CPU-bound: redimensionar, aplicar filtro, recodificar
    resultado = aplicar_filtro_pesado(dados)
    return resultado
 
imagens = carregar_lote(100)  # 100 imagens, ~50ms de CPU cada
 
# "Vou usar 8 threads, deve rodar ~8x mais rápido"
inicio = time.perf_counter()
threads = []
for lote in dividir_em_partes(imagens, 8):
    t = threading.Thread(target=processar_lote, args=(lote,))
    t.start()
    threads.append(t)
for t in threads:
    t.join()
fim = time.perf_counter()
 
print(f"Tempo com 8 threads: {fim - inicio:.2f}s")
# Resultado real: praticamente IDÊNTICO a rodar com 1 thread só
# (às vezes até um pouco PIOR, por causa do overhead de troca de contexto)

O código está correto — sem deadlock, sem race condition visível, sem exceção. Mas as 8 threads não aceleram nada. Em Java, esse mesmo padrão usaria os 16 núcleos de verdade. Em Python, o htop mostra um único núcleo perto de 100% enquanto os outros 15 ficam praticamente ociosos. O desenvolvedor não tem um bug de lógica — tem uma suposição errada sobre o que threading em CPython é capaz de fazer, e essa suposição só é corrigível entendendo por que o GIL existe e o que, exatamente, ele protege. Esta nota assume que você já sabe, das notas 02 e 03, o que é ob_refcnt e como o reference counting funciona — e constrói em cima disso a peça que faltava: por que essa contagem precisava, desde o primeiro dia do CPython (1990s), de um mecanismo de proteção entre threads.

Pré-requisito

Esta nota pressupõe 02 (o que é ob_refcnt, Py_INCREF/Py_DECREF) e 03 (como o reference counting decide quando um objeto morre). Se esses dois mecanismos não estiverem claros, volte a eles antes — esta nota não os reexplica, só assume que ob_refcnt já é um conceito familiar e foca no problema de concorrência que ele cria.

O que é

O Global Interpreter Lock é um mutex — um único lock, global ao processo inteiro, não por objeto e não por thread — que qualquer thread precisa segurar para executar bytecode Python. Ele é literalmente um PyThread_type_lock interno do CPython (uma primitiva de exclusão mútua do sistema operacional), guardado dentro da struct de estado do interpretador (PyInterpreterState em builds modernos — CPython 3.12+ move parte desse estado para permitir múltiplos interpretadores isolados via PEP 684, mas o lock em si continua com a mesma função). Só existe uma exceção formal a “global”: builds free-threaded (PEP 703, ver nota 06 deste galho) removem o GIL por completo, trocando-o por locks mais finos — mas isso é uma variante de build ainda opt-in, não o comportamento padrão do CPython em 2026.

Em CPython “normal” (com GIL), o modelo é simples de enunciar e traiçoeiro de internalizar: não importa quantas threads Python você crie, no máximo uma delas está, em qualquer instante dado, executando bytecode do interpretador. As outras estão bloqueadas esperando o lock, ou fora do interpretador fazendo alguma coisa que explicitamente não precisa dele (mais sobre isso adiante).

GIL em uma frase: um mutex único, global ao processo, que qualquer thread precisa segurar para tocar bytecode Python — existe para que o reference counting continue seguro sem precisar de um lock por objeto.

Por que importa: o problema real que o GIL resolve

ob_refcnt++ não é uma instrução atômica

Aqui está o núcleo técnico da nota, e é mais simples do que a reputação do GIL sugere. Em C, uma linha como:

obj->ob_refcnt++;

não é uma única operação de hardware. Ela se decompõe, no mínimo, em três passos distintos:

  1. Ler o valor atual de ob_refcnt da memória para um registrador da CPU.
  2. Incrementar esse valor no registrador.
  3. Escrever o valor incrementado de volta na memória.

Se o CPython rodasse threads Python de verdade em paralelo — cada uma num núcleo de CPU diferente, sem nenhuma coordenação — e duas threads distintas executassem Py_INCREF(obj) sobre o mesmo objeto ao mesmo tempo, o intercalamento das três etapas pode acontecer assim:

sequenceDiagram
    participant T1 as Thread 1
    participant Mem as ob_refcnt (memória)
    participant T2 as Thread 2

    Note over Mem: ob_refcnt = 5
    T1->>Mem: lê valor (5)
    T2->>Mem: lê valor (5)
    T1->>T1: incrementa localmente (5 → 6)
    T2->>T2: incrementa localmente (5 → 6)
    T1->>Mem: escreve 6
    T2->>Mem: escreve 6
    Note over Mem: ob_refcnt = 6 (deveria ser 7!)

Duas threads incrementaram o contador, mas o valor final é 6, não 7 — um Py_INCREF inteiro desapareceu, porque as duas threads leram o mesmo valor de partida antes de qualquer uma escrever o resultado de volta. Esse fenômeno tem nome — lost update, a condição de corrida clássica de qualquer sistema com estado compartilhado mutável e escrita concorrente sem sincronização — e o dano que ele causa aqui é catastrófico especificamente por causa do que ob_refcnt significa: quando esse contador chega a zero, o CPython libera a memória do objeto imediatamente (tp_dealloc, coberto na nota 03).

Um ob_refcnt corrompido para menos do que deveria significa que o objeto pode ser desalocado enquanto ainda existem referências vivas apontando para ele — um use-after-free: código que acessa memória já liberada e reciclada pelo alocador para outra coisa, lendo lixo ou, pior, corrompendo dados de um objeto completamente diferente que passou a ocupar aquele endereço. É uma das classes de bug mais graves que existem em software de sistemas — a mesma categoria de vulnerabilidade que aparece em CVEs de segurança de linguagens sem gerenciamento de memória automático (C, C++). Um ob_refcnt corrompido para mais do que deveria é mais benigno, mas ainda errado: o objeto nunca é liberado, um vazamento de memória permanente, silencioso, sem qualquer exceção ou sinal visível no código Python que o desenvolvedor escreveu.

Este bug não é hipotético nem exclusivo do CPython

Corromper um contador de referências sob concorrência sem sincronização é exatamente o motivo pelo qual std::shared_ptr em C++ oferece uma variante atomic (std::atomic<std::shared_ptr<T>>, C++20) e por que Objective-C/Swift usam operações atômicas dedicadas para ARC (Automatic Reference Counting) em contextos multi-thread. Qualquer runtime que use contagem de referências como mecanismo de gerenciamento de memória enfrenta este exato problema assim que introduz threads nativas com memória compartilhada — a única pergunta é como ele resolve: um lock por objeto (caro, e ainda vulnerável a deadlock entre locks de objetos diferentes), operações atômicas de CPU por incremento/decremento (mais rápidas, mas ainda um custo por operação, e o caminho que builds free-threaded do CPython adotam — ver nota 06), ou um lock único e global que elimina o problema por completo ao eliminar a concorrência real dentro do interpretador (a escolha histórica do CPython).

A escolha de design: um lock global em vez de um lock por objeto

A alternativa óbvia — dar a cada PyObject seu próprio lock, travado a cada Py_INCREF/Py_DECREF — foi tentada e descartada historicamente. Guido van Rossum implementou o GIL já nas primeiras versões multi-thread do CPython (início dos anos 1990) justamente porque locks finos por objeto tinham dois problemas sérios:

  1. Custo de performance em código single-threaded: a esmagadora maioria dos programas Python, na época e ainda hoje, não usa múltiplas threads de forma pesada. Pagar o custo de adquirir e liberar um lock a cada incremento/decremento de referência — em todo objeto, mesmo quando não há concorrência nenhuma acontecendo — seria um imposto de performance universal para resolver um problema que só existe quando há threads competindo.
  2. Risco de deadlock entre locks aninhados: operações que tocam múltiplos objetos ao mesmo tempo (por exemplo, inserir um item numa lista que está dentro de um dicionário) precisariam adquirir múltiplos locks em sequência — e qualquer sistema com múltiplos locks adquiridos em ordens potencialmente diferentes por threads diferentes é uma receita clássica para deadlock.

O GIL evita os dois problemas ao custo de um terceiro: elimina paralelismo real de bytecode Python entre threads, mesmo em máquinas com múltiplos núcleos. É uma troca deliberada, documentada como tal desde sempre pelos próprios mantenedores do CPython — não um acidente de implementação nem um sinal de que “Python não sabe fazer concorrência”. O Python Wiki sobre o GIL descreve essa história com mais detalhe.

Como funciona

O ciclo de troca: sys.getswitchinterval()

Enquanto o GIL existe (build padrão, sem free-threading), o CPython precisa decidir quando forçar a thread atual a soltar o lock, para que outra thread tenha uma chance de rodar. A partir do Python 3.2 (PEP incorporado à implementação, sem número dedicado — mudança direta no scheduler do GIL), esse ritmo é baseado em tempo de parede (wall-clock time), não em número de instruções de bytecode executadas (o esquema anterior, mais antigo e menos previsível sob cargas heterogêneas).

import sys
 
print(sys.getswitchinterval())   # 0.005 — 5 milissegundos, o padrão do CPython

O mecanismo funciona assim: a cada switchinterval segundos, a thread que está segurando o GIL recebe um sinal interno para soltá-lo — mas ela só solta de fato no próximo ponto seguro de verificação do interpretador (não instantaneamente, e não no meio de uma operação atômica de bytecode). Se nenhuma outra thread está esperando pelo GIL, a thread atual simplesmente continua rodando sem interrupção real — o intervalo só importa quando há contenção de verdade.

import sys
 
# Reduzir o intervalo: troca de thread mais frequente,
# mais responsivo para latência entre threads, mais overhead de troca de contexto
sys.setswitchinterval(0.001)   # 1ms
 
# Aumentar o intervalo: menos overhead de troca, mas cada thread monopoliza
# o GIL por mais tempo antes de ceder — pior para latência de outras threads
sys.setswitchinterval(0.1)     # 100ms

sys.setswitchinterval() não afeta paralelismo, só a granularidade da alternância

Ajustar o intervalo de troca não faz threads Python rodarem bytecode em paralelo — continua sendo uma thread por vez, sempre. O que muda é a frequência com que o interpretador troca qual thread está segurando o lock. Um intervalo menor favorece programas com muitas threads curtas competindo por responsividade (cada uma espera menos tempo pela sua vez); um intervalo maior favorece throughput bruto de uma única thread dominante, ao custo de fazer as outras esperarem mais entre suas janelas de execução. Ajustar esse valor é uma otimização de latência entre threads, não uma forma de contornar o GIL.

Quando o GIL é liberado de verdade

Esta é a seção que separa quem decorou “o GIL trava tudo” de quem entende o mecanismo. O GIL protege especificamente a execução de bytecode Python e as manipulações de ob_refcnt que ela implica — então, sempre que o CPython sabe, com certeza, que uma operação não vai tocar o estado interno de nenhum PyObject, o interpretador pode soltar o lock deliberadamente antes de fazer essa operação, e reaquirir depois de terminar.

flowchart TB
    Start["Thread quer executar código"] --> HasGIL{"Segura o GIL?"}
    HasGIL -- Não --> Wait["Espera na fila do GIL"]
    Wait --> HasGIL
    HasGIL -- Sim --> Bytecode["Executa bytecode Python\n(Py_INCREF/Py_DECREF protegidos)"]
    Bytecode --> Blocking{"Vai bloquear em I/O\nou chamar extensão C pesada?"}
    Blocking -- Não --> Bytecode
    Blocking -- Sim --> Release["Py_BEGIN_ALLOW_THREADS\n(solta o GIL)"]
    Release --> External["Syscall bloqueante / loop C puro\n(outras threads podem rodar bytecode agora)"]
    External --> Reacquire["Py_END_ALLOW_THREADS\n(readquire o GIL)"]
    Reacquire --> Bytecode

    style Start fill:#4A90D9,color:#fff
    style HasGIL fill:#4A90D9,color:#fff
    style Bytecode fill:#4A90D9,color:#fff
    style Blocking fill:#F5A623,color:#000
    style Release fill:#F5A623,color:#000
    style External fill:#D0021B,color:#fff
    style Reacquire fill:#F5A623,color:#000

Os três casos concretos em que isso acontece:

1. Chamadas de I/O bloqueantes. Toda operação da biblioteca padrão que espera por algo externo — ler de um socket (socket.recv), ler de um arquivo (file.read), time.sleep(), esperar por um subprocess (subprocess.wait) — solta o GIL antes de entrar na chamada de sistema (syscall) e o readquire assim que ela retorna. Isso faz sentido estrutural: enquanto a thread está bloqueada esperando o kernel do sistema operacional responder, ela não está executando nenhum bytecode Python, não está tocando ob_refcnt de objeto nenhum — não há razão nenhuma para segurar o lock enquanto isso acontece, e segurá-lo impediria qualquer outra thread de progredir à toa.

2. Extensões C que chamam Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS. Qualquer extensão escrita em C (ou Cython, ou Rust via PyO3) pode envolver um trecho de código que não toca a API do CPython com essas duas macros:

/* Dentro de uma função de extensão C */
Py_BEGIN_ALLOW_THREADS
/* Este bloco NÃO PODE chamar nenhuma API do CPython
   (nenhum Py_INCREF, nenhuma alocação via PyObject_New, etc.) —
   é uma promessa que o autor da extensão faz ao interpretador */
resultado = funcao_c_pura_e_pesada(dados);
Py_END_ALLOW_THREADS
/* GIL readquirido aqui; agora é seguro voltar a tocar objetos Python */

Isso é uma decisão explícita e deliberada do autor da extensão — o interpretador não solta o GIL sozinho durante uma chamada de extensão C comum; a extensão precisa pedir isso ativamente, e assumir a responsabilidade de que o bloco entre as duas macros genuinamente não mexe em nenhum PyObject.

3. Bibliotecas de computação numérica, como NumPy. É aqui que a intuição popular de “NumPy é rápido porque usa múltiplas threads” precisa de um ajuste fino: NumPy libera o GIL, via exatamente o mecanismo do item 2, durante os loops internos de operações vetorizadas pesadas — uma multiplicação de matrizes grandes, uma soma de array, uma operação element-wise sobre milhões de floats. Enquanto esse loop C roda, o GIL está solto, e outra thread Python pode, nesse intervalo, rodar bytecode de verdade em paralelo — inclusive outra chamada NumPy, ou qualquer outro código Python puro. Isso é real paralelismo de CPU dentro de um programa Python com threading, mas ele só acontece dentro da porção de código que já saiu do interpretador Python para dentro do loop C puro — não é threading “acelerando NumPy” de forma genérica; é NumPy, especificamente, decidindo soltar o lock durante a parte do trabalho que não precisa dele.

Uma exceção real: sub-interpretadores com GIL próprio (PEP 684)

Vale registrar uma nuance que a frase “o GIL é global ao processo” simplifica demais: desde a PEP 684 (Python 3.12), o CPython suporta sub-interpretadores com estado isolado, cada um com seu próprio GIL, dentro do mesmo processo. Isso não é o comportamento default de threading.Thread — é uma API separada e mais avançada (interpreters no módulo concurrent, ainda em maturação na biblioteca padrão via PEP 734), que cria interpretadores independentes, cada um incapaz de compartilhar objetos Python arbitrários com os outros (a comunicação entre eles passa por canais explícitos de serialização, não por referências compartilhadas de memória).

Por que threads Python não paralelizam CPU-bound

Juntando as duas seções anteriores, a resposta completa à pergunta que abre esta nota fica direta: trabalho CPU-bound escrito em Python puro — um loop for fazendo aritmética, processamento de string, qualquer coisa que seja só bytecode Python do início ao fim — nunca solta o GIL espontaneamente (só quando o switchinterval expira, e mesmo assim é uma troca cooperativa entre threads que continuam se revezando, uma de cada vez, nunca em paralelo). Rodar esse tipo de trabalho em N threads não faz N threads executarem bytecode simultaneamente em N núcleos — faz N threads se revezarem, uma de cada vez, no mesmo núcleo lógico do ponto de vista do interpretador, com o overhead adicional de trocar de contexto entre elas. O resultado observado — tempo total igual ou pior que rodar sequencialmente — é exatamente o esperado, não um bug de configuração.

Trabalho I/O-bound é o caso oposto: o tempo dominante do programa é gasto esperando por algo externo (rede, disco, outro processo), não executando bytecode. Nesse cenário, o GIL é solto durante toda a espera (item 1 da seção anterior), então múltiplas threads Python conseguem, de fato, ter múltiplas requisições de rede em voo simultaneamente, múltiplas leituras de arquivo pendentes ao mesmo tempo — paralelismo real na parte que importa (a espera), mesmo que o processamento do resultado, quando ele chega, ainda seja serializado pelo GIL como qualquer bytecode Python.

Tipo de trabalhothreading acelera?Por quê
CPU-bound puro (loop Python, aritmética, parsing)NãoNunca solta o GIL espontaneamente; threads se revezam, não paralelizam
I/O-bound (rede, disco, subprocess)Sim, de verdadeGIL solto durante a espera bloqueante; múltiplas esperas acontecem em paralelo
CPU-bound em extensão C que libera o GIL (NumPy, hashing, regex compilado, zlib)Parcialmente, dentro do trecho CGIL solto só durante o loop C interno; overhead de orquestração em Python continua serializado

Uma nota importante de fronteira: esta nota explica por que essa distinção existe. A comparação prática e aprofundada entre threading e multiprocessing como as duas saídas reais para esse problema — quando escolher cada uma, o custo de serialização entre processos, os padrões de produção — é o assunto da próxima nota deste galho; esta nota não antecipa esse conteúdo.

GIL e paralelismo em uma frase: o GIL não impede paralelismo de qualquer tipo — impede paralelismo de bytecode Python, que é exatamente o que trabalho CPU-bound puro precisa e o que trabalho I/O-bound/extensão-C não precisa (porque passa a maior parte do tempo fora do interpretador).

Na prática

Cenário 1: medindo o efeito do GIL diretamente

O experimento mais direto para visualizar o problema descrito na abertura desta nota — sem depender de intuição, com números reais:

import threading
import time
 
def trabalho_cpu(n):
    """CPU-bound puro: só bytecode Python, nenhuma extensão C, nenhum I/O."""
    total = 0
    for i in range(n):
        total += i * i
    return total
 
N = 20_000_000
 
# Sequencial: uma thread, duas chamadas
inicio = time.perf_counter()
trabalho_cpu(N)
trabalho_cpu(N)
print(f"Sequencial: {time.perf_counter() - inicio:.2f}s")
 
# "Paralelo": duas threads, uma chamada cada
inicio = time.perf_counter()
t1 = threading.Thread(target=trabalho_cpu, args=(N,))
t2 = threading.Thread(target=trabalho_cpu, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Duas threads: {time.perf_counter() - inicio:.2f}s")
 
# Resultado típico (CPython 3.12, sem free-threading):
# Sequencial: ~2.1s
# Duas threads: ~2.3s  ← IGUAL ou PIOR, nunca ~1.05s (que seria o "paralelo de verdade")

O ganho esperado de duas threads rodando em paralelo seria reduzir o tempo total pela metade. O resultado real mostra o oposto do que a intuição de outras linguagens sugere: o tempo com duas threads é igual ou levemente pior que sequencial, porque a única coisa que as duas threads fizeram foi se revezar segurando o mesmo GIL, pagando o custo extra de troca de contexto entre elas sem ganhar nenhum paralelismo real.

Cenário 2: o mesmo experimento com I/O real

Trocando o trabalho CPU-bound por I/O-bound (aqui simulado com time.sleep, que internamente solta o GIL exatamente como uma chamada de rede real faria):

import threading
import time
 
def trabalho_io(segundos):
    """I/O-bound simulado: time.sleep solta o GIL enquanto espera."""
    time.sleep(segundos)
 
N_THREADS = 8
DURACAO = 0.5
 
# Sequencial: 8 esperas de 0.5s, uma atrás da outra
inicio = time.perf_counter()
for _ in range(N_THREADS):
    trabalho_io(DURACAO)
print(f"Sequencial: {time.perf_counter() - inicio:.2f}s")   # ~4.0s
 
# Com threads: 8 esperas simultâneas
inicio = time.perf_counter()
threads = [threading.Thread(target=trabalho_io, args=(DURACAO,)) for _ in range(N_THREADS)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Com threads: {time.perf_counter() - inicio:.2f}s")   # ~0.5s — paralelismo REAL

A diferença entre os dois cenários é a demonstração mais direta possível da fronteira que esta nota descreve: mesma ferramenta (threading), mesmo número de threads, resultado radicalmente diferente — porque num caso o trabalho é bytecode Python que nunca solta o GIL, e no outro é uma espera que solta o GIL o tempo inteiro.

Cenário 3: verificando na prática que uma extensão C libera o GIL

Uma forma indireta, mas real, de observar o item 3 da seção “Quando o GIL é liberado” é medir speedup de operações NumPy grandes sob threading — o ganho, quando existe, é a evidência empírica de que o GIL foi solto durante o cálculo:

import threading
import time
import numpy as np
 
def multiplicar_matrizes(tamanho):
    a = np.random.rand(tamanho, tamanho)
    b = np.random.rand(tamanho, tamanho)
    return a @ b   # multiplicação de matriz — loop C interno, GIL solto durante o cálculo
 
TAMANHO = 1200
 
inicio = time.perf_counter()
multiplicar_matrizes(TAMANHO)
multiplicar_matrizes(TAMANHO)
print(f"Sequencial: {time.perf_counter() - inicio:.2f}s")
 
inicio = time.perf_counter()
t1 = threading.Thread(target=multiplicar_matrizes, args=(TAMANHO,))
t2 = threading.Thread(target=multiplicar_matrizes, args=(TAMANHO,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Duas threads: {time.perf_counter() - inicio:.2f}s")
 
# O ganho medido aqui varia por máquina/build de NumPy (algumas distribuições já
# paralelizam @ internamente via BLAS multi-thread, o que confunde a medição isolada) —
# mas em builds single-threaded de BLAS, um ganho real e mensurável aparece,
# ao contrário do Cenário 1.

Medir "ganho do GIL solto" em NumPy pode ser confuso por causa de paralelismo interno do BLAS

Muitas distribuições de NumPy (as que vêm com pip install numpy em builds recentes) já usam uma implementação de BLAS (OpenBLAS, MKL) que internamente cria suas próprias threads nativas de sistema operacional para operações de álgebra linear grandes — paralelismo que acontece independente do threading do Python. Isso significa que uma única chamada a @ b já pode usar múltiplos núcleos por conta própria, mascarando o efeito específico de “o GIL foi solto” que este cenário tenta isolar. Para medir só o efeito do GIL, fixar OMP_NUM_THREADS=1/OPENBLAS_NUM_THREADS=1 antes de rodar o experimento remove essa variável de confusão.

Diagnosticando contenção de GIL em produção

Fora do laboratório controlado dos cenários acima, o sintoma que leva um time a investigar o GIL costuma ser mais sutil: um serviço com múltiplas threads que devia estar aproveitando vários núcleos, mas o htop/top mostra utilização de CPU concentrada em torno de 100-150% (um núcleo e meio, aproximadamente), nunca perto de N × 100% esperado. Duas ferramentas tornam essa contenção diretamente observável, sem precisar instrumentar o código:

  • py-spy dump --pid <PID> — um sampling profiler externo (assunto aprofundado na nota 08 deste galho) que consegue anexar a um processo Python já rodando e mostrar, para cada thread, se ela está holding the GIL ou apenas esperando por ele. Ver várias threads simultaneamente marcadas como “waiting for the GIL” é a confirmação direta de que o gargalo é exatamente o mecanismo descrito nesta nota, não outra causa (lock de aplicação, contenção de banco de dados, etc.).
  • sys._current_frames() — uma função de baixo nível da biblioteca padrão que devolve o frame de execução atual de cada thread do processo; combinada com inspeção manual, permite confirmar que múltiplas threads estão, de fato, tentando executar bytecode Python simultaneamente (e portanto competindo pelo GIL) em vez de estarem bloqueadas em I/O.

O diagnóstico prático segue uma pergunta simples: se a CPU total usada pelo processo nunca ultrapassa ~1 núcleo mesmo com várias threads ativas, e o trabalho é predominantemente Python puro (sem extensão C liberando o GIL), a causa quase sempre é exatamente o mecanismo desta nota — não um bug de configuração, não uma thread mal escrita, apenas o comportamento esperado do GIL sobre trabalho CPU-bound.

O contraste que interessa: GIL vs. threads nativas vs. event loop single-threaded

Para quem vem de outra linguagem, vale situar o GIL entre os dois modelos de concorrência mais comuns que ele costuma ser confundido com um ou outro:

AspectoCPython com GILJava (threads nativas)Node.js (event loop single-threaded)
Modelo de execução1 thread executa bytecode Python por vez; alternância cooperativa a cada switchintervalN threads nativas do SO, cada uma em seu núcleo, verdadeiramente simultâneas1 única thread para JS; concorrência via callbacks/event loop, nunca paralelismo real de JS
Paralelismo de CPU dentro do processoSó fora do interpretador (extensão C com GIL solto, sub-interpretador PEP 684, multiprocessing)Sim, nativo — é o caso de uso principal de Thread/ExecutorServiceNão — I/O é assíncrono via libuv (thread pool interno em C), mas JS do usuário nunca roda em paralelo consigo mesmo
Sincronização de memória compartilhadaDesnecessária para o interpretador (GIL já serializa); ainda necessária para lógica de negócio (Lock explícito)Necessária e responsabilidade do desenvolvedor (synchronized, java.util.concurrent) — fonte clássica de bugs de corridaNão se aplica — não há memória compartilhada mutável entre “threads” de JS porque só existe uma
Por que essa escolha de designReference counting precisa de proteção; um lock global evita locks por objeto e deadlock entre elesJVM não usa refcounting (GC tracing, nota Garbage Collection) — não tem esse problema estrutural para resolverDesign deliberado para simplificar o modelo mental de concorrência (sem race conditions de memória compartilhada) às custas de nunca paralelizar CPU-bound em JS puro

O contraste em uma frase: o GIL não é “Java sem paralelismo” nem “Node.js com mais uma API” — é uma escolha própria, motivada por um problema específico (refcounting seguro), com uma superfície de API (threading) que engana quem espera o modelo de outra linguagem.

Armadilhas comuns

Achar que o GIL protege qualquer código Python de condições de corrida

O que acontece: desenvolvedor assume que, como “só uma thread roda por vez”, qualquer sequência de operações Python é automaticamente segura contra corrida entre threads — inclusive lógica de negócio como contador += 1 sobre um contador compartilhado. Por quê: o GIL garante que cada bytecode individual (uma instrução LOAD_FAST, um BINARY_ADD) execute sem interrupção por outra thread no meio dela — mas contador += 1 em Python é várias instruções de bytecode (carregar o valor, somar, guardar de volta), e o GIL pode trocar de thread exatamente entre essas instruções. Duas threads incrementando o mesmo contador Python de alto nível ainda podem perder atualizações, pela mesma razão estrutural (leitura-modificação-escrita não atômica) que motivou a nota inteira — só que agora no nível do bytecode Python, não do ob_refcnt em C. Como evitar: usar threading.Lock (ou primitivas de mais alto nível, threading.Semaphore/Condition) para qualquer seção que leia-modifique-escreva estado compartilhado entre threads, exatamente como se faria em qualquer linguagem sem essa garantia — o GIL não substitui sincronização explícita de lógica de negócio, só protege a integridade interna do interpretador.

Confundir "o GIL existe" com "Python não escala em produção"

O que acontece: decisão de arquitetura descarta Python inteiro para um serviço de alta concorrência, com base em “tem GIL, não escala”. Por quê: a maior parte de serviços de produção reais (APIs web, workers de fila, scrapers) é dominada por I/O — chamadas de rede para banco de dados, APIs externas, filesystem — exatamente o caso em que o GIL é solto e threading (ou, mais comum hoje, asyncio — assunto do Galho 8) entrega concorrência real. Os frameworks que sustentam a maior parte do backend Python em produção (Django, FastAPI, Celery) são, estruturalmente, arquiteturas I/O-bound. Como evitar: identificar se o gargalo real é CPU-bound (processamento de imagem, parsing pesado, cálculo científico) ou I/O-bound (a maioria dos serviços web) antes de descartar Python por causa do GIL. Para a fração genuinamente CPU-bound, a resposta não é “abandonar Python” — é multiprocessing (processos separados, cada um com seu próprio interpretador e seu próprio GIL, paralelismo real entre eles) ou extensões C/Rust que liberam o GIL, ambos cobertos na próxima nota.

Ajustar sys.setswitchinterval() esperando ganho de paralelismo

O que acontece: ao descobrir a existência do parâmetro, desenvolvedor reduz o intervalo achando que vai “liberar mais paralelismo” para threads CPU-bound. Por quê: o switchinterval controla só a frequência com que a alternância cooperativa entre threads acontece — quantas vezes por segundo o GIL troca de dono. Reduzir o intervalo faz a alternância mais frequente (potencialmente melhor para latência de resposta entre threads competindo), mas nunca faz duas threads executarem bytecode ao mesmo tempo — o número de threads rodando bytecode simultaneamente continua sendo exatamente 1, sempre, com qualquer valor de switchinterval. Como evitar: tratar sys.setswitchinterval() como uma ferramenta de tuning de latência/responsividade entre threads que já são I/O-bound ou já liberam o GIL via extensão C — nunca como alavanca de paralelismo CPU-bound, que ela estruturalmente não pode entregar.

Em entrevista

A pergunta “o que é o GIL e por que ele existe” é praticamente garantida em qualquer entrevista sênior de Python — e a resposta que separa quem decorou o nome de quem entende o mecanismo é, de novo, a mesma da nota sobre reference counting: nomear a causa raiz, não só o sintoma.

“The GIL is a single global mutex in CPython that a thread must hold to execute Python bytecode. It doesn’t exist because Python is bad at concurrency — it exists to protect one specific thing: the ob_refcnt field that every PyObject carries for reference counting. Incrementing or decrementing that counter in C isn’t an atomic operation — it’s read, modify, write, three separate steps — and if two threads did that concurrently on the same object without any lock, you’d get a lost update: the refcount would end up wrong, which can cause a use-after-free or a permanent memory leak. Rather than adding a lock per object — which would hurt single-threaded performance everywhere and risk deadlocks between nested locks — CPython’s original design uses one global lock, switched between threads roughly every 5 milliseconds by default, tunable via sys.setswitchinterval(). The GIL is released explicitly during blocking I/O and inside C extensions that call Py_BEGIN_ALLOW_THREADS — which is exactly why threading gives real concurrency for I/O-bound work but no real parallelism for CPU-bound pure-Python work: CPU-bound code never releases the GIL on its own, so threads just take turns on one core instead of running simultaneously on several.”

Uma pergunta de acompanhamento quase certa: “como você faria paralelismo real de CPU em Python, então?” — a resposta sênior nomeia as duas saídas reais (aprofundadas na próxima nota do galho): multiprocessing, que cria processos de sistema operacional separados, cada um com seu próprio interpretador e seu próprio GIL — paralelismo genuíno entre processos, ao custo de serialização de dados entre eles (pickle na maioria dos casos) — ou extensões C/Rust (NumPy, Cython com nogil, PyO3) que liberam o GIL durante o trabalho pesado, dando paralelismo real dentro de um único processo para o trecho que está fora do interpretador Python puro.

Como explicar em inglês

PTEN
Lock Global do InterpretadorGlobal Interpreter Lock (GIL)
condição de corridarace condition
atualização perdidalost update
operação atômicaatomic operation
usar-depois-de-liberaruse-after-free
intervalo de troca (entre threads)switch interval
I/O bloqueanteblocking I/O
liberar o GILrelease the GIL
readquirir o GILreacquire the GIL
CPU-boundCPU-bound
I/O-boundI/O-bound
build sem GIL / de threads livresfree-threaded build

O que vem a seguir

Entender por que o GIL existe é o pré-requisito direto para decidir, na prática, quando threading ajuda e quando não ajuda — e o que fazer no caso em que não ajuda. A próxima nota do galho pega exatamente esse fio:

Fontes

Consultado em 2026-07-10.