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

نظام الملفات

10. نظام الملفات

الغرض من نظام الملفات هو تنظيم البيانات وتخزينها. وتدعم أنظمة الملفات عادةً مشاركة البيانات بين المستخدمين والتطبيقات، فضلًا عن الثبات (persistence) الذي يضمن بقاء البيانات متاحةً بعد إعادة التشغيل.

يوفّر نظام الملفات في xv6 ملفات ومجلدات وأسماء مسارات شبيهةً بنظام Unix (انظر الفصل 1)، ويخزّن بياناته على قرصٍ لضمان الثبات. ويتعامل نظام الملفات مع عدّة تحديات:

  • يحتاج نظام الملفات إلى بنيات بيانات على القرص لتمثيل شجرة المجلدات والملفات المسماة، ولتسجيل هويّة الكتل التي تحمل محتوى كل ملف، ولتسجيل المناطق الحرة من القرص.
  • قد تعمل عمليّات مختلفة على نظام الملفات في الوقت نفسه، لذا يجب أن تتنسّق شيفرة نظام الملفات للحفاظ على الثوابت.
  • إنّ الوصول إلى القرص أبطأ بمراتب قدر من الوصول إلى الذاكرة، لذا يجب أن يحتفظ نظام الملفات بذاكرة تخزين مؤقت في الذاكرة للكتل الأكثر استخدامًا.
  • يجب أن يدعم نظام الملفات الاستعادة بعد الانهيار. أي أنّه إذا حدث انهيار (كانقطاع التيار الكهربائي مثلًا)، فيجب أن يستمرّ نظام الملفات في العمل بصورة صحيحة بعد إعادة التشغيل. والخطر هو أنّ الانهيار قد يقاطع سلسلةً من التحديثات فيترك بنيات بيانات غير متسقة على القرص (كتلةً مستخدمةً في ملف ومسجَّلةً حرة في الوقت نفسه مثلًا).

يشرح بقيّة هذا الفصل كيف يعالج xv6 التحديات الثلاثة الأولى، بينما يركز الفصل 11 على الاستعادة بعد الانهيار.

10.1 نظرة عامة

تُنظَّم تنفيذ نظام الملفات في xv6 في سبع طبقات، كما يظهر في الشكل 10.1. تقرأ طبقة القرص الكتل من قرص virtio وتكتبها؛ وvirtio قرصٌ محاكى توفّره qemu. وتُخزّن طبقة ذاكرة التخزين المؤقت كتل القرص مؤقتًا وتزامن الوصول إليها، بما يضمن أنّ عمليّة نوويّة واحدة فقط في كل مرة يمكنها تعديل البيانات المخزّنة في أيّ كتلة معيّنة. وتتيح طبقة السجلّات للطبقات الأعلى منها تغليف تحديثات عدّة كتل في معاملة (transaction)، وتضمن تحديث الكتل ذرّيًّا في مواجهة الانهيارات (أي تحديثها كلّها أو لا شيء). وتوفّر طبقة العقد ملفات منفردة، يمثّل كلٌّ منها عقدة (inode) لها رقم i فريد وبعض الكتل التي تحمل بيانات الملف. وتنفّذ طبقة المجلدات كل مجلد بوصفه نوعًا خاصًا من العقد، محتواه سلسلة من مدخلات المجلد، يحتوي كلٌّ منها على اسم الملف ورقمه i. وتوفّر طبقة أسماء المسارات أسماء مسارات هرميّة مثل /usr/rtm/xv6/fs.c، وتحلّها بالبحث التعاودي. وتجرّد طبقة أواصف الملفات العديد من موارد Unix (كالأنابيب والأجهزة والملفات وغيرها) باستخدام واجهة نظام الملفات، ما يبسّط حياة مبرمجي التطبيقات.

الشكل 10.1: طبقات نظام ملفات xv6.

يعرض عتاد القرص تقليديًّا البيانات المخزّنة عليه بوصفها سلسلةً مرقّمةً من القطاعات (sectors) بحجم 512 بايت: القطاع 0 هو أوّل 512 بايت، والقطاع 1 هو الذي يليه، وهكذا. ولا يدعم عتاد القرص القراءة والكتابة إلّا بمضاعفات كاملة من القطاعات. فإن احتاج نظام التشغيل مثلًا إلى تغيير بايت واحد على القرص، فيجب أن يقرأ القطاع المحيط به إلى الذاكرة، ويعدّل البايت الواحد، ثم يكتب القطاع بأكمله ثانيةً إلى القرص. ولهذا السبب، تخصّص أنظمة الملفات مساحة القرص للملفات بوحدات قطاع واحد أو أكثر. وتُسمّى دقّة التخصيص التي يستخدمها نظام الملفات حجم الكتلة. وتستخدم معظم أنظمة الملفات كتلًا متعدّدة القطاعات لكفاءة أعلى؛ ويستخدم xv6 حجم كتلة مقدار قطاعين (يحدّده BSIZE في fs.h).

يحتفظ xv6 بنسخ الكتل التي قرأها إلى الذاكرة في كائنات من النوع struct buf. والبيانات المخزّنة في هذه البنية قد تختلف أحيانًا عن البيانات على القرص: إذ قد تكون لم تُقرأ بعد من القرص (القرص يعمل على ذلك لكنّه لم يُعِد محتوى الكتلة بعد)، أو قد تكون حُدّثت بالبرمجيّات لكن لم تُكتب بعد إلى القرص.

الشكل 10.2: بنية نظام ملفات xv6.

يجب أن يكون لنظام الملفات خطّةٌ لمكان تخزين العقد وكتل المحتوى على القرص. ولهذا يقسّم xv6 القرص إلى عدّة أقسام، كما يظهر في الشكل 10.2. ولا يستخدم نظام الملفات الكتلة 0 (الكتلة 0 تحمل غالبًا شيفرة الإقلاع، وإن كانت في xv6 غير مستخدمةٍ فحسب). وتُسمّى الكتلة 1 الكتلة الفائقة (superblock)؛ وهي تحتوي على البيانات الوصفية لنظام الملفات (حجم نظام الملفات بالكتل، وعدد كتل البيانات، وعدد العقد، وعدد كتل السجلّات). والكتل بدءًا من 2 تحمل السجلّات. وبعد السجلّات تأتي العقد، وعقد متعددة في الكتلة الواحدة. ثم تأتي بعدها كتل خريطة البتّات التي تتتبّع كتل البيانات المستخدمة. والكتل الباقية هي كتل بيانات؛ كلٌّ منها إمّا مسجَّلٌ حرًّا في كتلة خريطة البتّات، أو يحمل محتوى لملف أو مجلد.

يُنشأ نظام ملفات xv6 ابتداءً ببرنامج خارج xv6 يُسمّى mkfs. ويكتب mkfs الكتلة الفائقة، ويهيّئ عقدةً للمجلد الجذر (الفارغ)، ويسجّل جميع العقد وكتل البيانات حرّة.

وفي هذه المرحلة يُرجى قراءة الملفات kernel/buf.h وkernel/fs.h وkernel/fs.c وkernel/sysfile.c وkernel/file.c.

10.2 طبقة ذاكرة التخزين المؤقت

تؤدّي ذاكرة التخزين المؤقت مهمّتين: (1) مزامنة الوصول إلى كتل القرص لضمان وجود نسخة واحدة فقط من الكتلة في الذاكرة وأنّ خيطًا نوويًّا واحدًا فقط يستخدم تلك النسخة في كل مرة؛ (2) تخزين الكتل الأكثر استخدامًا مؤقتًا حتى لا تلزم إعادة قراءتها من القرص البطيء. والشيفرة في bio.c.

تتكوّن الواجهة الرئيسة التي تصدّرها ذاكرة التخزين المؤقت من bread وbwrite؛ الأولى تحصل على مخزن مؤقت (buf) يحتوي على نسخة من كتلة يمكن قراءتها أو تعديلها في الذاكرة، والثانية تكتب مخزنًا مؤقتًا معدَّلًا إلى الكتلة المناسبة على القرص. ويجب على خيط النواة أن يحرّر مخزنًا مؤقتًا باستدعاء brelse عند انتهائه منه. وتستخدم ذاكرة التخزين المؤقت قفل نومٍ خاصًّا بكل مخزن مؤقت لضمان أنّ خيطًا واحدًا فقط في كل مرة يستخدم كل مخزن مؤقت (وبالتالي كل كتلة قرص)؛ فتُعيد bread مخزنًا مؤقتًا مقفلًا، ويحرّر brelse القفل.

لذاكرة التخزين المؤقت عدد ثابت من المخازن المؤقتة لحمل كتل القرص، ما يعني أنّه إذا طلب نظام الملفات كتلةً غير موجودة أصلًا في الذاكرة المؤقتة، فيجب أن تعيد تدوير مخزن مؤقت يحمل حاليًا كتلةً أخرى. وتعيد ذاكرة التخزين المؤقت تدوير المخزن المؤقت الأقلّ استخدامًا مؤخّرًا (LRU) لأجل الكتلة الجديدة. والافتراض هو أنّ المخزن المؤقت الأقلّ استخدامًا مؤخّرًا هو الأقلّ احتمالًا لأن يُستخدم ثانيةً قريبًا.

هناك تفاعلان بين طبقة ذاكرة التخزين المؤقت وطبقة السجلّات: 1) لتنفيذ المعاملات، تحدّث طبقات نظام الملفات الواقعة فوق طبقة السجلّات كتلة قرص باستدعاء log_write، وهو بديلٌ عن bwrite، والطبقة السجلّة فقط هي التي تستدعي bwrite؛ 2) لا يمكن لذاكرة التخزين المؤقت أن تعيد تدوير مخزن مؤقت تستخدمه معاملةٌ حتّى تُثبّت طبقة السجلّات المعاملة (انظر القسم 11.2). ولتجنّب التدوير المبكّر، تستخدم طبقة السجلّات دالّتي ذاكرة التخزين المؤقت bpin وbunpin لتثبيت مخزن مؤقت وإطلاق تثبيته في ذاكرة التخزين المؤقت.

وفي هذه المرحلة يُرجى قراءة الملفين kernel/bio.c وkernel/log.c.

10.3 الشيفرة: ذاكرة التخزين المؤقت

ذاكرة التخزين المؤقت قائمة مترابطة مزدوجة من المخازن المؤقتة. والدالّة binit، التي تستدعيها main، تهيّئ القائمة بالمخازن المؤقتة NBUF في المصفوفة الساكنة buf. وجميع عمليات الوصول الأخرى إلى ذاكرة التخزين المؤقت تشير إلى القائمة المترابطة عبر bcache.head، لا إلى مصفوفة buf.

لكل مخزن مؤقت حقلان حالة مرتبطان به. ويشير الحقل valid إلى أنّ المخزن المؤقت يحتوي على نسخة من الكتلة. ويشير الحقل disk إلى أنّ محتوى المخزن المؤقت قد سُلّم إلى القرص، ما قد يغيّر المخزن المؤقت (بكتابة بيانات من القرص إلى data مثلًا).

تستدعي bread الدالّة bget للحصول على مخزن مؤقت للكتلة المعطاة (bio.c:96). فإذا كان لا بدّ من قراءة المخزن المؤقت من القرص، تستدعي bread الدالّة virtio_disk_rw لفعل ذلك قبل إعادة المخزن المؤقت.

تفحص bget قائمة المخازن المؤقتة بحثًا عن مخزن مؤقت له رقم الجهاز ورقم الكتلة المعطيين (bio.c:64-72). فإذا وُجد مثل هذا المخزن المؤقت، تحصل bget على قفل النوم الخاص به. ثم تعيد bget المخزن المؤقت المقفل.

وإذا لم يكن هناك مخزن مؤقت مخزَّن للكتلة المعطاة، فيجب على bget أن تصنع واحدًا، ربما بإعادة استخدام مخزن مؤقت حمل كتلةً مختلفة. وهي تفحص قائمة المخازن المؤقتة مرة ثانية، بحثًا عن مخزن مؤقت غير مستخدم (b->refcnt = 0)؛ وأيّ مخزن مؤقت من هذا القبيل يمكن استخدامه. وتعدّل bget بيانات المخزن المؤقت الوصفية لتسجيل الجهاز الجديد ورقم الكتلة، وتحصل على قفل النوم الخاص به. ولاحظ أنّ الإحلال b->valid = 0 يضمن أنّ bread ستقرأ بيانات الكتلة من القرص بدلًا من استخدام المحتوى السابق للمخزن المؤقت بشكل خاطئ.

من المهمّ ألّا يكون هناك أكثر من مخزن مؤقت مخزَّن واحد لكل كتلة قرص، لضمان أن يرى القارئون الكتابات، ولأنّ نظام الملفات يستخدم الأقفال على المخازن المؤقتة للمزامنة. وتضمن bget هذا الثابت بإمساكها bcache.lock باستمرار من فحص الحلقة الأولى لوجود الكتلة مخزَّنةً إلى إعلان الحلقة الثانية أنّ الكتلة أصبحت مخزَّنة الآن (بضبط dev وblockno وrefcnt). وهذا يجعل فحص وجود كتلة و(إذا لم تكن موجودة) تعيين مخزن مؤقت لحملها عمليّةً ذرّية.

ومن الآمن لـbget أن تحصل على قفل النوم الخاص بالمخزن المؤقت خارج القسم الحرج bcache.lock، إذ يمنع b->refcnt غير الصفري إعادة استخدام المخزن المؤقت لكتلة قرص مختلفة. ويحمي قفل النوم قراءة محتوى الكتلة المخزَّن وكتابته، بينما يحمي bcache.lock معلومات أيّ الكتل مخزَّنة.

وإذا كانت جميع المخازن المؤقتة مشغولة، فهذا يعني أنّ عددًا كبيرًا جدًا من العمليات ينفّذ نداءات نظام الملفات في وقت واحد؛ وعندها تُطلق bget هلعة (panic). وقد يكون الردّ الأرصن أن تنام حتّى يتحرّر مخزن مؤقت، وإن كان سيترتّب على ذلك احتمال الجمود.

وبمجرّد أن تقرأ bread القرص (إن لزم) وتعيد المخزن المؤقت إلى مستدعيها، يصبح للمستدعي استخدام حصري للمخزن المؤقت ويمكنه قراءة أو كتابة بايتات البيانات. وإذا عدّل المستدعي المخزن المؤقت فعلًا، فيجب أن يستدعي bwrite لكتابة البيانات المعدَّلة إلى القرص قبل تحرير المخزن المؤقت. وتستدعي bwrite الدالّة virtio_disk_rw للتحدّث إلى عتاد القرص.

وعلى المستدعي أن يستدعي brelse لتحرير المخزن المؤقت عند انتهائه منه. (اسم brelse، اختصارٌ لكلمة b-release، غامضٌ لكنّه جدير بالتعلّم: فهو قديم من Unix ويُستخدم أيضًا في BSD وLinux وSolaris.) ويحرّر brelse قفل النوم وينقل المخزن المؤقت إلى مقدّمة القائمة المترابطة (bio.c:128-133). والنقل يجعل القائمة مرتّبةً بحسب مدى حداثة استخدام المخازن المؤقتة (أي تحريرها): أوّل مخزن مؤقت في القائمة هو الأكثر استخدامًا مؤخّرًا، وآخرها هو الأقلّ استخدامًا مؤخّرًا. وتستفيد الحلقتان في bget من هذا: ففحص وجود مخزن مؤقت يجب أن يمرّ على القائمة كلّها في أسوأ الحالات، لكن فحص المخازن المؤقتة الأكثر استخدامًا أوّلًا (بالبدء من bcache.head واتّباع مؤشّرات next) سيقلّل زمن الفحص عند وجود تجانس حسن في مراجع الاستخدام. أمّا الفحص لاختيار مخزن مؤقت لإعادة استخدامه فيختار المخزن المؤقت الأقلّ استخدامًا مؤخّرًا بالمسح إلى الوراء (باتّباع مؤشّرات prev).

10.4 الشيفرة: مخصّص الكتل

يُخزَّن محتوى الملفات والمجلدات في كتل قرص، يجب تخصيصها من مجموعة حرّة. ويحتفظ مخصّص الكتل في xv6 بخريطة بتّات حرّة على القرص، ببتّ واحد لكل كتلة. ويشير البتّ الصفري إلى أنّ الكتلة المقابلة حرّة؛ ويشير البتّ الواحد إلى أنّها مستخدمة. وحين يُنشئ mkfs نظام ملفات جديدًا، يضبط البتّات المقابلة لقطاع الإقلاع والكتلة الفائقة وكتل السجلّات وكتل العقد وكتل خريطة البتّات.

يوفّر مخصّص الكتل دالّتين: balloc تخصّص كتلة قرص جديدة، وbfree تحرّر كتلة. وتنظر الحلقة في balloc عند (fs.c:74) في كل كتلة، بدءًا من الكتلة 0 حتّى sb.size، عدد كتل نظام الملفات. وتبحث عن كتلة بتّها في خريطة البتّات صفر، ما يشير إلى أنّها حرّة. فإذا وجدت balloc مثل هذه الكتلة، حدّثت خريطة البتّات وأعادت الكتلة. وبغرض الكفاءة، قُسّمت الحلقة إلى جزأين. تقرأ الحلقة الخارجية كل كتلة من بتّات خريطة البتّات. وتفحص الحلقة الداخلية جميع بتّات الكتلة الواحدة (BPB) في كتلة خريطة البتّات ذاتها. والتنافس الذي قد يقع إذا حاولت عمليّتان تخصيص كتلة في الوقت نفسه مُتجنَّب بحقيقة أنّ ذاكرة التخزين المؤقت تتيح لعمليّة واحدة فقط استخدام أيّ كتلة خريطة بتّات واحدة في كل مرة (انظر القسم 10.2).

تجد bfree كتلة خريطة البتّات اللازمة وتمسح البتّ اللازم. ومرّة أخرى، الاستخدام الحصري الذي يترتّب على bread وbrelse يتجنّب الحاجة إلى تقفير صريح.

ومثل كثير من الشيفرة الموضَّحة في بقيّة هذا الفصل، يجب استدعاء balloc وbfree داخل معاملة.

10.5 طبقة العقد

قد يكون لمصطلح العقدة (inode) معنيان مرتبطان. فقد يشير إلى بنية البيانات على القرص التي تحتوي على حجم الملف وقائمة أرقام كتل بياناته. أو قد يشير إلى عقدة في الذاكرة، تحتوي على نسخة من العقدة على القرص بالإضافة إلى معلومات إضافية يحتاجها داخل النواة.

العقد على القرص مكدَّسة في منطقة متّصلة من القرص تُسمّى كتل العقد. وكل عقدة بالحجم نفسه، لذا يسهل، نظرًا إلى عدد n، إيجاد العقدة رقم n على القرص. وفي الحقيقة، هذا العدد n، المسمّى رقم العقدة أو الرقم i، هو كيفيّة التعرّف على العقد في التنفيذ.

تُعرَّف العقدة على القرص بـstruct dinode. ويميّز الحقل type بين الملفات والمجلدات والملفات الخاصة (الأجهزة). ويشير النوع صفر إلى أنّ العقدة على القرص حرّة. ويعدّ الحقل nlink عدد مدخلات المجلد التي تشير إلى هذه العقدة، للتعرّف على وجوب تحرير العقدة على القرص وكتل بياناتها. ويسجّل الحقل size عدد بايتات محتوى الملف. وتسجّل مصفوفة addrs أرقام كتل القرص التي تحمل محتوى الملف.

تحتفظ النواة بمجموعة العقد النشطة في الذاكرة في جدول يُسمّى itable؛ وstruct inode هي النسخة في الذاكرة من struct dinode على القرص. وتخزّن النواة عقدةً في الذاكرة فقط إذا كانت هناك مؤشّرات C تشير إلى تلك العقدة. ويعدّ الحقل ref عدد مؤشّرات C التي تشير إلى العقدة في الذاكرة، وتتخلّص النواة من العقدة من الذاكرة إذا هبط عدد الإشارات إلى الصفر. وتحصل الدالّتان iget وiput على مؤشّرات إلى عقدة وتحرّرهما، معدِّلَتين عدد الإشارات. وقد تأتي مؤشّرات العقدة من أواصف الملفات، ومن مجلّدات العمل الحالية، ومن شيفرة نوويّة عابرة مثل kexec.

هناك أربع آليّات قفل أو شبيهة بالقفل في شيفرة العقد في xv6. يحمي itable.lock الثابت الذي يقضي بوجود العقدة في جدول العقد مرّة واحدة على الأكثر، والثابت الذي يقضي بأنّ حقل ref في العقدة في الذاكرة يعدّ عدد المؤشّرات في الذاكرة إلى العقدة. ولكل عقدة في الذاكرة حقل lock يحتوي على قفل نوم، يضمن الوصول الحصري إلى حقول العقدة (كلون الملف مثلًا) وكذلك إلى كتل محتوى ملفها أو مجلّدها. وإنّ ref العقدة، إذا كان أكبر من صفر، يجعل النظام يحافظ على العقدة في الجدول، ولا يعيد استخدام مدخل الجدول لعقدة مختلفة. وأخيرًا، تحتوي كل عقدة على حقل nlink (على القرص ومنسوخ في الذاكرة إذا كانت العقدة في الذاكرة) يعدّ عدد مدخلات المجلد التي تشير إلى الملف؛ ولا يحرّر xv6 عقدةً إذا كان عدد روابطها أكبر من صفر.

مؤشّر struct inode الذي تعيده iget() مضمون الصحة حتّى الاستدعاء المقابل لـiput()؛ فلن تُحذف العقدة، ولن يُعاد استخدام الذاكرة التي يشير إليها المؤشّر لعقدة مختلفة. وتوفّر iget() وصولًا غير حصري إلى العقدة، حتّى يكون هناك العديد من المؤشّرات إلى العقدة نفسها. ويعتمد كثير من أجزاء شيفرة نظام الملفات على هذا السلوك لـiget()، سواءً للحفاظ على مراجع طويلة الأمد إلى العقد (كالملفات المفتوحة والمجلّدات الحالية) أو لمنع التنافس مع تجنّب الجمود في الشيفرة التي تعالج عقدًا متعددة (كالبحث في أسماء المسارات).

قد لا يكون لـstruct inode الذي تعيده iget أيّ محتوى مفيد. ولضمان أنّها تحمل نسخة من العقدة على القرص، يجب على الشيفرة أن تستدعي ilock. وهذا يقفل العقدة (حتّى لا تستطيع عمليّة أخرى استدعاء ilock عليها) ويقرأ العقدة من القرص، إذا لم تكن قد قُرئت بعد. وتحرّر iunlock القفل عن العقدة. والفصل بين الحصول على مؤشّرات العقدة والتقفير يساعد في تجنّب الجمود في بعض المواقف، أثناء البحث في المجلّدات مثلًا. فيمكن لعمليّات متعددة أن تحمل مؤشّر C إلى عقدة أعادتها iget، لكن عمليّة واحدة فقط يمكنها تقفير العقدة في كل مرة.

لا يخزّن جدول العقد إلّا العقد التي تحمل إليها مؤشّرات C في شيفرة النواة أو بنيات بياناتها. ومهمّته الرئيسة هي مزامنة الوصول من عمليّات متعددة. ويخزّن جدول العقد أيضًا العقد كثيرة الاستخدام مؤقتًا، لكنّ التخزين المؤقت ثانوي؛ فإذا استُخدمت عقدة بكثرة، فمن المرجّح أن تحفظها ذاكرة التخزين المؤقت في الذاكرة. والشيفرة التي تعدّل عقدةً في الذاكرة تكتبها إلى القرص بـiupdate.

10.6 الشيفرة: العقد

لتخصيص عقدة جديدة (عند إنشاء ملف مثلًا)، يستدعي xv6 الدالّة ialloc. وialloc شبيهة بـballoc: فهي تتكرّر على بنى العقد على القرص، كتلةً في كل مرّة، باحثةً عن عقدة مسجَّلةٍ حرّة. وحين تجد واحدة، تطالب بها بكتابة type الجديد إلى القرص ثم تعيد مدخلًا من جدول العقد باستدعاء ذيلي لـiget. ويعتمد التشغيل الصحيح لـialloc على حقيقة أنّ عمليّة واحدة فقط في كل مرة يمكن أن تحمل مرجعًا إلى bp: فتستطيع ialloc أن تطمئنّ إلى أنّ عمليّة أخرى لا ترى في الوقت نفسه أنّ العقدة متاحة وتحاول المطالبة بها.

تبحث iget في جدول العقد عن مدخل نشط (ip->ref > 0) له الجهاز ورقم العقدة المطلوبين. فإذا وجدت واحدًا، تعيد مرجعًا جديدًا إلى تلك العقدة (fs.c:258-262). وأثناء فحص iget، تسجّل موضع أوّل خانة فارغة (fs.c:263-264)، التي تستخدمها إذا احتاجت إلى تخصيص مدخل جدول.

يجب على الشيفرة أن تقفل العقدة بـilock قبل قراءة أو كتابة بياناتها الوصفية أو محتواها. ويستخدم ilock قفل نوم لهذا الغرض. وبمجرّد أن تحصل ilock على وصول حصري إلى العقدة، تقرأ العقدة من القرص (على الأرجح من ذاكرة التخزين المؤقت) إذا لزم. وتحرّر الدالّة iunlock قفل النوم، ما قد يُيقظ أيّ عمليّات نائمة.

تحرّر iput مؤشّر C إلى عقدة بخفض عدد الإشارات (fs.c:373). فإذا كان هذا هو المرجع الأخير، صارت خانة العقدة في جدول العقد حرّة الآن ويمكن إعادة استخدامها لعقدة مختلفة.

وإذا رأت iput أنّه لا توجد مراجع بمؤشّرات C إلى عقدة وأنّ العقدة لا روابط إليها (أي أنّها غير موجودة في أيّ مجلد) (fs.c:357)، فيجب تحرير العقدة وكتل بياناتها. فتستدعي iput الدالّة itrunc لاقتطاع الملف إلى صفر بايت، محرّرةً كتل البيانات، ثم تستدعي ifree لضبط نوع العقدة على 0 (غير مخصّصة) على القرص.

يستحقّ بروتوكول التقفير في iput، في الحالة التي يحرّر فيها العقدة، نظرةً أدقّ. وأحد المخاطر أن خيطًا متزامنًا قد يكون منتظرًا في ilock ليستخدم هذه العقدة (ليقرأ ملفًا أو يسرد مجلدًا مثلًا)، ولن يكون مستعدًّا ليجد أنّ العقدة لم تعد مخصّصة. ولا يمكن أن يحدث هذا لأنّه لا سبيل لنداء نظام إلى الحصول على مؤشّر إلى عقدة في الذاكرة إذا لم تكن لها روابط وكان ip->ref قيمته واحدًا. وذلك المرجع الواحد هو المرجع الذي يملكه الخيط المستدعي لـiput. أمّا الخطر الرئيس الآخر فهو أنّ استدعاءً متزامنًا لـialloc قد يختار العقدة نفسها التي تحرّرها iput. ولا يمكن أن يحدث هذا إلّا بعد أن تكتب ifree على القرص حتّى يصير نوع العقدة صفرًا. وتستدعي iput الدالّة ifree أخيرًا، بعد أن تكون قد اقتطعت العقدة، وأطلقت قفل نوم العقدة، وأسقطت مرجعها الخاص. فحينما يتمكّن الخيط المخصِّص من رؤية أنّ العقدة حرّة، تكون iput قد انتهت منها، ويكون التنافس حميدًا.

تستطيع iput() أن تكتب على القرص. وهذا يعني أنّ أيّ نداء نظام يستخدم نظام الملفات قد يكتب على القرص، لأنّ نداء النظام قد يكون الأخير الذي يحمل مرجعًا إلى الملف. وحتّى النداءات التي يبدو أنّها للقراءة فقط، مثل read()، قد تنتهي باستدعاء iput(). وهذا يعني بدوره أنّ نداءات النظام التي للقراءة فقط يجب أن تُغلَّف في معاملات أيضًا إذا كانت تستخدم نظام الملفات.

هناك تفاعل عسير بين iput() والانهيارات. فلا تقتطع iput() ملفًا فورًا عندما يهبط عدد روابطه إلى الصفر، لأنّ عمليّةً ما قد لا تزال تحمل مرجعًا إلى العقدة في الذاكرة: إذ قد تكون العمليّة لا تزال تقرأ الملف وتكتب فيه، لأنّها فتحته بنجاح. لكن إذا حدث انهيار قبل أن تغلق العمليّة الأخيرة واصف الملف، فسيُسجَّل الملف مخصّصًا على القرص دون أن تكون هناك مدخلة مجلد تشير إليه.

تتعامل أنظمة الملفات مع هذه الحالة بإحدى طريقتين. والحلّ البسيط هو أنّ نظام الملفات، عند الاستعادة بعد إعادة التشغيل، يمسح نظام الملفات بأكمله بحثًا عن ملفات مسجَّلةٍ مخصّصة، لا توجد مدخلات مجلّد تشير إليها. فإذا وُجد أيّ ملف من هذا القبيل، أمكنه تحرير تلك الملفات. وهذا هو الحلّ المنفَّذ في xv6. فخلال الإقلاع، تستدعي fsinit الدالّة ireclaim التي تفحص كل عقدة على القرص، باحثةً عن العقد المسجَّلة مخصّصةً (ذات نوع غير صفري) لكن عدد روابطها صفر؛ وهذه هي تمامًا الملفات التي أُزيلت روابطها لكنها ظلّت مفتوحة حين حدث الانهيار. ولكلّ يتيم من هؤلاء، تستدعي ireclaim الدالّة iput لتحرير العقدة وكتل بياناتها.

أمّا الحلّ الثاني فلا يستلزم مسح نظام الملفات. وفي هذا الحلّ، يسجّل نظام الملفات على القرص (في الكتلة الفائقة مثلًا) الرقم i لعقدة ملف هبط عدد روابطه إلى الصفر لكن عدد إشاراته ليس صفرًا. فإذا أزال نظام الملفات الملف عندما يبلغ عدد إشاراته الصفر، حدّث القائمة على القرص بإزالة تلك العقدة منها. وعند الاستعادة، يحرّر نظام الملفات أيّ ملف في القائمة.

10.7 الشيفرة: محتوى العقدة

الشكل 10.3: تمثيل ملف على القرص.

تحتوي بنية العقدة على القرص، struct dinode، على حجم ومصفوفة من أرقام الكتل (انظر الشكل 10.3). وتوجد بيانات العقدة في الكتل المدرجة في مصفوفة dinode المسماة addrs. وتُدرج أوّل NDIRECT كتلة بيانات في أوّل NDIRECT مدخلات المصفوفة؛ وتُسمّى هذه الكتل كتلًا مباشرة. أمّا NINDIRECT كتلة البيانات التالية فتُدرج ليس في العقدة بل في كتلة بيانات تُسمّى الكتلة غير المباشرة. والمدخل الأخير في مصفوفة addrs يعطي عنوان الكتلة غير المباشرة. وبالتالي، يمكن تحميل أوّل 12 كيلوبايت ( NDIRECT x BSIZE) بايت من الملف من كتل مدرجة في العقدة، بينما لا يمكن تحميل 256 كيلوبايت ( NINDIRECT x BSIZE) بايت التالية إلّا بعد استشارة الكتلة غير المباشرة. وهذا تمثيل جيّد على القرص لكنّه معقّد بالنسبة إلى العملاء. وتدير الدالّة bmap هذا التمثيل كي لا تحتاج الإجراءات ذات المستوى الأعلى، مثل readi وwritei، التي سنراها بعد قليل، إلى إدارة هذا التعقيد. وتعيد bmap رقم كتلة القرص الخاصّة بكتلة البيانات رقم bn للعقدة ip. فإذا لم تكن ip تملك مثل هذه الكتلة بعد، تخصّص bmap واحدةً.

تبدأ الدالّة bmap باستبعاد الحالة السهلة: فأوّل NDIRECT كتلة مدرجة في العقدة ذاتها (fs.c:426-434). أمّا NINDIRECT كتلة التالية فمدرجة في الكتلة غير المباشرة عند ip->addrs[NDIRECT]. وتقرأ bmap الكتلة غير المباشرة (fs.c:445) ثم تقرأ رقم كتلة من الموضع الصحيح داخل الكتلة (fs.c:446). فإذا تجاوز رقم الكتلة القيمة NDIRECT+NINDIRECT، هلعت bmap؛ أمّا writei فتحتوي على الفحص الذي يمنع حدوث ذلك (fs.c:551).

تخصّص bmap كتلًا حسب الحاجة. والمدخل الصفري في ip->addrs[] أو في الكتلة غير المباشرة يشير إلى عدم تخصيص أيّ كتلة. وحين تصادف bmap أصفارًا، تستبدلها بأرقام كتل جديدة، مخصّصةً عند الطلب (fs.c:427-428) (fs.c:439-440).

تحرّر itrunc كتل ملف، معيدةً ضبط حجم العقدة على الصفر. فتبدأ itrunc بتحرير الكتل المباشرة (fs.c:470-475)، ثم الكتل المدرجة في الكتلة غير المباشرة (fs.c:480-483)، وأخيرًا الكتلة غير المباشرة ذاتها (fs.c:485-486).

تُيسّر bmap لـreadi وwritei النفاذ إلى بيانات عقدة. فتبدأ readi بالتأكّد من أنّ الإزاحة والعدد لا يتجاوزان نهاية الملف. والقراءات التي تبدأ بعد نهاية الملف تعيد خطأً (fs.c:515-516)، بينما القراءات التي تبدأ عند نهاية الملف أو تجاوزها تعيد بايتات أقلّ من المطلوب (fs.c:517-518). وتعالج الحلقة الرئيسة كل كتلة من الملف، ناسبةً بيانات من المخزن المؤقت إلى dst. أمّا writei فهي مطابقة لـreadi، بثلاثة استثناءات: فالكتابات التي تبدأ عند نهاية الملف أو تجاوزها تُكبّر الملف، حتّى الحدّ الأقصى لحجم الملف (fs.c:551-552)؛ والحلقة تنسخ البيانات إلى المخازن المؤقتة بدلًا من خارجها (fs.c:560)؛ وإذا كانت الكتابة قد مدّت الملف، فيجب على writei أن تحدّث حجمه (fs.c:570-571).

تنسخ الدالّة stati البيانات الوصفية للعقدة إلى بنية stat، التي تُكشف لبرامج المستخدم عبر نداء النظام stat.

10.8 الشيفرة: طبقة المجلدات

يُنفَّذ المجلد داخليًّا بمنزلة ملف إلى حدٍّ كبير. فعقدته من النوع T_DIR وبياناته سلسلة من مدخلات المجلد. وكل مدخلة هي struct dirent، تحتوي على اسم ورقم عقدة. والاسم لا يتجاوز DIRSIZ (14) محرفًا؛ وإن كان أقصر، فإنّه يُنهى ببايت NULL (0). ومدخلات المجلد ذات رقم العقدة صفر حرّة.

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

تبحث الدالّة dirlookup في مجلد عن مدخلة بالاسم المعطى. فإذا وجدت واحدة، تعيد مؤشّرًا إلى العقدة المقابلة، غير مقفلة، وتضبط *poff على إزاحة المدخلة بايتيًّا داخل المجلد، تحسّبًا لرغبة المستدعي في تعديلها. فإذا وجدت dirlookup مدخلةً بالاسم الصحيح، حدّثت *poff وأعادت عقدةً غير مقفلة حصلت عليها عبر iget. وdirlookup هي السبب في أنّ iget تعيد عقدًا غير مقفلة. والمستدعي قد قفل dp، فإن كان البحث عن .، الاسم المستعار للمجلد الحالي، لمحاولة قفل العقدة قبل الإعادة كانت ستُعيد قفل dp وتتجمّد. (وهناك سيناريوهات جمود أكثر تعقيدًا تشمل عمليّات متعددة و..، الاسم المستعار للمجلد الأب؛ فـ. ليست المشكلة الوحيدة.) ويمكن للمستدعي أن يفكّ قفل dp ثم يقفل ip، بما يضمن أنّها لا تحمل إلّا قفلًا واحدًا في كل مرة.

تكتب الدالّة dirlink مدخلة مجلد جديدة بالاسم ورقم العقدة المعطيين في المجلد dp. فإذا كان الاسم موجودًا أصلًا، تعيد dirlink خطأً (fs.c:626-630). وتقرأ الحلقة الرئيسة مدخلات المجلد باحثةً عن مدخلة غير مخصّصة. وحين تجد واحدة، توقف الحلقة مبكّرًا (fs.c:632-637)، مع ضبط off على إزاحة المدخلة المتاحة. وإلّا، فإنّ الحلقة تنتهي وoff مضبوط على dp->size. وفي كلتا الحالتين، تضيف dirlink بعد ذلك مدخلةً جديدة إلى المجلد بالكتابة عند الإزاحة off.

10.9 الشيفرة: أسماء المسارات

يتضمّن البحث عن اسم المسار تتابعًا من الاستدعاءات لـdirlookup، واحدًا لكل مكوّن مسار. وتعالج namei المسار path وتعيد inode المقابل. والدالّة nameiparent صيغة مغايرة: فهي تتوقّف قبل العنصر الأخير، عائدةً عقدة المجلد الأب وناسبةً العنصر الأخير إلى name. وكلاهما يستدعي الدالّة المعمّمة namex لأداء العمل الحقيقي.

يبدأ namex بتقرير أين يبدأ تقييم المسار. فإن بدأ المسار بشرطة مائلة، بدأ التقييم من الجذر؛ وإلّا فمن المجلد الحالي (fs.c:696-699). ثم يستخدم skipelem للنظر في كل عنصر من عناصر المسار بالتتابع (fs.c:701). ويجب أن تبحث كل دورة من الحلقة عن name في العقدة الحالية ip. وتبدأ الدورة بقفل ip والتحقّق من أنّه مجلد، فإن لم يكن كذلك فشل البحث (fs.c:702-706). (وقفل ip لازم ليس لأنّ ip->type يمكن أن يتغيّر تحت الأقدام — فهو لا يمكن — بل لأنّ تشغيل ilock هو ما يضمن تحميل ip->type من القرص.) فإذا كان الاستدعاء هو nameiparent وهذا هو عنصر المسار الأخير، توقف الحلقة مبكّرًا، وفق تعريف nameiparent؛ وقد نُسخ عنصر المسار الأخير أصلًا إلى name، لذا لا يحتاج namex إلّا إلى إعادة ip غير المقفلة. وأخيرًا، تبحث الحلقة عن عنصر المسار باستخدام dirlookup وتستعدّ للدورة التالية بضبط ip = next. وحين تنفد عناصر المسار من الحلقة، تعيد ip.

قد يستغرق الإجراء namex وقتًا طويلًا حتّى يكتمل: فقد يشمل عدة عمليات قرص لقراءة العقد وكتل المجلدات للمجلدات المطروقة في اسم المسار (إذا لم تكن في ذاكرة التخزين المؤقت). وقد صُمّم xv6 بعناية حتّى إذا كان استدعاء namex من خيط نوويّ متوقّفًا على قراءة/كتابة قرص، يتمكّن خيط نوويّ آخر يبحث عن اسم مسار مختلف من المضي قدمًا تزامنيًّا. ويقفل namex كل مجلد في المسار منفردًا، بما يتيح لإجراء عمليات البحث في مجلدات مختلفة أن تمضي قدمًا على التوازي.

يُدخِل هذا التزامن بعض التحديات. فمثلًا، بينما يبحث خيط نوويّ عن اسم مسار، قد يكون خيط نوويّ آخر غيَّر شجرة المجلدات بإزالة رابط مجلد. وأحد المخاطر المحتملة أن يكون البحث يجري في مجلد حذفه خيط نوويّ آخر وأُعيد استخدام كتله لمجلد أو ملف آخر.

يتجنّب xv6 مثل هذه التنافسات. فمثلًا، عند تنفيذ dirlookup في namex، يحمل خيط البحث قفل المجلد وتعيد dirlookup عقدةً حُصل عليها باستخدام iget. وتزيد iget عدد إشارات العقدة. وفقط بعد تلقّي العقدة من dirlookup يحرّر namex قفل المجلد. وعندئذٍ قد يزيل خيط آخر رابط العقدة من المجلد، لكن xv6 لن يحذف العقدة بعد، لأنّ عدد إشارات العقدة لا يزال أكبر من صفر.

وهناك خطر آخر هو الجمود. فمثلًا، يشير next إلى العقدة نفسها التي يشير إليها ip عند البحث عن “.”، وقفل next قبل تحرير القفل على ip سيؤدّي إلى جمود. ولتجنّب هذا الجمود، يفكّ namex قفل المجلد قبل الحصول على قفل على next. وهنا أيضًا نرى سبب أهميّة الفصل بين iget وilock.

10.10 طبقة أواصف الملفات

أحد الجوانب الجميلة في واجهة Unix هو أنّ معظم الموارد في Unix تُمثَّل بوصفها ملفات، بما في ذلك الأجهزة كالطرفية والأنابيب، وبالطبع الملفات الحقيقية. وطبقة أواصف الملفات هي الطبقة التي تحقّق هذا التوحيد.

يمنح xv6 كل عمليّة جدولها الخاص بالملفات المفتوحة، أو أواصف الملفات، كما رأينا في الفصل 1. ويمثّل كل ملف مفتوح بـstruct file، وهو غلاف حول عقدة أو أنبوب، بالإضافة إلى إزاحة قراءة/كتابة. وكل استدعاء لـopen يُنشئ ملفًا مفتوحًا جديدًا (struct file جديدًا): فإن فتحت عمليّات متعددة الملف نفسه بصورة مستقلّة، كانت للنُسخ المختلفة إزاحات قراءة/كتابة مختلفة. ومن جهة أخرى، يمكن لملف مفتوح واحد (struct file نفسه) أن يظهر مرات متعددة في جدول ملفات عمليّة واحدة، وفي جداول ملفات عمليّات متعددة أيضًا. ويحدث هذا إذا استخدمت عمليّةٌ ما الدالّة open لفتح الملف ثم أنشأت أسماء مستعارة باستخدام dup أو شاركته مع ابن باستخدام fork. ويعقّب عدد الإشارات عدد المراجع إلى ملف مفتوح معيّن. ويمكن فتح الملف للقراءة أو للكتابة أو لهما معًا. ويتعقّب الحقلان readable وwritable ذلك.

تُحفظ جميع الملفات المفتوحة في النظام في جدول ملفات عامّ، هو ftable. ويحتوي جدول الملفات على دوالّ لتخصيص ملف (filealloc)، وإنشاء مرجع مكرّر (filedup)، وتحرير مرجع (fileclose)، وقراءة البيانات وكتابتها (fileread وfilewrite).

تتبع الثلاثة الأولى الشكل المألوف الآن. تفحص filealloc جدول الملفات بحثًا عن ملف لا مرجع له (f->ref == 0) وتعيد مرجعًا جديدًا؛ وتزيد filedup عدد الإشارات؛ وتخفض fileclose إيّاه. وحين يبلغ عدد إشارات ملف الصفر، يحرّر fileclose الأنابيب أو العقدة الكامنة تحته، حسب النوع.

تنفّذ الدالّات filestat وfileread وfilewrite عمليّات fstat وread وwrite على الملفات. ولا يُسمح بـfilestat إلّا على العقد، وهي تستدعي stati. وتتحقّق fileread وfilewrite من أنّ العمليّة مسموحة بنمط الفتح ثم تمرّران النداء إلى تنفيذ الأنابيب أو العقدة. فإن مثل الملف عقدةً، استخدمت fileread وfilewrite إزاحة القراءة/الكتابة بوصفها إزاحة العمليّة ثم تزيحانها (file.c:122-123) (file.c:162-163). وليس للأنابيب مفهوم الإزاحة. وتذكّر أنّ دوالّ العقدة تتطلّب من المستدعي أن يتولّى التقفير (file.c:94-96) (file.c:121-124) (file.c:161-164). ولتقفير العقدة الأثر الجانبي المريح أنّ إزاحتي القراءة والكتابة تُحدَّثان ذرّيًّا، حتّى لا تصطدم كتابات متعددة إلى الملف نفسه في وقت واحد ببعضها فتلغي بيانات بعضها، وإن كانت كتاباتها قد تتشابك في النهاية.

10.11 الشيفرة: نداءات النظام

بالدوالّ التي توفّرها الطبقات الأدنى، يصبح تنفيذ معظم نداءات النظام تافهًا (انظر (sysfile.c)). وهناك نداءات قليلة تستحقّ نظرة أقرب.

تعدّل الدالّتان sys_link وsys_unlink المجلدات، منشئتين مراجع إلى العقد أو مذيلتين إياها. وهما مثال آخر جيّد على قوة استخدام المعاملات. تبدأ sys_link بجلب معطيَيها، نصّين old وnew. وبافتراض أنّ old موجود، وليس مجلدًا، وله أقلّ من NLINK_MAX رابط (sysfile.c:133-145)، تزيد sys_link عداده ip->nlink (sysfile.c:151). والحدّ موجود لأنّ nlink حقل 16-بت في العقدة على القرص؛ فدون هذا الفحص، لكان إنشاء عدد كبير جدًا من الروابط سيُفيض العدّ. ثم تستدعي sys_link الدالّة nameiparent للعثور على المجلد الأب وعنصر المسار الأخير لـnew، وتُنشئ مدخلة مجلد جديدة تشير إلى عقدة old (sysfile.c:165). ويجب أن يكون المجلد الأب الجديد موجودًا وعلى الجهاز نفسه الذي توجد عليه العقدة القائمة: فأرقام العقد لها معنى فريد على قرص واحد فقط. فإن حدث خطأ من هذا القبيل، فيجب على sys_link أن ترجع فتُخفض ip->nlink.

تبسّط المعاملات التنفيذ لأنّه يتطلّب تحديث كتل قرص متعددة، لكن لا يجب أن نقلق بشأن الترتيب الذي نُجريها به. فإنها إمّا أن تنجح كلّها أو لا تنجح واحدة منها. فمثلًا، دون معاملات، كان تحديث ip->nlink قبل إنشاء الرابط سيضع نظام الملفات مؤقتًا في حالة غير آمنة، وأمكن لانهيار في الأثناء أن يُسبّب فوضى. أمّا بوجود المعاملات فلا داعي للقلق بشأن هذا.

تُنشئ sys_link اسمًا جديدًا لعقدة قائمة. والدالّة create تُنشئ اسمًا جديدًا لعقدة جديدة. وهي تعميم لنداءات النظام الثلاثة لإنشاء الملفات: فـopen مع العَلَم O_CREATE يُنشئ ملفًا عاديًّا جديدًا، وmkdir تُنشئ مجلدًا جديدًا، وmkdev تُنشئ ملف جهاز جديد. ومثل sys_link، تبدأ create باستدعاء nameiparent للحصول على عقدة المجلد الأب. ثم تستدعي dirlookup للتحقّق ممّا إذا كان الاسم موجودًا أصلًا (sysfile.c:280). فإن كان الاسم موجودًا، فسلوك create يتوقّف على نداء النظام الذي تُستخدم من أجله: فـopen له دلالات مختلفة عن mkdir وmkdev. فإن كانت create تُستخدم نيابةً عن open (type == T_FILE) والاسم الموجود هو نفسه ملف عادي، تعامل open مع ذلك بوصفه نجاحًا، وكذلك تفعل create (sysfile.c:284). وإلّا فهو خطأ (sysfile.c:285-286). فإن لم يكن الاسم موجودًا أصلًا، تخصّص create الآن عقدةً جديدة بـialloc. فإن كانت العقدة الجديدة مجلدًا، تهيّئها create بمدخلتَي . و... وأخيرًا، الآن بعد أن هُيّئت البيانات على الوجه الصحيح، تستطيع create أن تربطها في المجلد الأب (sysfile.c:306). وتحمل create، مثل sys_link، قفلَي عقدتين في الوقت نفسه: ip وdp. ولا احتمال للجمود لأنّ العقدة ip مخصّصةٌ للتوّ: فلن تحمل أيّ عمليّة أخرى في النظام قفل ip ثم تحاول قفل dp.

باستخدام create، يسهل تنفيذ sys_open وsys_mkdir وsys_mknod. وsys_open هي الأكثر تعقيدًا، لأنّ إنشاء ملف جديد ليس إلّا جزءًا صغيرًا مما يمكنها فعله. فإن مُرّر إلى open العَلَم O_CREATE، تستدعي create. وإلّا، تستدعي namei. وتعيد create عقدةً مقفلة، بخلاف namei، لذا يجب على sys_open أن تقفل العقدة بنفسها. وهذا يوفّر موضعًا مريحًا للتحقّق من أنّ المجلدات لا تُفتح إلّا للقراءة، لا للكتابة. وبافتراض أنّ العقدة قد حُصِل عليها بطريقة أو بأخرى، تخصّص sys_open ملفًا وواصف ملف (sysfile.c:368) ثم تملأ الملف (sysfile.c:380-385). ولاحظ أنّ أيّ عمليّة أخرى لا تستطيع الوصول إلى الملف المهيّأ جزئيًّا لأنّه في جدول العمليّة الحالية فقط.

فحص الفصل 9 تنفيذ الأنابيب قبل أن يكون لدينا نظام ملفات أصلًا. وتربط الدالّة sys_pipe ذلك التنفيذ بنظام الملفات بتوفير طريقة لإنشاء زوج أنابيب. ومعيطها مؤشّر إلى مساحة لعددين صحيحين، تُسجّل فيه الواصفين الجديدين. ثم تخصّص الأنبوب وتنصّب واصفي الملف.

10.12 العالم الواقعي

ذاكرة التخزين المؤقت في نظام تشغيل واقعيّ أعقد بكثير من ذاكرة xv6، لكنّها تؤدّي الغرضين نفسهما: التخزين المؤقت ومزامنة الوصول إلى القرص. وتستخدم ذاكرة التخزين المؤقت في xv6 سياسة إخلاء “الأقلّ استخدامًا مؤخّرًا” (LRU)؛ وهناك سياسات أعقد كثيرًا يمكن تنفيذها، كلٌّ منها جيّدة لبعض أحمال العمل وليست بالجودة ذاتها للبعض الآخر. وذاكرة تخزين مؤقت LRU أكثر كفاءةً كانت ستُلغي القائمة المترابطة، واستخدمت بدلًا منها جدول تجزئة للبحث وكومة (heap) لإخلاء LRU. وتُدمج ذاكرات التخزين المؤقت الحديثة عادةً مع نظام الذاكرة الافتراضيّة لدعم الملفات المُقابَلة في الذاكرة (memory-mapped).

يستخدم xv6 تخطيطًا على القرص للعقد والمجلدات شبيهًا بما كان في Unix المبكّرة؛ وقد ثبتت هذه المقاربة ثباتًا لافتًا على مرّ السنين. وتستخدم UFS/FFS في بي إس دي وext2/ext3 في Linux بنيات البيانات نفسها في الجوهر. وأكثر أجزاء تخطيط نظام الملفات عجزًا هو المجلد، الذي يستلزم مسحًا خطيًّا لكل كتل القرص في كل عملية بحث. وهذا معقول حين تكون المجلدات كتل قرص قليلة فقط، لكنّه مكلف للمجلدات التي تحمل ملفات كثيرة. فأنظمة مثل ويندوز من مايكروسوفت (إن تي إف إس)، وماك أو إس (إتش إف إس)، وسولاريس (زد إف إس)، لأذكر بعضها فقط، تنفّذ المجلد بوصفه شجرة متوازنة من الكتل على القرص. وهذا معقّد لكنّه يضمن عمليات بحث في المجلدات بزمن لوغاريتمي.

يشترط xv6 أن يستقيم نظام الملفات على جهاز قرص واحد وألّا يتغيّر حجمه. وبينما تدفع قواعد البيانات الضخمة والملفات المتعدّدة الوسائط متطلّبات التخزين إلى الأعلى باستمرار، تطوّر أنظمة التشغيل طرقًا لإزالة عنق الزجاجة المتمثّل في “قرص واحد لكل نظام ملفات”. والمنهج الأساس هو دمج أقراص عديدة في قرص منطقيّ واحد. وما زالت الحلول العتاديّة مثل RAID هي الأكثر شعبيّة، لكنّ الاتجاه الحالي يميل إلى تنفيذ أكبر قدر ممكن من هذا المنطق في البرمجيّات. وتتيح هذه التنفيذات البرمجيّة عادةً وظائف غنيّة مثل تكبير الجهاز المنطقيّ أو تصغيره بإضافة أقراص أو إزالتها أثناء التشغيل. وبالطبع، تتطلّب طبقة تخزين تستطيع التكبير أو التصغير أثناء التشغيل نظام ملفات يستطيع ذلك أيضًا: فمصفوفة كتل العقد ثابتة الحجم التي يستخدمها xv6 لن تُجدي نفعًا كثيرًا في هذه البيئات. وفصل إدارة الأقراص عن نظام الملفات قد يكون الأنقى تصميمًا، لكنّ الواجهة المعقّدة بينهما دفعت بعض الأنظمة، مثل ZFS من Sun، إلى دمجهما.

يفتقر نظام ملفات xv6 إلى العديد من الميزات الأخرى لأنظمة الملفات الحديثة؛ فهو مثلًا لا يدعم اللقطات والنسخ الاحتياطي التزايدي.

تتيح أنظمة Unix الحديثة الوصول إلى العديد من أنواع الموارد بالنداءات البرمجية نفسها المستخدمة للتخزين على القرص: الأنابيب المسماة، والاتصالات الشبكيّة، وأنظمة الملفات الشبكيّة ذات الوصول البعيد، وواجهات المراقبة والتحكّم مثل /proc. وبدلًا من تعليمات if في fileread وfilewrite في xv6، تعطي هذه الأنظمة عادةً كل ملف مفتوح جدولًا من مؤشّرات الدوالّ، واحدًا لكل عمليّة، وتستدعي مؤشّر الدالّة لاستدعاء تنفيذ تلك العقدة للنداء. وتوفّر أنظمة الملفات الشبكيّة وأنظمة الملفات على مستوى المستخدم دوالّ تحوّل تلك النداءات إلى نداءات إجراء بعيد (RPC) على الشبكة وتنتظر الردّ قبل الإعادة.

10.13 تمارين

  1. لماذا تهلع balloc؟ وهل يستطيع xv6 التعافي؟
  2. لماذا تهلع ialloc؟ وهل يستطيع xv6 التعافي؟
  3. لماذا لا تهلع filealloc عندما تنفد الملفات؟ ولماذا هذا أكثر شيوعًا وعليه جدير بالمعالجة؟
  4. لنفترض أنّ عمليّة أخرى أزالت رابط الملف المقابل لـip بين نداءَي sys_link إلى iunlock(ip) وdirlink. فهل سيُنشأ الرابط بصورة صحيحة؟ ولماذا أو لماذا لا؟
  5. تجري create أربع نداءات دوالّ (واحد لـialloc وثلاثة لـdirlink) تستلزم نجاحها. فإن لم ينجح أيٌّ منها، استدعت create الدالّة panic. فلماذا هذا مقبول؟ ولماذا لا يمكن أن يفشل أيٌّ من نداءاتها الأربعة؟
  6. تستدعي sys_chdir الدالّة iunlock(ip) قبل iput(cp->cwd)، التي قد تحاول قفل cp->cwd، ومع ذلك فإنّ تأجيل iunlock(ip) حتّى بعد iput لا يُسبّب جمودًا. فلماذا لا؟
  7. نفّذ نداء النظام lseek. وسيتطلّب دعم lseek أيضًا أن تعدّل filewrite لتملأ ثغرات الملف بالأصفار إذا ضبط lseek الإزاحة off إلى ما بعد f->ip->size.
  8. أضف O_TRUNC وO_APPEND إلى open، حتّى يعمل المشغّلان > و>> في الصدفة.
  9. عدّل نظام الملفات ليدعم الروابط الرمزيّة.
  10. عدّل نظام الملفات ليدعم الأنابيب المسماة.
  11. عدّل نظام الملفات ونظام الذاكرة الافتراضيّة ليدعم الملفات المُقابَلة في الذاكرة.