Skip to content

    Chapter 46, Advanced

    Concurrency: GIL, threads, and processes

    Choosing the wrong concurrency model is the most expensive performance mistake in Python. The decision depends on one question: is your bottleneck waiting or computing?

    Code for this chapter: avancado/cap46_concorrencia.py

    The GIL

    In CPython, a global lock (the GIL) allows only one thread execute bytecode Python at a time. Two practical consequences:

    • For work that waits (network, disk, database), threads work well because the GIL is released while the thread waits.
    • For work that calculates (loops, calculations), threads do not speed anything up because only one runs at a time. Here you need processes.

    There is an optional build of the interpreter without GIL (the executable python3.14t in 3.14). I treat it as something to test with your workload, and not as standard: the extension ecosystem is still adapting.

    The bottleneck is...Use
    I/O wait, a few dozen tasksThreadPoolExecutor
    I/O wait, thousands of connectionsasyncio (next chapter)
    Heavy computation in pure PythonProcessPoolExecutor
    Numerical computation in arraysC libraries, like NumPy

    Protect execution with __name__ == "__main__"

    On Windows and macOS, child processes are created by importing the main file again. Without the protection if __name__ == "__main__", each child would execute the entire program and create more children. Therefore, in this chapter, all code that does something is within this condition. The functions that the processes execute need to be at the module level, and the arguments and results need to be serializable (picklable).

    Threads for I/O

    The concurrent.futures provides a unique interface for threads and processes. Here, five "downloads" of 0.2 seconds each. In sequence, they take one second. In five threads, about 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)
    
    Output
    True
    threads foram mais rápidas: True
    

    Processes for CPU

    Counting primes is pure computation. Each process has its own interpreter and its own GIL, so they run on different cores for real. The cost is creating the processes and copying data between them, so it only pays off when each task is heavy:

    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)
    
    Output
    [2262, 2262, 2262, 2262]
    

    Shared state requires locking

    Threads share memory. When two modify the same data, the result can be lost because contador += 1 is not an indivisible operation (reading, adding, and writing are separate steps). The Lock ensures that only one thread enters the critical region at a time:

    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)
    
    Output
    40000
    

    Communicate via queue, instead of sharing

    The safest way to coordinate threads is not to share state: a queue.Queue delivers items from a producer to a consumer, and already comes with built-in synchronization:

    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)
    
    Output
    [0, 1, 4, 9, 16]
    

    The None at the end is a sentinel: the producer notifies the consumer that it is done. It is the simplest pattern to close a queue.

    Exercise 1

    Download multiple resources with a thread limit

    Write baixar_todos(ids, max_workers=4) that uses ThreadPoolExecutor and returns the results in the order of the ids.