CodeGym /Cursos /C++ SELF /std::async — políticas de execução

std::async — políticas de execução

C++ SELF
Nível 71, Lição 0
Disponível

1. Por que usar std::async e std::future

Se você acabou de começar a trabalhar com threads, a reação natural ao std::async soa assim: "É mais uma forma de fazer a mesma coisa, só que diferente?". Sim e não. Thread é sobre "iniciar execução em algum lugar em paralelo". E std::async é sobre "iniciar um cálculo e me devolver uma alça pela qual eu recuperarei o resultado depois". Essa "alça" chama-se std::future.

Imagine uma cena do dia a dia: você pediu uma pizza. Thread é como "vou preparar a pizza na cozinha". std::async é como "fiz o pedido da pizza e recebi um número de pedido: com ele eu consulto o status e retiro o resultado depois". O número do pedido é o future. Você não precisa saber como a cozinha funciona por dentro — o importante é que o resultado virá e você pode obtê‑lo de forma controlada.

E uma diferença importante: std::async pode adiar a execução. Ou seja, você pode dizer: "não prepare a pizza agora — prepare quando eu for buscá‑la". Isso já é política de execução.

Assincronicidade é um contrato, não mágica

A palavra async dá a impressão de que tudo será necessariamente paralelo, rápido e bonito. Na prática, em C++ isso não é "mágica", é um contrato entre você e a biblioteca padrão: você dispara uma tarefa, e a biblioteca decide quando e onde executá‑la — dependendo da política escolhida.

Durante a padronização houve muitas discussões sobre nuances de comportamento e formulações relativas a std::async e itens relacionados — até sobre se certas partes da especificação podem ser implementadas da mesma forma em todas as bibliotecas padrão. Isso é um bom sinal: o tema não é trivial e não se deve "adivinhar" o comportamento pelo nome.

Portanto o objetivo aqui é prático: aprender a escrever código que não dependa de adivinhações, e entender quando std::async realmente executa o trabalho em paralelo e quando não.

2. Conceitos básicos e sintaxe

Mini-glossário: callable, tarefa, resultado, future

Antes de escrever código, vamos nomear os termos de forma simples.

Callable é "algo que pode ser chamado". Uma função comum é callable. Uma lambda é callable. Às vezes até um objeto com operator() é callable (mas não entraremos nessa profundidade hoje).

Tarefa é seu callable + seus argumentos: aquilo que você quer executar.

Resultado é o valor que o callable retorna (por exemplo, int), ou apenas o fato de conclusão (void).

std::future<T> é um objeto ligado a uma "célula de resultado" (frequentemente chamada shared state). Você obterá o valor mais tarde, quando estiver pronto.

Para std::async é importante guardar uma ideia: ele inicia o callable e retorna um future. Como exatamente ele inicia depende da política.

(Aliás, até nos relatórios e edições da especificação dá para ver que std::async tem seu lugar na seção de futures — não é uma utilidade secundária, mas parte integral do modelo de resultados.)

Sintaxe básica de std::async: "inicia e recebe um future"

Começamos com um exemplo mínimo: uma função soma dois números, mas a executamos via std::async.

#include <future>
#include <iostream>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::future<int> f = std::async(std::launch::async, add, 2, 3);
    std::cout << f.get() << '\n'; // 5
}

Repare em três pontos.

  • Primeiro — incluímos <future>, porque std::async e std::future vivem ali.
  • Segundo — passamos explicitamente a política std::launch::async. Por que especificar, se queremos async? Porque sem política você pode se enganar nas expectativas, e esse é o ponto central da palestra.
  • Terceiro — get() parece "ok, peguei o valor". Hoje não vamos aprofundar o ciclo de vida do future, mas é importante entender: get() é o ponto onde você realmente obtém o resultado, e se ele não estiver pronto você bloqueia.

3. Políticas de execução std::launch

Agora — a parte mais importante.

std::async aceita políticas de execução indicadas por std::launch.

std::launch::async

Essa política significa: "execute a tarefa de forma assíncrona". Na prática típica isso será uma thread separada (ou uma atividade assíncrona equivalente na implementação).

Ou seja, seu callable começará a executar imediatamente, e a thread principal continuará.

std::launch::deferred

Essa política significa: "adie a execução até que alguém solicite o resultado".

O ponto mais importante: com deferred a tarefa é executada na thread que chamou a espera (get() ou wait()), frequentemente — diretamente em main. Isso não é paralelismo. É execução preguiçosa.

Por que isso pode ser útil? Por exemplo, você quer descrever um cálculo, mas executá‑lo somente se ele realmente for necessário. É parecido com "vou gerar o relatório, mas só se você clicar em 'mostrar relatório'".

Demonstração: deferred — "depois e na mesma thread"

Vamos um exemplo que imprime a ordem dos eventos. Aqui nem precisamos de um resultado — o importante é ver quando ocorre a execução.

#include <future>
#include <iostream>

int main() {
    auto f = std::async(std::launch::deferred, [] {
        std::cout << "work\n";
        return 10;
    });

    std::cout << "before get\n";
    std::cout << f.get() << '\n';
}

Saída esperada:

// before get
// work
// 10

A ideia é: enquanto você não chamar get(), o "trabalho" nem começou.

"Quando é realmente async": a armadilha da política padrão

Aqui começa a parte em que o iniciante costuma dizer: "Eu chamei std::async, então é async!". E a biblioteca padrão responde: "Ha‑ha. Que gracinha".

Se você chama std::async sem política explícita, normalmente recebe uma escolha "ambas as opções são permitidas": a implementação pode executar a tarefa assincronamente ou adiá‑la. Na prática isso significa: em uma plataforma você verá paralelismo, em outra não, e em outra — "às vezes sim, às vezes não".

Por isso é útil adotar a regra: se paralelismo é essencial — declare std::launch::async explicitamente.

Vamos ilustrar isso com uma tarefa "artificialmente longa".

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slow() {
    std::this_thread::sleep_for(std::chrono::seconds{1});
    return 7;
}

int main() {
    auto f = std::async(std::launch::async, slow);
    std::cout << "doing something...\n";
    std::cout << f.get() << '\n'; // 7
}

Se aqui a política fosse deferred, "doing something..." poderia ser impresso, mas slow() só começaria no get(), resultando em uma "pseudo-assincronicidade": parece que você fez algo, mas nada foi calculado em paralelo.

Esquema visual: async vs deferred

Para tirar a mágica do processo, vamos fixar numa forma simples.

flowchart TD
    A[main: chama std::async] --> B{política de execução}
    B -->|launch::async| C[tarefa inicia imediatamente em outra thread/atividade]
    B -->|launch::deferred| D[tarefa NÃO inicia imediatamente]
    D --> E["main: depois chama get()/wait()"]
    E --> F[tarefa é executada na thread main]
    C --> G["main: depois chama get()/wait()"]
    G --> H[main obtém o resultado]
    F --> H

Esse esquema é seu "anti-misticismo". Se você o entende, não escreverá código no espírito "ah, deve paralelizar".

4. Resultados, tempo de vida e design

std::future<void>: quando o valor não importa, mas a conclusão importa

Às vezes você não precisa de um valor. Você só precisa aguardar que a tarefa tenha terminado. Ex.: "gravar log", "limpar cache", "fechar conexão corretamente".

Nesse caso é conveniente retornar void — e obter um std::future<void>.

#include <future>
#include <iostream>

void work() {
    // faz algo útil
}

int main() {
    std::future<void> f = std::async(std::launch::async, work);
    f.get(); // aguardamos a conclusão; sem valor
    std::cout << "done\n"; // done
}

Note que get() para future<void> é "aguardar e verificar que tudo está ok". Não entraremos em exceções de future hoje, mas a moral é: future<void> também é um "canal de resultado", só que o resultado é o fato de ter terminado com sucesso.

Argumentos e tempo de vida: por que async "de repente falha"

Agora um ponto importante que quebra iniciantes com mais frequência do que vírgulas em cout.

std::async inicia o trabalho "depois" ou "em algum lugar paralelo". Logo, tudo que for usado dentro da tarefa deve viver tempo suficiente.

Se você capturou uma referência para uma variável local e a função já terminou — a referência fica pendurada. Isso pode se manifestar como "crash aleatório", "lixo aleatório" ou "funciona no meu computador".

Estilo perigoso — capturar por referência sem garantia de tempo de vida:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [&] {
        return name.size();
    });

    return 0; // risco: a tarefa pode ainda ler name
}

Estilo mais seguro — capturar por valor:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [name] {
        return name.size();
    });

    (void)f.get();
}

Sim, é uma cópia. Mas para std::string a cópia é o preço da tranquilidade. Otimizações virão quando o código estiver correto (e quando houver necessidade clara).

Nuance de design: std::async não é "fire-and-forget"

O iniciante tem rápido a tentação: "Vou espalhar std::async pelo programa todo e tudo ficará rápido!". É como distribuir rádios para todos os funcionários e achar que a empresa ficou mais eficiente.

std::async pressupõe que você mantenha o future, ou seja, você tem um ponto onde ou pega o resultado, ou pelo menos aguarda a conclusão. É disciplina: se você "perde" o future, perde o controle sobre a tarefa.

Boas práticas: crie o future → guarde numa variável → posteriormente chame conscientemente get() (ou wait(), que veremos depois).

Pensamento para o futuro: future e a "célula de resultado" compartilhada

Hoje não vamos nos aprofundar no ciclo de vida do future, mas vale saber que internamente existe uma "célula de resultado" e a biblioteca padrão passou anos refinando como essa célula é criada e liberada. Até sobre future::get houve debate sobre o quão explicitamente deveria estar escrito que o estado compartilhado é "desanexado" e liberado.

Isso não é para assustar. É para você entender: future não é "apenas um int numa caixinha". É um objeto com ciclo de vida, e deve ser tratado com disciplina.

5. Exemplo prático: gerador de relatórios em console

Para que os exemplos não sejam um conjunto de "cavalos esféricos no vácuo", vamos montar um pequeno app que seja fácil de estender.

A ideia é simples: temos uma lista de "tasks" (não threads, mas tarefas do projeto), e queremos construir um "relatório" — uma operação cara (na prática: leitura, parsing, agregação). Enquanto o relatório é gerado, a thread principal pode continuar a fazer outras coisas (no exemplo de ensino — ao menos imprimir mensagens).

Passo 1: modelo de dados e função "cara"

#include <chrono>
#include <string>
#include <thread>
#include <vector>

struct Task {
    std::string title;
    int estimateHours;
};

int buildReport(const std::vector<Task>& tasks) {
    std::this_thread::sleep_for(std::chrono::milliseconds{300}); // simulando trabalho
    int total = 0;
    for (const auto& t : tasks) total += t.estimateHours;
    return total;
}

A função retorna um int: a soma de horas estimadas.

Passo 2: iniciar o relatório via std::async com política explícita

#include <future>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{"Parse logs", 5}, {"Generate PDF", 2}};

    auto futureTotal = std::async(std::launch::async, buildReport, std::cref(tasks));
    std::cout << "report started...\n";

    int total = futureTotal.get();
    std::cout << "total hours = " << total << '\n'; // total hours = 7
}

Aqui aparece um detalhe novo: std::cref(tasks). É uma forma cuidadosa de passar uma referência como argumento (em vez de copiar todo o vector). Se não tiver certeza, pode passar uma cópia — para tamanhos didáticos isso é aceitável. Mas mostro std::cref porque é comum e evita "arrastar um vagão" de dados.

Importante: especificamos explicitamente std::launch::async, porque hoje queremos aprender a não adivinhar se será paralelo.

Outra demonstração: dois cálculos em paralelo

Vamos a um exemplo que dispara dois cálculos. Mesmo sem cronometrar você verá a diferença de abordagem.

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slowCalc(int x) {
    std::this_thread::sleep_for(std::chrono::milliseconds{200});
    return x * 2;
}

int main() {
    auto a = std::async(std::launch::async, slowCalc, 10);
    auto b = std::async(std::launch::async, slowCalc, 20);

    std::cout << a.get() + b.get() << '\n'; // 60
}

Se ambas as tarefas realmente começarem em paralelo, o tempo total ficará mais próximo de 200 ms (mais overhead) do que de 400 ms. Se você usasse deferred, ambas só começariam no get(), e então quase certamente seriam "uma após a outra" (pois get() é chamado sequencialmente).

6. Erros típicos ao trabalhar com std::async e políticas de execução

Erro #1: usar std::async sem política e esperar paralelismo garantido.
Esse código pode "acidentalmente" rodar em paralelo na sua máquina e de repente virar adiado em outra. Se o paralelismo faz parte da lógica (ex.: você conta com sobreposição de I/O/CPU), declare explicitamente std::launch::async, para que o contrato fique claro para você e para o leitor do código.

Erro #2: pensar que std::launch::deferred é "executar após um tempo".
deferred não significa "espere 2 segundos e depois comece". Significa "não comece até que eu peça o resultado". Ou seja, o momento de início é definido não por um temporizador, mas pelo chamado get()/wait().

Erro #3: capturar por referência variáveis locais que podem morrer antes da tarefa.
Essa é a armadilha clássica de lambdas em código concorrente. Você olha a variável, ela parece viva e simpática, e um milissegundo depois main terminou, a variável sumiu e a tarefa ainda tenta acessá‑la. Se não houver garantia firme de tempo de vida — capture por valor ou passe dados de forma que o dono viva mais tempo.

Erro #4: "perder" o future e achar que a tarefa agora vive por conta própria.
std::async não é um detach. Se você não guarda o future, perde o ponto normal de sincronização e observação do resultado. Considere future parte obrigatória do contrato: iniciou a tarefa — guardou a alça — depois conscientemente esperou e/ou pegou o resultado.

Erro #5: tentar colocar tudo dentro de std::async, inclusive trabalho com estruturas de dados compartilhadas
std::async inicia um cálculo, mas não anula as regras de concorrência. Se dentro da tarefa você acessa dados compartilhados, continua precisando de acordos: quem é o dono, quem lê, onde está o mutex, onde usar condition_variable. std::async é uma forma de obter um resultado "depois", não um amuleto universal contra races.

1
Tarefa
C++ SELF, nível 71, lição 0
Bloqueado
Quadrados Assíncronos
Quadrados Assíncronos
1
Tarefa
C++ SELF, nível 71, lição 0
Bloqueado
Entrega adiada
Entrega adiada
1
Tarefa
C++ SELF, nível 71, lição 0
Bloqueado
Dois aceleradores
Dois aceleradores
1
Tarefa
C++ SELF, nível 71, lição 0
Bloqueado
Gravação em segundo plano
Gravação em segundo plano
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION