“Chúng tôi đưa dữ liệu vào hàng đợi và thức luồng worker đang chờ. Nó đã chạy sáu tháng, rồi một ngày cố đọc hàng đợi rỗng và sự cố.” “Chúng tôi đang gửi thông báo, nhưng thỉnh thoảng một luồng không bao giờ thức.” — Điểm hẹn đa luồng trông như đang hoạt động, và là ổ nuôi lỗi chỉ xuất hiện hiếm. Điều tra loại này thường đáp xuống mã bọc wait của biến điều kiện trong if. Và phía sau đó ngồi thức tỉnh giả — hiện tượng trở về từ wait mà không nhận được thông báo.
“Nó thức dù không ai thông báo” nghe như khiếm khuyết của triển khai, nhưng đó là hành vi mà Win32, C++ và POSIX đều nêu rõ trong tài liệu hoặc chuẩn, và Monitor của .NET được thiết kế trên giả định rằng “một khi đã thức, bạn kiểm tra lại điều kiện”. Vì sao hành vi đó được phép? Nó xảy ra ở tầng nào trên Windows? Và bạn viết lần chờ thế nào để không bao giờ gặp phải? Hướng tới các nhà phát triển viết ứng dụng nghiệp vụ và phần mềm điều khiển thiết bị trên Windows, bài viết này tháo ra thức tỉnh giả thực sự là gì từ nguồn gốc, và cô đọng lần chờ đúng thành Win32 (C), C++ và C#.
1. Kết luận trước
waitcủ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. Tài liệu chính thức của Win32 nêu rằng biến điều kiện chịu thức tỉnh giả (lần thức không gắn với lần thức tường minh) và thức tỉnh bị cướp (một luồng khác tiêu thụ điều kiện trước luồng đã thức).1- Vì vậy bạn phải luôn viết lần chờ như “vòng while cộng kiểm tra lại điều kiện”. Mã kiểm tra một lần bằng
ifrồiwaittrông như đang hoạt động, và chứa lỗi chỉ tái hiện hiếm.12 - Đây không phải kỳ lạ riêng Windows; POSIX và chuẩn C++ nói cùng điều. Một triển khai “tuyệt đối không bao giờ thức giả” sẽ làm chậm mọi thao tác biến điều kiện, nên lần thức được phép trên giả định bên chờ sẽ kiểm tra lại.34
- Trong C++, dạng vị từ
wait(lock, pred)để thư viện thực hiện vòng cho bạn. Dạng đó thực chất chạywhile (!pred()) wait(lock);. Đó là mặc định cho mã mới.5 Monitor.Waitcủa C# cần cùng kỷ luật. Điều kiện có thể bị tiêu thụ trong khoảng giữa lúc được thức và lấy lại khóa, nên bạn kiểm tra lại điều kiện trongwhilerồi trở lạiWait.6- Cập nhật và kiểm tra điều kiện dưới cùng khóa. Nếu bạn nhìn điều kiện ngoài khóa rồi vào
wait, một thông báo có thể lọt qua khe — thức tỉnh bị mất.1 - Đừng tái tạo thông báo thoáng “thức ai đang chờ ngay lúc này” của biến điều kiện bằng xung trên sự kiện.
PulseEventnói riêng có thể bỏ lỡ thông báo đúng khoảnh khắc APC chế độ kernel tạm nhấc lần chờ, và chính Microsoft nói, bằng đúng lời, “nó không đáng tin, đừng dùng, dùng biến điều kiện thay”.7
Phần tiếp lần theo các cơ chế chống đỡ kết luận này, theo thứ tự.
2. Thức tỉnh giả là gì — Thức dậy không nghĩa là điều kiện đứng vững
Biến điều kiện là nguyên thủy đồng bộ hóa cho “đưa một luồng ngủ cho tới khi điều kiện nào đó đứng vững, và để nó được thức khi điều đó xảy ra”. Trên Win32 đó là cấu trúc CONDITION_VARIABLE cùng SleepConditionVariableCS / SleepConditionVariableSRW (chờ) và WakeConditionVariable / WakeAllConditionVariable (thông báo). API chờ nguyên tử nhả khóa bạn đang giữ (critical section hoặc khóa SRW) rồi đi ngủ, và khi thức nó lấy lại khóa trước khi trở về.1
Câu hỏi là sự thật “đã trở về từ wait” thực sự nghĩa là gì. Ngây thơ bạn muốn nghĩ “thông báo tới = điều kiện đứng vững”, nhưng trên thực tế có ba trường hợp wait trở về.
| Trường hợp | Thông báo | Điều kiện lúc trở về |
|---|---|---|
| Thức tỉnh thật | Có | Thường thỏa, nhưng không được bảo đảm |
| Thức tỉnh giả | Không cái nào gửi cho bạn | Vẫn chưa thỏa |
| Thức tỉnh bị cướp | Có | Một luồng khác đã tiêu thụ trước; chưa thỏa |
flowchart TB
accTitle: Ba trường hợp wait trở về
accDescr: Lần chờ biến điều kiện có thể trở về không chỉ từ thông báo thật mà còn từ thức tỉnh giả không có thông báo và từ thức tỉnh bị cướp nơi thông báo tới nhưng điều kiện đã bị tiêu thụ trước, nên mọi trường hợp cần kiểm tra lại điều kiện
w["Trở về từ wait"] --> a["Thông báo thật"]
w --> b["Thức tỉnh giả(không có thông báo)"]
w --> c["Thức tỉnh bị cướp(điều kiện đã bị tiêu thụ)"]
a --> r["Kiểm tra lại điều kiện, rồi tiếp"]
b --> r
c --> r
Hình 1: Có ba đường trở về từ wait, và phía gọi không thể biết mình đã đi đường nào, nên bạn phải luôn kiểm tra lại điều kiện.
Thức tỉnh giả là trường hợp thứ hai này — hiện tượng API chờ trở về mà không gắn với thông báo tường minh được định thức bạn. Nó không giới hạn ở tình huống WakeConditionVariable chưa bao giờ được gọi ở đâu trong hệ thống. Ví dụ, dưới tải cao nơi thông báo tới thành cụm ngắn, triển khai có thể thức thêm các luồng đang chờ theo lô, và từ phía không có thông báo tương ứng thì điều đó cũng là thức tỉnh giả. Trang biến điều kiện của Microsoft Learn nói thẳng điều này: “Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.”1
Điểm quan trọng là phía gọi không thể biết mình trở về qua trường hợp nào trong ba. Nếu bạn không thể biết, chỉ còn một chiến lược: mỗi lần trở về, kiểm tra bản thân điều kiện bạn đang chờ, và đi ngủ lại nếu nó không đứng vững. Đó là nội dung thật của quy tắc sắt “bọc wait trong while”. Nói ngược lại, miễn là bạn giữ quy tắc đó, mã đúng dù trường hợp nào trong ba đã thức bạn.
3. Vì sao đặc tả cho phép — Thông báo chính xác thì đắt
“Thức mà không được thông báo chỉ là triển khai cẩu thả, phải không?” là câu hỏi hợp lý. Thực ra về lý thuyết có thể xây triển khai không bao giờ thức giả. Dẫu vậy, POSIX, Windows và chuẩn C++ đều đứng về phía “nó có thể xảy ra”. Lý do được nêu thẳng trong Rationale của pthread_cond_wait trong POSIX (The Open Group Base Specifications).3
Lý do thứ nhất là hiệu năng. Cố triển khai thông báo “đáng tin thức đúng một luồng” một cách nghiêm, đặc biệt trên đa bộ xử lý, thêm chi phí đồng bộ hóa vào mọi thao tác biến điều kiện. Một bộ lập lịch ngồi giữa thông báo và thức, và tùy thời điểm ngắt và chiếm quyền bạn không tránh được “một luồng khác chạy trước cái bạn định thức”. Bắt mọi người trả chi phí bịt kín điều đó hoàn toàn thì tệ hơn, để giữ biến điều kiện nhanh, so với chấp nhận rằng “bạn có thể thỉnh thoảng thức thêm”.
Lý do thứ hai là quan sát rằng sự đánh đổi này không làm hỏng ứng dụng — nó thực ra làm chúng vững hơn. Vì thức tỉnh giả được phép, mã đúng luôn viết vòng kiểm tra vị từ (điều kiện đang được chờ). Rationale của POSIX nói rằng buộc vòng này làm mã tự tài liệu và vững hơn.3 Một khi vòng đã đó, ý nghĩa của thông báo bị hạ từ “bảo đảm rằng điều kiện đứng vững” xuống “gợi ý rằng điều kiện có thể đã đổi”, và phía chờ trở nên khoan dung với thay đổi thiết kế khiêm tốn phía thông báo (thức quá nhiều, thức theo lô, và tương tự).
Thức tỉnh bị cướp còn là việc cấu trúc hơn. Luôn có khe thời gian giữa lúc bên thông báo gọi WakeConditionVariable và lúc luồng đã thức lấy lại khóa rồi trở về từ wait. Nếu một luồng thứ ba có thể lấy khóa trong khoảng đó, nó có thể tiêu thụ điều kiện (nội dung hàng đợi, và tương tự) trước. Đó là khe mà dù đánh bóng triển khai đến đâu cũng không xóa được, vì nó đến từ hình dạng của chính công cụ biến điều kiện.
sequenceDiagram
accTitle: Dòng thời gian của thức tỉnh bị cướp
accDescr: Producer đặt một mục vào hàng đợi và thức consumer A đang chờ, nhưng trước khi A lấy lại khóa consumer B lấy khóa và lấy mục đó, nên hàng đợi rỗng tới lúc A thức
participant A as Consumer A(đang chờ)
participant P as Producer
participant B as Consumer B
P->>P: Thêm một mục vào hàng đợi
P->>A: WakeConditionVariable
Note over A: Đã thức, đang chờ lấy lại khóa
B->>B: Lấy khóa và lấy một mục
A->>A: Lấy lại khóa rồi trở về từ wait
Note over A: Hàng đợi rỗng(bị cướp)
A->>A: Kiểm tra lại trong vòng while rồi chờ lại
Hình 2: “Thức tỉnh bị cướp”, trong đó luồng thứ ba tiêu thụ điều kiện trong khe thời gian giữa thông báo và thức, có thể xảy ra dưới mọi triển khai.
Nói cách khác, ngay cả nếu hệ điều hành triệt tiêu hoàn toàn thức tỉnh giả, miễn là thức tỉnh bị cướp còn tồn tại bạn vẫn không thể viết “tôi thức = điều kiện đứng vững”. Vòng kiểm tra lại của bên chờ vẫn bắt buộc, và đã vậy thì rẻ hơn khi cho phép thức tỉnh giả và giữ triển khai nhanh — đó là phán đoán thiết kế biến điều kiện đã mang hàng thập kỷ.
4. Nó hiện mặt ở những tầng nào trên Windows
Tính chất này hiện mặt dù bạn dùng tầng nguyên thủy đồng bộ hóa Windows nào. Để cảm nhận sự thật rằng bạn không thoát được dù viết chống API tầng nào, chúng ta sẽ nhìn các tầng đại diện.
Biến điều kiện Win32 (CONDITION_VARIABLE) như đã nêu, được ghi trên SleepConditionVariableCS / SleepConditionVariableSRW là chịu cả thức tỉnh giả lẫn thức tỉnh bị cướp, và bạn được yêu cầu kiểm tra lại vị từ trong vòng while.2 Mẫu dùng chính thức (hàng đợi producer–consumer) cũng viết lần chờ bên trong vòng while.8
WaitOnAddress còn thấp hơn là API chờ nguyên thủy hơn biến điều kiện: “chờ cho tới khi giá trị tại địa chỉ cho trước đổi” (Windows 8 trở đi). Ngay cả API gần đáy này cũng có tài liệu nêu “nó được bảo đảm trở về khi địa chỉ được tín hiệu, nhưng cũng được phép trở về vì lý do khác”, và liệt kê làm ví dụ thức sớm một điều kiện bộ nhớ thấp, bỏ lần thức trước cho cùng địa chỉ, và chạy bản checked. Đó là vì sao mẫu dùng của chính tài liệu có dạng “vòng while so sánh lại giá trị”.9
std::condition_variable của C++ cũng vậy. Tài liệu của MSVC nói về wait không vị từ rằng nó “chặn cho tới khi được tín hiệu bởi lời gọi notify_one / notify_all. Nó cũng có thể thức giả”, và giải thích rằng dạng vị từ wait(lock, pred) thực chất chạy mã sau.5
while (!Pred())
wait(Lck);
Nói cách khác, wait dạng vị từ được khuyến nghị trong C++ không gì khác hơn thư viện nhận “bọc nó trong while”, như bài này mô tả, khỏi tay bạn. cppreference cũng nêu rằng wait không vị từ có thể được bỏ chặn giả.4
Monitor.Wait / Pulse của .NET có cấu trúc hàng đợi riêng gồm hàng đợi chờ và hàng đợi sẵn, nhưng kỷ luật không đổi. Một luồng được thức bởi Pulse / PulseAll chuyển sang hàng đợi sẵn và trở về từ Wait theo thứ tự nó có thể lấy lại khóa. Một luồng khác có thể tiêu thụ điều kiện trong khoảng trước khi khóa được lấy lại giống như trên Win32, và tài liệu cũng được viết trên giả định rằng “luồng đã thức đánh giá lại điều kiện đã khiến nó vào lần chờ, và gọi Wait lại nếu cần”.610
flowchart TB
accTitle: Mọi tầng đều đòi vị từ được kiểm tra lại
accDescr: Tài liệu chính thức đòi điều kiện được kiểm tra lại sau khi thức ở mọi tầng — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE, và WaitOnAddress tầng thấp
cpp["C++ std::condition_variable"] --> rule["Khi thức, kiểm tra lại điều kiện(while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
Hình 3: Đổi ngôn ngữ hoặc khung và yêu cầu chính thức vẫn giống nhau ở mọi tầng nguyên thủy chờ: kiểm tra lại sau khi bạn thức.
5. Cách chờ đúng — Viết bằng while và vị từ
Từ đây trở đi, triển khai. Chỉ có ba nguyên tắc.
- Giữ thứ bạn chờ như trạng thái (một vị từ), không như “thông báo”. Điều kiện là trạng thái dùng chung được khóa bảo vệ — “hàng đợi có khác rỗng không?”, “cờ đã được đặt chưa?” — không phải “tôi đã được thức chưa?”.
- Luôn đặt
waitbên trong vòng while trên điều kiện. Mỗi lần bạn thức, kiểm tra điều kiện, và đi ngủ lại nếu nó không đứng vững. - Cập nhật và kiểm tra điều kiện dưới cùng khóa. Bên thông báo cập nhật trạng thái rồi mới thông báo.
flowchart TB
accTitle: Luồng của vòng chờ đúng
accDescr: Lấy khóa và kiểm tra điều kiện; nếu chưa thỏa, nhả khóa và ngủ; khi thức lấy lại khóa và trở về kiểm tra điều kiện. Tiếp với khóa vẫn được giữ chỉ khi điều kiện thỏa
l["Lấy khóa"] --> c{"Điều kiện đã thỏa?"}
c -->|"Chưa"| s["wait(nhả khóa và ngủ)"]
s --> wk["Thức(lấy lại khóa)"]
wk --> c
c -->|"Rồi"| go["Tiếp trong khi vẫn giữ khóa"]
Hình 4: Lần chờ đúng là một vòng, và không có khe giữa kiểm tra điều kiện và xử lý (cả hai xảy ra trong khi khóa được giữ).
Hình dạng này có một lợi ích dễ bỏ lỡ. Khoảnh khắc bạn rời vòng while, đã được xác lập, trong khi bạn vẫn giữ khóa, rằng “điều kiện đứng vững”. Vòng chống thức tỉnh giả, nguyên như vậy, là bảo đảm không có khe điều kiện đua giữa kiểm tra điều kiện và xử lý nó.
Hình dạng cơ bản trong Win32 (C)
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // shared state protected by cs
// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) { // always while, never if
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);
// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount; // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // notifying after releasing the lock is fine
Bạn có thể gọi thông báo (WakeConditionVariable) từ bên trong khóa hoặc từ ngoài, nhưng tài liệu nói rằng thức sau khi nhả khóa thường tốt hơn, để giảm chuyển ngữ cảnh.1 Mặt khác, bản thân cập nhật trạng thái (++queueCount) phải luôn xảy ra dưới khóa. Đừng lẫn hai việc.
Hình dạng cơ bản trong C++ — Làm wait vị từ thành mặc định
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// Notifier
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
Vì wait dạng vị từ thực hiện vòng cho bạn, vòng while viết tay không cần. Khi bạn sửa mã hiện có vẫn còn vòng viết tay, while (q.empty()) cv.wait(lk); là hình dạng đúng, nên không cần vội viết lại. Dạng duy nhất sai là if (q.empty()) cv.wait(lk);.
Hình dạng cơ bản trong C#
private readonly object _gate = new();
private readonly Queue<Item> _queue = new();
// Waiter
lock (_gate)
{
while (_queue.Count == 0) // always while, never if
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// Notifier
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor.Pulse can only be called inside the lock
}
Monitor.Wait / Pulse / PulseAll chỉ có thể được gọi từ bên trong khóa (khối lock), khác với Win32. Gọi chúng ngoài khóa ném SynchronizationLockException.10
Chờ với thời gian chờ — Tính thời gian còn lại từ hạn chót
Khi bạn chờ với thời gian chờ, truyền “cùng giá trị thời gian chờ” ở mỗi lần lặp vòng kéo dài lần chờ mỗi khi thức tỉnh giả xảy ra. Hình dạng đúng là chốt hạn chót trước rồi tính lại thời gian còn lại.
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // timeout (condition still unsatisfied)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // on failure other than timeout, stop waiting and leave
}
// Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: Luồng đúng của lần chờ có thời gian chờ
accDescr: Chốt hạn chót trước, và mỗi lần thức kiểm tra điều kiện và hạn chót; nếu vẫn còn thời gian, tính lại thời gian còn lại rồi trở về lần chờ
d["Chốt hạn chót"] --> c{"Điều kiện đã thỏa?"}
c -->|"Rồi"| go["Tiếp tới xử lý"]
c -->|"Chưa"| t{"Hạn chót đã qua?"}
t -->|"Rồi"| to["Xử lý hết thời gian chờ"]
t -->|"Chưa"| w["Tính thời gian còn lại rồi chờ"]
w --> c
Hình 5: Lần chờ có thời gian chờ không truyền lại “cùng thời lượng chờ”; nó tính lại thời gian còn lại từ hạn chót.
Trong C++, bạn có thể để phép tính này, cả hạn chót, cho wait_until (thời gian tuyệt đối) cộng overload vị từ. Ngay cả khi nó trở về vì hết thời gian chờ nó cũng cho bạn giá trị cuối của vị từ, nên bạn cũng có thể quyết “chúng ta hết thời gian chờ, hay chúng ta kịp?” trên vị từ.5
6. Danh mục các mẫu cần tránh
Chỉ kiểm tra một lần bằng if. Đây là ngôi sao của bài. Khoảnh khắc thức tỉnh giả hoặc thức tỉnh bị cướp xảy ra, xử lý tiếp tục với điều kiện chưa thỏa. Lấy từ hàng đợi rỗng, chạm dữ liệu chưa khởi tạo, giải phóng kép — triệu chứng trở thành “sự cố hoặc hỏng dữ liệu chỉ xuất hiện thỉnh thoảng”.
Kiểm tra hoặc cập nhật điều kiện ngoài khóa. Nếu bên chờ nhìn điều kiện ngoài khóa, quyết “chưa”, và trong khe trước khi vào wait bên thông báo cập nhật trạng thái và gửi thông báo, thông báo được bắn vào biến điều kiện không có người chờ rồi biến mất. Bên chờ rồi vào wait và cứ chờ thông báo sẽ không bao giờ tới lại. Đó là thức tỉnh bị mất, ảnh gương của thức tỉnh giả. Lý do API chờ của biến điều kiện được thiết kế để “nguyên tử nhả khóa rồi đi ngủ” chính là để đóng khe này.1 Nó không xảy ra miễn là bạn giữ kỷ luật khóa.
sequenceDiagram
accTitle: Dòng thời gian của thức tỉnh bị mất
accDescr: Nếu bên chờ kiểm tra điều kiện ngoài khóa và bên thông báo cập nhật trạng thái rồi thông báo trong khe trước khi wait được vào, thông báo được gửi tới biến điều kiện không có người chờ rồi biến mất, và bên chờ cứ chờ thông báo sẽ không bao giờ tới
participant W as Bên chờ
participant N as Bên thông báo
W->>W: Kiểm tra điều kiện ngoài khóa(chưa thỏa)
N->>N: Cập nhật trạng thái rồi thông báo
Note over N: Không có người chờ lúc này
W->>W: Vào wait
Note over W: Thông báo đã mất và nó không bao giờ thức
Hình 6: Nếu bạn kiểm tra điều kiện ngoài khóa, thông báo lọt qua khe giữa lần kiểm và wait — một “thức tỉnh bị mất”.
Tái tạo “thông báo thoáng” của biến điều kiện bằng xung trên sự kiện. Bản thân sự kiện (CreateEvent + SetEvent) không phải mẫu chống. Tín hiệu thức trong cấu hình một consumer đơn xử lý hàng đợi cho tới khi rỗng, hoặc chỉ thị dừng một khi đã nâng thì không bao giờ hạ (sự kiện reset thủ công), là cách dùng đúng sự kiện; và khi bạn muốn ghép nó với mục tiêu chờ khác qua WaitForMultipleObjects, hoặc vượt ranh giới tiến trình, biến điều kiện — đối tượng chế độ user không thể chia sẻ xuyên tiến trình — mới là cái không dùng được.1 Thứ nguy hiểm là cố tái tạo, bằng thao tác sự kiện, thông báo thoáng của biến điều kiện “chỉ thức các luồng đang chờ đúng lúc đó và không để lại trạng thái”. Ý tưởng đó gần như luôn dẫn tới mục tiếp, PulseEvent.
Dùng PulseEvent. Đó là API, trên sự kiện reset thủ công, “thức mọi người hiện đang chờ rồi ngay lập tức trả sự kiện về trạng thái không tín hiệu”, nhưng chính Microsoft nêu trong tài liệu rằng “hàm này không đáng tin và không nên được dùng. Nó tồn tại chủ yếu vì tương thích ngược. Dùng biến điều kiện thay.” Lý do là một luồng đang chờ có thể bị tạm gỡ khỏi trạng thái chờ bởi APC chế độ kernel rồi trở lại lần chờ sau khi APC xong. Nếu PulseEvent được gọi trong khoảng ngắn đó, luồng đó không nằm trong “những ai đang chờ đúng lúc nó được gọi” và không được thức.7 APC kernel là thứ hệ điều hành dùng nội bộ; ứng dụng không điều khiển được chúng.11 Vấn đề này cũng là cảnh báo phân tích tĩnh (C28648).12 Nếu thức tỉnh giả là vấn đề “thức thêm”, đây là vấn đề “ngủ quên khi lẽ ra phải thức”, và vòng while không cứu được — vì bản thân thông báo đã mất.
Chỉ gửi thông báo trước, mà không giữ khóa, trước khi cập nhật trạng thái. Gọi WakeConditionVariable trong khi trạng thái vẫn cũ, rồi mới lấy khóa và cập nhật trạng thái — theo thứ tự đó, luồng đã thức vẫn thấy điều kiện chưa thỏa khi kiểm tra, và đi ngủ lại. Nếu không có thông báo nữa, nó ở lại đó. Lưu ý rằng nếu bạn viết “thông báo → cập nhật → nhả” trong khi vẫn giữ cùng khóa, không có hại thật, vì bên chờ không thể kiểm tra điều kiện cho tới khi lấy lại khóa. Dẫu vậy, để người đọc không phải xác minh điều kiện an toàn này mỗi lần, an toàn hơn là chuẩn hóa thứ tự “cập nhật trạng thái dưới khóa, và thông báo sau đó”.
7. Cách điều tra khi bạn gặp phải
Lỗi liên quan thức tỉnh giả có đặc trưng “chỉ xuất hiện hiếm”. Làm việc ngược từ triệu chứng, chúng tách thành hai họ sau.
Họ 1: Xử lý tiếp tục với điều kiện chưa thỏa. Ngoại lệ hoặc sự cố từ lấy hàng đợi rỗng, thiếu kết quả, và tương tự. Nghi lần chờ không vị từ. Bạn có thể lược điều này máy móc trong rà soát mã — tìm chỗ cv.wait( chỉ có một đối số, và chỗ SleepConditionVariableCS / Monitor.Wait được bọc trong if chứ không phải while. Lần kiểm này không đòi chờ tái hiện, và là nước đi đòn bẩy cao nhất bạn có.
Họ 2: Luồng lẽ ra phải thức thì không (treo). Nghi thức tỉnh bị mất (kiểm tra điều kiện ngoài khóa, hoặc thông báo ngoài khóa trước khi cập nhật trạng thái) và PulseEvent. Lấy dump từ tiến trình đã treo và nhìn stack của từng luồng, và bạn có thể nhận diện luồng nào kẹt trong API chờ nào. Từ đó, lần theo mã “ai lẽ ra phải gửi thông báo đó, và theo thứ tự nào”.
flowchart TB
accTitle: Luồng phân loại từ triệu chứng
accDescr: Nếu xử lý tiếp tục với điều kiện chưa thỏa, lược các lần chờ không vị từ bằng tìm trong mã; nếu một luồng không thức, nhận diện chỗ chờ từ dump rồi nghi thức tỉnh bị mất hoặc PulseEvent
s["Lỗi chỉ xuất hiện hiếm"] --> a["Xử lý tiếp tục với điều kiện chưa thỏa"]
s --> b["Luồng lẽ ra phải thức thì không"]
a --> a1["Tìm trong mã các lần chờ không vị từ"]
b --> b1["Nhận diện luồng đang chờ từ dump"]
a1 -.-> a2["Đổi if thành while, hoặc dùng wait vị từ"]
b1 -.-> b2["Nghi thức tỉnh bị mất hoặc PulseEvent"]
Hình 7: Liệu triệu chứng là “đi quá xa” hay “không bao giờ thức” tách cả thứ bạn nghi lẫn cách bạn điều tra.
Nếu bạn muốn tái hiện, nước đi chuẩn là nới cửa sổ đua. Tăng nhiễu thời điểm bằng cách dùng nhiều luồng hơn số nhân vật lý, chèn một Sleep cố ý giữa wait và notify, và chạy cả bản gỡ lỗi lẫn bản phát hành. Khi bạn xác nhận “nó ngừng tái hiện sau khi chúng tôi sửa lần chờ không vị từ”, so sánh dưới cùng sức ép.
8. Tóm tắt — Một danh sách kiểm
- Các đường trở về từ
waitlà ba — thông báo thật, thức tỉnh giả, và thức tỉnh bị cướp — và phía gọi không phân biệt được chúng. Nên luôn viết lần chờ như vòng while trên điều kiện. - Thức tỉnh giả là hành vi mà Win32, C++ và POSIX cố ý cho phép như sự đánh đổi với hiệu năng, và nó sẽ không biến mất bằng bản sửa hệ điều hành hay đổi thư viện.
Monitor.Waitcủa .NET không được giả định thức không lý do, nhưng vì thức tỉnh bị cướp và hết thời gian chờ tồn tại, cùng kỷ luật while vẫn bắt buộc. - Trong C++, mặc định dùng dạng vị từ
wait(lock, pred). Thư viện thực hiện vòng. - Cập nhật và kiểm tra điều kiện dưới cùng khóa. Gửi thông báo “sau khi cập nhật trạng thái”. Thông báo Win32/C++ có thể xảy ra sau khi nhả khóa;
Pulsecủa C# chỉ bên trong khóa. - Với lần chờ có thời gian chờ, chốt hạn chót rồi tính lại thời gian còn lại. Trong C++,
wait_untilcộng vị từ. - Đừng tái tạo thông báo thoáng của biến điều kiện bằng xung trên sự kiện.
PulseEventnói riêng là thứ tài liệu chính thức nêu, bằng đúng lời, “đừng dùng, dùng biến điều kiện thay”. Bản thân sự kiện vẫn là công cụ đúng cho chỉ thị dừng, ghép vớiWaitForMultipleObjects, và đồng bộ xuyên tiến trình. - Trong rà soát, tìm máy móc “lần chờ không vị từ” và “
if+ wait”. Bạn có thể giết lỗi tái hiện hiếm mà không chờ tái hiện.
Thức tỉnh giả, trái với sự kỳ lạ của tên, cô đọng thành từ khóa một dòng cho cách sửa — đổi if thành while. Và phía sau dòng đó nằm ý tưởng thiết kế của công cụ biến điều kiện: “thông báo chính xác thì đắt, nên kiểm tra là trách nhiệm của bên chờ”. Hiểu nó như một cơ chế và bạn nên có thể áp dụng cùng kỷ luật không do dự khi ngôn ngữ hoặc khung đổi.
Bài viết liên quan
- 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 C — Viết an toàn theo cách Win32 API
- 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
- Vì sao nên ưu tiên chờ sự kiện hơn Sleep(1) trên Windows
- Những cái bẫy của bộ nhớ dùng chung và thực hành tốt nhất
- Chiều sâu I/O của Windows (Phần 2) — I/O đồng bộ và bất đồng bộ: OVERLAPPED thực sự nghĩa là gì
Lĩnh vực tư vấn liên quan
KomuraSoft LLC đảm nhận rà soát thiết kế đa luồng, điều tra nguyên nhân gốc rễ (phân tích dump) của sự cố và treo “chỉ tái hiện thỉnh thoảng”, và di chuyển mã đồng bộ hóa cũ (phụ thuộc sự kiện và PulseEvent, và tương tự) sang nền biến điều kiện. Bắt đầu từ phân loại triệu chứng cũng được — xin cứ liên hệ.
- Tư vấn kỹ thuật và rà soát thiết kế
- Điều tra lỗi và phân tích nguyên nhân gốc rễ
- 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, Condition Variables. Về biến điều kiện là đối tượng chế độ user nguyên tử nhả khóa rồi vào lần chờ; về có thức tỉnh giả (lần thức không gắn với lần thức tường minh) và thức tỉnh bị cướp (một luồng khác chạy trước luồng đã thức), nên sau khi trở về từ lần chờ bạn nên kiểm tra lại vị từ trong vòng while; và về thông báo có thể từ trong hoặc ngoài khóa, nhưng thức sau khi nhả khóa tốt hơn để giảm chuyển ngữ cảnh. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Về việc nguyên tử nhả critical section chỉ định rồi chờ trên biến điều kiện; về luồng đã thức lấy lại critical section trước khi trở về; về ERROR_TIMEOUT được trả về khi hết thời gian chờ; và về có thức tỉnh giả và thức tỉnh bị cướp, nên sau khi trở về từ lần chờ bạn nên kiểm tra lại vị từ (thường trong vòng while). ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Về thức tỉnh giả từ pthread_cond_wait / pthread_cond_timedwait có thể xảy ra; về trở về từ wait không nói gì về giá trị vị từ, nên vị từ nên được đánh giá lại; và về Rationale nêu rằng triển khai “thức đúng một” có thể làm chậm thao tác biến điều kiện đặc biệt trên đa bộ xử lý, và việc cho phép thức tỉnh giả buộc vòng kiểm tra vị từ và làm ứng dụng vững hơn. ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. Về wait không vị từ có thể được bỏ chặn bởi thức tỉnh giả; và về overload vị từ tương đương while (!pred()) wait(lock); và được định nghĩa như vòng lấy lại khóa rồi kiểm tra vị từ ở mỗi thông báo hoặc thức tỉnh giả. ↩ ↩2
-
Microsoft Learn, condition_variable Class. Về wait không vị từ được nêu là bỏ chặn trên notify_one / notify_all và cũng có thể thức giả; về dạng vị từ wait(lock, pred) thực chất chạy while (!Pred()) wait(Lck);; và về wait_for / wait_until có cùng tính chất và overload vị từ. ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. Về Wait nhả khóa rồi vào hàng đợi chờ; về không trở về sau khi được thức bởi Pulse / PulseAll cho tới khi khóa được lấy lại; và về cách dùng dự định là luồng đã thức đánh giá lại điều kiện đã khiến nó vào lần chờ rồi gọi Wait lại nếu cần. ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). Về một luồng đang chờ có thể bị tạm gỡ khỏi trạng thái chờ bởi APC chế độ kernel rồi trở lại sau khi APC xong, nên nếu PulseEvent được gọi trong khoảng đó luồng không được nhả; và về PulseEvent vì vậy không đáng tin và không nên dùng trong ứng dụng mới, biến điều kiện được dùng thay. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. Về mẫu chính thức triển khai hàng đợi producer–consumer bằng một critical section và hai biến điều kiện (BufferNotEmpty và BufferNotFull). Lần chờ được thực hiện bên trong vòng kiểm tra vị từ. ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). Về hàm chờ giá trị của một địa chỉ đổi được bảo đảm trở về khi được tín hiệu nhưng cũng được phép trở về vì lý do khác; về các ví dụ thức sớm gồm điều kiện bộ nhớ thấp, bỏ lần thức trước cho cùng địa chỉ, và chạy bản checked; và về vì vậy cần so sánh lại giá trị sau khi trở về, chính mẫu chính thức là vòng while. ↩
-
Microsoft Learn, Monitor.PulseAll Method. Về PulseAll chuyển luồng từ hàng đợi chờ sang hàng đợi sẵn, và luồng tiếp trên hàng đợi sẵn lấy khóa khi khóa được nhả; và về Pulse / PulseAll / Wait chỉ gọi được từ bên trong khối đồng bộ hóa. ↩ ↩2
-
Microsoft Learn, Waits and APCs. Về APC kernel thực thi chiếm quyền, và hệ thống nội bộ gián đoạn rồi tiếp tục lần chờ mà không trở về từ API chờ, nên tín hiệu thoáng như KePulseEvent có thể bị bỏ lỡ trong khoảng đó. ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. Về cảnh báo phân tích tĩnh khi dùng PulseEvent; về luồng đã ra khỏi lần chờ vì APC không được nhả và có thể treo mãi; và về hướng dẫn thay bằng SetEvent hoặc đối tượng đồng bộ hóa khác. ↩
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.
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...
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 ...
Named pipe trong thực tiễn — IPC chuẩn của Windows, từ thiết kế đến bảo mật
Hướng dẫn thực tiễn về named pipe, cơ chế giao tiếp giữa các tiến trình chuẩn của Windows. Bài viết sắp xếp, từ nguồn gốc, việc chọn giữa...
"Không phản hồi" thực sự là gì — Cách Windows quyết định ứng dụng đã treo, và cách thiết kế ứng dụng không treo
"Không phản hồi" của Windows là cơ chế trong đó hệ điều hành phán rằng một cửa sổ chưa lấy thông điệp trong 5 giây và thay nó bằng cửa sổ...
Ứ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...
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.
- Thức tỉnh giả có phải lỗi của hệ điều hành hoặc thư viện không?
- Không — đó là hành vi được nêu rõ trong đặc tả. SleepConditionVariableCS của Win32, std::condition_variable của C++, và pthread_cond_wait của POSIX đều có tài liệu chính thức hoặc chuẩn nói tường minh rằng lần thức không gắn với thông báo có thể xảy ra. Một triển khai cấm điều đó về lý thuyết là có thể, nhưng nó sẽ làm chậm mọi thao tác biến điều kiện (đặc biệt thông báo trên đa bộ xử lý), nên sự đánh đổi là cho phép nó với hiểu biết rằng "tính đúng được giữ nếu bên chờ kiểm tra lại điều kiện". Cách chữa vì vậy không phải chờ bản sửa hệ điều hành, mà luôn viết wait bên trong vòng while (hoặc dùng wait dạng vị từ).
- Bọc wait trong vòng while có hại hiệu năng không?
- Trong thực tiễn chi phí không đáng kể. Tất cả vòng while thêm là một lần kiểm tra điều kiện thêm mỗi khi bạn thức, và đó là so sánh rẻ trong khi bạn đã giữ khóa. Bản thân thức tỉnh giả hiếm, nên lần lặp vòng thêm chỉ xảy ra trong trường hợp ngoại lệ. Chi phí để kiểm tra như if, mặt khác, là "lỗi chỉ tái hiện hiếm" trong đó xử lý tiếp tục với điều kiện chưa thỏa — không có so sánh. Thứ thực sự thống trị chi phí chờ biến điều kiện là tranh khóa và tần suất bạn thông báo, không phải việc while có đó hay không.
- Nếu tôi dùng wait dạng vị từ của C++, tôi có thể quên thức tỉnh giả không?
- Với vòng wait, có: cv.wait(lock, pred) thực chất là while (!pred()) wait(lock); nên cả thức tỉnh giả lẫn thức tỉnh bị cướp đều được hấp thụ tự động. Mã C++ mới nên mặc định dùng overload vị từ. Bạn vẫn phải bảo vệ cập nhật trạng thái dùng chung mà vị từ đọc bằng cùng mutex, và bên thông báo vẫn phải cập nhật trạng thái đó trước khi gọi notify. Wait vị từ nhận vòng khỏi tay bạn; nó không nhận kỷ luật khóa khỏi tay bạn.
- Cùng vấn đề có xảy ra với Monitor.Wait của C# không?
- Có. Một luồng đang chờ trong Monitor.Wait được thức bởi Pulse/PulseAll rồi lấy lại khóa trước khi trở về từ Wait, nhưng trong khoảng đó một luồng khác có thể đã lấy khóa trước và tiêu thụ điều kiện (thức tỉnh bị cướp). Tài liệu của Microsoft được viết trên giả định rằng luồng đã thức đánh giá lại điều kiện đã khiến nó chờ, và gọi Wait lại nếu cần. Nên hình dạng cơ bản trong C# cũng là while (!condition) Monitor.Wait(gate);. Một ràng buộc khác Win32 là bạn chỉ có thể gọi Wait/Pulse từ bên trong câu lệnh lock.
- Thức tỉnh giả cũng xảy ra khi bạn chờ sự kiện bằng WaitForSingleObject không?
- Trong lần chờ thông thường (không alertable), WAIT_OBJECT_0 chỉ được trả về khi đối tượng thực sự trở thành signaled; không có "thức không lý do" kiểu biến điều kiện. Dẫu vậy, "sự kiện trở thành signaled" và "điều kiện của ứng dụng bạn đứng vững" là hai việc khác nhau. Nếu vài consumer được thức bởi cùng sự kiện, luồng lấy khóa trước tiêu thụ điều kiện, nên bạn vẫn cần kiểm tra lại điều kiện sau khi thức. Thiết kế cố tái tạo thông báo thoáng "chỉ thức ai đang chờ đúng lúc đó" của biến điều kiện bằng sự kiện cũng dễ gặp vấn đề độ tin cậy của PulseEvent, nên để chờ một điều kiện bên trong tiến trình, biến điều kiện là công cụ an toàn hơn.