Start Debugging

IEnumerable vs IAsyncEnumerable vs IQueryable in C#: Was sollte die Methode zurückgeben?

Drei Sequenz-Interfaces, drei Ausführungsmodelle. Verwenden Sie IQueryable, wenn eine Datenbank die Abfrage übersetzen kann, IAsyncEnumerable, wenn der Producer asynchron ist und Sie streamen möchten, IEnumerable für alles andere im Speicher.

Wenn Sie zwischen IEnumerable<T>, IAsyncEnumerable<T> und IQueryable<T> für eine Methodensignatur in C# 14 / .NET 11 wählen, ist die Regel fast mechanisch. Geben Sie IQueryable<T> nur dann zurück, wenn der Konsument weitere Where/Select/OrderBy-Aufrufe komponieren kann und der zugrundeliegende Provider (EF Core 11, LINQ to SQL, ein OData-Client) sie in die entfernte Abfrage übersetzen kann. Geben Sie IAsyncEnumerable<T> zurück, wenn der Producer pro Element oder pro Batch E/A ausführt und Sie wollen, dass der Konsument mit der Verarbeitung beginnt, bevor der Producer fertig ist. Geben Sie IEnumerable<T> für alles zurück, was bereits im Speicher ist oder was Sie an der Grenze vollständig materialisieren wollen. Der Fehler, den es zu vermeiden gilt, ist IQueryable<T> aus einem Repository heraus zu lecken: jedes nachfolgende .Where(...) wird Teil des SQL, ob Sie es wollten oder nicht, und “wo läuft diese Abfrage eigentlich” wird zu einer Frage, die Sie mit dem Debugger beantworten müssen.

Dieser Beitrag ist die Langfassung. Alle Beispiele zielen auf <TargetFramework>net11.0</TargetFramework> mit <LangVersion>14.0</LangVersion> und, wo relevant, Microsoft.EntityFrameworkCore 11.0.0.

Drei Interfaces, drei Ausführungsmodelle

Die drei Interfaces sehen auf dem Papier ähnlich aus. Alle stellen eine einzelne Sequenz von T zur Verfügung. Der Unterschied ist, wo die Arbeit passiert und wann.

Die wichtigste Konsequenz: Ein IEnumerable<T>, das von einem EF-Core-Aufruf zurückgegeben wird, hat die Datenbank bereits verlassen. Ein IQueryable<T>, das vom gleichen Aufruf zurückgegeben wird, nicht. Diese eine Tatsache ist verantwortlich für mehr “Warum ist diese Abfrage langsam”-Tickets als jede andere einzelne Ursache in EF-Core-Code.

Feature-Matrix

FähigkeitIEnumerable<T>IAsyncEnumerable<T>IQueryable<T>
Ausführungsmodellsync pullasync pulldeferred, vom Provider übersetzt
Wo die Arbeit läuftaufrufender Thread, im SpeicherProducer-Seite, awaitableentfernter Provider (DB, OData, Cosmos)
Kann await zwischen Elementenneinjan/a (keine Arbeit pro Element)
Verfügbare LINQ-OperatorenLINQ to ObjectsLINQ to Objects (Async)providerspezifische Untermenge
Komponierbar nach Rückgabeja (im Speicher)ja (im Speicher)ja (entfernt übersetzt)
Streamt ohne Pufferungja (lazy yield return)jahängt vom Provider ab
Abbruchkeiner, der Loop ist syncCancellationToken pro Elementpro Abfrage via ToListAsync(token)
Risiko beim Rückgeben aus einem Repositorygeringmittel (Lebensdauer des Providers)hoch (Aufrufer kann SQL anhängen)
Beste PassformIn-Memory-Sammlungenentfernte Streams, server-sentrepo-interne Abfrageobjekte
Materialisiert beibei jedem MoveNextbei jedem await MoveNextAsyncbeim terminalen Operator

Die Matrix ist der Beitrag. Alles darunter ist die Begründung.

Wann IEnumerable<T> der richtige Rückgabetyp ist

IEnumerable<T> ist die Standardwahl für “Ich habe Elemente, gib mir eine Sequenz”. Es ist synchron, hat jeden LINQ-to-Objects-Operator und komponiert günstig. Verwenden Sie es für:

Die Falle ist, IEnumerable<T> als Rückgabetyp einer Repository-Methode zu verwenden, die einen asynchronen E/A-Aufruf umschließt. Das zwingt das Repository, intern .ToList() zu machen und die Streaming-Eigenschaft zu verlieren, oder es zwingt den Aufrufer zu .Result und einer Threadpool-Blockade. Beides ist falsch. Wenn die Quelle asynchron ist, sollte die Signatur IAsyncEnumerable<T> oder Task<List<T>> sein, nicht IEnumerable<T>.

// .NET 11, C# 14
public static IEnumerable<string> ReadLowercaseLines(string path)
{
    foreach (var line in File.ReadLines(path))
    {
        yield return line.ToLowerInvariant();
    }
}

File.ReadLines gibt ein IEnumerable<string> zurück, das die Datei lazy liest. Die Transformation bleibt lazy. Nichts zwingt die Datei dazu, vollständig geladen zu werden, bevor das erste Element den Aufrufer erreicht.

Das Schlüsselwort yield return ist das, was dies funktionieren lässt. Es weist den Compiler an, eine Zustandsmaschine zu generieren, die Elemente einzeln zurückgibt und die Methode zwischen Yields aussetzt. Es ist das synchrone Gegenstück zu await foreach plus yield return zusammen.

Wann IAsyncEnumerable<T> der richtige Rückgabetyp ist

IAsyncEnumerable<T> ist das, wonach Sie greifen, wenn der Producer zwischen Elementen await benötigt. Das Paradebeispiel ist ein paginierter HTTP-Endpunkt: Sie holen Seite 1, liefern jedes Element, holen Seite 2, liefern jedes Element. Sie wollen, dass der Konsument mit der Arbeit an Seite 1 beginnt, während Seite 2 noch unterwegs ist. Sie wollen auch ein CancellationToken eingebunden haben, damit der Konsument den Producer sauber stoppen kann.

Verwenden Sie es für:

// .NET 11, C# 14
public static async IAsyncEnumerable<Order> FetchAllAsync(
    HttpClient http,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    string? next = "/api/orders?page=1";
    while (next is not null)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var page = await http.GetFromJsonAsync<PageOf<Order>>(next, cancellationToken)
                   ?? throw new InvalidOperationException("page was null");
        foreach (var order in page.Items)
        {
            yield return order;
        }
        next = page.NextLink;
    }
}

Zwei Details, die Leute auf dem falschen Fuß erwischen. Erstens ist [EnumeratorCancellation] erforderlich, um das Token von WithCancellation(...) an der Aufrufstelle in den Iterator zu verdrahten. Ohne es verwirft await foreach (var x in source.WithCancellation(token)) das Token still. Zweitens kann eine asynchrone Iterator-Methode kein try/catch um ein yield return für eine Ausnahme verwenden, die von einem nachgelagerten Operator kommt; die Ausnahme fließt durch den Konsumenten, nicht durch den Producer. Wickeln Sie die E/A-Aufrufe explizit ein, wenn Sie Retry-Logik brauchen.

Für EF Core 11 ist das Äquivalent auf einem DbSet<T> AsAsyncEnumerable:

// .NET 11, C# 14, EF Core 11.0.0
await foreach (var order in db.Orders
    .Where(o => o.Status == "shipped")
    .AsAsyncEnumerable()
    .WithCancellation(cancellationToken))
{
    await sink.WriteAsync(order, cancellationToken);
}

Das hält den SQL-DataReader offen und zieht Zeilen bei Bedarf nach. Das gesamte Set landet niemals in List<Order>. Für die EF-Core-Spezifika siehe wie man IAsyncEnumerable mit EF Core 11 verwendet.

Wann IQueryable<T> der richtige Rückgabetyp ist

IQueryable<T> ist die richtige Form innerhalb eines Repositorys oder eines Abfrage-Builder-Helpers, wo der Aufrufer noch komponieren soll. Es ist die falsche Form über eine Netzwerkgrenze hinweg oder aus einer Schicht heraus, die der nächste Aufrufer möglicherweise nicht versteht.

Verwenden Sie es für:

Das Muster, das beißt, ist IQueryable<T> aus einer Service-Schicht heraus offenzulegen, von der der Aufrufer annimmt, dass sie In-Memory-Daten zurückgibt:

// Anti-Pattern: kein IQueryable<T> aus einem öffentlichen Service zurückgeben
public IQueryable<Order> GetRecentOrders() => _db.Orders.Where(o => o.At > _start);

// Aufrufer, kilometerweit entfernt
var bad = service.GetRecentOrders()
                 .Where(o => SomeLocalMethod(o))   // EF Core wirft: nicht übersetzbar
                 .OrderBy(o => o.Total)
                 .Take(50)
                 .ToList();

SomeLocalMethod ist eine C#-Methode, die EF Core nicht übersetzen kann. Der Where-Aufruf hängt einen Ausdruck an, den der Provider nicht zu SQL absenken kann, und bei der Materialisierung bekommen Sie eine Ausnahme. Oder schlimmer: in einem Provider, der still auf Client-Evaluation zurückfällt, ziehen Sie versehentlich jede Zeile über die Leitung, um im Prozess zu filtern. EF Core 11 wirft standardmäßig; älterer Code mit mitten in einer Kette eingefügten AsEnumerable-Wechseln ist noch schwerer zu lesen.

Die Lösung ist, an der Grenze zu materialisieren:

// .NET 11, C# 14
public async Task<IReadOnlyList<Order>> GetRecentOrdersAsync(
    int count, CancellationToken ct)
{
    return await _db.Orders
        .Where(o => o.At > _start)
        .OrderByDescending(o => o.At)
        .Take(count)
        .ToListAsync(ct);
}

Die Methode gibt jetzt eine konkrete, materialisierte Sammlung zurück. Der Aufrufer kann nicht versehentlich SQL anhängen. Wenn der Aufrufer einen anderen Filter will, fragt er explizit über einen Parameter oder eine neue Methode. Das ist die gleiche Begründung, die wie man N+1-Abfragen in EF Core 11 erkennt antreibt: Seien Sie explizit darüber, wo die Abfragegrenze liegt.

Der Benchmark: eine Million Zeilen auf drei Arten streamen

Eine echte Zahl. Das Setup: 1.000.000 schmale Zeilen (ein Guid Id, ein int Status, ein DateTime At) in SQL Server 2022. Der Konsument zählt Zeilen, die einen Filter (Status == 1) passieren, und schreibt eine Summe der Timestamps. Wir machen es auf drei Arten:

// .NET 11, C# 14, EF Core 11.0.0, BenchmarkDotNet 0.14.0
[MemoryDiagnoser]
public class SequenceShapes
{
    private AppDb _db = null!;

    [GlobalSetup] public void Setup() => _db = AppDb.Connect();

    [Benchmark]
    public long Materialize_Then_Enumerate()
    {
        var rows = _db.Events.ToList();              // pull all 1,000,000
        long sum = 0; long count = 0;
        foreach (var r in rows)
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark]
    public async Task<long> StreamAsync()
    {
        long sum = 0; long count = 0;
        await foreach (var r in _db.Events.AsAsyncEnumerable())
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark(Baseline = true)]
    public async Task<long> Queryable_Aggregate()
    {
        var count = await _db.Events.Where(e => e.Status == 1).CountAsync();
        var sum   = await _db.Events.Where(e => e.Status == 1)
                                    .SumAsync(e => (long)e.At.Ticks);
        return sum + count;
    }
}

Methodik: BenchmarkDotNet 0.14.0, .NET 11.0.0 RTM, EF Core 11.0.0, SQL Server 2022 16.0.4135 auf derselben Maschine über Loopback. Windows 11 24H2, AMD Ryzen 9 7900X, 64 GB DDR5. Die Zahlen stammen aus einem repräsentativen Lauf.

MethodeMittelZugewiesen
Queryable_Aggregate (Baseline)38 ms1,4 KB
StreamAsync1.210 ms410 MB
Materialize_Then_Enumerate1.380 ms432 MB

Das Muster ist konsistent damit, wie die drei Interfaces funktionieren. IQueryable<T> lässt die Datenbank die Zählung und die Summe machen und schickt zwei Skalare zurück. IAsyncEnumerable<T> spart Ihnen etwa 12 Prozent Gesamtzeit gegenüber ToList-und-Loop, und es spart das spitzenförmige Speicherprofil (die List<Event>-Zuweisung in Materialize_Then_Enumerate ist in dotnet-counters als einzelner gen2-Peak sichtbar). Aber beide verlieren gegen die queryable-Form um den Faktor 30, weil die Arbeit zur Datenbank gehörte, nicht zum Client.

Die Erkenntnis ist nicht “immer IQueryable verwenden”. Sie lautet: Wenn die Operation in der Abfragesprache des Providers ausgedrückt werden kann, ziehen Sie die Zeilen nicht heraus. Wenn Sie die Zeilen herausziehen müssen (CSV-Export, eine Transformation, die nicht übersetzt, ein nachgelagerter Service, der einzelne Elemente will), bevorzugen Sie IAsyncEnumerable<T> gegenüber einem materialisierten IEnumerable<T>.

Die Stolpersteine, die für Sie entscheiden

Ein paar Dinge entscheiden für Sie, unabhängig von Präferenz.

Die meinungsstarke Empfehlung, neu formuliert

Standardmäßig IEnumerable<T> für In-Memory-Arbeit. Greifen Sie zu IAsyncEnumerable<T> in dem Moment, in dem der Producer await benötigt, und verdrahten Sie [EnumeratorCancellation] von Tag eins an. Halten Sie IQueryable<T> innerhalb der Repository- oder Abfrage-Builder-Schicht; konvertieren Sie zu einer materialisierten IReadOnlyList<T> oder zu IAsyncEnumerable<T>, bevor Sie eine Service-Grenze überqueren.

Zwei Korollare, die es wert sind, ins Muskelgedächtnis übernommen zu werden:

Verwandt

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück