//JorgenHoc
← Todos los artículos
Async C#Guía PrincipalPor Jorge CalderónActualizado 17 min read

Guía completa de async/await en C# — Task, ValueTask, ConfigureAwait

Domina async/await en C# desde los fundamentos: Task, Task<T>, ValueTask, ConfigureAwait(false), errores comunes y manejo de excepciones con ejemplos prácticos ejecutables.

#csharp#async#dotnet

La programación asíncrona en C# es engañosamente sencilla de escribir y engañosamente fácil de hacer mal. Las palabras clave async/await manejan la mayor parte de la complejidad, pero entender qué ocurre por debajo separa el código que funciona del código que rinde bien, gestiona los errores correctamente y no entra en deadlock bajo carga.

Cada tema aquí tiene un artículo dedicado con un proyecto de consola ejecutable donde cada afirmación de comportamiento es una aserción que falla ruidosamente si un runtime futuro cambia — reunidos en Samples ejecutables de esta guía al final. Escribir esos samples fue una cura de humildad: varias cosas que había creído durante años resultaron ser falsas cuando un programa tuvo que demostrarlas (las cifras de asignaciones de ValueTask más abajo son el ejemplo más claro). Cuando esta guía da un número, fue medido en mi máquina con el sample enlazado, así que puedes volver a ejecutarlo en lugar de confiar en él.

⚡ async/await Execution Visualizer

Step 1 — Synchronous entry

The caller invokes the async method. Execution begins synchronously on the caller's thread until the first await is reached.

Main Thread
Running async method
Async Method
Running (synchronous entry)
I/O Task
Not started
// Caller thread
var result = await GetDataAsync();  // ← call starts here

async Task<string> GetDataAsync()
{
    Console.WriteLine("Before await"); // ← runs synchronously
    // ... not yet at an await
}
1 / 4

Por qué existe Async

El problema central: un servidor web que gestiona 1.000 solicitudes concurrentes no puede permitirse 1.000 hilos bloqueados esperando respuestas de la base de datos. Los hilos son costosos — cada uno consume ~1 MB de espacio de pila.

La E/S asíncrona resuelve esto. Cuando tu código hace await a una llamada a la base de datos, el hilo regresa al pool de hilos para atender otras solicitudes. Cuando la base de datos responde, un hilo retoma tu método y continúa. El resultado: un rendimiento notablemente superior con menos hilos.

// Sincrónico — bloquea un hilo durante toda la duración de la llamada a la BD
public Product GetProduct(int id)
{
    return _db.Products.Find(id)!; // El hilo está atascado aquí esperando a la BD
}
 
// Asincrónico — el hilo se libera mientras espera a la BD
public async Task<Product?> GetProductAsync(int id)
{
    return await _db.Products.FindAsync(id); // El hilo regresa al pool aquí
}

Los fundamentos de async/await

Todo método que contenga await debe marcarse como async. El tipo de retorno cambia así:

Tipo de retorno síncronoTipo de retorno asíncrono
voidasync Task (o async void solo para manejadores de eventos)
Tasync Task<T>
(ruta caliente)async ValueTask<T>
// Método asíncrono que devuelve un valor
public async Task<string> FetchUserNameAsync(int userId)
{
    var user = await _db.Users.FindAsync(userId);
    return user?.Name ?? "Unknown";
}
 
// Método asíncrono sin valor de retorno
public async Task SendWelcomeEmailAsync(string email)
{
    var message = BuildMessage(email);
    await _emailService.SendAsync(message);
}
 
// Llamar a métodos asíncronos
var name = await FetchUserNameAsync(42);
await SendWelcomeEmailAsync("user@example.com");

Cómo funciona await realmente

await es azúcar sintáctica para una máquina de estados. El compilador transforma tu método asíncrono en una clase que implementa IAsyncStateMachine. Cuando la operación esperada se completa, la máquina de estados reanuda la ejecución en el punto después del await.

La clave: await suspende el método actual, no el hilo actual. El hilo queda libre para hacer otro trabajo.

Task vs Task<T>

Task representa una operación asíncrona sin valor de retorno. Task<T> representa una que devuelve un valor de tipo T.

// Task — sin valor de retorno
public async Task ProcessOrderAsync(int orderId)
{
    var order = await _db.Orders.FindAsync(orderId)
        ?? throw new ArgumentException($"Order {orderId} not found");
 
    order.Status = OrderStatus.Processing;
    await _db.SaveChangesAsync();
    await _notificationService.NotifyAsync(order.UserId, "Order is processing");
}
 
// Task<T> — devuelve un valor
public async Task<OrderSummary> GetOrderSummaryAsync(int orderId)
{
    var order = await _db.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == orderId)
        ?? throw new KeyNotFoundException($"Order {orderId} not found");
 
    return new OrderSummary(order.Id, order.Items.Sum(i => i.Price), order.Status);
}

Ejecutar múltiples tareas de forma concurrente

// Secuencial — cada una espera antes de que empiece la siguiente (lento)
var user = await GetUserAsync(userId);         // Espera esto...
var orders = await GetOrdersAsync(userId);     // Luego espera esto
var recommendations = await GetRecsAsync(userId); // Luego esto
 
// Concurrente — todas inician de inmediato, luego se espera a todas (rápido)
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var recsTask = GetRecsAsync(userId);
 
await Task.WhenAll(userTask, ordersTask, recsTask);
 
var user = await userTask;
var orders = await ordersTask;
var recommendations = await recsTask;
💡

Usa Task.WhenAll cuando múltiples operaciones asíncronas independientes puedan ejecutarse en paralelo. Si tres llamadas a la base de datos tardan 50 ms cada una, la ejecución secuencial toma 150 ms; la concurrente toma ~50 ms.

Task.WhenAny

// Completar cuando cualquier tarea termine — útil para timeouts
var dataTask = FetchDataAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
 
var completed = await Task.WhenAny(dataTask, timeoutTask);
 
if (completed == timeoutTask)
    throw new TimeoutException("Data fetch timed out after 5 seconds");
 
var data = await dataTask; // Seguro de esperar — ya completó

ValueTask

ValueTask<T> es una alternativa a Task<T> que evita asignaciones de memoria para métodos que frecuentemente se completan de forma sincrónica (sin esperar realmente).

// Usando Task<T> — siempre asigna un Task en el heap
public async Task<string?> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out var cached))
        return cached;  // La ruta sincrónica también asigna un Task
 
    var value = await _redis.GetAsync(key);
    _cache.Set(key, value);
    return value;
}
 
// Usando ValueTask<T> — sin asignación en la ruta sincrónica (acierto de caché)
public ValueTask<string?> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out var cached))
        return ValueTask.FromResult(cached);  // Cero asignaciones
 
    return FetchAndCacheAsync(key);  // La ruta asíncrona sigue usando Task internamente
}
 
private async ValueTask<string?> FetchAndCacheAsync(string key)
{
    var value = await _redis.GetAsync(key);
    _cache.Set(key, value);
    return value;
}
⚠️

No uses ValueTask en todas partes. Solo es beneficioso en rutas calientes donde la ruta sincrónica es el caso común (p. ej., aciertos de caché, operaciones ya completadas). Para la mayor parte del código de aplicación, Task<T> es más simple y el costo de asignación es insignificante.

Restricciones de ValueTask

ValueTask tiene restricciones importantes en comparación con Task:

// INCORRECTO — ValueTask solo puede ser esperado una vez
var vt = GetCachedValueAsync("key");
var result1 = await vt;  // Correcto
var result2 = await vt;  // COMPORTAMIENTO INDEFINIDO
 
// INCORRECTO — No se puede esperar el mismo ValueTask desde múltiples lugares
// INCORRECTO — No se puede usar .Result en un ValueTask de forma segura
 
// CORRECTO — Esperar de inmediato, o convertir a Task si necesitas reutilizarlo
var task = GetCachedValueAsync("key").AsTask();
var result1 = await task;
var result2 = await task;  // Seguro con Task

Lo que ValueTask ahorra realmente — medido

La promesa de ValueTask es "sin asignaciones", así que medí exactamente eso con BenchmarkDotNet en .NET 10 x64, usando la forma de caché de arriba. El resultado cambió mi manera de usarlo:

RutaTask<T> asignadoValueTask<T> asignado
Acierto de caché (completa de forma sincrónica)72 B0 B
Fallo de caché (espera de verdad)195 B229 B

La fila sincrónica es la promesa cumplida: Task<T> paga 72 bytes por llamada, ValueTask<T> no paga nada. La fila asíncrona es la parte que nadie incluye en el discurso: cuando el método realmente se suspende, ValueTask<T> asignó más que Task<T>, porque su builder igualmente tiene que asignar en el heap el estado de la suspensión. Así que ValueTask es un intercambio, no una mejora gratuita — gana exactamente en proporción a la frecuencia con que tu método se completa de forma sincrónica, y un método que normalmente se suspende sale ligeramente peor parado con él.

Una nota metodológica que vale la pena robar: en tres ejecuciones en la misma máquina, las columnas de tiempos se movieron hasta un 4% mientras que las columnas de asignaciones volvieron idénticas byte a byte cada vez. Cuando dos candidatos difieren por nanosegundos, las asignaciones son la señal reproducible. El artículo de ValueTask vs Task incluye la salida completa de BenchmarkDotNet con el encabezado de entorno y la StdDev, además de la misma comparación ejecutada contra una consulta real de EF Core.

ConfigureAwait(false)

Por defecto, cuando el código se reanuda después de un await, intenta retomar en el SynchronizationContext original (p. ej., el hilo de UI en WinForms, o el contexto de solicitud de ASP.NET Classic).

ConfigureAwait(false) le indica al runtime "no captures el contexto — reanuda en cualquier hilo del pool de hilos".

// Sin ConfigureAwait — captura y restaura el contexto de sincronización
public async Task<Data> GetDataAsync()
{
    var result = await httpClient.GetFromJsonAsync<Data>("/api/data");
    return result!;
}
 
// Con ConfigureAwait(false) — sin captura de contexto, ligeramente más rápido
public async Task<Data> GetDataAsync()
{
    var result = await httpClient.GetFromJsonAsync<Data>("/api/data")
                                 .ConfigureAwait(false);
    return result!;
}

Cuándo usar ConfigureAwait(false)

Código de biblioteca: Usa siempre ConfigureAwait(false). Las bibliotecas no son propietarias del SynchronizationContext y no deben intentar reanudar en él. Esto previene deadlocks cuando los consumidores de la biblioteca usan .Result o .Wait().

Código de aplicación ASP.NET Core: Generalmente no es necesario. ASP.NET Core no tiene SynchronizationContext, por lo que no hay nada que capturar. Agregar ConfigureAwait(false) en todas partes del código de ASP.NET Core es inofensivo pero genera ruido innecesario.

Código de aplicación WinForms/WPF: Úsalo con cuidado. Si necesitas actualizar la UI después de un await, debes estar en el hilo de UI, así que no uses ConfigureAwait(false) antes de actualizaciones de UI.

// Biblioteca NuGet — usa siempre ConfigureAwait(false)
public static class MyLibrary
{
    public static async Task<string> FetchAsync(string url)
    {
        using var client = new HttpClient();
        var response = await client.GetAsync(url).ConfigureAwait(false);
        var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        return content;
    }
}

.NET 8: ConfigureAwaitOptions

.NET 8 convirtió el booleano en un enum de flags — ConfigureAwait(ConfigureAwaitOptions) — y dos de las cuatro opciones hacen cosas que el booleano nunca pudo:

// None == ConfigureAwait(false); ContinueOnCapturedContext == ConfigureAwait(true)
await task.ConfigureAwait(ConfigureAwaitOptions.None);
 
// SuppressThrowing — el await se completa sin lanzar aunque la task haya
// fallado o sido cancelada. Útil para "esperar la limpieza, ignorar sus errores".
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
 
// ForceYielding — nunca continuar de forma sincrónica, incluso si ya completó.
await task.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
 
// Se pueden combinar:
await task.ConfigureAwait(
    ConfigureAwaitOptions.SuppressThrowing | ConfigureAwaitOptions.ForceYielding);

SuppressThrowing es la que hay que conocer porque es la única excepción a una regla en la que, por lo demás, puedes confiar: las excepciones se propagan a través de await de forma idéntica hayas usado ConfigureAwait(false) o no — el sample de ConfigureAwait lo verifica con una aserción capturando una excepción posterior a ConfigureAwait(false) con un try/catch ordinario en el punto de llamada. Ese sample también mide lo que ConfigureAwait hace realmente, instalando un SynchronizationContext que cuenta llamadas y comparando las invocaciones de Post con y sin él — una demostración mucho más honesta que "es ligeramente más rápido". El artículo completo repasa los conteos.

Errores comunes

1. async void

// PELIGROSO — las excepciones no son observadas y crashean el proceso
public async void LoadData()
{
    var data = await FetchAsync(); // Una excepción aquí mata la aplicación
    Process(data);
}
 
// CORRECTO — devuelve Task para que los llamadores puedan observar las excepciones
public async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}
 
// async void SOLO es aceptable para manejadores de eventos
button.Click += async (sender, e) =>
{
    await DoWorkAsync(); // Los manejadores de eventos deben ser void
};

2. Deadlocks con .Result y .Wait()

// DEADLOCK en ASP.NET Classic / WinForms / WPF
public string GetData()
{
    return FetchDataAsync().Result;  // Bloquea el hilo actual
    // FetchDataAsync intenta reanudar en este hilo — DEADLOCK
}
 
// CORRECTO — ser asíncrono de principio a fin
public async Task<string> GetDataAsync()
{
    return await FetchDataAsync();
}
⚠️

Nunca uses .Result, .Wait(), o .GetAwaiter().GetResult() sobre un Task desde un contexto que tenga un SynchronizationContext (aplicaciones de UI, ASP.NET clásico). Causa deadlocks. En ASP.NET Core no produce deadlock, pero igualmente bloquea un hilo de forma innecesaria.

Este deadlock se describe a menudo pero rara vez se demuestra, lo que deja creer que es teórico. No lo es: el sample de deadlock instala un SynchronizationContext de un solo hilo de ~40 líneas — la misma forma que usan WinForms y WPF — y se cuelga bajo demanda, en cada ejecución, y luego des-cuelga el código idéntico añadiendo ConfigureAwait(false) en el await correcto. Reproducirlo de forma determinista también demuestra por qué ASP.NET Core es inmune: sin SynchronizationContext, no hay nada por lo que pelear. El artículo sobre deadlocks traza el ciclo paso a paso.

3. Olvidar el await

// BUG — fire-and-forget, las excepciones se tragan silenciosamente
public async Task ProcessAsync()
{
    SaveToDatabase();  // ¡No esperado! Se ejecuta pero los errores se pierden
    LogActivity();     // Tampoco esperado
}
 
// CORRECTO
public async Task ProcessAsync()
{
    await SaveToDatabaseAsync();
    await LogActivityAsync();
}

4. Async en constructores

Los constructores no pueden ser async. Usa el patrón de método de fábrica:

// INCORRECTO — no se puede hacer await en el constructor
public class DataService
{
    public DataService()
    {
        _data = await LoadDataAsync(); // Error de compilación
    }
}
 
// CORRECTO — método de fábrica estático
public class DataService
{
    private readonly Data _data;
 
    private DataService(Data data) => _data = data;
 
    public static async Task<DataService> CreateAsync()
    {
        var data = await LoadDataAsync();
        return new DataService(data);
    }
}
 
// Uso
var service = await DataService.CreateAsync();

Manejo de errores

try/catch básico

Las excepciones en métodos asíncronos se propagan naturalmente a través de await:

public async Task ProcessOrderAsync(int orderId)
{
    try
    {
        var order = await _db.Orders.FindAsync(orderId)
            ?? throw new KeyNotFoundException($"Order {orderId} not found");
 
        await _paymentService.ChargeAsync(order.TotalAmount);
        await _emailService.SendConfirmationAsync(order.CustomerEmail);
    }
    catch (PaymentException ex)
    {
        _logger.LogError(ex, "Payment failed for order {OrderId}", orderId);
        throw; // Re-lanzar para que el llamador lo maneje
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error processing order {OrderId}", orderId);
        throw;
    }
}

Excepciones con Task.WhenAll

await Task.WhenAll(...) relanza solo la primera excepción, aunque fallen varias tareas. La propiedad Exception de la task combinada las contiene todas como un AggregateException — así que para registrar cada fallo, inspecciona las tasks en vez de confiar en la excepción lanzada:

public async Task ProcessMultipleOrdersAsync(int[] orderIds)
{
    // Materializa con ToList() — es esencial. Select es diferido, así que
    // volver a enumerar `tasks` en el catch llamaría a ProcessOrderAsync OTRA
    // VEZ, iniciando cada pedido por segunda vez e inspeccionando tasks nuevas
    // que aún no han fallado. Enumera una sola vez, aquí.
    var tasks = orderIds.Select(id => ProcessOrderAsync(id)).ToList();
 
    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception)
    {
        // await lanzó solo la primera excepción. Inspecciona la misma lista
        // materializada de tasks para recopilarlas TODAS.
        var allExceptions = tasks
            .Where(t => t.IsFaulted)
            .SelectMany(t => t.Exception!.InnerExceptions)
            .ToList();
 
        foreach (var ex in allExceptions)
            _logger.LogError(ex, "Order processing failed");
 
        throw;
    }
}
⚠️

El .ToList() no es cosmético. orderIds.Select(id => ProcessOrderAsync(id)) es una consulta diferida — cada enumeración vuelve a invocar ProcessOrderAsync. Esperar Task.WhenAll(tasks) la enumera una vez; volver a enumerar tasks en el catch iniciaría cada operación por segunda vez e inspeccionaría tasks nuevas que aún no han fallado. Materializa la secuencia exactamente una vez. El artículo sobre manejo de excepciones asíncronas verifica con aserciones el comportamiento de primera-excepción vs AggregateException de principio a fin.

Cancelación

public async Task<List<Product>> GetProductsAsync(CancellationToken cancellationToken = default)
{
    return await _db.Products
        .Where(p => p.IsActive)
        .ToListAsync(cancellationToken);
}
 
// Con timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
    var products = await GetProductsAsync(cts.Token);
}
catch (OperationCanceledException)
{
    _logger.LogWarning("GetProducts timed out after 10 seconds");
}

Una distinción que vale la pena memorizar porque cambia lo que tus bloques catch pueden saber: cuando HttpClient.Timeout se dispara, obtienes una TaskCanceledException con una TimeoutException interna (desde .NET 5); cuando es tu token el que cancela la misma solicitud, no hay TimeoutException interna y la excepción lleva tu token. Esa es la diferencia entre "el servidor fue lento" y "desistimos a propósito", y se puede capturar:

catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // HttpClient.Timeout se disparó — no es la cancelación de tu llamador
}

El artículo de CancellationToken verifica esto con aserciones contra un servidor local deliberadamente congelado — junto con otros 15 comportamientos de cancelación, incluidos los que yo entendí mal durante años (fuentes de tokens vinculadas, qué token lleva realmente una OperationCanceledException y la comprobación cooperativa en bucles cerrados).

Canceled vs Faulted — son estados distintos

Una OperationCanceledException no deja una task en estado faulted — la cancela, y la asimetría es más marcada de lo que la mayoría espera. El sample de manejo de excepciones (36 aserciones) fija la regla exacta: un método asíncrono que lanza OperationCanceledException termina en Canceled incluso si el token que lleva nunca fue cancelado — pero un delegado sincrónico pasado a Task.Run que lanza la misma excepción termina en Faulted, a menos que el token coincida con el que se le dio a Task.Run. El código de monitoreo que solo observa IsFaulted ignora silenciosamente las tasks canceladas, y el código que trata cada OperationCanceledException como "el usuario pulsó cancelar" atribuye mal los timeouts genuinos. El artículo sobre manejo de excepciones asíncronas cubre esto además de la regla de primera-excepción de Task.WhenAll demostrada arriba.

Streams asíncronos (IAsyncEnumerable)

Para transmitir conjuntos de resultados grandes sin almacenarlos todos en memoria:

// Producir un stream asíncrono
public async IAsyncEnumerable<Order> GetOrdersStreamAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await foreach (var order in _db.Orders.AsAsyncEnumerable().WithCancellation(cancellationToken))
    {
        yield return order;
    }
}
 
// Consumir un stream asíncrono
await foreach (var order in GetOrdersStreamAsync(cancellationToken))
{
    await ProcessOrderAsync(order);
}

La diferencia de memoria no es sutil. Medí ambas formas con 100.000 filas y un productor en memoria para aislar el efecto del buffering: la List<T> con buffer mantiene 18,5 MB vivos — todo el grafo de objetos a la vez — mientras que el pico de la versión en streaming se redondea a 0 MB, porque cada fila se convierte en basura antes de que exista la siguiente. Con filas más pesadas la cifra con buffer escala linealmente; el pico del streaming se mantiene plano. Dos hechos más del sample de IAsyncEnumerable que contradicen consejos habituales: las acciones de controlador de ASP.NET Core sí pueden devolver iteradores asíncronos (verificado con aserciones contra un servidor in-process en vivo), y a partir de .NET 10 los operadores LINQ para IAsyncEnumerable vienen en la BCL — ya no necesitas el paquete System.Linq.Async. El artículo completo cubre la transferencia por fragmentos, la contrapresión con Channel y qué operadores almacenan en buffer de todos modos (OrderBy no tiene alternativa).

Patrones de rendimiento

Paralelismo con grado de paralelismo

// Procesar hasta 10 elementos a la vez
var semaphore = new SemaphoreSlim(10);
 
var tasks = orderIds.Select(async id =>
{
    await semaphore.WaitAsync();
    try
    {
        return await ProcessOrderAsync(id);
    }
    finally
    {
        semaphore.Release();
    }
});
 
await Task.WhenAll(tasks);

Caché con Lazy<Task>

// Inicializar una vez, compartir entre solicitudes
public class ConfigService
{
    private readonly Lazy<Task<AppConfig>> _config;
 
    public ConfigService(IConfigLoader loader)
    {
        _config = new Lazy<Task<AppConfig>>(loader.LoadAsync);
    }
 
    public Task<AppConfig> GetConfigAsync() => _config.Value;
}

Samples ejecutables de esta guía

Cada sección de arriba se amplía en un artículo enfocado con un proyecto de consola que puedes clonar y ejecutar — cada uno convierte sus afirmaciones en aserciones que fallan ruidosamente si el comportamiento cambia. Todos están en jorgenhoc-org/dotnet-samples.

Sección de esta guíaArtículo dedicadoSample
ValueTask y su regla de un solo awaitValueTask vs Taskvaluetask-vs-task-csharp
ConfigureAwait(false)ConfigureAwait(false) explicadoconfigureawait-false-csharp
Deadlocks con .Result / .Wait()Cómo evitar deadlocks asíncronosasync-deadlocks-csharp
Manejo de errores, excepciones de Task.WhenAllManejo de excepciones asíncronasasync-exception-handling-csharp
CancelaciónPatrones de CancellationTokencancellationtoken-csharp
Streams asíncronos (IAsyncEnumerable)IAsyncEnumerable — streaming de datosiasyncenumerable-csharp

Resumen

El modelo mental para async/await en C#:

  1. async marca un método como una máquina de estados; await lo suspende hasta que la operación esperada se completa
  2. Usa Task para fire-and-forget, Task<T> para resultados, ValueTask<T> solo en rutas calientes demostradas
  3. Sé asíncrono de principio a fin — no bloquees código asíncrono con .Result o .Wait()
  4. Usa ConfigureAwait(false) en código de biblioteca; las aplicaciones ASP.NET Core generalmente no lo necesitan
  5. Siempre pasa y comprueba CancellationToken para operaciones de larga duración
  6. Gestiona AggregateException de Task.WhenAll si necesitas todos los detalles de los fallos

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados