Skip to main content

6.8 — 6. Common Pitfalls

Summary

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ộ làm giảm khả năng chịu tải chứ không tăng; 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.

Nội dung bài học​

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

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.4 và trong bài Gọi .Result khi nào thì deadlock.

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

// 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 duy nhất: đẩy công việc CPU-bound ra khỏi thread đang cần giải phóng — UI thread, hoặc một vòng lặp đang giữ khoá.

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

[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 thread pool. Task.Run lấy một thread khác cũng từ chính pool đó rồi bắt thread hiện tại chờ — tổng số thread bận không đổi, và bạn trả thêm phí chuyển ngữ cảnh.

Với công việc CPU nặng trong web, câu trả lời không phải Task.Run mà là: đẩy ra background job, hoặc chấp nhận nó và scale theo chiều ngang.

6.8.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:

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.3: 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.8.5 — Bẫy 5: LINQ Select(async ...) lười biếng​

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

await Task.WhenAll(tasks);
var count = tasks.Count(); // DUYET LAI -> CHAY LAI TAT CA MOT LAN NUA

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.

var tasks = customers.Select(c => ProcessAsync(c)).ToList();   // .ToList() chot lai
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.8.6 — Bẫy 6: List<T> không thread-safe​

// 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.

// ĐÚ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.8.7 — Bẫy 7: cần khoá quanh một thao tác async​

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.

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

await _gate.WaitAsync(ct);
try { await DoAsync(ct); }
finally { _gate.Release(); } // finally la bat buoc

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.8.8 — Bẫy 8: exception không được quan sát​

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:

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.8.9 — Bẫy 9: coi OperationCanceledException là lỗi​

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".

catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("Request bi huy boi 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.5.

6.8.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ắn 200 request đồng thời và so sánh thông lượng cùng số luồng thread pool.

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

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.2. Điểm mới: trong ASP.NET Core, luồng mà Task.Run mượn đến từ đâu?

Lời giải.

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)));
bombardier -c 200 -n 2000 http://localhost:5000/v1
bombardier -c 200 -n 2000 http://localhost:5000/v2

Kết quả điển hình:

/v1 Task.Run/v2 async thật
Thông lượngThấpCao hơn nhiều
Số luồng poolTăng dầnỔn định
threadpool-queue-lengthTăng~0
Chi phí thêm2 lần chuyển ngữ cảnh mỗi request0

Vì sao Task.Run tệ hơn cả gọi đồng bộ thẳng trong ASP.NET Core. Đây là điểm phản trực giác nhất:

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

Task.Run bọc I/O đồng bộ:
Luồng A trả về pool tại await
Luồng B (MƯỢN TỪ CHÍNH POOL ĐÓ) bị giam 1 lần I/O
Xong thì đánh thức lại luồng A
-> Tốn: 1 luồng, CỘNG 2 lần chuyển ngữ cảnh, CỘNG chi phí xếp lịch

Cùng số luồng bị giam, nhưng thêm chi phí. Trong ASP.NET Core, Task.Run lấy luồng 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 rồi trả phí vận chuyển.

Đ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(() => IoDongBo())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:

// 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ó.

Bật cảnh báo tự động:

<WarningsAsErrors>$(WarningsAsErrors);CA1849</WarningsAsErrors>

CA1849 cảnh báo khi gọi phương thức đồng bộ trong ngữ cảnh async mà có sẵn bản bất đồng bộ.

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.

for (int lan = 1; lan <= 10; lan++)
{
var ds = new List<int>();
await Parallel.ForEachAsync(Enumerable.Range(1, 1000), async (i, ct) =>
{
await Task.Yield();
ds.Add(i); // KHÔNG an toàn khi truy cập đồng thời
});
Console.WriteLine($"lần {lan,2}: {ds.Count,4} phần tử");
}

Kết quả đo thật trên .NET 9:

lần  1:  999 phần tử   <- MẤT
lần 2: 975 phần tử <- MẤT
lần 3: 975 phần tử <- MẤT
lần 4: 969 phần tử <- MẤT
lần 5: 980 phần tử <- MẤT
lần 6: 991 phần tử <- MẤT
lần 7: 991 phần tử <- MẤT
lần 8: 981 phần tử <- MẤT
lần 9: 990 phần tử <- MẤT
lần 10: 991 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ừ 1 tới 31 phần tử.

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:

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:

// 1. ConcurrentBag — đơn giản nhất, không bảo đảm thứ tự
var bag = new ConcurrentBag<int>();
await Parallel.ForEachAsync(nguon, 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
var kq = await Task.WhenAll(nguon.Select(async i => { await Task.Yield(); return i; }));

// 3. Khoá tường minh — khi buộc phải giữ List
var ds = new List<int>();
var khoa = new Lock();
await Parallel.ForEachAsync(nguon, async (i, ct) =>
{
await Task.Yield();
lock (khoa) { ds.Add(i); }
});

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 sáu bẫy:

public class ReportService
{
private readonly CrmDbContext _db;
private readonly List<string> _results = new(); // bẫy 6

public async void Process() // bẫy 4
{
var customers = _db.Customers.ToList(); // bẫy 2 (có ToListAsync)

var tasks = customers.Select(async c => await ComputeAsync(c)); // bẫy 5 — chưa chạy
var count = tasks.Count(); // chỉ đếm, không await

await Parallel.ForEachAsync(customers, async (c, ct) =>
{
var orders = await _db.Orders.Where(o => o.CustomerId == c.Id)
.ToListAsync(ct); // bẫy 3 — DbContext dùng chung
_results.Add(orders.Count.ToString()); // bẫy 6
});

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

private decimal SumTotals(Customer c)
=> _db.Orders.SumAsync(o => o.Total).Result; // bẫy 1
}

Danh sách rà soát, kèm thông tin công cụ có bắt được không:

#BẫyDấu hiệuCông cụ bắt được?
1sync-over-async.Result, .Wait(), .GetAwaiter().GetResult()Có — CA1849
2async-over-syncTask.Run bọc I/O, hoặc gọi bản đồng bộCó — CA1849
3DbContext dùng chung_db trong Parallel hoặc WhenAllKhông
4async voidchữ ký async void không phải event handlerCó — VSTHRD100
5Select(async ...) lườiIEnumerable<Task> không được awaitKhông
6List<T> dùng chung.Add trong ngữ cảnh song songKhông
7lock quanh awaitkhông biên dịch được, nhưng Monitor thủ công thì cóMột phần
8Ngoại lệ không quan sátTask bị bỏ, _ = Method()Một phần — CS4014
9Thiếu CancellationTokenphương thức async không có tham số ctKhông

Bốn 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. Cả bốn đề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ố 3 và số 6 là hai bẫy tốn kém nhất, vì cả hai đều không ném ngoại lệ mà chỉ làm hỏng dữ liệu một cách ngẫu nhiên — như bài 2 đã chứng minh.

Đư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, bật các quy tắc mà công cụ bắt được:

<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>Recommended</AnalysisMode>
<WarningsAsErrors>$(WarningsAsErrors);CA1849;CA2007;CS4014</WarningsAsErrors>
</PropertyGroup>

Cộng thêm gói Microsoft.VisualStudio.Threading.Analyzers để có VSTHRD100 và một loạt quy tắc khác về async.

Nguyên tắc chung, lặp lại từ bài 4.7. 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 bốn bẫy còn lại.

Tự kiểm tra​

Frequently asked questions

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, nên Task.Run chỉ lấy một thread khác cũng từ pool đó và bắt thread hiện tại chờ. Tổng số thread bận không đổi, còn bạn trả thêm phí chuyển ngữ cảnh. 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​