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
· Go Komura · Windows, Đa luồng, C, Win32 API, Ứng dụng nghiệp vụ, Điều tra lỗi, Thiết kế
“Tôi đang viết tiến trình thường trú điều khiển thiết bị bằng C.” “Chúng tôi đành thêm luồng vào một ứng dụng C hai mươi năm tuổi.” “Chúng tôi dừng luồng bằng TerminateThread, nhưng thỉnh thoảng cả tiến trình bị khóa.” — Đa luồng trong C là thế giới ngôn ngữ giúp bạn ít nhất. Không có ngoại lệ, không RAII, không template; tính đúng của đồng bộ hoàn toàn dựa vào API bạn chọn và mức kỷ luật khi gọi chúng.
Bài này là bản C của loạt đa luồng thực tiễn. Nhắm tới nhà phát triển viết C trên Win32 API, nó lấy các nguyên tắc thiết kế đa luồng — ngừng sản xuất luồng hàng loạt, giảm trạng thái biến đổi dùng chung, giữ kỷ luật khóa, và thiết kế cách dừng trước khi thiết kế cách bắt đầu — rồi ánh xạ chúng lên bộ công cụ Win32: cách tạo luồng (_beginthreadex), cách chọn đối tượng đồng bộ, cách thiết kế đường dừng không dùng TerminateThread, và ràng buộc của DllMain, được sắp xếp quanh nguồn gốc sơ cấp tính đến tháng 8 năm 2026. Viết để đọc độc lập. Cùng nguyên tắc, triển khai cho ngôn ngữ khác, cũng có trong bản .NET, bản C++, và bản Java.
1. Kết luận trước
- Tạo luồng bằng
_beginthreadex, không phảiCreateThread. Nếu luồng gọi CRT được tạo bằng CreateThread, CRT có thể kết thúc tiến trình khi bộ nhớ thấp.12 - Khóa mặc định trong một tiến trình là khóa SRW; chỉ dùng CRITICAL_SECTION khi cần lấy đệ quy. Dùng Mutex cho loại trừ trong tiến trình là “sai lầm phổ biến” luôn kéo theo chuyển sang kernel mode.3
- Cập nhật biến đơn bằng họ hàm Interlocked.
volatilekhông bảo đảm tính nguyên tử cũng không bảo đảm thứ tự. Hầu hết hàm Interlocked mang rào cản bộ nhớ đầy đủ.4 - Chờ bằng biến điều kiện (họ
SleepConditionVariableCS) hoặc sự kiện cộng hàm chờ. Vòng polling dựa trênSleeplãng phí cả CPU lẫn độ đáp ứng.5 - Đừng bao giờ dùng
TerminateThread. Đó là hàm nguy hiểm làm hỏng khóa, heap và trạng thái DLL, và là đích của cảnh báo phân tích mã C6258. Thiết kế dừng như shutdown hợp tác: sự kiện dừng cộngWaitForMultipleObjects.67 - Giao việc ngắn cho nhóm luồng Windows (
CreateThreadpoolWork) thay vì tự song song hóa bằng luồng của bạn. Đừng bao giờ kết thúc luồng nhóm bằngExitThread/TerminateThread.89 - Đừng tạo luồng, đồng bộ, hay chờ luồng kết thúc trong DllMain. Nó được gọi khi loader lock đang bị giữ, thành ổ deadlock.10
<threads.h>của C11 dùng được từ VS 2022 17.8 trở đi, nhưng<stdatomic.h>vẫn experimental. Với cơ sở mã chỉ-Windows, cách Win32 là lựa chọn thực tế.11
2. Vì sao đa luồng khó? — Race condition và deadlock
Đun sôi các vấn đề đa luồng đưa vào, bất kể ngôn ngữ, còn hai loại.
Race condition là lỗi mà kết quả đổi tùy thứ tự nhiều luồng tới một đoạn mã. Ví dụ cổ điển là bộ đếm dùng chung: biểu thức đơn count++ tan ở mức mã máy thành ba bước — đọc, cộng, ghi lại. Nếu hai luồng vào ba bước này cùng lúc, phép cộng của một luồng bị ghi đè và mất khi luồng kia ghi lại.4 Kết quả đổi từ lần chạy này sang lần kia, và kết quả nào bạn nhận thì không đoán được.
sequenceDiagram
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: count = 11 dù hai lần tăng<br/>Lần tăng của luồng A đã mất
Hình 1: Race condition cổ điển khi bộ đếm dùng chung mất một lần tăng. 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 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ữ, và không bên nào 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ỉ thế cũng đủ để cả hai dừng mãi.
flowchart LR
A["Luồng A<br/>đang giữ khóa 1"] -->|"đang chờ khóa 2 được nhả"| B["Luồng B<br/>đang giữ khóa 2"]
B -->|"đang chờ khóa 1 được nhả"| A
Hình 2: Chờ vòng của deadlock. Khoảnh khắc các mũi tên chờ thành vòng, mọi luồng trong vòng đó dừng mãi
Cả hai đều phụ thuộc thời điểm: tổ hợp thứ tự thực thi chỉ hiện 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 với số nhân và thời điểm khác. “Không tái hiện khi gắn debugger” xảy ra vì chính quan sát đổi thời điểm — hành vi điển hình của lỗi race. Vậy mọi nguyên tắc trong bài này chỉ một hướng: giảm chỗ cần đồng bộ, trước khi lo đồng bộ cho đúng.
2.1. Giả định riêng của C — Ngôn ngữ không bảo vệ bạn khỏi gì cả
Trong C, vì ngôn ngữ không có cơ chế ép các nguyên tắc này, chúng cần được viết rõ như kỷ luật.
Thứ nhất, dựng bảo đảm nhả vào cấu trúc. Không có thứ tương đương RAII của C++, việc nhả khóa và gọi CloseHandle trên handle phải được bảo vệ bằng mẫu goto cleanup gom mọi lối ra hàm qua một chỗ, hoặc bằng quy ước mã hóa cặp mỗi lần lấy với một lần nhả. Thêm return sớm rồi làm rò khóa là tai nạn cổ điển của C.
Thứ hai, xử lý data race giống C++. Đọc hoặc ghi đơn giản một biến 32-bit căn chỉnh đúng là nguyên tử trên Windows, nhưng không gì hơn — biến 64-bit trên Windows 32-bit, thao tác phức hợp, hay nhất quán giữa nhiều biến — được bảo đảm.12 Mã “tình cờ chạy” gãy ngay khi trình biên dịch hoặc mức tối ưu đổi.
Thứ ba, quyết định quyền sở hữu. Văn hóa ghi rõ, trong chú thích hàm, “luồng nào ghi bộ đệm này, và từ khi nào nó thuộc về ai” đền đáp trong đa luồng C không kém lựa chọn nguyên thủy đồng bộ.
3. Cách tạo luồng — _beginthreadex, và không gì khác
3.1. Vì sao CreateThread là lựa chọn sai
API gốc của Win32 là CreateThread, nhưng hướng dẫn chính thức là mọi luồng gọi hàm CRT (C runtime) phải được tạo bằng _beginthreadex. _beginthreadex khởi tạo dữ liệu nội bộ theo từng luồng mà CRT cần trước khi bắt đầu luồng. Nếu luồng tạo bằng CreateThread gọi hàm CRT, CRT có thể kết thúc tiến trình trong điều kiện bộ nhớ thấp.12 Vì printf, malloc và strtok đều là hàm CRT, quy tắc thực tế: “luồng viết bằng C luôn dùng _beginthreadex”.
Cũng tránh _beginthread (không có ex). Nó có bẫy: nếu luồng nó tạo kết thúc sớm, handle trả về có thể đã không hợp lệ — thậm chí trỏ tới luồng khác — trong khi _beginthreadex, handle của nó có thể đưa an toàn cho API đồng bộ, là lựa chọn an toàn hơn. Bên gọi đóng handle _beginthreadex trả về bằng CloseHandle.13
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... vòng chờ ở Mục 5 và 6 ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* xử lý thất bại */ }
/* ...sau yêu cầu dừng... */
WaitForSingleObject(hThread, INFINITE); /* join */
CloseHandle(hThread);
3.2. Việc ngắn đi vào nhóm luồng Windows
Nếu bạn muốn “ném rất nhiều việc nhỏ vào thứ gì đó” hoặc thấy mình “tạo rồi hủy luồng sống ngắn hết lần này đến lần khác”, dùng nhóm luồng Windows (API nhóm luồng có từ Vista trở đi) thay vì luồng của bạn. Tạo đối tượng work bằng CreateThreadpoolWork và gửi bằng SubmitThreadpoolWork, rồi các luồng worker của nhóm thực thi callback song song.8 Quản lý số luồng để OS lo, và chi phí tạo rồi hủy luồng biến mất. Đây là câu trả lời của C cho nguyên tắc “đừng tự tạo luồng”.
Kỷ luật dùng nhóm cũng được tài liệu chính thức ghi: đừng bao giờ kết thúc luồng nhóm bằng TerminateThread / ExitThread; khôi phục mọi trạng thái bạn đổi trong callback (TLS, độ ưu tiên luồng, v.v.) trước khi trả về; và giữ handle chờ sống đến khi nhóm dùng xong.9 Một lưu ý thực tế nữa: nhóm chỉ giới hạn số luồng worker — số callback chưa chạy được xếp hàng qua SubmitThreadpoolWork có thể chồng không giới hạn. Trong cấu hình chạy dài nơi việc gửi luôn vượt xử lý, đặt bộ hạn chế vào như semaphore, hoặc hàng đợi có biên, ở phía ứng dụng, để bên gửi chờ hoặc bị từ chối khi đầy (đây là back-pressure để quá tải không thành vấn đề bộ nhớ, cùng nguyên tắc với thiết kế hàng đợi ở Mục 5).
4. Giảm tối đa trạng thái biến đổi dùng chung — Phân vùng, chỉ-đọc, và bàn giao
Race chỉ xảy ra khi cả “nhiều luồng” và “dữ liệu biến đổi dùng chung” cùng có mặt. Trước khi chọn nguyên thủy đồng bộ (chương sau), nghĩ xem bạn có thể giảm chia sẻ ngay từ đầu không. Có ba họ kỹ thuật.
Phân vùng. Với tổng hợp song song, thay vì mỗi luồng ghi vào bộ đếm dùng chung, dựng tiểu kế trong biến cục bộ theo luồng (hoặc bộ đệm cấp phát theo luồng), rồi gộp một lần ở cuối bằng thứ như InterlockedAdd. Ghi vào trạng thái dùng chung giảm từ “mỗi vòng lặp” xuống “một lần mỗi luồng”, và cả chi phí đồng bộ lẫn cửa sổ tranh chấp co lại theo bậc độ lớn. Kỷ luật sở hữu ở Mục 2.1 — “bộ đệm này thuộc luồng nào” — trở thành bản vẽ cách bạn phân vùng.
Làm chỉ-đọc. Cấu hình và bảng dựng lúc khởi động rồi không sửa sau đó an toàn để đọc từ bất kỳ số luồng nào một khi khởi tạo xong. Hoặc xong mọi khởi tạo trước khi luồng nào bắt đầu, hoặc, nếu cần khởi tạo lười, dùng khởi tạo một lần của Win32 (InitOnceExecuteOnce), và làm ranh giới — “từ khi nào cái này thành chỉ-đọc” — rõ trong mã.3
Bàn giao. Thay vì cả hai phía chạm một biến dùng chung, dẫn dòng dữ liệu giữa các luồng qua hàng đợi producer/consumer. Trong C, triển khai đúng là mẫu biến điều kiện ở Mục 5 (bộ đệm vòng có biên cộng SleepConditionVariableCS), vốn là ví dụ chính thức đã làm; bộ đệm có giới hạn dung lượng cũng cho back-pressure tự nhiên — sản xuất chờ khi vượt tiêu thụ.5
5. Chọn đối tượng đồng bộ và kỷ luật khóa
Win32 có nhiều loại nguyên thủy đồng bộ, và chọn sai tốn cả hiệu năng lẫn tính đúng. Đây là hướng dẫn chính thức gói trong một hình.3
flowchart TB
S{"Cần đồng bộ<br/>xuyên tiến trình?"} -->|"Có"| Q2{"Dùng để làm gì?"}
Q2 -->|"Loại trừ lẫn nhau"| MTX["Mutex có tên"]
Q2 -->|"Giới hạn số truy cập đồng thời"| SEM["Semaphore có tên"]
Q2 -->|"Thông báo sự kiện"| EVT["Sự kiện có tên"]
S -->|"Không - trong tiến trình"| Q3{"Cần lấy đệ quy<br/>bởi cùng một luồng?"}
Q3 -->|"Có"| CS["CRITICAL_SECTION"]
Q3 -->|"Không"| Q4{"Mã C++ di động<br/>là ưu tiên?"}
Q4 -->|"Có"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"Không"| SRW["Khóa SRW - lựa chọn mặc định"]
Hình 3: Cách chọn nguyên thủy đồng bộ Win32. Nhánh đầu là “có xuyên tiến trình không” — điểm then chốt là đừng với lấy đối tượng kernel (Mutex) khi không
| Nguyên thủy | Phạm vi | Đặc điểm | Chỗ dùng |
|---|---|---|---|
| Khóa SRW | Trong tiến trình | Nhanh (thường ở lại hoàn toàn user mode), cỡ con trỏ, không đệ quy | Mặc định cho mã mới. AcquireSRWLockShared cũng cho phép đọc dùng chung |
| CRITICAL_SECTION | Trong tiến trình | Nhanh (quay, rồi rơi xuống chờ kernel), đệ quy | Khi cùng một luồng cần lấy đệ quy |
| Mutex | Trong tiến trình / xuyên tiến trình | Luôn là đối tượng kernel, nên chậm hơn | Loại trừ xuyên tiến trình (có tên), hoặc kết hợp với WaitForMultipleObjects |
| Semaphore | Trong tiến trình / xuyên tiến trình | Đối tượng kernel | Giới hạn truy cập đồng thời vào nhóm tài nguyên |
| Sự kiện | Trong tiến trình / xuyên tiến trình | Đối tượng kernel | Thông báo “có chuyện xảy ra” (không để bảo vệ dữ liệu) |
| Hàm Interlocked | Trong tiến trình (xuyên tiến trình nữa, qua bộ nhớ dùng chung) | Thao tác nguyên tử không khóa | Bộ đếm, cờ, đổi con trỏ4 |
Một chú thích cho bảng và sơ đồ. Đối tượng kernel như sự kiện, semaphore và mutex hoạt động hoàn toàn tốt cho đồng bộ trong tiến trình khi tạo không tên (sự kiện dừng ở Mục 6 đúng là sự kiện không tên). Đối tượng kernel không có nghĩa chỉ xuyên tiến trình. Ngược lại, “không tên” cũng không nghĩa chặt “giam trong một tiến trình” — nếu bạn để tiến trình con thừa kế handle, hoặc nhân bản nó vào tiến trình khác bằng DuplicateHandle, cùng một đối tượng kernel có thể dùng từ nhiều tiến trình dù không có tên. Cách nói chính xác là đặt tên là một cách tiêu biểu để các tiến trình mở lại cùng một đối tượng. Nhánh Hình 3 nắm điểm chọn lựa là “đừng chọn đối tượng kernel cho khóa trong tiến trình” — với thông báo trong tiến trình (sự kiện) hoặc giới hạn đồng thời (semaphore), đối tượng kernel không tên vẫn là câu trả lời đúng.
Họ Interlocked tương ứng lớp Interlocked ở bản .NET và std::atomic ở bản C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange thực hiện thao tác trên một biến không tách được, và vì hầu hết hàm này mang rào cản bộ nhớ đầy đủ, bạn cũng nhận bảo đảm thứ tự.4 “Ổn vì tôi đánh dấu volatile” là hiểu nhầm — volatile không bảo đảm tính nguyên tử cũng không bảo đảm thứ tự (xem FAQ). Còn một điều kiện tiên quyết nữa: căn chỉnh. Biến mà hàm Interlocked nhắm phải được căn trên biên tự nhiên (biên 4 byte cho giá trị 32-bit, biên 8 byte cho giá trị 64-bit); nếu không, hành vi không đoán được.12 Đừng bao giờ nhắm trường trong struct #pragma pack, hoặc trường trong bộ đệm ánh xạ thẳng lên định dạng dây, bằng hàm Interlocked. Giới hạn bộ đếm và cờ ở biến khai báo thông thường — những cái trình biên dịch căn giúp bạn. Còn một lưu ý riêng quanh đổi con trỏ bằng thứ như InterlockedExchangePointer: chỉ bản thân lần đổi là không tách được, và không ai bảo đảm tuổi thọ khối cũ sau khi đổi. Nếu bên đọc nạp con trỏ cũ đúng lúc bên ghi đổi nó rồi gọi free, đó là truy cập bộ nhớ đã giải phóng. Thiết kế cập nhật dữ liệu dùng chung bằng đổi con trỏ chỉ chạy khi đi cặp với giao thức thu hồi — khóa, đếm tham chiếu, hoặc tương tự (nếu phân vân, bảo vệ bằng khóa SRW là mặc định an toàn).
Để chờ thứ gì đó, có biến điều kiện. Tạo một cái bằng InitializeConditionVariable; bên tiêu thụ ngủ trên SleepConditionVariableCS (ghép với CRITICAL_SECTION), và bên sản xuất đánh thức bằng WakeConditionVariable — đúng hình dạng ví dụ hàng đợi producer/consumer chính thức trên bộ đệm có biên.5 Kỷ luật quan trọng: khi thức, luôn kiểm tra lại điều kiện (hàng đợi có khác rỗng không) trong khóa, và quay lại chờ nếu sai. Biến điều kiện có thể bị spurious wakeup mà không có thông báo nào, và đến lúc bạn thức thì bên tiêu thụ khác có thể đã lấy mục trước — nên “tôi bị đánh thức” không nhất thiết nghĩa là “điều kiện đúng”. Đây là công cụ của C để dựng cùng hình dạng kênh ở bản .NET Mục 4.3 và BlockingQueue ở bản C++ Mục 4. Khi ghép với khóa SRW, dùng SleepConditionVariableSRW.
5.1. Kỷ luật khóa — Ba nguyên tắc đứng vững bất kể nguyên thủy bạn chọn
Chọn đúng nguyên thủy thôi chưa đủ — không dùng có kỷ luật, bạn vẫn không ngăn được race.
- Quyết định, một-đối-một, khóa nào bảo vệ dữ liệu nào. Gán đúng một khóa (khóa SRW hoặc CRITICAL_SECTION) cho mỗi tập dữ liệu biến đổi bạn muốn bảo vệ, và lấy cùng khóa đó ở mọi chỗ chạm dữ liệu ấy. Trong C nói riêng, ghi rõ trong chú thích header — “struct này được
g_lockFoobảo vệ” — rất đền đáp. - Đừng làm gì chậm hoặc bên ngoài khi đang giữ khóa. Việc duy nhất ổn khi giữ khóa là đọc hoặc ghi dữ liệu nó bảo vệ. I/O tệp, gọi mạng, hoặc gọi callback khi vẫn giữ khóa vừa kéo dài thời gian giữ vừa rủi ro bên được gọi cố lấy khóa khác, tạo chờ vòng ở Hình 2.
- Cố định thứ tự lấy khi có nhiều khóa. Ở bất kỳ chỗ nào lấy hai khóa trở lên, đặt quy tắc mọi luồng lấy chúng cùng thứ tự (hệ cấp khóa). Tài liệu thực hành tốt nhất về DLL nêu rõ đảo thứ tự đó (lock order inversion) sinh deadlock khó gỡ, và bạn nên định hệ cấp rồi tuân thủ nhất quán.10
6. Thiết kế cách dừng — Đừng bao giờ TerminateThread
6.1. TerminateThread làm hỏng gì
TerminateThread xóa luồng đích mà không để nó thực thi bất kỳ mã user-mode nào. Hệ quả tài liệu chính thức liệt kê rất nặng. Nếu luồng đích đang giữ critical section, nó không bao giờ được nhả; nếu đang giữa thao tác heap, khóa heap vẫn bị giữ (và mọi luồng sau gọi malloc treo); và nếu đang thao tác trạng thái toàn cục của DLL, trạng thái đó bị để hỏng. Lập trường chính thức là đó là “hàm nguy hiểm chỉ nên dùng trong những trường hợp cực đoan nhất”, và phân tích mã gắn nó là cảnh báo C6258.67
Tìm thấy TerminateThread khi điều tra ứng dụng “thỉnh thoảng khóa cả tiến trình” đúng là cảnh tượng thường gặp trong thực tế. Nếu thấy, hãy coi đó là thứ cần sửa.
6.2. Mẫu đúng: sự kiện dừng cộng WaitForMultipleObjects
Mẫu đã ổn định cho shutdown hợp tác trong C là tạo một sự kiện dừng reset thủ công, và để mỗi luồng worker chờ “tín hiệu việc” và “tín hiệu dừng” cùng lúc. Chính tài liệu cảnh báo C6258 chỉ đúng mẫu này — tạo sự kiện, để mỗi luồng theo dõi bằng WaitForSingleObject, và để luồng tự kết thúc — như cách kết thúc đúng.7
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): reset thủ công */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): reset tự động.
Tự trở về non-signaled khi được nhận
(sự kiện reset thủ công sẽ để các lần chờ đi thẳng
sau khi được signal một lần, thành vòng bận
quay mãi trên hàng đợi rỗng) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* vd. handle không hợp lệ. Bỏ không xử lý thì quay hết tốc */
LogLastError(); /* Ghi GetLastError() rồi thoát */
break;
}
if (r == WAIT_OBJECT_0) /* Đã yêu cầu dừng */
break;
if (r == WAIT_OBJECT_0 + 1) { /* Có việc */
/* Cũng đưa sự kiện dừng vào ProcessNextItem: nếu nó chờ lâu
bên trong cho một mục, và không quan sát được dừng ở đó nữa,
shutdown bị một mục cầm làm con tin */
while (ProcessNextItem(hStopEvent)) { /* Xử lý một mục từ hàng; FALSE nếu rỗng */
/* Kiểm tra yêu cầu dừng cả lúc rút. Bỏ bước này thì bạn
không dừng được khi việc cứ chồng (stop starvation) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* Tự dọn lấy */
return 0; /* Tự kết thúc */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* Nếu yêu cầu dừng không tới, */
LogLastError(); /* đừng đi tiếp tới join không giới hạn */
return FALSE;
}
/* Mọi người giờ đã có yêu cầu dừng nâng lên cùng lúc */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* Chỉ đóng handle đã xác nhận join */
} else {
LogLastError(); /* WAIT_FAILED: vd. handle không hợp lệ */
ok = FALSE; /* Đừng báo "mọi người đã dừng" */
}
}
return ok; /* Nếu FALSE, đừng tiến tới nhả tài nguyên dùng chung */
}
Có lý do bên dừng join từng luồng một bằng WaitForSingleObject. WaitForMultipleObjects chỉ chờ tối đa MAXIMUM_WAIT_OBJECTS (64) handle một lúc; đưa mảng lớn hơn thì bản thân lần chờ thất bại với WAIT_FAILED, để bạn đóng handle trong khi tin đã chờ mọi người khi thực ra chẳng chờ ai. Nếu bạn chỉ cần chờ mọi người xong, vòng từng cái một không trần trên là lựa chọn an toàn.
flowchart TB
OWNER["Bên dừng"] -->|"SetEvent(hStopEvent)"| SE["Sự kiện dừng<br/>reset thủ công - mọi người thấy cùng lúc"]
SE --> W1["Worker 1 - chờ dừng và việc<br/>cùng lúc qua WaitForMultipleObjects"]
SE --> W2["Worker 2 - chờ dừng và việc<br/>cùng lúc qua WaitForMultipleObjects"]
W1 --> C1["Tự dọn rồi return"]
W2 --> C2["Tự dọn rồi return"]
C1 --> J["Bên dừng chờ handle luồng rồi join<br/>chỉ lúc này mới gọi là đã dừng"]
C2 --> J
Hình 4: Mẫu sự kiện dừng. Dùng sự kiện reset thủ công cho tín hiệu dừng nghĩa là một SetEvent đánh thức mọi worker đang chờ cùng lúc. Mỗi luồng tự quyết cách nó kết thúc, và dừng chỉ được coi là xong khi join đã xong
Có ba điểm then chốt. Làm sự kiện dừng reset thủ công (để một SetEvent hiện với mọi worker); đặt sự kiện dừng lên đầu mảng chờ (để nếu cả hai được signal cùng lúc, dừng được ưu tiên); và bên dừng luôn phải join handle luồng trước khi đóng chúng.
Hai lưu ý về phạm vi. Thứ nhất, mẫu “sự kiện cộng rút-hết” này dành cho cấu hình một worker. Dù gọi SetEvent bao nhiêu lần trên sự kiện reset tự động, nó chỉ diễn tả được “có một trạng thái signaled” (các tín hiệu liên tiếp gộp lại), nên với nhiều worker chỉ một thức dậy và kết cục xử lý cả đợt theo kiểu nối tiếp. Nếu nhiều worker chia một hàng, chuyển tín hiệu việc sang semaphore, tăng đếm bằng ReleaseSemaphore(hSem, 1, NULL) mỗi lần xếp một mục. Chờ semaphore thành công tiêu thụ một đếm, cho tương ứng đúng: đúng bằng số worker đang chờ thức dậy, từng cái một, bằng số mục đã xếp (cách dùng này nằm gọn trong phận sự semaphore, giống “giới hạn truy cập đồng thời vào nhóm tài nguyên” ở bảng Hình 3). Nhưng khi chuyển sang semaphore, cũng đổi phía tiêu thụ sao cho một lần chờ thành công bằng xử lý đúng một mục từ hàng. Để vòng rút-hết ở mẫu trên nguyên, một lần chờ — vốn chỉ tiêu thụ một phép — sẽ làm rỗng cả hàng, làm sổ sách lệch: worker khác thức dậy tới hàng rỗng trên các phép còn lại, và ReleaseSemaphore của bên sản xuất bắt đầu thất bại vì vượt đếm tối đa. Giữ tương ứng “một phép bằng một việc” là điều kiện tiên quyết của cách semaphore. Thứ hai, dẫn đường dừng xuyên qua xử lý một mục nữa. Nếu ProcessNextItem chờ chặn lâu bên trong, hoặc đưa sự kiện dừng vào đó nữa rồi chờ cả hai cùng, hoặc gắn timeout hữu hạn. Chỉ kiểm tra giữa các mục để lại lỗ “shutdown chờ mãi vì một mục không bao giờ xong”. Điều này nói đúng những gì StopAsync ở bản .NET và jthread cộng join ở bản C++ nói.
Luồng đang chờ I/O chặn (pipe, socket, cổng nối tiếp) không thể quay lại kiểm tra sự kiện, nên phía I/O cần thiết kế riêng — hoặc I/O OVERLAPPED kết hợp sự kiện bạn chờ cùng, hoặc đánh thức I/O bằng CancelIoEx (ví dụ giao tiếp nối tiếp cụ thể, xem “Serial Communication App Pitfalls - Through Reconnection and Log Design”).
7. DllMain và loader lock — bãi mìn cho người viết DLL
Thành phần dùng chung viết bằng C thường thành DLL, và DLL mang ràng buộc riêng: loader lock. Bộ nạp OS gọi DllMain khi đang giữ loader lock, nên làm bất kỳ việc nào sau đây bên trong trở thành nguồn deadlock hoặc crash.10
- Đồng bộ với luồng khác (lấy khóa, chờ luồng kết thúc)
- Gọi
LoadLibrary/FreeLibrary, trực tiếp hoặc gián tiếp - Tạo luồng (nguy hiểm nếu kéo theo đồng bộ), hoặc gọi
ExitThread
“Chờ trong DllMain cho luồng worker kết thúc khi DLL bị gỡ” trông rất hợp lý nhưng là deadlock cổ điển: luồng đang kết thúc cố lấy loader lock để giao DLL_THREAD_DETACH, và hai phía kết cục chờ nhau. DLL có luồng riêng nên lộ hàm khởi tạo và shutdown tường minh — kiểu MyLib_Init / MyLib_Shutdown — và làm khởi động cùng join luồng ở đó. DllMain lý tưởng gần một stub rỗng.10
8. Lựa chọn luồng C11 — Tình hình hiện tại
Nếu muốn viết C di động không phụ thuộc Win32, lựa chọn là <threads.h> của C11 (thrd_create / mtx_lock / cnd_wait) và <stdatomic.h>. Theo bảng tuân thủ chính thức, hỗ trợ của MSVC đứng như sau: <threads.h> được hỗ trợ từ Visual Studio 2022 17.8 (cần /std:c11 và Windows SDK tương ứng), trong khi <stdatomic.h> vẫn experimental, ở giai đoạn cần tùy chọn /experimental:c11atomics.11
Nếu chia sẻ mã với Linux là yêu cầu, luồng C11 (hoặc lớp bọc pthread) có giá trị thật, nhưng với cơ sở mã chỉ-Windows, cách Win32 bài này trình bày có lợi thế về lượng thông tin sẵn có, bề dày, và dễ gỡ lỗi. Dù chọn gì, các nguyên tắc thiết kế đã bàn — giảm chia sẻ, tương ứng giữa khóa và dữ liệu, và shutdown hợp tác — không đổi.
9. Kiểm chứng và gỡ lỗi — Chuẩn bị cho “không tái hiện”
Bạn không thể kỳ vọng kiểm thử bắt lỗi race. Kiểm thử thường đếm lần chạy mà race “vừa khéo không kích hoạt” là đậu. Nghĩ hàng phòng thủ theo ba lớp.
Tuyến đầu là thiết kế. Khi xem xét, xác nhận bằng bảng: dữ liệu biến đổi nào được chia, khóa nào bảo vệ từng phần (tương ứng Mục 5.1), thứ tự lấy khóa có rõ không, và sự kiện dừng có tới mọi worker không. Thiết kế không điền nổi bảng này chưa xong, dù hiện đang chạy.
Thứ hai, làm dị thường quan sát được. Thay vì chờ vô điều kiện bằng INFINITE, gắn timeout ở điểm then chốt và ghi timeout khi nó nổ, biến treo lẽ ra kéo dài mãi thành thất bại phát hiện được. Khi treo xảy ra ngoài hiện trường, bắt dump, kiểm tra ngăn xếp mọi luồng, và tìm vòng ai đang chờ khóa của ai. Kiểm bằng Application Verifier được khuyến nghị chính thức cho lỗi quanh DLL.10 Dựng dump và ghi nhật ký được bàn trong “Designing Windows Apps to Leave Logs and Dumps When They Crash”.
Thứ ba, lắc bằng tải. Kiểm thử căng — chạy với nhiều luồng hơn số nhân, xáo thứ tự xử lý, chèn trễ nhân tạo — là cách thực tế tăng khả năng bạn trúng “jackpot” race trên máy phát triển. Đừng quên cũng kiểm tái hiện trên bản phát hành đã tối ưu dưới tải nặng.
10. Tóm tắt — danh sách kiểm bản C
- Mọi luồng đều được tạo bằng
_beginthreadex(không lẫnCreateThread/_beginthread)? - Bạn join handle luồng (
WaitForSingleObject) trước khi gọiCloseHandle? - Bạn có đang sản xuất hàng loạt luồng riêng cho việc sống ngắn (có thể giao cho API nhóm luồng không)?
- Loại trừ trong tiến trình có dùng khóa SRW / CRITICAL_SECTION (chứ không lạm dụng Mutex)?
- Bộ đếm và cờ dùng chung được cập nhật bằng hàm Interlocked chứ không dựa vào
volatile? - Còn polling
Sleepnào không (đã thay bằng biến điều kiện hoặc chờ sự kiện chưa)? TerminateThread(giết cưỡng bức luồng khác) vắng mặt mọi nơi? Worker kết thúc bằngreturntừ hàm luồng chứ không gọiExitThread(để dọn CRT chạy đúng qua_endthreadex)?- Mọi worker có đường dừng qua sự kiện dừng cộng
WaitForMultipleObjects, và bạn cũng đánh thức được luồng đang chặn trên I/O? - Việc nhả khóa và handle được bảo đảm trên mọi đường trả về (kỷ luật
goto cleanup)? DllMaintránh tạo luồng, đồng bộ, hay chờ luồng kết thúc?
Đổi lại không có sự giúp đỡ từ ngôn ngữ, chất lượng đa luồng trong C đúng bằng lựa chọn API và kỷ luật của bạn. Lấy _beginthreadex, khóa SRW, hàm Interlocked và sự kiện dừng làm bộ bốn mặc định, thì ngay cả trong C bạn cũng thiết kế được cách tránh “thỉnh thoảng bị khóa”.
Bài viết liên quan
- Thực hành tốt nhất về đa luồng: Bản .NET — Những gì cần quyết định trước khi thêm luồng
- Thực hành tốt nhất về đa luồng: Bản C++ — Loại bỏ tai nạn 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 Java — Quy ước thời đại virtual thread
- Shared Memory Pitfalls and Practical Best Practices
- Why You Should Prefer Event Waits over Sleep(1) on Windows
- Serial Communication App Pitfalls - Through Reconnection and Log Design
- Designing Windows Apps to Leave Logs and Dumps When They Crash
Lĩnh vực tư vấn liên quan
KomuraSoft LLC đảm nhận đánh giá thiết kế đa luồng cho tiến trình thường trú, ứng dụng điều khiển thiết bị và DLL viết bằng C; điều tra nguyên nhân gốc (phân tích dump) của treo và crash do TerminateThread hoặc khóa bị rò; và tư vấn kỹ thuật về việc thêm luồng vào mã C cũ.
- 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, CreateThread function. Về luồng trong tệp thực thi gọi CRT cần được quản lý bằng _beginthreadex / _endthreadex chứ không phải CreateThread / ExitThread, và về CRT có thể kết thúc tiến trình trong điều kiện bộ nhớ thấp khi luồng tạo bằng CreateThread gọi CRT. ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. Về chương trình gọi thư viện CRT cần bắt đầu luồng bằng _beginthread / _beginthreadex chứ không phải CreateThread / ExitThread của Win32; về họ _beginthread khởi tạo biến theo luồng của CRT; và về SuspendThread có thể dừng luồng khi nó đang truy cập cấu trúc dữ liệu nội bộ CRT, dẫn tới deadlock. ↩ ↩2
-
Microsoft Learn, About Synchronization. Về hướng dẫn chọn nguyên thủy đồng bộ Win32: khóa SRW là mặc định cho mã mới, cỡ con trỏ và thường ở lại user mode; CRITICAL_SECTION cho trường hợp cần lấy đệ quy; Mutex luôn là đối tượng kernel, dùng cho đồng bộ xuyên tiến trình có tên và kết hợp WaitForMultipleObjects; dùng Mutex cho đồng bộ trong tiến trình là “sai lầm phổ biến” chậm hơn nhiều dưới thao tác thường xuyên; và semaphore dùng để giới hạn truy cập đồng thời vào nhóm tài nguyên, sự kiện cho thông báo. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. Về hàm Interlocked đồng bộ truy cập biến dùng chung giữa nhiều luồng và thực hiện thao tác không tách được; về InterlockedIncrement / Decrement gói đọc, cộng và ghi lại thành một thao tác nguyên tử, vì không đồng bộ thì tăng đồng thời từ hai luồng có thể mất một lần tăng; về họ InterlockedExchange / InterlockedCompareExchange; về dùng được giữa luồng ở tiến trình khác khi biến nằm trong bộ nhớ dùng chung; và về hầu hết hàm Interlocked cung cấp rào cản bộ nhớ đầy đủ, với biến thể Acquire / Release để chọn ngữ nghĩa thứ tự. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. Về ví dụ đã làm của hàng đợi producer/consumer triển khai trên bộ đệm vòng có biên được CRITICAL_SECTION bảo vệ; về cấu trúc InitializeConditionVariable tạo biến điều kiện, bên tiêu thụ chờ bằng SleepConditionVariableCS, và bên sản xuất đánh thức bằng WakeConditionVariable; và về biến điều kiện được hỗ trợ từ Windows Vista trở đi. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. Về TerminateThread kết thúc luồng đích mà không để nó thực thi mã user-mode; về critical section của đích không được nhả nếu nó đang giữ; về khóa heap không được nhả nếu luồng đang cấp phát bộ nhớ từ heap; về trạng thái kernel32 hoặc trạng thái toàn cục của DLL có thể bị hỏng; và về đó là “hàm nguy hiểm chỉ nên dùng trong những trường hợp cực đoan nhất”, không nên gọi trừ khi bạn biết và kiểm soát hoàn toàn mọi đường mã luồng đích có thể đang chạy. ↩ ↩2
-
Microsoft Learn, Warning C6258. Về cảnh báo phân tích mã C6258 phát hiện việc dùng TerminateThread; về TerminateThread không thể dọn luồng đúng cách; và về thủ tục kết thúc đúng được chỉ là tạo sự kiện bằng CreateEvent, để mỗi luồng theo dõi trạng thái sự kiện bằng WaitForSingleObject, và để luồng tự kết thúc thực thi một khi sự kiện thành signaled. ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. Về tạo đối tượng work bằng CreateThreadpoolWork và để luồng worker của nhóm thực thi callback mỗi lần gọi SubmitThreadpoolWork; về có thể chỉ định môi trường thực thi qua môi trường callback (TP_CALLBACK_ENVIRON); và về sẵn có từ Windows Vista trở đi. ↩ ↩2
-
Microsoft Learn, Thread Pools. Về nhóm luồng phù hợp ứng dụng thực thi số lớn việc bất đồng bộ ngắn, hoặc thường xuyên tạo luồng sống ngắn; về các thành phần API nhóm luồng mới được thiết kế lại ở Vista; về thực hành tốt nhất không bao giờ kết thúc luồng nhóm bằng TerminateThread hay gọi ExitThread từ trong callback, dọn mọi trạng thái tạo trong callback trước khi trả về, và giữ handle chờ sống đến khi nhóm dùng xong. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. Về DllMain được gọi khi loader lock đang bị giữ, đặt hạn chế nghiêm đối với API nào gọi an toàn; về đồng bộ với luồng khác trong DllMain dẫn tới deadlock; về gọi LoadLibrary nằm trong danh sách hành động cấm; về mẫu chờ luồng kết thúc trong DllMain lúc gỡ DLL deadlock với nỗ lực của chính luồng đó lấy loader lock để giao DLL_THREAD_DETACH; về DllMain lý tưởng gần stub rỗng, khởi tạo trì hoãn hết mức; và về định hệ cấp khóa với loader lock ở đỉnh. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Về bảng tuân thủ thư viện chuẩn C, cho thấy luồng C11 (threads.h) được hỗ trợ từ Visual Studio 2022 17.8; stdatomic.h bị coi là experimental (sau tùy chọn /experimental:c11atomics); và hỗ trợ trình biên dịch C11 / C17 cần Visual Studio 2019 16.8 trở lên cùng Windows SDK tương ứng. ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. Về đọc hoặc ghi đơn giản một biến 32-bit căn chỉnh đúng là nguyên tử, nhưng đồng bộ (thứ tự) của truy cập không được bảo đảm; về đọc hoặc ghi đơn giản một biến 64-bit là nguyên tử trên Windows 64-bit nhưng không được bảo đảm trên Windows 32-bit; và về biến kích thước khác không được bảo đảm nguyên tử trên nền tảng nào. ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. Về vì sao _beginthreadex an toàn hơn _beginthread: luồng tạo bằng _beginthread có thể để handle trả về không hợp lệ (hoặc trỏ tới luồng khác) nếu kết thúc sớm; handle từ _beginthreadex phải được bên gọi đóng bằng CloseHandle và tính hợp lệ được bảo đảm; _beginthreadex cho phép đưa handle cho API đồng bộ; hàm luồng trả mã thoát luồng theo quy ước gọi __stdcall; và cần liên kết CRT đa luồ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: ấ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...
API thread pool Win32 — Đồng thời mà không tạo luồng, với CreateThreadpoolWork
Bạn đang rải các lời gọi CreateThread khắp mã native? Bài viết này giải thích API thread pool Win32 được thiết kế lại từ Vista — bốn đối ...
Ứng dụng hỏng khi thức dậy từ ngủ — Sự kiện nguồn Windows và cách viết ứng dụng nghiệp vụ sống sót
Bạn mở laptop và kết nối của ứng dụng nghiệp vụ đã chết — nguyên nhân là thiết kế chưa tính đến ngủ. Bài viết này trình bày luồng thông b...
DllMain và Loader Lock — Lý do thật sự bạn bị bảo "đừng làm gì trong khởi tạo DLL"
Vì sao bạn không được gọi LoadLibrary hoặc đồng bộ với luồng khác từ DllMain. Dựa trên nguồn gốc, bài viết giải thích cách loader lock tu...
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.
- Tôi nên dùng CreateThread hay _beginthreadex?
- Dùng _beginthreadex cho mọi luồng gọi hàm trong thư viện runtime C (CRT). _beginthreadex khởi tạo dữ liệu nội bộ theo từng luồng mà CRT cần trước khi bắt đầu luồng. Tài liệu chính thức nói thẳng rằng nếu một luồng tạo bằng CreateThread gọi hàm CRT, CRT có thể kết thúc tiến trình khi bộ nhớ thấp. Trong thực tế, luồng trong ứng dụng C gần như luôn gọi một hàm CRT ở đâu đó (printf, malloc, strtok, v.v.), nên nhớ quy tắc "luôn _beginthreadex" chẳng hại gì. Cũng tránh _beginthread (không có ex) — nó có bẫy là handle trả về có thể trở nên không hợp lệ nếu luồng vừa tạo kết thúc sớm, nên _beginthreadex, handle của nó có thể đưa cho API đồng bộ, mới là lựa chọn.
- Tôi không được dừng luồng bằng TerminateThread sao?
- Không, bạn không nên. TerminateThread xóa luồng đích mà không để nó thực thi bất kỳ mã user-mode nào, nên nếu luồng đó đang giữ critical section thì không bao giờ được nhả, nếu đang cấp phát bộ nhớ từ heap thì khóa heap vẫn bị giữ, và nếu đang thao tác trạng thái toàn cục của DLL thì trạng thái đó bị hỏng. Tài liệu chính thức nêu rõ đó là "hàm nguy hiểm chỉ nên dùng trong những trường hợp cực đoan nhất", và phân tích mã cũng gắn nó là cảnh báo C6258. Cách dừng đúng là shutdown hợp tác: tạo sự kiện dừng, để mỗi luồng theo dõi sự kiện đó bằng WaitForSingleObject / WaitForMultipleObjects, và để mỗi luồng tự dọn rồi tự kết thúc.
- Tôi đang dùng Mutex cho loại trừ trong một tiến trình. Có gì sai?
- Nó chạy, nhưng bạn trả giá hiệu năng rất lớn. Mutex Win32 luôn là đối tượng kernel, nên mỗi lần lấy và nhả đều kích hoạt chuyển sang kernel mode. Với loại trừ trong một tiến trình, khóa SRW hoặc CRITICAL_SECTION — vốn ở lại user mode và chỉ rơi xuống chờ kernel khi có tranh chấp — nhanh hơn rõ, và tài liệu chính thức gọi thẳng việc dùng Mutex cho đồng bộ trong tiến trình là "sai lầm phổ biến". Mutex đáng dùng khi bạn cần loại trừ xuyên tiến trình như đối tượng có tên, hoặc khi muốn chờ nó cùng các đối tượng kernel khác bằng WaitForMultipleObjects.
- Tôi có thể dùng threads.h và stdatomic.h của C11 trên Windows không?
- Trong MSVC, luồng C11 (threads.h) được hỗ trợ từ Visual Studio 2022 17.8 (cần /std:c11 và Windows SDK tương ứng). stdatomic.h thì vẫn bị coi là experimental, cần tùy chọn /experimental:c11atomics (theo bảng tuân thủ chính thức tính đến tháng 8 năm 2026). Đó là lựa chọn khả thi nếu tính di động là ưu tiên hàng đầu, nhưng với cơ sở mã chỉ-Windows, viết theo Win32 API (_beginthreadex, khóa SRW, biến điều kiện, hàm Interlocked) là lựa chọn thực tế xét theo bề dày kinh nghiệm và lượng thông tin sẵn có.
- Thêm volatile có làm cờ dùng chung an toàn không?
- Không. volatile của C chỉ triệt tối ưu hóa trình biên dịch như cache giá trị trong thanh ghi — nó không bảo đảm tính nguyên tử của thao tác cũng không bảo đảm thứ tự bộ nhớ giữa các bộ xử lý. Đọc hoặc ghi đơn giản một biến 32-bit căn chỉnh đúng bản thân đã nguyên tử trên Windows, nhưng "đọc, cộng, rồi ghi lại" bị tách thành các bước riêng, và cũng không có bảo đảm về thứ tự của nó so với các thao tác bộ nhớ xung quanh. Dùng họ hàm Interlocked để cập nhật bộ đếm hoặc cờ dùng chung. Hầu hết hàm Interlocked mang rào cản bộ nhớ đầy đủ, nên bạn nhận bảo đảm thứ tự cùng lúc. Khi cần bảo vệ vài biến cùng nhau, dùng khóa SRW hoặc CRITICAL_SECTION.