Pular para o conteúdo

    Capítulo 46, Avançado

    Concorrência: GIL, threads e processos

    Escolher o modelo de concorrência errado é o erro de desempenho mais caro em Python. A decisão depende de uma pergunta: o seu gargalo é esperar ou calcular?

    Código deste capítulo: avancado/cap46_concorrencia.py

    O GIL

    No CPython, uma trava global (o GIL) permite que apenas uma thread execute bytecode Python por vez. Duas consequências práticas:

    • Para trabalho que espera (rede, disco, banco), as threads funcionam bem, porque o GIL é liberado enquanto a thread espera.
    • Para trabalho que calcula (laços, contas), threads não aceleram nada, porque só uma roda por vez. Aqui você precisa de processos.

    Existe um build opcional do interpretador sem GIL (o executável python3.14t no 3.14). Eu o trato como algo a testar com a sua carga de trabalho, e não como padrão: o ecossistema de extensões ainda está se adaptando.

    O gargalo é...Use
    Espera de I/O, poucas dezenas de tarefasThreadPoolExecutor
    Espera de I/O, milhares de conexõesasyncio (próximo capítulo)
    Cálculo pesado em Python puroProcessPoolExecutor
    Cálculo numérico em arraysBibliotecas em C, como NumPy

    Proteja a execução com __name__ == "__main__"

    No Windows e no macOS, os processos filhos são criados importando o arquivo principal de novo. Sem a proteção if __name__ == "__main__", cada filho executaria o programa inteiro e criaria mais filhos. Por isso, neste capítulo, todo código que faz algo fica dentro dessa condição. As funções que os processos executam precisam estar no nível do módulo, e os argumentos e resultados precisam ser serializáveis (picklable).

    Threads para I/O

    O concurrent.futures oferece uma interface única para threads e processos. Aqui, cinco "downloads" de 0,2 segundo cada. Em sequência levam um segundo. Em cinco threads, cerca de 0,2:

    avancado/cap46_concorrencia.pylinhas 10 a 27
    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
    
    avancado/cap46_concorrencia.pylinhas 29 a 40
    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)
    
    Saída
    True
    threads foram mais rápidas: True
    

    Processos para CPU

    Contar primos é cálculo puro. Cada processo tem o seu próprio interpretador e o seu próprio GIL, então eles rodam em núcleos diferentes de verdade. O custo é criar os processos e copiar os dados entre eles, por isso só compensa quando cada tarefa é pesada:

    avancado/cap46_concorrencia.pylinhas 45 a 49
    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)
    
    Saída
    [2262, 2262, 2262, 2262]
    

    Estado compartilhado exige trava

    Threads compartilham memória. Quando duas alteram o mesmo dado, o resultado pode se perder, porque contador += 1 não é uma operação indivisível (ler, somar e gravar são passos separados). O Lock garante que só uma thread entre na região crítica por vez:

    avancado/cap46_concorrencia.pylinhas 54 a 73
    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)
    
    Saída
    40000
    

    Comunicar por fila, em vez de compartilhar

    A forma mais segura de coordenar threads é não compartilhar estado: uma queue.Queue entrega os itens de um produtor para um consumidor, e já vem com a sincronização embutida:

    avancado/cap46_concorrencia.pylinhas 78 a 101
    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)
    
    Saída
    [0, 1, 4, 9, 16]
    

    O None no final é uma sentinela: o produtor avisa ao consumidor que acabou. É o padrão mais simples para encerrar uma fila.

    Exercício 1

    Baixar vários recursos com um limite de threads

    Escreva baixar_todos(ids, max_workers=4) que use ThreadPoolExecutor e devolva os resultados na ordem dos ids.