LINQ y async en C#

Dos herramientas que distinguen a C# de casi cualquier otro lenguaje. LINQ permite filtrar, ordenar, agrupar y sumar colecciones con consultas cortas y legibles, en lugar de bucles con variables auxiliares. async/await permite esperar a la red, al disco o a un temporizador sin bloquear el programa, escribiendo el código casi como si fuera secuencial. Las dos se apoyan en lo de la lección 6: genéricos, lambdas e IEnumerable<T>.

LINQ: consultas sobre colecciones

LINQ (Language Integrated Query) es un conjunto de métodos de extensión sobre IEnumerable<T>: cualquier array, lista o secuencia los tiene. Cada uno recibe una lambda, como las de la lección 4, y devuelve otra secuencia, así que se encadenan. Usaremos esta lista de productos en toda la primera parte:

List<Producto> productos =
[
    new("Teclado", "Periféricos", 49.90m, 12),
    new("Ratón", "Periféricos", 19.90m, 0),
    new("Monitor", "Pantallas", 189.00m, 4),
    new("Portátil", "Equipos", 899.00m, 2),
    new("Webcam", "Periféricos", 59.00m, 7),
    new("Tableta", "Equipos", 329.00m, 0),
];

record Producto(string Nombre, string Categoria, decimal Precio, int Stock);
var disponibles = productos
    .Where(p => p.Stock > 0)
    .OrderBy(p => p.Precio)
    .Select(p => $"{p.Nombre} ({p.Precio} USD)");

foreach (var linea in disponibles)
{
    Console.WriteLine(linea);
}
salidaTeclado (49.90 USD)
Webcam (59.00 USD)
Monitor (189.00 USD)
Portátil (899.00 USD)

Se lee de arriba abajo: de los productos, quédate con los que tienen stock, ordénalos por precio y conviértelos en un texto. Where filtra, OrderBy ordena (y OrderByDescending al revés, con ThenBy para desempatar) y Select transforma cada elemento en otra cosa. Fíjate en que decimal conserva los decimales con que se escribió el número: 49.90m se imprime como 49.90, no 49.9.

Contar, sumar y buscar

Otros métodos no devuelven una secuencia, sino un único valor:

Console.WriteLine(productos.Count(p => p.Stock == 0));
Console.WriteLine(productos.Sum(p => p.Precio * p.Stock));
Console.WriteLine(productos.Max(p => p.Precio));
Console.WriteLine(productos.Average(p => p.Stock));
Console.WriteLine(productos.Any(p => p.Categoria == "Audio"));
Console.WriteLine(productos.First(p => p.Precio > 100).Nombre);
Console.WriteLine(productos.FirstOrDefault(p => p.Precio > 1000)?.Nombre ?? "ninguno");
salida2
3565.80
899.00
4.166666666666667
False
Monitor
ninguno

First lanza una excepción si ningún elemento cumple la condición; FirstOrDefault devuelve null (o el valor por omisión del tipo), y por eso se combina tan bien con ?. y ?? de la lección 2. Lo mismo pasa con Single y SingleOrDefault, que además exigen que haya uno solo.

Agrupar con GroupBy

var porCategoria = productos
    .GroupBy(p => p.Categoria)
    .Select(g => new
    {
        Categoria = g.Key,
        Cantidad = g.Count(),
        Valor = g.Sum(p => p.Precio * p.Stock),
    })
    .OrderByDescending(x => x.Valor);

foreach (var g in porCategoria)
{
    Console.WriteLine($"{g.Categoria,-12} cantidad: {g.Cantidad}  valor: {g.Valor,8:F2} USD");
}
salidaEquipos      cantidad: 2  valor:  1798.00 USD
Periféricos  cantidad: 3  valor:  1011.80 USD
Pantallas    cantidad: 1  valor:   756.00 USD

GroupBy reparte los elementos en grupos según una clave; cada grupo tiene su Key y se puede recorrer o resumir como cualquier secuencia. El new { ... } sin nombre de clase crea un tipo anónimo: un objeto de usar y tirar con las propiedades que pongas, muy práctico dentro de una consulta. En la interpolación, ,-12 rellena hasta 12 caracteres alineando a la izquierda y ,8:F2 alinea a la derecha en 8 con dos decimales: así salen las columnas.

La otra sintaxis

LINQ también tiene una sintaxis parecida a SQL. El compilador la traduce a las mismas llamadas, así que es cuestión de gusto; la de métodos es la más usada y la única que tiene todos los operadores:

var caros = from p in productos
            where p.Precio > 100
            orderby p.Precio descending
            select p.Nombre;

Console.WriteLine(string.Join(", ", caros));
salidaPortátil, Tableta, Monitor

Si sabes SQL, el curso de SQL te resultará familiar: Where, GroupBy y OrderBy son las mismas ideas. Y para ordenar y buscar en listas grandes hay más trucos en el ejemplo ordenar y buscar en C#.

La ejecución diferida

Una consulta LINQ no se ejecuta cuando la escribes, sino cuando la recorres, igual que los iteradores con yield de la lección anterior. Y se vuelve a ejecutar cada vez que la recorres:

var numeros = new List<int> { 1, 2, 3 };
var dobles = numeros.Select(n =>
{
    Console.WriteLine($"  calculando {n}");
    return n * 2;
});

numeros.Add(4);   // la consulta aún no se ha ejecutado: verá el 4

Console.WriteLine("Primera vuelta:");
Console.WriteLine(string.Join(", ", dobles));
Console.WriteLine("Segunda vuelta:");
Console.WriteLine(dobles.Sum());

var fijos = dobles.ToList();   // se ejecuta una vez más y se guarda el resultado
numeros.Add(5);
Console.WriteLine($"fijos tiene {fijos.Count} elementos");
salidaPrimera vuelta:
  calculando 1
  calculando 2
  calculando 3
  calculando 4
2, 4, 6, 8
Segunda vuelta:
  calculando 1
  calculando 2
  calculando 3
  calculando 4
20
  calculando 1
  calculando 2
  calculando 3
  calculando 4
fijos tiene 4 elementos

Tres lecciones en una salida. La consulta vio el 4, añadido después de escribirla. La lambda se ejecutó una vez por número en cada recorrido, tres veces en total contando el ToList(), lo que con un cálculo caro o una consulta a una base de datos sale muy caro. Y ToList() (o ToArray(), ToDictionary()…) ejecuta la consulta en ese momento y guarda el resultado, que ya no cambia. Regla práctica: si vas a recorrer una consulta más de una vez, guárdala con ToList(). Este comportamiento está entre las rarezas de C#.

async y await

Muchas operaciones pasan la mayor parte del tiempo esperando: la respuesta de un servidor, un archivo del disco, una consulta a la base de datos. Un método async puede ceder el control mientras espera, sin bloquear el hilo, y continuar donde lo dejó cuando llega el resultado. Se escribe así:

Console.WriteLine("Pidiendo el precio...");
decimal precio = await ObtenerPrecioAsync("teclado");
Console.WriteLine($"Precio: {precio} USD");

static async Task<decimal> ObtenerPrecioAsync(string producto)
{
    await Task.Delay(200);   // simula una petición por la red
    return producto.Length * 10m;
}
salidaPidiendo el precio...
Precio: 70 USD

Las piezas: el método se marca con async y devuelve un Task<decimal>, una «promesa» de que habrá un decimal más adelante (o Task a secas si no devuelve nada). Dentro, await espera a otra tarea sin bloquear. Quien lo llama también usa await para obtener el valor. Por convención, estos métodos terminan en Async. Las instrucciones de nivel superior admiten await directamente; en un proyecto con Main, este tendría que ser static async Task Main.

El error que te ahorra: olvidar el await

int palabras = ContarPalabrasAsync("hola mundo");
Console.WriteLine(palabras);

static async Task<int> ContarPalabrasAsync(string texto)
{
    await Task.Delay(10);
    return texto.Split(' ').Length;
}
dotnet buildsin_await.cs(1,16): error CS0029: No se puede convertir implícitamente el tipo 'System.Threading.Tasks.Task<int>' en 'int'

Un método asíncrono no devuelve el número, sino la tarea que lo producirá. Sin el await, el compilador ve que intentas guardar un Task<int> en un int y lo detiene. En JavaScript, el mismo olvido no da error: la variable se queda con una Promise y el programa escribe [object Promise] o, peor, sigue adelante con datos que aún no han llegado.

Varias tareas a la vez con Task.WhenAll

La gran ventaja aparece al esperar varias cosas. Con un await detrás de otro, cada espera empieza cuando acaba la anterior. Si lanzas las tareas primero y luego esperas a todas con Task.WhenAll, las esperas se solapan:

using System.Diagnostics;

var reloj = Stopwatch.StartNew();
int a = await DescargarAsync("a.txt");
int b = await DescargarAsync("b.txt");
int c = await DescargarAsync("c.txt");
Console.WriteLine($"Una tras otra: {a + b + c} bytes en unos {Redondear(reloj)} ms");

reloj.Restart();
int[] tamanos = await Task.WhenAll(DescargarAsync("a.txt"), DescargarAsync("b.txt"), DescargarAsync("c.txt"));
Console.WriteLine($"A la vez: {tamanos.Sum()} bytes en unos {Redondear(reloj)} ms");
Console.WriteLine(string.Join(", ", tamanos));

static async Task<int> DescargarAsync(string archivo)
{
    await Task.Delay(300);   // la «descarga»
    return archivo.Length * 100;
}

static long Redondear(Stopwatch r) => r.ElapsedMilliseconds / 100 * 100;
salidaUna tras otra: 1500 bytes en unos 900 ms
A la vez: 1500 bytes en unos 300 ms
500, 500, 500

Arriba, tres descargas esperadas una tras otra con await ocupan 900 milisegundos. Abajo, las mismas tres lanzadas a la vez y esperadas con Task.WhenAll terminan en 300 milisegundosTres esperas de 300 ms: una tras otra o a la vezawait, await,awaita.txtb.txtc.txtTask.WhenAlla.txtb.txtc.txt0 ms300 ms600 ms900 mslisto en 300 ms

Con await uno detrás de otro, las esperas se suman. Con Task.WhenAll, las tres tareas esperan a la vez y el total es el de la más lenta. Esquema: decodigo.com.

Task.WhenAll devuelve los resultados en el mismo orden en que le pasaste las tareas, aunque terminen en otro. Su pariente Task.WhenAny espera solo a la primera que acabe. Los tiempos del programa están truncados a centenas de milisegundos (Redondear) para que la salida sea estable de una ejecución a otra. En el ejemplo tareas en paralelo hay más: Parallel.ForEachAsync y cómo limitar cuántas tareas corren a la vez, y en peticiones HTTP, descargas de verdad con HttpClient.

Cancelar con CancellationToken

Una operación larga debería poder cancelarse: el usuario se cansa, se acaba el tiempo de espera. En .NET se hace pasando un CancellationToken, que casi todos los métodos asíncronos aceptan como último parámetro:

using var cancelacion = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
    await TareaLargaAsync(cancelacion.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelada: tardaba demasiado");
}

static async Task TareaLargaAsync(CancellationToken token)
{
    for (int paso = 1; paso <= 5; paso++)
    {
        await Task.Delay(200, token);
        Console.WriteLine($"Paso {paso} terminado");
    }
}
salidaPaso 1 terminado
Cancelada: tardaba demasiado

El CancellationTokenSource se cancela solo a los 300 ms; también se puede cancelar a mano con Cancel(). Task.Delay recibe el token y, al cancelarse, lanza una OperationCanceledException que interrumpe el bucle en mitad del paso 2. El using libera el temporizador al terminar, como viste en la lección 4.

Secuencias asíncronas: await foreach

Si combinas un iterador con yield y async, obtienes un IAsyncEnumerable<T>: una secuencia cuyos elementos llegan poco a poco, como las lecturas de un sensor o las páginas de una API. Se recorre con await foreach:

await foreach (var temperatura in LeerSensorAsync(3))
{
    Console.WriteLine($"Lectura: {temperatura} °C");
}

static async IAsyncEnumerable<double> LeerSensorAsync(int lecturas)
{
    for (int i = 0; i < lecturas; i++)
    {
        await Task.Delay(100);
        yield return 20.5 + i * 0.5;
    }
}
salidaLectura: 20.5 °C
Lectura: 21 °C
Lectura: 21.5 °C
Nunca async void. Un método asíncrono debe devolver Task o Task<T>. Con void, quien lo llama no puede esperarlo ni capturar sus excepciones, que acaban tumbando el programa. La única excepción son los manejadores de eventos de las interfaces gráficas. Está explicado en las rarezas.

Resumen

ParaEscribeRecuerda
Filtrar.Where(p => p.Stock > 0)devuelve otra secuencia
Transformar.Select(p => p.Nombre)
Ordenar.OrderBy(...), .ThenBy(...)OrderByDescending al revés
Agrupar.GroupBy(p => p.Categoria)cada grupo tiene su Key
Un solo valorCount, Sum, Max, Any, FirstFirstOrDefault no lanza
Fijar el resultado.ToList()si no, se recalcula en cada recorrido
Un método asíncronoasync Task<T> NombreAsync()nunca async void
Esperar el resultadoawait NombreAsync()sin await tienes un Task
Varias a la vezawait Task.WhenAll(t1, t2, t3)resultados en el orden de las tareas
Poder cancelarCancellationTokenOperationCanceledException
Una secuencia que llega poco a pocoIAsyncEnumerable<T> y await foreach

Retos

Reto 1. Con la lista de productos, escribe los nombres de los periféricos que tienen stock, en mayúsculas y por orden alfabético, separados por comas.

Ver la solución
var nombres = productos
    .Where(p => p.Categoria == "Periféricos" && p.Stock > 0)
    .Select(p => p.Nombre.ToUpper())
    .Order();

Console.WriteLine(string.Join(", ", nombres));
salidaTECLADO, WEBCAM

Order(), sin lambda, ordena los propios elementos (existe desde .NET 7). Con OrderBy(n => n) el resultado es el mismo.

Reto 2. Calcula el precio medio de cada categoría y escríbelo con dos decimales, de la categoría más cara a la más barata.

Ver la solución
var medias = productos
    .GroupBy(p => p.Categoria)
    .Select(g => (Categoria: g.Key, Media: g.Average(p => p.Precio)))
    .OrderByDescending(x => x.Media);

foreach (var (categoria, media) in medias)
{
    Console.WriteLine($"{categoria}: {media:F2} USD");
}
salidaEquipos: 614.00 USD
Pantallas: 189.00 USD
Periféricos: 42.93 USD

En lugar de un tipo anónimo, aquí la consulta devuelve una tupla con nombres, que se puede descomponer directamente en el foreach.

Reto 3. Tres servidores responden con retrasos de 300, 100 y 200 ms. Usa Task.WhenAny para quedarte con la respuesta del más rápido.

Ver la solución
Task<string> respuesta = await Task.WhenAny(
    ResponderAsync("servidor A", 300),
    ResponderAsync("servidor B", 100),
    ResponderAsync("servidor C", 200));

Console.WriteLine($"Ganó {await respuesta}");

static async Task<string> ResponderAsync(string nombre, int retraso)
{
    await Task.Delay(retraso);
    return nombre;
}
salidaGanó servidor B

Task.WhenAny devuelve la tarea que terminó primero, no su resultado: por eso hay un segundo await para sacarlo (que ya no espera nada, porque la tarea está terminada). Las otras dos siguen en marcha hasta acabar; para pararlas haría falta un CancellationToken.

Todo el código de esta lección se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te funciona? Revisa que tengas .NET 10: dotnet --version.