Skip to content

6.12 - Ôn tập và đánh giá Module 6 ​

Tóm tắt: Bài ôn tập cuối Module 6 gồm ba phần: 10 câu quiz bám vào những chỗ dễ sai nhất của async trong .NET (thread và await, async void, deadlock .Result, ConfigureAwait, await trong vòng lặp, DbContext song song, WhenAll mất ngoại lệ, huỷ, BackgroundService), ba bài lab chạy được bằng một class library và một project xUnit, có lời giải và tiêu chí chấm kiểm tra được, và một rubric mô tả từng mức điểm. Làm quiz trước khi xem đáp án, làm lab trước khi mở lời giải.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Trả lời đúng ít nhất 8/10 câu quiz và giải thích lý do cho từng câu.
  • Chuyển một service đồng bộ dùng EF Core sang async, truyền CancellationToken suốt chuỗi, và test việc huỷ.
  • Gọi nhiều kênh song song bằng Task.WhenAll với hạn chung, ghi nhận lỗi từng kênh mà không nuốt tín hiệu huỷ của bên gọi.
  • Xuất dữ liệu lớn bằng IAsyncEnumerable<T> mà không nạp hết vào bộ nhớ.
  • Tự chấm bài lab theo rubric và chỉ ra bài nào trong module cần đọc lại.

Cần biết trước: toàn bộ Module 6, từ bài 6.1 tới bài 6.11; viết test xUnit (bài 0.4); EF Core tối thiểu với SQLite (bài 8.10). Lab dùng SQLite chạy trong bộ nhớ nên không cần cài database.

Nội dung bài học ​

6.12.1 — Cách làm bài ​

  1. Làm Phần A (quiz) trên giấy hoặc file nháp, ghi đáp án kèm một dòng lý do. Xong cả 10 câu mới mở đáp án.
  2. Làm Phần B (lab) trong một solution gồm hai project: Crm.Lab (class library) và Crm.Lab.Tests (xUnit, tham chiếu Crm.Lab). Ba bài lab dùng chung solution và chung lớp Customer, CrmDbContext.
  3. Tự chấm theo rubric ở mục 6.12.4. Câu quiz sai và tiêu chí lab bị trừ điểm cho biết bài nào cần đọc lại.

6.12.2 — Phần A: Quiz (20 điểm, mỗi câu 2 điểm) ​

Câu 1. Một endpoint await _db.Orders.ToListAsync(ct) mất 50 ms. Trong 50 ms đó, thread đã chạy endpoint làm gì? A. Ngồi chờ kết quả, không làm gì khác B. Được trả về thread pool và có thể phục vụ request khác C. Bị khoá cho tới khi database trả lời D. Chuyển sang một thread mới do await tạo ra

Đáp án câu 1

B. await không tạo thread mới, và với I/O thật thì trong lúc chờ không có thread nào bị giữ. Khi dữ liệu về, một thread bất kỳ của pool chạy tiếp. Đó là toàn bộ lý do async tăng thông lượng (bài 6.1, bài 6.2).

Câu 2. Khi nào được dùng async void? A. Cho phương thức fire-and-forget trong backend B. Cho event handler của giao diện (WPF, WinForms), vì chữ ký do framework quy định, và phải bọc toàn bộ thân trong try/catch C. Cho controller action không trả dữ liệu D. Cho phương thức public của thư viện

Đáp án câu 2

B. Ngoại lệ trong async void không có Task nào để lưu, nên làm sập tiến trình và try/catch bên gọi không bắt được. Trong backend không có trường hợp nào cần async void; việc chạy nền thì dùng hàng đợi và BackgroundService (bài 6.2, bài 6.6).

Câu 3. Cùng một dòng var data = GetDataAsync().Result;. Câu nào đúng? A. Treo vĩnh viễn ở cả WPF lẫn ASP.NET Core B. Treo ở WPF (luồng UI vừa bị chặn vừa là nơi phần sau await phải quay về); ở ASP.NET Core không treo nhưng giam một thread của pool trong lúc chờ C. Không sao ở cả hai vì .Result chỉ đọc kết quả D. Treo ở ASP.NET Core, không treo ở WPF

Đáp án câu 3

B. WPF có SynchronizationContext một luồng; ASP.NET Core không có. Không deadlock không có nghĩa là an toàn: dưới tải, .Result gây thread pool starvation (bài 6.3, bài 6.11).

Câu 4. Dự án nào nên bật quy tắc CA2007 (bắt buộc ConfigureAwait) và dùng ConfigureAwait(false) ở mọi await? A. Mọi dự án B. Ứng dụng ASP.NET Core C. Thư viện dùng lại, có thể được gọi từ WPF hay ứng dụng cũ có SynchronizationContext D. Không dự án nào, ConfigureAwait đã lỗi thời

Đáp án câu 4

C. Ứng dụng ASP.NET Core không có context để quay về nên không cần, và rải ConfigureAwait(false) chỉ làm rối code. Thư viện thì không biết ai gọi mình (bài 6.3).

Câu 5. Đoạn code sau gọi 500 lần một API mất khoảng 1 giây. Tổng thời gian khoảng bao nhiêu?

csharp
foreach (var c in customers)
    await _api.SyncAsync(c, ct);

A. Khoảng 1 giây, vì code đã async B. Khoảng 500 giây, vì mỗi vòng chờ xong mới sang vòng sau C. Khoảng 50 giây D. Không xác định

Đáp án câu 5

B. async giải phóng thread trong lúc chờ nhưng không tự làm các lời gọi chạy song song. Muốn song song phải khởi động nhiều task rồi chờ chung, và nên giới hạn số lượng bằng Parallel.ForEachAsync (bài 6.10).

Câu 6. Đoạn sau chạy thế nào?

csharp
var t1 = _db.Customers.ToListAsync(ct);
var t2 = _db.Orders.ToListAsync(ct);
await Task.WhenAll(t1, t2);

A. Chạy song song, nhanh gấp đôi B. Ném InvalidOperationException: A second operation was started on this context instance... C. EF Core tự xếp hàng hai truy vấn D. Chỉ chạy truy vấn đầu

Đáp án câu 6

B. DbContext chỉ cho một thao tác tại một thời điểm. Muốn song song thì mỗi task một context từ IDbContextFactory, và với hai truy vấn nhỏ thì thường không đáng (bài 6.5).

Câu 7. Ba task cùng ném ngoại lệ. try { await Task.WhenAll(t1, t2, t3); } catch (Exception ex) { ... } bắt được gì? A. Một AggregateException chứa cả ba B. Một trong ba ngoại lệ; muốn đủ ba thì đọc Exception của chính task mà WhenAll trả về C. Ngoại lệ của t1, luôn luôn D. Không bắt được gì

Đáp án câu 7

B. await mở gói AggregateException và ném lại ngoại lệ đầu tiên trong danh sách; thứ tự đó đổi theo task nào lỗi trước. Gán var all = Task.WhenAll(...) rồi đọc all.Exception.InnerExceptions để có đủ (bài 6.5).

Câu 8. Một phương thức bắt OperationCanceledException rồi return null;. Vấn đề là gì? A. Không có vấn đề, đó là cách xử lý huỷ chuẩn B. Bên gọi không phân biệt được "không có dữ liệu" với "đã bỏ cuộc giữa chừng"; nếu giá trị đó đi tiếp vào một quyết định ghi, dữ liệu có thể sai C. Trình biên dịch báo lỗi D. Chỉ sai trong ASP.NET Framework

Đáp án câu 8

B. OperationCanceledException là tín hiệu điều khiển, không phải lỗi cần nuốt: để nó bay lên, chỉ log ở mức Information nếu cần (bài 6.4).

Câu 9. BackgroundService đọc channel không có try/catch quanh từng job. Một job ném ngoại lệ. Với cấu hình mặc định của .NET 10, chuyện gì xảy ra? A. Job đó bị bỏ qua, consumer tiếp tục B. Consumer chết im lặng, ứng dụng vẫn phục vụ HTTP C. ExecuteAsync kết thúc với lỗi và host dừng cả ứng dụng (BackgroundServiceExceptionBehavior.StopHost) D. Host tự khởi động lại consumer

Đáp án câu 9

C. Mặc định từ .NET 6 là StopHost. Đáp án B chỉ xảy ra khi dự án đổi sang Ignore. Cả hai trường hợp đều cho thấy consumer phải tự bắt lỗi từng job (bài 6.8).

Câu 10. Kiểu trả về nào nên là mặc định cho một phương thức async trong service? A. ValueTask<T>, vì nó luôn nhanh hơn B. Task<T>; chỉ đổi sang ValueTask<T> khi phương thức thường hoàn tất đồng bộ, nằm trên đường chạy nóng, và đã đo thấy cấp phát là vấn đề C. void D. Task<T> cho đọc, ValueTask<T> cho ghi

Đáp án câu 10

B. ValueTask chỉ await được một lần và có thêm quy tắc sử dụng. Benchmark ở bài 6.9 cho thấy nó giảm cấp phát 11 lần trong trường hợp cache 95% nhưng không nhanh hơn rõ rệt về thời gian (bài 6.2).

6.12.3 — Phần B: Lab (80 điểm) ​

Dựng solution:

bash
dotnet new classlib -n Crm.Lab
dotnet new xunit -n Crm.Lab.Tests
dotnet add Crm.Lab.Tests reference Crm.Lab
dotnet add Crm.Lab package Microsoft.EntityFrameworkCore.Sqlite
dotnet add Crm.Lab package Microsoft.Extensions.Logging.Abstractions

Microsoft.Extensions.Logging.Abstractions cho ILogger<T> và NullLogger<T> (logger không ghi gì, dùng trong test).

Bài 1 — Chuyển CustomerSyncService sang async (Dễ, 25 điểm) ​

Cho service đồng bộ sau:

csharp
// TRƯỚC — đồng bộ, giam thread trong lúc chờ database
public class CustomerSyncService(CrmDbContext db)
{
    public Customer? GetById(int id)
        => db.Customers.FirstOrDefault(c => c.Id == id);

    public List<Customer> GetActive()
        => db.Customers.Where(c => c.IsActive).ToList();

    public void UpdateLastContact(int id, DateTime contactDate)
    {
        var customer = db.Customers.Find(id);
        if (customer is null) return;
        customer.LastContactDate = contactDate;
        db.SaveChanges();
    }
}

Yêu cầu:

  1. Viết CustomerService với GetByIdAsync, GetActiveAsync, UpdateLastContactAsync, dùng FirstOrDefaultAsync, ToListAsync, FindAsync, SaveChangesAsync.
  2. Mỗi phương thức nhận CancellationToken ct = default làm tham số cuối và truyền nó xuống mọi lời gọi EF Core.
  3. UpdateLastContactAsync trả về bool: false nếu không có khách hàng.
  4. Viết test bằng SQLite trong bộ nhớ: một test cho kết quả đúng, một test chứng minh token đã huỷ làm phương thức ném OperationCanceledException, một test chứng minh khi huỷ thì không có gì được lưu.

Tiêu chí chấm:

Tiêu chíĐiểm
Ba phương thức async đúng tên, đúng kiểu trả về, không còn lời gọi EF Core đồng bộ nào8
CancellationToken là tham số cuối và được truyền vào mọi lời gọi EF Core6
Không có .Result, .Wait(), async void3
Test dùng SQLite trong bộ nhớ, có test huỷ (ThrowsAnyAsync<OperationCanceledException>) và test "không lưu khi huỷ", dotnet test xanh8
Lời giải — Bài 1

Crm.Lab/Customers.cs:

csharp
using Microsoft.EntityFrameworkCore;

namespace Crm.Lab;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? LastContactDate { get; set; }
}

public class CrmDbContext(DbContextOptions<CrmDbContext> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
}

// Bài 1 — bản async của CustomerSyncService
public class CustomerService(CrmDbContext db)
{
    public Task<Customer?> GetByIdAsync(int id, CancellationToken ct = default)
        => db.Customers.FirstOrDefaultAsync(c => c.Id == id, ct);

    public Task<List<Customer>> GetActiveAsync(CancellationToken ct = default)
        => db.Customers.Where(c => c.IsActive).ToListAsync(ct);

    // Trả về false nếu không có khách hàng, thay cho "return" im lặng của bản cũ
    public async Task<bool> UpdateLastContactAsync(int id, DateTime contactDate, CancellationToken ct = default)
    {
        var customer = await db.Customers.FindAsync([id], ct);
        if (customer is null) return false;

        customer.LastContactDate = contactDate;
        await db.SaveChangesAsync(ct);
        return true;
    }
}

Crm.Lab.Tests/LabTests.cs, phần chung và test của bài 1:

csharp
using System.Diagnostics;
using Crm.Lab;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;

namespace Crm.Lab.Tests;

// Database SQLite trong bộ nhớ: sống chừng nào connection còn mở
public sealed class SqliteFixture : IDisposable
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");
    public CrmDbContext Db { get; }

    public SqliteFixture()
    {
        _connection.Open();
        Db = new CrmDbContext(new DbContextOptionsBuilder<CrmDbContext>().UseSqlite(_connection).Options);
        Db.Database.EnsureCreated();
        Db.Customers.AddRange(
            new Customer { Name = "An", Email = "[email protected]", IsActive = true, CreatedAt = new DateTime(2026, 1, 10) },
            new Customer { Name = "Bình", Email = "[email protected]", IsActive = false, CreatedAt = new DateTime(2026, 2, 5) },
            new Customer { Name = "Chi, Lê", Email = "[email protected]", IsActive = true, CreatedAt = new DateTime(2026, 3, 1) });
        Db.SaveChanges();
        Db.ChangeTracker.Clear();
    }

    public void Dispose() { Db.Dispose(); _connection.Dispose(); }
}

public class CustomerServiceTests
{
    [Fact]
    public async Task GetActiveAsync_ReturnsOnlyActive()
    {
        using var f = new SqliteFixture();
        var result = await new CustomerService(f.Db).GetActiveAsync();
        Assert.Equal(2, result.Count);
    }

    [Fact]
    public async Task GetActiveAsync_CancelledToken_Throws()
    {
        using var f = new SqliteFixture();
        using var cts = new CancellationTokenSource();
        cts.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => new CustomerService(f.Db).GetActiveAsync(cts.Token));
    }

    [Fact]
    public async Task UpdateLastContactAsync_CancelledToken_DoesNotSave()
    {
        using var f = new SqliteFixture();
        using var cts = new CancellationTokenSource();
        cts.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => new CustomerService(f.Db).UpdateLastContactAsync(1, new DateTime(2026, 9, 1), cts.Token));

        f.Db.ChangeTracker.Clear();
        Assert.Null((await f.Db.Customers.FindAsync(1))!.LastContactDate);
    }

    [Fact]
    public async Task UpdateLastContactAsync_UnknownId_ReturnsFalse()
    {
        using var f = new SqliteFixture();
        Assert.False(await new CustomerService(f.Db).UpdateLastContactAsync(999, DateTime.UtcNow));
    }
}

Ba chi tiết:

  1. SQLite :memory: chỉ sống khi connection còn mở, nên fixture tự mở một SqliteConnection và truyền nó cho UseSqlite. Mỗi test tạo fixture riêng, dữ liệu không dính sang nhau.
  2. ThrowsAnyAsync chứ không phải ThrowsAsync. EF Core có thể ném TaskCanceledException (lớp con của OperationCanceledException); ThrowsAnyAsync chấp nhận cả lớp con.
  3. Test "không lưu khi huỷ" gọi ChangeTracker.Clear() trước khi đọc lại, để chắc chắn giá trị đọc từ database chứ không phải từ bộ nhớ của context.

Bài 2 — Gửi thông báo qua ba kênh song song, có hạn chung (Trung bình, 30 điểm) ​

Viết CustomerNotificationService nhận danh sách INotifier (email, SMS, push) qua constructor:

csharp
public interface INotifier
{
    string Channel { get; }
    Task SendAsync(int customerId, string message, CancellationToken ct);
}

SendToAllChannelsAsync(int customerId, string message, CancellationToken ct = default) phải:

  1. Gửi qua mọi kênh song song bằng Task.WhenAll.
  2. Áp hạn chung (mặc định 5 giây, cho phép truyền vào constructor để test) bằng CreateLinkedTokenSource nối token của bên gọi với token hết hạn.
  3. Kênh nào lỗi hoặc quá hạn thì ghi log và tính là thất bại, không làm hỏng các kênh khác.
  4. Nếu bên gọi huỷ thì ném OperationCanceledException, không trả về một con số trông như hợp lệ.
  5. Trả về số kênh gửi thành công.

Tiêu chí chấm:

Tiêu chíĐiểm
Các kênh chạy song song (tổng thời gian khoảng bằng kênh chậm nhất hoặc bằng hạn, không phải tổng)6
Hạn chung dùng CreateLinkedTokenSource, cả hai CancellationTokenSource đều nằm trong using6
Lỗi và quá hạn của từng kênh được bắt riêng trong kênh đó, có log, trả về đúng số kênh thành công8
Phân biệt được bên gọi huỷ (ném lên) với quá hạn (tính là thất bại) bằng mệnh đề when6
Test: một kênh thành công, một kênh ném lỗi, một kênh treo; kết quả là 1 và xong trước 2 giây; thêm test bên gọi huỷ thì ném4
Lời giải — Bài 2

Crm.Lab/Notifications.cs:

csharp
using Microsoft.Extensions.Logging;

namespace Crm.Lab;

public interface INotifier
{
    string Channel { get; }
    Task SendAsync(int customerId, string message, CancellationToken ct);
}

// Bài 2 — gửi qua mọi kênh song song, có hạn chung
public class CustomerNotificationService(
    IEnumerable<INotifier> notifiers,
    ILogger<CustomerNotificationService> logger,
    TimeSpan? timeout = null)
{
    private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(5);

    public async Task<int> SendToAllChannelsAsync(int customerId, string message, CancellationToken ct = default)
    {
        using var timeoutCts = new CancellationTokenSource(_timeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);

        var results = await Task.WhenAll(
            notifiers.Select(n => SendOneAsync(n, customerId, message, linkedCts.Token, ct)));

        return results.Count(ok => ok);
    }

    private async Task<bool> SendOneAsync(
        INotifier notifier, int customerId, string message,
        CancellationToken linkedToken, CancellationToken callerToken)
    {
        try
        {
            await notifier.SendAsync(customerId, message, linkedToken);
            return true;
        }
        catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
        {
            // Hết hạn do ta đặt ra: là lỗi của kênh này, ghi log và đi tiếp
            logger.LogWarning("Kênh {Channel} quá {Timeout} giây", notifier.Channel, _timeout.TotalSeconds);
            return false;
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogError(ex, "Kênh {Channel} gửi thất bại", notifier.Channel);
            return false;
        }
        // Bên gọi huỷ: OperationCanceledException bay lên, không bị nuốt
    }
}

Test:

csharp
public class NotificationTests
{
    private sealed class OkNotifier(string channel) : INotifier
    {
        public string Channel => channel;
        public Task SendAsync(int id, string m, CancellationToken ct) => Task.Delay(50, ct);
    }
    private sealed class FailingNotifier : INotifier
    {
        public string Channel => "sms";
        public async Task SendAsync(int id, string m, CancellationToken ct)
        { await Task.Delay(10, ct); throw new HttpRequestException("SMS gateway 503"); }
    }
    private sealed class HangingNotifier : INotifier
    {
        public string Channel => "push";
        public Task SendAsync(int id, string m, CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
    }

    [Fact]
    public async Task OneFails_OneHangs_ReturnsOneSuccess_WithinTimeout()
    {
        var service = new CustomerNotificationService(
            [new OkNotifier("email"), new FailingNotifier(), new HangingNotifier()],
            NullLogger<CustomerNotificationService>.Instance,
            timeout: TimeSpan.FromMilliseconds(300));

        var sw = Stopwatch.StartNew();
        var sent = await service.SendToAllChannelsAsync(1, "Xin chào");

        Assert.Equal(1, sent);
        Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"mất {sw.Elapsed}");
    }

    [Fact]
    public async Task CallerCancels_ThrowsInsteadOfReturningCount()
    {
        var service = new CustomerNotificationService(
            [new OkNotifier("email"), new HangingNotifier()],
            NullLogger<CustomerNotificationService>.Instance,
            timeout: TimeSpan.FromSeconds(10));

        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.SendToAllChannelsAsync(1, "x", cts.Token));
    }
}

Điểm mấu chốt nằm ở hai mệnh đề when:

  • catch (OperationCanceledException) when (!callerToken.IsCancellationRequested): token nối bị huỷ mà bên gọi không huỷ, nghĩa là hạn của ta đã hết. Đó là lỗi của kênh, ghi log và trả false.
  • Khi bên gọi huỷ, không khối catch nào khớp, ngoại lệ bay lên, Task.WhenAll kết thúc ở trạng thái bị huỷ và await ném OperationCanceledException cho bên gọi. Đúng quy tắc "không nuốt tín hiệu huỷ" của bài 6.4.

Mỗi kênh tự bắt lỗi của nó (mẫu "thu thập kết quả từng phần" ở bài 6.5), nên WhenAll không bao giờ mất ngoại lệ. Kết quả trên .NET 10: cả 9 test của ba bài lab xanh, bộ test chạy khoảng 1 giây.

Bài 3 — Xuất khách hàng ra CSV bằng IAsyncEnumerable<T> (Khá, 25 điểm) ​

csharp
public record CustomerExportDto(int Id, string Name, string Email, DateTime CreatedAt);
public record CustomerExportCriteria(DateTime? FromDate, DateTime? ToDate, bool OnlyActive);

public class CustomerExportService(CrmDbContext db)
{
    // Stream khách hàng từ database theo điều kiện lọc, không nạp hết vào RAM
    public IAsyncEnumerable<CustomerExportDto> StreamAsync(CustomerExportCriteria criteria, CancellationToken ct = default)
        => throw new NotImplementedException();

    // Dùng StreamAsync để ghi file CSV từng dòng; trả về số dòng dữ liệu đã ghi
    public Task<int> ExportToCsvAsync(string filePath, CustomerExportCriteria criteria, CancellationToken ct = default)
        => throw new NotImplementedException();
}

Yêu cầu:

  1. StreamAsync là async iterator (async IAsyncEnumerable<T> + yield return), áp điều kiện lọc của criteria trong truy vấn (không lọc sau khi đã nạp), dùng AsNoTracking() và AsAsyncEnumerable().
  2. Tham số token có [EnumeratorCancellation] để huỷ có tác dụng.
  3. ExportToCsvAsync dùng await foreach để ghi từng dòng; trường có dấu phẩy phải được bọc trong nháy kép.
  4. Test: xuất với OnlyActive, lọc theo khoảng ngày, và huỷ giữa chừng thì ném OperationCanceledException.

Tiêu chí chấm:

Tiêu chíĐiểm
StreamAsync lọc trong truy vấn, AsNoTracking, trả dần bằng yield return8
[EnumeratorCancellation] có mặt và test huỷ giữa chừng chứng minh vòng lặp dừng sau đúng một dòng7
ExportToCsvAsync dùng await using cho StreamWriter, ghi từng dòng, xử lý dấu phẩy6
Ba test xanh4
Lời giải — Bài 3

Crm.Lab/Export.cs:

csharp
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;

namespace Crm.Lab;

public record CustomerExportDto(int Id, string Name, string Email, DateTime CreatedAt);
public record CustomerExportCriteria(DateTime? FromDate, DateTime? ToDate, bool OnlyActive);

// Bài 3 — stream khách hàng, không nạp hết vào RAM
public class CustomerExportService(CrmDbContext db)
{
    public async IAsyncEnumerable<CustomerExportDto> StreamAsync(
        CustomerExportCriteria criteria,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        var query = db.Customers.AsNoTracking();
        if (criteria.OnlyActive) query = query.Where(c => c.IsActive);
        if (criteria.FromDate is { } from) query = query.Where(c => c.CreatedAt >= from);
        if (criteria.ToDate is { } to) query = query.Where(c => c.CreatedAt < to);

        var rows = query
            .OrderBy(c => c.Id)
            .Select(c => new CustomerExportDto(c.Id, c.Name, c.Email, c.CreatedAt))
            .AsAsyncEnumerable();

        await foreach (var row in rows.WithCancellation(ct))
            yield return row;
    }

    public async Task<int> ExportToCsvAsync(string filePath, CustomerExportCriteria criteria, CancellationToken ct = default)
    {
        await using var writer = new StreamWriter(filePath);
        await writer.WriteLineAsync("Id,Name,Email,CreatedAt");

        int count = 0;
        await foreach (var c in StreamAsync(criteria, ct))
        {
            await writer.WriteLineAsync($"{c.Id},{Csv(c.Name)},{Csv(c.Email)},{c.CreatedAt:O}");
            count++;
        }
        return count;
    }

    // Trường có dấu phẩy, nháy kép hoặc xuống dòng thì bọc trong nháy kép
    private static string Csv(string value)
        => value.IndexOfAny([',', '"', '\n']) >= 0 ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}

Test:

csharp
public class ExportTests
{
    [Fact]
    public async Task ExportToCsvAsync_OnlyActive_WritesHeaderAndRows()
    {
        using var f = new SqliteFixture();
        var path = Path.GetTempFileName();

        var count = await new CustomerExportService(f.Db)
            .ExportToCsvAsync(path, new CustomerExportCriteria(null, null, OnlyActive: true));

        var lines = await File.ReadAllLinesAsync(path);
        Assert.Equal(2, count);
        Assert.Equal(3, lines.Length);                           // header + 2 dòng
        Assert.StartsWith("3,\"Chi, Lê\",", lines[2]);           // tên có dấu phẩy được bọc nháy kép
        File.Delete(path);
    }

    [Fact]
    public async Task StreamAsync_DateRange_Filters()
    {
        using var f = new SqliteFixture();
        var rows = new List<CustomerExportDto>();
        await foreach (var r in new CustomerExportService(f.Db)
            .StreamAsync(new CustomerExportCriteria(new DateTime(2026, 2, 1), new DateTime(2026, 3, 1), false)))
            rows.Add(r);
        Assert.Equal("Bình", Assert.Single(rows).Name);
    }

    [Fact]
    public async Task StreamAsync_CancelMidway_Throws()
    {
        using var f = new SqliteFixture();
        using var cts = new CancellationTokenSource();
        var read = 0;

        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
        {
            await foreach (var _ in new CustomerExportService(f.Db)
                .StreamAsync(new CustomerExportCriteria(null, null, false), cts.Token))
            {
                read++;
                cts.Cancel();                                    // huỷ sau dòng đầu tiên
            }
        });
        Assert.Equal(1, read);
    }
}

Ba chi tiết:

  1. Lọc trong truy vấn. Các Where được nối vào IQueryable trước khi gọi AsAsyncEnumerable(), nên EF Core dịch chúng thành WHERE trong SQL. Lọc sau khi nạp là kéo cả bảng về.
  2. [EnumeratorCancellation]. Thiếu nó, token truyền qua WithCancellation từ bên ngoài sẽ không tới được vòng lặp bên trong (bài 6.6). Test huỷ giữa chừng chứng minh vòng lặp dừng ngay sau dòng đầu.
  3. ExportToCsvAsync không bao giờ giữ cả danh sách. Bộ nhớ của nó không phụ thuộc số khách hàng; số đo ở bài tập 1 của bài 6.6: 23 MB so với 168 MB cho 200.000 dòng.

6.12.4 — Rubric và ngưỡng điểm ​

Tổng điểm = quiz (20) + lab (80, theo bảng tiêu chí từng bài).

MứcTổng điểmMô tả
Chưa đạtdưới 60Lab chưa chạy được ít nhất hai bài, hoặc sai từ 5 câu quiz trở lên. Đọc lại các bài ứng với câu sai trước khi sang Module 7.
Đạt60 – 74Bài 1 và Bài 2 chạy đúng, dotnet test xanh. Có thể thiếu phần phân biệt huỷ và quá hạn ở Bài 2. Giải thích được đáp án quiz bằng lời của mình.
Khá75 – 89Cả ba bài đủ tiêu chí, kể cả test huỷ ở cả ba bài. Mọi phương thức async có CancellationToken là tham số cuối và truyền tới tận EF Core.
Giỏi90 – 100Như mức Khá, cộng thêm một trong hai: bật Microsoft.VisualStudio.Threading.Analyzers với VSTHRD002, VSTHRD100, VSTHRD103 ở mức lỗi mà build vẫn sạch (bài 6.7); hoặc đo bộ nhớ của ExportToCsvAsync với 100.000 dòng so với bản dùng ToListAsync.

6.12.5 — Tóm tắt Module 6 ​

BàiĐiều cần mang theo
6.1Async tăng thông lượng, không giảm độ trễ; CPU thấp mà timeout là dấu hiệu starvation
6.2Task là lời hứa, không phải thread; không async void trong backend; giữ await khi có using/try
6.3Deadlock cần context một luồng và một lời gọi chặn; ConfigureAwait(false) chỉ cho thư viện
6.4Huỷ là hợp tác; truyền token tới tận I/O; không nuốt OperationCanceledException
6.5WhenAll biến tổng thành max; giới hạn song song; mỗi task một DbContext
6.6IAsyncEnumerable cho dữ liệu lớn; cache Task<T> chống stampede; await việc xếp hàng
6.7Chín bẫy; analyzer bắt được một phần, phần còn lại phải review
6.8Bounded channel tạo backpressure; consumer tự bắt lỗi từng job; drain khi tắt
6.9TimeProvider để test thời gian; PeriodicTimer ném khi huỷ; quyết định ValueTask bằng số đo cấp phát
6.10await trong vòng lặp là tuần tự; song song có giới hạn; việc dài thành job nền
6.11Một .Result làm chậm cả API; chặn bằng analyzer và cảnh báo hàng đợi thread pool

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.