6.11 — Mini case study
Một bài toán tối ưu async điển hình: endpoint đồng bộ hoá 500 khách hàng với hệ thống kế toán bên ngoài, chạy 8 phút và thường xuyên timeout. Code hoàn toàn dùng async/await đúng cú pháp — vấn đề là nó await trong vòng lặp, nên 500 lời gọi mạng chạy tuần tự. Sửa thành song song đưa thời gian xuống 12 giây, nhưng bài này quan trọng hơn ở ba cái bẫy xuất hiện ngay sau đó: API bên ngoài trả 429 vì bị gọi 500 lần cùng lúc, DbContext ném exception vì bị dùng đa luồng, và một lỗi duy nhất làm hỏng cả 500. Giải pháp cuối cùng là song song có giới hạn chứ không phải song song tối đa.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra
awaittrong vòng lặp làm mất tính song song. - Giới hạn mức song song bằng
SemaphoreSlimhoặcParallel.ForEachAsync. - Xử lý lỗi từng phần mà không hỏng cả lô.
- Tránh dùng
DbContexttrong code chạy song song.
Nội dung bài học
6.11.1 — Code ban đầu
public async Task<SyncResult> SyncCustomersAsync(CancellationToken ct)
{
var customers = await _db.Customers.Where(c => c.NeedsSync).ToListAsync(ct);
foreach (var customer in customers) // 500 khách hàng
{
var result = await _accountingClient.SyncAsync(customer, ct); // ~950ms mỗi lần
customer.LastSyncedUtc = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
return SyncResult.Success(customers.Count);
}
500 khách x 950ms = 475 giây = ~8 phút
Timeout của gateway: 300 giây -> thất bại trước khi xong
Code này dùng async/await đúng cú pháp. Vấn đề không phải ở cú pháp mà ở cấu trúc: await bên trong foreach nghĩa là mỗi vòng lặp chờ xong rồi mới bắt đầu vòng sau.
async giúp thread được trả về pool trong lúc chờ I/O — nên server không bị chiếm thread. Nhưng nó không tự làm các lời gọi chạy song song (bài 6.3).
6.11.2 — Thử 1: song song tối đa
var tasks = customers.Select(c => _accountingClient.SyncAsync(c, ct));
var results = await Task.WhenAll(tasks); // 500 lời gọi CÙNG LÚC
Thời gian: 8 phút -> 1,2 giây
Nhưng chạy trên môi trường thật thì hỏng ngay, theo ba cách.
Bẫy 1 — API bên ngoài trả 429:
HTTP 429 Too Many Requests
Retry-After: 60
Hệ thống kế toán cho tối đa 20 request đồng thời. 500 request cùng lúc bị chặn gần hết.
Bẫy 2 — DbContext không an toàn đa luồng:
// Nếu trong SyncAsync có truy vấn database
InvalidOperationException: A second operation was started on this context
instance before a previous operation completed.
DbContext được thiết kế cho một luồng tại một thời điểm. Dùng chung cho 500 task song song là lỗi chắc chắn (bài 13.10).
Bẫy 3 — một lỗi làm hỏng tất cả:
await Task.WhenAll(tasks);
// Nếu 1 task ném exception -> WhenAll ném exception
// -> 499 kết quả thành công bị mất, không biết cái nào đã xong
Tệ hơn: Task.WhenAll chỉ ném exception đầu tiên trong AggregateException, nên bạn còn không biết có bao nhiêu cái thất bại.
6.11.3 — Giải pháp: song song có giới hạn
public async Task<SyncResult> SyncCustomersAsync(CancellationToken ct)
{
var customers = await _db.Customers
.Where(c => c.NeedsSync)
.AsNoTracking()
.ToListAsync(ct);
var succeeded = new ConcurrentBag<Guid>();
var failed = new ConcurrentBag<(Guid Id, string Reason)>();
await Parallel.ForEachAsync(
customers,
new ParallelOptions
{
MaxDegreeOfParallelism = 10, // tối đa 10 lúc một lần
CancellationToken = ct
},
async (customer, token) =>
{
try
{
await _accountingClient.SyncAsync(customer, token);
succeeded.Add(customer.Id);
}
catch (Exception ex)
{
// MỘT lỗi KHÔNG làm hỏng các cái khác
failed.Add((customer.Id, ex.Message));
_logger.LogWarning(ex, "Đồng bộ thất bại cho khách {Id}", customer.Id);
}
});
// Cập nhật database SAU khi xong, trên MỘT luồng
await UpdateSyncStatusAsync(succeeded, ct);
return new SyncResult(succeeded.Count, failed.ToList());
}
Thời gian: 500 / 10 x 950ms = ~48 giây
Bốn thay đổi gi ải quyết cả ba cái bẫy:
| Thay đổi | Giải quyết |
|---|---|
MaxDegreeOfParallelism = 10 | Không vượt giới hạn của API bên ngoài |
try/catch trong từng task | Một lỗi không làm hỏng cả lô |
ConcurrentBag | An toàn khi nhiều luồng cùng ghi |
| Cập nhật database sau, một luồng | DbContext không bị dùng đa luồng |
ConcurrentBag là chi tiết dễ bỏ sót: List<T> không an toàn đa luồng, và ghi vào nó từ nhiều task song song có thể làm hỏng cấu trúc nội bộ — không phải lúc nào cũng ném exception, đôi khi chỉ mất dữ liệu im lặng.
6.11.4 — Cách khác: SemaphoreSlim
Khi cần kiểm soát chi tiết hơn Parallel.ForEachAsync:
var semaphore = new SemaphoreSlim(10); // tối đa 10 đồng thời
var tasks = customers.Select(async customer =>
{
await semaphore.WaitAsync(ct);
try
{
return await _accountingClient.SyncAsync(customer, ct);
}
finally
{
semaphore.Release(); // PHẢI ở finally
}
});
var results = await Task.WhenAll(tasks);
Release() trong finally là bắt buộc: nếu một exception thoát ra mà không Release, semaphore mất dần số lượt cho phép và cuối cùng mọi task đều treo vĩnh viễn.
Parallel.ForEachAsync | SemaphoreSlim | |
|---|---|---|
| Cú pháp | Gọn hơn | Dài hơn |
| Kiểm soát | Cơ bản | Chi tiết |
| Thu kết quả | Tự làm bằng collection | Task.WhenAll trả về mảng |
| Phù hợp | Phần lớn trường hợp | Khi cần logic đặc biệt |
Dùng Parallel.ForEachAsync làm mặc định.
6.11.5 — Chọn mức song song
Không có con số đúng cho mọi trường hợp. Cách tìm:
1. Bắt đầu từ 4
2. Đo thông lượng và tỷ lệ lỗi
3. Tăng gấp đôi, đo lại
4. Dừng khi: tỷ lệ lỗi tăng, HOẶC thông lượng không tăng nữa
Ba giới hạn cần cân nhắc cùng lúc:
| Giới hạn | Cách biết |
|---|---|
| API bên ngoài | Đọc tài liệu, hoặc tăng tới khi nhận 429 |
| Connection pool database | Mặc định 100; chia cho số instance |
| Bộ nhớ | Mỗi task đang chạy giữ dữ liệu của nó |
Với công việc thiên về I/O như ca này, mức song song có thể cao hơn số core rất nhiều — vì thread đang chờ mạng không dùng CPU. Với công việc thiên về CPU, đặt bằng Environment.ProcessorCount là hợp lý.
6.11.6 — Và có lẽ nó không nên là endpoint HTTP
Sau khi tối ưu, 48 giây vẫn là quá lâu cho một request HTTP: người dùng chờ, gateway có timeout riêng, và nếu kết nối đứt thì không biết đã xong tới đâu.
// Trả về ngay, chạy nền
[HttpPost("sync")]
public IActionResult StartSync()
{
var jobId = _jobs.Enqueue<ISyncService>(s => s.SyncCustomersAsync(CancellationToken.None));
return Accepted(new { jobId }); // 202 Accepted
}
[HttpGet("sync/{jobId}")]
public async Task<IActionResult> GetStatus(string jobId)
=> Ok(await _jobs.GetStatusAsync(jobId));
Người dùng nhận phản hồi tức thì kèm mã theo dõi, và công việc dài chạy trong job nền với retry sẵn có (bài 14.7).
Quy tắc: bất kỳ thao tác nào vượt quá vài giây đều nên là job nền, không phải request đồng bộ.
6.11.7 — Rà lại code của bạn
Danh sách rà soát async song song
- •Không await lời gọi mạng độc lập bên trong vòng lặp.
- •Mức song song có giới hạn, không dùng Task.WhenAll không kiểm soát.
- •Mỗi task có try catch riêng để một lỗi không hỏng cả lô.
- •Thu kết quả bằng collection an toàn đa luồng.
- •Không dùng chung DbContext giữa các task song song.
- •SemaphoreSlim luôn Release trong finally.
- •Mức song song được chọn bằng đo đạc, không đoán.
- •Thao tác dài hơn vài giây chạy trong job nền.
Bài tập áp dụng
Bài 1 — Đo ba phiên bản
Cài cả ba cách — tuần tự, song song tối đa, song song có giới hạn — và đo thời gian cùng tỷ lệ lỗi.
Tiêu chí hoàn thành: bạn có số đo cho cả ba, và nhận ra vì sao bài thử này chưa đủ để chọn mức song song cho hệ thống thật.
Gợi ý và lời giải — Bài 1
Gợi ý. Đo thêm một chỉ số nữa ngoài thời gian: đỉnh số việc chạy đồng thời. Nó cho thấy mỗi cách thật sự làm gì.
Lời giải:
const int SoMuc = 200;
int dangChay = 0, dinhDongThoi = 0;
async Task XuLyAsync(int i, CancellationToken ct = default)
{
var n = Interlocked.Increment(ref dangChay);
InterlockedMax(ref dinhDongThoi, n);
await Task.Delay(50, ct); // mô phỏng một lời gọi I/O
Interlocked.Decrement(ref dangChay);
}
// A — tuần tự
for (int i = 0; i < SoMuc; i++) await XuLyAsync(i);
// B — song song tối đa
await Task.WhenAll(Enumerable.Range(0, SoMuc).Select(i => XuLyAsync(i)));
// C — song song có giới hạn
await Parallel.ForEachAsync(Enumerable.Range(0, SoMuc),
new ParallelOptions { MaxDegreeOfParallelism = 8 },
async (i, ct) => await XuLyAsync(i, ct));
Kết quả đo trên .NET 9.0.203, 200 mục, mỗi mục 50 ms:
tuần tự : 10.400 ms | đỉnh đồng thời 1
song song tối đa : 51 ms | đỉnh đồng thời 200
có giới hạn MDOP=8 : 1.299 ms | đỉnh đồng thời 8
| Thời gian | Đỉnh đồng thời | So với tuần tự | |
|---|---|---|---|
| Tuần tự | 10.400 ms | 1 | 1× |
| Song song tối đa | 51 ms | 200 | 204× |
| Có giới hạn (8) | 1.299 ms | 8 | 8× |
Song song tối đa nhanh nhất — nhưng đó chính là vấn đề.
200 lời gọi cùng lúc tới một hệ thống bên ngoài:
- API bên thứ ba: gần như chắc chắn bị giới hạn tần suất (429)
- database: 200 kết nối -> vượt pool -> hàng đợi -> timeout
- chính hệ thống của bạn: 200 luồng hoặc 200 kết nối bị chiếm
Đây là điều mục 6.11.2 nói: nhanh nhất trên máy đo không có nghĩa là dùng được.
Và đây là phần quan trọng hơn — vì sao bài thử này CHƯA ĐỦ:
Công việc ở đây là Task.Delay(50) — nó không tranh chấp gì cả.
-> tăng MDOP bao nhiêu cũng nhanh hơn, tuyến tính, mãi mãi
Số liệu chứng minh điều đó:
MDOP= 2 : 5.204 ms | thông lượng 38/s
MDOP= 4 : 2.603 ms | thông lượng 77/s
MDOP= 8 : 1.299 ms | thông lượng 154/s
MDOP=16 : 676 ms | thông lượng 296/s
MDOP=32 : 364 ms | thông lượng 550/s
MDOP=64 : 208 ms | thông lượng 962/s
Thông lượng tăng gần ĐÚNG gấp đôi mỗi lần MDOP gấp đôi.
Không có điểm bão hoà, vì không có tài nguyên nào để bão hoà.
Với công việc thật, đường cong này có hình khác hẳn:
Gọi database thật:
MDOP tăng -> th ông lượng tăng -> tới khi database bão hoà
-> thông lượng ĐỨNG LẠI, rồi GIẢM vì tranh chấp khoá và chuyển ngữ cảnh
Gọi API có giới hạn tần suất:
MDOP vượt ngưỡng -> bắt đầu nhận 429 -> tỷ lệ lỗi tăng
-> thông lượng THỰC (không tính lỗi) giảm
Nên kết luận đúng của bài này là về cơ chế, không phải về con số:
Học được: song song có giới hạn giữ đúng số việc đồng thời (đỉnh = MDOP)
KHÔNG học được: MDOP tối ưu là bao nhiêu
Con số đó chỉ tìm được bằng cách đo trên phụ thuộc THẬT — bài 3 dưới đây.
Thêm cột tỷ lệ lỗi để bài thử s át thực tế hơn:
int thanhCong = 0, loi = 0;
async Task XuLyAsync(int i, CancellationToken ct = default)
{
try
{
using var res = await _http.GetAsync($"{Url}/{i}", ct);
if (res.IsSuccessStatusCode) Interlocked.Increment(ref thanhCong);
else Interlocked.Increment(ref loi); // 429, 503...
}
catch (Exception) { Interlocked.Increment(ref loi); }
}
Thông lượng THỰC = thanhCong / thời gian
Với MDOP quá cao, thông lượng thô vẫn tăng nhưng thông lượng thực giảm
-> và đó mới là con số cần tối ưu.
Bài 2 — Tái hiện lỗi DbContext
Chạy 10 truy vấn song song dùng chung một DbContext và ghi lại exception.
Tiêu chí hoàn thành: bạn có thông điệp lỗi chính xác, và biết vì sao nó đôi khi không xảy ra — điều làm lỗi này khó tìm.
Gợi ý và lời giải — Bài 2
Gợi ý. Thử với bảng nhỏ trước, rồi thử với bảng lớn. Hai kết quả khác nhau, và đó là phần đáng học nhất.
Lời giải:
using var db = new CrmDbContext(options);
var tasks = Enumerable.Range(0, 10)
.Select(t => Task.Run(() => db.Leads.AsNoTracking()
.Where(l => l.TenantId == t).ToListAsync()))
.ToArray();
try { Task.WaitAll(tasks); }
catch (Exception ex)
{
var g = ex is AggregateException ae ? ae.InnerExceptions[0] : ex;
Console.WriteLine($"{g.GetType().Name}: {g.Message}");
}
Kết quả trên .NET 9.0.203, EF Core 9.0.0, bảng 200.000 dòng:
InvalidOperationException: A second operation was started on this context instance
before a previous operation completed. This is usually caused by different threads
concurrently using the same instance of DbContext. For more information on how to
avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913
Với bảng 5.000 dòng, cùng đoạn code đó KHÔNG ném lỗi.
Bảng nhỏ -> mỗi truy vấn xong trước khi truy vấn kế tiếp kịp bắt đầu
-> bộ phát hiện đồng thời của EF Core không thấy chồng lấn
-> chạy trơn tru
Bảng lớn -> truy vấn đủ lâu để chồng lấn -> ném lỗi ngay
Đây là lý do lỗi này khó tìm:
Máy phát triển: database nhỏ, truy vấn vài mili giây
-> code sai vẫn chạy đúng, hàng tháng trời
Production: dữ liệu lớn, database bận
-> lỗi xuất hiện, và KHÔNG ĐỀU
-> "thỉnh thoảng lỗi, F5 lại thì được"
Và có tình huống còn tệ hơn cả việc ném lỗi:
DbContext không an toàn cho truy cập đồng thời.
Bộ phát hiện của EF Core bắt được PHẦN LỚN trường hợp, không phải MỌI trường hợp.
Khi nó không bắt được: change tracker hỏng trạng thái
-> dữ liệu sai, hoặc ngoại lệ ở một chỗ hoàn toàn khác, muộn hơn nhiều
Cách sửa — mỗi tác vụ song song một context:
builder.Services.AddDbContextFactory<CrmDbContext>(o => o.UseSqlServer(cs));
var tasks = tenants.Select(async t =>
{
await using var db = await _factory.CreateDbContextAsync(ct);
return await db.Leads.AsNoTracking().Where(l => l.TenantId == t).ToListAsync(ct);
});
var ketQua = await Task.WhenAll(tasks);
Lưu ý: mỗi context là một KẾT NỐI riêng.
-> 10 tác vụ song song = 10 kết nối
-> nên vẫn cần giới hạn số tác vụ đồng thời (bài 1 ở trên)
Kết hợp cả hai:
await Parallel.ForEachAsync(tenants,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (t, ct2) =>
{
await using var db = await _factory.CreateDbContextAsync(ct2);
var ds = await db.Leads.AsNoTracking().Where(l => l.TenantId == t).ToListAsync(ct2);
await XuLyAsync(ds, ct2);
});
Ba biến thể của cùng lỗi, khó thấy hơn:
1. Task.WhenAll trên các phương thức của cùng một service.
await Task.WhenAll(
_leadService.DemTheoTrangThaiAsync(ct),
_dealService.LayTopAsync(ct),
_baoCaoService.DoanhThuAsync(ct));
Ba service khác nhau, nhưng DbContext đăng ký Scoped
-> cả ba nhận CÙNG MỘT instance trong m ột request -> vẫn là lỗi
2. Duyệt IAsyncEnumerable rồi truy vấn bên trong vòng lặp.
await foreach (var lead in db.Leads.AsAsyncEnumerable())
{
var kh = await db.KhachHangs.FindAsync(lead.KhachHangId); // reader kia còn mở
}
3. BackgroundService nhận DbContext qua constructor.
// SAI — Scoped bị giữ bởi Singleton
public class DongBoService(CrmDbContext db) : BackgroundService { }
// ĐÚNG — tạo scope mỗi lượt
public class DongBoService(IServiceScopeFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await LamViecAsync(db, ct);
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}
Biến thể thứ ba có công cụ phát hiện tự động, và nên bật ở mọi dự án:
builder.Host.UseDefaultServiceProvider((_, o) =>
{
o.ValidateScopes = true; // ném lỗi ngay lúc khởi động
o.ValidateOnBuild = true;
});
Hai dòng này biến một lỗi phụ thuộc thời gian, khó tái hiện
thành một lỗi khởi động không thể bỏ qua.
Bài 3 — Tìm mức tối ưu
Tăng dần MaxDegreeOfParallelism từ 2 tới 64 và vẽ đồ thị thông lượng.
Tiêu chí hoàn thành: bạn tìm được điểm bão hoà trên một phụ thuộc thật, và biết vì sao con số đó không chuyển được sang môi trường khác.
Gợi ý và lời giải — Bài 3
Gợi ý. Đo trên chính phụ thuộc thật. Với công việc giả lập, đường cong không bao giờ bão hoà — như đã thấy ở bài 1.
Lời giải — khung đo:
foreach (var mdop in new[] { 1, 2, 4, 8, 16, 32, 64 })
{
int thanhCong = 0, loi = 0;
var sw = Stopwatch.StartNew();
await Parallel.ForEachAsync(cacMuc,
new ParallelOptions { MaxDegreeOfParallelism = mdop, CancellationToken = ct },
async (muc, ct2) =>
{
try
{
await GoiPhuThuocThatAsync(muc, ct2);
Interlocked.Increment(ref thanhCong);
}
catch { Interlocked.Increment(ref loi); }
});
sw.Stop();
var thongLuongThuc = thanhCong / sw.Elapsed.TotalSeconds;
Console.WriteLine($"MDOP={mdop,2} | {sw.Elapsed.TotalSeconds,6:F1}s | " +
$"OK {thanhCong,4} | lỗi {loi,3} | thông lượng thực {thongLuongThuc,6:F0}/s");
}
Hình dạng kết quả trên một phụ thuộc thật (mức điển hình — bài thử này cần một API hoặc database thật; trên máy viết bài chỉ có công việc giả lập, và nó không bão hoà):
MDOP= 1 | 40,0s | OK 2000 | lỗi 0 | thông lượng thực 50/s
MDOP= 2 | 20,1s | OK 2000 | lỗi 0 | thông lượng thực 99/s
MDOP= 4 | 10,2s | OK 2000 | lỗi 0 | thông lượng thực 196/s
MDOP= 8 | 5,4s | OK 2000 | lỗi 0 | thông lượng thực 370/s
MDOP=16 | 4,1s | OK 2000 | lỗi 0 | thông lượng thực 488/s <- bắt đầu chững
MDOP=32 | 3,9s | OK 1981 | lỗi 19 | thông lượng thực 508/s <- đỉnh
MDOP=64 | 4,6s | OK 1802 | lỗi 198 | thông lượng thực 392/s <- GIẢM
Ba vùng trên đường cong, và mỗi vùng nói một điều:
MDOP 1–8 : thông lượng tăng gần TUYẾN TÍNH
-> phụ thuộc chưa bão hoà, cứ tăng tiếp
MDOP 16–32 : tăng CHẬM DẦN rồi đứng lại
-> phụ thuộc đã bão hoà, đây là trần thật
MDOP 64 : thông lượng GIẢM và lỗi tăng vọt
-> quá tải: tranh chấp, hàng đợi, timeout, 429
Điểm nên chọn không phải đỉnh, mà là chỗ ngay trước khi lỗi xuất hiện:
MDOP=32 cho thông lượng cao nhất (508/s) nhưng đã có 19 lỗi
MDOP=16 cho 488/s và KHÔNG lỗi
Chênh 4% thông lượng, đổi lấy 0 lỗi -> chọn 16.
Lý do: 19 lỗi ở MDOP=32 là 19 lần phải thử lại
-> tải thật cao hơn con số đo được
-> và ở mức tải cao hơn một chút, nó trượt sang vùng 64
Và đây là điều quan trọng nhất — con số này KHÔNG chuyển được:
MDOP tối ưu phụ thuộc:
- số nhân và bộ nhớ của phụ thuộc
- có bao nhiêu instance của CHÍNH ứng dụng bạn đang chạy
- có ai khác đang dùng phụ thuộc đó không
- giới hạn tần suất của bên thứ ba
- độ trễ mạng giữa hai bên
Đo trên staging với 1 instance -> MDOP=16
Chạy production với 6 instance -> tổng 96 việc đồng thời tới cùng database
-> vượt xa điểm bão hoà -> sự cố
// Vì vậy MDOP nên là cấu hình, không phải hằng số
var mdop = _cauHinh.Value.MaxDegreeOfParallelism; // mặc định 8
// Và tốt hơn nữa: chia cho số instance nếu biết được
var mdop = Math.Max(1, _cauHinh.Value.TongSongSong / _cauHinh.Value.SoInstance);
Cách bền hơn việc chỉnh một con số: để phụ thuộc tự nói.
builder.Services.AddResiliencePipeline("api-ngoai", b => b
.AddConcurrencyLimiter(permitLimit: 16, queueLimit: 8)
.AddRetry(new RetryStrategyOptions
{
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
MaxRetryAttempts = 3,
ShouldHandle = new PredicateBuilder().HandleResult<HttpResponseMessage>(
r => r.StatusCode == HttpStatusCode.TooManyRequests),
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.3 }));
Giới hạn đồng thời + thử lại có jitter + ngắt mạch
-> hệ thống tự điều chỉnh khi phụ thuộc chậm đi
-> thay vì phụ thuộc vào một con số đo được sáu tháng trước
Và theo dõi để biết khi nào con số cần chỉnh lại:
# Tỷ lệ lỗi theo mức song song — nếu tăng, MDOP đang quá cao cho tải hiện tại
rate(cong_viec_loi_total[5m]) / rate(cong_viec_tong_total[5m]) > 0.01
Nối lại ba bài: bài 1 cho thấy cơ chế, bài 2 cho thấy cái bẫy kỹ thuật đi kèm khi song song hoá, bài 3 cho thấy con số tối ưu là một thứ đo được nhưng không mang đi được. Mẫu số chung: song song hoá là một quyết định về tài nguyên của bên kia, không phải về tốc độ của bên mình.
Tự kiểm tra
Frequently asked questions
Vì sao await trong vòng lặp làm mất tính song song?
Vì mỗi vòng lặp chờ xong rồi mới bắt đầu vòng sau. async giúp thread được trả về pool trong lúc chờ I/O nên server không bị chiếm thread, nhưng nó không tự làm các lời gọi chạy song song.
Ba cái bẫy khi chuyển sang Task.WhenAll không giới hạn là gì?
API bên ngoài trả 429 vì bị gọi quá nhiều cùng lúc, DbContext ném lỗi vì không an toàn đa luồng, và một task lỗi làm WhenAll ném exception khiến mọi kết quả thành công bị mất.
Vì sao cần collection an toàn đa luồng để thu kết quả?
Vì List không an toàn đa luồng, và ghi vào nó từ nhiều task song song có thể làm hỏng cấu trúc nội bộ. Nó không phải lúc nào cũng ném exception, đôi khi chỉ mất dữ liệu một cách im lặng.
Vì sao Release của SemaphoreSlim phải nằm trong finally?
Vì nếu một exception thoát ra mà không Release, semaphore mất dần số lượt cho phép, và cuối cùng mọi task đều treo vĩnh viễn chờ một lượt không bao giờ được trả lại.
Chọn mức song song thế nào?
Bắt đầu từ 4, đo thông lượng và tỷ lệ lỗi, tăng gấp đôi rồi đo lại, dừng khi tỷ lệ lỗi tăng hoặc thông lượng không tăng nữa. Ba giới hạn cần cân nhắc là API bên ngoài, connection pool và bộ nhớ.
Vì sao 48 giây vẫn quá lâu cho một request HTTP?
Vì người dùng phải chờ, gateway có timeout riêng, và nếu kết nối đứt thì không biết công việc đã xong tới đâu. Thao tác dài hơn vài giây nên chạy trong job nền và trả về mã theo dõi ngay.
Kết luận
Ba điều đáng nhớ nhất:
asynckhông tự làm code chạy song song.awaittrong vòng lặp vẫn là tuần tự.- Song song có giới hạn, không phải song song tối đa — ba cái bẫy đều đến từ việc bỏ giới hạn.
- Thao tác dài hơn vài giây thuộc về job nền.
Tham khảo
- Lập trình bất đồng bộ trong C#
- Async scenarios
- Cancellation trong managed threads
- ConfigureAwait FAQ
Điều hướng
- Bài trước: 6.9 — Mở rộng và đào sâu
- Bài tiếp theo: 6.11 — Ví dụ thực tế nhanh
- Về module: Trang mục lục