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
· Go Komura · 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)vàlock(typeof(X))bị cấm. Từ .NET 9 trở đi, dùng kiểu chuyên dụngSystem.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.Channelshoặ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
CancellationTokenlà câu trả lời đúng duy nhất để dừng;Thread.Abortné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
sequenceDiagram
accTitle: Điều kiện đua trên bộ đếm dùng chung
accDescr: Đ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
participant A as Luồng A
participant M as Biến dùng chung count
participant B as Luồng B
Note over M: count = 10
A->>M: Đọc (10)
B->>M: Đọc (10)
A->>A: Cộng cục bộ (11)
B->>B: Cộng cục bộ (11)
A->>M: Ghi lại (11)
B->>M: Ghi lại (11)
Note over M: Đã cộng hai lần vậy mà count = 11<br/>phép cộng của Luồng A bị mất
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
flowchart LR
accTitle: Vòng chờ của deadlock
accDescr: 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
A["Luồng A<br/>đang giữ khóa 1"] -->|"đang chờ giải phóng khóa 2"| B["Luồng B<br/>đang giữ khóa 2"]
B -->|"đang chờ giải phóng khóa 1"| A
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.Forthự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.
flowchart TB
accTitle: Các nhánh trước khi dựng luồng
accDescr: 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ệ
S["Có việc muốn chạy đồng thời"] --> Q1{"Chủ thể của việc là?"}
Q1 -->|"Chờ I/O chiếm phần lớn<br/>tệp, mạng, DB"| ASYNC["I/O bất đồng bộ với async/await<br/>không tăng số luồng"]
Q1 -->|"Tính toán dùng CPU"| Q2{"Hình dạng công việc là?"}
Q2 -->|"Áp dụng cùng xử lý<br/>cho mọi phần tử của tập hợp"| PAR["Parallel.For / ForEach"]
Q2 -->|"Một khối việc nền<br/>độc lập"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Vòng thông điệp, yêu cầu STA, v.v.<br/>tính chất của chính luồng là yêu cầu"| TH["new Thread<br/>(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
flowchart TB
accTitle: Tổng hợp cục bộ theo luồng
accDescr: 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
SRC["Mảng dữ liệu (đối tượng xử lý)"] --> T1["Luồng 1<br/>xử lý phần của mình và<br/>chỉ cộng vào tổng phụ cục bộ"]
SRC --> T2["Luồng 2<br/>xử lý phần của mình và<br/>chỉ cộng vào tổng phụ cục bộ"]
SRC --> T3["Luồng 3<br/>xử lý phần của mình và<br/>chỉ cộng vào tổng phụ cục bộ"]
T1 --> M["Gộp: Interlocked.Add<br/>phản ánh vào tổng một lần mỗi luồng"]
T2 --> M
T3 --> M
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);
}
flowchart LR
accTitle: Cấu trúc producer/consumer quanh kênh
accDescr: 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
P1["Producer 1<br/>WriteAsync"] --> CH["kênh bounded (sức chứa 100)<br/>hàng đợi FIFO<br/>đồng bộ do kênh quản lý"]
P2["Producer 2<br/>WriteAsync"] --> CH
CH --> C1["Consumer 1<br/>ReadAllAsync"]
CH --> C2["Consumer 2<br/>ReadAllAsync"]
CH -.->|"nếu đầy thì bắt ghi chờ<br/>(sức ép ngược)"| P1
CH -.->|"nếu rỗng thì bắt đọc chờ"| C1
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 và _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».
flowchart TB
accTitle: Hình dạng hủy hợp tác
accDescr: 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
OWNER["Bên dừng"] -->|"Gọi Cancel() một lần"| CTS["CancellationTokenSource"]
CTS -->|"Giao Token"| W1["Xử lý worker 1"]
CTS -->|"Giao Token"| W2["Xử lý worker 2"]
CTS -->|"Giao Token"| W3["API thư viện<br/>hỗ trợ hủy"]
W1 -->|"Kiểm tra IsCancellationRequested<br/>dọn rồi tự kết thúc"| E1["Kết thúc bình thường"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= được coi là hủy hoàn tất"]
W3 -->|"Ngắt ngay cả khi đang chờ"| E3["Hủ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ư ManualResetEventSlim và SemaphoreSlim để 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».
flowchart LR
accTitle: Đổi cập nhật UI thành lời nhờ
accDescr: Đổ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
OS["Windows<br/>chuột, bàn phím, vẽ lại"] --> Q["Hàng đợi thông điệp<br/>của luồng UI"]
BG["Luồng nền<br/>(việc nặng, giao tiếp)"] -->|"Nhờ qua Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Luồng UI<br/>luồng duy nhất được chạm điều khiển"]
BG -.->|"Chạm điều khiển trực tiếp"| NG["Cấm<br/>nguyê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.
- 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)?
- Bạn sắp viết
new Thread(có diễn đạt được bằng Task,Parallel, hoặc thread pool không)? - 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?
- 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?
- 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?
- 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?
CancellationTokencó được đưa cho mọi thao tác chạy lâu, và bạn giải thích được đường dừng?- 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
- Thực hành tốt nhất về đa luồng: ấn bản C++ — loại bỏ sự cố bằng cấu trúc với RAII và jthread
- Thực hành tốt nhất về đa luồng: Bản C — Viết an toàn theo cách Win32 API
- Thực hành đa luồng tốt nhất: bản Java — quy ước thời đại virtual thread
- Bảng quyết định thực tiễn cho C# async/await - Task.Run và ConfigureAwait
- async của WPF/WinForms và luồng UI trên một trang
- Chiều sâu I/O của Windows (Phần 3) — I/O Completion Port (IOCP) và Thread Pool của .NET: Tầng hầm dưới async/await
- Nền tảng COM STA/MTA - Mô hình phân luồng và cách tránh treo
- Vì sao nên ưu tiên chờ sự kiện hơn Sleep(1) trên Windows
- Cạm bẫy bộ nhớ dùng chung và thực hành tốt nhất
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».
- Tư vấn kỹ thuật và đánh giá thiết kế
- Điều tra lỗi và nguyên nhân
- Phát triển ứng dụng Windows
- Liên hệ với chúng tôi
Liên kết tham khảo
-
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
-
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
-
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
-
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
thislàm đối tượng khóa, và từ .NET 9 / C# 13 trở đi nên dùng thực thểSystem.Threading.Lockchuyên dụng; về câulockcủa C# bảo đảmMonitor.Exittrong khốifinally; về phát hiện deadlock bằng timeout củaMonitor.TryEnter; về lớpInterlockednhanh 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 -
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
-
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
-
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
-
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
-
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
-
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
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Về
Parallel.For/Parallel.ForEachcung 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. ↩ -
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
-
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
lockchứ 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 -
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Về câu
lockcủa C# và kiểuLockcó ái lực luồng nên không dùng được qua mộtawait(vì luồng thực thi phần tiếp có thể đổi trước và sau await); về dùngSemaphoreSlimsố đếm 1, quaWaitAsyncvàReleasetrongfinally, cho loại trừ trong mã bất đồng bộ; và vềChannelbounded là lựa chọn thay khi mục đích là giới hạn lưu lượng. ↩
Bài viết liên quan
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.
Thực hành tốt nhất về đa luồng: Bản C — Viết an toàn theo cách Win32 API
Cách tiếp cận đã ổn định cho đa luồng trong C với Win32 là tạo luồng qua _beginthreadex, khóa SRW và biến điều kiện, hàm Interlocked, cùn...
Thực hành tốt nhất về đa luồng: ấn bản C++ — loại bỏ sự cố bằng cấu trúc với RAII và jthread
Trong C++, đa luồng là thế giới mà data race chính là hành vi không xác định. Bài viết lần theo bẫy hàm hủy std::thread, thiết kế cơ chế ...
Thực hành đa luồng tốt nhất: bản Java — quy ước thời đại virtual thread
Trong Java, thực hành đã ổn định của đa luồng là không tạo thread trực tiếp mà dựa trên ExecutorService và virtual thread. Bài viết nêu c...
Dùng WMI/CIM từ C# và PowerShell — hướng dẫn thực hành lấy thông tin phần cứng, giám sát tiến trình và truy vấn từ xa
Cách chuẩn để lấy số serial PC, giám sát dung lượng đĩa trống và phát hiện tiến trình khởi chạy là WMI/CIM. Bài viết trình bày cmdlet CIM...
Thức tỉnh giả — Vì sao biến điều kiện thức "mà không được thông báo" và cách chờ đúng trên Windows
Lần chờ của biến điều kiện có thể trở về ngay cả khi không có thông báo nào tới (thức tỉnh giả). Bài viết này giải thích, từ triển khai W...
Chủ đề liên quan
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.
Chủ đề kỹ thuật Windows
Cổng vào phát triển Windows, điều tra lỗi và khai thác tài sản hiện có.
Dịch vụ liên quan đến chủ đề này
Bài viết liên quan trực tiếp đến các dịch vụ sau.
Phát triển ứng dụng Windows
Ứng dụng nghiệp vụ, tích hợp thiết bị và công cụ liên lạc, từ yêu cầu đến phát triển.
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.