CodeGym /Cursos /C# SELF /Channel: productor–consumidor (

Channel: productor–consumidor ( Channel)

C# SELF
Nivel 62, Lección 3
Disponible

1. Introducción

Hoy subimos de nivel. Es hora de conocer una herramienta especial: Channel. Esto fue creado específicamente para aplicaciones asíncronas modernas en .NET: donde los bloqueos habituales o no ayudan, o te hacen perder mucho rendimiento en tu camino hacia la felicidad.

Te espera el patrón "Productor-Consumidor" (Producer-Consumer), que no pasa de moda desde los años 60 del siglo XX. Vas a crear una "línea de procesamiento" asíncrona sencilla, donde unas tareas o hilos producen cosas (por ejemplo, descargan archivos, calculan números, esperan eventos) y otras las procesan (por ejemplo, guardan, escriben en la base de datos, actualizan la UI).

¿Por qué nació Channel?

  1. El patrón "Productor-Consumidor" tradicionalmente se resolvía con colas: el productor pone tareas en la cola y el consumidor las saca. ¡Pero! BlockingCollection<T>, las colas con ConcurrentQueue<T>, incluso la sincronización manual con lock — todo eso no es asíncrono. Es decir, el hilo solo puede bloquearse esperando datos, en lugar de ceder el control al planificador async/await.
  2. La asincronía en .NET no es solo una palabra de moda los viernes, es la base de la arquitectura moderna. Bloquear hilos para esperar elementos es caro e ineficiente. Necesitamos poder esperar datos sin bloquear — esa es la tarea que resuelve Channel.
  3. Flexibilidad: con canales puedes construir pipelines complejos, separar la lógica entre tareas, añadir pasos intermedios y balanceo de carga — y todo eso sin pelear con sincronización de bajo nivel.

¿Qué es Channel? (Analogía y arquitectura)

Imagina que tienes un testigo de relevos (o una cinta transportadora) por la que puedes pasar objetos de un sitio a otro, sin necesidad de que los colegas se vean en persona. Lo importante es que el testigo no se pierda por el camino.

Channel es una herramienta integrada en .NET para la transferencia asíncrona de datos entre distintas tasks, hilos o partes del programa. Implementa una cola asíncrona que soporta "esperar" tanto para insertar como para extraer elementos.

  • Productor pone elementos en el canal (por ejemplo, peticiones a procesar);
  • Consumidor extrae elementos — ¡y asunto resuelto!

2. La clase Channel<T> y su estructura

Todo empieza con el namespace:

using System.Threading.Channels;

A diferencia de las colecciones habituales, Channel es una fábrica que crea objetos especiales para la transferencia de datos.

Tipos principales:

  • ChannelWriter<T> — el "writer" (productor). Solo inserta elementos.
  • ChannelReader<T> — el "reader" (consumidor). Solo extrae elementos.
  • El canal (Channel) separa responsabilidades: el writer no sabe nada del reader y viceversa.

En .NET hay varias implementaciones de canal, cada una con sus peculiaridades: unbounded (sin límite), bounded (con límite de elementos), single-producer-single-consumer (SPSC), multi-producer-multi-consumer (MPMC), etc. Empezaremos por la más versátil.

Ejemplo simple: cola de tareas asíncrona

using System;
using System.Threading.Channels;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        // Creamos un canal sin límite de tamaño
        var channel = Channel.CreateUnbounded<int>();

        // Tarea productora
        var producer = Task.Run(async () =>
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine($"Productor: Pone {i} en el canal");
                await channel.Writer.WriteAsync(i); // ¡Escritura asíncrona!
                await Task.Delay(100); // Simula trabajo
            }
            channel.Writer.Complete(); // Indicamos que ya no vamos a escribir más
        });

        // Tarea consumidora
        var consumer = Task.Run(async () =>
        {
            await foreach (var item in channel.Reader.ReadAllAsync())
            {
                Console.WriteLine($"Consumidor: Recibió {item} del canal");
                await Task.Delay(200); // Simula procesamiento
            }
            Console.WriteLine("Consumidor: El canal se cerró");
        });

        await Task.WhenAll(producer, consumer);
    }
}

¿Qué pasa aquí?

  • Channel.CreateUnbounded<int>() — creamos un canal sin límite de tamaño de la cola.
  • El productor escribe números de 0 a 9 en el canal usando WriteAsync.
  • Al terminar la escritura se llama a Complete() — señal "¡No habrá más elementos!".
  • El consumidor itera todos los elementos con ReadAllAsync() (también asíncrono), hasta que el canal se cierra.
  • Las esperas (Task.Delay) simulan trabajo real: verás que los números se escriben más rápido de lo que se leen.

3. ¿Por qué todo esto funciona de forma asíncrona?

Las colas bloqueantes tradicionales (por ejemplo, BlockingCollection o las protegidas por lock) solo pueden bloquear hilos. Y eso significa perder recursos valiosos si tienes muchas tareas o quieres la máxima eficiencia.

Con canales:

  • Si el productor es más rápido, el canal acumula elementos (limitado por la memoria o por un tamaño máximo, si se especifica).
  • Si el consumidor es más rápido, esperará hasta que haya algo (y no bloqueará el hilo, sino que lo devolverá al planificador).

Esto es ideal para escenarios donde no sabes de antemano quién será más rápido — productores o consumidores.

Uso en la vida real

  • Logging asíncrono: escribir mensajes en un archivo o base de datos desde un hilo separado;
  • Procesamiento de solicitudes web: una tarea descarga un lote de páginas y otra las analiza;
  • Escaneo e indexado de carpetas: unas tareas recorren el sistema de archivos y otras calculan estadísticas;
  • Pipelines de procesamiento complejos: por ejemplo en ETL (Extract–Transform–Load), un paso convierte materia prima en semiprocesados y otro los convierte en producto final.

4. Canal limitado (Bounded Channel)

Los canales "sin límite" son divertidos, pero la memoria no es infinita (aunque tu equipo parezca enorme).

Un canal limitado (bounded) permite fijar el número máximo de elementos que pueden estar dentro al mismo tiempo. Si el canal está lleno, el productor esperará hasta que el consumidor saque algo.

Ejemplo:

var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(3)
{
    FullMode = BoundedChannelFullMode.Wait // (por defecto) - esperar hasta que haya espacio
});

Aquí solo tres elementos pueden estar en el canal simultáneamente. Si el productor intenta escribir un cuarto, esperará.

Varios productores y consumidores

var channel = Channel.CreateUnbounded<int>();

// 2 productores
for (int producerId = 0; producerId < 2; producerId++)
{
    Task.Run(async () =>
    {
        for (int i = 0; i < 5; i++)
        {
            int value = producerId * 100 + i;
            Console.WriteLine($"Productor {producerId}: Pone {value}");
            await channel.Writer.WriteAsync(value);
            await Task.Delay(50);
        }
        // ¡Cada productor llama a Complete() — peligroso!
    });
}
// Truco: Complete() debe llamarse solo una vez, cuando TODOS los productores han terminado.
// Para el ejemplo dejaremos una tarea consumidora:
Task.Run(async () =>
{
    await foreach (var item in channel.Reader.ReadAllAsync())
    {
        Console.WriteLine($"Consumidor recibió {item}");
        await Task.Delay(100);
    }
});

¡Atención! El canal debe cerrarse (con Complete()) solo después de que todos los productores hayan terminado. Si no, otros intentarán escribir y el canal ya estará cerrado. En la práctica se suele usar un contador de tareas o Task.WhenAll.

5. Práctica: Procesamiento de imágenes a través de un canal

Complicamos un poco. Imagina que tienes una carpeta con imágenes. Una tarea busca imágenes y pone sus rutas en el canal, y otra toma la ruta y hace algo útil con el archivo (por ejemplo, calcula tamaño o convierte).

Aclaración: Para simplificar el ejemplo trabajaremos con nombres de archivos (sin procesamiento real de imágenes), pero la idea es la misma.

using System;
using System.IO;
using System.Threading.Channels;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var channel = Channel.CreateBounded<string>(5);

        // Productor: busca archivos .jpg en la carpeta
        var producer = Task.Run(async () =>
        {
            foreach (var file in Directory.EnumerateFiles(@"images", "*.jpg"))
            {
                await channel.Writer.WriteAsync(file);
                Console.WriteLine($"Añadido a la cola: {file}");
                await Task.Delay(50); // simulamos retraso de búsqueda
            }
            channel.Writer.Complete(); // fin de la cola
        });

        // Consumidor: lee y "procesa" archivos
        var consumer = Task.Run(async () =>
        {
            await foreach (var file in channel.Reader.ReadAllAsync())
            {
                Console.WriteLine($"Procesando archivo: {file}");
                await Task.Delay(200); // simulación de procesamiento
            }
            Console.WriteLine("¡Todas las imágenes procesadas!");
        });

        await Task.WhenAll(producer, consumer);
    }
}

6. Configurando Channel: opciones y matices

Los canales se pueden configurar con opciones al crearlos — aquí los parámetros claves para canales limitados:

Opción Descripción
Capacity
Número máximo de elementos que pueden estar en el canal simultáneamente
SingleWriter
true si tienes solo un productor (mejora rendimiento)
SingleReader
true si tienes solo un consumidor (mejora rendimiento)
FullMode
¿Qué hacer si el canal está lleno? Valores posibles: Wait, DropWrite, DropOldest, DropNewest

Ejemplo con opciones:

var options = new BoundedChannelOptions(10)
{
    SingleWriter = false,
    SingleReader = true,
    FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<string>(options);

7. Métodos asíncronos: ReadAsync, WriteAsync, ReadAllAsync

¿Por qué async es tan importante?

Los métodos WriteAsync y ReadAsync no bloquean el hilo. Si no hay nada que leer, la task se pausa y libera el hilo para otras tareas. Esto es crítico en aplicaciones de servidor y UI, donde un bloqueo extra puede causar "congelamientos".

ReadAllAsync — la comodidad del C# moderno

Puedes iterar de forma asíncrona:

await foreach (var item in channel.Reader.ReadAllAsync())
{
    // Trabajamos con item
}

Channel<T> y colecciones thread-safe: ¿cuál es la diferencia?

ConcurrentQueue<T>/BlockingCollection<T> son útiles para escenarios con hilos, pero no encajan bien con la asincronía pura (escenarios con await).

Channel<T> fue pensado precisamente para aplicaciones pipeline asíncronas. En cuanto a seguridad entre hilos, ambas familias manejan bien la concurrencia, pero los canales ofrecen flexibilidad e integración con características modernas de C# (IAsyncEnumerable, etc.).

8. Errores y trampas típicas

No olvides llamar a Complete() en el writer cuando todos los elementos hayan sido añadidos. Si no, el consumidor se quedará esperando nuevos elementos para siempre.

No llames a Complete() varias veces si hay muchos producers — hazlo solo cuando todos los productores hayan terminado.

Tras cerrar el canal no se puede escribir más elementos, pero sí se pueden leer los que queden.

Condición de carrera al escribir simultáneamente: si el canal está cerrado y alguien intenta escribir, obtendrás una excepción.

2
Tarea
C# SELF, nivel 62, lección 3
Bloqueada
Creación de un canal simple para transferir números
Creación de un canal simple para transferir números
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION