Skip to main content

Command Palette

Search for a command to run...

A Saga do Assincronismo: Do Callback Hell ao Async/Await

Updated
6 min read
A Saga do Assincronismo: Do Callback Hell ao Async/Await
M
6+ years of experience focused on Backend Development (.NET) for mission-critical systems, banking, and judicial data. Specialist in the Microsoft ecosystem with MCSA and MCSD certifications, plus additional certifications in Azure, AWS, and GCP. Focused on C# API and microservices development (.NET Core to .NET 10), emphasizing Background Services (Workers), messaging, and Batch processing.

“Esquerda: Garantia de emprego. Direita: Tédio legível.”

Entrei na área no início da popularização do async/await. Isso me colocou de frente com sistemas híbridos, onde tive que aprender na marra como migrar do sistema velho para o novo.

Olhando para trás, foi a melhor escola possível. O açúcar sintático de hoje é excelente para a legibilidade, mas perigoso para o aprendizado: ele passa uma falsa sensação de simplicidade que impede muitas pessoas de compreender a engenharia real por trás do código.

Se o objetivo é apenas entregar a feature, a abstração basta. Mas é importante entender o runtime de verdade, e para isso é necessário conhecer a Task Parallel Library (TPL). O async/await não a substituiu e vocês verão ao longo do artigo o porquê.


A “Idade da Pedra”: O Modelo APM e o Callback Hell

Antes da classe Task (introduzida no .NET 4.0), o modelo padrão para operações assíncronas era o APM (Asynchronous Programming Model), baseado nos métodos Begin... e End....

Esse modelo introduziu um problema clássico: o Callback Hell.

Isso acontece quando o fluxo de execução é controlado exclusivamente por callbacks aninhados. Para encadear o próximo passo, você precisa escrever o código dentro do callback do passo anterior.

Hadouken!!!

Resumindo: O código funciona, mas a manutenção é um pesadelo, a indentação foge do monitor e o tratamento de erros é fragmentado.

public static void ExecutarApmSimulado()
{
    Console.WriteLine("[APM] Iniciando Pipeline...");

    BeginDownloadAPM((resultadoDownload) =>
    {
        Console.WriteLine($"[APM Callback 1] Baixado: {resultadoDownload}");

        BeginDescriptografarAPM(resultadoDownload, (resultadoDecript) =>
        {
            Console.WriteLine($"[APM Callback 2] Limpo: {resultadoDecript}");

            BeginSalvarBancoAPM(resultadoDecript, (resultadoBanco) =>
            {
                Console.WriteLine($"[APM Callback 3] Salvo ID: {resultadoBanco}");
                Console.WriteLine("[APM] Fim do sofrimento.");
            });
        });
    });

    // Pausa para o console não encerrar antes dos callbacks
    Thread.Sleep(2000);
}

A introdução da TPL: “faça isso, e se der bom, faça aquilo”

A TPL surgiu para acabar com essa loucura. A ideia era simples: representar uma operação assíncrona como um objeto (Task) e permitir encadear o próximo passo de forma declarativa usando ContinueWith.

Isso resolveu o problema de passar estado manualmente, mas trouxe um novo vilão para quem não dominava o modelo: a “Matrioska” de Tasks.

Parabéns, a recompensa por terminar sua tarefa é… outra tarefa.


O problema da “Matrioska” (TPL sem Unwrap)

Se uma expressão lambda retorna Task<string> e o ContinueWith empacota isso em outra Task, o resultado não é uma string, mas sim um Task<Task<string>>.

Você acaba com um handle para a tarefa que criou a tarefa, e não para a operação em si. Na prática, isso gera aberrações como Result.Result.

public static void ExecutarTplSemUnwrap()
{
    Console.WriteLine("[TPL-SemUnwrap] Iniciando (Prepare-se para .Result.Result)...");

    Task<Task<Task<int>>> tarefaMatrioska =
        Task.Run(() => DownloadAsync())
            .ContinueWith(t1 =>
            {
                Console.WriteLine($"[TPL-SemUnwrap] Passo 1 OK: {t1.Result}");

                return DescriptografarAsync(t1.Result).ContinueWith(t2 =>
                {
                    Console.WriteLine($"[TPL-SemUnwrap] Passo 2 OK: {t2.Result}");

                    return SalvarNoBancoAsync(t2.Result).ContinueWith(t3 =>
                    {
                        return t3.Result;
                    });
                });
            });

    try
    {
        var nivel2 = tarefaMatrioska.Result;
        var nivel3 = nivel2.Result;
        int idFinal = nivel3.Result;

        Console.WriteLine($"[TPL-SemUnwrap] Final: {idFinal}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"[Erro]: {ex.Message}");
    }
}

Escapando com .Unwrap()

Para transformar essa árvore de Tasks em um pipeline linear, usamos o método de extensão .Unwrap(). Ele "funde" a task externa com a interna, fazendo Task<Task<T>> virar apenas Task<T>.

Isso permite encadear múltiplos ContinueWith no mesmo nível de indentação. O código volta a crescer para baixo, e não para os lados.

{
    Console.WriteLine("[TPL-Unwrap] Iniciando Pipeline Fluente...");

    DownloadAsync()
        .ContinueWith(t =>
        {
            VerificarErro(t);
            Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] Baixado: {t.Result}");
            return DescriptografarAsync(t.Result);
        })
        .Unwrap()
        .ContinueWith(t =>
        {
            VerificarErro(t);
            Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] Limpo: {t.Result}");
            return SalvarNoBancoAsync(t.Result);
        })
        .Unwrap()
        .ContinueWith(t =>
        {
            if (t.IsFaulted)
                Console.WriteLine($"[FALHA FATAL]: {t.Exception?.InnerException?.Message}");
            else
                Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] SUCESSO! ID Banco: {t.Result}");
        })
        .Wait(); // Apenas para segurar o Console App
}

O Controle Manual (Que Ninguém Sente Falta)

Linearizar o código foi um avanço, mas ainda havia arestas cortantes.

O “If/Else” Assíncrono (TaskContinuationOptions)

Antes do await, como fazíamos para bifurcar o fluxo? Ou seja, como dizer "se der certo faça X, se der erro faça Y"?

A thread não estava bloqueada, então não podíamos usar if simples. A solução era o enum TaskContinuationOptions. Isso permitia “pendurar” múltiplas continuações na mesma task pai: uma para sucesso (OnlyOnRanToCompletion) e outra para falha (OnlyOnFaulted).

Era um if/else verboso e propenso a erros:

public static void ExecutarComOptions()
{
    Console.WriteLine("[Options] Iniciando Pipeline com Download Instável...");

    Task<string> downloadTask = Task.Run(() =>
    {
        Thread.Sleep(500);
        if (new Random().Next(0, 2) == 0)
            throw new InvalidOperationException("Falha ao ler arquivo de origem.");

        return "DADOS_CRIPTOGRAFADOS_V1";
    });

    downloadTask.ContinueWith(t =>
    {
        string erro = t.Exception?.InnerException?.Message ?? "Erro desconhecido";
        Console.WriteLine($"[Options] ERRO NO DOWNLOAD: {erro}");
    }, TaskContinuationOptions.OnlyOnFaulted);

    downloadTask
        .ContinueWith(t =>
        {
            Console.WriteLine($"[Options] Arquivo carregado: {t.Result}");
            return DescriptografarAsync(t.Result);
        }, TaskContinuationOptions.OnlyOnRanToCompletion)
        .Unwrap()
        .ContinueWith(t =>
        {
            VerificarErro(t);
            Console.WriteLine($"[Options] Conteúdo processado: {t.Result}");
            return SalvarNoBancoAsync(t.Result);
        })
        .Unwrap()
        .ContinueWith(t =>
        {
            if (t.IsFaulted)
                Console.WriteLine($"[Options] ERRO FINAL: {t.Exception?.InnerException?.Message}");
            else
                Console.WriteLine($"[Options] SUCESSO! Registro salvo. ID: {t.Result}");
        })
        .Wait();
}

Controle de contexto: Quando a thread importa

Em aplicações Desktop legadas (WinForms, WPF), a thread de UI não aceita interferência externa. Se você tentasse atualizar um TextBox de dentro de um Task.Run, recebia a clássica exceção “Cross-thread operation not valid

Com await, o SynchronizationContext é capturado automaticamente. Com ContinueWith, isso era trabalho manual:

var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
    Thread.Sleep(500);
    return "Arquivo processado com sucesso";
})
.ContinueWith(t =>
{
    txtStatus.Text = t.Result;
}, uiScheduler);

Coordenação: esperando tudo sem travar a thread

Nem todo fluxo é linear. Muitas vezes disparamos várias operações e queremos reagir quando todas terminarem. O WaitAll bloqueava a thread (inaceitável em UI). A solução da TPL era o ContinueWhenAll.

Task<string> apiUsuario = Task.Run(() => "Dados do Usuário");
Task<string> apiPermissoes = Task.Run(() => "Permissões");

Task.Factory.ContinueWhenAll(
    new[] { apiUsuario, apiPermissoes },
    tasks =>
    {
        foreach (var task in tasks)
        {
            Console.WriteLine($"Módulo carregado: {task.Result}");
        }

        Console.WriteLine("Sistema pronto.");
    });

Isso é o avô manual do await Task.WhenAll.

O Vilão Oculto: **AggregateException**

Por fim, o tratamento de erros. Na TPL pura, quando uma Task falha, a exceção é embrulhada em uma AggregateException.

Se você usar um try/catch ao redor de um .Wait(), você não captura o erro real, mas sim esse contêiner. É necessário iterar sobre ex.InnerExceptions ou usar ex.Flatten() para descobrir o que realmente aconteceu.

public static void ExecutarComOVilao()
{
    Console.WriteLine("[Vilão] Iniciando tarefa que vai falhar...");

    try
    {
        Task.Run(() =>
        {
            Console.WriteLine("[Vilão] Download OK.");
            throw new InvalidOperationException("O arquivo está corrompido! (Erro Real)");
        })
        .Wait();
    }
    catch (AggregateException exAgregada)
    {
        Console.WriteLine($"[Capa do Vilão]: {exAgregada.Message}");

        var errosReais = exAgregada.Flatten();

        foreach (var erro in errosReais.InnerExceptions)
        {
            Console.WriteLine($"[A Verdade]: {erro.Message}");
            Console.WriteLine($"[Tipo]: {erro.GetType().Name}");
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Isso nunca imprime: {ex.Message}");
    }
}

O Futuro: Async e Await

O async/await que usamos hoje nada mais é do que uma máquina de estados gerada pelo compilador, que escreve esses ContinueWith, faz os Unwrap, propaga exceções corretamente (desembrulhando a AggregateException) e respeita o contexto de sincronização automaticamente.

Ele não elimina a TPL, ele a automatiza.

public static async Task ExecutarModernoAsync()
{
    Console.WriteLine("[Async/Await] Iniciando vida boa...");

    try
    {
        string baixado = await DownloadAsync();
        Console.WriteLine($"[Async] Baixado: {baixado}");

        string limpo = await DescriptografarAsync(baixado);
        Console.WriteLine($"[Async] Limpo: {limpo}");

        int id = await SalvarNoBancoAsync(limpo);
        Console.WriteLine($"[Async] Sucesso! ID: {id}");
     }
     catch (Exception ex)
     {
         Console.WriteLine($"[Async Erro]: {ex.Message}");
     }
}

Conclusão

O async/await automatizou três responsabilidades que antes eram explícitas e dolorosas:

  1. Controle de fluxo (Fim dos ContinueWith e .Unwrap())

  2. Controle de contexto (Gerenciamento automático de Threads de UI)

  3. Tratamento de erros (Morte à AggregateException manual)

Então, entender a TPL é entender o que realmente acontece nos bastidores quando você escreve await. Respeite a história, e aproveite o açúcar sintático.

Se quiser acompanhar mais conteúdos e projetos: