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

· · 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ải CreateThread. 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. volatile khô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ên Sleep lã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ộng WaitForMultipleObjects.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ằng ExitThread / 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.

Luồng BBiến dùng chung countLuồng ALuồng BBiến dùng chung countLuồng Acount = 10count = 11 dù hai lần tăngLần tăng của luồng A đã 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: 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.

đang chờ khóa 2 được nhảđang chờ khóa 1 được nhảLuồng Ađang giữ khóa 1Luồng Bđang giữ khóa 2

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.12printf, mallocstrtok đề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

Loại trừ lẫn nhauGiới hạn số truy cập đồng thờiThông báo sự kiệnKhông - trong tiến trìnhKhôngKhôngCần đồng bộxuyên tiến trình?Dùng để làm gì?Mutex có tênSemaphore có tênSự kiện có tênCần lấy đệ quybởi cùng một luồng?CRITICAL_SECTIONMã C++ di độnglà ưu tiên?std::mutex /std::shared_mutexKhó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_lockFoo bả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.

SetEvent(hStopEvent)Bên dừngSự kiện dừngreset thủ công - mọi người thấy cùng lúcWorker 1 - chờ dừng và việccùng lúc qua WaitForMultipleObjectsWorker 2 - chờ dừng và việccùng lúc qua WaitForMultipleObjectsTự dọn rồi returnTự dọn rồi returnBên dừng chờ handle luồng rồi joinchỉ lúc này mới gọi là đã dừng

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

  1. Mọi luồng đều được tạo bằng _beginthreadex (không lẫn CreateThread / _beginthread)?
  2. Bạn join handle luồng (WaitForSingleObject) trước khi gọi CloseHandle?
  3. 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)?
  4. Loại trừ trong tiến trình có dùng khóa SRW / CRITICAL_SECTION (chứ không lạm dụng Mutex)?
  5. 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?
  6. Còn polling Sleep nào không (đã thay bằng biến điều kiện hoặc chờ sự kiện chưa)?
  7. 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ằng return từ hàm luồng chứ không gọi ExitThread (để dọn CRT chạy đúng qua _endthreadex)?
  8. 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?
  9. Việc nhả khóa và handle được bảo đảm trên mọi đường trả về (kỷ luật goto cleanup)?
  10. DllMain trá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

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

Liên kết tham khảo

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

  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

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

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

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

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

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

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

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

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

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

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

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

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.

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.

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