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,awaittrong vòng lặp,DbContextsong song,WhenAllmấ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
CancellationTokensuốt chuỗi, và test việc huỷ. - Gọi nhiều kênh song song bằng
Task.WhenAllvớ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
- 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.
- 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ếuCrm.Lab). Ba bài lab dùng chung solution và chung lớpCustomer,CrmDbContext. - 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?
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?
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:
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.AbstractionsMicrosoft.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:
// 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:
- Viết
CustomerServicevớiGetByIdAsync,GetActiveAsync,UpdateLastContactAsync, dùngFirstOrDefaultAsync,ToListAsync,FindAsync,SaveChangesAsync. - Mỗi phương thức nhận
CancellationToken ct = defaultlàm tham số cuối và truyền nó xuống mọi lời gọi EF Core. UpdateLastContactAsynctrả vềbool:falsenếu không có khách hàng.- 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ào | 8 |
CancellationToken là tham số cuối và được truyền vào mọi lời gọi EF Core | 6 |
Không có .Result, .Wait(), async void | 3 |
Test dùng SQLite trong bộ nhớ, có test huỷ (ThrowsAnyAsync<OperationCanceledException>) và test "không lưu khi huỷ", dotnet test xanh | 8 |
Lời giải — Bài 1
Crm.Lab/Customers.cs:
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:
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:
- SQLite
:memory:chỉ sống khi connection còn mở, nên fixture tự mở mộtSqliteConnectionvà truyền nó choUseSqlite. Mỗi test tạo fixture riêng, dữ liệu không dính sang nhau. ThrowsAnyAsyncchứ không phảiThrowsAsync. EF Core có thể némTaskCanceledException(lớp con củaOperationCanceledException);ThrowsAnyAsyncchấp nhận cả lớp con.- 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:
public interface INotifier
{
string Channel { get; }
Task SendAsync(int customerId, string message, CancellationToken ct);
}SendToAllChannelsAsync(int customerId, string message, CancellationToken ct = default) phải:
- Gửi qua mọi kênh song song bằng
Task.WhenAll. - Áp hạn chung (mặc định 5 giây, cho phép truyền vào constructor để test) bằng
CreateLinkedTokenSourcenối token của bên gọi với token hết hạn. - 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.
- 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ệ. - 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 using | 6 |
| 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ông | 8 |
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 đề when | 6 |
| 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ém | 4 |
Lời giải — Bài 2
Crm.Lab/Notifications.cs:
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:
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
catchnào khớp, ngoại lệ bay lên,Task.WhenAllkết thúc ở trạng thái bị huỷ vàawaitnémOperationCanceledExceptioncho 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)
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:
StreamAsynclà async iterator (async IAsyncEnumerable<T>+yield return), áp điều kiện lọc củacriteriatrong truy vấn (không lọc sau khi đã nạp), dùngAsNoTracking()vàAsAsyncEnumerable().- Tham số token có
[EnumeratorCancellation]để huỷ có tác dụng. ExportToCsvAsyncdùngawait foreachđể ghi từng dòng; trường có dấu phẩy phải được bọc trong nháy kép.- Test: xuất với
OnlyActive, lọc theo khoảng ngày, và huỷ giữa chừng thì némOperationCanceledException.
Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
StreamAsync lọc trong truy vấn, AsNoTracking, trả dần bằng yield return | 8 |
[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òng | 7 |
ExportToCsvAsync dùng await using cho StreamWriter, ghi từng dòng, xử lý dấu phẩy | 6 |
| Ba test xanh | 4 |
Lời giải — Bài 3
Crm.Lab/Export.cs:
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:
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:
- Lọc trong truy vấn. Các
Wheređược nối vàoIQueryabletrước khi gọiAsAsyncEnumerable(), nên EF Core dịch chúng thànhWHEREtrong SQL. Lọc sau khi nạp là kéo cả bảng về. [EnumeratorCancellation]. Thiếu nó, token truyền quaWithCancellationtừ 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.ExportToCsvAsynckhô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ức | Tổng điểm | Mô tả |
|---|---|---|
| Chưa đạt | dưới 60 | Lab 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. |
| Đạt | 60 – 74 | Bà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 – 89 | Cả 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ỏi | 90 – 100 | Như 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.1 | Async tăng thông lượng, không giảm độ trễ; CPU thấp mà timeout là dấu hiệu starvation |
| 6.2 | Task là lời hứa, không phải thread; không async void trong backend; giữ await khi có using/try |
| 6.3 | Deadlock cần context một luồng và một lời gọi chặn; ConfigureAwait(false) chỉ cho thư viện |
| 6.4 | Huỷ là hợp tác; truyền token tới tận I/O; không nuốt OperationCanceledException |
| 6.5 | WhenAll biến tổng thành max; giới hạn song song; mỗi task một DbContext |
| 6.6 | IAsyncEnumerable cho dữ liệu lớn; cache Task<T> chống stampede; await việc xếp hàng |
| 6.7 | Chín bẫy; analyzer bắt được một phần, phần còn lại phải review |
| 6.8 | Bounded channel tạo backpressure; consumer tự bắt lỗi từng job; drain khi tắt |
| 6.9 | TimeProvider để test thời gian; PeriodicTimer ném khi huỷ; quyết định ValueTask bằng số đo cấp phát |
| 6.10 | await 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.11 | Mộ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
- Asynchronous programming with async and await
- Cancellation in managed threads
- System.Threading.Channels
- Generate and consume async streams
- ConfigureAwait FAQ
- There is no thread — Stephen Cleary
Điều hướng
- Bài trước: 6.11 — Sự cố thực tế: một dòng .Result làm treo cả API
- Bài tiếp theo: 7.1 — Vấn đề DI giải quyết
- Về module: Trang mục lục