CodeGym /Cursos /C# SELF /Geradores síncronos e assíncronos em C# (

Geradores síncronos e assíncronos em C# ( yield)

C# SELF
Nível 62 , Lição 1
Disponível

1. Introdução

Imagine que você precisa processar um arquivo de logs gigante, com milhões de linhas, ou gerar uma sequência de números incrivelmente longa. Como você faria isso sem geradores?

O approach tradicional seria algo assim:


// Problema: gera toda a coleção de uma vez na memória
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // Retorna quando tudo estiver pronto
}

// Uso:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 milhão de números na memória de uma vez!
foreach (var num in myNumbers) { /* Processamento */ }

Quais são os problemas aqui?

  1. Consumo de memória: Se count for muito grande, toda a coleção é criada na memória, o que pode levar a OutOfMemoryException.
  2. Latency (atraso): O usuário ou a próxima parte do programa precisa esperar até que todos os dados sejam totalmente gerados e carregados na memória.
  3. Sequências infinitas: Essa abordagem simplesmente não funciona se a sequência for potencialmente infinita.

É aí que entram os geradores! Eles implementam o conceito de avaliação preguiçosa (Lazy Evaluation) e processamento em streaming. Em vez de gerar todos os dados de uma vez, o gerador produz elementos um por um, somente quando realmente necessários.

2. Fundamentos dos geradores

Em C# os geradores são criados usando a palavra-chave especial yield.

O que é um Gerador?

É um método, o bloco get de uma propriedade ou uma expressão que contém uma ou mais instruções yield return.

yield return

É o coração dos geradores. Quando o compilador encontra yield return:

  1. O elemento especificado depois de yield return é entregue ao código chamador.
  2. A execução do método-gerador é pausada, e seu estado atual (onde está no loop, valores das variáveis locais) é salvo.
  3. Na próxima solicitação de elemento (por exemplo, na próxima iteração do foreach), a execução do método é retomada do ponto onde foi pausada.

Tipo de retorno: O método-gerador deve retornar IEnumerable<T> ou IEnumerator<T>. O compilador vai gerar toda a "mágica" necessária.


// Exemplo 2.1: Gerador simples de números
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("Início da geração...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"Gerando: {i}");
        yield return i; // Pausa e retorna o elemento
    }
    Console.WriteLine("Geração finalizada.");
}

// Uso:
// Repare que "Início da geração..." aparece só na primeira iteração!
// E "Gerando: X" aparece a cada nova iteração.
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"Recebido no foreach: {num}");
}

yield break

Usado para terminar a iteração antecipadamente. Após yield break nenhum elemento será retornado. Se a execução chegar ao fim do método, um yield break explícito não é necessário.


// Exemplo 2.2: Gerador com condição de saída
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // Saímos do gerador
        }
        yield return item;
        count++;
    }
}

// Uso:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // Vai imprimir "Apple", "Banana"
// }

3. Máquina de estados

Como isso funciona "por baixo do capô"? Não tem mágica — só trabalho esperto do compilador.

Quando você escreve um método com yield, o compilador C# o transforma em uma classe que implementa IEnumerator<T> e IEnumerable<T>. Essa classe gerada é a máquina de estados.

  • Persistência de estado: a máquina guarda um número de estado (onde parou) e os valores de todas as variáveis locais no momento da pausa.
  • Iteração: ao iterar com foreach são chamados métodos MoveNext() e lida a propriedade Current. MoveNext() retoma a execução até o próximo yield return/yield break, e Current retorna o elemento atual.

Na prática o compilador implementa pra você o padrão Iterator.

4. Aplicações dos geradores

Exemplo: Processamento de grandes volumes de dados

Ler um arquivo linha a linha sem carregar tudo na memória.


// Simulação de leitura de um arquivo grande
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"Abrindo arquivo: {filePath}");
    // Em uma aplicação real aqui teria um StreamReader
    yield return "Linha de dado 1";
    yield return "Linha de dado 2";
    yield return "Linha de dado 3";
    Console.WriteLine("Terminei de simular a leitura do arquivo.");
}

// Uso:
Console.WriteLine("Início do processamento.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"Linha processada: {line}");
    if (line.Contains("2")) break; // Podemos parar quando quisermos
}
Console.WriteLine("Processamento finalizado.");

Repare que a mensagem final aparece só depois de terminar a iteração.

Exemplo: Sequências infinitas


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // Sequência potencialmente infinita
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// Uso:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // Precisamos limitar pra não travar
}

Exemplo: Pipelines de processamento

Criar cadeias de métodos onde cada etapa processa os dados "on the fly".


IEnumerable<int> GetNumbers()
{
    yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}

IEnumerable<int> FilterEven(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        if (num % 2 == 0) yield return num;
    }
}

IEnumerable<int> Square(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        yield return num * num;
    }
}

// Uso:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

Isso é bem parecido com como muitos operadores do LINQ funcionam (por exemplo, Where, Select, Take, Skip).

5. Geradores assíncronos

Geradores síncronos são ótimos, mas e se cada elemento da sequência exigir uma operação assíncrona (por exemplo, uma chamada de rede)? Antes do C# 8.0 isso era difícil de implementar.

Problema: Fluxos de dados assíncronos

Não podemos usar await dentro de um método-gerador síncrono.


// Isso NÃO VAI COMPILAR!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // Erro: await só pode em método async
    yield return "Hello";
}

Solução: IAsyncEnumerable<T> e await foreach

  • IAsyncEnumerable<T> — análogo assíncrono de IEnumerable<T>.
  • await foreach — sintaxe conveniente para iterar uma sequência assíncrona (internamente chama MoveNextAsync() e lida com o estado assíncrono).

async yield return

Agora você pode usar yield return dentro de um método async que retorna IAsyncEnumerable<T>. O compilador vai construir uma máquina de estados assíncrona.


// Exemplo 5.1: Gerador assíncrono de números
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("Início da geração assíncrona...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // Simula trabalho assíncrono (ex.: chamada de rede)
        Console.WriteLine($"Gerando assíncronamente: {i}");
        yield return i; // Retorna o elemento
    }
    Console.WriteLine("Geração assíncrona finalizada.");
}

// Uso:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("Início do processamento assíncrono...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"Recebido assíncronamente: {number}");
    }
    Console.WriteLine("Processamento assíncrono finalizado.");
}

// Execução:
await ConsumeAsyncNumbers(); // Chame isso a partir de um async Main ou contexto similar

IAsyncDisposable e await using (no contexto de geradores)

Se o gerador abre um recurso que é liberado de forma assíncrona (DisposeAsync()), use await using. Ao terminar um await foreach, o iterador interno terá DisposeAsync() chamado automaticamente se implementar IAsyncDisposable.


// Exemplo 5.2: Leitura assíncrona de arquivo com await using
// StreamReader real implementa IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[Gerador] Abrindo arquivo assincronamente: {filePath}");
    // await using garante chamada a DisposeAsync() após sair do bloco
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // Leitura assíncrona de linha
    {
        yield return line;
    }
    Console.WriteLine($"[Gerador] Terminei de ler o arquivo: {filePath}");
}

// Uso:
async Task ProcessFileAsync()
{
    Console.WriteLine("[Handler] Início do processamento de arquivo.");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // substitua por um caminho real
    {
        Console.WriteLine($"[Handler] Linha recebida: {line}");
        // Aqui dá pra fazer processamento assíncrono de cada linha
        await Task.Delay(50); 
    }
    Console.WriteLine("[Handler] Processamento do arquivo finalizado.");
}

// Execução:
await ProcessFileAsync(); // Chame isso a partir de async Main

Cancelamento de geradores assíncronos: CancellationToken

Adicione um CancellationToken aos geradores assíncronos para que o código chamador possa cancelar a geração.


// Exemplo 5.3: Gerador assíncrono com cancelamento
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // Checa o token de cancelamento
        await Task.Delay(100, token); // Task.Delay também suporta cancelamento
        yield return start + i;
    }
}

Uso


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // Deixa o gerador trabalhar um pouco
    cts.Cancel(); // Cancelamos!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"Recebido: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Geração cancelada.");
}

6. Limitações e quando usar com cuidado

Limitações do yield:

  • Não é permitido usar yield return em blocos try onde catch ou finally também contenham yield.
  • Métodos com yield não podem ser unsafe.
  • Não é possível usar yield em métodos async void (use async Task ou IAsyncEnumerable<T>).

Performance: Para coleções muito pequenas ou fixas, a sobrecarga da máquina de estados pode ser um pouco maior do que retornar diretamente um List<T>. Mas em cenários com muitos dados, o ganho da lazy evaluation e do streaming geralmente compensa muito mais.

Tratamento de erros: Exceções lançadas dentro do gerador vão alcançar corretamente o código chamador e podem ser capturadas lá como em métodos normais.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION