Thức tỉnh giả — Vì sao biến điều kiện thức "mà không được thông báo" và cách chờ đúng trên Windows

· · Windows, Đa luồng, Biến điều kiện, Đồng bộ hóa, C++, C#, Win32 API, Xử lý sự cố

“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

  • wait của biến điều kiện có thể trở về ngay cả khi không có thông báo nào tới. 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 if rồi wait trô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ạy while (!pred()) wait(lock);. Đó là mặc định cho mã mới.5
  • Monitor.Wait củ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 trong while rồi trở lại Wait.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. PulseEvent nó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 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 Một luồng khác đã tiêu thụ trước; chưa thỏa
Ba trường hợp wait trở về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ệnTrở về từ waitThông báo thậtThức tỉnh giả(không có thông báo)Thức tỉnh bị cướp(điều kiện đã bị tiêu thụ)Kiểm tra lại điều kiện, rồi tiếp

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.

Dòng thời gian của thức tỉnh bị cướpProducer đặ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ứcConsumer BProducerConsumer A(đang chờ)Consumer BProducerConsumer A(đang chờ)Đã thức, đang chờ lấy lại khóaHàng đợi rỗng(bị cướp)Thêm một mục vào hàng đợiWakeConditionVariableLấy khóa và lấy một mụcLấy lại khóa rồi trở về từ waitKiể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

Mọi tầng đều đòi vị từ được kiểm tra lạiTà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ấpC++ std::condition_variableKhi thức, kiểm tra lại điều kiện(while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

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.

  1. 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?”.
  2. Luôn đặt wait bê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.
  3. 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.
Luồng của vòng chờ đúngLấ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ỏaChưaRồiLấy khóaĐiều kiện đã thỏa?wait(nhả khóa và ngủ)Thức(lấy lại khóa)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();

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);
Luồng đúng của lần chờ có thời gian chờ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ờRồiChưaRồiChưaChốt hạn chótĐiều kiện đã thỏa?Tiếp tới xử lýHạn chót đã qua?Xử lý hết thời gian chờTính thời gian còn lại rồi chờ

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.

Dòng thời gian của thức tỉnh bị mấtNế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ớiBên thông báoBên chờBên thông báoBên chờKhông có người chờ lúc nàyThông báo đã mất và nó không bao giờ thứcKiểm tra điều kiện ngoài khóa(chưa thỏa)Cập nhật trạng thái rồi thông báoVào wait

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

Luồng phân loại từ triệu chứngNế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 PulseEventLỗi chỉ xuất hiện hiếmXử lý tiếp tục với điều kiện chưa thỏaLuồng lẽ ra phải thức thì khôngTìm trong mã các lần chờ không vị từNhận diện luồng đang chờ từ dumpĐổi if thành while, hoặc dùng wait vị từ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ừ wait là 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.Wait củ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; Pulse củ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_until cộ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. PulseEvent nó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ới WaitForMultipleObjects, 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

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

Liên kết tham khảo

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

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

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

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

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

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

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

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

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

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

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

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

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.

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.

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