Thực hành tốt nhất về đa luồng: ấn bản .NET — Những gì cần quyết trước khi thêm luồng

· · Windows, Đa luồng, C#, .NET, Ứng dụng nghiệp vụ, Điều tra lỗi, Thiết kế

«Xử lý chậm nên chúng tôi thêm luồng để song song hóa, rồi tổng hợp thỉnh thoảng lệch.» «Chúng tôi thêm việc nền, rồi ứng dụng đóng băng một lần mỗi tháng.» «Họ bảo gắn trình gỡ lỗi thì không tái hiện, nhưng ở máy khách thì chắc chắn xảy ra.» — Điều khiến lập trình đa luồng đáng sợ là nó trông đúng ngay lúc bạn vừa viết xong. Bug điều kiện đua phụ thuộc thời điểm: chúng lọt qua kiểm thử và chỉ lộ mặt ở môi trường sản xuất.

Đồng thời, khi phần cứng đa lõi đã là mặc định, ngay cả ứng dụng nghiệp vụ cũng có những tình huống không tránh được đa luồng — yêu cầu như «chạy việc nặng mà không đóng băng UI» hay «xử lý đồng thời nhiều thiết bị hoặc tệp». Điều quan trọng là quyết nguyên tắc thiết kế trước khi thêm luồng. Bug đa luồng không phải thứ bạn dập bằng gỡ lỗi; chúng là thứ bạn thiết kế sao cho không còn chỗ để chúng len vào.

Bài này là ấn bản .NET của loạt đa luồng thực hành. Hướng tới nhà phát triển viết ứng dụng nghiệp vụ trên Windows khi cần đa luồng, bài sắp xếp các nguyên tắc thiết kế đúng bất kể ngôn ngữ hay hệ điều hành, cùng bộ công cụ cụ thể của C#/.NET, dựa trên nguồn gốc tính đến tháng 8 năm 2026. Bản thân các nguyên tắc không đổi trên Linux hay trong C++. Nếu viết mã gốc, hãy xem các bài đồng hành ánh xạ cùng nguyên tắc sang công cụ từng ngôn ngữ — «ấn bản C++» và «ấn bản C» — còn nếu viết Java, xem «ấn bản Java».

1. Kết luận trước

  • Thực hành tốt nhất đầu tiên là đừng tự tạo luồng. Dựa trên API tầng trên — Task, thread pool, lớp Parallel — thay vì new Thread, và để runtime quản lý số luồng.12
  • Thứ cần cắt trước khi song song hóa là «trạng thái biến đổi dùng chung». Chỗ nhiều luồng ghi cùng một biến chính là nơi đua sinh ra; trước khi lấy khóa để bảo vệ, hãy giảm chính việc dùng chung bằng tách dữ liệu, bất biến, và chuyển giao.3
  • Gắn kỷ luật cho khóa. Quyết, một-đối-một, «khóa nào bảo vệ dữ liệu nào», và để đối tượng khóa là thực thể riêng không lộ ra ngoài. lock(this)lock(typeof(X)) bị cấm. Từ .NET 9 trở đi, dùng kiểu chuyên dụng System.Threading.Lock.4
  • Dẫn việc chuyển dữ liệu giữa các luồng qua hàng đợi. Cấu trúc producer/consumer dựng trên System.Threading.Channels hoặc tập hợp đồng thời đơn giản hơn việc rải khóa khắp nơi, và còn cho bạn một biên rõ.56
  • Thiết kế cách dừng trước. Hủy hợp tác qua CancellationToken là câu trả lời đúng duy nhất để dừng; Thread.Abort ném ngoại lệ runtime trên .NET (dòng Core).78
  • UI thuộc riêng luồng UI. Không điều khiển WinForms nào, cũng không phần tử WPF nào, được chạm từ luồng khác ngoài luồng đã tạo chúng. Từ luồng khác, hãy nhờ qua Control.Invoke / Dispatcher.910
  • «Song song nghĩa là nhanh hơn» không luôn đúng. Vòng mà việc mỗi lần lặp nhỏ có thể chậm đi vì chi phí song song hóa. Luôn đo trước khi áp dụng.3

2. Vì sao đa luồng khó — điều kiện đua và deadlock

Nói gọn, đa luồng đưa vào hai loại vấn đề.4

Điều kiện đua (race condition) là bug mà kết quả đổi tùy thứ tự nhiều luồng đến một đoạn mã. Ví dụ kinh điển là tăng bộ đếm dùng chung: một dòng count++ thực ra tách thành ba bước — «đọc → cộng → ghi lại». Nếu hai luồng thực thi ba bước đó cùng lúc, lần ghi lại của một luồng ghi đè phép cộng của luồng kia, và lần tăng bị mất. Kết quả đổi mỗi lần chạy, và kết quả nào bạn nhận thì không đoán được.4

Điều kiện đua trên bộ đếm dùng chungĐiều kiện đua kinh điển trong đó một lần tăng trên bộ đếm dùng chung bị mất. Nếu luồng khác xen vào trong ba bước của count++, lần ghi lại nào xảy ra sau cùng sẽ ghi đè lần kiaLuồng BBiến dùng chung countLuồng ALuồng BBiến dùng chung countLuồng Acount = 10Đã cộng hai lần vậy mà count = 11phép cộng của Luồng A bị mấtĐọc (10)Đọc (10)Cộng cục bộ (11)Cộng cục bộ (11)Ghi lại (11)Ghi lại (11)

Hình 1: Điều kiện đua kinh điển trong đó một lần tăng trên bộ đếm dùng chung bị mất. Nếu luồng khác xen vào trong ba bước của count++, lần ghi lại nào xảy ra sau cùng sẽ ghi đè lần kia

Deadlock là trạng thái hai luồng mỗi bên chờ khóa bên kia đang giữ, nên không ai tiến được. Luồng A giữ khóa 1 và chờ khóa 2; luồng B giữ khóa 2 và chờ khóa 1 — chỉ vậy là đủ để cả hai dừng mãi.4

Vòng chờ của deadlockVòng chờ của deadlock. Khoảnh khắc các mũi tên chờ tạo thành vòng, mọi luồng trong vòng đó dừng mãiđang chờ giải phóng khóa 2đang chờ giải phóng khóa 1Luồng Ađang giữ khóa 1Luồng Bđang giữ khóa 2

Hình 2: Vòng chờ của deadlock. Khoảnh khắc các mũi tên chờ tạo thành vòng, mọi luồng trong vòng đó dừng mãi

Điều khiến cả hai khó chịu là chúng phụ thuộc thời điểm. Một xen kẽ (một tổ hợp thứ tự thực thi) chỉ trúng một lần trong hàng chục nghìn lần chạy trên máy phát triển có thể xảy ra mỗi ngày trên máy khách, nơi số lõi và thời điểm đều khác. «Không tái hiện khi gắn trình gỡ lỗi» và «biến mất khi tôi thêm ghi nhật ký» xảy ra vì chính việc quan sát đổi thời điểm — đó là hành vi kinh điển của bug đua.

Đó chính là lý do mọi nguyên tắc từ đây hướng một chiều: trước khi «đồng bộ cho đúng», hãy giảm những chỗ cần đồng bộ — đây là xương sống của thiết kế đa luồng.

3. Nguyên tắc 1: Đừng tự tạo luồng

3.1. Dựa trên Task và thread pool

Tạo luồng trực tiếp bằng new Thread(...) là, trong .NET ngày nay, biện pháp cuối cùng mang tính ngoại lệ. Từ .NET Framework 4, phương tiện được khuyến nghị cho mã đa luồng và song song là TPL (Task Parallel Library) — tức họ API lấy Task làm trung tâm. TPL điều chỉnh động độ song song cho khớp số bộ xử lý sẵn có, và gánh hết việc tầng thấp: chia việc, lập lịch lên thread pool, xử lý hủy, và quản lý trạng thái.1

Thread pool là hạ tầng mà chính .NET dùng rộng rãi — chạy Task, hoàn tất I/O bất đồng bộ, callback bộ hẹn giờ, và hơn thế — và miễn bạn ném vào những việc ngắn, nhà phát triển không cần tự quản vòng đời luồng.2

// Chạy tính toán nặng CPU ở nền
var result = await Task.Run(() => HeavyCalculation(input));

// Chạy vài thao tác độc lập đồng thời rồi chờ tất cả (khi số lượng nhỏ)
// * Hình dạng này giả định ProcessAsync là phương thức bất đồng bộ I/O-bound.
//   WhenAll chỉ «chờ các Task đã chạy», nên nếu muốn chạy việc CPU-bound
//   đồng thời, hãy bọc từng phần trong Task.Run(() => Calc(x)) để đưa lên thread pool
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// Nếu có nhiều phần tử, hãy giới hạn độ đồng thời
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// Hai điểm quan trọng: nối token của bên gọi vào ParallelOptions
// (quên điều này thì ct trong thân luôn là None), và đưa cùng ct đó
// vào thân luôn (đừng bỏ)

Có một lưu ý. Task.WhenAll(items.Select(...)) bắt đầu xử lý mọi phần tử cùng lúc, ngay khi được liệt kê. Vài việc đến vài chục việc cố định thì không sao, nhưng dùng trên tập hợp lớn sẽ cạn socket, kết nối DB và bộ nhớ một lần. Với việc không đoán được khối lượng, hãy gắn trần độ đồng thời như Parallel.ForEachAsync ở trên, hoặc kiểm soát lưu lượng bằng kênh bounded, nói sau.

Tự tạo luồng gần như chỉ được biện minh khi tính chất của chính luồng là yêu cầu — những thứ như «cần vòng thông điệp riêng», «cần chỉ định apartment (STA)», hoặc «cần chạy suốt vòng đời ứng dụng».

3.2. Với song song dữ liệu, dùng Parallel.For / ForEach

Với song song dữ liệu — «áp dụng cùng xử lý cho mọi phần tử của một tập hợp để tăng tốc toàn bộ» — hãy dùng Parallel.For / Parallel.ForEach chứ đừng tự chia vòng sang các luồng. TPL lo việc tách nguồn dữ liệu (partitioning) và cân lại tải, và với vòng cơ bản bạn thậm chí không cần khóa.11

Tuy nhiên có hai bẫy mà tài liệu chính thức nêu tường minh.3

  • Đừng giả định song song luôn nhanh hơn. Vòng ít lần lặp, hoặc việc mỗi lần lặp nhẹ, có thể chậm đi vì chi phí song song hóa vượt thân việc. Hiệu năng phụ thuộc nhiều yếu tố, nên luôn đo rồi mới quyết.
  • Đừng để các lần lặp chờ nhau. Không có bảo đảm mỗi lần lặp của Parallel.For thực sự chạy song song. Mã một lần lặp chờ sự kiện do lần lặp khác đặt có thể deadlock, tùy lập lịch.

3.3. Đưa việc «chờ» sang I/O bất đồng bộ, không sang luồng

Việc chủ yếu chờ I/O — tệp, mạng, cơ sở dữ liệu — không phải ứng viên để thêm luồng. Giữ nguyên một luồng trong lúc chờ chỉ là lãng phí; I/O bất đồng bộ qua async/await không tiêu thụ luồng khi chờ. Sự phân biệt này — song song hóa việc CPU-bound, bất đồng bộ hóa việc I/O-bound — là đường kẻ đầu tiên bạn nên vẽ ở cửa vào thiết kế đa luồng.

Các nhánh trước khi dựng luồngCác nhánh cần đi trước khi «dựng một luồng». Hầu hết việc nghiệp vụ rơi vào một trong ba lối ra phía trên, và tới new Thread chỉ là trường hợp ngoại lệChờ I/O chiếm phần lớntệp, mạng, DBTính toán dùng CPUÁp dụng cùng xử lýcho mọi phần tử của tập hợpMột khối việc nềnđộc lậpVòng thông điệp, yêu cầu STA, v.v.tính chất của chính luồng là yêu cầuCó việc muốn chạy đồng thờiChủ thể của việc là?I/O bất đồng bộ với async/awaitkhông tăng số luồngHình dạng công việc là?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(biện pháp cuối cùng, ngoại lệ)

Hình 3: Các nhánh cần đi trước khi «dựng một luồng». Hầu hết việc nghiệp vụ rơi vào một trong ba lối ra phía trên, và tới new Thread chỉ là trường hợp ngoại lệ

Quyết định thực tiễn cho async/await được trình bày trong «Bảng quyết định thực tiễn cho C# async/await - Task.Run và ConfigureAwait», còn cách thread pool và I/O bất đồng bộ nối với nhau ở tầng dưới được trình bày chi tiết trong «Chiều sâu I/O của Windows (Phần 3) — I/O Completion Port (IOCP) và Thread Pool của .NET».

4. Nguyên tắc 2: Tối thiểu hóa trạng thái biến đổi dùng chung

Đua chỉ xảy ra khi vừa có «nhiều luồng» vừa có «dữ liệu biến đổi dùng chung». Số luồng do yêu cầu quyết định, nên thiết kế có thể cắt là phần dùng chung. Có ba cách.

4.1. Tách — để mỗi luồng chỉ chạm dữ liệu của mình

Cách đơn giản và mạnh nhất là chia dữ liệu theo từng luồng. Với tổng hợp trong vòng song song, thay vì ghi vào biến tổng dùng chung mỗi lần lặp, hãy dùng overload của Parallel.For nhận trạng thái cục bộ theo luồng để mỗi luồng dựng tổng phụ ở chỗ mình, rồi gộp một lần lúc cuối. Số lần ghi vào trạng thái dùng chung giảm từ «mỗi lần lặp» xuống «một lần mỗi luồng», và cả chi phí đồng bộ lẫn cửa sổ đua thu nhỏ theo cấp số.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                     // Giá trị ban đầu cục bộ theo luồng
    (i, state, local) => local + Weigh(items[i]),  // Mỗi lần lặp chỉ cộng vào local của mình
    local => Interlocked.Add(ref total, local));   // Gộp xảy ra một lần mỗi luồng
Tổng hợp cục bộ theo luồngTổng hợp cục bộ theo luồng. Vì mỗi luồng chỉ chạm dữ liệu của mình trong lúc xử lý, không còn chỗ cho đua, và ghi vào trạng thái dùng chung chỉ xảy ra một lần mỗi luồng, lúc gộpMảng dữ liệu (đối tượng xử lý)Luồng 1xử lý phần của mình vàchỉ cộng vào tổng phụ cục bộLuồng 2xử lý phần của mình vàchỉ cộng vào tổng phụ cục bộLuồng 3xử lý phần của mình vàchỉ cộng vào tổng phụ cục bộGộp: Interlocked.Addphản ánh vào tổng một lần mỗi luồng

Hình 4: Tổng hợp cục bộ theo luồng. Vì mỗi luồng chỉ chạm dữ liệu của mình trong lúc xử lý, không còn chỗ cho đua, và ghi vào trạng thái dùng chung chỉ xảy ra một lần mỗi luồng, lúc gộp

4.2. Làm bất biến — thứ bạn không viết lại thì được chia sẻ tự do

Dữ liệu chỉ được đọc thì an toàn để đọc đồng thời từ bất kỳ số luồng nào. Giá trị cấu hình, dữ liệu gốc, đầu vào tính toán và tương tự có thể chia sẻ không đồng bộ nếu bạn làm chúng bất biến — không bị viết lại sau khi xây. Trong C#, kiểu record và thuộc tính init chống lưng cho thiết kế này. Chỉ cần quyết «khi cần thay đổi, xây thực thể mới rồi hoán, chứ không viết lại cái đang có» là bớt một mảnh trạng thái biến đổi bạn phải bảo vệ.

Nhưng «trông như chỉ-đọc» và «là bất biến» là hai việc khác nhau. Giao diện chỉ-đọc như IReadOnlyList<T> chỉ nghĩa «bạn không viết lại được qua giao diện đó» — nó không ngăn List<T> phía dưới bị viết lại qua một tham chiếu khác. Bảo đảm của record / init cũng nông: nó không bảo vệ các đối tượng mà thuộc tính trỏ tới. Với dữ liệu bạn thực sự muốn chia sẻ an toàn giữa các luồng, hãy dùng tập hợp bất biến từ System.Collections.Immutable như ImmutableArray<T>, hoặc đưa một bản sao đúng lúc chia sẻ, cắt đứt đường viết lại. Khi đó điều kiện là bản thân kiểu phần tử T cũng phải bất biến. Tập hợp bất biến chỉ bảo vệ «thứ tự» — tham chiếu tới đối tượng phần tử biến đổi vẫn được chia sẻ nguyên, nên nếu nội dung một phần tử bị viết lại qua đường khác, đua vẫn còn. Hãy làm đồ thị đối tượng bất biến đến tận lá, hoặc đưa một bản sao sâu.

4.3. Chuyển giao — gửi qua hàng đợi thay vì dùng chung

Dù vậy, bạn vẫn cần chuyển dữ liệu giữa các luồng. Khi đó, thay vì «cả hai bên chạm một biến dùng chung», hãy dùng cấu trúc producer/consumer một bên ghi, một bên đọc, với hàng đợi ở giữa.

Lựa chọn đầu trên .NET là System.Threading.Channels. Đó là FIFO mà producer ghi dữ liệu bất đồng bộ và consumer đọc bất đồng bộ, và chính kênh quản lý hết việc đồng bộ.5

var channel = Channel.CreateBounded<WorkItem>(100); // Sức chứa 100 — tạo sức ép ngược

// Phía producer
await channel.Writer.WriteAsync(item, ct); // Nếu đầy, chờ đến khi có chỗ
// …khi mọi producer đã ghi xong:
channel.Writer.Complete();   // Tuyên bố «không còn gì nữa». Không có dòng này thì vòng của bên đọc không bao giờ kết thúc

// Phía consumer
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
Cấu trúc producer/consumer quanh kênhCấu trúc producer/consumer dựng quanh một kênh. Không bên nào chạm biến dùng chung trực tiếp; cả việc chờ lẫn kiểm soát sức chứa đều để kênh lonếu đầy thì bắt ghi chờ(sức ép ngược)nếu rỗng thì bắt đọc chờProducer 1WriteAsynckênh bounded (sức chứa 100)hàng đợi FIFOđồng bộ do kênh quản lýProducer 2WriteAsyncConsumer 1ReadAllAsyncConsumer 2ReadAllAsync

Hình 5: Cấu trúc producer/consumer dựng quanh một kênh. Không bên nào chạm biến dùng chung trực tiếp; cả việc chờ lẫn kiểm soát sức chứa đều để kênh lo

Điều quan trọng trong thực tiễn là chọn kênh có sức chứa bị chặn (bounded). Hành vi mặc định khi chạm trần là «bên ghi chờ chỗ trống», trở thành sức ép ngược (backpressure) tự nhiên. Dùng hàng đợi không trần trong cấu hình sản xuất vượt tiêu thụ, bạn nhận một quả bom hẹn giờ vẫn chạy trong lúc bộ nhớ cứ lớn.5

Trong thế giới đồng bộ, BlockingCollection<T> với sức chứa được chỉ định đóng cùng vai trò với kênh bounded. Nó kết hợp chặn với kiểm soát sức chứa: trần sức chứa ngăn producer đi quá xa so với consumer, và nó chặn, bắt consumer chờ khi rỗng.12 Ngược lại ConcurrentQueue<T> / ConcurrentStack<T> là tập hợp nhanh đạt an toàn luồng chỉ bằng thao tác Interlocked chứ không bằng khóa6, nhưng chúng là hàng đợi an toàn luồng trần, không trần sức chứa cũng không cơ chế «chờ khi rỗng». Hãy nghĩ chúng như một thành phần, không phải ngôi sao của thiết kế chuyển giao. Cũng lưu ý BlockingCollection<T> không được thiết kế với truy cập bất đồng bộ, nên nếu ghép với async/await, hãy chọn Channel<T>.12

Một lưu ý: hãy cảnh giác giả định «đổi từ điển sang ConcurrentDictionary là an toàn luồng». Ngay khi từng thao tác đã an toàn luồng, thao tác ghép như «kiểm tra có chưa, rồi thêm» vẫn đua (hãy dùng phương thức dành cho thao tác ghép, như GetOrAdd). Và bản thân GetOrAdd cũng có lưu ý riêng: giá trị cuối cùng được lưu thì được bảo đảm là một, nhưng hàm factory dựng giá trị có thể được gọi hơn một lần khi có tranh chấp. Đưa tác dụng phụ vào factory — mở kết nối, tạo tệp, v.v. — thì lần gọi trùng làm rò, nên hoặc để factory không tác dụng phụ, hoặc, với khởi tạo bạn cần xảy ra đúng một lần, hãy lưu Lazy<T> làm giá trị. Đổi kiểu tập hợp không thay được việc giảm trạng thái biến đổi dùng chung.

5. Nguyên tắc 3: Gắn kỷ luật cho khóa

Dù đã giảm trạng thái biến đổi dùng chung, thường bạn không đưa được về không. Dùng loại trừ (khóa) cho phần còn dùng chung, nhưng khóa không phải công cụ «bọc lock quanh chỗ trông khả nghi, phòng hờ». Có bốn điểm kỷ luật.

5.1. Quyết «bạn đang bảo vệ gì», và khóa bằng đối tượng riêng

Hãy nghĩ đơn vị khóa là «dữ liệu», không phải «một đoạn mã». Gán một đối tượng khóa cho mỗi tập dữ liệu biến đổi bạn muốn bảo vệ, và lấy đúng khóa đó ở mọi chỗ đụng dữ liệu đó — bug đua, trong thực tế, là những gì bạn nhận khi bảng tương ứng này đã vỡ.

Để đối tượng khóa là thực thể riêng không lộ ra ngoài. lock(this) chia sẻ khóa với mọi mã ngoài tham chiếu được thực thể của bạn, còn lock(typeof(X)) chia sẻ nó với toàn bộ application domain — cả hai đều là đất nuôi deadlock. Từ .NET 9 / C# 13 trở đi, dùng một thực thể của kiểu chuyên dụng System.Threading.Lock làm đối tượng khóa được khuyến nghị.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (readonly object trước đó)
    private readonly List<Order> _orders = [];    // Dữ liệu mà _gate bảo vệ

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

Câu lock của C# bảo đảm khóa được giải phóng ngay cả khi ngoại lệ xảy ra. Cách trình biên dịch triển khai nó phụ thuộc kiểu đối tượng khóa: với đối tượng thường nó trở thành lời gọi Monitor.Exit trong khối finally, còn với kiểu Lock nó trở thành lời gọi EnterScope() và việc hủy của nó.413 Nói cách khác, trường kiểu Lock là cơ chế khác với Monitor, và nếu chỉ một phần mã viết tay Monitor.Enter(_gate), loại trừ lẫn nhau với lock (_gate) không còn đứng. Với cả hai kiểu, an toàn hơn là ngừng viết tay Monitor.Enter / Exit và thống nhất hoàn toàn trên cú pháp lock.4

5.2. Đừng làm việc chậm hay bên ngoài trong lúc giữ khóa

Giữ khóa càng ngắn càng tốt; điều duy nhất bạn nên làm khi đang giữ là đọc và ghi dữ liệu nó bảo vệ. Viết mã thực hiện I/O khi đang giữ khóa, hoặc gọi ra mã ngoài qua sự kiện hay callback, không chỉ kéo dài thời gian giữ — nó mở đường callee cố lấy khóa khác rồi deadlock. Chuẩn bị ngoài khóa, và trong khóa chỉ hoán — đó là hình dạng cơ bản.

Cũng lưu ý bạn không await được bên trong lock (đó là lỗi biên dịch). Đây là bảo vệ, không chỉ là hạn chế: Monitor có ái lực luồng — luồng đã lấy khóa phải là luồng giải phóng nó — điều không tương thích với mã bất đồng bộ, nơi luồng thực thi có thể đổi qua một await. Với loại trừ trong mã bất đồng bộ, dùng SemaphoreSlim với số đếm ban đầu là 1.14

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

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. Luôn lấy nhiều khóa theo cùng một thứ tự

Khi bạn có hai khóa trở lên, mẫu deadlock kinh điển là thứ tự chiếm bị đảo từ luồng này sang luồng kia. Cách sửa đơn giản: đặt quy tắc mọi luồng lấy khóa theo cùng thứ tự. Chỗ bạn không bảo đảm được thứ tự, hãy dùng overload có timeout của Monitor.TryEnter, và nếu không lấy được khóa, lùi rồi thử lại (hoặc ghi bất thường) — điều đó biến một lần treo kéo dài mãi thành thất bại phát hiện được.4

5.4. Dùng Interlocked cho cập nhật đơn giản, ReaderWriterLockSlim khi đọc chiếm phần lớn

Với cập nhật nguyên tử một biến đơn — tăng hoặc giảm bộ đếm, hoán cờ — lớp Interlocked (Increment / Add / CompareExchange) nhanh hơn lock. Không tranh chấp, nó có thể chỉ tốn một tiền tố lệnh CPU.4 Ngược lại, Interlocked chỉ đi tới đó; nó không giữ vài biến nhất quán cùng lúc. Cấu trúc không-khóa viết tay kết hợp volatile là công cụ của chuyên gia, đòi hiểu sâu mô hình bộ nhớ, và không phải thứ bạn nên viết trong ứng dụng nghiệp vụ.

Với dữ liệu dùng chung «đọc thường xuyên nhưng ghi hiếm», còn có lựa chọn ReaderWriterLockSlim, loại trừ chỉ ghi trong lúc để đọc đi qua đồng thời.13

6. Nguyên tắc 4: Thiết kế cách dừng trước

Câu hỏi đầu tiên cần hỏi trong rà soát thiết kế đa luồng là «cái này dừng thế nào». Bạn viết được mã bắt đầu chạy mà không nghĩ tới điều đó, nhưng mã dừng an toàn không ra đời trừ khi bạn thiết kế nó.

6.1. Hủy hợp tác (CancellationToken) là câu trả lời đúng duy nhất

Mô hình dừng của .NET được thống nhất quanh hủy hợp tác. Bên muốn dừng tạo một CancellationTokenSource và đưa Token của nó cho từng phần xử lý. Khi muốn dừng, nó gọi Cancel(). Bên xử lý theo dõi token và, ở điểm thuận tiện do chính nó chọn, dọn rồi kết thúc — vì là hợp tác chứ không cưỡng bức, bên xử lý có thể kết thúc trong lúc giữ trạng thái nhất quán suốt.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // Từ chối Start kép trong lúc vẫn chạy
        throw new InvalidOperationException("Worker đang chạy rồi.");
    if (_worker is { IsFaulted: true })       // Đừng dựng lại trên lần thất bại trước đã bị nuốt
        throw new InvalidOperationException("Worker lần trước đã thất bại.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // Bắt vào biến cục bộ trước, để không đua với lần Start lại sau khi dừng
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // Theo dõi bằng polling
    {
        ProcessNextItem(ct);              // Đưa ct vào mọi lời gọi chặn để ngắt ngay
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // Ghim vào biến cục bộ để dù các trường bị
    var worker = _worker;    // thay trong lúc chờ, ta không dừng nhầm đích
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // Callback đăng ký trên token có thể ném
    catch (Exception ex) { cancelFailure = ex; }   // Giữ lại và báo sau khi đã gộp

    try
    {
        try { await worker; }    // Luôn gộp bất kể Cancel thành công hay không, và quan sát mọi thất bại giữa chừng
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // Chỉ coi hủy do chính ta yêu cầu là «bình thường»
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // Đừng mất lần thất bại nào
        }
    }
    finally
    {
        cts.Dispose();           // Hủy nguồn sau khi đã gộp (giải phóng tài nguyên OS như WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // Đừng để StopAsync sau dùng nguồn đã Dispose
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

Lưu ý rằng Start / StopAsync này là cấu trúc tối thiểu giả định được gọi tuần tự từ một luồng (luồng UI, chẳng hạn). Nếu nhiều luồng có thể thao tác vòng đời đồng thời, hãy tuần tự hóa chính Start / StopAsync bằng thứ như SemaphoreSlim — sẽ phản tác dụng nếu thao tác quản lý vòng đời tự đua với nhau, trước cả khi bạn bảo vệ worker.

Mẫu nhỏ này cũng gắn sẵn vài chỉnh đáng giá trong thực tiễn. Trước hết, Start từ chối lời gọi kép trong lúc đang chạy. Ghi đè _cts_worker không điều kiện sẽ mất tham chiếu tới worker trước, để lại một «luồng lạc» chạy song song — thứ bạn không dừng cũng không gộp được. Chuẩn của API vòng đời (Start/Stop) là tự bảo vệ «cùng lúc chỉ một». Ngoài ra còn ba điểm. Thứ nhất, API dừng chờ hoàn tất. Cancel() chỉ «yêu cầu» hủy; khoảnh khắc nó trả về, worker vẫn có thể đang giữa ProcessNextItem. Biến nó thành Stop() chỉ yêu cầu rồi trả về, bạn tạo một cuộc đua mới: bên gọi bắt đầu dọn trong lúc worker vẫn chạy. Thứ hai, giữ Task chứ đừng bỏ. Ném đi bằng _ = Task.Run(...) thì không ai hay nếu worker chết vì ngoại lệ. Thứ ba, bắt token vào biến cục bộ rồi mới đưa, chứ đừng tham chiếu _cts.Token bên trong lambda. Tham chiếu trong lambda nghĩa là nó được đánh giá lúc thực thi, và nếu có lần Start lại ngay sau khi dừng, bạn nhận tình huống worker cũ nắm token mới. Đưa cùng token đó làm đối số thứ hai của Task.Run nữa nghĩa là khi bên xử lý kết thúc qua ThrowIfCancellationRequested hoặc OperationCanceledException từ API hỗ trợ hủy, Task được phân loại «Canceled» chứ không «Faulted» (trong ví dụ này, thoát bình thường qua điều kiện vòng, như đã viết, vẫn được tính là hoàn tất thành công). Thêm nữa: catch trong StopAsync dùng bộ lọc when để bắt chỉ hủy xuất phát từ token của chính nó. Nuốt vô điều kiện OperationCanceledException sẽ biến cả thất bại thật do token khác bên trong xử lý — timeout từng phần tử, chẳng hạn — thành «đã dừng nên ổn». Lưu ý nhận dạng bằng khớp token này vỡ nếu WorkLoop bên trong dùng token liên kết (phép ghép liên kết ở mục 6.1), vì ngoại lệ bay ra mang token phía liên kết. Trong cấu hình đó, hãy chọn tường minh, như một quyết định thiết kế, hoặc gọi ct.ThrowIfCancellationRequested() ở lối ra của WorkLoop để «dịch» về token ngoài trước khi thoát, hoặc nới bộ lọc thành when (cts.IsCancellationRequested) và chấp nhận «hủy trong lúc đang có yêu cầu dừng được tính là bình thường».

Hình dạng hủy hợp tácHình dạng hủy hợp tác. Bên dừng chỉ gọi Cancel(); từng phần xử lý tự quyết «khi nào và thế nào» nó kết thúc. Đó là lý do nó dừng được trong lúc giữ trạng thái nhất quánGọi Cancel() một lầnGiao TokenGiao TokenGiao TokenKiểm tra IsCancellationRequesteddọn rồi tự kết thúcThrowIfCancellationRequestedNgắt ngay cả khi đang chờBên dừngCancellationTokenSourceXử lý worker 1Xử lý worker 2API thư việnhỗ trợ hủyKết thúc bình thườngOperationCanceledException= được coi là hủy hoàn tấtHủy hoàn tất

Hình 6: Hình dạng hủy hợp tác. Bên dừng chỉ gọi Cancel(); từng phần xử lý tự quyết «khi nào và thế nào» nó kết thúc. Đó là lý do nó dừng được trong lúc giữ trạng thái nhất quán

Phía thư viện cũng có quy ước ổn định. Thao tác có thể hủy nên cung cấp phương thức công khai nhận CancellationToken, và trong vòng tính toán, hoặc kiểm tra IsCancellationRequested định kỳ hoặc gọi ThrowIfCancellationRequested(). Cách sau ném OperationCanceledException, thứ Task coi là «hủy hoàn tất» chứ không «thất bại». Khi muốn dừng cả trên token do bên ngoài đưa lẫn trên mối quan tâm nội bộ (timeout, chẳng hạn), hãy ghép chúng bằng token liên kết.7

6.2. Coi Thread.Abort như không tồn tại

Thread.Abort — «giết từ ngoài một luồng không chịu nghe» — chỉ ném PlatformNotSupportedException trên .NET Core / .NET 5 trở đi; nó không còn dùng được chút nào. Ném ngoại lệ vào một luồng mà không biết nó đang thực thi chỗ nào rủi ro ngắt việc dọn tài nguyên và làm hỏng trạng thái. Nếu cần buộc kết thúc mã bên thứ ba không đáp hủy hợp tác (hoặc bạn không viết lại được để nó đáp), hướng dẫn chính thức là chạy nó trong tiến trình riêng rồi dừng bằng Process.Kill.8

6.3. Khi chờ, dùng wait handle, không polling

Viết «chờ trong vòng Sleep(100) đến khi cờ được đặt» lãng phí cả CPU lẫn độ đáp ứng. Có nguyên thủy đồng bộ như ManualResetEventSlimSemaphoreSlim để ra tín hiệu giữa các luồng, đưa luồng ngủ đúng cách đến khi được báo.13 Cách chọn giữa độ chính xác bộ hẹn giờ và chờ sự kiện trên Windows được trình bày chi tiết trong «Vì sao nên ưu tiên chờ sự kiện hơn Sleep(1) trên Windows».

7. Trường hợp đặc biệt của luồng UI — luật của ứng dụng máy tính để bàn Windows

Ứng dụng máy tính để bàn Windows có thêm một ràng buộc mạnh trên các nguyên tắc chung. Luật chỉ luồng đã tạo UI (luồng UI) mới được chạm nó.

Điều khiển WinForms không an toàn luồng; thao tác chúng từ nhiều luồng đẩy điều khiển vào trạng thái không nhất quán và gây đua, deadlock, đóng băng. Windows đòi ứng dụng có một luồng chuyên dụng nhận thông điệp hệ thống, và việc tạo cùng thao tác UI phải tập trung trên luồng đó.9 WPF có đúng cùng cấu trúc: chỉ luồng UI mới đổi được phần tử UI.10

Khi muốn cập nhật UI từ luồng khác, đừng chạm trực tiếp — hãy biến nó thành «một lời nhờ tới luồng UI».

Đổi cập nhật UI thành lời nhờĐổi cập nhật UI thành một «lời nhờ». Việc của luồng nền dừng ở chỗ được đặt lên hàng đợi thông điệp — luôn là chính luồng UI chạm điều khiểnNhờ qua Control.Invoke /Dispatcher.InvokeAsyncChạm điều khiển trực tiếpWindowschuột, bàn phím, vẽ lạiHàng đợi thông điệpcủa luồng UILuồng nền(việc nặng, giao tiếp)Luồng UIluồng duy nhất được chạm điều khiểnCấmnguyên nhân đua, deadlock, đóng băng

Hình 7: Đổi cập nhật UI thành một «lời nhờ». Việc của luồng nền dừng ở chỗ được đặt lên hàng đợi thông điệp — luôn là chính luồng UI chạm điều khiển

Framework Cách nhờ
WinForms Control.Invoke (đồng bộ) / Control.BeginInvoke (bất đồng bộ) / từ .NET 9 trở đi, Control.InvokeAsync9
WPF Dispatcher.Invoke (đồng bộ) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (bất đồng bộ)10

Trong số đó, dạng đồng bộ (Control.Invoke / Dispatcher.Invoke) cần thận trọng. Nếu luồng UI đang chờ đồng bộ worker đó xong, và worker gọi Invoke, bạn nhận deadlock mỗi bên chờ bên kia (đúng vòng chờ ở mục 2). Hãy lấy dạng bất đồng bộ (BeginInvoke / InvokeAsync) làm mặc định cho thông báo và báo tiến độ từ luồng nền, và giới hạn dạng đồng bộ ở tình huống bạn nói chắc luồng UI không đang chờ bạn.

Trong thực tiễn còn có câu trả lời tốt hơn một bậc. Viết xử lý bắt đầu trên luồng UI bằng async/await, và await bắt SynchronizationContext của luồng UI rồi tự động nối phần tiếp trên luồng UI, làm giảm mạnh số chỗ bạn cần viết tay Invoke. Tuy nhiên đây không phải tính chất vô điều kiện. Mã đi vào từ callback nền, hoặc phần tiếp sau ConfigureAwait(false), không trở về luồng UI, nên vẫn cần điều phối tường minh nếu bạn chạm UI trên đường đó. Ổn định ở hình «việc nặng đi vào Task.Run hoặc I/O bất đồng bộ, và phản ánh kết quả lên màn hình xảy ra ở phần tiếp sau await» là dạng cơ bản của ứng dụng Windows hiện đại. Quan hệ giữa luồng UI và async/await được gom trên một hình trong «async của WPF/WinForms và luồng UI trên một trang».

Ngoài ra, khi có COM — tích hợp Office, thành phần cũ, v.v. — thêm một lớp nữa: mô hình luồng riêng của COM (STA/MTA). Sự cố như «chúng tôi tạo đối tượng COM trên luồng UI nhưng gọi từ luồng khác rồi bị treo» thuộc lớp này, và được giải thích trong «Nền tảng COM STA/MTA - Mô hình phân luồng và cách tránh treo».

8. Khi viết bằng mã gốc (C++/C)

Các nguyên tắc đến đây — đừng tạo luồng trực tiếp, giảm trạng thái biến đổi dùng chung, kỷ luật khóa, thiết kế cách dừng — áp dụng nguyên sang mã gốc. Thứ đổi là bộ công cụ. Trong C++, đối ứng là RAII cùng std::jthread / std::mutex / std::atomic; trong C, chúng là _beginthreadex của Win32 API, khóa SRW, biến điều kiện, và mẫu sự kiện dừng. Từng cái được trình bày, gồm cả bẫy riêng ngôn ngữ (hàm hủy std::thread, nguy hiểm của TerminateThread, DllMain và loader lock, và hơn thế), trong «ấn bản C++» và «ấn bản C» của loạt này.

9. Kiểm chứng và gỡ lỗi — chuẩn bị trên giả định sẽ không tái hiện

Bạn không thể kỳ vọng kiểm thử tìm ra bug đa luồng. Kiểm thử đơn vị thông thường tính một lần chạy «tình cờ không đua» là đậu. Hãy nghĩ sự chuẩn bị thành ba lớp.

Tuyến phòng thủ đầu là đúng các nguyên tắc thiết kế đã nêu. Giữa ứng dụng có năm mảnh trạng thái biến đổi dùng chung và ứng dụng có năm mươi, số chỗ bạn phải nghi khác nhau gấp mười. Khi rà, xác nhận bằng bảng: «dữ liệu biến đổi nào được dùng chung», «khóa nào bảo vệ từng cái», «thứ tự chiếm khóa có duy nhất không», và «đường dừng ở đâu». Thiết kế mà bạn không viết được bảng này thì chưa xong, dù đang chạy.

Thứ hai, hãy làm trạng thái bất thường quan sát được thay vì giấu. Phát hiện bất thường chờ khóa bằng timeout của Monitor.TryEnter và ghi nhật ký,4 ghi chứ đừng nuốt ngoại lệ chưa quan sát từ việc ném lên thread pool, và sẵn sàng bắt dump đầy đủ khi treo để kiểm tra ngăn xếp mọi luồng — cuộc chiến với bug «chỉ thỉnh thoảng xảy ra» được quyết bởi bạn lấy được bao nhiêu thông tin từ đúng lần nó xảy ra. Thiết lập dump và ghi nhật ký được trình bày trong «Thiết kế ghi nhật ký và bắt dump khi ứng dụng Windows crash».

Thứ ba, lay mọi thứ dưới tải. Kiểm thử stress làm dễ trúng xen kẽ xui trên máy phát triển — chạy lâu với độ song song nhiều hơn số lõi, xáo thứ tự xử lý, bơm trễ nhân tạo, v.v. — là cách thực tế để moi đua trước khi xuất xưởng. Bug biến mất dưới trình gỡ lỗi thường tái hiện dưới bản phát hành cộng tải nặng.

10. Tóm tắt — danh sách kiểm trước khi thêm luồng

Nói gọn, thực hành tốt nhất của lập trình đa luồng không phải «kỹ năng viết đồng bộ cho đúng» mà là «thiết kế để bạn khỏi phải viết đồng bộ». Nếu trả lời được tám câu sau trước khi bắt tay, bạn ngăn được gần như mọi sự cố lớn.

  1. Việc này là CPU-bound hay I/O-bound (nếu là cái sau, câu trả lời là async/await, không phải luồng)?
  2. Bạn sắp viết new Thread (có diễn đạt được bằng Task, Parallel, hoặc thread pool không)?
  3. Dữ liệu biến đổi nào được dùng chung giữa các luồng — bạn liệt kê được không?
  4. Việc dùng chung đó có xóa được bằng tách, bất biến, hoặc chuyển giao qua hàng đợi không?
  5. Với từng mảnh dữ liệu dùng chung còn lại, đã có đúng một khóa tương ứng được quyết chưa?
  6. Thứ tự chiếm khóa có duy nhất trên mọi luồng, và bạn có đang tránh lời gọi ra ngoài trong lúc giữ khóa?
  7. CancellationToken có được đưa cho mọi thao tác chạy lâu, và bạn giải thích được đường dừng?
  8. Mã chạm UI có được tập trung trên luồng UI?

Bug đa luồng không lộ vào ngày bạn viết mã — chúng nhe răng ở máy khách lâu sau khi bạn đã quên. Nói ngược lại, nếu đi qua danh sách này ở giai đoạn thiết kế, bạn nhặt được loại sự cố đắt nhất — «thỉnh thoảng crash», «đóng băng một lần mỗi tháng» — trước khi viết một dòng mã.

Bài viết liên quan

Lĩnh vực tư vấn liên quan

KomuraSoft LLC đảm nhận rà soát thiết kế ứng dụng nghiệp vụ có đa luồng, điều tra nguyên nhân gốc các lỗi khó tái hiện như «thỉnh thoảng crash/treo» — phân tích dump và khoanh chỗ đua — và tư vấn kỹ thuật về song song hóa hoặc bất đồng bộ hóa ứng dụng sẵn có. Chúng tôi sẵn sàng vào từ giai đoạn sớm như «xin kiểm tra thiết kế này có đua được không».

Liên kết tham khảo

  1. Microsoft Learn, Task Parallel Library (TPL). Về TPL là phương tiện được khuyến nghị cho mã đa luồng và song song từ .NET Framework 4; về việc nó điều chỉnh động độ song song cho khớp bộ xử lý sẵn có; về việc nó gánh chia việc, lập lịch lên thread pool, xử lý hủy và quản lý trạng thái; về vòng mà việc mỗi lần lặp nhỏ có thể chậm đi vì chi phí song song hóa; và về việc hiểu cơ bản khóa, deadlock và điều kiện đua vẫn được khuyến nghị ngay cả khi dùng TPL.  2

  2. Microsoft Learn, The managed thread pool. Về lớp ThreadPool cung cấp nhóm luồng worker do hệ thống quản lý, để nhà phát triển tập trung vào tác vụ ứng dụng chứ không quản lý luồng; và về .NET dùng thread pool rộng rãi cho thao tác TPL, hoàn tất I/O bất đồng bộ, callback bộ hẹn giờ, chờ đã đăng ký, kết nối socket, và hơn thế.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Về vòng song song đôi khi chậm hơn vòng tuần tự nên luôn phải đo; về việc tránh ghi vào bộ nhớ dùng chung trong vòng song song, với overload nhận trạng thái cục bộ theo luồng được khuyến nghị; và về việc không bảo đảm mỗi lần lặp của For/ForEach thực sự chạy song song, nên mã chờ giữa các lần lặp có thể deadlock.  2 3 4

  4. Microsoft Learn, Managed threading best practices. Về định nghĩa điều kiện đua (ví dụ tăng bộ đếm tách thành đọc, cộng, ghi lại rồi bị ghi đè và mất) và deadlock; về việc dùng hủy hợp tác chứ không Thread.Abort; về việc không được dùng kiểu hay this làm đối tượng khóa, và từ .NET 9 / C# 13 trở đi nên dùng thực thể System.Threading.Lock chuyên dụng; về câu lock của C# bảo đảm Monitor.Exit trong khối finally; về phát hiện deadlock bằng timeout của Monitor.TryEnter; về lớp Interlocked nhanh hơn cho thay đổi trạng thái đơn giản; và về hướng dẫn thiết kế dữ liệu tĩnh mặc định nên an toàn luồng còn dữ liệu thực thể mặc định không nên an toàn luồng.  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. Về kênh là FIFO cho mô hình producer/consumer, tự quản đồng bộ bên trong; về việc tạo kênh có trần sức chứa bằng CreateBounded; về hành vi mặc định khi chạm trần là bên ghi chờ, với các FullMode khác như DropOldest cũng chọn được; và về sức ép ngược được áp khi ghi nhanh hơn đọc.  2 3

  6. Microsoft Learn, Thread-safe collections. Về các tập hợp dưới System.Collections.Concurrent đạt an toàn luồng bằng khóa hạt mịn hoặc cơ chế không-khóa; và về ConcurrentQueue cùng ConcurrentStack được triển khai không khóa, dùng thao tác Interlocked, nên chịu được thêm và xóa thường xuyên từ nhiều luồng.  2

  7. Microsoft Learn, Cancellation in Managed Threads. Về quy trình hủy hợp tác dùng CancellationTokenSource và CancellationToken; về hủy là hợp tác chứ không cưỡng bức, với bên nghe quyết cách dừng; về ba cách theo dõi: polling, đăng ký callback, và wait handle; về ThrowIfCancellationRequested ném OperationCanceledException, thứ Task coi là hủy hoàn tất; về ghép nhiều token bằng token liên kết; và về thư viện cần cung cấp phương thức công khai nhận CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. Về CancellationToken là cách đúng để dừng luồng; về Thread.Abort ném PlatformNotSupportedException trên .NET Core và .NET 5 trở đi, với cảnh báo không còn dùng (SYSLIB0006) lúc biên dịch từ .NET 5 trở đi; và về việc buộc kết thúc mã bên thứ ba không đáp hủy hợp tác đòi chạy nó trong tiến trình riêng rồi dừng bằng Process.Kill.  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. Về truy cập điều khiển WinForms không an toàn luồng, với thao tác từ nhiều luồng dẫn tới trạng thái không nhất quán, đua, deadlock và đóng băng; về mọi điều khiển cần được tạo và truy cập trên cùng một luồng, với Windows đòi một luồng UI chuyên dụng để giao thông điệp hệ thống; và về gọi an toàn từ luồng khác bằng Control.Invoke, Control.InvokeAsync từ .NET 9 trở đi, hoặc BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). Về thay đổi UI trong WPF bị giới hạn ở một luồng, với luồng nền đăng ký mục việc cho Dispatcher của luồng UI để nhờ; về Dispatcher.Invoke là đồng bộ còn InvokeAsync và BeginInvoke là bất đồng bộ; và về Dispatcher xử lý việc như hàng đợi có ưu tiên.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). Về Parallel.For / Parallel.ForEach cung cấp song song dữ liệu với cảm giác gần như viết vòng for; về việc không cần tạo luồng hay xếp hàng mục việc, và vòng cơ bản không cần khóa; và về TPL tách nguồn dữ liệu trên nhiều luồng rồi cân lại tải nếu lệch. 

  12. Microsoft Learn, BlockingCollection<T> Class. Về BlockingCollection là triển khai producer/consumer có chặn và trần sức chứa; về trần sức chứa ngăn producer đi quá xa so với consumer; và về việc nó không được thiết kế cho truy cập bất đồng bộ, với Channel<T> được khuyến nghị cho producer/consumer bất đồng bộ.  2

  13. Microsoft Learn, Overview of synchronization primitives. Về Monitor cung cấp loại trừ lẫn nhau qua đối tượng khóa và có ái lực luồng; về mã C# được kỳ vọng dùng câu lock chứ không Monitor trực tiếp; về ReaderWriterLockSlim làm ghi độc quyền trong lúc cho phép đọc đồng thời; và về SemaphoreSlim là semaphore nhẹ dùng trong một tiến trình, còn Semaphore có tên và dùng được cho đồng bộ xuyên tiến trình.  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Về câu lock của C# và kiểu Lock có ái lực luồng nên không dùng được qua một await (vì luồng thực thi phần tiếp có thể đổi trước và sau await); về dùng SemaphoreSlim số đếm 1, qua WaitAsyncRelease trong finally, cho loại trừ trong mã bất đồng bộ; và về Channel bounded là lựa chọn thay khi mục đích là giới hạn lưu lượng. 

Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.

Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.

Bài viết liên quan trực tiếp đến các dịch vụ sau.

Câu hỏi thường gặp

Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.

Vì sao nên tránh lock(this) hay lock(typeof(MyClass))?
Vì đối tượng bạn khóa lộ ra ngoài mã của bạn. this chính là thực thể của bạn, nên mã ngoài nào tham chiếu được thực thể đó đều khóa được cùng đối tượng, gây tranh chấp hoặc deadlock ngoài ý muốn. typeof(MyClass) còn nguy hiểm hơn: chỉ có một đối tượng Type trong mỗi application domain, nên bạn chia sẻ khóa với mã hoàn toàn không liên quan. Hãy dùng một đối tượng khóa riêng không bao giờ lộ ra ngoài. Từ .NET 9 / C# 13 trở đi, khuyến nghị là dùng một thực thể của kiểu chuyên dụng System.Threading.Lock làm đối tượng khóa.
Được tạo bao nhiêu luồng? Số luồng tối ưu là bao nhiêu?
«Đừng tự quyết số luồng» là câu trả lời hiện đại. Dùng Task và lớp Parallel, thread pool sẽ tự điều chỉnh độ song song theo số lõi CPU và tải hiện tại. Thiết kế gọi new Thread lặp đi lặp lại dễ dư hoặc thiếu trên máy khách có số lõi khác. Điều cần chú ý không phải con số mà là loại việc: tính toán làm đầy CPU không nhanh hơn khi song song hóa quá số lõi, còn việc chủ yếu chờ I/O thì không nên thêm luồng — cách đúng là I/O bất đồng bộ với async/await.
Thêm volatile có làm thứ gì đó an toàn luồng không?
Không. volatile bảo đảm thứ tự — truy cập trường đó không bị sắp lại với các thao tác bộ nhớ xung quanh (ngữ nghĩa acquire/release) — chứ không bảo đảm tính nguyên tử của thao tác ghép như «đọc, tính, ghi lại». Ví dụ, dù nhiều luồng ++ trên bộ đếm volatile int, các lần tăng vẫn bị mất. Dùng lớp Interlocked để tăng/giảm bộ đếm hoặc so sánh-và-đổi, và dùng lock khi cần bảo vệ vài biến cùng lúc. volatile gần như chỉ đáng xét trong trường hợp đơn giản như cờ dừng, nơi một luồng ghi và các luồng khác chỉ đọc — và ngay cả cờ đó, hiện nay chuẩn là diễn đạt bằng CancellationToken.
Làm sao phân biệt lỗi chỉ thỉnh thoảng xảy ra có phải do đa luồng?
Ba dấu hiệu đáng ngờ: «cùng thao tác lúc tái hiện lúc không», «không tái hiện khi gắn trình gỡ lỗi hoặc thêm ghi nhật ký», và «chỉ xảy ra khi tải nặng hoặc ngay sau khi khởi động». Bug phụ thuộc thời điểm đặc trưng bởi kết quả đổi mỗi lần chạy — đó đúng là định nghĩa điều kiện đua. Để khoanh vùng, trước hết liệt kê mọi dữ liệu biến đổi đang dùng chung, rồi lập bảng «khóa nào bảo vệ từng cái». Chỉ một truy cập không được bảo vệ cũng đã là nghi phạm. Khi treo, bắt ngăn xếp mọi luồng và kiểm tra chúng có tạo vòng chờ khóa của nhau không. Tạm dừng trong trình gỡ lỗi Visual Studio và xem Parallel Stacks, hoặc ở môi trường sản xuất, bắt dump rồi phân tích.

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog