MÓDULO 04 · CONCEITO 14 DE 14

Escolhendo o modelo: I/O-bound vs CPU-bound

A pergunta que fecha o módulo. Que modelo de concorrência usar para qual problema? Como medir, como combinar, e por que a resposta certa raramente é "mais threads".

Tempo de leitura ~22 min Pré-requisito Conceitos 01 a 13 Próximo Módulo 05 — Aspect-Driven & Cross-cutting Concerns

Treze conceitos depois, o módulo se fecha com a pergunta que resume todo o resto: dado um problema concorrente, qual modelo você escolhe? Async/await, goroutines, threads tradicionais, multiprocessing, actors, virtual threads, channels — você viu o catálogo inteiro. Cada um tem casos onde brilha e casos onde atrapalha. A intuição que separa sêniores dos demais é saber classificar o problema antes de escrever código, e escolher ferramenta adequada à natureza do trabalho.

A pergunta central é sempre uma versão de: onde o tempo vai?. Se a maior parte do tempo é gasta esperando — rede, banco, disco, API externa, um humano clicando — você tem um problema I/O-bound, e os modelos cooperativos (async/await, goroutines, asyncio) são o caminho. Se a maior parte é gasta calculando — comprimir vídeo, treinar modelo, fazer parse pesado, criptografar grandes volumes — você tem um problema CPU-bound, e a única forma de acelerar é dividir trabalho entre cores reais (paralelismo). A confusão entre os dois é a causa mais comum de decisões arquiteturais que envelhecem mal.

Pike, em sua palestra de 2012 que o módulo abriu citando, não estava sendo pedante: a distinção concorrência vs paralelismo organiza essa decisão. Concorrência é o modelo de programa (estrutura). Paralelismo é o uso de hardware (execução). Para I/O-bound, concorrência sem paralelismo (asyncio em Python, Node, single-thread async) já entrega a maior parte do ganho. Para CPU-bound, você precisa de fato de paralelismo (multi-core em uso simultâneo). Tentar resolver CPU-bound com asyncio é desperdício; tentar resolver I/O-bound massivo com threads do SO é desperdício no sentido oposto.

Este conceito de fechamento traz a árvore de decisão completa. Como medir natureza do trabalho. Como escolher modelo. Como combinar modelos quando o problema é misto (a maioria dos casos reais). E como avaliar trade-offs entre simplicidade, performance e custo operacional.

I/O-bound vs CPU-bound — definição operacional

A distinção formal é simples: trabalho é I/O-bound se o tempo de execução é dominado por espera em recursos externos; CPU-bound se é dominado por processamento que mantém o processador ocupado. Operacionalmente, a fronteira fica clara quando você profila. Em CPU-bound, a função roda quase 100% de CPU enquanto executa. Em I/O-bound, a função passa a maior parte do wall-clock parada — bloqueada em syscall ou aguardando rede.

Exemplos típicos de I/O-bound:

Exemplos típicos de CPU-bound:

Casos mistos são o padrão em sistemas reais. Servidor que recebe JSON (I/O), parseia (CPU breve), valida (CPU breve), consulta banco (I/O), faz cálculo (CPU médio), retorna (I/O). Sob carga, qual fase domina? Tipicamente uma — o gargalo. E essa fase determina escolha de modelo.

Como medir — a escolha não é por intuição

Sêniores não decidem por intuição. Eles medem. Os instrumentos certos para classificar trabalho:

CPU usage durante a operação

Em Linux, top -H -p PID mostra CPU per-thread em tempo real. Se uma thread gasta 100% de um core enquanto roda, é CPU-bound. Se gasta 5% e está sleeping/waiting o resto, é I/O-bound. htop com H ligado mostra o mesmo visualmente.

Profilers

perf em Linux: perf record + perf report mostra distribuição de CPU. Função que aparece como consumindo muito CPU é candidata a CPU-bound. Função que aparece como negligível em CPU mas o programa demora muito é I/O-bound. Linguagens têm equivalentes: py-spy (Python), pprof (Go), Visual Studio Profiler (C#), async-profiler (Java). Flame graphs (Brendan Gregg) tornam o resultado visualmente óbvio.

Wall-clock vs CPU-time

Trick útil: cronometrar a operação duas vezes — wall-clock (time.Now, time.perf_counter) e CPU-time (os.process_time, ou subtração de getrusage). Se wall-clock >> CPU-time, é I/O-bound (o tempo extra foi gasto bloqueado). Se são próximos, é CPU-bound.

# Python
import time, os

def medir(operacao):
    wall_start = time.perf_counter()
    cpu_start = time.process_time()
    operacao()
    wall = time.perf_counter() - wall_start
    cpu = time.process_time() - cpu_start
    print(f"wall: {wall:.2f}s  cpu: {cpu:.2f}s  ratio: {cpu/wall:.1%}")

medir(buscar_url)        # wall: 0.5s, cpu: 0.01s, ratio: 2%   → I/O-bound
medir(comprimir_video)   # wall: 30s, cpu: 29s, ratio: 97%     → CPU-bound

Heurística rápida

Para classificar sem instrumentação completa: pergunte "se eu rodar isso em uma máquina com 100x mais CPU, fica 100x mais rápido?". Se sim (no limite teórico), é CPU-bound. Se a resposta é "não, ainda demora porque depende de rede/disco", é I/O-bound. Em casos mistos, "rodaria 2-3x mais rápido" — você tem CPU-bound parcial.

Escolha de modelo — uma escada

A escolha cai em quatro perguntas em sequência. Cada resposta elimina algumas opções e aponta outras.

1. Há concorrência genuína a explorar?

Se o problema é estritamente sequencial (cada passo depende do anterior), concorrência não ajuda. Adicionar threads/async em problema sequencial só adiciona overhead. Resposta: código síncrono normal.

2. Trabalho é I/O-bound ou CPU-bound?

Esta é a pergunta de classificação que dominou este conceito até aqui. Tipicamente clara após medição de CPU usage / wall vs CPU time.

3. Quantas operações simultâneas?

Em I/O-bound: dezenas? threads tradicionais bastam. Centenas? pool de threads ou async começa a fazer diferença. Milhares ou milhões? só modelos cooperativos cabem (asyncio, goroutines, virtual threads, actors). A escala muda a resposta.

Em CPU-bound: a escala é limitada por número de cores. 8 cores = no máximo 8 unidades de paralelismo útil para CPU pura. Adicionar mais workers só introduz contention.

4. Há requisitos especiais?

Real-time? sistema distribuído? localidade transparente? cada um pesa modelos diferentes (lock-free para real-time; actor model para distribuído com identidade). Casos especiais escolhem ferramenta especial.

Modelos por categoria de trabalho

I/O-bound, alta concorrência (10K+ operações)

Caso clássico: web server, scraper, gateway, proxy. Modelos cooperativos vencem por economia de memória e overhead. asyncio em Python, async/await em C#/Node/Rust, goroutines em Go, virtual threads em Java Loom. Para 100K conexões em servidor, threads tradicionais do SO custariam ~800 GB de stack virtual; asyncio cabe em poucos MB. A diferença é arquitetural.

I/O-bound, baixa concorrência (dezenas)

Worker que processa jobs com chamadas a APIs externas, em paralelismo modesto. Aqui qualquer modelo serve — threads tradicionais, async, goroutines. Não há ganho mensurável de modelos cooperativos em escala pequena. Escolha por ergonomia da linguagem ou ecossistema.

CPU-bound em multi-core

Compressão, encoding, ML inference, simulação. Dividir o trabalho entre threads do SO em cores físicos (não lógicos — hyperthreading não ajuda em CPU puro). O modelo é thread pool com tamanho ≈ cores físicos. Parallel.ForEach em C#, multiprocessing.Pool em Python, rayon em Rust. Em Go, goroutines com runtime.NumCPU() e channels. Async/await aqui é contraproducente — você só serializa CPU-bound em um thread.

CPU-bound em Python — caso especial

O GIL torna threading de Python ineficaz para CPU-bound. As opções:

  1. multiprocessing: fork de processos separados, cada um com GIL próprio. Comunicação custosa via IPC (pickle).
  2. extensões C com nogil: NumPy, SciPy, Pandas, Polars liberam GIL em código nativo. Multi-thread em Python pode acelerar se a hot path está em código C que solta GIL.
  3. Python 3.13 free-threaded (no-GIL): experimental desde out/2024. Em 2026, ecosystem ainda se adapta. Para código novo greenfield, vale considerar; para legacy, espere maturidade.
  4. Cython com nogil: escrever hot path em Cython e marcar nogil.
  5. Mude de linguagem para o hot path: Python para a aplicação, Rust ou Go para a parte CPU pesada, invocada via FFI ou serviço. Padrão comum em ML serving.

Mixed — combinando modelos

Sistemas reais raramente são puramente um ou outro. Servidor web que processa JSON pesado: I/O-bound no recebimento + CPU-bound no parse + I/O-bound no banco. A solução é combinar modelos:

Escolha de runtime — qual linguagem para qual problema

Não é só sobre o modelo dentro de uma linguagem; é sobre qual linguagem escolher quando o problema permite escolha. Em 2026, o cenário pragmático:

Go

Goroutines + scheduler M:N gratuito. I/O e CPU em mesmo modelo, sem distinção sintática. Asincronicidade implícita; sem "cor de função". Excelente para ambos. Usado por toda infraestrutura cloud-native (Kubernetes, Docker, Cloudflare). Para sistema híbrido novo greenfield com requisitos altos de concorrência, Go é candidato natural.

Java + Loom (JDK 21+)

Virtual threads desde 2023 colocam Java no nível de Go para I/O-bound massivo. Threads tradicionais ainda existem para CPU-bound onde paralelismo de cores é o que importa. Ecosystem maduro, JVM excelente em CPU-bound. Para sistemas com base Java existente, Loom torna concorrência muito mais fácil sem reescrita.

C# / .NET

async/await idiomático para I/O. Parallel, Task.Run, Parallel.ForEachAsync para CPU. ThreadPool gerenciado é excepcional. .NET 8+ e CoreCLR estão entre as runtimes mais rápidas em geral. Forte para backend de aplicação misto.

Python

Forte para I/O via asyncio (FastAPI, aiohttp, asyncpg). Limitado em CPU-bound puro pelo GIL — mitigado por NumPy/Polars em data, multiprocessing em geral, ou no-GIL em 3.13+. Para científico, ML, scripting, data engineering, é primeira escolha mesmo com as limitações. Para sistemas onde CPU domina e escalar é crítico, considere outra linguagem para o hot path.

Node.js

Excelente em I/O via event loop libuv. CPU-bound exige worker_threads separados. Ecossistema rico para web. Para microsserviços I/O-bound (gateways, BFFs), continua sólido em 2026.

Rust + tokio

Async/await com runtime tokio para I/O. Threads + Rayon para CPU. Sem GIL, sem GC pauses, performance previsível. Curva de aprendizagem mais íngreme mas resultado é o mais previsível em latência tail. Para sistemas onde performance é diferenciador, Rust é a escolha mais comum em 2026 (Cloudflare Workers, Discord, partes de Linux kernel).

O mesmo problema misto nas três linguagens

Para concretizar combinação de modelos, considere o cenário: receber lista de URLs de imagens, baixar cada uma (I/O), aplicar filtro de processamento (CPU), salvar resultado (I/O). I/O-bound no fetch e save; CPU-bound no filtro. Volume: 1000 imagens.

C# — async para I/O + Task.Run para CPU
using System.Net.Http;

async Task ProcessarImagensAsync(string[] urls, CancellationToken ct) {
    using var http = new HttpClient();
    var paraleloIO = 50;     // alto para I/O
    var paraleloCPU = Environment.ProcessorCount;  // = cores físicos

    var sem = new SemaphoreSlim(paraleloIO);
    var cpuOpts = new ParallelOptions {
        MaxDegreeOfParallelism = paraleloCPU,
        CancellationToken = ct
    };

    await Parallel.ForEachAsync(urls, cpuOpts, async (url, ct2) => {
        await sem.WaitAsync(ct2);
        byte[] bytes;
        try {
            bytes = await http.GetByteArrayAsync(url, ct2);
        } finally {
            sem.Release();
        }

        // Aplicar filtro em thread pool (CPU-bound)
        byte[] filtrado = await Task.Run(() => AplicarFiltro(bytes), ct2);

        await SalvarAsync(filtrado, ct2);
    });
}

Parallel.ForEachAsync com MaxDegreeOfParallelism = cores limita o paralelismo CPU (filtro). SemaphoreSlim separadamente limita concorrência I/O para 50. Task.Run joga o filtro CPU-bound para o ThreadPool, evitando bloquear o context async do download.

Python — asyncio para I/O + ProcessPoolExecutor para CPU
import asyncio
import aiohttp
from concurrent.futures import ProcessPoolExecutor
import os

async def processar_imagens(urls: list[str]):
    paralelo_io = 50
    paralelo_cpu = os.cpu_count() or 4
    sem_io = asyncio.Semaphore(paralelo_io)

    loop = asyncio.get_running_loop()
    pool = ProcessPoolExecutor(max_workers=paralelo_cpu)

    async with aiohttp.ClientSession() as session:
        async def processar(url):
            async with sem_io:
                async with session.get(url) as resp:
                    bytes_ = await resp.read()
            # Filtro CPU-bound em processo separado (escapa GIL)
            filtrado = await loop.run_in_executor(
                pool, aplicar_filtro, bytes_)
            await salvar(filtrado)

        async with asyncio.TaskGroup() as tg:
            for url in urls:
                tg.create_task(processar(url))

    pool.shutdown()

ProcessPoolExecutor escapa o GIL fazendo fork — cada filtro roda em processo separado com Python próprio. Custo: serialização (pickle) para passar dados. Em workload onde o tempo de CPU é grande comparado ao tempo de serialização, ganho é claro. Asyncio orquestra os I/O.

Go — goroutines + canais limitados (M:N gratuito)
package main

import (
    "context"
    "io"
    "net/http"
    "runtime"
    "golang.org/x/sync/errgroup"
)

func processarImagens(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)

    // Semáforo para I/O (limita conexões simultâneas)
    semIO := make(chan struct{}, 50)
    // Semáforo para CPU (limita filtros simultâneos)
    semCPU := make(chan struct{}, runtime.NumCPU())

    for _, url := range urls {
        url := url
        g.Go(func() error {
            // Reserva slot I/O
            select {
            case semIO <- struct{}{}:
            case <-ctx.Done():
                return ctx.Err()
            }
            req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
            resp, err := http.DefaultClient.Do(req)
            <-semIO
            if err != nil { return err }
            defer resp.Body.Close()
            bytes, _ := io.ReadAll(resp.Body)

            // Reserva slot CPU
            select {
            case semCPU <- struct{}{}:
            case <-ctx.Done():
                return ctx.Err()
            }
            filtrado := aplicarFiltro(bytes)
            <-semCPU

            return salvar(ctx, filtrado)
        })
    }
    return g.Wait()
}

Em Go o mesmo modelo de goroutines serve para I/O e CPU — o runtime distribui em GOMAXPROCS kernel threads automaticamente. Channels usados como semáforos limitam concorrência separadamente para I/O (50) e CPU (= cores). errgroup propaga cancelamento se qualquer goroutine falha.

Anti-padrões na escolha de modelo

"Mais threads = mais rápido"

Adicionar threads sem entender a natureza do trabalho. Em I/O-bound, depois de um ponto, não ajuda mais (limite é externo). Em CPU-bound, depois do número de cores, prejudica (context switching). Sempre meça throughput vs número de workers.

Async para CPU-bound

Aplicação Node ou asyncio que faz hash de senha (bcrypt) no handler async. O hash bloqueia o event loop por centenas de ms; todas as outras requests param. Solução: mover para worker pool (workers separados em Node, ProcessPoolExecutor em Python). Sintoma: latência tail explodindo enquanto CPU mostra um core em 100%.

Threads para I/O-bound massivo

Servidor Java pré-Loom com pool de 10.000 threads tentando atender 10.000 conexões. 80 GB de stack reservado, scheduler do SO escalando ruim. Em Java moderno (21+), virtual threads resolvem; em outras stacks, async cooperativo já era o caminho certo desde sempre.

multiprocessing onde threading bastaria

Em Python, multiprocessing é caro: fork, pickle entre processos, sem memória compartilhada barata. Para I/O-bound, asyncio ou threading é dramaticamente mais leve. Use multiprocessing apenas quando a escolha é justificada por CPU-bound + GIL.

Distribuir prematuramente

Quebrar em microsserviços por "escalabilidade" antes de esgotar concorrência em uma máquina. Uma máquina moderna (16-128 cores, 64-512 GB de RAM) atende muito mais do que se imagina. Distribuição introduz problemas (latência de rede, falhas parciais, consenso) que não existiam. Distribua quando uma máquina não basta — não antes.

armadilha cultural

Adotar o modelo da moda em vez do adequado. "Microsserviços" em 2018, "serverless" em 2020, "edge computing" em 2024 — cada época tem padrões que viram resposta automática para qualquer problema, e o resultado é overhead onde não precisa. A pergunta certa não é "qual a tecnologia mais moderna?", é "qual o tipo de trabalho e qual modelo serve a ele?". Sêniores resistem ao mimetismo arquitetural.

A árvore de decisão consolidada

Sumarizando o módulo todo em uma sequência de perguntas:

  1. O problema é genuinamente concorrente? Se cada passo depende do anterior, escreva síncrono. Se há passos independentes, vale concorrência.
  2. Tempo é dominado por espera ou por cálculo? Meça com profile ou wall-clock vs CPU-time.
  3. I/O-bound: escolha modelo cooperativo (asyncio, async/await, goroutines, virtual threads). Dimensione pool/concorrência alto (centenas a milhares).
  4. CPU-bound: escolha thread/process pool com tamanho = cores físicos. Em Python, considere multiprocessing ou nogil.
  5. Misto: combine. Modelo cooperativo principal + pool dedicado para fases CPU-bound.
  6. Estrutura do problema:
    • Fluxo de dados → CSP / channels (Go, Channels em .NET).
    • Identidade longa → actor model (Erlang, Akka, Orleans).
    • Padrões de pipeline → workers + queues bounded.
  7. Cancelamento e timeout: propagação end-to-end via contexto. Estrutura léxica (TaskGroup, errgroup) facilita.
  8. Backpressure: queues bounded em todo lugar. Estratégia explícita quando enchem.
  9. Estado compartilhado: minimizar. Quando inevitável, primeiro tente imutabilidade ou channels; depois mutex; só então atomics; lock-free só com forte justificativa medida.
  10. Hardware-aware quando importa: teste em ARM se rodar em ARM (memory model). Considere NUMA em servers grandes. Cache locality em hot paths.

Sumário do módulo — o que você aprendeu

Treze conceitos antes deste, você cobriu: a distinção conceitual fundamental (concorrência vs paralelismo), fundamentos de sistema (threads, processos, scheduler), a teoria por trás (coroutines, fibers), modelos de programação (async/await, structured concurrency, CSP, actors), sincronização (locks, atomics, memory model), e padrões operacionais (workers, pipelines, backpressure, cancelamento). Cada conceito foi um ângulo do mesmo problema central: como múltiplos fluxos cooperam sem se atropelar.

A síntese: concorrência é sobre estrutura, paralelismo é sobre execução; modelos cooperativos são apropriados para I/O massivo e modelos preemptivos para CPU paralelizável; estado compartilhado é a fonte primária de complexidade e deveria ser minimizado; cancelamento, timeout e backpressure são tão importantes quanto o modelo principal de concorrência. Você agora tem vocabulário, ferramentas, e modelos mentais para escolher e implementar com consciência.

O próximo módulo (05) entra em Aspect-Driven & Cross-cutting Concerns: como organizar logging, autenticação, retry, circuit breaker, métricas — coisas que aparecem em todo lugar no código sem caber bem em OOP nem em funcional. Junto com a base de concorrência consolidada aqui, formará a infraestrutura conceitual para discutir resiliência, escalabilidade e operação que vêm nos módulos seguintes.

Como praticar

  1. Classifique três sistemas que você usa. Pegue três programas/serviços que você toca: classifique cada um como I/O-bound, CPU-bound ou misto. Que modelo usam? Está correto? Se você tivesse que reescrever, manteria? Documente suas conclusões em uma página.
  2. Profile uma operação ambígua. Pegue uma função em sistema seu sobre a qual você não tem certeza (tipo "esse handler é I/O-bound porque chama banco, mas também faz parsing pesado"). Profile com flame graph (pprof, py-spy, async-profiler). Identifique o gargalo. Documente: qual fração é CPU, qual é wait, qual é I/O. Surpresa é frequente.
  3. Implemente o mesmo programa em três modelos. Tarefa: 1.000 chamadas HTTP a endpoint público (I/O-bound) + compressão gzip de cada resposta (CPU-bound moderado). Versão A: async puro. Versão B: thread pool puro. Versão C: misto (async para I/O, pool dedicado para CPU). Meça wall clock, memória, CPU usage. Plote os três. Vai descobrir que a versão mista vence em throughput e a versão async pura é a mais econômica em memória — trade-offs em ação.

Referências para aprofundar

  1. livro Systems Performance — Brendan Gregg (2ª ed., 2020). Tratado canônico de performance em Linux moderno. Métodos de medição (USE, RED), profiling, identificação de gargalos. Aplica-se diretamente à classificação I/O vs CPU.
  2. livro The Go Programming Language — Donovan & Kernighan (2015). Caps. 8 e 9 cobrem concorrência. Boa referência consolidada para padrões idiomáticos discutidos ao longo do módulo.
  3. livro Concurrency in C# Cookbook — Stephen Cleary (2ª ed., 2019). Receituário de padrões mistos em .NET. Cobertura completa de I/O-bound vs CPU-bound em código async.
  4. livro Designing Data-Intensive Applications — Martin Kleppmann (2017). Visão sistêmica para sistemas em escala. Caps. sobre stream processing e replicação são extensão natural deste módulo.
  5. artigo Concurrency is not Parallelism — Rob Pike (transcrição, 2012). go.dev/blog/waza-talk — Onde o módulo começou. Vale reler agora, com o vocabulário completo, para apreciar a clareza do argumento original.
  6. artigo The C10K Problem — Dan Kegel (1999, atualizado). kegel.com/c10k.html — O artigo que motivou décadas de evolução em servidores I/O-bound. Histórico mas formativo.
  7. artigo The C10M Problem — Robert Graham (2013). robertgraham.wordpress.com — Sucessor moderno: 10 milhões de conexões em uma máquina. Mostra como o limite saiu do SO e foi para arquitetura de aplicação.
  8. artigo Go's work-stealing scheduler — Dmitry Vyukov. docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw — Como Go escolhe entre I/O e CPU automaticamente em runtime.
  9. artigo State of the Loom — Ron Pressler (2024). cr.openjdk.org/~rpressler/loom/loom — Justificativa de virtual threads, com benchmarks comparando I/O-bound massivo entre modelos.
  10. docs Site Reliability Engineering — Handling Overload. sre.google/sre-book/handling-overload — Cap. 21. Operação em escala: como Google decide modelos e capacity planning.
  11. vídeo Choose Boring Technology — Dan McKinley (2015). YouTube. Não específico de concorrência, mas o argumento se aplica diretamente: escolha o modelo que você entende e que tem ferramentas maduras, não o mais moderno por modernidade.
  12. vídeo Three Decades of Concurrency — Doug Lea (Strange Loop). YouTube. Doug Lea é arquiteto de java.util.concurrent. Retrospectiva de evolução de modelos, com perspectiva histórica que organiza o módulo todo.