Skip to content

6.7 - Common Pitfalls ​

Tóm tắt: Chín cái bẫy, phần lớn không gây lỗi biên dịch và không gây lỗi trên máy dev. Chúng chỉ xuất hiện khi có tải thật: Task.Run bọc quanh I/O đồng bộ không tăng khả năng chịu tải (đo được 13,2 giây so với 13,1 giây của bản đồng bộ); lambda async truyền vào tham số Action lặng lẽ trở thành async void; Select(async ...) trả về IEnumerable<Task> lười biếng có thể chạy hai lần; và List<T> cập nhật từ nhiều task song song thì im lặng mất phần tử thay vì báo lỗi. Bài này là danh sách rà soát trước khi đưa code async lên production.

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

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

  • Phân biệt sync-over-async và async-over-sync, và biết cả hai đều sai.
  • Nhận ra async void ở những nơi nó không hiện ra trong chữ ký.
  • Giải thích vì sao Task.Run không tăng throughput cho ASP.NET Core.
  • Viết đúng khi cần khoá quanh một thao tác async.
  • Rà một pull request async và chỉ ra vấn đề trước khi nó lên production.

Cần biết trước: các bài 6.1 tới 6.6 của module này; bài này gom lại các bẫy đã gặp rải rác ở đó. Bẫy 6 dùng Parallel.ForEachAsync (bài 6.5), bẫy 9 dùng CancellationToken (bài 6.4).

Nội dung bài học ​

6.7.1 — Bẫy 1: sync-over-async (.Result, .Wait()) ​

csharp
public string GetName(int id) => GetCustomerAsync(id).Result;   // SAI

Deadlock trong ASP.NET Framework và WPF; trong ASP.NET Core thì không deadlock nhưng vẫn chiếm một thread pool thread để ngồi không — đúng thứ async sinh ra để tránh.

Cách chữa là async lên tới tận trên cùng. Cơ chế deadlock đã mổ xẻ ở bài 6.3 và trong bài Gọi .Result khi nào thì deadlock.

6.7.2 — Bẫy 2: async-over-sync (Task.Run bọc I/O đồng bộ) ​

csharp
// SAI — vẫn chiếm một thread, chỉ là thread khác
public Task<string> ReadAsync(string path)
    => Task.Run(() => File.ReadAllText(path));

// ĐÚNG — I/O bất đồng bộ thật
public Task<string> ReadAsync(string path, CancellationToken ct = default)
    => File.ReadAllTextAsync(path, ct);

Task.Run không biến thao tác đồng bộ thành bất đồng bộ. Nó chỉ chuyển việc chờ từ thread này sang thread khác, và cộng thêm chi phí chuyển ngữ cảnh. Với API đồng bộ trong thư viện bên thứ ba, giải pháp là tìm overload ...Async; không có thì chấp nhận gọi đồng bộ chứ đừng giả vờ.

Task.Run đúng cho một việc: đẩy công việc CPU-bound ra khỏi một thread đặc biệt cần được giải phóng, điển hình là luồng giao diện của ứng dụng desktop.

6.7.3 — Bẫy 3: Task.Run trong ASP.NET Core không tăng throughput ​

csharp
[HttpGet]
public async Task<IActionResult> Get()
{
    var result = await Task.Run(() => HeavyCalculation());   // không giúp gì
    return Ok(result);
}

Trong ứng dụng web, request đã chạy trên một thread của thread pool. Tại await Task.Run(...), thread của request được trả về pool, nhưng một thread khác của cùng pool bị chiếm cho phần tính toán. Tổng số thread bận không đổi, và bạn trả thêm phí xếp lịch. Request cũng không xong sớm hơn: phần tính toán vẫn mất chừng ấy thời gian.

Đây là cùng một thông điệp với bài 6.1: Task.Run cho việc CPU-bound có ích ở ứng dụng desktop (giải phóng luồng giao diện), còn trong ASP.NET Core thì không. Với công việc CPU nặng trong web, câu trả lời là: gọi thẳng nếu nó ngắn, đẩy ra background job nếu nó dài, hoặc chấp nhận nó và scale theo chiều ngang.

6.7.4 — Bẫy 4: async void ở nơi không thấy chữ void ​

Chữ ký async void hiếm khi được viết ra một cách có ý thức. Nó lẻn vào qua lambda:

csharp
list.ForEach(async x => await ProcessAsync(x));          // Action<T>     -> async void
Parallel.ForEach(list, async x => await ProcessAsync(x)); // Action<T>     -> async void
timer.Elapsed += async (s, e) => await TickAsync();       // EventHandler  -> async void
_ = new Timer(async _ => await TickAsync(), null, 0, 1000); // TimerCallback -> async void

Quy tắc nhận biết: nếu tham số nhận Action, Action<T> hay một delegate trả void, thì lambda async bạn truyền vào là async void. Hậu quả đã nói ở bài 6.2: vòng lặp kết thúc trước khi việc xong, và exception làm sập tiến trình.

Cách chữa: dùng overload nhận Func<T, Task> (Parallel.ForEachAsync), hoặc foreach + await, hoặc với event handler thì bọc toàn bộ thân trong try/catch.

6.7.5 — Bẫy 5: LINQ Select(async ...) lười biếng ​

csharp
var tasks = customers.Select(async c => await ProcessAsync(c));   // IEnumerable<Task>

await Task.WhenAll(tasks);
var count = tasks.Count();      // DUYỆT LẠI -> CHẠY LẠI TẤT CẢ MỘT LẦN NỮA

Select lười: nó chưa chạy gì cho tới khi bị duyệt. Duyệt hai lần là chạy hai lần. Với thao tác chỉ đọc thì chỉ tốn tài nguyên; với thao tác có ghi thì bạn ghi trùng.

csharp
var tasks = customers.Select(c => ProcessAsync(c)).ToList();   // .ToList() chốt lại
await Task.WhenAll(tasks);

.ToList() buộc mọi task khởi động ngay và cho bạn một tập hợp cố định. Lưu ý Task.WhenAll tự gọi ToArray bên trong, nên dòng await phía trên vẫn đúng — vấn đề chỉ xuất hiện khi bạn dùng lại biến tasks.

Và Where(async c => await IsValidAsync(c)) thì không biên dịch được, vì Where cần bool chứ không phải Task<bool>.

6.7.6 — Bẫy 6: List<T> không thread-safe ​

csharp
// SAI — List<T> không thread-safe
var results = new List<Order>();

await Parallel.ForEachAsync(ids, ct, async (id, token) =>
{
    results.Add(await LoadAsync(id, token));   // mất phần tử, hoặc ném ngẫu nhiên
});

List<T>.Add đọc _size, ghi vào mảng, rồi tăng _size. Hai task chạy xen kẽ giữa ba bước đó thì một phần tử bị ghi đè — im lặng, không exception. Đôi khi nó ném IndexOutOfRangeException từ bên trong List, và đó lại là may mắn vì ít nhất bạn biết.

csharp
// ĐÚNG 1 — collection thread-safe
var results = new ConcurrentBag<Order>();

// ĐÚNG 2 — không chia sẻ state, gom lại ở cuối (thích hơn)
var results = await Task.WhenAll(ids.Select(id => LoadAsync(id, ct)));

Cách 2 tốt hơn: không có trạng thái chia sẻ thì không có vấn đề đồng bộ hoá.

6.7.7 — Bẫy 7: cần khoá quanh một thao tác async ​

csharp
lock (_gate)
{
    await DoAsync();      // KHÔNG BIÊN DỊCH ĐƯỢC
}

Trình biên dịch chặn việc này, vì lock của .NET có quan hệ với thread: nó phải được giải phóng bởi đúng thread đã lấy nó, còn await thì có thể quay lại trên thread khác.

csharp
private readonly SemaphoreSlim _gate = new(1, 1);

await _gate.WaitAsync(ct);
try     { await DoAsync(ct); }
finally { _gate.Release(); }        // finally là bắt buộc

SemaphoreSlim không gắn với thread nên dùng được. Hai lưu ý: nó không reentrant (gọi lồng nhau là tự khoá chính mình), và thiếu finally thì một exception sẽ treo vĩnh viễn mọi lời gọi sau.

Từ .NET 9, System.Threading.Lock là kiểu khoá mới nhưng vẫn không dùng được quanh await.

6.7.8 — Bẫy 8: exception không được quan sát ​

csharp
var task = ProcessAsync();   // ném exception
// không bao giờ await -> không ai biết

Exception nằm trong Task cho tới khi có người await hoặc đọc .Exception. Không ai đọc thì nó đi vào TaskScheduler.UnobservedTaskException lúc GC dọn Task — nghĩa là muộn, ngẫu nhiên, và không liên quan gì tới chỗ gây lỗi.

Đặt một lưới an toàn ở Program.cs để ít nhất chúng có mặt trong log:

csharp
TaskScheduler.UnobservedTaskException += (_, e) =>
{
    logger.LogError(e.Exception, "Unobserved task exception");
    e.SetObserved();
};

Đây là lưới an toàn, không phải giải pháp. Giải pháp là await mọi Task bạn tạo ra.

6.7.9 — Bẫy 9: coi OperationCanceledException là lỗi ​

csharp
catch (Exception ex)
{
    _logger.LogError(ex, "Thất bại");   // huỷ không phải thất bại
    return StatusCode(500);
}

Khi client đóng kết nối, CancellationToken bị kích hoạt và bạn nhận OperationCanceledException. Đó là hành vi đúng, không phải sự cố. Log nó ở mức Error làm dashboard đầy báo động giả, và mỗi lần người dùng bấm nút Stop trên trình duyệt lại là một "lỗi 500".

csharp
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    _logger.LogInformation("Request bị huỷ bởi client");
    return StatusCode(499);
}
catch (Exception ex) { ... }

Mệnh đề when quan trọng: nó phân biệt client huỷ với timeout nội bộ — cái sau mới là sự cố thật. Chi tiết ở bài 6.4.

6.7.10 — Rà lại code của bạn ​

Danh sách rà soát trước khi merge code async

  • Không có .Result hay .Wait() nào trong đường chạy request.
  • Không có Task.Run nào bọc quanh một lời gọi I/O đồng bộ.
  • Không có Task.Run nào trong controller chỉ để 'cho nó async'.
  • Mọi lambda async đều truyền vào tham số kiểu Func<..., Task>, không phải Action.
  • Mọi Select(async ...) đều có .ToList() hoặc .ToArray() ngay sau đó.
  • Không có List<T> hay Dictionary nào bị ghi từ nhiều task song song.
  • Khoá quanh await dùng SemaphoreSlim với Release trong finally.
  • Mọi Task được tạo ra đều có nơi await hoặc có try/catch ghi log.
  • OperationCanceledException do client huỷ không bị log ở mức Error.
  • Program.cs có handler cho TaskScheduler.UnobservedTaskException.

Bài tập áp dụng ​

Bài 1 — Đo async-over-sync ​

Viết hai endpoint đọc cùng một file, một dùng Task.Run(() => File.ReadAllText(...)), một dùng File.ReadAllTextAsync. Bật bộ phân tích và xem công cụ nào bắt được bản Task.Run. Dùng số đo ở bài tập 2 của bài 6.1 để trả lời: bản Task.Run có tốt hơn bản gọi đồng bộ thẳng không?

Tiêu chí hoàn thành: bạn nêu được vì sao Task.Run bọc I/O đồng bộ không tốt hơn việc gọi đồng bộ thẳng trong ASP.NET Core, và vì sao nó khó bị phát hiện hơn.

Gợi ý và lời giải — Bài 1

Gợi ý. Bài này bổ sung cho bài 2 ở bài 6.1. Điểm mới: trong ASP.NET Core, luồng mà Task.Run mượn đến từ đâu?

Lời giải.

csharp
app.MapGet("/v1", async () =>
    Results.Text(await Task.Run(() => File.ReadAllText("data.txt"))));

app.MapGet("/v2", async (CancellationToken ct) =>
    Results.Text(await File.ReadAllTextAsync("data.txt", ct)));

Số đo (bài tập 2 của bài 6.1, .NET 10, 200 request, mỗi request chờ I/O đồng bộ 1 giây):

Thời gian xong 200 requestThread pool lúc đỉnh
Gọi đồng bộ thẳng13,1 giây17
Task.Run bọc I/O đồng bộ13,2 giây18
Async thật1,0 giây15

Vì sao Task.Run không tốt hơn gọi đồng bộ thẳng trong ASP.NET Core:

text
Gọi đồng bộ thẳng:
  Thread A (đang xử lý request) bị giam suốt lần I/O
  -> Tốn: 1 thread

Task.Run bọc I/O đồng bộ:
  Thread A trả về pool tại await
  Thread B (MƯỢN TỪ CHÍNH POOL ĐÓ) bị giam suốt lần I/O
  Xong thì một thread của pool chạy tiếp phần còn lại của request
  -> Tốn: vẫn 1 thread, CỘNG chi phí xếp lịch

Cùng số thread bị giam, cộng thêm chi phí. Trong ASP.NET Core, Task.Run lấy thread từ chính thread pool đang phục vụ các request khác, nên nó không "giải phóng" gì cả, chỉ chuyển gánh nặng từ tay trái sang tay phải.

Điều này khác với ứng dụng có giao diện, nơi Task.Run thật sự có ích vì nó chuyển việc khỏi luồng UI, một luồng đặc biệt và duy nhất.

Bảng phân biệt hai bẫy đối xứng:

BẫyBiểu hiệnHậu quả
sync-over-async.Result, .Wait()Deadlock hoặc giam luồng
async-over-syncTask.Run(() => SyncIo())Giam luồng + chi phí thừa, và che giấu vấn đề

Bẫy thứ hai nguy hiểm hơn về mặt phát hiện: chữ ký phương thức có async Task, nên nhìn qua tưởng đã đúng. Nó trông bất đồng bộ nhưng không bất đồng bộ.

Cách nhận ra async-over-sync trong code review:

csharp
// Dấu hiệu: Task.Run bọc một lời gọi có bản Async sẵn
await Task.Run(() => File.ReadAllText(p));        // có File.ReadAllTextAsync
await Task.Run(() => _db.Customers.ToList());     // có ToListAsync
await Task.Run(() => http.GetString(url));        // có GetStringAsync

Quy tắc: nếu thư viện có sẵn bản Async, luôn dùng bản đó. Task.Run chỉ dành cho công việc CPU không có bản bất đồng bộ nào, vì bản chất nó không thể có.

Công cụ bắt được tới đâu. Đã thử trên .NET 10 với AnalysisMode=Recommended và gói Microsoft.VisualStudio.Threading.Analyzers:

  • Gọi thẳng File.ReadAllText hay db.SaveChanges() bên trong phương thức async: cả CA1849 lẫn VSTHRD103 đều báo, vì cùng lớp có sẵn bản ...Async.
  • await Task.Run(() => File.ReadAllText(p)): không quy tắc nào báo, vì lambda bên trong là đồng bộ. Đây là lý do async-over-sync phải bắt bằng mắt khi review.
  • db.Customers.ToList() trong phương thức async: cũng không bị báo, vì ToListAsync nằm ở một lớp khác (extension method của EF Core).
xml
<WarningsAsErrors>$(WarningsAsErrors);CA1849;VSTHRD103</WarningsAsErrors>

Bài 2 — Làm mất phần tử với List<T> ​

Dùng Parallel.ForEachAsync với 1000 phần tử cùng Add vào một List<T>. Chạy 10 lần và ghi lại số phần tử của mỗi lần. So sánh với ConcurrentBag.

Tiêu chí hoàn thành: bạn ghi lại được kết quả của cả 10 lần và nhận ra đặc điểm nguy hiểm nhất của lỗi này.

Gợi ý và lời giải — Bài 2

Gợi ý. Chạy một lần là không đủ — lỗi đua có tính ngẫu nhiên. Hãy chạy nhiều lần và nhìn vào sự phân tán của kết quả.

Lời giải.

csharp
using System.Collections.Concurrent;

for (int run = 1; run <= 10; run++)
{
    var list = new List<int>();
    await Parallel.ForEachAsync(Enumerable.Range(1, 1000), async (i, ct) =>
    {
        await Task.Yield();             // nhường thread để các task chạy xen kẽ nhau
        list.Add(i);                    // KHÔNG an toàn khi truy cập đồng thời
    });
    Console.WriteLine($"lần {run,2}: {list.Count,4} phần tử");
}

var bag = new ConcurrentBag<int>();
await Parallel.ForEachAsync(Enumerable.Range(1, 1000), async (i, ct) => { await Task.Yield(); bag.Add(i); });
Console.WriteLine($"ConcurrentBag: {bag.Count} phần tử");

Kết quả chạy trên .NET 10, dotnet run -c Release:

text
lần  1:  988 phần tử   <- MẤT
lần  2:  975 phần tử   <- MẤT
lần  3:  973 phần tử   <- MẤT
lần  4:  965 phần tử   <- MẤT
lần  5:  984 phần tử   <- MẤT
lần  6:  987 phần tử   <- MẤT
lần  7:  986 phần tử   <- MẤT
lần  8:  988 phần tử   <- MẤT
lần  9:  940 phần tử   <- MẤT
lần 10:  990 phần tử   <- MẤT
ConcurrentBag: 1000 phần tử

Đặc điểm nguy hiểm nhất: không có ngoại lệ nào được ném ra. Cả 10 lần đều "chạy thành công". Dữ liệu chỉ đơn giản là biến mất, và số lượng mất khác nhau mỗi lần: từ 10 tới 60 phần tử. Máy của bạn sẽ cho con số khác, nhưng hầu như không bao giờ đủ 1000.

Hãy hình dung điều này trong một tác vụ đồng bộ dữ liệu: mỗi đêm bạn mất vài bản ghi, số lượng ngẫu nhiên, không có log lỗi nào. Phải mất nhiều tháng mới có người nhận ra, và lúc đó việc truy ngược để biết đã mất những gì là bất khả thi.

Vì sao mất. List<T>.Add không phải thao tác nguyên tử. Nó gồm ba bước:

text
1. Kiểm tra sức chứa, cấp phát mảng mới nếu đầy
2. _items[_size] = item
3. _size++

Hai luồng chạy xen kẽ có thể cùng đọc _size bằng 500, cùng ghi vào ô 500, rồi cùng tăng lên 501 — một phần tử bị ghi đè, và bộ đếm chỉ tăng một lần thay vì hai.

Tệ hơn: nếu chúng gặp nhau đúng lúc mảng nội bộ đang được cấp phát lại, bạn có thể nhận IndexOutOfRangeException hoặc một mảng có phần tử null ở giữa. Những trường hợp đó hiếm hơn nhưng khó chẩn đoán hơn.

Ba cách sửa:

csharp
var source = Enumerable.Range(1, 1000);

// 1. ConcurrentBag — đơn giản nhất, không bảo đảm thứ tự
var bag = new ConcurrentBag<int>();
await Parallel.ForEachAsync(source, async (i, ct) => { await Task.Yield(); bag.Add(i); });

// 2. Thu thập kết quả thay vì ghi vào tập hợp chung — THƯỜNG LÀ TỐT NHẤT
int[] results = await Task.WhenAll(source.Select(async i => { await Task.Yield(); return i; }));

// 3. Khoá tường minh — khi buộc phải giữ List (System.Threading.Lock có từ .NET 9)
var list = new List<int>();
var gate = new Lock();
await Parallel.ForEachAsync(source, async (i, ct) =>
{
    await Task.Yield();
    lock (gate) { list.Add(i); }   // lock được, vì bên trong lock không có await
});

Cách 2 thường tốt nhất vì nó loại bỏ hoàn toàn trạng thái chia sẻ — không có gì để tranh chấp thì không có lỗi đua. Nó cũng giữ nguyên thứ tự, điều mà ConcurrentBag không bảo đảm.

Bảng chọn kiểu tập hợp an toàn:

Nhu cầuKiểu
Thêm vào, không cần thứ tựConcurrentBag<T>
Tra cứu theo khoáConcurrentDictionary<K,V>
Hàng đợi vào trước ra trướcConcurrentQueue<T>
Cần giữ thứ tự của nguồnThu thập bằng Task.WhenAll

Một lưu ý về ConcurrentBag. Nó không bảo đảm thứ tự, và cũng không nhanh cho mọi tình huống — nó được tối ưu cho trường hợp cùng một luồng vừa thêm vừa lấy. Với việc chỉ thêm rồi đọc một lần ở cuối, ConcurrentQueue<T> thường nhanh hơn.

Bài 3 — Rà một pull request bằng danh sách chín bẫy ​

Lấy một pull request async trong repo của bạn, hoặc tự viết một file vi phạm năm trong chín bẫy, rồi dùng danh sách rà soát. Ghi lại bao nhiêu bẫy bạn tìm ra mà code review thường bỏ sót.

Tiêu chí hoàn thành: bạn có một danh sách rà soát dùng được, và biết bẫy nào công cụ bắt được còn bẫy nào phải người đọc tự tìm.

Gợi ý và lời giải — Bài 3

Gợi ý. Tự viết file vi phạm sẽ hiệu quả hơn là đi tìm — vì bạn kiểm soát được là có đúng năm bẫy, nên kiểm chứng được mình tìm sót bao nhiêu.

Lời giải — file thử vi phạm năm trong chín bẫy, cộng hai lỗi đã gặp ở bài 6.4 và 6.5:

csharp
public class ReportService(CrmDbContext db)
{
    private readonly List<string> _results = new();         // dùng ở dưới -> bẫy 6

    public async void Process()                             // bẫy 4: async void
    {
        var customers = await db.Customers.ToListAsync();   // thêm: thiếu CancellationToken (bài 6.4)

        var tasks = customers.Select(async c => await ComputeAsync(c));  // bẫy 5: Select lười
        var count = tasks.Count();                          // duyệt -> chạy cả loạt, rồi không await

        await Parallel.ForEachAsync(customers, async (c, ct) =>
        {
            var orders = await db.Orders.Where(o => o.CustomerId == c.Id)
                                        .ToListAsync(ct);   // thêm: DbContext dùng chung (bài 6.5)
            _results.Add(orders.Count.ToString());          // bẫy 6: List<T> ghi song song
        });

        var t = SendEmailAsync();                           // bẫy 8: không await, không quan sát
    }

    public decimal SumTotals()
        => db.Orders.SumAsync(o => o.Total).Result;         // bẫy 1: sync-over-async
}

Danh sách rà soát theo đúng chín bẫy của bài, kèm kết quả thử công cụ trên .NET 10 (AnalysisMode=Recommended cộng gói Microsoft.VisualStudio.Threading.Analyzers):

#BẫyDấu hiệuCông cụ bắt được?
1sync-over-async.Result, .Wait(), .GetAwaiter().GetResult()Có — VSTHRD002, ở cả phương thức đồng bộ lẫn async. CA1849 thì không báo .Result
2async-over-syncTask.Run bọc I/O, hoặc gọi bản đồng bộ trong asyncMột phần — CA1849/VSTHRD103 báo gọi thẳng File.ReadAllText, SaveChanges; không báo Task.Run(() => ...) và ToList() của LINQ
3Task.Run trong ASP.NET Coreawait Task.Run(...) trong controller, endpointKhông
4async voidchữ ký async void, lambda async truyền vào ActionCó — VSTHRD100 cho chữ ký, VSTHRD101 cho lambda async truyền vào List.ForEach, Parallel.ForEach
5Select(async ...) lườiIEnumerable<Task> được duyệt nhiều lầnKhông
6List<T> dùng chung.Add trong ngữ cảnh song songKhông
7lock quanh awaittrình biên dịch chặn lock, nhưng Monitor thủ công thì khôngMột phần
8Ngoại lệ không quan sátTask bị bỏMột phần — CS4014 chỉ báo khi gọi trần SendEmailAsync();; var t = ... và _ = ... thì không công cụ nào báo
9Coi OperationCanceledException là lỗicatch (Exception) log mức ErrorKhông

Ngoài chín bẫy, hai lỗi trong file mẫu cũng phải bắt bằng mắt: DbContext dùng chung giữa các task song song (thường ném InvalidOperationException: A second operation was started... như bài 6.5, nhưng không phải lần chạy nào cũng ném), và phương thức async thiếu tham số CancellationToken.

Những bẫy công cụ không bắt được, và đó chính là giá trị của việc rà bằng mắt: số 3, 5, 6, 9, phần lớn số 8, và hai lỗi thêm. Chúng đều là lỗi ngữ cảnh: cùng một dòng code có thể đúng ở chỗ này và sai ở chỗ kia, tuỳ vào việc nó có chạy song song hay không.

Bẫy số 6 và DbContext dùng chung là hai lỗi tốn kém nhất. List<T> ghi song song không ném ngoại lệ, chỉ làm mất dữ liệu ngẫu nhiên như bài 2 đã chứng minh. DbContext dùng chung thường ném ngay, nhưng khi bộ phát hiện của EF Core không kịp thấy thì change tracker hỏng trạng thái âm thầm.

Đưa vào quy trình. Dán danh sách chín dòng trên vào mẫu pull request của đội, phần "người review cần kiểm tra". Kèm theo, cài gói Microsoft.VisualStudio.Threading.Analyzers và bật các quy tắc mà công cụ bắt được:

xml
<PropertyGroup>
  <AnalysisMode>Recommended</AnalysisMode>
  <WarningsAsErrors>$(WarningsAsErrors);VSTHRD002;VSTHRD100;VSTHRD101;VSTHRD103;CA1849;CS4014</WarningsAsErrors>
</PropertyGroup>

Không thêm CA2007 (bắt buộc ConfigureAwait) ở đây: quy tắc đó chỉ dành cho dự án thư viện, ứng dụng ASP.NET Core thì tắt nó, như bài 6.3 giải thích.

Nguyên tắc chung, lặp lại từ bài 4.6. Cái gì công cụ kiểm được thì để công cụ kiểm; sức chú ý của người review là tài nguyên khan hiếm, hãy dành nó cho những bẫy công cụ không bắt được.

Tự kiểm tra ​

Sync-over-async và async-over-sync khác nhau thế nào?

Sync-over-async là gọi .Result hay .Wait() trên một Task, gây deadlock trong ASP.NET Framework và WPF, còn trong ASP.NET Core thì chiếm một thread ngồi không. Async-over-sync là bọc một thao tác đồng bộ trong Task.Run, nó không biến thao tác thành bất đồng bộ mà chỉ chuyển việc chờ sang thread khác và cộng thêm chi phí chuyển ngữ cảnh. Cả hai đều sai.

Task.Run trong controller ASP.NET Core có tăng throughput không?

Không. Request đã chạy trên thread pool rồi. Tại await Task.Run, thread của request được trả về pool, nhưng một thread khác của cùng pool bị chiếm cho phần việc đó. Tổng số thread bận không đổi, còn bạn trả thêm phí xếp lịch. Với công việc CPU nặng thì nên đẩy ra background job hoặc scale ngang.

Làm sao nhận ra async void khi nó không có trong chữ ký?

Nhìn vào kiểu tham số. Nếu phương thức nhận Action, Action<T>, EventHandler hay bất kỳ delegate nào trả về void, thì lambda async truyền vào là async void. Ví dụ điển hình là List.ForEach, Parallel.ForEach, đăng ký event và TimerCallback.

Vì sao Select(async ...) nguy hiểm?

Vì Select lười, nó chưa chạy gì cho tới khi bị duyệt, nên duyệt hai lần là chạy hai lần. Với thao tác có ghi thì bạn ghi trùng. Cách chữa là gọi ToList hoặc ToArray ngay sau Select để chốt tập hợp task lại.

Vì sao không lock được quanh await?

Vì lock của .NET gắn với thread, nó phải được giải phóng bởi đúng thread đã lấy nó, trong khi code sau await có thể chạy trên thread khác. Trình biên dịch chặn thẳng việc này. Thay bằng SemaphoreSlim với WaitAsync và Release trong finally; lưu ý SemaphoreSlim không reentrant.

Có nên log OperationCanceledException ở mức Error không?

Không, khi nó đến từ việc client đóng kết nối thì đó là hành vi đúng chứ không phải sự cố, và log mức Error sẽ làm dashboard đầy báo động giả. Nên bắt riêng bằng mệnh đề when kiểm tra token của request, để phân biệt client huỷ với timeout nội bộ; chỉ cái sau mới là sự cố thật.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Task.Run không làm gì đồng bộ trở thành bất đồng bộ. Nó chỉ đổi thread.
  2. async void lẻn vào qua lambda. Xem kiểu tham số, không xem chữ ký phương thức.
  3. Trạng thái chia sẻ giữa các task song song hỏng im lặng. Đừng chia sẻ; gom kết quả ở cuối.

Tham khảo ​

Điều hướng ​

Bài liên quan ​

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