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

IAsyncEnumerable<T> en C# — Streaming de Datos de la Forma Correcta

Usa IAsyncEnumerable<T> para transmitir datos sin cargar todo en memoria: iteradores async, streaming con EF Core, respuestas en streaming en ASP.NET Core y cancelación.

#csharp#async#dotnet

Cargar 100,000 filas de una base de datos en una List<T> antes de enviarlas al cliente es una forma segura de agotar la memoria bajo carga. IAsyncEnumerable<T>, introducido en C# 8 / .NET Core 3.0, ofrece una manera de primera clase para producir y consumir secuencias de forma asíncrona — un elemento a la vez, bajo demanda.

Cada Afirmación Aquí Está Verificada, No Narrada

El comportamiento de un stream es observable como hechos duros: cuántos elementos había creado el productor cuando el consumidor vio el primero, si un bloque finally se ejecutó, qué hace un channel acotado cuando está lleno — y, para las afirmaciones HTTP, qué llega realmente por un socket real. samples/iasyncenumerable-csharp convierte este artículo en un proyecto de consola donde cada línea de salida es una verificación que pasa. Los demos HTTP arrancan un servidor Kestrel en proceso sobre un puerto de loopback y verifican contra respuestas reales, con compuertas (TaskCompletionSource) que hacen el orden determinista: el servidor no puede producir el elemento 2 hasta que el cliente demuestra que recibió el elemento 1.

Escribir el sample corrigió tres cosas que versiones anteriores de este artículo tenían mal: yield return await ... es legal como una sola sentencia, las acciones de controlador pueden ser iteradores async, y en .NET 10 los operadores LINQ vienen en el BCL — sin necesidad del paquete System.Linq.Async. Detalles en línea más abajo.

DemoVerificado
Básicos del iteradorlos elementos llegan en orden; yield return await ... compila y corre como una sola sentencia
Pull perezosollamar al método iterador no ejecuta nada; el primer MoveNextAsync produce exactamente un elemento
CancelaciónWithCancellation inyecta el token en [EnumeratorCancellation]; el OperationCanceledException porta el token del llamador; finally se ejecuta
Break tempranosalir de await foreach con break desecha el enumerador y ejecuta el finally del iterador
Excepcioneslos elementos emitidos antes del throw se entregan; la excepción aparece en await foreach con su tipo original; finally se ejecuta
Tiempo al primer elementocon buffer: los N elementos existen antes de que empiece el consumo; streaming: exactamente uno
Memoria, medida100k filas: la lista en buffer retiene ~18 MB vivos; el pico del streaming redondea a cero
LINQ en el BCLWhere/Select/ToListAsync funcionan sin paquete NuGet en .NET 10; OrderBy entrega su primer elemento solo después de producirse todos
Channel<T>un channel acotado bloquea al escritor cuando está lleno — backpressure real; ReadAllAsync termina en Complete()
Streaming HTTPla respuesta es Transfer-Encoding: chunked; el cliente tiene el elemento 1 en mano mientras el elemento 2 demostrablemente no existe aún
Desconexión del clientecortar la conexión a mitad del stream dispara el CancellationToken del endpoint dentro del iterador
Acción de controladoruna acción declarada async IAsyncEnumerable<int> con yield transmite correctamente
Salida de consola del sample: 23 verificaciones pasando en 12 demos — yield return await corre como una sola sentencia; los iteradores son perezosos y dirigidos por pull; WithCancellation inyecta el token del llamador y finally se ejecuta; break desecha el enumerador; las excepciones aparecen en await foreach; con buffer los 5 elementos existen antes de que el consumidor empiece mientras streaming mantiene uno; la memoria medida muestra 18.5 MB con buffer contra un pico de 0.00 MB en streaming para 100k filas; LINQ funciona desde el BCL sin paquete NuGet y OrderBy almacena en buffer; un channel acotado bloquea su segunda escritura hasta ser leído; la respuesta HTTP es Transfer-Encoding chunked con el elemento 1 recibido antes de que existiera el 2; la desconexión del cliente dispara el CancellationToken del endpoint; y una acción de controlador declarada como iterador async devuelve 1, 2, 3. Todas las verificaciones pasaron.
La ejecución del sample: 23 aserciones en 12 demos, incluyendo verificaciones HTTP contra Kestrel en vivo — el streaming es un contrato: un elemento existe a la vez, y el consumidor marca el ritmo.

El Problema con List<T> para Grandes Volúmenes de Datos

Considera un endpoint de exportación típico que carga todo de antemano:

// Cargar todo antes de hacer algo — el uso de memoria aumenta con el número de filas
public async Task<IActionResult> ExportProducts()
{
    List<Product> products = await _db.Products
        .AsNoTracking()
        .ToListAsync();                 // TODAS las filas en memoria de una vez
 
    return Ok(products);               // luego serializar todo
}

Para 100 filas esto está bien. Para 100,000 filas, ToListAsync() bloquea hasta que se obtiene el resultado completo, asigna un buffer contiguo grande, y luego el serializador debe mantener la lista completa en memoria mientras escribe la respuesta.

IAsyncEnumerable<T> rompe esto en un pipeline: las filas se producen a medida que llegan de la base de datos y se consumen (serializan, procesan, reenvían) antes de que llegue el siguiente lote.

Qué es IAsyncEnumerable<T>

IAsyncEnumerable<T> es el equivalente asíncrono de IEnumerable<T>. Expone un único método:

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
 
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }
    ValueTask<bool> MoveNextAsync();
}

Puntos clave:

  • MoveNextAsync() devuelve ValueTask<bool> — awaitable y eficiente en asignaciones para el caso común de completación síncrona.
  • El enumerador en sí es IAsyncDisposable, por lo que la liberación de recursos subyacentes (conexiones DB, streams) también es asíncrona.
  • CancellationToken forma parte del contrato en el nivel superior.

Escribiendo Métodos Iteradores Async

Un iterador async es un método async que devuelve IAsyncEnumerable<T> y usa yield return. El compilador lo transforma en una máquina de estados, igual que los iteradores síncronos, pero con soporte async.

public async IAsyncEnumerable<int> GenerateSequenceAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(10);   // simular trabajo async por elemento
        yield return i;
    }
}

Puedes mezclar expresiones await libremente con yield return — incluso en la misma sentencia. Yo solía repetir la afirmación de que no se pueden combinar; el sample la refuta con una línea:

public async IAsyncEnumerable<int> FetchFirstAsync()
{
    yield return await Task.FromResult(42); // una sentencia, ambas palabras clave — legal
}

Agregar Soporte para CancellationToken

La forma idiomática de soportar cancelación en un iterador async es el atributo [EnumeratorCancellation]:

using System.Runtime.CompilerServices;
 
public async IAsyncEnumerable<Product> StreamProductsAsync(
    int categoryId,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await using var connection = await _dbFactory.OpenConnectionAsync(cancellationToken);
 
    await foreach (var product in FetchFromDbAsync(connection, categoryId, cancellationToken))
    {
        // transformación ligera por elemento
        product.Price = Math.Round(product.Price, 2);
        yield return product;
 
        // verificación explícita si el cuerpo del bucle es lento
        cancellationToken.ThrowIfCancellationRequested();
    }
}

Cuando un llamador pasa un token mediante WithCancellation(), el runtime lo inyecta automáticamente en el parámetro [EnumeratorCancellation]:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
await foreach (var product in StreamProductsAsync(categoryId: 5)
                   .WithCancellation(cts.Token))
{
    Console.WriteLine(product.Name);
}
💡

Acepta siempre [EnumeratorCancellation] CancellationToken en los iteradores async públicos. El costo es insignificante cuando no se usa, y hace que el método sea seguro para cancelación sin cambios que rompan compatibilidad después. El sample verifica el recorrido completo: cancela el token pasado a WithCancellation, y el OperationCanceledException que sale de await foreach porta ese mismo token — mientras el bloque finally del iterador aún se ejecuta.

Consumir con await foreach

await foreach es el azúcar sintáctico que llama a GetAsyncEnumerator, itera sobre MoveNextAsync, y elimina el enumerador al terminar (incluso en caso de excepción):

await foreach (var item in source)
{
    Process(item);
}
 
// se descompone aproximadamente en:
await using var enumerator = source.GetAsyncEnumerator(cancellationToken);
while (await enumerator.MoveNextAsync())
{
    Process(enumerator.Current);
}

ConfigureAwait en await foreach

Para código de biblioteca que debe evitar capturar el contexto de sincronización:

await foreach (var item in source.ConfigureAwait(false))
{
    Process(item);
}

EF Core: AsAsyncEnumerable()

Las extensiones de IQueryable<T> de EF Core incluyen AsAsyncEnumerable(), que ejecuta la consulta y transmite las filas a medida que llegan desde la base de datos, en lugar de almacenarlas todas en un buffer.

public async IAsyncEnumerable<ProductDto> StreamProductsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    // AsAsyncEnumerable() abre el reader y devuelve filas de una en una
    await foreach (var p in _db.Products
                               .AsNoTracking()           // sin seguimiento de cambios para lecturas
                               .Where(p => p.IsActive)
                               .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                               .AsAsyncEnumerable()
                               .WithCancellation(ct))
    {
        yield return p;
    }
}
⚠️

Mantén el DbContext activo durante toda la duración del stream. No lo elimines dentro del iterador, y ten en cuenta que el contexto no es thread-safe — solo un lector concurrente por contexto.

Comparando ToListAsync vs AsAsyncEnumerable

// ToListAsync — almacena todo en buffer, luego procesa
var products = await _db.Products.ToListAsync(ct);
foreach (var p in products)
    await SendToClientAsync(p, ct);
 
// AsAsyncEnumerable — procesa cada fila a medida que llega
await foreach (var p in _db.Products.AsAsyncEnumerable().WithCancellation(ct))
    await SendToClientAsync(p, ct);

Para 100,000 filas, la primera versión alcanza un pico con el grafo de objetos completo en memoria; la segunda mantiene solo una única fila a la vez dentro del cuerpo del bucle.

ASP.NET Core: Retornar IAsyncEnumerable desde Controladores

El serializador System.Text.Json de ASP.NET Core soporta IAsyncEnumerable<T> de forma nativa. Cuando lo retornas desde una acción de controlador, el runtime transmite el array JSON a medida que los elementos están disponibles:

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;
 
    public ProductsController(AppDbContext db) => _db = db;
 
    // La respuesta es un array JSON transmitido a medida que llegan las filas — sin buffer completo
    [HttpGet("export")]
    public IAsyncEnumerable<ProductDto> ExportAsync(CancellationToken ct)
    {
        return _db.Products
                  .AsNoTracking()
                  .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                  .AsAsyncEnumerable();
    }
}

No se necesita await ni wrapper Ok() — ASP.NET Core detecta IAsyncEnumerable<T> y maneja el resto. El sample verifica lo que realmente viaja por el cable: la respuesta es Transfer-Encoding: chunked (sin Content-Length, sin buffering completo), y el serializador hace flush cada vez que MoveNextAsync tiene que esperar — el cliente del sample lee [1 del socket mientras una compuerta garantiza que el elemento 2 aún no fue producido. Un matiz que vale conocer: para elementos que llegan seguidos sin await entre medio, System.Text.Json acumula en su buffer interno y hace flush por tamaño, no por elemento.

💡

El parámetro CancellationToken ct en un método de acción se vincula automáticamente al token HttpContext.RequestAborted. El sample lo verifica de punta a punta: el cliente lee el primer elemento, corta la conexión a mitad del stream, y el token se dispara dentro del iterador — su bloque finally observa la cancelación.

Versión con Minimal API

app.MapGet("/api/products/export", (AppDbContext db, CancellationToken ct) =>
    db.Products
      .AsNoTracking()
      .Select(p => new ProductDto(p.Id, p.Name, p.Price))
      .AsAsyncEnumerable());

Usando Channel<T> como Puente Productor/Consumidor

Channel<T> (de System.Threading.Channels) es útil cuando los datos son empujados desde una fuente externa (p. ej., una cola de mensajes, WebSocket o tarea en segundo plano) y quieres exponerlos como IAsyncEnumerable<T>.

public async IAsyncEnumerable<OrderEvent> WatchOrdersAsync(
    string customerId,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    // Canal acotado provee contrapresión — el productor bloquea cuando está lleno
    var channel = Channel.CreateBounded<OrderEvent>(new BoundedChannelOptions(100)
    {
        FullMode = BoundedChannelFullMode.Wait
    });
 
    // Productor: se ejecuta independientemente, escribe en el canal
    var producer = Task.Run(async () =>
    {
        try
        {
            await foreach (var evt in _messageBus.SubscribeAsync(customerId, ct))
                await channel.Writer.WriteAsync(evt, ct);
        }
        finally
        {
            channel.Writer.Complete();  // señal de fin de stream
        }
    }, ct);
 
    // Consumidor: expone el canal como IAsyncEnumerable
    await foreach (var evt in channel.Reader.ReadAllAsync(ct))
        yield return evt;
 
    await producer;   // propagar excepciones del productor
}

Channel<T> desacopla la tasa del productor de la del consumidor y proporciona contrapresión natural a través de la capacidad acotada.

Channel vs Iteración Directa

EscenarioEnfoque
Fuente pull (DB, archivo, API paginada)Iterador async directo
Fuente push (eventos, sockets, colas)Puente Channel<T>
Fan-out a múltiples consumidoresChannel<T> con múltiples lectores

IEnumerable vs IAsyncEnumerable vs IObservable

IEnumerable<T>IAsyncEnumerable<T>IObservable<T> (Rx)
Modelo de ejecuciónPull síncronoPull asíncronoPush (observer notificado)
ContrapresiónImplícita (el llamador controla)Implícita (el llamador controla)Requiere operadores (p. ej., Buffer)
CancelaciónNo integradaCancellationToken de primera claseSuscripción IDisposable
Manejo de erroresExcepciones se propagan normalmenteExcepciones se propagan normalmenteCallback OnError
Soporte LINQCompleto (System.Linq)Completo en el BCL desde .NET 10 (antes, paquete System.Linq.Async)Completo (Rx.NET)
Modelo de hilosHilo del llamadorHilo del llamador (sin scheduling implícito)Basado en Scheduler
Mejor paraColecciones en memoriaFuentes async (DB, archivos, APIs)Streams de eventos, operadores complejos
💡

Prefiere IAsyncEnumerable<T> para streaming de bases de datos e I/O. Recurre a IObservable<T> solo cuando necesites operadores reactivos (debounce, merge, throttle) o cuando un modelo push sea fundamentalmente más natural.

Uso de Memoria: List<T> vs Streaming

El sample mide la diferencia entre almacenar en buffer y transmitir 100,000 filas (un record pequeño con un id, un nombre de ~50 caracteres y un precio) comparando instantáneas de GC.GetTotalMemory contra una línea base previa:

// Enfoque A: almacenar todo en buffer
public async Task<long> BufferedApproachAsync()
{
    List<Product> products = await _db.Products
        .AsNoTracking()
        .ToListAsync();
 
    long total = 0;
    foreach (var p in products)
        total += (long)p.Price;
 
    return total;
}
 
// Enfoque B: transmitir fila por fila
public async Task<long> StreamingApproachAsync(CancellationToken ct = default)
{
    long total = 0;
 
    await foreach (var p in _db.Products
                               .AsNoTracking()
                               .AsAsyncEnumerable()
                               .WithCancellation(ct))
    {
        total += (long)p.Price;
    }
 
    return total;
}
MétricaCon BufferStreaming
Heap vivo, medido (100k filas)18.5 MB — todo el grafo de objetos a la vezel pico redondea a 0 MB — cada fila es basura antes de que exista la siguiente
Tiempo hasta el primer resultado (verificado)las 100,000 filas existen antes de que el consumidor vea la primeraexactamente 1 fila existe cuando el consumidor ve la primera
Adecuado paraConjuntos pequeños a medianosConjuntos grandes o ilimitados

Los números medidos vienen del productor en memoria del sample, así que aíslan el efecto del buffering en sí; una base de datos real agrega buffering del driver encima, y filas más pesadas escalan la cifra con buffer linealmente mientras el pico del streaming se mantiene plano.

Patrón Real: Endpoint de Exportación Grande

Juntando todo — una exportación en streaming lista para producción que maneja cancelación, aplica una transformación y transmite JSON al cliente:

[ApiController]
[Route("api/reports")]
public class ReportController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly ILogger<ReportController> _logger;
 
    public ReportController(AppDbContext db, ILogger<ReportController> logger)
    {
        _db = db;
        _logger = logger;
    }
 
    [HttpGet("orders/export")]
    [Produces("application/json")]
    public IAsyncEnumerable<OrderExportRow> ExportOrdersAsync(
        [FromQuery] DateOnly from,
        [FromQuery] DateOnly to,
        CancellationToken ct)                   // vinculado a RequestAborted automáticamente
    {
        var fromDate = from.ToDateTime(TimeOnly.MinValue);
        var toDate   = to.ToDateTime(TimeOnly.MaxValue);
 
        return StreamOrdersAsync(fromDate, toDate, ct);
    }
 
    // Método privado separado mantiene la lógica del iterador limpia
    private async IAsyncEnumerable<OrderExportRow> StreamOrdersAsync(
        DateTime fromDate,
        DateTime toDate,
        [EnumeratorCancellation] CancellationToken ct)
    {
        int count = 0;
 
        await foreach (var order in _db.Orders
                                       .AsNoTracking()
                                       .Where(o => o.CreatedAt >= fromDate && o.CreatedAt <= toDate)
                                       .OrderBy(o => o.CreatedAt)
                                       .Select(o => new
                                       {
                                           o.Id,
                                           o.CustomerName,
                                           o.Total,
                                           o.CreatedAt,
                                           ItemCount = o.Items.Count
                                       })
                                       .AsAsyncEnumerable()
                                       .WithCancellation(ct))
        {
            yield return new OrderExportRow(
                order.Id,
                order.CustomerName,
                order.Total,
                order.CreatedAt,
                order.ItemCount);
 
            count++;
 
            // registrar progreso cada 1000 filas sin bloquear el stream
            if (count % 1000 == 0)
                _logger.LogInformation("Exportadas {Count} órdenes hasta ahora", count);
        }
 
        _logger.LogInformation("Exportación completa. Total de filas: {Count}", count);
    }
}
 
public record OrderExportRow(
    Guid Id,
    string CustomerName,
    decimal Total,
    DateTime CreatedAt,
    int ItemCount);

Decisiones de diseño clave:

  1. Método iterador privado separado — una decisión de estilo, no un requisito. Yo solía afirmar que las acciones de controlador no pueden ser iteradores async; el sample lo refuta con una acción declarada async IAsyncEnumerable<int> usando yield, y transmite correctamente. Delegar a un método privado igual se gana su lugar aquí: mantiene el parseo de parámetros (DateOnlyDateTime) fuera del iterador perezoso, así una entrada inválida lanza cuando se llama la acción y no cuando empieza la enumeración.
  2. AsNoTracking() — el seguimiento de cambios agrega costo por entidad; nunca uses tracking para exportaciones de solo lectura.
  3. Select lleva la proyección al SQL — solo las columnas necesarias viajan por la red.
  4. Logging de progreso cada 1,000 filas da visibilidad sin saturar los logs.
  5. CancellationToken de la acción — si el cliente se desconecta a mitad de la exportación, la consulta DB se cancela.

LINQ sobre IAsyncEnumerable<T>

Desde .NET 10, los operadores LINQ para IAsyncEnumerable<T> (Where, Select, OrderBy, ToListAsync y el resto) vienen en el BCL — el sample los usa sin referenciar ningún paquete. En .NET 9 y anteriores, agrega el paquete NuGet System.Linq.Async para obtener los mismos operadores:

dotnet add package System.Linq.Async   # solo necesario en .NET 9 y anteriores
using System.Linq;
 
// Filtrar y transformar antes de consumir
var expensiveProducts = _db.Products
    .AsAsyncEnumerable()
    .Where(p => p.Price > 100)
    .Select(p => new { p.Name, p.Price })
    .OrderBy(p => p.Price);       // nota: ordena en memoria después del streaming
 
await foreach (var p in expensiveProducts)
    Console.WriteLine($"{p.Name}: {p.Price:C}");
⚠️

OrderBy sobre un IAsyncEnumerable<T> almacena toda la secuencia en buffer antes de devolver el primer elemento — el sample verifica que el primer elemento ordenado llega solo después de que cada elemento de la fuente fue producido. Para ordenar streams grandes, lleva el ORDER BY a la base de datos mediante IQueryable<T> antes de llamar a AsAsyncEnumerable().

Manejo de Excepciones en Iteradores Async

Las excepciones lanzadas dentro de un iterador async se propagan al llamador del await foreach exactamente como las excepciones síncronas se propagan a través de foreach — el sample verifica que los elementos emitidos antes del throw se entregan, y que la excepción aparece en el bucle con su tipo original. (Para lo que pasa con las excepciones async en general — AggregateException, Task.WhenAll, tareas no observadas — ve Manejo de Excepciones Async en C#.)

public async IAsyncEnumerable<string> RiskyStreamAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    yield return "primero";
    await Task.Delay(10, ct);
    throw new InvalidOperationException("algo salió mal");
    // nada después del throw se alcanza jamás
}
 
try
{
    await foreach (var item in RiskyStreamAsync())
        Console.WriteLine(item);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Stream falló: {ex.Message}");
}

El bloque finally (y la limpieza de IAsyncDisposable) aún se ejecuta cuando una excepción escapa:

public async IAsyncEnumerable<Row> StreamWithCleanupAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await using var reader = await OpenReaderAsync(ct);
    try
    {
        while (await reader.ReadAsync(ct))
            yield return reader.GetRow();
    }
    finally
    {
        // se ejecuta incluso si el llamador lanza excepción o cancela
        _logger.LogInformation("Reader cerrado");
    }
}

Resumen

EscenarioRecomendación
Conjunto pequeño (< ~1,000 filas)ToListAsync() — más simple, impacto en memoria despreciable
Conjunto grande, solo lecturaAsAsyncEnumerable() con await foreach
Exportación HTTP en streamingRetornar IAsyncEnumerable<T> desde la acción del controlador
Fuente de eventos pushChannel<T> conectado a IAsyncEnumerable<T>
Operadores reactivos complejosIObservable<T> (Rx.NET)
CancelaciónAgregar siempre [EnumeratorCancellation] CancellationToken
Código entre bibliotecasAgregar .ConfigureAwait(false) a await foreach

IAsyncEnumerable<T> es el valor predeterminado correcto para cualquier secuencia async que provenga de una base de datos, archivo, API externa u otra fuente de I/O. Mantiene la memoria estable, inicia el consumidor en cuanto el primer elemento está listo, y se integra limpiamente con ASP.NET Core y EF Core sin ninguna plomería extra.

Cada afirmación de comportamiento de arriba está verificada por samples/iasyncenumerable-csharp — clónalo y ejecuta dotnet run si quieres ver alguna fallar en un runtime futuro.

Dos temas vecinos valen la pena como siguiente lectura: MoveNextAsync devuelve ValueTask<bool> por una razón — ValueTask vs Task explica qué aporta eso en la ruta mayormente síncrona — y la distinción Canceled-vs-Faulted de la que dependen los demos del iterador se cubre en CancellationToken en C# — Patrones Prácticos.

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