Spurious wakeup — mengapa condition variable bangun "tanpa diberitahu" dan cara menunggu dengan benar di Windows

· · Windows, Multithreading, Condition variable, Sinkronisasi, C++, C#, Win32 API, Pemecahan masalah

“Kami menaruh data di antrian dan membangunkan thread worker yang menunggu. Ia sudah berjalan selama enam bulan, lalu suatu hari ia mencoba membaca antrian kosong dan crash.” “Kami mengirim notifikasi, tetapi sesekali thread tidak pernah bangun.” — Rendezvous multithreaded tampak seolah bekerja, dan adalah tempat berkembang biaknya bug yang hanya muncul jarang. Investigasi jenis ini sering mendarat pada kode yang membungkus wait condition variable dalam if. Dan di belakang itu duduk spurious wakeup — fenomena kembali dari wait tanpa telah menerima notifikasi.

“Ia bangun meskipun tidak ada yang memberitahunya” terdengar seperti cacat di implementasi, tetapi itu perilaku yang Win32, C++, dan POSIX semuanya tuliskan di dokumentasi atau standar mereka, dan Monitor .NET dirancang dengan asumsi bahwa “sekali bangun, Anda memeriksa ulang kondisi”. Mengapa perilaku itu diizinkan? Di lapisan mana ia terjadi di Windows? Dan bagaimana Anda menulis tunggu agar tidak pernah menabraknya? Ditujukan kepada pengembang yang menulis aplikasi bisnis dan perangkat lunak kontrol peralatan di Windows, artikel ini mengurai apa sebenarnya spurious wakeup dari sumber primer, dan merebus tunggu yang benar menjadi Win32 (C), C++, dan C#.

1. Kesimpulan lebih dulu

  • wait condition variable dapat kembali bahkan ketika tidak ada notifikasi yang datang. Dokumentasi resmi Win32 menyatakan bahwa condition variable tunduk pada spurious wakeup (bangun yang tidak terikat pada bangun eksplisit) dan stolen wakeup (thread lain mengonsumsi kondisi sebelum thread yang dibangunkan).1
  • Jadi Anda harus selalu menulis tunggu sebagai “loop while plus pemeriksaan ulang kondisi”. Kode yang memeriksa sekali dengan if lalu wait tampak seolah bekerja, dan menyimpan bug yang hanya tereproduksi jarang.12
  • Ini bukan keanehan khusus Windows; POSIX dan standar C++ mengatakan hal yang sama. Implementasi yang “sama sekali tidak pernah bangun secara spurious” akan memperlambat setiap operasi condition variable, jadi bangun diizinkan dengan asumsi bahwa waiter akan memeriksa ulang.34
  • Di C++, bentuk predikat wait(lock, pred) membuat pustaka melakukan loop untuk Anda. Bentuk itu secara efektif menjalankan while (!pred()) wait(lock);. Itu bawaan untuk kode baru.5
  • Monitor.Wait C# membutuhkan disiplin yang sama. Kondisi dapat dikonsumsi dalam interval antara dibangunkan dan memperoleh kembali kunci, jadi Anda memeriksa ulang kondisi dalam while dan kembali ke Wait.6
  • Perbarui dan periksa kondisi di bawah kunci yang sama. Jika Anda melihat kondisi di luar kunci lalu masuk wait, notifikasi dapat lewat di celah — lost wakeup.1
  • Jangan membuat ulang notifikasi transien condition variable “bangunkan siapa pun yang menunggu sekarang” dengan pulse pada event. PulseEvent khususnya dapat melewatkan notifikasi pada saat APC mode kernel mengangkat tunggu sejenak, dan Microsoft sendiri mengatakan, dengan kata-kata yang gamblang, “ia tidak andal, jangan pakai, pakai condition variable sebagai gantinya”.7

Yang berikut menelusuri mekanisme yang mendukung kesimpulan ini, secara berurutan.

2. Apa itu spurious wakeup — bangun tidak berarti kondisi bertahan

Condition variable adalah primitif sinkronisasi untuk “menidurkan thread sampai suatu kondisi bertahan, dan membangunkannya ketika itu terjadi”. Di Win32 itu adalah struktur CONDITION_VARIABLE bersama SleepConditionVariableCS / SleepConditionVariableSRW (tunggu) dan WakeConditionVariable / WakeAllConditionVariable (beri tahu). API tunggu secara atomik melepas kunci yang Anda pegang (critical section atau SRW lock) dan pergi tidur, dan pada bangun ia memperoleh kembali kunci sebelum kembali.1

Pertanyaannya adalah apa arti fakta “telah kembali dari wait” sebenarnya. Secara naif Anda ingin berpikir “notifikasi datang = kondisi bertahan”, tetapi dalam kenyataan ada tiga kasus di mana wait kembali.

Kasus Notifikasi Kondisi saat kembali
Bangun sungguhan Ya Sering terpenuhi, tetapi tidak dijamin
Spurious wakeup Tidak ada yang ditujukan kepada Anda Masih tidak terpenuhi
Stolen wakeup Ya Thread lain mengonsumsinya lebih dulu; tidak terpenuhi
Tiga kasus di mana wait kembaliTunggu condition variable dapat kembali bukan hanya dari notifikasi sungguhan tetapi juga dari spurious wakeup tanpa notifikasi dan dari stolen wakeup di mana notifikasi datang tetapi kondisi dikonsumsi lebih dulu, jadi setiap kasus membutuhkan kondisi diperiksa ulangKembali dari waitNotifikasi sungguhanSpurious wakeup (tanpa notifikasi)Stolen wakeup (kondisi sudah dikonsumsi)Periksa ulang kondisi, lalu lanjut

Gambar 1: Ada tiga jalur kembali dari wait, dan pemanggil tidak dapat membedakan yang mana yang diambil, jadi Anda harus selalu memeriksa ulang kondisi.

Spurious wakeup adalah kasus kedua ini — fenomena API tunggu kembali tanpa terikat pada notifikasi eksplisit yang dimaksudkan untuk membangunkan Anda. Ia tidak terbatas pada situasi di mana WakeConditionVariable tidak pernah dipanggil di mana pun di sistem. Misalnya, di bawah beban tinggi di mana notifikasi datang dalam semburan singkat, implementasi dapat membangunkan thread yang menunggu ekstra secara batch, dan dari sisi yang tidak punya notifikasi yang sesuai itu juga spurious wakeup. Halaman condition variable Microsoft Learn mengatakan ini dengan gamblang: “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

Poin pentingnya adalah bahwa pemanggil tidak dapat membedakan yang mana dari tiga kasus ia kembali. Jika Anda tidak dapat membedakan, hanya ada satu strategi yang tersedia: setiap kali Anda kembali, periksa kondisi yang Anda tunggu itu sendiri, dan kembali tidur jika ia tidak bertahan. Itulah isi sungguhan aturan besi “bungkus wait dalam while”. Dibalik, selama Anda menjaga aturan itu, kode benar tidak peduli yang mana dari tiga kasus yang membangunkan Anda.

3. Mengapa spesifikasi mengizinkannya — notifikasi tepat mahal

“Bangun tanpa diberitahu hanyalah implementasi yang ceroboh, bukan?” adalah pertanyaan yang wajar. Faktanya secara teoretis mungkin membangun implementasi yang tidak pernah bangun secara spurious. Meskipun begitu, POSIX, Windows, dan standar C++ semuanya turun ke sisi “itu dapat terjadi”. Alasannya dinyatakan terus terang di Rationale untuk pthread_cond_wait di POSIX (The Open Group Base Specifications).3

Alasan pertama adalah performa. Mencoba mengimplementasikan notifikasi yang “andal membangunkan persis satu thread” secara ketat, terutama pada multiprosesor, menambah biaya sinkronisasi ekstra pada setiap operasi condition variable. Scheduler duduk di antara notifikasi dan bangun, dan tergantung waktu interupsi dan preemption Anda tidak dapat menghindari “thread berbeda berjalan sebelum yang Anda maksudkan untuk dibangunkan”. Membayar semua orang biaya menyegel itu sepenuhnya lebih buruk, untuk menjaga condition variable tetap cepat, daripada menerima bahwa “Anda kadang dapat membangunkan ekstra”.

Alasan kedua adalah observasi bahwa trade-off ini tidak merusak aplikasi — ia justru membuatnya lebih kokoh. Karena spurious wakeup diizinkan, kode yang benar selalu menulis loop yang memeriksa predikat (kondisi yang ditunggu). Rationale POSIX mengatakan bahwa memaksa loop ini membuat kode self-documenting dan lebih kokoh.3 Begitu loop ada, arti notifikasi diturunkan dari “jaminan bahwa kondisi bertahan” menjadi “petunjuk bahwa kondisi mungkin telah berubah”, dan sisi yang menunggu menjadi toleran terhadap perubahan desain sederhana pada pemberi tahu (membangunkan terlalu banyak, membangunkan secara batch, dan sebagainya).

Stolen wakeup adalah perkara yang lebih struktural lagi. Selalu ada celah waktu antara pemberi tahu memanggil WakeConditionVariable dan thread yang dibangunkan memperoleh kembali kunci serta kembali dari wait. Jika thread ketiga dapat mengambil kunci dalam interval itu, ia dapat mengonsumsi kondisi (isi antrian, dan sebagainya) lebih dulu. Itu celah yang tidak ada jumlah pemolesan implementasi yang dapat menghapusnya, karena ia datang dari bentuk alat condition variable itu sendiri.

Linimasa stolen wakeupProdusen menaruh satu item di antrian dan membangunkan konsumen A yang menunggu, tetapi sebelum A memperoleh kembali kunci konsumen B memperoleh kunci dan mengambil satu item, jadi antrian kosong pada saat A bangunKonsumen BProdusenKonsumen A (menunggu)Konsumen BProdusenKonsumen A (menunggu)Dibangunkan, menunggu memperoleh kembali kunciAntrian kosong (dicuri)Tambah satu item ke antrianWakeConditionVariablePeroleh kunci dan ambil satu itemPeroleh kembali kunci dan kembali dari waitPeriksa ulang di loop while dan tunggu lagi

Gambar 2: “Stolen wakeup”, di mana thread ketiga mengonsumsi kondisi di celah waktu antara notifikasi dan bangun, dapat terjadi di bawah implementasi apa pun.

Dengan kata lain, bahkan jika OS sepenuhnya mengeradikasi spurious wakeup, selama stolen wakeup ada Anda tetap tidak dapat menulis “saya bangun = kondisi bertahan”. Loop pemeriksaan ulang waiter tetap diperlukan, dan mengingat itu, lebih murah mengizinkan spurious wakeup dan menjaga implementasi tetap cepat — itulah penilaian desain yang condition variable bawa selama puluhan tahun.

4. Di lapisan mana ia muncul di Windows

Properti ini menunjukkan wajahnya di lapisan primitif sinkronisasi Windows mana pun Anda pakai. Untuk merasakan fakta bahwa Anda tidak dapat lolos tidak peduli API lapisan mana yang Anda tulis, kita akan melihat lapisan representatif.

Condition variable Win32 (CONDITION_VARIABLE) , seperti sudah dicatat, didokumentasikan pada SleepConditionVariableCS / SleepConditionVariableSRW sebagai tunduk pada baik spurious wakeup maupun stolen wakeup, dan Anda diwajibkan memeriksa ulang predikat dalam loop while.2 Sampel pemakaian resmi (antrian produsen–konsumen) juga menulis tunggu di dalam loop while.8

WaitOnAddress yang lebih rendah lagi adalah API tunggu yang lebih primitif daripada condition variable: “tunggu sampai nilai di alamat tertentu berubah” (Windows 8 dan seterusnya). Bahkan API hampir-lapisan-bawah ini punya dokumentasi yang menyatakan “dijamin kembali ketika alamat di-signal, tetapi juga diizinkan kembali karena alasan lain”, dan mencantumkan sebagai contoh bangun lebih awal kondisi memori rendah, meninggalkan bangun sebelumnya untuk alamat yang sama, dan menjalankan checked build. Itulah mengapa sampel pemakaian dokumentasi sendiri berbentuk “loop while yang membandingkan nilai lagi”.9

std::condition_variable C++ sama. Dokumentasi MSVC mengatakan tentang wait tanpa predikat bahwa ia “memblokir sampai di-signal oleh panggilan ke notify_one / notify_all. Ia juga dapat bangun secara spurious”, dan menjelaskan bahwa bentuk predikat wait(lock, pred) secara efektif menjalankan kode berikut.5

while (!Pred())
    wait(Lck);

Dengan kata lain, wait berbentuk predikat yang direkomendasikan di C++ tidak lain adalah pustaka yang mengambil “bungkus dalam while”, seperti artikel ini jelaskan, dari tangan Anda. cppreference juga menyatakan bahwa wait tanpa predikat dapat di-unblock secara spurious.4

Monitor.Wait / Pulse .NET punya struktur antrian sendiri berupa waiting queue dan ready queue, tetapi disiplinnya tidak berubah. Thread yang dibangunkan oleh Pulse / PulseAll pindah ke ready queue dan kembali dari Wait dalam urutan ia dapat memperoleh kembali kunci. Thread lain yang dapat mengonsumsi kondisi dalam interval sebelum kunci diperoleh kembali sama seperti di Win32, dan dokumentasi juga ditulis dengan asumsi bahwa “thread yang dibangunkan mengevaluasi ulang kondisi yang menyebabkannya masuk tunggu, dan memanggil Wait lagi jika perlu”.610

Setiap lapisan mensyaratkan predikat diperiksa ulangDokumentasi resmi mensyaratkan kondisi diperiksa ulang setelah bangun di setiap lapisan — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE, dan WaitOnAddress tingkat rendahC++ std::condition_variableSaat bangun, periksa ulang kondisi (while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

Gambar 3: Ganti bahasa atau kerangka dan persyaratan resmi tetap sama di setiap lapisan primitif tunggu: periksa ulang setelah Anda bangun.

5. Cara menunggu yang benar — tulis dengan while dan predikat

Dari sini, implementasi. Hanya ada tiga prinsip.

  1. Pegang apa yang Anda tunggu sebagai state (predikat), bukan sebagai “notifikasi”. Kondisi adalah shared state yang dilindungi kunci — “apakah antrian tidak kosong?”, “apakah flag ditetapkan?” — bukan “apakah saya dibangunkan?”.
  2. Selalu tempatkan wait di dalam loop while pada kondisi. Setiap kali Anda bangun, periksa kondisi, dan kembali tidur jika ia tidak bertahan.
  3. Perbarui dan periksa kondisi di bawah kunci yang sama. Pemberi tahu memperbarui state lalu memberi tahu.
Alur loop tunggu yang benarPeroleh kunci dan periksa kondisi; jika tidak bertahan, lepas kunci dan tidur; saat bangun peroleh kembali kunci dan kembali ke pemeriksaan kondisi. Lanjut dengan kunci dipegang hanya ketika kondisi bertahanTidakYaPeroleh kunciKondisi terpenuhi?wait (lepas kunci dan tidur)Bangun (peroleh kembali kunci)Lanjut sementara masih memegang kunci

Gambar 4: Tunggu yang benar adalah loop, dan tidak ada celah antara memeriksa kondisi dan memproses (keduanya terjadi sementara kunci dipegang).

Bentuk ini punya manfaat yang mudah terlewat. Saat Anda meninggalkan loop while, sudah ditetapkan, sementara Anda masih memegang kunci, bahwa “kondisi bertahan”. Loop yang membela terhadap spurious wakeup, apa adanya, adalah jaminan bahwa tidak ada celah race-condition antara memeriksa kondisi dan memprosesnya.

Bentuk dasar di 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

Anda dapat memanggil notifikasi (WakeConditionVariable) dari dalam kunci atau dari luarnya, tetapi dokumentasi mengatakan bahwa membangunkan setelah melepas kunci biasanya lebih baik, untuk mengurangi context switch.1 Di sisi lain, pembaruan state itu sendiri (++queueCount) harus selalu terjadi di bawah kunci. Jangan campur keduanya.

Bentuk dasar di C++ — jadikan wait predikat sebagai bawaan

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();

Karena wait berbentuk predikat melakukan loop untuk Anda, while yang ditulis tangan tidak perlu. Ketika Anda memperbaiki kode yang ada yang masih punya loop yang ditulis tangan, while (q.empty()) cv.wait(lk); adalah bentuk yang benar, jadi tidak perlu buru-buru menulis ulang. Satu-satunya bentuk yang salah adalah if (q.empty()) cv.wait(lk);.

Bentuk dasar di 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 hanya dapat dipanggil dari dalam kunci (blok lock), yang berbeda dari Win32. Memanggilnya di luar kunci melempar SynchronizationLockException.10

Ketika Anda menunggu dengan timeout, melewatkan “nilai timeout yang sama” pada setiap iterasi loop meregangkan tunggu setiap kali spurious wakeup terjadi. Bentuk yang benar adalah menetapkan tenggat lebih dulu dan menghitung ulang sisa waktu.

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);
Alur yang benar untuk tunggu dengan timeoutTetapkan tenggat lebih dulu, dan setiap kali Anda bangun periksa kondisi dan tenggat; jika masih ada waktu, hitung ulang sisa waktu dan kembali ke tungguYaTidakYaTidakTetapkan tenggatKondisi terpenuhi?Lanjut ke pemrosesanTenggat sudah lewat?Tangani timeoutHitung sisa waktu dan tunggu

Gambar 5: Tunggu dengan timeout tidak melewatkan “durasi tunggu yang sama” lagi; ia menghitung ulang sisa waktu dari tenggat.

Di C++, Anda dapat menyerahkan perhitungan ini, tenggat dan semuanya, kepada overload wait_until (waktu absolut) plus predikat. Bahkan ketika ia kembali pada timeout ia memberi Anda nilai akhir predikat, jadi Anda juga dapat memutuskan “apakah kita timeout, atau kita sempat?” pada predikat.5

6. Katalog pola yang harus dihindari

Memeriksa hanya sekali dengan if. Ini bintang artikel. Saat spurious wakeup atau stolen wakeup terjadi, pemrosesan lanjut dengan kondisi tidak terpenuhi. Mengambil dari antrian kosong, menyentuh data yang belum diinisialisasi, double free — gejalanya menjadi “crash atau korupsi data yang hanya muncul kadang-kadang”.

Memeriksa atau memperbarui kondisi di luar kunci. Jika waiter melihat kondisi di luar kunci, memutuskan “belum”, dan di celah sebelum masuk wait pemberi tahu memperbarui state dan mengirim notifikasi, notifikasi ditembakkan ke condition variable tanpa waiter dan hilang. Waiter kemudian masuk wait dan terus menunggu notifikasi yang tidak akan datang lagi. Itu lost wakeup, cermin spurious wakeup. Alasan API tunggu condition variable dirancang untuk “secara atomik melepas kunci dan pergi tidur” justru untuk menutup celah ini.1 Ia tidak terjadi selama Anda menjaga disiplin kunci.

Linimasa lost wakeupJika waiter memeriksa kondisi di luar kunci dan pemberi tahu memperbarui state serta memberi tahu di celah sebelum wait dimasuki, notifikasi dikirim ke condition variable tanpa waiter dan hilang, dan waiter terus menunggu notifikasi yang tidak akan datangPemberi tahuWaiterPemberi tahuWaiterTidak ada waiter pada saat iniNotifikasi sudah hilang dan ia tidak pernah bangunPeriksa kondisi di luar kunci (tidak terpenuhi)Perbarui state dan beri tahuMasuk wait

Gambar 6: Jika Anda memeriksa kondisi di luar kunci, notifikasi menyelip lewat celah antara pemeriksaan dan wait — “lost wakeup”.

Membuat ulang “notifikasi transien” condition variable dengan pulse pada event. Event sendiri (CreateEvent + SetEvent) bukan anti-pola. Sinyal bangun dalam pengaturan di mana satu konsumen memproses antrian sampai kosong, atau instruksi berhenti yang sekali diangkat tidak pernah diturunkan (manual-reset event), adalah pemakaian event yang benar; dan ketika Anda ingin menggabungkannya dengan target tunggu lain lewat WaitForMultipleObjects, atau menyeberangi batas proses, condition variable — objek mode pengguna yang tidak dapat dibagi lintas proses — adalah yang tidak dapat dipakai.1 Yang berbahaya adalah mencoba membuat ulang, dengan operasi event, notifikasi transien condition variable yang “membangunkan hanya thread yang menunggu pada saat itu dan tidak meninggalkan state”. Gagasan itu hampir selalu menuju item berikutnya, PulseEvent.

Memakai PulseEvent. Itu API yang, pada manual-reset event, “membangunkan semua yang saat ini menunggu dan segera mengembalikan event ke keadaan non-signaled”, tetapi Microsoft sendiri menyatakan di dokumentasi bahwa “fungsi ini tidak andal dan tidak boleh dipakai. Ia ada terutama untuk kompatibilitas mundur. Pakai condition variable sebagai gantinya.” Alasannya adalah bahwa thread yang menunggu dapat sementara dikeluarkan dari keadaan tunggu oleh APC mode kernel dan kembali ke tunggu setelah APC selesai. Jika PulseEvent dipanggil dalam interval singkat itu, thread itu tidak termasuk di antara “mereka yang menunggu pada saat ia dipanggil” dan tidak dibangunkan.7 Kernel APC adalah sesuatu yang OS pakai secara internal; aplikasi tidak dapat mengendalikannya.11 Masalah ini juga peringatan analisis statis (C28648).12 Jika spurious wakeup adalah masalah “membangunkan ekstra”, ini adalah masalah “tidur berlebihan ketika Anda seharusnya bangun”, dan loop while tidak dapat menyelamatkan Anda — karena notifikasi itu sendiri telah hilang.

Mengirim hanya notifikasi lebih dulu, tanpa memegang kunci, sebelum memperbarui state. Memanggil WakeConditionVariable sementara state masih basi, dan baru kemudian mengambil kunci dan memperbarui state — dalam urutan itu, thread yang dibangunkan masih melihat kondisi tidak terpenuhi ketika ia memeriksa, dan kembali tidur. Jika tidak ada notifikasi lebih lanjut, ia tetap di sana. Perhatikan bahwa jika Anda menulis “beri tahu → perbarui → lepas” sementara masih memegang kunci yang sama, tidak ada bahaya sungguhan, karena waiter tidak dapat memeriksa kondisi sampai ia memperoleh kembali kunci. Meskipun begitu, agar pembaca tidak harus memverifikasi syarat keamanan ini setiap kali, lebih aman menstandarkan pada urutan “perbarui state di bawah kunci, dan beri tahu setelah itu”.

7. Cara menyelidiki ketika Anda menemuinya

Bug yang melibatkan spurious wakeup dicirikan “hanya muncul jarang”. Bekerja mundur dari gejala, mereka terbelah ke dua keluarga berikut.

Keluarga 1: Pemrosesan lanjut dengan kondisi tidak terpenuhi. Pengecualian atau crash dari mengambil dari antrian kosong, hasil yang hilang, dan sebagainya. Curigai tunggu tanpa predikat. Anda dapat menyisir ini secara mekanis dalam tinjauan kode — cari tempat di mana cv.wait( hanya punya satu argumen, dan tempat di mana SleepConditionVariableCS / Monitor.Wait dibungkus dalam if daripada while. Pemeriksaan ini tidak mensyaratkan menunggu reproduksi, dan adalah gerakan dengan leverage tertinggi yang Anda punya.

Keluarga 2: Thread yang seharusnya bangun tidak (hang). Curigai lost wakeup (memeriksa kondisi di luar kunci, atau memberi tahu di luar kunci sebelum memperbarui state) dan PulseEvent. Ambil dump dari proses yang hang dan lihat stack setiap thread, dan Anda dapat mengidentifikasi thread mana yang macet di API tunggu mana. Dari situ, kejar lewat kode “siapa yang seharusnya mengirim notifikasi itu, dan dalam urutan apa”.

Alur triase dari gejalaJika pemrosesan lanjut dengan kondisi tidak terpenuhi, sisir tunggu tanpa predikat dengan mencari kode; jika thread tidak bangun, identifikasi situs tunggu dari dump dan curigai lost wakeup atau PulseEventBug yang hanya muncul jarangPemrosesan lanjut dengan kondisi tidak terpenuhiThread yang seharusnya bangun tidakCari di kode tunggu tanpa predikatIdentifikasi thread yang menunggu dari dumpUbah if menjadi while, atau pakai wait predikatCurigai lost wakeup atau PulseEvent

Gambar 7: Apakah gejalanya “lanjut terlalu jauh” atau “tidak pernah bangun” membelah baik apa yang Anda curigai maupun cara Anda menyelidiki.

Jika Anda ingin mereproduksinya, gerakan standar adalah melebarkan jendela balapan. Tingkatkan jitter waktu dengan memakai lebih banyak thread daripada inti fisik, menyisipkan Sleep yang disengaja antara wait dan notify, dan menjalankan baik build debug maupun release. Ketika Anda mengonfirmasi bahwa “ia berhenti tereproduksi setelah kita memperbaiki tunggu tanpa predikat”, bandingkan di bawah tekanan yang sama.

8. Ringkasan — daftar periksa

  • Jalur kembali dari wait ada tiga — notifikasi sungguhan, spurious wakeup, dan stolen wakeup — dan pemanggil tidak dapat membedakannya. Jadi selalu tulis tunggu sebagai loop while pada kondisi.
  • Spurious wakeup adalah perilaku yang Win32, C++, dan POSIX dengan sengaja izinkan sebagai trade-off terhadap performa, dan ia tidak akan hilang dengan perbaikan OS atau tukar pustaka. Monitor.Wait .NET tidak diasumsikan bangun tanpa alasan, tetapi karena stolen wakeup dan timeout ada, disiplin while yang sama tetap diperlukan.
  • Di C++, bawaan ke bentuk predikat wait(lock, pred). Pustaka melakukan loop.
  • Perbarui dan periksa kondisi di bawah kunci yang sama. Kirim notifikasi “setelah memperbarui state”. Notifikasi Win32/C++ boleh terjadi setelah melepas kunci; Pulse C# hanya di dalam kunci.
  • Untuk tunggu dengan timeout, tetapkan tenggat dan hitung ulang sisa waktu. Di C++, wait_until plus predikat.
  • Jangan membuat ulang notifikasi transien condition variable dengan pulse pada event. PulseEvent khususnya adalah sesuatu yang dokumentasi resmi nyatakan, dengan kata-kata yang gamblang, “jangan pakai, pakai condition variable sebagai gantinya”. Event sendiri tetap alat yang tepat untuk instruksi berhenti, bergabung dengan WaitForMultipleObjects, dan sinkronisasi lintas proses.
  • Dalam tinjauan, cari secara mekanis “tunggu tanpa predikat” dan “if + wait”. Anda dapat membunuh bug yang jarang tereproduksi tanpa menunggu reproduksi.

Spurious wakeup, bertentangan dengan keanehan namanya, mengental menjadi kata kunci perbaikan satu baris — ubah if menjadi while. Dan di belakang satu baris itu terletak gagasan desain alat condition variable: “notifikasi tepat mahal, jadi pemeriksaan adalah tanggung jawab waiter”. Pahami sebagai mekanisme dan Anda seharusnya dapat menerapkan disiplin yang sama tanpa ragu ketika bahasa atau kerangka berubah.

Artikel terkait

Area konsultasi terkait

KomuraSoft LLC menangani tinjauan desain multithread, investigasi akar (analisis dump) crash dan hang yang “hanya tereproduksi kadang-kadang”, serta memigrasikan kode sinkronisasi warisan (bergantung event dan PulseEvent, dan sejenisnya) ke basis condition variable. Mulai dari men-triase gejala sudah cukup — silakan menghubungi kami.

Tautan referensi

  1. Microsoft Learn, Condition Variables. Tentang condition variable sebagai objek mode pengguna yang secara atomik melepas kunci dan masuk tunggu; tentang adanya spurious wakeup (bangun yang tidak terikat pada bangun eksplisit) dan stolen wakeup (thread lain berjalan sebelum thread yang dibangunkan), sehingga setelah kembali dari tunggu Anda harus memeriksa ulang predikat dalam loop while; dan tentang notifikasi dimungkinkan dari dalam atau luar kunci, tetapi membangunkan setelah melepas kunci lebih baik untuk mengurangi context switch.  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Tentang secara atomik melepas critical section yang ditentukan dan menunggu pada condition variable; tentang thread yang dibangunkan memperoleh kembali critical section sebelum kembali; tentang ERROR_TIMEOUT dikembalikan pada timeout; dan tentang adanya spurious wakeup dan stolen wakeup, sehingga setelah kembali dari tunggu Anda harus memeriksa ulang predikat (biasanya dalam loop while).  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Tentang spurious wakeup dari pthread_cond_wait / pthread_cond_timedwait dapat terjadi; tentang kembali dari wait tidak berarti apa-apa tentang nilai predikat, jadi predikat harus dievaluasi ulang; dan tentang Rationale menyatakan bahwa implementasi yang “membangunkan persis satu” dapat memperlambat operasi condition variable terutama pada multiprosesor, dan bahwa mengizinkan spurious wakeup memaksa loop pemeriksaan predikat dan membuat aplikasi lebih kokoh.  2 3

  4. cppreference.com, std::condition_variable::wait. Tentang wait tanpa predikat dapat di-unblock oleh spurious wakeup; dan tentang overload predikat setara dengan while (!pred()) wait(lock); dan didefinisikan sebagai loop yang memperoleh kembali kunci dan memeriksa predikat pada setiap notifikasi atau spurious wakeup.  2

  5. Microsoft Learn, condition_variable Class. Tentang wait tanpa predikat dinyatakan di-unblock pada notify_one / notify_all dan juga dapat bangun secara spurious; tentang bentuk predikat wait(lock, pred) secara efektif menjalankan while (!Pred()) wait(Lck);; dan tentang wait_for / wait_until punya properti yang sama dan overload predikat.  2 3

  6. Microsoft Learn, Monitor.Wait Method. Tentang Wait melepas kunci dan masuk waiting queue; tentang tidak kembali setelah dibangunkan oleh Pulse / PulseAll sampai kunci diperoleh kembali; dan tentang pemakaian yang dimaksudkan adalah bahwa thread yang dibangunkan mengevaluasi ulang kondisi yang menyebabkannya masuk tunggu dan memanggil Wait lagi jika perlu.  2

  7. Microsoft Learn, PulseEvent function (winbase.h). Tentang thread yang menunggu dapat sementara dikeluarkan dari keadaan tunggu oleh APC mode kernel dan kembali setelah APC selesai, sehingga jika PulseEvent dipanggil dalam interval itu thread tidak dilepas; dan tentang PulseEvent karena itu tidak andal dan tidak boleh dipakai di aplikasi baru, condition variable dipakai sebagai gantinya.  2

  8. Microsoft Learn, Using Condition Variables. Tentang sampel resmi yang mengimplementasikan antrian produsen–konsumen dengan satu critical section dan dua condition variable (BufferNotEmpty dan BufferNotFull). Tunggu dilakukan di dalam loop yang memeriksa predikat. 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). Tentang fungsi yang menunggu nilai alamat berubah dijamin kembali ketika di-signal tetapi juga diizinkan kembali karena alasan lain; tentang contoh bangun lebih awal termasuk kondisi memori rendah, meninggalkan bangun sebelumnya untuk alamat yang sama, dan menjalankan checked build; dan tentang karena itu perlu membandingkan nilai lagi setelah kembali, sampel resmi sendiri adalah loop while. 

  10. Microsoft Learn, Monitor.PulseAll Method. Tentang PulseAll memindahkan thread dari waiting queue ke ready queue, dan thread berikutnya di ready queue memperoleh kunci ketika kunci dilepas; dan tentang Pulse / PulseAll / Wait hanya dapat dipanggil dari dalam blok sinkronisasi.  2

  11. Microsoft Learn, Waits and APCs. Tentang kernel APC mengeksekusi secara preemptive, dan sistem secara internal menginterupsi dan melanjutkan tunggu tanpa kembali dari API tunggu, sehingga sinyal transien seperti KePulseEvent dapat terlewat dalam interval itu. 

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. Tentang peringatan analisis statis pada pemakaian PulseEvent; tentang thread yang keluar dari tunggu karena APC tidak dilepas dan dapat hang selamanya; dan tentang panduan menggantinya dengan SetEvent atau objek sinkronisasi lain. 

Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.

Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.

Artikel ini berkaitan langsung dengan layanan berikut.

Pertanyaan yang sering diajukan

Pertanyaan yang sering muncul dalam konsultasi tentang topik artikel ini.

Apakah spurious wakeup bug di OS atau pustaka?
Tidak — itu perilaku yang tertulis di spesifikasi. SleepConditionVariableCS Win32, std::condition_variable C++, dan pthread_cond_wait POSIX semuanya punya dokumentasi resmi atau standar yang menyatakan secara eksplisit bahwa bangun yang tidak terikat pada notifikasi dapat terjadi. Implementasi yang melarangnya secara teoretis mungkin, tetapi itu akan memperlambat setiap operasi condition variable (terutama notifikasi pada multiprosesor), jadi trade-off-nya adalah mengizinkannya dengan pemahaman bahwa "kebenaran dipertahankan jika waiter memeriksa ulang kondisi". Obatnya karena itu bukan menunggu perbaikan OS, melainkan selalu menulis wait di dalam loop while (atau memakai wait berbentuk predikat).
Apakah membungkus wait dalam loop while merusak performa?
Dalam praktik biayanya dapat diabaikan. Semua yang ditambahkan loop while adalah satu pemeriksaan kondisi ekstra setiap kali Anda bangun, dan itu perbandingan murah sementara Anda sudah memegang kunci. Spurious wakeup sendiri jarang, jadi iterasi loop ekstra hanya terjadi dalam kasus luar biasa. Biaya meninggalkan pemeriksaan sebagai if, di sisi lain, adalah "bug yang hanya tereproduksi jarang" di mana pemrosesan lanjut dengan kondisi tidak terpenuhi — tidak ada bandingannya. Yang sebenarnya mendominasi biaya tunggu condition variable adalah contention kunci dan seberapa sering Anda memberi tahu, bukan apakah while ada.
Jika saya memakai wait berbentuk predikat C++, bisakah saya lupa tentang spurious wakeup?
Untuk loop wait, ya: cv.wait(lock, pred) secara efektif adalah while (!pred()) wait(lock); jadi baik spurious wakeup maupun stolen wakeup diserap secara otomatis. Kode C++ baru sebaiknya memakai overload predikat sebagai bawaan. Anda tetap harus melindungi pembaruan pada shared state yang predikat baca dengan mutex yang sama, dan pemberi tahu tetap harus memperbarui state itu sebelum memanggil notify. Wait predikat mengambil loop dari tangan Anda; ia tidak mengambil disiplin kunci dari tangan Anda.
Apakah masalah yang sama terjadi dengan Monitor.Wait C#?
Ya. Thread yang menunggu di Monitor.Wait dibangunkan oleh Pulse/PulseAll lalu memperoleh kembali kunci sebelum kembali dari Wait, tetapi dalam interval itu thread lain mungkin telah memperoleh kunci lebih dulu dan mengonsumsi kondisi (stolen wakeup). Dokumentasi Microsoft ditulis dengan asumsi bahwa thread yang dibangunkan mengevaluasi ulang kondisi yang menyebabkannya menunggu, dan memanggil Wait lagi jika perlu. Jadi bentuk dasar di C# juga while (!condition) Monitor.Wait(gate);. Satu kendala yang berbeda dari Win32 adalah bahwa Anda hanya dapat memanggil Wait/Pulse dari dalam pernyataan lock.
Apakah spurious wakeup juga terjadi ketika Anda menunggu event dengan WaitForSingleObject?
Dalam tunggu biasa (non-alertable), WAIT_OBJECT_0 dikembalikan hanya ketika objek benar-benar menjadi signaled; tidak ada "bangun tanpa alasan" seperti yang dimiliki condition variable. Meskipun begitu, "event menjadi signaled" dan "kondisi aplikasi Anda bertahan" adalah hal yang berbeda. Jika beberapa konsumen dibangunkan oleh event yang sama, thread yang mengambil kunci lebih dulu mengonsumsi kondisi, jadi Anda tetap perlu memeriksa ulang kondisi setelah bangun. Desain yang mencoba membuat ulang notifikasi transien condition variable "bangunkan hanya siapa pun yang menunggu pada saat itu" dengan event juga cenderung menabrak masalah keandalan PulseEvent, jadi untuk menunggu kondisi di dalam proses, condition variable adalah alat yang lebih aman.

Profil penulis

Halaman perkenalan penulis artikel.

Go Komura

Direktur KomuraSoft LLC

Berspesialisasi dalam pengembangan perangkat lunak Windows, konsultasi teknis, dan investigasi bug, terutama pada proyek dengan sistem yang sudah ada dan bug yang sulit direproduksi.

Kembali ke blog