الإيقاظات الزائفة ── لماذا تستيقظ متغيّرات الشرط «دون إشعار» وكيف تنتظر بشكل صحيح على Windows
· غو كومورا · Windows, تعدّد مؤشّرات الترابط, متغيّرات الشرط, التزامن, C++, C#, Win32 API, استكشاف الأخطاء
«نضع بيانات في الطابور ونوقظ مؤشّر العامل المنتظِر. كان يعمل لستّة أشهر، ثمّ في يوم حاول قراءة طابور فارغ وانهار.» «نحن نرسل الإشعار، لكن بين حين وآخر مؤشّر لا يستيقظ أبداً.» ── لقاء تعدّد مؤشّرات الترابط يبدو كأنّه يعمل، وهو مرتع لأخطاء لا تظهر إلا نادراً. تحقيقات من هذا النوع غالباً ترسو على شيفرة تلفّ wait متغيّر شرط في if. وخلف ذلك يجلس الإيقاظ الزائف ── ظاهرة العودة من wait دون تلقّي إشعار.
«يستيقظ رغم أنّ أحداً لم يُشعِره» يبدو كعيب في التنفيذ، لكنّه سلوك تنصّ عليه وثائق أو معايير Win32 وC++ وPOSIX جميعاً، وMonitor في .NET مصمَّم على افتراض أنّ «متى استيقظت، تعيد فحص الشرط». لماذا يُسمَح بذلك السلوك؟ في أيّ طبقة يحدث على Windows؟ وكيف تكتب الانتظار بحيث لا تصطدم به أبداً؟ موجَّه إلى المطوِّرين الذين يكتبون تطبيقات أعمال وبرمجيّات التحكّم بالمعدّات على Windows، يفكّك هذا المقال ما هو الإيقاظ الزائف حقّاً من المصادر الأوّليّة، ويغلي الانتظار الصحيح إلى Win32 (C) وC++ وC#.
1. الخلاصة أوّلاً
- يمكن لـ
waitمتغيّر شرط أن يعود حتّى عندما لا يكون إشعار قد وصل. الوثائق الرسميّة لـWin32 تنصّ أنّ متغيّرات الشرط خاضعة لإيقاظات زائفة (إيقاظات غير مرتبطة بإيقاظ صريح) ولإيقاظات مسروقة (مؤشّر آخر يستهلك الشرط قبل أن يفعل المؤشّر الموقَظ).1 - لذا يجب دائماً كتابة الانتظار «حلقة while زائد إعادة فحص الشرط». شيفرة تفحص مرّة بـ
ifثمّwaitتبدو كأنّها تعمل، وتؤوي خطأً لا يُعاد إنتاجه إلا نادراً.12 - هذا ليس شذوذاً خاصّاً بـWindows؛ POSIX ومعيار C++ يقولان الشيء نفسه. تنفيذ «لا يوقظ زائفاً أبداً على الإطلاق» سيبطئ كلّ عمليّة متغيّر شرط، لذا يُسمَح بالإيقاظ على افتراض أنّ المنتظِر سيعيد الفحص.34
- في C++، شكل المحمول
wait(lock, pred)يجعل المكتبة تؤدّي الحلقة عنك. ذلك الشكل يعمل فعليّاًwhile (!pred()) wait(lock);. هو الافتراضيّ للشيفرة الجديدة.5 Monitor.Waitفي C# يحتاج الانضباط نفسه. يمكن استهلاك الشرط في الفترة بين الاستيقاظ وإعادة أخذ القفل، لذا تعيد فحص الشرط فيwhileوتعود إلىWait.6- حدِّث وافحص الشرط تحت القفل نفسه. إن نظرت إلى الشرط خارج القفل ثمّ دخلت
wait، يمكن لإشعار أن يمرّ عبر الفجوة ── إيقاظ مفقود.1 - لا تُعِد إنشاء إشعار متغيّر الشرط العابر «أيقظ من ينتظر الآن» بنبضة على حدث.
PulseEventخصوصاً يمكن أن يفوِّت الإشعار في لحظة يرفع فيها APC وضع النواة الانتظار وجيزاً، ومايكروسوفت نفسها تقول، بكلّ هذا الوضوح، «غير موثوق، لا تستخدمه، استخدم متغيّر شرط بدلاً منه».7
ما يلي يسير عبر الآليّات التي تدعم هذا الاستنتاج، بالترتيب.
2. ما الإيقاظ الزائف ── الاستيقاظ لا يعني أنّ الشرط متحقّق
متغيّر الشرط بدائيّة تزامن لـ«تنويم مؤشّر ترابط حتّى يتحقّق شرط ما، وإيقاظه عندما يتحقّق». على Win32 تلك بنية CONDITION_VARIABLE مع SleepConditionVariableCS / SleepConditionVariableSRW (انتظار) وWakeConditionVariable / WakeAllConditionVariable (إشعار). واجهة الانتظار تحرِّر ذريّاً القفل الذي تمسكه (قسماً حرجاً أو قفل SRW) وتذهب إلى النوم، وعند الاستيقاظ تعيد أخذ القفل قبل العودة.1
السؤال هو ما الذي تعنيه حقيقة «العودة من wait» فعليّاً. بسذاجة تريد أن تفكّر «وصل إشعار = الشرط متحقّق»، لكن في الواقع ثمّة ثلاث حالات يعود فيها wait.
| الحالة | الإشعار | الشرط عند العودة |
|---|---|---|
| إيقاظ حقيقيّ | نعم | غالباً متحقّق، لكن غير مضمون |
| إيقاظ زائف | لا شيء موجَّه إليك | ما زال غير متحقّق |
| إيقاظ مسروق | نعم | مؤشّر آخر استهلكه أوّلاً؛ غير متحقّق |
flowchart TB
accTitle: ثلاث حالات يعود فيها wait
accDescr: يمكن لانتظار متغيّر شرط أن يعود ليس من إشعار حقيقيّ فحسب بل أيضاً من إيقاظ زائف بلا إشعار ومن إيقاظ مسروق وصل فيه إشعار لكن استُهلِك الشرط أوّلاً، لذا كلّ حالة تحتاج إعادة فحص الشرط
w["عاد من wait"] --> a["إشعار حقيقيّ"]
w --> b["إيقاظ زائف(بلا إشعار)"]
w --> c["إيقاظ مسروق(الشرط مستهلَك أصلاً)"]
a --> r["أعد فحص الشرط، ثمّ تابع"]
b --> r
c --> r
الشكل 1: ثمّة ثلاثة مسارات للعودة من wait، والمستدعي لا يستطيع معرفة أيّها سلك، لذا يجب دائماً إعادة فحص الشرط.
الإيقاظ الزائف هو هذه الحالة الثانية ── ظاهرة عودة واجهة الانتظار دون ارتباط بإشعار صريح كان مقصوداً إيقاظك. ليس مقصوراً على أوضاع لم يُستدعَ فيها WakeConditionVariable في أيّ مكان في النظام. مثلاً، تحت حمل عالٍ تصل فيه الإشعارات في دفعة قصيرة، قد يوقظ التنفيذ مؤشّرات منتظِرة إضافيّة دفعةً، ومن الجانب الذي ليس له إشعار مقابل ذلك أيضاً إيقاظ زائف. صفحة متغيّر الشرط في Microsoft Learn تقول هذا بصراحة: “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
النقطة المهمّة أنّ المستدعي لا يستطيع معرفة أيّ الحالات الثلاث عاد عبرها. إن لم تستطع المعرفة، فثمّة استراتيجيّة واحدة متاحة: في كلّ مرّة تعود، افحص الشرط الذي كنت تنتظره نفسه، وعد إلى النوم إن لم يكن متحقّقاً. هذا المحتوى الحقيقيّ للقاعدة الحديديّة «لُفّ wait في while». بالعكس، طالما تحفظ تلك القاعدة، الشيفرة صحيحة أيّاً من الحالات الثلاث أيقظك.
3. لماذا تسمح المواصفة به ── الإشعار الدقيق مكلف
«الاستيقاظ دون إشعار مجرّد تنفيذ متسيّب، أليس كذلك؟» سؤال عادل. في الواقع ممكن نظريّاً بناء تنفيذ لا يوقظ زائفاً أبداً. ومع ذلك، نزل POSIX وWindows ومعيار C++ جميعاً على جانب «يمكن أن يحدث». السبب منصوص بصراحة في مسوِّغ pthread_cond_wait في POSIX (مواصفات قاعدة The Open Group).3
السبب الأوّل الأداء. محاولة تنفيذ إشعار «يوقظ بموثوقيّة مؤشّر ترابط واحد بالضبط» بصرامة، خصوصاً على متعدّدات المعالجات، تضيف كلفة تزامن إضافيّة إلى كلّ عمليّة متغيّر شرط. يجلس مجدول بين الإشعار والإيقاظ، وحسب توقيت المقاطعات والانتزاع لا تستطيع تجنّب «مؤشّر مختلف يعمل قبل الذي قصدت إيقاظه». دفع الجميع كلفة إغلاق ذلك تماماً أسوأ، لإبقاء متغيّرات الشرط سريعة، من قبول أنّ «قد توقظ إضافيّاً أحياناً».
السبب الثاني ملاحظة أنّ هذه المقايضة لا تكسر التطبيقات ── بل تجعلها في الواقع أكثر متانة. لأنّ الإيقاظات الزائفة مسموحة، تكتب الشيفرة الصحيحة دائماً حلقة تفحص المحمول (الشرط المنتظَر). يقول مسوِّغ POSIX إنّ فرض هذه الحلقة يجعل الشيفرة توثِّق نفسها وأكثر متانة.3 متى وُجدت الحلقة، تُنزَّل دلالة الإشعار من «ضمان أنّ الشرط متحقّق» إلى «تلميح أنّ الشرط ربما تغيّر»، ويصير جانب الانتظار متسامحاً مع تغيّرات تصميم متواضعة على المُشعِر (إيقاظ عدد أكبر مما ينبغي، والإيقاظ دفعةً، وما شابه).
الإيقاظات المسروقة مسألة أكثر بنيويّة بعد. ثمّة دائماً فجوة زمنيّة بين استدعاء المُشعِر WakeConditionVariable وإعادة أخذ المؤشّر الموقَظ القفل والعودة من wait. إن استطاع مؤشّر ثالث أخذ القفل في تلك الفترة، يمكنه استهلاك الشرط (محتويات الطابور، وما شابه) أوّلاً. تلك فجوة لا يمحوها أيّ مقدار من صقل التنفيذ، لأنّها تأتي من شكل أداة متغيّر الشرط نفسها.
sequenceDiagram
accTitle: خطّ زمنيّ لإيقاظ مسروق
accDescr: يضع المنتج عنصراً واحداً في الطابور ويوقظ المستهلك أ المنتظِر، لكن قبل أن يعيد أ أخذ القفل يأخذ المستهلك ب القفل ويأخذ العنصر الواحد، لذا الطابور فارغ بحلول وقت استيقاظ أ
participant A as المستهلك أ(ينتظر)
participant P as المنتج
participant B as المستهلك ب
P->>P: أضِف عنصراً واحداً إلى الطابور
P->>A: WakeConditionVariable
Note over A: استيقظ، ينتظر إعادة أخذ القفل
B->>B: خذ القفل وخذ عنصراً واحداً
A->>A: أعد أخذ القفل وعُد من wait
Note over A: الطابور فارغ(مسروق)
A->>A: أعد الفحص في حلقة while وانتظر مرّة أخرى
الشكل 2: «إيقاظ مسروق»، فيه يستهلك مؤشّر ثالث الشرط في الفجوة الزمنيّة بين الإشعار والإيقاظ، يمكن أن يحدث تحت أيّ تنفيذ.
بمعنى آخر، حتّى لو استأصل نظام التشغيل الإيقاظات الزائفة تماماً، طالما الإيقاظات المسروقة موجودة ما زلت لا تستطيع كتابة «استيقظت = الشرط متحقّق». حلقة إعادة فحص المنتظِر مطلوبة على أيّ حال، ومع ذلك، أرخص السماح بالإيقاظات الزائفة وإبقاء التنفيذ سريعاً ── ذلك حكم التصميم الذي حملته متغيّرات الشرط لعقود.
4. في أيّ طبقات يظهر على Windows
تُظهر هذه الخاصّيّة وجهها أيّاً كانت طبقة بدائيّة تزامن Windows التي تستخدمها. للحصول على إحساس بأنّك لا تستطيع الهرب منها مهما كانت طبقة الواجهة التي تكتب ضدّها، سننظر إلى الطبقات التمثيليّة.
متغيّرات شرط Win32 (CONDITION_VARIABLE) موثَّقة، كما ذُكر، على SleepConditionVariableCS / SleepConditionVariableSRW كخاضعة لإيقاظات زائفة وإيقاظات مسروقة معاً، ويُفرَض عليك إعادة فحص المحمول في حلقة while.2 عيّنة الاستخدام الرسميّة (طابور منتج–مستهلك) تكتب الانتظار أيضاً داخل حلقة while.8
WaitOnAddress في طبقة أدنى بعد واجهة انتظار أكثر بدائيّة من متغيّر شرط: «انتظر حتّى تتغيّر القيمة عند عنوان معطى» (Windows 8 فما بعد). حتّى هذه الواجهة شبه القاع لها وثائق تنصّ أنّها «مضمونة العودة عندما يُشار إلى العنوان، لكن يُسمَح لها أيضاً بالعودة لأسباب أخرى»، وتسرد كأمثلة للإيقاظ المبكّر حالة ذاكرة منخفضة، والتخلي عن إيقاظ سابق للعنوان نفسه، وتشغيل بناء مفحوص. لذلك عيّنة الاستخدام في الوثائق نفسها بشكل «حلقة while تقارن القيمة مرّة أخرى».9
std::condition_variable في C++ الشيء نفسه. وثائق MSVC تقول عن wait بلا محمول إنّه «يحجب حتّى يُشعَر باستدعاء notify_one / notify_all. قد يستيقظ زائفاً أيضاً»، وتشرح أنّ شكل المحمول wait(lock, pred) يعمل فعليّاً الشيفرة التالية.5
while (!Pred())
wait(Lck);
بمعنى آخر، wait بشكل المحمول الموصى به في C++ ليس سوى المكتبة تأخذ «لُفّه في while»، كما يصف هذا المقال، عن يديك. cppreference كذلك تنصّ أنّ wait بلا محمول يمكن إلغاء حجه زائفاً.4
Monitor.Wait / Pulse في .NET لها بنية طابور خاصّة بها من طابور انتظار وطابور جاهزيّة، لكنّ الانضباط لا يتغيّر. مؤشّر توقظه Pulse / PulseAll ينتقل إلى طابور الجاهزيّة ويعود من Wait بالترتيب الذي يستطيع فيه إعادة أخذ القفل. قدرة مؤشّر آخر على استهلاك الشرط في الفترة قبل إعادة أخذ القفل هي نفسها كما على Win32، والوثائق أيضاً مكتوبة على افتراض أنّ «المؤشّر الموقَظ يعيد تقييم الشرط الذي جعله يدخل الانتظار، ويستدعي Wait مرّة أخرى إن لزم».610
flowchart TB
accTitle: كلّ طبقة تتطلّب إعادة فحص المحمول
accDescr: الوثائق الرسميّة تتطلّب إعادة فحص الشرط بعد الاستيقاظ في كلّ طبقة ── C++ std::condition_variable و.NET Monitor وWin32 CONDITION_VARIABLE وWaitOnAddress المنخفض
cpp["C++ std::condition_variable"] --> rule["عند الاستيقاظ، أعد فحص الشرط(while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
الشكل 3: غيّر اللغة أو الإطار والمتطلَّب الرسميّ ما زال نفسه في كلّ طبقة بدائيّة انتظار: أعد الفحص بعد أن تستيقظ.
5. الطريقة الصحيحة للانتظار ── اكتبها بـwhile ومحمول
من هنا فصاعداً، التنفيذ. ثمّة ثلاثة مبادئ فقط.
- أمسك ما تنتظره كحالة (محمول)، لا كـ«إشعار». الشرط حالة مشتركة يحميها قفل ── «هل الطابور غير فارغ؟»، «هل العَلَم مضبوط؟» ── لا «هل استيقظت؟».
- ضَع
waitدائماً داخل حلقة while على الشرط. في كلّ مرّة تستيقظ، افحص الشرط، وعد إلى النوم إن لم يكن متحقّقاً. - حدِّث وافحص الشرط تحت القفل نفسه. المُشعِر يحدِّث الحالة ثمّ يُشعِر.
flowchart TB
accTitle: تدفّق حلقة انتظار صحيحة
accDescr: خذ القفل وافحص الشرط؛ إن لم يكن متحقّقاً، حرِّر القفل ونَمْ؛ عند الاستيقاظ أعد أخذ القفل وعُد إلى فحص الشرط. تابع والقفل ممسوك فقط عندما يكون الشرط متحقّقاً
l["خذ القفل"] --> c{"هل الشرط متحقّق؟"}
c -->|"لا"| s["wait(حرِّر القفل ونَمْ)"]
s --> wk["استيقظ(أعد أخذ القفل)"]
wk --> c
c -->|"نعم"| go["تابع وأنت ما زلت تمسك القفل"]
الشكل 4: انتظار صحيح حلقة، ولا فجوة بين فحص الشرط ومعالجته (كلاهما يحدث والقفل ممسوك).
لهذا الشكل فائدة يسهل تفويتها. لحظة مغادرة حلقة while، يكون ثابتاً، وأنت ما زلت تمسك القفل، أنّ «الشرط متحقّق». الحلقة التي تدافع ضدّ الإيقاظات الزائفة هي، كما هي، ضمان أنّه لا فجوة حالة سباق بين فحص الشرط ومعالجته.
الشكل الأساسيّ في 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
يمكنك استدعاء الإشعار (WakeConditionVariable) من داخل القفل أو من خارجه، لكنّ الوثائق تقول إنّ الإيقاظ بعد تحرير القفل عادةً أفضل، لتقليل تبديلات السياق.1 من جهة أخرى، تحديث الحالة نفسه (++queueCount) يجب أن يحدث دائماً تحت القفل. لا تخلط بينهما.
الشكل الأساسيّ في C++ ── اجعل wait بالمحمول الافتراضيّ
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 بشكل المحمول يؤدّي الحلقة عنك، حلقة while مكتوبة باليد غير ضروريّة. عندما تصلح شيفرة قائمة ما زالت فيها حلقة مكتوبة باليد، while (q.empty()) cv.wait(lk); شكل صحيح، لذا لا حاجة للاستعجال إلى إعادة كتابتها. الشكل غير الصحيح الوحيد هو if (q.empty()) cv.wait(lk);.
الشكل الأساسيّ في 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 يمكن استدعاؤها فقط من داخل قفل (كتلة lock)، وهذا يختلف عن Win32. استدعاؤها خارج القفل يرمي SynchronizationLockException.10
الانتظار بمهلة ── احسب الوقت المتبقّي من موعد نهائيّ
عندما تنتظر بمهلة، تمرير «قيمة المهلة نفسها» في كلّ دورة حلقة يمدّ الانتظار في كلّ مرّة يحدث إيقاظ زائف. الشكل الصحيح تثبيت الموعد النهائيّ أوّلاً وإعادة حساب الوقت المتبقّي.
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: التدفّق الصحيح لانتظار بمهلة
accDescr: ثبِّت الموعد النهائيّ أوّلاً، وفي كلّ مرّة تستيقظ افحص الشرط والموعد النهائيّ؛ إن بقي وقت، أعد حساب الوقت المتبقّي وعُد إلى الانتظار
d["ثبِّت الموعد النهائيّ"] --> c{"هل الشرط متحقّق؟"}
c -->|"نعم"| go["تابع إلى المعالجة"]
c -->|"لا"| t{"هل انقضى الموعد النهائيّ؟"}
t -->|"نعم"| to["عالج المهلة"]
t -->|"لا"| w["احسب الوقت المتبقّي وانتظر"]
w --> c
الشكل 5: انتظار بمهلة لا يمرِّر «مدّة الانتظار نفسها» مرّة أخرى؛ يعيد حساب الوقت المتبقّي من موعد نهائيّ.
في C++، يمكنك ترك هذا الحساب، الموعد النهائيّ وكلّ شيء، لزيادة التحميل wait_until (وقت مطلق) زائد محمول. حتّى عندما يعود عند المهلة يعطيك القيمة النهائيّة للمحمول، لذا يمكنك أيضاً حسم «هل انتهت المهلة، أم أدركناها؟» على المحمول.5
6. كتالوج أنماط للتجنّب
الفحص مرّة واحدة فقط بـif. هذا نجم المقال. لحظة حدوث إيقاظ زائف أو إيقاظ مسروق، تتقدّم المعالجة والشرط غير متحقّق. الأخذ من طابور فارغ، ولمس بيانات غير مُهيَّأة، وتحرير مزدوج ── يصير العَرَض «انهياراً أو فساد بيانات لا يظهر إلا أحياناً».
فحص الشرط أو تحديثه خارج القفل. إن نظر المنتظِر إلى الشرط خارج القفل، وقرّر «ليس بعد»، وفي الفجوة قبل دخول wait حدَّث المُشعِر الحالة وأرسل إشعاراً، يُطلَق الإشعار على متغيّر شرط بلا منتظِر ويختفي. ثمّ يدخل المنتظِر wait ويظلّ ينتظر إشعاراً لن يأتي مرّة أخرى. ذلك إيقاظ مفقود، الصورة المرآة لإيقاظ زائف. سبب تصميم واجهة انتظار متغيّر الشرط على «حرِّر القفل ذريّاً واذهب إلى النوم» بالضبط إغلاق هذه الفجوة.1 لا يحدث طالما تحفظ انضباط الأقفال.
sequenceDiagram
accTitle: خطّ زمنيّ لإيقاظ مفقود
accDescr: إن فحص المنتظِر الشرط خارج القفل وحدَّث المُشعِر الحالة وأشعر في الفجوة قبل دخول wait، يُرسَل الإشعار إلى متغيّر شرط بلا منتظِر ويختفي، ويظلّ المنتظِر ينتظر إشعاراً لن يأتي
participant W as المنتظِر
participant N as المُشعِر
W->>W: افحص الشرط خارج القفل(غير متحقّق)
N->>N: حدِّث الحالة وأشعِر
Note over N: لا منتظِر في هذه اللحظة
W->>W: ادخل wait
Note over W: الإشعار ذهب أصلاً ولا يستيقظ أبداً
الشكل 6: إن فحصت الشرط خارج القفل، ينزلق الإشعار عبر الفجوة بين الفحص وwait ── «إيقاظ مفقود».
إعادة إنشاء «إشعار عابر» لمتغيّر شرط بنبضة على حدث. الأحداث نفسها (CreateEvent + SetEvent) ليست نمطاً مضادّاً. إشارة إيقاظ في إعداد يعالج فيه مستهلك واحد الطابور حتّى يفرغ، أو تعليمات إيقاف تُرفَع مرّة ولا تُخفَض أبداً (حدث إعادة ضبط يدويّ)، استخدامات صحيحة لحدث؛ وعندما تريد ضمّه إلى أهداف انتظار أخرى عبر WaitForMultipleObjects، أو عبور حدود عمليّة، متغيّر الشرط ── كائن وضع مستخدم لا يمكن مشاركته عبر العمليّات ── هو الذي لا يمكن استخدامه.1 الخطر هو محاولة إعادة إنشاء، بعمليّات حدث، إشعار متغيّر شرط عابر «يوقظ فقط مؤشّرات الترابط المنتظِرة في تلك اللحظة ولا يترك حالة خلفه». تلك الفكرة تؤدّي شبه دائماً إلى البند التالي، PulseEvent.
استخدام PulseEvent. واجهة، على حدث إعادة ضبط يدويّ، «توقظ كلّ من ينتظر حالياً وتعيد الحدث فوراً إلى الحالة غير المُشارة»، لكن مايكروسوفت نفسها تنصّ في الوثائق أنّ «هذه الدالّة غير موثوقة وينبغي ألا تُستخدَم. توجد أساساً للتوافق مع الخلف. استخدم متغيّر شرط بدلاً منها.» السبب أنّ مؤشّر ترابط منتظِراً يمكن إزالته مؤقّتاً من حالة الانتظار بـAPC وضع نواة ويعود إلى الانتظار بعد اكتمال الـAPC. إن استُدعي PulseEvent في تلك الفترة الوجيزة، لا يُدرَج ذلك المؤشّر بين «من كانوا ينتظرون في لحظة الاستدعاء» ولا يوقَظ.7 APCs النواة شيء يستخدمه نظام التشغيل داخليّاً؛ التطبيق لا يستطيع التحكّم بها.11 هذه المشكلة أيضاً تحذير تحليل ساكن (C28648).12 إن كان الإيقاظ الزائف مشكلة «إيقاظ إضافيّ»، فهذه مشكلة «نوم زائد عندما كان ينبغي أن تستيقظ»، وحلقة while لا تستطيع إنقاذك ── لأنّ الإشعار نفسه قد فُقِد.
إرسال الإشعار فقط أوّلاً، دون إمساك القفل، قبل تحديث الحالة. استدعاء WakeConditionVariable والحالة ما زالت قديمة، ثمّ أخذ القفل وتحديث الحالة فقط ── بذلك الترتيب، ما زال المؤشّر الموقَظ يرى الشرط غير متحقّق عندما يفحص، ويعود إلى النوم. إن لم يأتِ إشعار إضافيّ، يبقى هناك. لاحظ أنّه إن كتبت «أشعِر → حدِّث → حرِّر» وأنت ما زلت تمسك القفل نفسه، لا ضرر حقيقيّ، لأنّ المنتظِر لا يستطيع فحص الشرط حتّى يعيد أخذ القفل. ومع ذلك، حتّى لا يحتاج القرّاء إلى التحقّق من شرط الأمان هذا في كلّ مرّة، من الأسلم توحيد الترتيب «حدِّث الحالة تحت القفل، وأشعِر بعد ذلك».
7. كيف تحقّق عندما تصادفه
الأخطاء التي تتضمّن إيقاظات زائفة تتميّز بـ«الظهور نادراً فقط». بالعمل إلى الوراء من العَرَض، تنقسم إلى العائلتين التاليتين.
العائلة 1: المعالجة تتقدّم والشرط غير متحقّق. استثناء أو انهيار من الأخذ من طابور فارغ، ونتائج مفقودة، وما شابه. اشتبه في انتظار بلا محمول. يمكنك تمشيط هذا آليّاً في مراجعة الشيفرة ── ابحث عن مواضع يكون لـcv.wait( وسيط واحد فقط، ومواضع يُلَفّ فيها SleepConditionVariableCS / Monitor.Wait في if لا while. هذا الفحص لا يتطلّب انتظار إعادة إنتاج، وهو أعلى حركة رافعة لديك.
العائلة 2: مؤشّر ينبغي أن يستيقظ لا يفعل (تعليق). اشتبه في إيقاظ مفقود (فحص الشرط خارج القفل، أو الإشعار خارج القفل قبل تحديث الحالة) وPulseEvent. خذ تفريغاً من العمليّة المعلَّقة وانظر إلى مكدّس كلّ مؤشّر، ويمكنك تحديد أيّ مؤشّر عالق في أيّ واجهة انتظار. من هناك، طارد عبر الشيفرة «من كان يُفترَض أن يرسل ذلك الإشعار، وبأيّ ترتيب».
flowchart TB
accTitle: تدفّق الفرز من العَرَض
accDescr: إن تقدّمت المعالجة والشرط غير متحقّق، مشِّط الانتظارات بلا محمول بالبحث في الشيفرة؛ إن لم يستيقظ مؤشّر، حدِّد موقع الانتظار من تفريغ واشتبه في إيقاظ مفقود أو PulseEvent
s["خطأ لا يظهر إلا نادراً"] --> a["المعالجة تتقدّم والشرط غير متحقّق"]
s --> b["مؤشّر ينبغي أن يستيقظ لا يفعل"]
a --> a1["ابحث في الشيفرة عن انتظارات بلا محمول"]
b --> b1["حدِّد مؤشّرات الانتظار من تفريغ"]
a1 -.-> a2["غيِّر if إلى while، أو استخدم wait بالمحمول"]
b1 -.-> b2["اشتبه في إيقاظ مفقود أو PulseEvent"]
الشكل 7: ما إذا كان العَرَض «التقدّم أبعد مما ينبغي» أو «عدم الاستيقاظ أبداً» يقسم ما تشتبه فيه وكيفيّة التحقيق معاً.
إن أردت إعادة إنتاجه، الحركة القياسيّة توسيع نافذة السباق. زِد ارتعاش التوقيت باستخدام مؤشّرات أكثر من الأنوية الفيزيائيّة، وإدراج Sleep متعمَّد بين الانتظار والإشعار، وتشغيل بناءَي التصحيح والإصدار. عندما تؤكِّد أنّ «توقّف عن إعادة الإنتاج بعد أن أصلحنا الانتظار بلا محمول»، قارن تحت الإجهاد نفسه.
8. الخلاصة ── قائمة تحقّق
- مسارات العودة من
waitثلاثة ── إشعار حقيقيّ، وإيقاظ زائف، وإيقاظ مسروق ── والمستدعي لا يستطيع التمييز بينها. لذا اكتب الانتظار دائماً كحلقة while على الشرط. - الإيقاظ الزائف سلوك سمحت به Win32 وC++ وPOSIX عمداً كمقايضة مقابل الأداء، ولن يختفي بإصلاح نظام تشغيل أو تبديل مكتبة.
Monitor.Waitفي .NET لا يُفترَض أن يستيقظ بلا سبب، لكن لأنّ الإيقاظات المسروقة والمهلات موجودة، ما زال انضباط while نفسه مطلوباً. - في C++، اجعل شكل المحمول
wait(lock, pred)افتراضيّاً. المكتبة تؤدّي الحلقة. - حدِّث وافحص الشرط تحت القفل نفسه. أرسل الإشعار «بعد تحديث الحالة». إشعار Win32/C++ يجوز بعد تحرير القفل؛
Pulseفي C# داخل القفل فقط. - لانتظار بمهلة، ثبِّت موعداً نهائيّاً وأعد حساب الوقت المتبقّي. في C++،
wait_untilزائد محمول. - لا تُعِد إنشاء إشعار متغيّر الشرط العابر بنبضة على حدث.
PulseEventخصوصاً شيء تنصّ الوثائق الرسميّة، بكلّ هذا الوضوح، «لا تستخدمه، استخدم متغيّر شرط بدلاً منه». الأحداث نفسها تظلّ الأداة الصحيحة لتعليمات إيقاف، والضمّ معWaitForMultipleObjects، والتزامن عبر العمليّات. - في المراجعة، ابحث آليّاً عن «انتظار بلا محمول» و«
if+ انتظار». يمكنك قتل خطأ نادر إعادة الإنتاج دون انتظار إعادة إنتاج.
الإيقاظ الزائف، على خلاف غرابة الاسم، يتكثّف إلى كلمة مفتاحيّة بسطر واحد للإصلاح ── غيِّر if إلى while. وخلف ذلك السطر الواحد فكرة تصميم أداة متغيّر الشرط: «الإشعار الدقيق مكلف، لذا الفحص مسؤوليّة المنتظِر». افهمه آليّةً وينبغي أن تستطيع تطبيق الانضباط نفسه بلا تردّد عندما تتغيّر اللغة أو الإطار.
مقالات ذات صلة
- أفضل ممارسات تعدّد مؤشّرات الترابط عمليّاً: طبعة C++ ── إزالة الحوادث بالبنية عبر RAII وjthread
- أفضل ممارسات تعدّد مؤشّرات الترابط عمليّاً: طبعة C ── الكتابة بأمان على طريقة واجهة Win32
- أفضل ممارسات تعدّد مؤشّرات الترابط عمليّاً: طبعة .NET ── ما ينبغي حسمه قبل أن تضيف مزيداً من مؤشّرات الترابط
- لماذا يجب أن يفضّل كود Windows انتظار الأحداث على الـ polling بـ timer
- المزالق وأفضل الممارسات عند استخدام shared memory - تنظيم مسبق للتزامن، الرؤية، العمر، ABI، والأمان
- أعماق الإدخال/الإخراج في Windows(الجزء 2)── الإدخال/الإخراج المتزامن وغير المتزامن: ما الذي يعنيه OVERLAPPED حقّاً
مجالات الاستشارة ذات الصلة
تتعامل شركة كومورا سوفت ذ.م.م. مع مراجعات تصميم تعدّد مؤشّرات الترابط، وتحقيق السبب الجذريّ (تحليل التفريغ) لانهيارات وتعليقات «لا تُعاد إنتاجها إلا أحياناً»، وترحيل شيفرة تزامن تراثيّة (معتمدة على الأحداث وPulseEvent، وما شابه) إلى قاعدة متغيّر شرط. البدء من فرز العَرَض جيّد ── لا تتردّدوا في التواصل.
- الاستشارة التقنيّة ومراجعة التصميم
- التحقيق في الأخطاء وتحليل الأسباب الجذريّة
- تطوير تطبيقات Windows
- التواصل معنا
روابط مرجعيّة
-
Microsoft Learn, Condition Variables. حول كون متغيّر الشرط كائن وضع مستخدم يحرِّر قفلاً ذريّاً ويدخل انتظاراً؛ وحول وجود إيقاظات زائفة (إيقاظات غير مرتبطة بإيقاظ صريح) وإيقاظات مسروقة (مؤشّر آخر يعمل قبل المؤشّر الموقَظ)، بحيث ينبغي بعد العودة من انتظار إعادة فحص المحمول في حلقة while؛ وحول إمكان الإشعار من داخل القفل أو خارجه، لكن الإيقاظ بعد تحرير القفل أفضل لتقليل تبديلات السياق. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). حول تحرير قسم حرج محدَّد ذريّاً والانتظار على متغيّر شرط؛ وحول إعادة أخذ المؤشّر الموقَظ القسم الحرج قبل العودة؛ وحول إرجاع ERROR_TIMEOUT عند المهلة؛ وحول وجود إيقاظات زائفة وإيقاظات مسروقة، بحيث ينبغي بعد العودة من انتظار إعادة فحص المحمول (عادةً في حلقة while). ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. حول إمكان حدوث إيقاظات زائفة من pthread_cond_wait / pthread_cond_timedwait؛ وحول أنّ العودة من wait لا تعني شيئاً عن قيمة المحمول، لذا ينبغي إعادة تقييم المحمول؛ وحول نصّ المسوِّغ أنّ تنفيذاً «يوقظ واحداً بالضبط» يمكن أن يبطئ عمليّات متغيّر الشرط خصوصاً على متعدّدات المعالجات، وأنّ السماح بالإيقاظات الزائفة يفرض حلقة فحص محمول ويجعل التطبيقات أكثر متانة. ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. حول إمكان إلغاء حجب wait بلا محمول بإيقاظ زائف؛ وحول تكافؤ زيادة التحميل بالمحمول مع while (!pred()) wait(lock); وتعريفها كحلقة تعيد أخذ القفل وتفحص المحمول عند كلّ إشعار أو إيقاظ زائف. ↩ ↩2
-
Microsoft Learn, condition_variable Class. حول النصّ أنّ wait بلا محمول يُلغى حجبه عند notify_one / notify_all ويمكنه أيضاً الاستيقاظ زائفاً؛ وحول شكل المحمول wait(lock, pred) يعمل فعليّاً while (!Pred()) wait(Lck);؛ وحول امتلاك wait_for / wait_until الخاصّيّة نفسها وزيادة تحميل بمحمول. ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. حول تحرير Wait القفل ودخول طابور الانتظار؛ وحول عدم العودة بعد الإيقاظ بـPulse / PulseAll حتّى يُعاد أخذ القفل؛ وحول الاستخدام المقصود أنّ المؤشّر الموقَظ يعيد تقييم الشرط الذي جعله يدخل الانتظار ويستدعي Wait مرّة أخرى إن لزم. ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). حول إمكان إزالة مؤشّر منتظِر مؤقّتاً من حالة الانتظار بـAPC وضع نواة والعودة بعد اكتمال الـAPC، بحيث إن استُدعي PulseEvent في تلك الفترة لا يُطلَق المؤشّر؛ وحول أنّ PulseEvent لذلك غير موثوق ولا ينبغي استخدامه في تطبيقات جديدة، ويُستخدَم متغيّر شرط بدلاً منه. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. حول العيّنة الرسميّة التي تنفِّذ طابور منتج–مستهلك بقسم حرج واحد ومتغيّرَي شرط (BufferNotEmpty وBufferNotFull). الانتظار يُؤدَّى داخل حلقة تفحص المحمول. ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). حول الدالّة التي تنتظر تغيّر قيمة عنوان مضمونة العودة عند الإشارة لكن يُسمَح لها أيضاً بالعودة لأسباب أخرى؛ وحول أمثلة الإيقاظ المبكّر بما في ذلك حالة ذاكرة منخفضة، والتخلي عن إيقاظ سابق للعنوان نفسه، وتشغيل بناء مفحوص؛ وحول الحاجة لذلك إلى مقارنة القيمة مرّة أخرى بعد العودة، والعيّنة الرسميّة نفسها حلقة while. ↩
-
Microsoft Learn, Monitor.PulseAll Method. حول نقل PulseAll مؤشّرات من طابور الانتظار إلى طابور الجاهزيّة، وأخذ المؤشّر التالي على طابور الجاهزيّة القفل عندما يُحرَّر القفل؛ وحول قابليّة استدعاء Pulse / PulseAll / Wait فقط من داخل كتلة تزامن. ↩ ↩2
-
Microsoft Learn, Waits and APCs. حول تنفيذ APCs النواة بشكل انتزاعيّ، ومقاطعة النظام داخليّاً استئناف انتظار دون العودة من واجهة الانتظار، بحيث يمكن تفويت إشارة عابرة مثل KePulseEvent في تلك الفترة. ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. حول تحذير التحليل الساكن على استخدام PulseEvent؛ وحول إمكان عدم إطلاق مؤشّر كان خارج الانتظار بسبب APC وتعليقه إلى الأبد؛ وحول توجيه لاستبداله بـSetEvent أو كائن تزامن آخر. ↩
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
DllMain وقفل المحمِّل ── السبب الحقيقي لعبارة «لا تفعل شيئاً في تهيئة الـDLL»
لماذا يجب ألا تستدعي LoadLibrary أو تتزامن مع مؤشّرات ترابط أخرى من DllMain. يستند المقال إلى المصادر الأوّليّة ليشرح كيف يسلسل قفل المحم...
واجهة مجمع مؤشّرات الترابط Win32 ── تزامن دون إنشاء مؤشّرات ترابط، عبر CreateThreadpoolWork
هل تنثر استدعاءات CreateThread في شيفرتك الأصليّة؟ يشرح هذا المقال واجهة مجمع مؤشّرات الترابط Win32 التي أُعيد تصميمها في Vista ── كائنات...
الأنابيب المسمّاة عمليّاً ── IPC القياسيّ في Windows من التصميم إلى الأمان
دليل عمليّ للأنابيب المسمّاة، وسيلة الاتّصال القياسيّة بين العمليّات في Windows. ينظّم المقال، من المصادر الأوّليّة، الاختيار بين وضع الب...
ما الذي يعنيه «لا يستجيب» حقّاً ── كيف يقرِّر Windows أنّ تطبيقاً قد تجمّد، وكيف تصمِّم تطبيقات لا تتجمّد
«لا يستجيب» في Windows آليّة يحكم فيها نظام التشغيل أنّ نافذة لم تسترجع رسالة لمدّة 5 ثوانٍ ويستبدلها بنافذة شبح. يغطّي المقال دواخل ذلك ...
تطبيقات تتعطّل عند الاستئناف من السكون ── كيف تعمل أحداث الطاقة في Windows وكيف تبني تطبيقات أعمال تصمد أمامها
فتحت الحاسوب المحمول فوجدت اتّصالات تطبيق الأعمال ميّتة ── السبب تصميم لم يحسب حساب السكون. يغطّي المقال تدفّق إشعار WM_POWERBROADCAST، و...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- هل الإيقاظ الزائف خطأ في نظام التشغيل أو المكتبة؟
- لا ── إنّه سلوك منصوص عليه في المواصفة. SleepConditionVariableCS في Win32، وstd::condition_variable في C++، وpthread_cond_wait في POSIX، كلّها لها وثائق رسميّة أو معيار ينصّ صراحةً أنّ إيقاظاً غير مرتبط بإشعار يمكن أن يحدث. تنفيذ يمنعه ممكن نظريّاً، لكنّه سيبطئ كلّ عمليّة متغيّر شرط (خصوصاً الإشعار على متعدّدات المعالجات)، لذا المقايضة السماح به على فهم أنّ «الصحة تُحفَظ إن أعاد المنتظِر فحص الشرط». العلاج إذن ليس انتظار إصلاح من نظام التشغيل، بل كتابة الانتظار دائماً داخل حلقة while (أو استخدام انتظار بشكل محمول).
- هل لفّ الانتظار في حلقة while يضرّ بالأداء؟
- عمليّاً الكلفة ضئيلة. كلّ ما تضيفه حلقة while فحص شرط إضافيّ واحد في كلّ مرّة تستيقظ، وذلك مقارنة رخيصة وأنت تمسك القفل أصلاً. الإيقاظات الزائفة نفسها نادرة، لذا دورة الحلقة الإضافيّة تحدث في حالات استثنائيّة فقط. كلفة ترك الفحص كـ if، من جهة أخرى، «خطأ لا يُعاد إنتاجه إلا نادراً» فيه تتقدّم المعالجة والشرط غير متحقّق ── لا مقارنة. ما يهيمن فعليّاً على كلفة انتظار متغيّر الشرط هو تنازع الأقفال وكم مرّة تُشعِر، لا ما إذا كان الـwhile موجوداً.
- إن استخدمت شكل المحمول لـ wait في C++، هل يمكنني نسيان الإيقاظات الزائفة؟
- لحلقة الانتظار، نعم: cv.wait(lock, pred) فعليّاً while (!pred()) wait(lock); لذا تُمتَصّ الإيقاظات الزائفة والإيقاظات المسروقة تلقائيّاً. شيفرة C++ جديدة ينبغي أن تجعل زيادة التحميل بالمحمول افتراضيّة. ما زال عليك حماية تحديثات الحالة المشتركة التي يقرأها المحمول بالقفل نفسه، وما زال على المُشعِر تحديث تلك الحالة قبل استدعاء notify. انتظار المحمول يأخذ الحلقة عن يديك؛ لا يأخذ انضباط الأقفال عن يديك.
- هل تحدث المشكلة نفسها مع Monitor.Wait في C#؟
- نعم. مؤشّر ترابط ينتظر في Monitor.Wait يوقظه Pulse/PulseAll ثمّ يعيد أخذ القفل قبل العودة من Wait، لكن في تلك الفترة قد يكون مؤشّر آخر أخذ القفل أوّلاً واستهلك الشرط (إيقاظ مسروق). وثائق مايكروسوفت مكتوبة على افتراض أنّ المؤشّر الموقَظ يعيد تقييم الشرط الذي جعله ينتظر، ويستدعي Wait مرّة أخرى إن لزم. لذا الشكل الأساسيّ في C# أيضاً while (!condition) Monitor.Wait(gate);. قيد واحد يختلف عن Win32 هو أنّك تستطيع استدعاء Wait/Pulse فقط من داخل عبارة lock.
- هل تحدث إيقاظات زائفة أيضاً عندما تنتظر حدثاً بـ WaitForSingleObject؟
- في انتظار عاديّ (غير قابل للتنبيه)، يُرجَع WAIT_OBJECT_0 فقط عندما يصير الكائن مُشاراً إليه فعليّاً؛ لا يوجد «إيقاظ بلا سبب» من النوع الذي لمتغيّرات الشرط. مع ذلك، «صار الحدث مُشاراً إليه» و«شرط تطبيقك متحقّق» شيئان مختلفان. إذا أُوقِظ عدّة مستهلكين بالحدث نفسه، فإنّ المؤشّر الذي يأخذ القفل أوّلاً يستهلك الشرط، لذا ما زلت تحتاج إعادة فحص الشرط بعد الاستيقاظ. التصاميم التي تحاول إعادة إنشاء إشعار متغيّر الشرط العابر «أيقظ فقط من ينتظر في تلك اللحظة» بحدث تميل أيضاً إلى الاصطدام بمشكلة موثوقيّة PulseEvent، لذا لانتظار شرط داخل عمليّة، متغيّر الشرط الأداة الأكثر أماناً.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.