xv6 — نظام يونكس تعليمي › النوم والإيقاظ 10 / 15 السابق التالي

النوم والإيقاظ

9. النوم والإيقاظ

تساعد الجدولة والأقفال على إخفاء أفعال خيطٍ عن خيطٍ آخر، لكنّنا نحتاج أيضًا إلى تجريداتٍ تساعد الخيوط على التفاعل بشكلٍ مقصود. فمثلًا، قد يحتاج قارئ أنبوبٍ في xv6 إلى الانتظار حتّى تنتج عمليّةٌ كاتبةٌ بياناتٍ؛ وقد يحتاج نداء wait في الأب إلى الانتظار حتّى يخرج ابنٌ؛ وتحتاج العمليّة التي تقرأ القرص إلى الانتظار حتّى ينهي عتاد القرص القراءة. وتستخدم نواة xv6 آليّةً تُسمّى النوم والإيقاظ في هذه المواقف (وفي مواقف أخرى كثيرة). ويسمح النوم لخيط النواة بالانتظار حتّى يصير بعض شرط (condition) صحيحًا؛ ويستطيع خيطٌ آخر أو معالج مقاطعةٍ أن يجعل الشرط صحيحًا (عادةً بتعديل متغيّرٍ أو عدّة متغيّرات) ثمّ ينادي الإيقاظ للإشارة إلى أنّ الخيوط المنتظرة للشرط ينبغي أن تستأنف. وكثيرًا ما تُسمّى دالّتا النوم والإيقاظ آليّتي تنسيق التسلسل (sequence coordination) أو المزامنة الشرطيّة (conditional synchronization).

قبل المضي قدمًا، يُرجى قراءة الدالّات sleep_prepare() وsleep() وwakeup() في kernel/proc.c، وكلّ الملفّ kernel/pipe.c.

9.1 نظرة عامة

تبدو واجهة النوم/الإيقاظ كالآتي:

void sleep_prepare(void *chan)
void sleep()
void wakeup(void *chan)

تنام العمليّة انتظارًا لصيرورة شرطٍ ما صحيحًا، ووسيط chan (أي قناة الانتظار (wait channel)) يسمّي الشرط المطلوب. وsleep_prepare(chan) تُعلن أنّ عمليّةً قد تنام قريبًا انتظارًا لقناة الانتظار المشار إليها؛ وهي ترجع فورًا. وwakeup(chan) تُنادى حين يصير الشرط المسمّى بـchan صحيحًا (أو يكون ربّما قد صار صحيحًا)؛ وهي توقظ جميع العمليات التي نادت sleep_prepare(chan) ثمّ sleep() بنفس الـchan. وsleep() ترجع فورًا إذا كان wakeup(chan) قد نُودي بعد النداء السابق (المطلوب) لـsleep_prepare(chan)؛ وإلّا فإنّ sleep() توسم العمليّة المناديّة بأنّها SLEEPING (لا RUNNABLE) وتُطلق المعالج بتبديل السياق إلى المُجدول، حتّى تستطيع عملياتٌ أخرى أن تعمل. وsleep_prepare() وsleep() تُناديان من العمليّة نفسها (عادةً في بضعة أسطرٍ من بعضها)؛ أمّا wakeup() فتُنادى من عمليّةٍ مختلفة أو من معالج مقاطعة.

تعامل هذه الدوالّ chan معاملة قيمةٍ معتمةٍ من 64 بتّ؛ فالشيء الوحيد الذي تفعله بها هو مقارنتها تساويًا. والنمط المعتاد هو أن يمرّر المنادون عنوان كائنٍ مناسبٍ كوسيط chan. ومبرمج النواة مسؤولٌ عن اختيار اصطلاحات قناة الانتظار، وعن تمرير الـchan نفسه إلى نداءَي sleep_prepare() وwakeup() اللذين ينبغي أن يتفاعلا.

ينام شيفرة النواة انتظارًا حتّى يصير بعض الشرط صحيحًا. فمثلًا، تنام شيفرة النواة التي تقرأ من أنبوبٍ إذا كان مخزَن الأنبوب فارغًا في الوقت الحاليّ؛ والشرط في هذه الحالة هو امتلاء مخزَن الأنبوب (بسبب كتابة عمليّةٍ أخرى إلى الأنبوب). وsleep_prepare() وsleep لا تدريان كيف تفحصان الشرط: الشيفرة المناديّة وحدها تدري. والنمط المعتاد هو أن تحوز العمليّة المرشّحة للنوم أوّلًا قفلًا يحمي الشرط؛ ثمّ تفحص الشرط؛ فإن لم يكن صحيحًا، نادت sleep_prepare()، وأطلقت القفل، ونادت sleep(). أمّا الشيفرة التي تجعل الشرط صحيحًا لاحقًا فتنادي wakeup.

وهذه رسمةٌ مبدئيّةٌ لكيف تنام شيفرة الأنابيب في نواة xv6:

piperead(pipe){
  acquire(&pipe->lock);
  while(there's no data in pipe->buffer){
    sleep_prepare(&pipe);
    release(&pipe->lock);
    sleep();
    acquire(&pipe->lock);
  }
  remove the data from the pipe;
  release(&pipe->lock);
}

pipewrite(pipe){
  acquire(&pipe->lock);
  append data to pipe->buffer;
  wakeup(&pipe);
  release(&pipe->lock);
}

تستخدم هذه الشيفرة عنوان بنية بيانات الأنبوب كقناة انتظار.

لا بدّ من نداء sleep_prepare(chan) إمّا قبل فحص الشرط، أو قبل إطلاق القفل الذي يحمي الشرط. ويسجّل النداء حقيقة أنّ العمليّة قد تحتاج إلى النوم على الـchan المشار إليه. فإذا نادت عمليّةٌ أخرى (أو مقاطعةٌ) الدالّة wakeup(chan) بين نداءَي sleep_prepare(chan) وsleep()، فإنّ wakeup() تضبط رايةً في العمليّة على وشك النوم تشير إلى أنّها لا ينبغي أن تنام فعلًا. فإذا رأت sleep() هذه الراية، رجعت بدلًا من أن تنام.

ما سبب وجود sleep_prepare(chan)؟ ثمّة احتمالٌ بأن يصير الشرط صحيحًا، ويُنادى wakeup()، بين لحظة رؤية قارئ الأنبوب أنّ المخزَن فارغ، ولحظة ندائه لـsleep. فبغير بعض الذكاء، لن تجد wakeup() أيّ عمليّةٍ نائمة، فلن يكون لها أيّ أثر. وحينئذٍ ستنادي العمليّة sleep() مع أنّ مخزَن الأنبوب فيه بياناتٌ للقراءة؛ وإذا (كما قد يكون الحال) لم يكتب كاتب الأنبوب أيّ شيءٍ بعد ذلك، فقد ينام القارئ إلى الأبد على نحوٍ خاطئ. ويُسمّى هذا الموقف غير المرغوب إيقاظ مفقود (lost wake-up). والسلوك الموصوف في الفقرة السابقة يتجنّب هذه المشكلة.

في مثال الأنبوب، كما في معظم شيفرة النوم، ثمّة قفلٌ يحمي الشرط ويمنع نداء wakeup() بين لحظة فحص العمليّة الموشكة على النوم للشرط ولحظة ندائها sleep_prepare(). ويُسمّى هذا القفل كثيرًا قفل الشرط (condition lock). ولا بدّ للعمليّة أن تُطلق القفل قبل نداء sleep() (حتّى تستطيع عمليّةٌ أخرى أو معالج مقاطعةٍ تعديل الشرط ونداء wakeup())، وأن تحوز القفل مرّةً أخرى بعد رجوع sleep().

9.2 الشيفرة: النوم والإيقاظ

ينفّذ sleep_prepare وsleep وwakeup في xv6 الواجهة المستخدمة في المثال أعلاه. والفكرة الأساسية هي أن تجعل sleep العمليّة الحاليّة موسومةً بـSLEEPING ثمّ تنادي sched لإطلاق المعالج؛ وwakeup تبحث عن العمليات النائمة على قناة الانتظار المعطاة وتوسمها بـRUNNABLE.

يسجّل sleep_prepare(chan) قناة الانتظار في p->chan ويرجع. فإذا كان p->chan ما زال غير صفريّ حين تُنادى sleep()، غيّرت sleep() العمليّة المناديّة إلى الحالة SLEEPING ونادت sched() لتتيح لعملياتٍ أخرى أن تعمل.

تتعامل wakeup(chan) مع موقفين فيما يتعلّق بعمليّةٍ أخرى قد تكون في طور النوم على chan تزامنيًّا. فإن كانت تلك العمليّة الأخرى قد وصلت إلى حدّ توسيم نفسها بـSLEEPING، فإنّ wakeup ستراها في تلك الحالة وتغيّرها إلى RUNNABLE؛ وحينئذٍ سيرجع نداؤها لـsleep(). وإن كانت العمليّة الأخرى قد وصلت إلى حدّ نداء sleep_prepare(chan) (لكنّ sleep() الخاصّة بها لم تصل إلى حدّ حوز p->lock)، فإنّ wakeup(chan) سترى أنّ p->chan يساوي chan، وwakeup ستضبط p->chan على الصفر. وفي تلك الحالة، حين تنادي العمليّة الأخرى sleep فعلًا، فسترؤ الصفر وترجع فورًا. والمقصود هو تجنّب إيقاظٍ مفقودٍ إذا نُوديت wakeup() في اللحظة نفسها التي تكون فيها عمليّةٌ أخرى على وشك نداء sleep().

قواعد استخدام sleep_prepare() تُجنّبنا الاضطرار إلى القلق حيال الموقف الذي لم تكن العمليّة الموشكة على النوم قد نادت فيه sleep_prepare() بعد. فإمّا أن تحيط تلك العمليّة فحص شرطها ونداء sleep_prepare() بقفلٍ يمنع أيّ نداء لـwakeup() للقناة المعنيّة، أو عليها أن تنادي sleep_prepare() قبل فحص الشرط.

تنام عدّة عملياتٍ أحيانًا على القناة نفسها؛ فعلى سبيل المثال، أكثر من عمليّةٍ تقرأ من أنبوبٍ. ونداءٌ واحدٌ لـwakeup سيوقظها جميعًا. وستعمل واحدةٌ منها أوّلًا، فتحوز (في حالة الأنابيب) قفل الأنبوب وتقرأ أيّ بياناتٍ تنتظر. أمّا العمليّات الأخرى فستجد أنّها، رغم إيقاظها، لا تجد بياناتٍ للقراءة. ومن وجهة نظرها كان الإيقاظ «زائفًا»، وعليها أن تنام مرّةً أخرى. ولهذا السبب يقع النوم دائمًا داخل حلقةٍ تُعيد فحص الشرط، كما في piperead أعلاه.

لا يقع ضررٌ إذا اختار استخدامان للنوم/الإيقاظ القناة نفسها سهوًا: فسيريان إيقاظاتٍ زائفة، لكنّ الدوران في حلقةٍ كما وُصف أعلاه سيتسامح مع هذه المشكلة. وكثيرٌ من سحر النوم/الإيقاظ أنّه خفيفٌ معًا (لا حاجة إلى إنشاء بنى بياناتٍ خاصّةٍ لتكون قنوات انتظار) ويوفّر طبقةً من الإحالة (لا يحتاج المنادون إلى معرفة العمليّة المحدّدة التي يتفاعلون معها).

9.3 الشيفرة: الأنابيب

أنابيب xv6 مثالٌ على شيفرةٍ تنام لمزامنة المنتجين والمستهلكين. وقد رأينا واجهة الأنابيب في الفصل 1: فالبايتات المكتوبة إلى أحد طرفَي الأنبوب تُنسخ إلى مخزَنٍ داخل النواة ثمّ يمكن قراءتها من الطرف الآخر للأنبوب. وستفحص الفصول اللاحقة دعم واصفات الملفّات المحيط بالأنابيب، لكن دعنا ننظر الآن إلى تنفيذَي pipewrite وpiperead.

يمثَّل كلّ أنبوبٍ بـstruct pipe، يحتوي على lock ومخزَن data. ويحسب الحقلان nread وnwrite العدد الكلّيّ للبايتات المقروءة من المخزَن والمكتوبة إليه. والمخزَن يلتفّ: البايت التاليّ المكتوب بعد buf[PIPESIZE-1] هو buf[0]. أمّا العدّادات فلا تلتفّ. ويتيح هذا الاصطلاح للتنفيذ التمييز بين مخزَن ممتلئ (nwrite == nread+PIPESIZE) ومخزَن فارغ (nwrite == nread)، لكنّه يعني أنّ الفهرسة في المخزَن يجب أن تستخدم buf[nread % PIPESIZE] بدلًا من buf[nread] فحسب (وكذلك الأمر في nwrite).

لنفترض أنّ نداءَي piperead وpipewrite يقعان تزامنيًّا على معالجين مختلفين. فيبدأ pipewrite بحوز قفل الأنبوب، الذي يحمي العدّادات والبيانات والثوابت المرتبطة بها. ثمّ تحاول piperead حوز القفل هي الأخرى، لكنّها لا تقدر. فتدور في acquire منتظرةً القفل. وبينما تنتظر piperead، يمرّ pipewrite على البايتات التي تُكتب (addr[0..n-1])، مضيفًا كلٍّ منها إلى الأنبوب دورًا (pipe.c:101). وخلال هذه الحلقة، قد يحدث أن يمتلئ المخزَن (pipe.c:88). وفي هذه الحالة، ينادي pipewrite الدالّة wakeup لتنبيه أيّ قارئٍ نائمٍ إلى أنّ في المخزَن بياناتٍ تنتظر، ثمّ يُطلق القفل، وينام على القناة &pi->nwrite انتظارًا لقارئٍ يُخرج بعض البايتات من المخزَن.

تحوز piperead الآن قفل الأنبوب وتدخل قسمها الحرج: فتجد أنّ pi->nread != pi->nwrite (إذ نام pipewrite لأنّ pi->nwrite == pi->nread + PIPESIZE)، فتنحدر إلى حلقة for، وتنسخ بياناتٍ من الأنبوب (pipe.c:129)، وتزيد nread بعدد البايتات المنسوخة. وتلك المساحة من المخزَن صارت متاحةً للكتابة الآن، فتنادي piperead الدالّة wakeup لإيقاظ أيّ كاتبٍ نائمٍ قبل أن ترجع. وتجد wakeup عمليّةً نائمةً على &pi->nwrite (العمليّة التي كانت تشغّل pipewrite لكنّها توقّفت حين امتلأ المخزَن). وتوسم wakeup تلك العمليّة بـRUNNABLE.

تستخدم شيفرة الأنبوب قناتَي انتظارٍ منفصلتين للقارئ والكاتب (&pi->nread و&pi->nwrite)؛ وقد يجعّل هذا النظام أكثر كفاءةً في حال وجود كثيرٍ من القرّاء والكتّاب المنتظرين الأنبوب نفسه. وتنام شيفرة الأنبوب داخل حلقةٍ تفحص شرط النوم؛ فإذا تعدّد القرّاء أو الكتّاب، فسترى جميع العمليات عدا الأولى التي تُوقظ أنّ الشرط غير صحيحٍ وتنام مرّةً أخرى.

9.4 الشيفرة: الانتظار والخروج والقتل

يُرجى قراءة شيفرة الدالّات kwait() وkexit() وkkill() في kernel/proc.c؛ فهذه هي التنفيذات الداخليّة لنداءات النظام المقابلة.

ومثالٌ مثيرٌ للاهتمام على النوم، مُقدَّمٌ في الفصل 1، هو التفاعل بين exit الابن وwait أبيه. فعند لحظة موت الابن، قد يكون الأب نائمًا في wait أصلًا، أو قد يكون يفعل شيئًا آخر؛ وفي الحالة الأخيرة، لا بدّ لنداء wait لاحق أن يلحظ موت الابن، ربّما بعد زمنٍ طويلٍ من ندائه لـexit. والطريقة التي يسجّل بها xv6 هلاك الابن حتّى يلحظه wait هي أن يضع exit المناديَ في الحالة ZOMBIE، حيث يبقى حتّى يلحظه wait الأب، فيغيّر حالة الابن إلى UNUSED، وينسخ حالة خروج الابن، ويعيد معرّف عمليّة الابن إلى الأب. فإن خرج الأب قبل الابن، فإنّ الأب يسلّم الابن إلى العمليّة init، التي تنادي wait على الدوام؛ وهكذا لكلّ ابنٍ أبٌ ينظّف من بعده. وأحد التحدّيات هو تجنّب المسابقات والجمود بين wait الأب وexit الابن التزامنيّين، وكذلك بين exit وexit التزامنيّين.

يبدأ kwait، وهو التنفيذ النواة لـwait، بحوز wait_lock، الذي يؤدي دور قفل الشرط ويساعد على ضمان ألّا يُفوت kwait نداء wakeup من ابنٍ خارج. ثمّ يطوف kwait على جدول العمليات. فإذا وجد ابنًا في الحالة ZOMBIE، حرّر موارد ذلك الابن وبنيته proc، ونسخ حالة خروج الابن إلى العنوان الممرَّر إلى wait (إذا لم يكن 0)، وأعاد معرّف عمليّة الابن. فإن وجد kwait أبناءً لكن لم يخرج أيٌّ منهم، فإنّه ينام انتظارًا لخروج أيٍّ منهم (proc.c:414)، ثمّ يطوف مرّةً أخرى. وكثيرًا ما يحوز kwait قفلين، wait_lock وpp->lock لعمليّةٍ ما؛ وترتيب تجنّب الجمود هو wait_lock أوّلًا ثمّ pp->lock.

يسجّل kexit حالة الخروج، ويحرّر بعض الموارد، وينادي reparent لتسليم أيّ أبناءٍ إلى العمليّة init، ويوقظ الأب في حال كان في wait، ويوسم المناديّ بأنّه زومبيّ، ويتنازل عن المعالج تنازلًا دائمًا. ويحوز kexit كلاً من wait_lock وp->lock خلال هذا التسلسل. وهو يحوز wait_lock لأنّه قفل الشرط الخاصّ بـwakeup(p->parent)، فيمنع أبًا موجودًا في wait من فقدان الإيقاظ. ولا بدّ أن يحوز kexit القفل p->lock لهذا التسلسل أيضًا، لمنع أبٍ موجودٍ في wait من رؤية أنّ الابن في الحالة ZOMBIE قبل أن يكون الابن قد نادى swtch أخيرًا. ويحوز kexit هذه الأقفال بالترتيب نفسه الذي يحوزها به kwait لتجنّب الجمود.

قد يبدو خطأً أنّ kexit يوقظ الأب قبل أن يضبط حالة مناديه على ZOMBIE، لكنّ ذلك آمن: فرغم أنّ wakeup قد تجعل الأب يعمل، إلّا أنّ الحلقة في kwait الأب لا تستطيع فحص الابن حتّى يُطلق القفل p->lock الخاصّ بالابن بواسطة scheduler، فلا تستطيع kwait النظر إلى العمليّة الخارجة إلّا بعد أن تضبط kexit حالتها على ZOMBIE.

وبينما يسمح exit لعمليّةٍ بأن تُنهي نفسها، فإنّ نداء النظام kill (proc.c:602) يتيح لعمليّةٍ أن تطلب من أخرى أن تُنهي نفسها. وسيكون من شديد التعقيد على kill أن يدمر العمليّة الضحيّة مباشرةً، إذ قد تكون الضحيّة منفّذةً على معالجٍ آخر، ربّما في وسط تسلسلٍ حسّاسٍ من تحديثات بنى بيانات النواة. ولهذا لا يفعل kkill إلا القليل جدًّا: فهو يضبط p->killed الخاصّ بالضحيّة، وإذا كانت نائمةً أيقظها. وفي النهاية ستدخل الضحيّة النواة أو تخرج منها، وحينها ستنادي الشيفرة في usertrap الدالّة kexit إذا كان p->killed مضبوطًا (تفحص ذلك بنداء killed). فإن كانت الضحيّة تعمل في فضاء المستخدم، فسترؤ أنّها قُتلت في المرّة التالية التي تدخل فيها النواة بإجراء نداء نظامٍ أو لأنّ المؤقّت (أو جهازًا آخر) قاطع.

إذا كانت العمليّة الضحيّة نائمةً، فإنّ kkill تضبط حالتها على RUNNABLE حتّى ترجع الضحيّة من sleep. وهذا خطرٌ محتملٌ لأنّ الشرط المنتظَر قد لا يكون صحيحًا. لكنّ نداءات sleep في xv6 ملفوفةٌ دائمًا في حلقة while تُعيد فحص الشرط بعد رجوع sleep. وبعض نداءات sleep تفحص أيضًا p->killed في الحلقة، وتترك النشاط الحاليّ إذا كان مضبوطًا. ولا يُفعل ذلك إلّا حين يكون مثل هذا الترك صحيحًا. فمثلًا، ترجع شيفرة قراءة الأنبوب وكتابته (pipe.c:84) إذا كانت راية القتل مضبوطة؛ وفي النهاية سترجع الشيفرة إلى المصيدة، التي ستفحص p->killed مرّةً أخرى وتخرج.

لا تفحص بعض حلقات sleep في xv6 الحقل p->killed لأنّ الشيفرة في وسط نداء نظامٍ متعدّد الخطوات ينبغي أن يكون ذرّيًّا (أي إنّ تركه في منتصفه سيكون خطأً). ومشغّل virtio (virtio_disk.c:287) مثالٌ على ذلك: فهو لا يفحص p->killed لأنّ عمليّة قرصٍ قد تكون واحدةً من مجموعة كتاباتٍ كلّها لازمةٌ حتّى يُترك نظام الملفّات في حالةٍ صحيحة. والعمليّة التي تُقتل أثناء انتظارها إدخال/إخراج القرص لن تخرج حتّى تُكمل نداء النظام الحاليّ وترى usertrap راية القتل.

9.5 أقفال العمليّات

القفل المرتبط بكلّ عمليّة (p->lock) هو أعقد قفلٍ في xv6. وطريقةٌ بسيطةٌ للتفكير في p->lock هي أنّه يجب أن يكون محجوزًا أثناء قراءة أيٍّ من حقول struct proc التالية أو كتابتها: p->state، وp->chan، وp->killed، وp->xstate، وp->pid. وقد تستخدم عملياتٌ أخرى هذه الحقول، أو خيوط مُجدولةٍ على معالجات أخرى، فمن الطبيعيّ أن تكون بحاجةٍ إلى حمايةٍ بقفل.

لكنّ معظم استخدامات p->lock تحمي ثوابت من مستوى أعلى في بنى بيانات العمليّات وخوارزميّات xv6. وهذه هي المجموعة الكاملة للأشياء التي يفعلها p->lock:

  • جنبًا إلى جنبٍ مع p->state، يمنع المسابقات في تخصيص خانات proc[] للعمليات الجديدة.
  • يحجب العمليّة عن النظر بينما هي تُنشَأ أو تُدمَّر.
  • يمنع wait الأب من تحصيل عمليّةٍ ضبطت حالتها على ZOMBIE لكنّها لم تتنازل عن المعالج بعد.
  • يمنع مُجدول معالجٍ آخر من تقرير تشغيل عمليّةٍ متنازلةٍ بعد أن تضبط حالتها على RUNNABLE لكن قبل أن تُكمل swtch.
  • يضمن أنّ مُجدول معالجٍ واحدٍ فقط هو الذي يقرّر تشغيل عمليّةٍ RUNNABLE.
  • يمنع مقاطعة مؤقّتٍ من جعل عمليّةٍ تتنازل بينما هي في swtch.
  • يمنع المسابقات بين sleep وwakeup.
  • يمنع العمليّة الضحيّة لـkill من الخروج وربّما من إعادة تخصيصها بين فحص kkill لـp->pid وضبط p->killed.
  • يجعل فحص kkill لـp->state وكتابته ذرّيّين.

الحقل p->parent محميٌّ بالقفل العامّ wait_lock لا بالقفل p->lock. وأب العمليّة وحده هو الذي يعدّل p->parent، وإن كان الحقل يُقرأ من العمليّة نفسها ومن عملياتٍ أخرى تبحث عن أبنائها. والغرض من wait_lock هو أداء دور قفل الشرط حين ينام wait منتظرًا خروج أيّ ابن. والابن الخارج يحوز إمّا wait_lock وإمّا p->lock حتّى يضبط حالته على ZOMBIE، ويوقظ أباه، ويتنازل عن المعالج. وwait_lock يُسلسل كذلك نداءات exit التزامنيّة من أبٍ وابن، حتّى يضمن إيقاظ العمليّة init (التي ترث الابن) من wait الخاصّة بها. وwait_lock قفلٌ عامّ لا قفلٌ خاصٌّ بكلّ أبٍ على حدة، لأنّ العمليّة، حتّى تحوزّه، لا تستطيع أن تعرف من هو أبوها.

9.6 عالم الواقع

وsleep وwakeup طريقةُ مزامنةٍ بسيطةٌ وفعّالة، لكن ثمّة طرقَ أخرى كثيرة؛ والشفّيّات (دايكسترا 1965) مثالٌ على ذلك. وأوّل تحدٍّ في جميعها هو تجنّب مشكلة «الإيقاظ المفقود» التي رأيناها في بداية هذا الفصل. فقد كان sleep في نواة Unix الأصليّة يعطّل المقاطعات ببساطة، وهو ما كان يكفي لأنّ Unix كانت تعمل على نظامٍ أحاديّ المعالج. ويستخدم sleep في Plan 9 دالّة ردّ نداءٍ تعمل مع احتجاز قفل الجدولة قبل النوم بقليل؛ وتؤدّي الدالّة دور فحصٍ في اللحظة الأخيرة لشرط النوم، لتجنّب الإيقاظ المفقود. ويستخدم sleep في نواة Linux طابور عملياتٍ صريحًا، يُسمّى طابور الانتظار، بدلًا من قناة الانتظار؛ ولكلّ طابورٍ قفله الداخليّ.

إنّ طوف مجموعة العمليات كافّةً في wakeup غير كفؤ. والحلّ الأفضل هو استبدال chan في كلٍّ من sleep وwakeup ببنية بياناتٍ تحتفظ بقائمة العمليات النائمة على تلك البنية، مثل طابور الانتظار في Linux. ويسمّي sleep وwakeup في Plan 9 تلك البنية نقطة تلاقٍ. وتشير مكتبات خيوطٍ كثيرة إلى البنية نفسها باسم متغيّر شرط؛ وفي ذلك السياق تُسمّى العمليّتان sleep وwakeup اسمَي wait وsignal. وجميع هذه الآليّات تشترك في الطابع نفسه: شرط النوم محميٌّ بنوعٍ ما من القفل الذي يُطلق ذرّيًّا خلال النوم.

توقظ wakeup في xv6 جميع العمليات المنتظرة على قناة انتظارٍ معيّنة. فإن كانت أكثر من واحدة، فستحاول جميعها حوز قفل الشرط وإعادة فحص الشرط؛ وفي حالاتٍ كثيرةٍ لا تستطيع سوى واحدةٍ منها فعل شيءٍ نافع (مثلًا قراءة جميع البيانات المنتظرة في أنبوب). أمّا الباقيات فستجد أنّ الشرط لم يبقَ صحيحًا وترجع إلى النوم؛ فقد كان إيقاظها مضيعةً لزمن المعالج. ونتيجةً لذلك، توفّر معظم تصاميم متغيّر الشرط أوّليّتين: signal، التي توقظ واحدةً من العمليات المنتظرة متغيّر الشرط، وbroadcast، التي توقظها جميعًا.

إنّ قتل العمليّات قسرًا يطرح بعض المشكلات. فمثلًا، قد تكون العمليّة المقتولة غائصةً في عمق النواة نائمةً، وإنفكاك مكدّسها يقتضي عنايةً، إذ قد تحتاج كلّ دالّةٍ على مكدّس النداءات إلى القيام ببعض التنظيف. وتُساعد بعض اللغات على ذلك بتوفير آليّة استثناء، لكن ليس C. وبالإضافة إلى ذلك، ثمّة أحداثٌ أخرى تستطيع إيقاظ عمليّةٍ نائمة، حتّى وإن لم يكن الحدث الذي تنتظره قد حدث بعد. فمثلًا، حين تنام عمليّة Unix، قد ترسل إليها عمليّةٌ أخرى signal. وفي هذه الحالة، سترجع العمليّة من نداء النظام المتوقّف بالقيمة -1 ومع ضبط رمز الخطأ على EINTR. ويمكن للتطبيق أن يفحص هذه القيم ويقرّر ما يفعله. أمّا xv6 فلا يدعم الإشارات فلا ينشأ هذا التعقيد.

دعم xv6 لـkill غير مرضيّ تمامًا: ثمّة حلقات نومٍ ينبغي على الأرجح أن تفحص p->killed. ومشكلةٌ ذات صلةٍ بذلك هي أنّ هناك، حتّى في حلقات sleep التي تفحص p->killed، مسابقةً بين sleep وkill؛ فقد تضبط الأخيرة p->killed وتحاول إيقاظ الضحيّة بعد لحظة فحص حلقة الضحيّة لـp->killed مباشرةً لكن قبل ندائها sleep. فإن وقعت هذه المشكلة، فلن تلحظ الضحيّة p->killed حتّى يقع الشرط الذي تنتظره. وقد يكون هذا لاحقًا إلى حدٍّ بعيدٍ أو حتّى أبدًا (مثلًا إذا كانت الضحيّة تنتظر مُدخلًا من لوحة التحكّم، لكن المستخدم لا يكتب أيّ مُدخل).

9.7 تمارين

  1. نفّذ شفّيّات العدّ في xv6. اختر بضعة استخداماتٍ للنوم والإيقاظ في xv6 واستبدلها بالشفّيّات. واحكم على النتيجة.
  2. هل تستطيع تنفيذ مخطّط نوم/إيقاظٍ لا يحتاج إلّا نداءً لـsleep() (ربّما بوسائط إضافيّة)، ولا يحتاج إلى شيءٍ يشبه نداء sleep_prepare()؟
  3. أصلح المسابقة المذكورة أعلاه بين kill وsleep، بحيث يؤدّي وقوع kill بعد فحص حلقة نوم الضحيّة للحقل p->killed لكن قبل ندائها sleep إلى ترك الضحيّة نداء النظام الحاليّ.
  4. صمّم خطةً بحيث تفحص كلّ حلقة نومٍ الحقل p->killed حتّى يتمكّن مثلًا، لعمليّةٍ موجودةٍ في مشغّل virtio أن ترجع بسرعةٍ من حلقة while إذا قتلها عمليّةٌ أخرى.