ola.ab

الأعضاء
  • المساهمات

    17
  • تاريخ الانضمام

  • تاريخ آخر زيارة

السُّمعة بالموقع

3 Neutral

1 متابع

  1. طوّر باحثون في مركز أبحاث (Xerox Palo Alto Research Center أو اختصارًا PARC) شبكة الإيثرنت (Ethernet) في منتصف السبعينات، ثم أصبحت في النهاية تقنية الشبكات المحلية المهيمنة، التي انبثقت عن مجموعة من التقنيات المنافسة، وتتنافس اليوم بصورةٍ أساسية مع الشبكات اللاسلكية 802.11، ولكنها لا تزال تحظى بشعبية كبيرة في شبكات الحرم الجامعي ومراكز البيانات. الاسم الأعم للتقنية الكامنة وراء الإيثرنت هو تحسس الحامل، والوصول المتعدد مع كشف التصادم (Carrier Sense, Multiple Access with Collision Detect أو اختصارًا CSMA / CD). شبكة الإيثرنت عبارة عن شبكةٍ متعددة الوصول، مما يعني أن مجموعةً من العقد ترسل وتستقبل الإطارات عبر رابطٍ (link) مشترك، لذلك يمكنك التفكير في شبكة الإيثرنت على أنها مثل الحافلة التي لديها محطات متعددة متصلة بها. يعني المصطلح carrier sense في CSMA / CD أن جميع العقد يمكنها التمييز بين الرابط الخامل والرابط المشغول، ويعني المصطلح collision detect أن العقدة تستمع أثناء الإرسال، فيمكنها اكتشاف ما إذا كان الإطار الذي ترسله قد تداخل أو تصادم مع إطارٍ مُرسَل بواسطة عقدة أخرى. تعود جذور الإيثرنت إلى شبكة رزمٍ راديوية قديمة تسمى ألوها (Aloha) طُوِّرت في جامعة هاواي لدعم اتصالات الحاسوب عبر جزر هاواي. إن المشكلة الأساسية التي تواجهها شبكة إيثرنت، مثل شبكة ألوها، هي كيفية التوسط في الوصول إلى وسيط مشترك بصورة عادلة وفعالة (كان الوسيط في شبكة ألوها هو الغلاف الجوي، بينما الوسيط في شبكة إيثرنت في الأصل هو الكبل المحوري). الفكرة الأساسية في كلٍّ من شبكتَي ألوها وإيثرنت هي خوارزمية تتحكم في وقت إرسال كل عقدة. أصبحت روابط إيثرنت الحديثة الآن إلى حد كبير من نقطة لنقطة، أي أنها تصل مضيفًا واحد بمبدّل إيثرنت (Ethernet switch)، أو أنها تربط المبدّلات ببعضها، نتيجة لذلك لا تُستخدَم خوارزمية الوصول المتعدد كثيرًا في شبكات إيثرنت السلكية حاليًا. لكن يُستخدم البديل الآن في الشبكات اللاسلكية، مثل شبكات 802.11 (المعروفة أيضًا باسم Wi-Fi). اخترنا وصف الخوارزمية الكلاسيكية هنا نظرًا للتأثير الهائل لشبكة إيثرنت، ثم سنشرح كيف تكيفت مع شبكة Wi-Fi في القسم التالي، وسنركز على كيفية عمل رابط إيثرنت واحد في الوقت الحالي. انضمت شركتا Digital Equipment Corporation و Intel Corporation إلى مركز Xerox لتحديد معيار إيثرنت بسرعة 10 ميجابت في الثانية في عام 1978. ثم شكّل هذا المعيار أساسًا لمعيار IEEE 802.3، والذي يحدد أيضًا مجموعة أكبر بكثير من الوسائط الفيزيائية التي يمكن لشبكة إيثرنت العمل عليها، بما في ذلك إصدارات 100 ميجابت في الثانية و 1 جيجابت في الثانية و 10 جيجابت في الثانية و 40 جيجابت في الثانية و 100 جيجابت في الثانية. الخصائص الفيزيائية (Physical Properties) نُفِّذت مقاطع إيثرنت في الأصل باستخدام كبل محوري بطول يصل إلى 500 متر، وكان هذا الكبل مشابهًا للنوع المستخدَم في تلفاز الكابل. تستخدم شبكة الإيثرنت الحديثة أزواجًا نحاسية ملتوية (twisted copper pairs) وعادةً ما يكون نوعًا معينًا يُعرف باسم Category 5، أو الألياف الضوئية، وفي بعض الحالات يمكن أن يكون أطول بكثير من 500 متر. يتصل المضيفون بمقطع إيثرنت من خلال وصله به. يكتشف جهاز الإرسال والاستقبال (transceiver)، وهو جهاز صغير متصل مباشرة بالقابس (tap)، إذا كان الخط خاملًا، كما أنه يقود الإشارة عندما يرسل المضيف، ويستقبل الإشارات الواردة. جهاز الإرسال والاستقبال بدوره متصلٌ بمحوّل (adaptor) إيثرنت يوصَل بالمضيف. يظهر هذا الإعداد (configuration) في الشكل التالي: يمكن ربط مقاطع الإيثرنت المتعددة معًا بواسطة المكرّرات (repeaters)، أو جهازٍ متعدد المنافذ مغايرٍٍ عن المكرّر، يسمى الموزع (hub). المكرّر هو جهاز يمرر الإشارات الرقمية، كمكبّر صوت يمرر الإشارات التناظرية، حيث لا تفهم المكررات البتات أو الإطارات، ولا يمكن وضع أكثر من أربعة مكررات بين أي زوج من الأجهزة المضيفة، مما يعني أن شبكة الإيثرنت الكلاسيكية يبلغ إجمالي وصولها 2500 متر فقط، فاستخدام مكررين فقط على سبيل المثال بين أي زوج من الأجهزة المضيفة يدعم ضبطًا مشابهًا للضبط الموضح في الشكل التالي، أي يمتد المقطع الأساسي أسفل المبنى مع وجود مقطعٍ في كل طابق:ِ تُبَث أي إشارة توضع على شبكة الإيثرنت بواسطة مضيف عبر الشبكة بأكملها، أي أن الإشارة تنتشر في كلا الاتجاهين، وتعيد المكررات والموزعات توجيه الإشارة على جميع المقاطع الصادرة. تمتص الوصلات النهائية (Terminators) المتصلة بنهاية كل مقطع الإشارة وتمنعها من الارتداد والتداخل مع الإشارات اللاحقة. استخدمت مواصفاتُ إيثرنت الأصلية مخططَ ترميز مانشستر الموضَّح سابقًا، بينما يُستخدم تشفير 4B / 5B (أو مخطط 8B / 10B المماثل) اليوم على شبكات إيثرنت عالية السرعة. من المهم أن تفهم أنه إذا كانت شبكة إيثرنت معينة تمتد على مقطع واحد، أو على تسلسل خطي من المقاطع المتصلة بواسطة مكررات، أو مقاطع متعددة متصلة في إعداد شبكة نجمة (star)، فإن البيانات التي يرسلها أي مضيف واحد على شبكة الإيثرنت هذه تصل إلى جميع المضيفين الآخرين، وهذه هي الأخبار الجيدة، أما النبأ السيئ فهو أن كل هؤلاء المضيفين يتنافسون للوصول إلى نفس الرابط، ونتيجة لذلك يقال إنهم في نفس مجال التصادم (collision domain). يتعلق الجزء متعدد الوصول من الإيثرنت بالتعامل مع المنافسة على الرابط الذي ينشأ في مجال التصادم. بروتوكول الوصول (Access Protocol) وجّه انتباهك الآن إلى الخوارزمية التي تتحكم في الوصول إلى رابط إيثرنت مشترك. يُطلق على هذه الخوارزمية اسم التحكم في الوصول إلى الوسائط (media access control أو اختصارًا MAC) الخاص بشبكة إيثرنت، ويُنفَّذ عادةً في الأجهزة الموجودة على محوّل الشبكة. لن نشرح العتاد في حد ذاته، ولكن بدلًا من ذلك سنركز على الخوارزمية التي تنفّذها، وسنشرح أولًا صيغة إطار وعناوين إيثرنت. صيغة الإطار (Frame Format) يُعرَّف كل إطار من إطارات إيثرنت بالتنسيق الوارد في الشكل الآتي، حيث تسمح المقدمة (preamble) ذات 64 بت للمستقبل بأن يتزامن مع الإشارة، وهذه المقدمة هي سلسلة من الأصفار والواحدات المتناوبة. يُحدَّد كل من مضيفَي المصدر والوجهة بعنوان 48 بت. يعمل حقل نوع الرزمة كمفتاح فك دمج (demultiplexing key)، حيث يحدِّد أي من بروتوكولات المستوى الأعلى التي يجب تسليم هذا الإطار إليها. يحتوي كل إطار على ما يصل إلى 1500 بايت من البيانات، ويجب أن يحتوي الإطار على 46 بايتًا على الأقل من البيانات، حتى لو كان هذا يعني أن المضيف يجب أن يحشو الإطار قبل إرساله، والسبب في هذا الحجم الأدنى للإطار هو أن الإطار يجب أن يكون طويلًا بما يكفي لاكتشاف التصادم. أخيرًا، يتضمن كل إطار 32 بت لفحص التكرار الدوري (CRC) من أجل كشف الأخطاء. بروتوكول إيثرنت عبارة عن بروتوكول تأطير موجَّهٌ بالبت مثل بروتوكول HDLC الموضّح سابقًا. لاحظ أنه من منظور المضيف، يحتوي إطار إيثرنت على ترويسة ذات 14 بايتًا: عنوانان مؤلفان من 6 بايتات وحقل نوع مؤلف من 2 بايت، ويضيف محولُ الإرسال المقدمةََ وحقل CRC قبل الإرسال، ثم يزيلهما محوّل الاستقبال. العناوين (Addresses) يملك كل مضيفٍ على شبكة إيثرنت، بالأحرى كل مضيف إيثرنت في العالم، عنوانَ إيثرنت فريد. ينتمي العنوان إلى المحوّل وليس إلى المضيف، ويُحرَق عادةً على الذاكرة ROM. تُطبع عناوين إيثرنت عادةً في شكلٍ قابلٍ للقراءة من خلال سلسلةٍ من ستة أرقام مفصول بينها بنقطتين. يقابل كل رقم بايتًا واحدًا من العنوان المكوّن من 6 بايتات، حيث يُوفَّر من خلال زوجٍ من الأرقام الست عشرية، أي رقمٌ لكل 4 بتات من البايت الواحد، وتُهمل الأصفار البادئة. العنوان 8:0:2b:e4:b1:2 على سبيل المثال هو تمثيلٌ قابل للقراءة لعنوان الإيثرنت التالي: 00001000 00000000 00101011 11100100 10110001 00000010 تُخصَّص بادئةٌ (prefix) مختلفة لكل مُصنِّعٍ لأجهزة إيثرنت والتي يجب أن تُضاف في بداية العنوان الموجود على كل محولٍ مُنشَأ لضمان حصول كل محول على عنوان فريد، حيث تُسنَد بادئة مكونة من 24 بت 080020 أو 8:0:20 للأجهزة الدقيقة المتقدمة (Advanced Micro Devices) على سبيل المثال، ثم تتأكد الشركة المصنّعة أن لواحق (suffixes) العنوان التي ينتجها فريدة. يُستقبَل كل إطار مُرسَل عبر إيثرنت بواسطة كل محول متصل بشبكة إيثرنت، ويتعرّف كل محول على تلك الإطارات الموجهة إلى عنوانه ويمرر تلك الإطارات فقط إلى المضيف، ولكن يمكن أيضًا برمجة المحول ليعمل في الوضع العشوائي (promiscuous mode)، حيث يسلّم جميع الإطارات المستلمة إلى المضيف في هذه الحالة، لكن ليس هذا الوضع العادي. يُعامَل أيضًا عنوان إيثرنت الذي يتكون من كل الواحدات كعنوان بث إذاعي (broadcast address) بالإضافة إلى عناوين البث الأحادي (unicast addresses). تمرر جميعُ المحوّلات الإطاراتِ الموجهة إلى عنوان البث الإذاعي إلى المضيف. وبالمثل، يُضبَط البت الأول من العنوان بالقيمة 1 ولكنه ليس عنوان بث إذاعي بحيث يُسمى عنوان البث المتعدد (multicast address)، يمكن لمضيفٍ معين برمجة محوّله لقبول مجموعة من عناوين البث المتعدد. تُستخدم عناوين البث المتعدد لإرسال رسائل إلى مجموعة فرعية من المضيفين على شبكة إيثرنت (جميع خواديم الملفات على سبيل المثال)، حيث يستقبل محول الإيثرنت جميع الإطارات ويقبل ما يلي: إطارات موجهة إلى عنوانها الخاص. إطارات موجهة إلى عنوان البث الإذاعي. إطارات موجهة إلى عنوان البث المتعدد، إذا وُجِّه للاستماع إلى هذا العنوان. جميع الإطارات إذا وُضَعت في الوضع العشوائي. ولكنه يمرّر إلى المضيف فقط الإطارات التي يقبلها. خوارزمية المرسل (Transmitter Algorithm) إن جانب المستقبل من بروتوكول إيثرنت بسيط، وتُعرَّف خوارزمية المرسل على النحو التالي: ينقل المحوّل الإطار على الفور دون وجود تفاوضٍ مع المحولات الأخرى عندما يملك المحول إطارًا لإرساله ويكون الخط خاملًا. يعني الحد الأعلى البالغ 1500 بايت في الرسالة أن المحول يمكنه شغل الخط لفترة زمنية ثابتة فقط. إذا ملك المحولُ إطارًا لإرساله ولكن الخط مشغول فإنه ينتظر أن يصبح الخط خاملًا ثم يرسله على الفور، حيث تنتظر جميع المحولات 9.6 ميكرو ثانية بعد نهاية إطار واحد قبل البدء في إرسال الإطار التالي، وينطبق هذا على كل من مرسل الإطار الأول والعقد التي تستمع إلى الخط حتى يصبح خاملًا. يُقال إن إيثرنت هو بروتوكول واحد ثابت (1-persistent) لأنه مع وجود إطارٍ لإرساله، يرسل المحول هذا الإطار باحتمال 1 عندما يصبح الخط المشغول خاملًا، وترسل خوارزمية p-persistent باحتمال 0≤ p ≤1 بعد أن يصبح الخط خاملًا وتتأخر باحتمال q = 1 - p. السبب وراء اختيار p < 1 هو أنه قد يكون هناك محولات متعددة في انتظار أن يصبح الخط المشغول خاملًا، ولا نريد أن يبدأ كل منهم بالإرسال في نفس الوقت. إذا أرسل كل محول على الفور باحتمال، 33% مثلًا، فيمكن أن ينتظر ما يصل إلى ثلاثة محولات للإرسال بحيث يبدأ محولٌ واحد فقط الإرسال عندما يصبح الخط خاملًا، ولكن يرسل محول إيثرنت دائمًا وفورًا بعد ملاحظة أن الشبكة أصبحت خاملة وهذا فعالٌ جدًا. قد تتساءل عن المدة التي يتعين على المرسل، الذي يقرر التأجيل، أن ينتظرها قبل أن يتمكن من الإرسال بالنسبة لبروتوكولات p-persistent عندما تكون p < 1. كانت الإجابة بالنسبة لشبكة ألوها، التي طوَّرت في الأصل هذا النمط من البروتوكول، هي تقسيم الوقت إلى فترات منفصلة، بحيث تتوافق كل فترة مع طول الوقت الذي يستغرقه إرسال إطار كامل، وكلما كان للعقدة إطار لإرساله واستشعرت فترةً فارغة (خاملة)، فإنها ترسل باحتمال p وتؤجل حتى الفترة التالية ذات الاحتمال q = 1 - p. إذا كانت الفترة التالية فارغة أيضًا، تقرر العقدة مرة أخرى الإرسال أو التأجيل، مع الاحتمالين p و q على التوالي. إذا لم تكن الفترة التالية فارغة، أي أن بعض المحطات الأخرى قررت الإرسال، فإن العقدة تنتظر ببساطة الفترة التالية الخاملة وتتكرر الخوارزمية. أما بالنسبة لشبكة إيثرنت، فنظرًا لعدم وجود تحكم مركزي، فمن الممكن لمحوّلين (أو أكثر) بدء الإرسال في نفس الوقت، إما لأن كليهما وجد الخط خاملًا أو لأنها ينتظران خطًا مشغولًا ليصبح خاملًا، وبالتالي يُقال أن الإطارين (أو أكثر) يتصادمان على الشبكة عندما يحدث ذلك. يستطيع كل مرسل تحديد وجود تصادم نظرًا لأن الإيثرنت يدعم اكتشاف التصادم. يتأكد المحوّل أولًا من إرسال سلسلة تشويش (jamming sequence) مؤلفة من 32 بت في اللحظة التي يكتشف فيها أن إطاره يصطدم بآخر ثم يوقف الإرسال، وبالتالي سيرسل جهاز الإرسال 96 بتًا على الأقل في حالة حدوث تصادم: مقدمة (preamble) مؤلفة من 64 بت بالإضافة إلى سلسلة تشويش (jamming sequence) مؤلفة من 32 بت. تُستخدَم إحدى الطرق التي يرسل بها المحول 96 بتًا فقط، حيث يسمى أحيانًا إطارًا ضعيفًا (runt frame) وهو إطار أصغر من أدنى حجم، إذا كان المضيفان قريبان من بعضهما البعض. إذا كان المضيفان بعيدان عن بعضهما البعض، فيجب عليهما إرسال إطارات أطول، وبالتالي إرسال المزيد من البتات قبل اكتشاف التصادم. يحدث أسوأ سيناريو عندما يكون المضيفان على طرفي شبكة إيثرنت، فقد يحتاج المرسل إلى إرسال ما يصل إلى 512 بتًا للتأكد من أن الإطار الذي أرسله للتو لم يتعارض مع إطار آخر، لذلك ليس من قبيل الصدفة أن يكون طول كل إطار إيثرنت 512 بتًا (64 بايتًا) على الأقل: 14 بايتًا ترويسة بالإضافة إلى 46 بايتًا من البيانات و 4 بايتات لحقل CRC. لماذا 512 بتًا؟ تتعلق الإجابة بسؤال آخر قد تطرحه عن شبكة إيثرنت: لماذا يقتصر طول هذه الشبكة على 2500 متر فقط؟ لماذا ليست 10 أو 1000 كم؟ تتعلق الإجابة عن هذين السؤالين بحقيقة أنه كلما تباعدت عقدتان، كلما ازداد الوقت الذي يستغرقه الإطار الذي ترسله إحداهما للوصول إلى الأخرى، وتكون الشبكة عرضة للتصادم خلال هذا الوقت. يوضح الشكل السابق السيناريو الأسوأ، حيث يكون المضيفان A و B على طرفي الشبكة. افترض أن المضيف A يبدأ في إرسال إطار في الوقت t، كما هو موضح في القسم (أ) من الشكل السابق، حيث يستغرق الأمر وقت استجابة (latency) رابط واحد (يُشار إلى وقت الاستجابة d) حتى يصل الإطار إلى المضيف B، وبالتالي يصل البت الأول من إطار المضيف A إلى المضيف B في الوقت t + d كما هو موضح في القسم (ب) من الشكل السابق. افترض وجود لحظة قبل وصول إطار المضيف A (كأن يرى المضيف B أن الخط مازال خاملًا مثلًا)، فيبدأ المضيف B في إرسال إطاره الخاص. سيتصادم إطار المضيف B على الفور مع إطار المضيف A، وسيكتشف المضيف B هذا التصادم كما في القسم (ج) من الشكل السابق. سيرسل المضيف B سلسلة التشويش 32 بت (سيكون إطار B عبارة عن إطار ضعيف). لن يعرف المضيف A حدوث التصادم حتى يصل إطار المضيف B إليه لسوء الحظ، وسيحدث وقت استجابة رابط واحد لاحقًا في الوقت t + 2 × d، كما هو موضح في القسم (د) من الشكل السابق. يجب أن يستمر المضيف A في الإرسال حتى هذا الوقت لاكتشاف التصادم، أي يجب أن يرسل المضيف A لمدة 2 × d للتأكد من أنه يكتشف جميع التصادمات المحتملة، وبما أن طول إيثرنت المضبوط بحد أقصى هو 2500 متر، وأنه قد يكون هناك ما يصل إلى أربعة مكررات بين أي مضيفين، فقد حُدِّد تأخير الرحلة ذهابًا وإيابًا (round-trip) ليكون 51.2 ميكرو ثانية، والذي يتوافق مع 512 بت على شبكة إيثرنت 10 ميجابت في الثانية. الطريقة الأخرى للنظر إلى هذا الموقف هي أننا بحاجة إلى تقييد أقصى وقت استجابة لشبكة إيثرنت إلى قيمة صغيرة إلى حد ما، 51.2 ميكرو ثانية على سبيل المثال، حتى تعمل خوارزمية الوصول، ومن ثم يجب أن يكون الحد الأقصى لطول شبكة إيثرنت في حدود 2500 متر. ينتظر المحول قدرًا معينًا من الوقت بمجرد أن يكتشف المحوّل تصادمًا ويوقف الإرسال ثم يحاول مرة أخرى، حيث يضاعف المحول مقدار الوقت الذي ينتظره قبل المحاولة مرة أخرى في كل مرة يحاول الإرسال ويفشل. تدعى هذه الإستراتيجية لمضاعفة فاصل التأخير الزمني بين كل محاولة لإعادة الإرسال باسم التراجع الأسي (exponential backoff)، أي يؤخر المحول أولًا إما 0 أو 51.2 ميكرو ثانية، حيث يُختار عشوائيًا، وإذا فشل ذلك، فإنه ينتظر عندئذٍ 0 أو 51.2 أو 102.4 أو 153.6 ميكرو ثانية (يُختار عشوائيًا) قبل المحاولة مرة أخرى، أي هو k × 51.2 من أجل k = 0..3، ثم ينتظر k × 51.2 من أجل k = 0.23 - 1 بعد الاصطدام الثالث، ويُختار مرة أخرى عشوائيًا. تختار الخوارزمية عشوائيًا k بين 0 و 2n - 1 وتنتظر k × 51.2 ميكرو ثانية، حيث n هو عدد التصادمات التي حدثت حتى الآن. يستسلم المحول بعد عدد معين من المحاولات ويبلّغ عن خطأٍ في الإرسال إلى المضيف. تعيد المحوّلات عادةً المحاولة حتى 16 مرة، على الرغم من أن خوارزمية التراجع تحدد n بالقيمة 10. طول عمر شبكة إيثرنت (Longevity of Ethernet) كانت شبكة إيثرنت هي تقنية الشبكات المحلية المهيمنة لأكثر من 30 عامًا، ولكن تُنشَر اليوم عادةً لشبكات من نقطة لنقطة بدلًا من توصيلها على كبلٍ ملتوٍ، وغالبًا ما تُشغَّل بسرعات 1 أو 10 جيجابت في الثانية بدلًا من 10 ميجابت في الثانية، وتسمح برزم ضخمة تصل إلى 9000 بايت من البيانات بدلًا من 1500 بايت، ولكنها تظل متوافقةً مع المعيار الأصلي. هذا يجعل الأمر يستحق قول بضع كلمات حول سبب نجاح الإيثرنت، حتى نتمكن من فهم الخصائص التي يجب أن نحاكيها أية تقنية تحاول استخدامها بدلًا من شبكة إيثرنت. أولًا من السهل للغاية إدارة وصيانة شبكة إيثرنت: لا توجد جداول توجيه أو ضبط يجب تحديثها، ومن السهل إضافة مضيف جديد إلى الشبكة، فمن الصعب تخيل شبكة أبسط لإدارتها. ثانيًا إنها غير مكلفة: الكبل / الألياف رخيصة نسبيًا، والتكلفة الأخرى الوحيدة هي محوّل الشبكة على كل مضيف. أصبحت شبكة إيثرنت راسخة بعمق لهذه الأسباب، وإن أي نهج قائم على التبديل (switch) يطمح إلى استبدالها يتطلب استثمارًا إضافيًا في البنية التحتية كالمبدّلات (switches)، بالإضافة إلى تكلفة كل محوّل. نجحت الشبكات القائمة على التبديل المغايرة عن شبكة إيثرنت في النهاية في استبدال الإيثرنت متعدد الوصول، ولكن هذا استبدال أولي لأنه يمكن نشره بصورة تدريجية مع بعض المضيفين المتصلين عن طريق روابط من نقطة لنقطة بالمبدّلات، بينما بقي المضيفون الآخرون متصلين بالأسلاك الملتوية إلى المكررات أو الموزعات مع الحفاظ على بساطة إدارة الشبكة. ترجمة -وبتصرّف- للقسم Multi-Access Networks من فصل Direct Links من كتاب Computer Networks: A Systems Approach
  2. تكون الإطارات تالفةً أحيانًا أثناء النقل كما ذُكر سابقًا، لذلك تُستخدَم شيفرة خطأ كشيفرة CRC لاكتشاف هذه الأخطاء. على الرغم من أنّ بعض شيفرات الأخطاء قويّة بما يكفي لتصحيح الأخطاء، إلا أنّ عدد البتات الإضافية عمليّا يكون أكبر من أن يعالِج نطاقَ أخطاء البتات وأخطاء الرشقات (burst) التي يمكن إدخالها إلى رابط (link) شبكة. ستكون بعض الأخطاء شديدة جدًا بحيث لا يمكن تصحيحها حتى عند استخدام شيفرات تصحيح الأخطاء (على الروابط اللاسلكية على سبيل المثال)، لذلك يجب إهمال بعض الإطارات الفاسدة. يجب أيضًا أن يستعيد بروتوكول مستوى الرابط، الذي يريد تسليم الإطارات بطريقةٍ موثوقة، بعضًا من هذه الإطارات المهمَلة أو المفقودة. تجدر الإشارة إلى أن الوثوقية هي وظيفة يمكن توفيرها على مستوى الرابط، ولكن تهمل العديد من تقنيات الروابط الحديثة هذه الوظيفة، وعلاوةً على ذلك يُوفَّر التسليم الموثوق في كثيرٍ من الأحيان في مستويات أعلى، بما في ذلك طبقة النقل (transport) وأحيانًا طبقة التطبيق (application)، ولكن المكان الذي يجب توفيره فيه بالضبط هو موضوعٌ للنقاش ويعتمد على العديد من العوامل. سنشرح أساسيات التسليم الموثوق هنا، نظرًا لأن المبادئ شائعة عبر الطبقات، ولكن يجب أن تدرك أننا لا نتحدث عن وظيفة طبقة الربط فقط. يُنجَز التسليم الموثوق باستخدام مزيجٍ من آليتين أساسيتين هما إشعارات الاستلام (acknowledgments) والمهلات الزمنية (timeouts). الإشعار (acknowledgment أو اختصارًا ACK) هو إطار تحكم (control frame) صغير يرسله البروتوكول إلى نظيره يخبره فيه أنه تلقى إطارًا سابقًا. يُقصد بإطار التحكم أنه ترويسة (header) بدون أي بيانات، وعلى الرغم من أن البروتوكول يمكن أن يحمِّل (piggyback) الإشعار على إطار البيانات، إلا أنه يرسَل في الاتجاه المعاكس فقط، حيث يشير "مستلم الإشعار" إلى أنه مرسل الإطار الأصلي الذي سُلِّم إطارُه بنجاح. إذا لم يتلقَّ المرسل إشعارًا بعد فترة زمنية معينة، فإنه يعيد إرسال الإطار الأصلي. يسمى هذا الإجراء المتمثل في الانتظار لفترة زمنية معينة مهلةً زمنية (timeout)، وتدعى الاستراتيجية العامة لاستخدام إشعارات الاستلام والمهلة الزمنية لتطبيق التسليم الموثوق أحيانًا طلب التكرار الآلي (automatic repeat request أو اختصارًا ARQ). يصف هذا القسم ثلاث خوارزميات ARQ مختلفة باستخدام لغة عامة، أي أننا لا نقدم معلومات مفصَّلة حول حقول ترويسة بروتوكولٍ معين. خوارزمية توقف وانتظر (Stop-and-Wait) أبسط مخطط لآلية ARQ هو خوارزمية توقف وانتظر. فكرة هذه الخوارزمية واضحة: ينتظر المرسل إشعارًا بعد إرسال إطارٍ واحد، قبل إرسال الإطار التالي، وإذا لم يصل الإشعار بعد فترة زمنية معينة، تنتهي مهلة المرسل ويعيد إرسال الإطار الأصلي. يوضح الشكل السابق الخطوط الزمنية لأربعة سيناريوهات مختلفة ناتجة عن الخوارزمية الأساسية، حيث يُمثَّل جانب الإرسال على اليسار وجانب الاستقبال على اليمين، ويتدفق الوقت من الأعلى إلى الأسفل. يوضح الشكل (أ) الحالة التي يُستلم بها إشعار ACK قبل انتهاء مدة المؤقت. يوضح الشكلان (ب) و (ج) الحالة التي فُقد فيها الإطار الأصلي والإشعار ACK على التوالي، ويوضح الشكل (د) الحالة التي تنتهي فيها المهلة في وقتٍ مبكرٍ جدًا. تذكر أن كلمة (فَقد) تعني أن الإطار تالف أثناء النقل، وأن هذا التلف اُكتشف بواسطة شيفرة خطأٍ على المستقبل الذي أهمل هذا الإطار لاحقًا. تُعد خطوط الرزم الزمنية الموضحة في هذا القسم أمثلةً لأداة مستخدمة بصورة متكررة في تدريس البروتوكولات وشرحها وتصميمها، فهي مفيدة لأنها توضح سلوك النظام الموزع بمرور الوقت، وهو أمر قد يكون من الصعب جدًا تحليله. يجب أن تكون مستعدًا لما هو غير متوقع عند تصميم بروتوكول مثل تعطل النظام أو فقد رسالة أو شيء توقعتَ حدوثه بسرعة ثم يتبين أنه يستغرق وقتًا طويلًا. يمكن أن تساعد هذه الأنواع من الرسوم البيانية في كثير من الأحيان في فهم الخطأ الذي قد يحدث في مثل هذه الحالات، وبالتالي مساعدة مصمم البروتوكول على الاستعداد لكل احتمال. افترض أن المرسل يرسل إطارًا ثم يرسل المستقبل إشعار الاستلام، لكن هذا الإشعار إما فُقد أو تأخر في الوصول، حيث وُضحت هذه الحالة في الخطين الزمنيين (ج) و (د) من الشكل السابق. تنتهي مهلة المرسل في كلتا الحالتين ويعيد إرسال الإطار الأصلي، ولكن سيعتقد المستقبل أنه الإطار التالي، لأنه استلم الإطار الأول بصورة صحيحة وأرسل إشعارًا بذلك، وبالتالي ستزداد احتمالية التسبب في إنشاء نسخٍ مكرَّرة من الإطار لتسليمها. لمعالجة هذه المشكلة، تتضمن ترويسة بروتوكول توقف وانتظر عادةً رقمًا تسلسليًا مؤلفًا من بتٍ واحد، أي يمكن أن يأخذ الرقم التسلسلي القيمتين 0 و 1، وتتضمن الترويسةُ أيضًا الأرقامَ التسلسلية المستخدمة لكل إطار بديل، مثل ما هو موضح في الشكل الآتي. وهكذا عندما يعيد المرسل إرسال الإطار 0، يمكن للمستقبل أن يحدد أنه يرى نسخة ثانية من الإطار 0 بدلًا من أن يعتقد أنه النسخة الأولى من الإطار 1، وبالتالي يمكنه إهماله (لا يزال على المستقبل أن يرسل إشعارًا باستلامه، في حالة ضياع أول إشعار ACK). يتمثل العيب الرئيسي في خوارزمية توقف وانتظر في أنها تسمح للمرسل بالحصول على إطار واحد فقط عن طريق الرابط في كل مرة، وقد يكون هذا أقل بكثير من سعة الرابط. افترض وجود رابط 1.5 ميجابت في الثانية مع وقت ذهاب وإياب (round-trip time أو اختصارًا RTT) يبلغ 45 ميلي ثانية على سبيل المثال. هذا الرابط لديه ناتج تأخير × حيز النطاق التراسلي (delay × bandwidth) يساوي 67.5 كيلو بت أو حوالي 8 كيلو بايت، ونظرًا لأن المرسل يمكنه إرسال إطار واحد فقط في كل RTT، وبافتراض أن حجم الإطار يبلغ 1 كيلو بايت، فهذا يعني أن الحد الأقصى لمعدل الإرسال يبلغ: Bits-Per-Frame / Time-Per-Frame = 1024 x 8 / 0.045 = 182 kbps أو يبلغ حوالي ثُمن سعة الرابط، وإذا كنت تريد استخدام الرابط بشكل كامل، فهذا يعني أن يتمكن المرسل من إرسال ما يصل إلى ثمانية إطارات قبل الاضطرار إلى انتظار الإشعار. النافذة المنزلقة (Sliding Window) افترض مرةً أخرى السيناريو الذي يحتوي فيه الرابط على تأخير × حيز النطاق التراسلي يبلغ 8 كيلو بايت، ويكون حجم الإطارات 1 كيلو بايت. يجب أن يكون المرسل جاهزًا لإرسال الإطار التاسع في نفس اللحظة تقريبًا التي يصل فيها الإشعار ACK للإطار الأول. تسمى الخوارزمية التي تسمح لنا بالقيام بذلك بخوارزمية النافذة المنزلقة (sliding window)، ويُعطى الخط الزمني التوضيحي في الشكل التالي: خوارزمية النافذة المنزلقة (The Sliding Window Algorithm) تعمل خوارزمية النافذة المنزلقة على النحو التالي: أولًا يسند المرسل رقمًا تسلسليًا، يُرمز له SeqNum، لكل إطار. افترض حاليًا تجاهل حقيقة أن SeqNum يُطبَّق بواسطة حقل ترويسة ذي حجمٍ محدود، وافترض بدلًا من ذلك أنه يمكن أن يزداد لا نهائيًا. يحتفظ المرسل بثلاثة متغيرات هي: حجم نافذة الإرسال (send window size)، المشار إليه SWS، الذي يعطي الحد الأعلى لعدد الإطارات المعلَّقة (غير المعترف بها) التي يمكن للمرسل إرسالها. المتغير الثاني هو LAR الذي يشير إلى الرقم التسلسلي لآخر إشعار مُستلَم (last acknowledgment received). المتغير الثالث LFS ويشير إلى الرقم التسلسلي لآخر إطارٍ مُرسَل (last frame sent). يحتفظ المرسل أيضًا بالثابت التالي: LFS - LAR <= SWS هذه الحالة موضحة في الشكل التالي: يحرّك المرسل المتغير LAR إلى اليمين عند وصول إشعارٍ بالاستلام، مما يسمح للمرسل بإرسال إطار آخر، ويربط المرسل مؤقِّتًا (timer) بكل إطارٍ يرسله، ويعيد إرسال الإطار في حالة انتهاء صلاحية المؤقت قبل استلام الإشعار ACK. لاحظ أن المرسل يجب أن يكون على استعداد لتخزين إطارات SWS مؤقتًا لأنه يجب أن يكون مستعدًا لإعادة إرسالها حتى يستلم إشعارًا بوصولها. يحافظ المستقبل على المتغيرات الثلاثة التالية: متغير حجم نافذة الاستلام (receive window size)، والمشار إليه RWS، الذي يعطي الحد الأعلى لعدد الإطارات المخالفة للترتيب التي يرغب المستقبل في قبولها. المتغير الثاني هو LAF ويشير إلى الرقم التسلسلي لأكبر إطار مقبول (largest acceptable frame). المتغير الثالث هو LFR ويشير إلى الرقم التسلسلي لآخر إطارٍ مُستقبَلٍ (last frame received). يحافظ المستقبل أيضًا على الثابت التالي: LAF - LFR <= RWS هذه الحالة موضحة في الشكل التالي: يتخذ المستقبل الإجراء التالي عند وصول إطار برقم تسلسلي SeqNum: إذا كان SeqNum <= LFR أو SeqNum > LAF، فسيكون الإطار خارج نافذة المستقبل وبالتالي يُهمَل. إذا كان LFR < SeqNum <= LAF، فسيكون الإطار داخل نافذة المستقبل ويُقبَل، ويحتاج المستقبل الآن أن يقرر ما إذا كان سيرسل إشعارًا ACK أم لا. افترض أن الرقم التسلسلي SeqNumToAck يشير إلى أكبر رقم تسلسلي لم يُرسَل إشعار استلامه بعد، بحيث تُستلَم جميع الإطارات ذات الأرقام التسلسلية الأقل من هذا الرقم التسلسلي أو تساويه. يرسل المستقبل إشعارًا باستلام الرقم التسلسلي SeqNumToAck، حتى إذا استلم رزمًا ذات أرقامٍ أعلى. يُقال أن هذه الإشعارات تراكمية (cumulative)، ثم يعيّن المستقبل LFR = SeqNumToAck ويضبط LAF = LFR + RWS. افترض أن LFR = 5 على سبيل المثال (أي أن آخر إشعارٍ ACK أرسله المستقبل كان للرقم التسلسلي 5) وأن RWS = 4، وهذا يعني أن LAF = 9. ستُخزَّن الإطارات 7 و 8 مؤقتًا في حالة وصولها لأنها ضمن نافذة المستقبل، ولكن لا يلزم إرسال إشعار نظرًا لأن الإطار 6 لم يصل بعد، حيث يقال أن الإطارات 7 و 8 قد وصلت مخالفةً للترتيب. يمكن للمستقبل إعادة إرسال إشعار ACK للإطار 5 عند وصول الإطارات 7 و 8، ولكن هل سيصل الإطار 6؟ ربما أصبح الوقت متأخرًا لأنه فُقد في المرة الأولى وكان لا بد من إعادة إرساله، أو ربما تأخر ببساطة. يرسل المستقبل إشعارًا بوصول الإطار 8، ويزيد LFR إلى 8، ويضبط LAF على 12، في حين أنه من غير المحتمل أن تتأخر الرزمة أو تصل مخالفةً للترتيب على رابط نقطةٍ لنقطة. تُستخدم هذه الخوارزمية ذاتها في الاتصالات متعددة القفزات، حيث يكون مثل هذا التأخير ممكنًا. إذا فُقد الإطار 6 بالفعل، فستنتهي مهلة الانتظار (timeout) عند المرسل، مما يؤدي إلى إعادة إرسال الإطار 6. لاحظ أنة تقل كمية البيانات المُرسَلة عند انتهاء المهلة، نظرًا لأن المرسل غير قادر على زيادة نافذته حتى يُرسَل إشعارٌ بوصول الإطار 6، وهذا يعني أن هذا المخطط لم يعد يحافظ على الأنبوب ممتلئًا عند حدوث فقدٍ لرزمة، وكلما طالت مدة ملاحظة حدوث فقد للرزمة، زادت خطورة هذه المشكلة. لاحظ أنه في هذا المثال كان من الممكن أن يرسل المستقبل إشعارًا سلبيًا (negative acknowledgment أو اختصارًا NAK) للإطار 6 بمجرد وصول الإطار 7، ولكن هذا غير ضروري لأن آلية مهلة المرسل الزمنية كافيةٌ لاكتشاف هذه الحالة، حيث يضيف إرسال إشعارات NAK تعقيدًا إضافيًا للمستقبل. يُسمَح أيضًا إرسال إشعارات إضافية للإطار 5 عند وصول الإطارات 7 و 8، ويمكن للمرسل في بعض الحالات استخدام إشعار ACK مُكرَّر كدليلٍ على فقد إطار. يساعد كلا الأسلوبين على تحسين الأداء من خلال السماح بالكشف المبكر عن فقدان الرزم، وهناك اختلاف آخر في هذا المخطط وهو استخدام إشعارات انتقائية (selective acknowledgments)، أي أن المستقبل يمكنه إرسال إشعارات بوصول تلك الإطارات التي استلمها بدلًا من مجرد استلام أعلى إطار مرقّمٍ بالترتيب، لذلك يمكن للمستقبل أن يرسل إشعارات باستلام الإطارات 7 و 8 في المثال أعلاه. يسهّل تقديم المزيد من المعلومات إلى المرسل عليه الحفاظ على الأنبوب ممتلئًا ولكنه يضيف تعقيدًا للتطبيق. يُحدَّد حجم نافذة الإرسال وفقًا لعدد الإطارات التي نريد أن تكون معلّقة على الرابط في وقت معين، ومن السهل حساب SWS للتأخير × حيز النطاق التراسلي، ويمكن للمستقبل من ناحية أخرى ضبط RWS بالقيمة التي يريد. هناك إعدادان شائعان هما RWS = 1 الذي يعني أن المستقبل لن يخزن مؤقتًا أي إطارات تصل مخالفة للترتيب، و RWS = SWS الذي يعني أن المستقبل يمكنه تخزين أي من الإطارات التي يرسلها المرسل مؤقتًا. ليس من المنطقي تعيين RWS > SWS نظرًا لأنه من المستحيل وصول إطارات أكثر من SWS مخالفة للترتيب. الأرقام التسلسلية المحدودة والنافذة المنزلقة (Finite Sequence Numbers and Sliding Window) بالعودة إلى التبسيط الذي أُدخل في الخوارزمية وهو الافتراض أن الأرقام التسلسلية يمكن أن تزداد كثيرًا بصورة لا نهائية، ولكن يُحدَّد، من الناحية العملية بالطبع، رقم الإطار التسلسلي في حقل ترويسة بحجم محدد. يعني الحقل 3 بت على سبيل المثال أن هناك ثمانية أرقام تسلسلية محتملة، 0..7، وهذا يجعل من الضروري إعادة استخدام الأرقام التسلسلية أو، بطريقة أخرى، التفاف الأرقام التسلسلية (wrap around)، فيؤدي ذلك إلى مشكلة القدرة على التمييز بين التجسيدات المختلفة لنفس الأرقام التسلسلية، مما يعني أن عدد الأرقام التسلسلية الممكنة يجب أن يكون أكبر من عدد الإطارات المعلّقة المسموح بها. سمحت خوارزمية توقف وانتظر على سبيل المثال بإطار واحد في كل مرة وله رقمان تسلسليان متميزان. افترض أن لديك رقمًا واحدًا في فضاء الأرقام التسلسلية أكثر من عدد الإطارات التي يحتمل أن تكون مميزة، وهذا يعني أن SWS <= MaxSeqNum - 1، حيث MaxSeqNum هو عدد الأرقام التسلسلية المتاحة، فهل هذا كافٍ؟ يعتمد الجواب على RWS، فإذا كانت RWS = 1، فإن ‎MaxSeqNum > = SWS + 1 كافٍ، وإذا كانت RWS تساوي RWS، فإن وجود MaxSeqNum أكبر من حجم نافذة الإرسال ليس جيدًا بما يكفي. لفهم ذلك، افترض الحالة التي تكون فيها الأرقام التسلسلية الثمانية من 0 إلى 7، و SWS = RWS = 7، وافترض أن المرسل يرسل الإطارات 0..6، ثم تُستلم بنجاح، ولكن تُفقد إشعاراتها. يتوقع المستقبل الآن الإطارات 7 و 0..5، ولكن تنتهي مهلة المرسل ويرسل الإطارات 0..6، وبالتالي يتوقع المستقبل لسوء الحظ التجسيد الثاني للإطارات 0..5 لكنه يحصل على التجسيد الأول لهذه الإطارات، وهذه هي بالضبط الحالة التي يجب تجنبها. اتضح أن حجم نافذة الإرسال لا يمكن أن يكون أكبر من نصف عدد الأرقام التسلسلية المتاحة عندما تكون RWS = SWS، أو تُحدَّد بدقة أكبر كما يلي: SWS < (MaxSeqNum + 1)/ 2 وهذا يعني أن بروتوكول النافذة المنزلقة يتناوب بين نصفي فضاء الأرقام التسلسلية، تمامًا كما تبدّل خوارزمية توقف وانتظر بين الرقمين التسلسليين 0 و 1، والفرق الوحيد هو أنه ينزلق باستمرار بين النصفين بدلًا من التناوب بينهما. ولكن لاحظ أن هذه القاعدة خاصة بالحالة RWS = SWS. سنترك لك تمرين تحديد القاعدة الأعم التي تعمل مع قيم RWS و SWS العشوائية. لاحظ أيضًا أن العلاقة بين حجم النافذة وفضاء الأرقام التسلسلية تعتمد على افتراضٍ واضح جدًا بحيث يسهل التغاضي عنه وهو أن الإطارات لا يُعاد ترتيبها أثناء النقل. لا يمكن أن يحدث هذا على الروابط المباشرة نقطة لنقطة لأنه لا توجد طريقة تمكّن إطارًا ما من تجاوز إطارٍ آخر أثناء الإرسال، ولكنك سترى خوارزمية النافذة المنزلقة المستخدمة في بيئات مختلفة، وستحتاج إلى وضع قاعدة أخرى. تطبيق النافذة المنزلقة (Implementation of Sliding Window) توضح الشيفرة الآتية كيف يمكن تطبيق جانبي الإرسال والاستقبال لخوارزمية النافذة المنزلقة، حيث أن هذه الشيفرة مأخوذة من بروتوكول عمل يسمى بروتوكول النافذة المنزلقة (Sliding Window Protocol أو اختصارًا SWP). يُشار إلى البروتوكول الموجود فوق بروتوكول SWP على أنه بروتوكول عالي المستوى (high-level protocol ويُختصر إلى HLP)، ويشار إلى البروتوكول الموجود أسفل بروتوكول SWP على أنه بروتوكول مستوى الرابط (link-level protocol أو اختصارًا LLP). نبدأ بتعريف زوجٍ من بنيات البيانات، حيث أولًا ترويسة الإطار بسيطة للغاية، فهي تحتوي على رقم تسلسلي (SeqNum) ورقم إشعار (AckNum)، وتحتوي أيضًا على حقل الرايات Flags الذي يحدد إذا كان الإطار عبارة عن إشعار ACK أو أنه يحمل بيانات. typedef u_char SwpSeqno; typedef struct { SwpSeqno SeqNum; /* رقم الإطار التسلسلي */ SwpSeqno AckNum; /* إشعار وصول الإطار المُستلَم */ u_char Flags; /*(flags) ما يصل إلى 8 بتات من الرايات */ } SwpHdr; تحتوي حالة خوارزمية النافذة المنزلقة على البنية التالية: تتضمن هذه الحالة من جانب إرسال البروتوكول على المتغيرين LAR و LFS، كما هو موضح سابقًا، بالإضافة إلى طابور يحتوي على الإطارات التي أُرسلت ولكن لم تُرسَل إشعارات وصولها بعد (sendQ). تتضمن حالة الإرسال أيضًا متغير تقييد وصول خاصٍ بالعد (counting semaphore) يسمى sendWindowNotFull. يُعد متغير تقييد الوصول أداة مزامنة أولية تدعم عمليات semWait و semSignal. يزيد كل استدعاء للعملية semSignal متغيرَ تقييد الوصول بمقدار 1، وينقص كل استدعاءٍ للعملية semWait المتغير s بمقدار 1، ويجب أن يؤدي توقف عملية الاستدعاء أو تعليقها إلى تقليل قيمة متغير تقييد الوصول إلى أقل من 0، وسيُسمح للعملية التي توقفت أثناء استدعائها باستئناف العملية semWait بمجرد إجراء عمليات semSignal كافيةٍ لرفع قيمة متغير تقييد الوصول إلى أعلى من 0. تتضمن هذه الحالة من جانب استلام البروتوكول على المتغير NFE، وهو الإطار التالي المتوقع (next frame expected)، وهو الإطار الذي يحتوي على رقم تسلسلي واحد أكثر من آخر إطار مُستلَم (LFR). يوجد أيضًا طابور يحتوي على الإطارات المستلَمة المخالفة للترتيب (recvQ)، وتُحدَّد أخيرًا أحجام نافذة المرسل والمستقبل المنزلقة بواسطة الثوابت SWS و RWS على التوالي: typedef struct { /* :حالة جانب المرسل */ SwpSeqno LAR; /* الرقم التسلسلي لآخر إشعار مستلَم */ SwpSeqno LFS; /* آخر إطار مُرسَل */ Semaphore sendWindowNotFull; SwpHdr hdr; /* ترويسة مُهيَّأة مسبقًا */ struct sendQ_slot { Event timeout; /* الحدث المرتبط بمهلة الإرسال الزمنية */ Msg msg; } sendQ[SWS]; /* :حالة جانب المستقبل */ SwpSeqno NFE; /* الرقم التسلسلي للإطار التالي المتوقَّع */ struct recvQ_slot { int received; /* هل الرسالة صالحة؟ */ Msg msg; } recvQ[RWS]; } SwpState; يُطبَّق جانب الإرسال من بروتوكول SWP بواسطة الإجرائية sendSWP البسيطة نوعًا ما، حيث أولًا تتسبب العملية semWait في أن تحظر هذه الإجرائية الوصول إلى متغير تقييد الوصول إلى أن يُسمح بإرسال إطار آخر، حيث تضبط الإجرائيةُ sendSWP الرقمَ التسلسلي في ترويسة الإطار بمجرد السماح بالمتابعة، وتحفظ نسخةً من الإطار في طابور الإرسال (sendQ)، وتجدول حدث انتهاء المهلة لمعالجة الحالة التي لا يُرسَل فيها إشعار وصول الإطار، وترسل الإطار إلى بروتوكول المستوى الأدنى التالي الذي نشير إليه بـ LINK. أحد التفاصيل الجديرة بالملاحظة هو استدعاء الدالة store_swp_hdr قبل استدعاء الدالة msgAddHdr، حيث تترجم هذه الشيفرة بنية لغة C التي تحتفظ بترويسة SWP (state-> hdr) كسلسلة بايتات يمكن ربطها بأمان بمقدمة الرسالة (hbuf). يجب أن تترجم هذه الإجرائية كل حقل عدد صحيح في الترويسة إلى ترتيب بايت شبكة وإزالة أي حشو أضافه المصرِّف (compiler) إلى بنية C. مسألة ترتيب البايتات هي مسألة غير بسيطة، ولكن يكفي افتراض أن هذه الإجرائية في الوقت الحالي تضع البت الأعلى أهمية من عددٍ صحيح متعدد الكلمات (multiword) في البايت ذي العنوان الأعلى. جزء آخر من التعقيد في هذه الشيفرة هو استخدام العملية semWait ومتغير تقييد الوصول sendWindowNotFull. يُهيَّأ المتغير sendWindowNotFull بحجم نافذة المرسل المنزلقة SWS (لا تُعرَض هذه التهيئة)، ثم تقلل العملية semWait هذا العدد في كل مرة يرسل فيها المرسل إطارًا وتوقِف المرسل إذا انتقل العدد إلى 0. تزيد العمليةُ semSignal التي تُستدعى ضمن الإجرائية deliverySWP هذا العددَ في كل مرة يُستلَم فيها إشعار ACK، وبالتالي يُستأنف أي مرسلٍ منتظرٍ. static int sendSWP(SwpState *state, Msg *frame) { struct sendQ_slot *slot; hbuf[HLEN]; /* انتظر فتحَ نافذة المرسل */ semWait(&state->sendWindowNotFull); state->hdr.SeqNum = ++state->LFS; slot = &state->sendQ[state->hdr.SeqNum % SWS]; store_swp_hdr(state->hdr, hbuf); msgAddHdr(frame, hbuf, HLEN); msgSaveCopy(&slot->msg, frame); slot->timeout = evSchedule(swpTimeout, slot, SWP_SEND_TIMEOUT); return send(LINK, frame); } يجب إصلاح بعض التناقض قبل المتابعة إلى جانب الاستلام، حيث قلنا أن البروتوكول عالي المستوى يستدعي خدمات بروتوكول مستوٍ منخفض عن طريق استدعاء العملية send، لذلك نتوقع أن يستدعي البروتوكول الذي يريد إرسال رسالة عبر SWP الدالة '(send(SWP, packet، ولكن يُطلق على الإجرائية التي تطبّق عملية إرسال SWP اسمsendSWP، وأول وسطائها هو متغير الحالة (SwpState)، حيث يوفّر نظام التشغيل شيفرةً لاصقة (glue code) تترجم استدعاءsendالعام إلى استدعاء برتوكولٍ خاصsendSWP. تربط الشيفرة اللاصقة الوسيطَ الأول من العمليةsend(متغير البروتوكول السحريSWP) مع كلٍ من الدالة المؤشرة إلى الإجرائيةsendSWP` والمؤشر إلى حالة البروتوكول التي يحتاجها SWP للقيام بعمله. السبب في أن البروتوكول عالي المستوى يستدعي بطريقة غير مباشرة الدالةََ الخاصة بالبروتوكول من خلال استدعاء الدالة العامة هو أننا نريد تحديد مقدار المعلومات التي شفّرها البروتوكول عالي المستوى حول البروتوكول منخفض المستوى، وهذا يجعل تغيير ضبط رسم البروتوكول البياني في وقت ما في المستقبل أمرًا سهلًا. ننتقل الآن إلى تطبيق بروتوكول SWP المحدّد للعملية deliver الموجود ضمن الإجرائية deliverSWP، حيث تعالج هذه الإجرائية نوعين مختلفين من الرسائل الواردة: إشعارات الإطارات المرسلة سابقًا من هذه العقدة وإطارات البيانات التي تصل إلى هذه العقدة، أي نٍصفُ إشعارات هذه الإجرائية هو المقابل لجانب المرسل من الخوارزمية الواردة في الإجرائية sendSWP. يُتخذ قرار بشأن ما إذا كانت الرسالة الواردة عبارة عن إشعار ACK أو إطار بيانات عن طريق التحقق من الحقل Flags في الترويسة. لاحظ أن هذا التطبيق لا يدعم حَملَ الإشعارات على إطارات البيانات. تبحث الإجرائية deliverySWP ببساطة عن الفتحة الموجودة في طابور الإرسال (sendQ) التي تتوافق مع الإشعار عندما يكون الإطار الوارد عبارة عن إشعار وتلغي حدث المهلة، وتحرر الإطار المحفوظ في تلك الفتحة. يُطبَّق هذا العمل في الواقع ضمن حلقة لأن الإشعار قد يكون تراكميًا. الشيء الآخر الوحيد الذي يجب ملاحظته حول هذه الحالة هو استدعاء الإجرائية الفرعية swpInWindow، حيث تضمن هذه الإجرائية الفرعية، الواردة أدناه، أن يكون رقم الإطار التسلسلي الذي يُرسَل إشعارٌ باستلامه ضمن نطاق مجموعة الإشعارات التي يتوقع المرسل استلامها حاليًا. تستدعي الإجرائية deliverSWP أولًا الإجرائيتين msgStripHdr و load_swp_hdr عندما يحتوي الإطار الوارد على بياناتٍ لاستخراج الترويسة من الإطار، حيث تُعد الإجرائية load_swp_hdr هي المقابل للإجرائية store_swp_hdr التي نوقشت سابقًا، فهي تترجم سلسلة بايتات إلى بنية بيانات C التي تحتوي على ترويسة SW، ثم تستدعي الإجرائيةُ DeliverySWP الإجرائيةَ swpInWindow للتأكد من أن رقم الإطار التسلسلي يقع ضمن نطاق الأرقام التسلسلية المتوقعة. إذا كان الأمر كذلك، فستدور الإجرائية عبر مجموعة الإطارات المتعاقبة المستلَمة وتمررها إلى بروتوكول المستوى الأعلى من خلال استدعاء الإجرائية deliveryHLP. كما ترسل أيضًا إشعارًا تراكميًا إلى المرسل، ولكنها تفعل ذلك عن طريق تكرار طابور الاستلام (ولا تستخدم متغير الإجرائية deliveryHLP). static int deliverSWP(SwpState state, Msg *frame) { SwpHdr hdr; char *hbuf; hbuf = msgStripHdr(frame, HLEN); load_swp_hdr(&hdr, hbuf) if (hdr->Flags & FLAG_ACK_VALID) { /* استلمتَ إشعارًا، إذًا نفّذ جانب المرسل*/ if (swpInWindow(hdr.AckNum, state->LAR + 1, state->LFS)) { do { struct sendQ_slot *slot; slot = &state->sendQ[++state->LAR % SWS]; evCancel(slot->timeout); msgDestroy(&slot->msg); semSignal(&state->sendWindowNotFull); } while (state->LAR != hdr.AckNum); } } if (hdr.Flags & FLAG_HAS_DATA) { struct recvQ_slot *slot; /* استلمتَ رزمة بيانات، إذًا نفّذ جانب المستقبل */ slot = &state->recvQ[hdr.SeqNum % RWS]; if (!swpInWindow(hdr.SeqNum, state->NFE, state->NFE + RWS - 1)) { /* أهمل الرسالة */ return SUCCESS; } msgSaveCopy(&slot->msg, frame); slot->received = TRUE; if (hdr.SeqNum == state->NFE) { Msg m; while (slot->received) { deliver(HLP, &slot->msg); msgDestroy(&slot->msg); slot->received = FALSE; slot = &state->recvQ[++state->NFE % RWS]; } /* :أرسل إشعارًا */ prepare_ack(&m, state->NFE - 1); send(LINK, &m); msgDestroy(&m); } } return SUCCESS; } الإجرائية swpInWindow هي إجرائية فرعية بسيطة تتحقق فيما إذا كان الرقم التسلسلي يقع بين حدي الرقم التسلسلي الأدنى والأعلى كما يلي: static bool swpInWindow(SwpSeqno seqno, SwpSeqno min, SwpSeqno max) { SwpSeqno pos, maxpos; pos = seqno - min; /* [0..MAX) يجب أن يكون ضمن المجال pos المتغير */ maxpos = max - min + 1; /* [0..MAX] يجب أن يكون ضمن المجال maxpos المتغير */ return pos < maxpos; } ترتيب الإطارات والتحكم في التدفق (Frame Order and Flow Control) قد يكون بروتوكول النافذة المنزلقة هو أفضل خوارزمية معروفة في شبكات الحاسوب، ولكن ما يربك بسهولة حول هذه الخوارزمية هو أنه يمكن استخدامها لإنجاز ثلاثة أدوار مختلفة. الدور الأول هو توصيل الإطارات بصورة موثوقة عبر رابطٍ غير موثوق به، أي يمكن استخدام الخوارزمية لتوصيل الرسائل بطريقة موثوقة عبر شبكة غير موثوقة، وهذه هي الوظيفة الأساسية للخوارزمية. الدور الثاني الذي يمكن أن تؤديه خوارزمية النافذة المنزلقة هو الحفاظ على الترتيب الذي تُرسَل الإطارات به، حيث من السهل القيام بذلك عند المستقبل، نظرًا لأن كل إطار يحتوي على رقم تسلسلي، وبالتالي يتأكد المستقبل فقط من أنه لا يمرر إطارًا إلى بروتوكول المستوى الأعلى التالي حتى يمرر بالفعل جميع الإطارات ذات الأرقام التسلسلية الأصغر، وهذا يعني أن مخازن المستقبل المؤقتة لا تمرر إطارات مخالفة للترتيب. يحافظ إصدار خوارزمية النافذة المنزلقة الموصوف في هذا القسم على ترتيب الإطارات، على الرغم من أننا يمكن أن نتخيل اختلافًا عندما يمرر المستقبل الإطارات إلى البروتوكول التالي دون انتظار تسليم جميع الإطارات السابقة. السؤال الذي يجب طرحه الآن هو ما إذا كنا نحتاج حقًا إلى بروتوكول النافذة المنزلقة للحفاظ على ترتيب الإطارات على مستوى الرابط، أو يجب بدلًا من ذلك تطبيق هذه الوظيفة بواسطة بروتوكول أعلى في المكدس. يتمثل الدور الثالث الذي تلعبه خوارزمية النافذة المنزلقة أحيانًا في دعم التحكم في التدفق (flow control)، وهي آلية للتغذية الراجعة التي يستطيع المستقبل بواسطتها خنق المرسل، حيث تُستخدم مثل هذه الآلية لمنع المرسل من الإفراط في تشغيل المستقبل، أي عدم إرسال بيانات أكثر مما يستطيع المستقبل معالجته. يتحقق ذلك عادةً عن طريق زيادة بروتوكول النافذة المنزلقة بحيث لا يرسل المستقبل إشعارًا باستلام الإطارات التي استقبلها فحسب، بل يُعلم المرسل أيضًا بعدد الإطارات التي يمكنه استقبالها. يتوافق عدد الإطارات التي يمكن للمستقبل استقبالها مع مقدار مساحة المخزَن المؤقت الحرة الموجودة. تحتاج، كما في حالة التسليم المرتّب، إلى التأكد من أن التحكم في التدفق ضروري على مستوى الرابط قبل دمجه في بروتوكول النافذة المنزلقة. القنوات المنطقية المتزامنة (Concurrent Logical Channels) يوفّر بروتوكول ربط البيانات المستخدم في شبكات أربانت ARPANET الأصلية بديلًا مثيرًا للاهتمام لبروتوكول النافذة المنزلقة، حيث يمكنه الحفاظ على الأنبوب ممتلئًا مع الاستمرار في استخدام خوارزمية توقف وانتظر البسيطة. إحدى النتائج المهمة لهذا الأسلوب هي أن الإطارات المرسلَة عبر رابطٍ معين لا يُحتفَظ بها بأي ترتيب محدد، ولا يشير البروتوكول أيضًا إلى أي شيء يتعلق بالتحكم في التدفق. الفكرة الأساسية لبروتوكول ARPANET، والتي يشار إليها بالقنوات المنطقية المتزامنة، هي دمج عدة قنوات منطقية على رابط من نقطة لنقطة واحدٍ وتشغيل خوارزمية توقف وانتظر على كل من هذه القنوات المنطقية. لا توجد علاقة محفوظة بين الإطارات المرسلة على أي من القنوات المنطقية، ولكن نظرًا لأن إطارًا مختلفًا يمكن أن يكون موجودًا على كل من القنوات المنطقية المتعددة، فيمكن للمرسل الاحتفاظ بالرابط ممتلئًا، وبتعبيرٍ أدق يحتفظ المرسل بثلاثة بتات من أجل حالة كل قناة: بت بولياني يوضح ما إذا كانت القناة مشغولة حاليًا، ورقم تسلسلي مؤلف من 1 بت لاستخدامه في المرة التالية التي يُرسَل فيها إطار على هذه القناة المنطقية، والرقم التسلسلي التالي الذي يمكن توقعه في إطار يصل إلى هذه القناة. تستخدم العقدةُ القناة الأدنى خمولًا عندما يكون لديها إطارٌ لإرساله، وبخلاف ذلك تتصرّف تمامًا مثل خوارزمية توقف وانتظر. تدعم شبكات ARPANET ثماني قنوات منطقية عبر كل رابط أرضي و 16 قناة عبر كل رابط فضائي. تضمنت ترويسة كل إطار في حالة الرابط الأرضي رقم قناة مؤلف من 3 بتات ورقمًا تسلسليًا مؤلفًا من بت واحد، ليصبح المجموع 4 بتات، وهذا هو بالضبط عدد البتات الذي يتطلبه بروتوكول النافذة المنزلقة لدعم ما يصل إلى 8 إطارات على الرابط عندما تكون RWS = SWS. ترجمة -وبتصرّف- للقسم Reliable Transmission من فصل Direct Links من كتاب Computer Networks: A Systems Approach
  3. تُدخَل أحيانًا أخطاء البت في الإطارات، ويحدث هذا على سبيل المثال بسبب التداخل الكهربائي (electrical interference) أو التّشويش الحراري (thermal noise)، وعلى الرّغم من ندرة الأخطاء خاصًّة على الروابط الضوئيّة، إلا أنّ هناك حاجة إلى بعض الآليات لاكتشاف هذه الأخطاء، وذلك من أجل اتخاذ الإجراءات الصحيحة، بينما يُترك المستخدم النهائي بخلاف ذلك يتساءل عن سبب ظهور خطأ صياغي (syntax error) فجأةً في برنامج مكتوب بلغة C يكون قد صُرِّف بنجاح منذ لحظة واحدة فقط، رغم أنّ كلّ ما حدث في غضون ذلك هو نسخه عبر نظام ملفّات الشبكة. هناك تاريخ طويل من تقنيّات التعامل مع أخطاء البت في أنظمة الحاسوب، حيث يعود تاريخه إلى الأربعينات على الأقل، وتُعَد شيفرات هامينغ (Hamming) وريد-سولومون (Reed-Solomon) مثالين بارزين طُوِّرا للاستخدام في قارئات البطاقات المُثقَّبة (punch card readers) عند تخزين البيانات على أقراص مغناطيسيّة (magnetic disks) وفي الذواكر الأساسيّة القديمة، حيث يشرح هذا القسم بعض تقنيّات اكتشاف الأخطاء الأكثر استخدامًا في الشبكات. يُعدّ اكتشاف الأخطاء جزءًا من المشكلة فقط، والجزء الآخر هو تصحيح الأخطاء بمجرد اكتشافها، ويمكن اتّباع طريقتين أساسيّتين عندما يكتشف مستلم الرسالة خطأً، والطريقة الأولى هي إبلاغ المرسل بأن الرسالة تالفة حتّى يتمكّن المرسل من إعادة إرسال نسخة من الرسالة، وبالتالي إذا كانت أخطاء البت ناذرة، فمن المحتمل أن تكون النسخة المعاد إرسالها خاليةً من الأخطاء؛ أمّا الطريقة الثانية، فتتمثّل في سماح بعض أنواع خوارزميّات اكتشاف الأخطاء للمستلم بإعادة بناء الرسالة الصحيحة حتّى بعد تلفها، حيث تعتمد مثل هذه الخوارزميات على شيفرات تصحيح الأخطاء (error-correcting codes) الموضّحة أدناه. وواحدة من أكثر التقنيّات شيوعًا لاكتشاف أخطاء الإرسال هي تقنيّة تُعرف باسم فحص التّكرار الدوري (cyclic redundancy check ويختصر إلى CRC)، والتي تُستخدم في جميع بروتوكولات مستوى الربط التي ستناقَش في هذا المقال تقريبًا، حيث سيوضّح هذا القسم خوارزميّة CRC الأساسيّة، ولكنّه سيشرح أوّلًا مخطّط المجموع الاختباري (checksum) الأبسط الذي تستخدمه العديد من بروتوكولات الإنترنت. الفكرة الأساسيّة وراء أيّ مخطّط اكتشاف أخطاء هي إضافة معلومات زائدة إلى إطار، بحيث يمكن استخدامها لتحديد فيما إذا كانت الأخطاء قد أُدخلت أم لا، ويمكنك تخيّل إرسال نسختين كاملتين من البيانات في أسوأ الأحوال، فإذا كانت النسختان متطابقتين في المستقبل، فمن المحتمل أن تكون كلتا النسختين صحيحتين، وإذا اختلفتا، فقد أُدخِل خطأٌ في إحدى النسختين (أو كلتيهما) وبالتالي يجب إهمالها، لكن هذا يُعدّ مخطّطًا سيّئًا إلى حدّ ما لاكتشاف الأخطاء وذلك لسببين، أوّلهما إرسال هذا المخطّط n بت زائدة (redundant) لرسالة بحجم n بت، وثانيهما عدم اكتشاف العديد من الأخطاء مثل أيّ خطأٍ يحدث لإتلاف مواضع البت نفسها في النسختين الأولى والثانية من الرسالة، ولكن هدف شيفرات اكتشاف الأخطاء هو توفير احتمال كبير لاكتشاف الأخطاء مع عدد قليل نسبيًّا من البتّات الزائدة (redundant bits)، ولحسن الحظ يمكن القيام بأفضل بكثير من هذا المخطط البسيط، حيث يمكن توفير قدرة قويّة جدًّا لاكتشاف الأخطاء مع إرسال k بتّ زائدة فقط لرسالة بحجم n بت، حيث k أصغر بكثير من n، ففي شبكة إيثرنت على سبيل المثال لا يتطلّب الإطار الذي يحمل ما يصل إلى 12000 بتًّا (1500 بايت) من البيانات سوى شيفرة CRC مؤلّفة من 32 بتًا، أو كما يُعبَّرعنها CRC-32، حيث ستلتقط مثل هذه الشيفرة الغالبيّة العظمى من الأخطاء كما سترى لاحقًا. يمكننا القول أنّ البتات الإضافية التي نرسلها زائدةٌ نظرًا لعدم إضافتها لمعلومات جديدة إلى الرسالة، بل تُشتَق مباشرةً من الرسالة الأصليّة باستخدام خوارزميّة معروفة جيّدًا، بحيث يعرف كلّ من المرسل والمستقبل بالضّبط ما هي هذه الخوارزميّة. ويطبّق المرسل الخوارزميّة على الرسالة لتوليد تلك البتات الزائدة، ثم ينقل الرسالة وتلك الأجزاء الإضافية القليلة. وإذا طبّق المستقبل نفس الخوارزميّة على الرسالة المستلمة، فيجب إنتاج نفس نتيجة المرسل (في حالة عدم وجود أخطاء)، حيث يوازن المستقبل تلك النّتيجة مع النّتيجة التي أرسلها المرسل إليه، فإذا تطابقت، يمكنه استنتاج (مع احتمال كبير) عدم حدوث إدخال أخطاءٍ في الرسالة أثناء الإرسال، وإذا لم تتطابق يمكن التأكّد من تلف الرسالة أو البتّات الزائدة، مع وجوب اتّخاذ الإجراء المناسب، أي إهمال الرسالة أو تصحيحها إذا كان ذلك ممكنًا. توجد ملاحظةٌ واحدة حول المصطلحات الخاصّة بهذه البتات الإضافيّة، حيث يشار إليها باسم شيفرات اكتشاف الأخطاء، وقد يطلق عليها في حالات محدّدة، عندما تعتمد الخوارزميّة على الجمع (addition) لإنشاء الشيفرة مثلًا، اسم المجموع الاختباري (checksum)، حيث سترى أنّ المجموع الاختباري للإنترنت قد سُمي بطريقة مناسبة، فهو فحص خطأ يستخدم خوارزميّة جمع، ولكن لسوء الحظ تُستخدَم غالبًا كلمة المجموع الاختباري بصورة غير دقيقة للإشارة إلى أي نوع من أنواع شيفرة اكتشاف الأخطاء بما في ذلك شيفرات CRC، وبالتالي قد يكون هذا محيّرًا، لذلك يمكنك استخدام المجموع الاختباري للتطبيق على الشيفرات التي تستخدم الجمع بالفعل، ويمكنك استخدام شيفرة اكتشاف الأخطاء للإشارة إلى صنف الشيفرات العامّة الموضّحة في هذا القسم. خوارزمية مجموع الإنترنت الاختباري (Internet Checksum Algorithm) تتمثل الطّريقة الأولى لاكتشاف الأخطاء في مجموع الإنترنت الاختباري، وعلى الرّغم من عدم استخدامه على مستوى الرابط، إلا أنّه يوفّر نفس نوع وظائف آليّة CRC. وتُعدّ الفكرة الكامنة وراء مجموع الإنترنت الاختباري بسيطةً للغاية، حيث تضاف كلّ الكلمات المرسَلة ثمّ ترسَل نتيجة هذا المجموع، والنتيجة هي المجموع الاختباري، حيث يجري المستقبل نفس العمليّات الحسابيّة على البيانات المستلَمة ويوازن النتيجة مع المجموع الاختباري المستلَم، وفي حالة تلف أي بيانات مرسلَة، بما في ذلك المجموع الاختباري نفسه، فلن تتطابق النتائج، لذلك يعلم المستقبل بحدوث خطأ. يمكنك تخيّل العديد من الاختلافات حول فكرة المجموع الاختباري الأساسيّة، ويعمل المخطط الذي تستخدمه بروتوكولات الإنترنت على النحو التالي: افترض أنّ البيانات التي سيطبّق عليها المجموع الاختباري هي سلسلة من الأعداد الصحيحة المؤلفة من 16 بتًا، ثم اجمعهم معًا باستخدام متمّم الواحد (ones’ complement) ذو 16 بت (الموضح أدناه)، ثم خذ متمّم الواحد للنتيجة، فهذا الرقم المؤلّف من 16 بتًا هو المجموع الاختباري، حيث يُمثَّل العدد الصحيح السالب -x باستخدام متمّم الواحد كمتمم للعدد x، أي يُقلَب كل بت من x، ويجب إضافة حِمل من البت الأكثر أهميّةً إلى النتيجة عند جمع الأعداد باستخدام مُتمّم الواحد. افترض على سبيل المثال جمع العددين -5 و -3 باستخدام مُتمّم الواحد على أعداد صحيحة مؤلّفة من 4 بتات: +5هي 0101 لذا -5 هي 1010، و +3 هي 0011 لذا -3 يساوي 1100. إذا جمعت العددين 1010، و1100، مع تجاهل الحمل، ستحصل على 0110. لكن بسبب حقيقة أنّ هذه العمليّة تسببت في حمل من البت الأكثر أهميّة باستخدام متمّم الواحد لذلك يجب زيادة النتيجة، قتصبح 0111، وهو تمثيل متمّم الواحد للعدد -8 الذي تحصل عليه من خلال قلب بتات العدد 1000. تعطي الشيفرة الآتية تطبيقًا مباشرًا لخوارزمية مجموع الإنترنت الاختباري، ويعطي الوسيط count طول المتغيّر buf المُقاس بطول 16 بت، حيث تفترض الشيفرة أنّ المتغيّر buf محشوٌّ أصفارًا إلى حد 16 بت: u_short cksum(u_short *buf, int count) { register u_long sum = 0; while (count--) { sum += *buf++; if (sum & 0xFFFF0000) { /* ظهر حِمل في البت الأعلى أهميةً، لذلك أضف واحدًا إلى المجموع */ sum &= 0xFFFF; sum++; } } return ~(sum & 0xFFFF); } تضمن الشيفرة السابقة استخدام العمليّة الحسابيّة لمتمّم الواحد بدلًا من المتمّم الثنائي المستخدم في معظم الأجهزة، ويمكنك ملاحظة استخدام عبارة if داخل حلقة while، وإذا وُجِد حِمل في البت الأعلى من 16 بت في المتغيّر sum الذي يُمثّل المجموع، فيجب زيادة المجموع كما في المثال المذكور سابقًا، وتحقق خوارزمية المجموع الاختباري نتائجًا جيّدةً عند استخدام عددٍ قليل من البتّات الزائدة، 16 بتًا فقط لأية رسالةٍ ذات أيّ طول، ولكنها لا تحقق نتائجًا جيدة للغاية بالنسبة لاكتشاف الأخطاء الأقوى، إذ لن يُكتشَف على سبيل المثال زوجٌ من الأخطاء أحاديّة البت، أحدهما يزيد كلمةً والآخر ينقص كلمةً أخرى بنفس المقدار، ويعود سبب استخدام مثل هذه الخوارزميّة رغم كون حمايتها ضعيفةً نسبيًّا ضدّ الأخطاء (مقارنةً بخوارزميّة CRC على سبيل المثال)، هو أنها خوارزميّة بسيطة، فهذه الخوارزميّة تُعدّ أسهل بكثير في التطبيق ضمن البرمجيّات، وقد أشارت التجربة إلى أنّ المجموع الاختباري (checksum) لهذا النموذج مناسب، ولكن أحد أسباب عدّه مناسبًا يعود لكون المجموع الاختباري بمثابة خطّ الدفاع الأخير في بروتوكول نقطة لنقطة، حيث تلتقط خوارزميّاتٍ أقوى لاكتشاف الأخطاء، مثل خوارزمية CRC، باكتشافها لغالبيّة الأخطاء على مستوى الروابط. فحص التكرار الدوري (Cyclic Redundancy Check أو اختصارًا CRC) يجب أن يكون واضحًا الآن أن الهدف الرئيسي في تصميم خوارزميات اكتشاف الأخطاء هو تكبير احتمالية اكتشاف الأخطاء إلى الحدّ الأقصى باستخدام عدد صغير فقط من البتّات الزائدة، حيث تُستخدم خوارزميّة فحص التكرار الدوري بعض الحسابات القويّة إلى حدّ ما لتحقيق هذا الهدف، وتوفّر خوارزميّة CRC ذات 32 بتًا على سبيل المثال حمايةً قويّةً ضدّ أخطاء البت الشائعة الموجودة في الرسائل التي يبلغ طولها آلاف البايتات. الأساس النظري لهذه الخوارزمية مُتجذّر في فرعٍ من فروع الرياضيات يسمّى الحقول المحدودة (finite fields). قد يبدو هذا صعبًا، ولكن يمكن فهم الأفكار الأساسيّة بسهولة، ابدأ بافتراض أن رسالةً مؤلّفةً من (n + 1) بت ممثّلةٌ بواسطة كثير حدود (polynomial) من الدّرجة n، أي كثير الحدود الذي حدّه الأعلى رتبةً هو xn. تُمثَّل الرسالة بواسطة كثير حدود باستخدام قيمة كلّ بت في الرسالة كمعاملٍ لكلّ حدٍ من كثير الحدود، بدءًا من البت الأعلى أهميّةً لتمثيل الحدّ الأعلى رتبةً، حيث تتكوّن رسالةٌ مؤلّفة من 8 بتات، وهي البتات 10011010 على سبيل المثال التي تتوافق مع كثير الحدود التالي: بالتالي يمكن التفكير في المرسل والمستقبل على أنهما يتبادلان كثيرات الحدود مع بعضهما البعض. يجب على المرسل والمستقبل الاتفاق على كثير الحدود القاسم (divisor polynomial) الذي يُرمز له C (x) لحساب CRC، حيث C (x) هو كثير حدود من الدرجة k. افترض أن C (x) = x3 + x2 + 1 على سبيل المثال، حيث k = 3 في هذه الحالة؛ أمّا الإجابة على السؤال (من أين أتى كثير الحدود C (x)؟) هي (ابحث عنه في السلسلة)، فإن اختياره له تأثير كبير على أنواع الأخطاء التي يمكن اكتشافها بطريقة موثوقة. هناك عدد قليل من كثيرات الحدود القاسمة التي تُعد اختيارات جيدة جدًا لبيئات مختلفة، ويُضاف ذلك الاختيار الدقيق كجزءٍ من تصميم البروتوكول، كما يستخدم معيار إيثرنت على سبيل المثال كثير الحدود المعروف ذو الدرجة 32. إذا أراد المرسل إرسال رسالة M (x) بطول n + 1 بت، فما يُرسَل بالفعل هو الرسالة التي طولها (n + 1) بت بالإضافة إلى k بت. وتدعى الرسالة المرسلة بالكامل بما في ذلك البتات الزائدة P (x). يجب جعل كثير الحدود الذي يمثّل P (x) قابلًا للقسمة تمامًا على C (x)، فإذا أُرسل P (x) عبر رابط دون حدوث أخطاء أثناء الإرسال، فيجب أن يكون المستقبل قادرًا على قسمة P (x)على C (x) تمامًا بلا باقٍ. ومن ناحية أخرى، إذا حدث خطأٌ ما في P (x) أثناء الإرسال، فلن يقبل كثير الحدود المستقبل القسمة تمامًا علىC (x)، وبالتالي سيحصل المستقبل على باقٍ غير صفريّ مما يعني حدوث خطأ. سيساعدك فهم ما يلي إذا كنت تعرف القليل عن حساب كثيرات الحدود، فهو يختلف قليلًا عن حساب الأعداد الصحيحة العاديّة، حيث ستتعامل هنا مع صنفٍ خاصّ من حساب كثيرات الحدود، فقد تكون المعامِلات (coefficients) واحدًا أو صفرًا فقط، وتُجرى العمليّات على المعاملات باستخدام نظام الحساب modulo 2، الذي يُشار إليه باسم نظام حساب modulo 2 لكثير الحدود. لكن نظرًا لأن هذه السلسلة عن الشبكات، وليست عن الرياضيات، فيجب التركيز فقط على الخصائص الرئيسيّة لهذا النوع من الحساب (والتي نطلب منك قبولها بناءً على الثّقة): يمكن تقسيم أيّ كثير حدود B (x) على كثير الحدود القاسم C (x) إذا كان B (x) من درجة أعلى من C (x). يمكن تقسيم أيّ كثير حدود B (x) مرةً واحدة بواسطة كثير الحدود القاسم C (x) إذا كان B (x) من نفس درجة C (x). ينتج باقي قسمة B (x) على C (x) من خلال إجراء عمليّة XOR على كلّ زوج من المعاملات المتطابقة. يمكن قسمة كثير الحدود x3 + 1 مثلًا على كثير الحدود x3 + x2 + 1 (لأنّ كليهما من الدرجة 3) والباقي هو ‎0×x3+1×x2+0×x1+0×x0=x2 (الذي نتج عن طريق إجراء عملية XOR على معاملات كل حد)، وبالتالي يمكن القول أنهّ يمكن قسمة 1001 على 1101 مع باقٍ هو 0100 (يجب أن تكون قادرًا الآن على رؤية أن الباقي هو مجرد تطبيق لعمليّة XOR ثنائيّة على الرسالتين)، وبعد أن عرفت القواعد الأساسيّة لتقسيم كثيرات الحدود، يمكنك إجراء قسمة طويلة، وهو أمر ضروري للتعامل مع الرسائل الأطول. تذكر أنك تريد إنشاء كثير حدود للإرسال مشتق من الرسالة الأصلية M (x)، فهل k بت أطول من M (x)، وهل M (x) قابلة للقسمة تمامًا على C (x)؟ يمكنك القيام بذلك بالطريقة التالية: اضرب M (x) بـ xk وهذا يعني إضافة k صفرًا في نهاية الرسالة. ادعُ هذه الرسالة الصفرية الموسعة T (x). اقسم T (x) على C (x) وجِد الباقي. اطرح الباقي من T (x). يجب أن يكون واضحًا أن ما تبقّى في هذه المرحلة هو رسالة قابلة للقسمة تمامًا على C (x)، وقد تلاحظ أيضًا أنّ الرسالة الناتجة تتكوّن من M (x) متبوعةً بالباقي الناتج عن الخطوة 2، فعند طرح الباقي (والذي لا يمكن أن يزيد طوله عن k بت)، فكأنّك أجريت عمليّة XOR فقط للباقي مع k صفرًا التي أُضيفت في الخطوة 1، وسيصبح هذا الجزء أوضح بمثال: افترِض أنّ الرسالة هي x7 + x4 + x3 + x1 أو 10011010. ابدأ بالضرب بـ x3، فكثير الحدود القاسم هو من الدرجة 3، وهذا يعطي 10011010000، ثمّ اقسم النتيجة السابقة على C (x) الذي يقابل 1101 في هذه الحالة. يوضح الشكل الآتي عمليّة قسمة كثير حدود طويلة، وبالنظر إلى قواعد حساب كثيرات الحدود الموصوفة سابقًا، تستمر عمليّة القسمة الطويلة كما لو كنت تقسم الأعداد الصحيحة، وهكذا ترى في الخطوة الأولى من المثال أنّ المقسوم عليه 1101 يُقسم عليه مرّةً واحدةً أوّل أربعة بتات من الرسالة (1001)، نظرًا لأنهما من نفس الدرجة، والباقي هو 100، أو (1101 XOR 1001). تتمثّل الخطوة التالية في إنزال رقم من كثير حدود الرسالة لتحصل على كثير حدود آخر بنفس درجة C (x)، وفي هذه الحالة هو 1001، ثمّ تحسب الباقي مرّةً أخرى وهو (100) وتستمر حتّى اكتمال الحساب. لاحظ أنّ نتيجة القسمة الطويلة (long division)، التي تظهر في الجزء العلويّ من الحساب، ليست ذات أهميّة كبيرة حقيقةً، وإنّما الباقي (remainder) في النهاية هو المهم. يمكنك الرّؤية في أسفل الشّكل الآتي أنّ باقي القسمة هو 101، لذلك ستعلم أنّ 10011010000 ناقص 101 سيكون قابلًا للقسمة تمامًا على C (x)، وهذا الذي سيُرسَل. عمليّة الطرح في حساب كثير الحدود هي عمليّة XOR منطقية، لذلك ما يُرسَل فعليًّا هو 10011010101، والذي هو عبارة عن الرسالة الأصلية فقط ملحوقًا بها باقي حساب القسمة الطويلة. يقسم المستقبل كثير الحدود المستلَم على C (x)، وإذا كانت النتيجة 0، فسيستنتج عدم وجود أخطاء؛ أمّا إذا كانت النتيجة غير صفريّة، فقد يكون من الضروري تجاهل الرسالة التالفة، ولكن قد يكون من الممكن تصحيح خطأٍ صغير مع وجود بعض الشيفرات (إذا أثر الخطأ على بت واحد فقط على سبيل المثال). يُطلق على الشيفرة التي تفعّل تصحيح الخطأ شيفرة تصحيح الخطأ (error-correcting code أو اختصارًا ECC). ولكن من أين أتى كثير الحدود C (x)؟ تكمن الفكرة في اختيار كثير الحدود هذا، بحيث لا يمكن تقسيم رسالة بها أخطاء عليه، فإذا كانت الرسالة المرسلة هي P (x)، فيمكن افتراض أنّ إدخال الأخطاء هو عبارة عن إضافة كثير حدود آخر هو E (x)، لذلك يرى المستقبل P (x) + E (x). الطريقة الوحيدة التي قد يمرّ بها الخطأ دون اكتشافه هي إذا كان من الممكن تقسيم الرّسالة المستلمة على C (x)، وبما أنك تعلم أن P (x) يمكن تقسيمها على C (x)، فقد يحدث هذا فقط إذا أمكن قسمة E (x) على C (x) أيضًا، ولكن الحل هو اختيار C (x) بحيث يكون غير محتملٍ جدًا بالنسبة لأنواع الأخطاء الشائعة، فمن بين أنواع الأخطاء الشائعة هو الخطأ أحادي البت (single-bit error)، والذي يمكن التعبير عنه بـ E (x) = xi عندما يؤثّر على موضع البت i إذا اخترت كثير الحدود ،C (x) بحيث يكون الحدّ الأوّل والأخير (أي الحدان xk و x0) غير صفريّين، فعندئذ يكون لديك بالفعل حدَّا كثير حدود لا يمكن تقسيمهما على حدٍّ واحد E (x)، وبالتالي يستطيع C (x) اكتشاف جميع الأخطاء أحادية البت. من الممكن إثبات أن الأنواع التالية من الأخطاء يمكن اكتشافها بواسطة كثير الحدود C (x): جميع الأخطاء أحادية البت (single-bit errors)، طالما أنّ الحدين xk وx0 لهما معاملات غير صفريّة. جميع أخطاء البت المضاعفة (double-bit errors)، طالما أنّ كثير الحدود C (x) له معامل بثلاثة حدود على الأقلّ. أيّ عدد فردي من الأخطاء (odd number of errors)، طالما يتضمّن كثير الحدود C (x) المعامل (x + 1). من الممكن استخدام الشيفرات التي لا تكتشف وجود الأخطاء فحسب، بل تتيح أيضًا تصحيح الأخطاء، ولن نتناول تفاصيل هذه الشيفرات هنا نظرًا لأنها تتطلّب رياضيات أعقد من تلك المطلوبة لفهم خوارزميّة CRC، ومع ذلك يجب معرفة مزايا تصحيح الأخطاء مقابل اكتشافها، فقد يبدو تصحيح (correction) الأخطاء للوهلة الأولى أفضل دائمًا من اكتشافها (detection)، لأنه يجب التخلّص من الرسالة عند اكتشاف الأخطاء، ثمّ طلب إرسال نسخةٍ أخرى، وبالتالي يُستهلك حيّز النطاق التراسلي وقد يؤدّي إلى التأخير أثناء انتظار إعادة الإرسال، لكن هناك جانب سلبي للتصحيح، إذ يتطلّب عمومًا عددًا أكبر من البتّات الزائدة لإرسال شيفرة تصحيح أخطاء قويّة (أي قادرة على التعامل مع نفس نطاق الأخطاء) مثل الشيفرة التي تكتشف أخطاءً فقط، وبالتالي بينما يتطلّب اكتشاف الخطأ إرسال مزيدٍ من البتّات عند حدوث أخطاء، فيتطلّب تصحيح الخطأ إرسال مزيدٍ من البتات طوال الوقت، لذلك يميل تصحيح الخطأ إلى أن يكون ذو فائدة أكبر عندما: (1) تكون الأخطاء محتملةً تمامًا، مثل البيئة اللّاسلكية على سبيل المثال، أو (2) تكلفة إعادة الإرسال مرتفعة للغاية، بسبب احتواء التأخير إعادة إرسال رزمة عبر رابط فضائي على سبيل المثال. يُشار أحيانًا إلى استخدام شيفرات تصحيح الأخطاء في الشبكات باسم تصحيح الأخطاء الأمامي (forward error correction أو اختصارًا FEC)، لأن تصحيح الأخطاء يُعالَج مسبقًا عن طريق إرسال معلومات إضافية، بدلًا من انتظار حدوث الأخطاء والتعامل معها لاحقًا عن طريق إعادة الإرسال، حيث يشيع استخدام آلية FEC في الشبكات اللاسلكية، مثل: 802.11. أي رشقة (burst) أخطاء (أي سلسلة بتات خاطئة متعاقبة) يكون طول الرشقة فيها أقل من k بت (معظم أخطاء الرشقات ذات طول أكبر من k يمكن أيضًا اكتشافها). تُستخدم ستّة إصدارات من كثير الحدود C (x)على نطاق واسع في بروتوكولات مستوى الرابط، حيث يستخدم الإيثرنت آليّة CRC-32 على سبيل المثال، والذي يُعرَّف على النحو التالي: لاحظ أخيرًا أنّ خوارزميّة CRC، رغم أنّها تبدو معقدة، ولكنها تُطبَّق بسهولة في العتاد باستخدام مسجّل إزاحة بحجم k بت وبوّابات XOR، فعدد البتات في مسجل الإزاحة يساوي درجة كثير الحدود المولد (k)، ويوضح الشّكل الآتي العتاد الذي سيستخدمه المولّد x3 + x2 + 1 من المثال السابق. حيث تُزاح الرسالة من اليسار بدءًا من البت الأكثر أهميّة وتنتهي بسلسلة k صفرًا المرفقة بالرسالة، فكما في مثال القسمة الطويلة إذا أُزيحت جميع البتّات وطُبِّق عليها XOR بطريقة مناسبة، فسيحتوي المسجّل على الباقي، وهذه هي خوارزميّة CRC (البت الأعلى أهميّةً على اليمين). يُحدَّد موضع بوابات XOR على النحو التالي: إذا سُميَّت البتات في مسجل الإزاحة من 0 إلى k − 1 من اليسار إلى اليمين، فضع بوّابة XOR أمام البت n إذا كان هناك حد xn في كثير الحدود المولّد، وبالتالي ترى بوابة XOR أمام الموضعين 0 و2 للمولد x3 + x2 + x0. ترجمة -وبتصرّف- للقسم Error Detection من فصل Direct Links من كتاب Computer Networks: A Systems Approach
  4. التشفير (Encoding) تتمثّل الخطوة الأولى في تحويل العُقد (nodes) والرّوابط (links) إلى لبنات أساسيّة قابلة للاستخدام من خلال فهم كيفيّة توصيلها بطريقة يمكن بها نقل البتّات من عقدة إلى أخرى، حيث تنتشر الإشارات عبر الرّوابط الفيزيائية، وتُشفَّر البيانات الثنائية التي تريد العقدة المصدر إرسالها إلى إشارات تستطيع الرّوابط حملها، ثم يُفكّ تشفير الإشارة مرّةً أخرى إلى البيانات الثّنائية المقابلة في العقدة المستقبلّة، يمكن تجاهل تفاصيل التّعديل (modulation) وافتراض أنّك تعمل مع إشارتين منفصلتين: مرتفعة ومنخفضة، ولكن قد تتوافق هذه الإشارات من الناّحية العمليّة مع جهدين (voltages) مختلفين على رابط نحاسي، أو مستويين مختلفين من القدرة (power) على رابط ضوئيّ، أو سعتين (amplitudes) مختلفتين على الإرسال الراديوي، وتُنفَّذ معظم الوظائف الموجودة في هذا المقال بواسطة محوّل الشّبكة (network adaptor) الذي هو عبارة عن قطعة عتاد تصل بين عقدةٍ ورابط، ويحتوي محوّل الشّبكة على مكوّن إشارة (signalling component) يشفّر فعليًّا البتّات إلى إشارات عند عقدة الإرسال ويفكّ تشفير الإشارات إلى بتات في عقدة الاستقبال، حيث تنتقل الإشارات عبر رابط بين مكوّنَي الإشارة، وتتدفّق البتات بين محوّلات الشّبكة (network adaptors) كما هو موضّح في الشّكل التّالي: بالعودة إلى مشكلة تشفير البتات إلى إشارات، فالشيء الواضح الذي يجب فعله هو ربط القيمة 1 بالإشارة المرتفعة (high signal) والقيمة 0 بالإشارة المنخفضة (low signal)، وهذا هو بالضّبط ما يستخدمه مخطّط تشفير (encoding scheme) مشفَّرٌ بدرجة كافية يدعى عدم العودة إلى الصّفر (non-return to zero ويختصر إلى NRZ)، ويوضّح الشّكل التّالي على سبيل المثال بيانيًّا إشارة NRZ المشفَّرة (في أسفل الشّكل) التي تقابل إرسال سلسلة محدّدة من البتات (في أعلى الشّكل): تكمن مشكلة ترميز NRZ بوجود سلسلة واحدات متعاقبة والتي تعني بقاء الإشارة مرتفعة على الرّابط لمدّة طويلة من الزّمن، وبالمثل يشير وجود عدّة أصفار متعاقبة إلى بقاء الإشارة منخفضةً لمدّة طويلة. توجد مشكلتان أساسيّتان تسبّبهما سلاسل الواحدات أو الأصفار الطّويلة، حيث تؤدّي المشكلة الأولى إلى حالةٍ تدعى التجوّل الأساسي (baseline wander)، حيث يحتفظ المستقبِل على وجه التّحديد بمتوسّط الإشارة التي شاهدها حتى الآن، ثمّ يستخدم هذا المتوسّط للتّمييز بين الإشارات المنخفضة والمرتفعة، فيستنتج المستقبِل أنه قد رأى صفرًا للتّو عندما تكون الإشارة أقلّ بكثير من هذا المتوسط، وبالمثل تُفسَّر الإشارة التي تكون أعلى بكثير من المتوسّط على أنّها 1. تكمن المشكلة بالطبع في أن عددًا كبيرًا جدًا من الواحدات أو الأصفار المتعاقبة يتسبّب في تغيير هذا المتوسّط، مما يجعل اكتشاف تغيير كبير في الإشارة أمرًا صعبًا. تتمثل المشكلة الثّانية في أنّ التّحولات المتكرّرة من الأعلى إلى الأسفل والعكس ضروريّة لتفعيل استعادة السّاعة (clock recovery)، فتتمثل مشكلة استعادة الساّعة في أن كلًا من عمليات التّشفير وفكّ التّشفير مُساقة بواسطة ساعة، حيث يرسل المرسل بتًا ويستقبل المستقبل بتًا في كل دورة ساعة. يجب أن تكون ساعات المرسل والمستقبل متزامنة بدقّة من أجل استعادة المستقبل لنفس البتات التي يرسلها المرسل، فإذا كانت ساعة المستقبل أسرع أو أبطأ قليلًا من ساعة المرسل، فلن يستطيع فكّ تشفير الإشارة بصورة صحيحة. يمكنك تخيل إرسال السّاعة إلى المستقبل عبر سلك منفصل، ولكن يجب تجنب ذلك إذ سيزيد من تكلفة توصيل الكابلات مرّتين، لذلك بدلًا من ذلك يستخرج المستقبل السّاعة من الإشارة المستقبلة حيث تدعى هذه العمليّة بعمليّة استعادة السّاعة، حيث يعرف المستقبل أنّه على حدود دورة ساعة عندما تتغيّر الإشارة كما هو الحال عند الانتقال من القيمة 1 إلى القيمة 0 أو من القيمة 0 إلى القيمة 1، وبالتالي يستطيع المستقبل إعادة مزامنة نفسه، ومع ذلك يؤدّي مرور فترة طويلة من الزّمن دون حدوث مثل هذا الانتقال إلى انجراف السّاعة (clock drift)، وبالتالي تعتمد استعادة السّاعة على وجود الكثير من الانتقالات في الإشارة بغضّ النّظر عن البيانات التي تُرسَل. تجعل إحدى الطّرق التي تعالج هذه المشكلة المرسل ينتقل من الإشارة الحاليّة من أجل تشفير القيمة ،1 ويبقى عند الإشارة الحاليّة من أجل تشفير القيمة 0، وتدعى هذه الطّريقة عدم العودة إلى الصفر المعكوس (non-return to zero inverted ويُختصر إلى NRZI)، حيث تحلّ هذه الطّريقة مشكلة الواحدات المتعاقبة، ولكن من الواضح أنّها لا تفعل شيئًا بالنسبة للأصفار المتعاقبة، ويوضّح الشّكل التّالي ترميز NRZI. يوجد بديل يدعى ترميز مانشستر (Manchester encoding) الذي يقوم بأمرٍ أوضح لدمج السّاعة مع الإشارة خلال إرسال عمليّة "أو" المقصورة (exclusive OR أو اختصارًا XOR) لبيانات NRZ المشفَّرة والساعة. يمكنك التّفكير بالسّاعة المحليّة كإشارة داخليّة تتناوب من المنخفض إلى المرتفع، حيث يُعدّ الزّوجُ (منخفض / مرتفع) دورةَ ساعةٍ واحدة. ويوضح الشّكل الآتي ترميز مانشستر أيضًا، حيث يُرمَّز ترميز مانشستر الذي ينتج عنه القيمة 0 على أنّه انتقالٌ من منخفض إلى مرتفع ويُرمَّز ترميز مانشستر الذي ينتج عنه القيمة 1 على أنّه انتقال من مرتفع إلى منخفض، ويمكن استعادة السّاعة بفعاليّة في المستقبل لأن كلًّا من القيمتين 0 و1 يسبّبان الانتقال إلى الإشارة. ويوجد أيضًا ترميز مغاير عن ترميز مانشستر، يُسمى تفاضل مانشستر (Differential Manchester)، حيث تساوي القيمةُ 1 المُشفَّرة مع النّصف الأوّل من الإشارة النصفَ الأخير من إشارة البت السابق، وتعاكس القيمةُ 0 المُشفَّرة مع النصف الأوّل من الإشارة النصفَ الأخير من إشارة البت السابق. تكمن مشكلة مخطّط ترميز مانشستر في أنّه يضاعف معدّل انتقالات الإشارة على الرابط، مما يعني أنّه لدى المستقبل نصف الوقت لاكتشاف كلّ نبضة إشارة، ويُطلق على المعدّل الذي تتغيّر به الإشارة بمعدل الباود (baud rate) للرابط، ويكون معدّل البت في حالة ترميز مانشستر نصف معدّل الباود، لذلك يُعَد الترميز فعّالًا بنسبة 50% فقط. ضع في حساباتك أنه إذا كان المستقبل قادرًا على مواكبة معدّل الباود الأسرع الذي يتطلّبه ترميز مانشستر في الشّكل السابق، فيمكن لكلّ من ترميزَي NRZ وNRZI إرسال ضعف عدد البتّات في نفس الفترة الزمنيّة. لاحظ أنه ليس بالضرورة أن يكون معدل البت أقل من معدّل الباود أو يساويه كما يوحي ترميز مانشستر، فإذا كان مخطّط التعديل قادرًا على استخدام (والتعرف على) أربع إشارات مختلفة بدلًا من اثنتين فقط (مرتفعة ومنخفضة على سبيل المثال)، فمن الممكن تشفير بتّين في كلّ فترة ساعة، مما ينتج عنه معدّل بت يعادل ضعف معدّل الباود. تعني القدرة على التعدّيل بين ثماني إشارات مختلفة القدرةَ على إرسال ثلاثة بتات لكلّ فترة ساعة، ومن المهمّ وضعك في بالك لوجود تعديلٍ مبسّط بإفراط، وهو أعقد من إرسال إشارات مرتفعة ومنخفضة، فمن المألوف تغيير مجموعة أطوار وسعات إشارة، مما يؤدي إلى تشفير 16 أو حتى 64 نمطًا مختلفًا، غالبًا رموز مظلمة (dalled symbols)، خلال كلّ فاصل زمنيّ على مدار السّاعة، حيث تعديل سعة التربيع (Quadrature Amplitude Modulation ويختصر إلى QAM) هو مثال مستخدم على نطاق واسع لمخطط التعديل هذا. يحاول التّرميز النهائي المسمّى 4B / 5B، معالجة عدم كفاءة ترميز مانشستر دون المعاناة من مشكلة تمديد فترات الإشارات المرتفعة أو المنخفضة، وتتمثّل فكرة ترميز 4B / 5B في إدخال بتات إضافية إلى مجرى البتات لتفكيك سلاسل طويلة من الأصفار أو الواحدات، وتُشفَّر كل 4 بتات من البيانات الفعليّة في شيفرة مكوّنة من 5 بتات تُرسَل بعد ذلك إلى المستقبل، حيث جاء الاسم 4B / 5B من ذلك. تُحدَّد الشيفرات المكونة من 5 بتات بحيث لا يحتوي كلّ منها على أكثر من صفرٍ واحد في بدايتها ولا يزيد عن صفرين في النهاية، وبالتالي لا ينتج عن أيّ زوج من الشيفرات ذات 5 بتات إرسال أكثر من ثلاثة أصفار متعاقبة وذلك عند الإرسال بالتّتالي، ثم تُرسَل الشيفرات ذات الـ 5 بتات الناتجة باستخدام ترميز NRZI، وهذا ما يفسّر سبب اهتمام الشيفرة بالأصفار المتعاقبة فقط، حيث يحلّ ترميز NRZI مشكلة الوحدات المتعاقبة. لاحظ أنّ ترميز 4B / 5B ينتج كفاءةً بنسبة 80%. يعطي الجدول التّالي شيفرات 5 بتات التي تتوافق مع كلّ رمز من 16 رمزًا من بيانات ذات 4 بتات ممكنة: رمز بيانات ذو 4 بتات (4-bit Data Symbol) شيفرة ذات 5 بتات 0000 11110 0001 01001 0010 10100 0011 10101 0100 01010 0101 01011 0110 01110 0111 01111 1000 10010 1001 10011 1010 10110 1011 10111 1100 11010 1101 11011 1110 11100 1111 11101 table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } لاحظ أن 5 بتّات كافية لترميز 32 شيفرة مختلفة حيث تُستخدم 16 شيفرة منها فقط للبيانات، فهناك 16 شيفرة متبقّية يمكن استخدامها لأغراض أخرى من بينها الشيفرة 11111 التي تُستخدم عندما يكون الخطّ خاملًا بدون عمل، وتقابل الشيفرة 00000 ما يشير إلى تعطُّل الخطّ (dead)، وتُفسَّر الشيفرة 00100 بأن الخطّ مقطوع (halt)، ويوجد 7 شيفرات من بين 13 شيفرةً متبقية غير صالحة لأنها تنتهك قاعدة وجود صفر واحد في البداية وصفرين في النهاية، وتمثل الـ 6 شيفرات الأخرى رموز تحكم مختلفة. تستخدم بعض بروتوكولات التأطير (framing protocols) الموضحة لاحقًا في هذا المقال رموز التحكم هذه. التأطير (Framing) فكّر في السيناريو الموجود في الشكل الآتي بعد معرفتك بكيفية نقل سلسلة من البتّات عبر رابط نقطة لنقطة أو من محوّل إلى محوّل، وتذكّر أننا نركز على شبكات تبديل الرّزم (packet-switched networks)، ممّا يعني أن كتل البيانات تسمى إطارات (frames) عند هذا المستوى، وليس مجرى بتات تتبادلها العُقد، حيث يمكّن محوّل الشبكة العُقد من تبادل الإطارات، وتخبر العقدة A محوّلها بإرسال إطار من ذاكرة العقدة عندما ترغب في إرسال إطار إلى العقدة B، فينتج عن هذا سلسلة من البتات تُرسَل عبر الرابط. ثم يجمع المحوّل الموجود على العقدة B سلسلة البتات الواصلة إلى الرابط معًا ويودِع الإطارَ المقابل في ذاكرة العقدة B، ويُعدّ التعرف على مجموعة البتات التي تشكّل إطارًا بالضبط، أي تحديد مكان بدء الإطار ونهايته، هو التّحدي الأساسي الذي يواجهه المحوّل. توجد عدّة طرق لمعالجة مشكلة التأطير، حيث يستخدم هذا القسم ثلاثة بروتوكولات مختلفة لذلك. لاحظ أنه بينما نناقش مشكلة التأطير في سياق الرّوابط من نقطة لنقطة، فهذه المشكلة هي مشكلة أساسيّة ويجب معالجتها أيضًا في شبكات الوصول المتعدّد (multiple-access)، مثل: شبكات الإيثرنت، والشبكات اللّاسلكية. البروتوكولات الموجّهة بالبايت (Byte-Oriented Protocols مثل بروتوكول PPP) إحدى أقدم الطرق المستخدمة للتأطير والتي لها جذورها في توصيل الطرفيات بالحواسيب المركزيّة هي التي تَعدّ كل إطار على أنه مجموعة من البايتات أو الأحرف بدلًا من كونها مجموعة من البتات، ومن الأمثلة القديمة لهذه البروتوكولات الموجَّهة بالبايت بروتوكول الاتصال الثنائي المتزامن (Binary Synchronous Communication ويختصر إلى BISYNC) الذي طورته شركة IBM في أواخر الستينات، وبروتوكول رسائل اتصالات البيانات الرقمية (Digital Data Communication Message Protocol واختصارًا DDCMP) المستخدم في بروتوكولات DECNET التي أنشأتها شركة Digital Equipment Corporation، وبنت شركات الحواسيب الكبيرة، مثل: شركتي IBM، وDEC ذات مرّة أيضًا شبكاتٍ خاصةً لعملائها، وبروتوكول نقطة لنقطة (Point-to-Point Protocol واختصارًا PPP) المستخدم على نطاق واسع هو مثال حديث للطرق المستخدمة للتأطير. توجد طريقتان للتأطير الموجّه بالبايت على مستوى عالٍ، الطريقة الأولى هي استخدام أحرف خاصّة معروفة باسم الأحرف الحارسة (sentinel characters) لتحديد مكان بدء الإطارات ونهايتها، والفكرة هي الإشارة إلى بداية الإطار عن طريق إرسال حرفٍ خاصّ بالتزامن هو SYN. ويُحتوى أحيانًا جزء البيانات من الإطار بين حرفين خاصّين آخرين هما: STX (بداية النصّ)، وETX (نهاية النصّ)، ويستخدم بروتوكول الاتصال الثنائي المتزامن BISYNC هذه الطّريقة، تكمن مشكلة طريقة الحارس (sentinel approach) في أن أحد الأحرف الخاصّة قد يظهر في جزء البيانات من الإطار، الطّريقة القياسيةّ للتغلّب على هذه المشكلة عن طريق تحويل هذا الحرف بوضع حرف تحويل رابط البيانات (data-link-escape أو اختصارًا DLE) قبله كلّما ظهر في جسم الإطار، حيث يجري تخطّي حرف DLE أيضًا (بوضع حرف DLE إضافي قبله) في جسم الإطار. قد يلاحظ مبرمجو لغة البرمجة C تشابه هذا مع الطريقة التي تحوَّل بها علامة الاقتباس (quotation mark) بواسطة الشرطة المائلة العكسيّة (backslash) عند ظهورها داخل سلسلة (string)، ويُطلق على هذا الأسلوب غالبًا حشو الأحرف (character stuffing) بسبب إدخال أحرف إضافيّة في جزء البيانات من الإطار. بديل اكتشاف نهاية إطار باستخدام قيمة حارسة (sentinel value) هو تضمين عدد بايتات الإطار في بداية الإطار وتحديدًا في ترويسة الإطار، حيث يستخدم بروتوكول DDCMP هذه الطريقة، ويتمثّل أحد مخاطر هذه الطريقة ، في إمكانيّة إفساد خطأ الإرسال لحقل العدّ (count field)، وفي هذه الحالة لن يُكتشف نهاية الإطار بصورة صحيحة. توجد مشكلة مماثلة مع الطريقة القائمة على الحارس إذا فسد حقل ETX، فإذا حدث ذلك، فسيجمّع المستقبل عدد البايتات كما يشير حقل العدّ التالف، ثم يستخدم حقل اكتشاف الخطأ لتحديد تلف الإطار، ويسمى هذا أحيانًا بخطأ التأطير (framing error)، ثمّ سينتظر المستقبل حتّى يرى حرف SYN التالي ليبدأ بجمع البايتات التي تُشكّل الإطار التالي، لذلك من الممكن تسبب خطأ التأطير في تلقّي إطارات متتالية بصورة غير صحيحة، ويستخدم بروتوكول نقطة لنقطة (Point-to-Point Protocol أو اختصارًا PPP)، والذي يشيع استخدامه لنقل رزم بروتوكول الإنترنت (Internet Protocol) عبر أنواع مختلفة من روابط نقطة لنقطة، ولنقل الحرّاس (sentinels)، وحشو الأحرف (character stuffing). تجد صيغة إطار PPP في الشّكل التالي: يعرض الشّكل السّابق الرّزمة مثل سلسلة من الحقول المصنَّفة، ويوجد فوق كلّ حقل رقمٌ يشير إلى طول هذا الحقل بالبتات، ولاحظ أنّ الرّزم تُرسَل بدءًا من الحقل الموجود في أقصى اليسار، فحرف بداية النصّ (start-of-text) الخاصّ الذي يُشار إليه على أنه حقل الراية Flag هو 01111110، ويحتوي حقلًا العنوان Address والتّحكم Control عادةً قيمًا افتراضية وبالتالي فهي غير مهمّة. ويُستخدم حقل البروتوكول (Protocol) لفكّ تعدُّد الإرسال، حيث يحدّد البروتوكول عالي المستوى مثل بروتوكول IP. يمكن التفاوض على حجم حِمل (payload) الإطار، ولكنّه يبلغ 1500 بايت افتراضيًا. يكون حقل المجموع الاختباري Checksum إمّا 2 (افتراضيًا) أو 4 بايتات. لاحظ أنه على الرّغم من الاسم الشائع لهذا الحقل إلا أنه في الواقع عبارة عن CRC، وليس checksum (كما سنوضّح لاحقًا). تختلف صيغة إطار بروتوكول PPP في مسألة التفاوض على العديد من أحجام الحقول بدلًا من إصلاحها، حيث يُجرى هذا التفاوض بواسطة بروتوكول يُسمّى بروتوكول التحكّم بالرابط (Link Control Protocol ويختصر إلى LCP). يعمل كلّ من بروتوكولي PPP، وLCP جنبًا إلى جنب، فيرسل بروتوكول LCP رسائل تحكّم مغلَّفة ضمن إطارات بروتوكول PPP، ويشار إلى هذه الرسائل بواسطة معرّف LCP في حقل PPP (حقل Protocol)، ثم يغيّر بروتوكول LCP صيغة إطار PPP استنادًا إلى المعلومات الواردة في رسائل التحكم هذه، ويشارك بروتوكول LCP أيضًا في إنشاء رابط بين نظيرين (peers) عندما يكتشف كلا الجانبين أن الاتّصال عبر هذا الرّابط ممكن (عندما يكتشف كلّ مستقبل ضوئي إشارةً واردةً من الألياف التي يتّصل بها على سبيل المثال). البروتوكولات الموجّهة بالبت (Bit-Oriented Protocols مثل بروتوكول HDLC) لا تهتمّ البروتوكولات الموجّهة بالبتّ بحدود البايت على عكس البروتوكولات الموجّهة بالبايت، حيث تنظر البروتوكولات الموجّهة بالبتّ ببساطة إلى الإطار على أنه مجموعة من البتات، وقد تأتي هذه البتات من بعض مجموعات الأحرف كنظام ASCII، وقد تكون قيم بكسلات في صورة، كما قد تكون تعليمات ومُعامَلات من ملف تنفيذيّ. بروتوكول التّحكم في رابط البيانات المتزامن (Synchronous Data Link Control الذي يختصر إلى SDLC) الذي طوّرته شركة IBM هو مثال على بروتوكولٍ موجَّه بالبت. وحّدت منظّمة ISO لاحقًا بروتوكول SDLC مثل بروتوكول التحكم في رابط البيانات عالي المستوى (High-Level Data Link Control واختصارًا HDLC). وستجد صيغة إطار HDLC في الشّكل التالي: يشير بروتوكول HDLC إلى بداية ونهاية الإطار مع سلسلة بتات مميّزة هي 01111110، وتُرسَل هذه السّلسلة أيضًا أثناء أيّ وقت يكون فيه الرّابط خاملًا بحيث يمكن للمرسل والمستقبل إبقاء ساعاتهما متزامنةً، باستخدام كلا البروتوكولين بهذه الطريقة طريقةَ الحارس (sentinel approach)، ونظرًا لأنّ سلسلة البتات هذه قد تظهر في أيّ مكان في جسم الإطار، فقد تعبر البتّات 01111110 حدود البايت، حيث تستخدم البروتوكولات الموجَّهة بالبتّ حرف DLE التناظري، وهي تقنيّة تُعرّف باسم حشو البتات (bit stuffing). يعمل حشو البتات في بروتوكول HDLC على النحو التالي: تُرسل خمسة واحدات متعاقبة على جانب الإرسال في أيّ وقت من نصّ الرسالة (باستثناء عندما يحاول المرسل إرسال السلسلة المميزة 01111110)، ويحشو المرسل 0 قبل إرسال البتّ التالي؛ أمّا على الجانب المستقبل وفي حالة وصول خمسة واحدات متعاقبة، فيتّخذ المستقبل قراره بناءً على البتّ التالي الذي يراه (أي البت الذي يتبع الواحدات الخمسة)، فإذا كان البت التالي يساوي 0، فلابدّ أنّها محشوّة وبالتالي يزيلها المستقبل، وإذا كان البت التالي 1، فسيكون أحد الأمرين صحيحًا، فإمّا أن تكون هذه علامة نهاية الإطار، أوقد أُدخِل خطأٌ في مجرى البتات، ويمكن للمستقبل التمييز بين هاتين الحالتين بالنظر إلى البت التالي، فإذا شاهد 0 (أي أنّ آخر 8 بتات نظر إليها هي01111110) فهذا يعني أنهّا علامة نهاية الإطار، وإذا شاهد 1 (أي أن آخر 8 بتات نظر إليها هي01111111)، فلا بد من وجود خطأ ثم يتجاهل الإطار بأكمله. يتعيّن على المستقبل في الحالة الأخيرة انتظار السلسلة 01111110 التالية قبل بدئه في الاستلام مرّةً أخرى، ونتيجةً لذلك هناك احتمال فشل المستقبل في استلام إطارين متتاليين. ومن الواضح أنه لا تزال هناك طرق يمكن من خلالها عدم اكتشاف أخطاء التأطير، كما هو الحال عند إنشاء نمط نهاية الإطار الزائف بالكامل بسبب الأخطاء، ولكن هذه الإخفاقات غير مُرجّحة نسبيًّا، وستناقش الطرق القوية لاكتشاف الأخطاء في قسم لاحق. من الخصائص المثيرة للاهتمام لحشو البتات، بالإضافة إلى حشو الأحرف، أن حجم الإطار يعتمد على البيانات التي تُرسَل في حِمل الإطار. في الواقع ليس ممكنًا جعل جميع الإطارات بنفس الحجم تمامًا، نظرًا لأن البيانات التي يمكن حملها في أي إطار عشوائية، ولإقناع نفسك بهذا، ضع في بالك ما يحدث إذا كان آخر بايت من جسم الإطار هو الحرف ETX. ستُشرح صيغة التأطير التي تضمن أن جميع الإطارات لها نفس الحجم في الفقرة التالية. التأطير المعتمد على الساعة (Clock-Based Framing مثل معيار SONET) يمثّل معيار الشّبكة الضوئيّة المتزامنة (Synchronous Optical Network أو اختصارًا SONET) الطريقةَ الثالثة للتأطير، ويشار إلى هذه الطريقة ببساطة على أنّها تأطير معتمد على الساعة لعدم وجود مصطلح عامّ مقبول على نطاق واسع. اقترحت أبحاث بيل للاتصالات (Bell Communications Research أو Bellcore) معيار SONET لأوّل مرّة، ثمّ طوّره المعهد القومي الأمريكي للمواصفات القياسيّة (American National Standards Institute وتُختصر إلى ANSI) للإرسال الرقمي عبر الألياف الضوئيّة، واعتمده الاتحاد الدولي للاتصالات ITU-T منذ ذلك الحين، وكان معيار SONET لسنوات عديدة هو المعيار المهيمن لنقل البيانات لمسافات طويلة عبر الشبكات الضوئيّة. يجب توضيح نقطة مهمّة حول معيار SONET وهي أنّ مواصفاته الكاملة أكبر بكثير من هذا الكتاب، وبالتالي ستغطّي المناقشة التالية النقاط الهامّة للمعيار فقط. يعالج معيار SONET كلًا من مشكلة التأطير ومشكلة التشفير، كما أنه يعالج مشكلة مهمّةً جدًّا لشركات الهاتف وهي مشكلة دمج (multiplexing) عدّة روابط منخفضة السّرعة على رابطٍ واحد عالي السّرعة، يعكس تصميم هذا المعيار في الواقع حقيقة أنّ شركات الهاتف يجب عليها الاهتمام بدمج أعداد كبيرة من القنوات التي تبلغ سرعتها 64 كيلوبت في الثانية والتي تُستخدم تقليديًا للمكالمات الهاتفيّة. يحتوي إطار SONET على بعض المعلومات الخاصّة التي تخبر المستقبل بمكان بدء الإطار ونهايته كما هو الحال مع مخطّطات الإطارات التي نوقشت سابقًا، ومع ذلك فهذا هو وجه التّشابه الوحيد بينهما، ومن الجدير بالذّكر أنه لا يستخدم حشو البتات، بحيث لا يعتمد طول الإطار على البيانات المرسلة، لذا فالسؤال الذي يجب طرحه هو كيف يعرف المستقبل أين يبدأ كلّ إطار وأين ينتهي؟ يُعدّ هذا السؤال مرتبطًا برابط SONET الأقلّ سرعةً، والذي يُعرف باسم STS-1 ويعمل بسرعة 51.84 ميجابت في الثانية، حيث يظهر إطار STS-1 في الشكل الآتي، وهو مُرتَّب على شكل 9 صفوف ويأخذ كل صفّ 90 بايتًا، وتكون البايتات الثلاثة الأولى من كلّ صفّ بايتات إضافيّة (overhead)، وتتاح باقي البايتات للبيانات المرسَلة عبر الرّابط. يحتوي البايتان الأوّليان من الإطار على نمط بت خاصّ، وهذه البايتات هي التي تُمكّن المستقبل من تحديد مكان بدء الإطار، ولا يوجد سبب لعدم ظهور هذا النّمط أحيانًا في جزء الحمولة (payload) من الإطار، نظرًا لعدم استخدام حشو البتات، لذلك يبحث المستقبل عن نمط البت الخاصّ باستمرار على أمل رؤيته مرةً واحدة كلّ 810 بايت، لأنّ كلّ إطار يبلغ طوله 9 × 90 = 810 بايتات، وبالتالي يستنتج المستقبل أنه متزامن ويمكنه بعد ذلك تفسير الإطار بصورة صحيحة عندما يظهر النمط الخاصّ في المكان المناسب مراتٍ كافية. أحد الأشياء التي لن نشرحها بسبب تعقيد معيار SONET هو الاستخدام التفصيلي لجميع البايتات الإضافية (overhead) الأخرى، وقد يُعزى جزء من هذا التّعقيد إلى حقيقة أنّ معيار SONET يعمل عبر شبكة المشغّل الضّوئية، وليس عبر رابط واحد فقط، تذكّر أنّنا نتخلى عن حقيقة أنّ شركات الاتصالات تنفّذ شبكة، ونركز بدلًا من ذلك على حقيقة قدرتنا على استئجار رابط SONET منها، ثم استخدام هذا الرّابط لبناء شبكتنا الخاصّة ذات نوع تبديل الرّزم (packet-switched). يأتي تعقيدٌ إضافي من حقيقة أنّ هذا المعيار يوفّر مجموعة خدمات أكثر ثراءً من مجرد نقل البيانات، حيث تُخصَّص على سبيل المثال 64 كيلوبت في الثانية من سعة رابط SONET لقناةٍ صوتية تُستخدم للصيانة. تُرمَّز بايتات إطار SONET الإضافية باستخدام ترميز NRZ، وهو التّرميز البسيط الذي نوقش سابقًا، حيث تكون سلسلة الواحدات مرتفعة وسلسلة الأصفار منخفضة، ويجب خلط (scrambled) بايتات الحِمل لضمان وجود الكثير من الانتقالات للسّماح للمستقبل باستعادة ساعة المرسل، حيث يتمّ ذلك عن طريق حساب عمليّة XOR للبيانات المراد إرسالها باستخدام نمط بتّات معروف. يحتوي نمط البت الذي يبلغ طوله 127 بتًّا على الكثير من الانتقالات من 1 إلى 0، لذلك من المحتمل أن ينتج عن عملية XOR لنمط البتّ مع البيانات المرسلَة إشارةٌ ذات انتقالات كافية لتفعيل استعادة الساعة. يدعم معيار SONET دمج عدّة روابط منخفضة السرعة بالطريقة التالية: يعمل رابط SONET المحدّد بمعدّلٍ من مجموعة محدودة من المعدّلات المحتملة، والتي تتراوح من 51.84 ميجابت في الثانية (STS-1) إلى 39813120 ميجابت في الثانية (STS-768). لاحظ أنّ كلّ هذه المعدّلات عبارة عن مضاعفات STS-1 العددية الصحيحة. تكمن أهمية التأطير في أن إطار SONET الواحد قد يحتوي على إطارات فرعيّة لقنوات متعدّدة ذات معدّلات أقلّ؛ أمّا الميزة الثانية فهي أن كلّ إطار يبلغ طوله 125 ميكرو ثانية، وهذا يعني أنّ طول إطار SONET يبلغ 810 بايت في معدلات STS-1، بينما في معدلات STS-3 يبلغ طول كلّ إطار SONET حوالي 2430 بايت. لاحظ الارتباط بين هاتين الميزتين حيث: 3 × 810 = 2430، مما يعني تلاؤم ثلاثة إطارات STS-1 تمامًا مع إطار STS-3 واحد. ترمز STS إلى إشارة النقل المتزامن (Synchronous Transport Signal)، وهي الطريقة التي يتحدّث بها معيار SONET عن الإطارات، ويوجد مصطلحٌ موازٍ هو الناقل الضوئي (Optical Carrier واختصارًا OC) الذي يُستخدم للتّحدث عن الإشارة الضوئية الأساسيّة التي تحمل إطارات SONET. يمكن القول أنّ هذين المصطلحين متوازيان لأن STS-3 و OC-3 على سبيل المثال يشيران إلى معدّل إرسال يبلغ 155.52 ميجابت في الثانية. سنلتزم بـالمصطلح STS نظرًا لأنّنا نركّز على التأطير هنا، ولكن من المرجح أن تسمع شخصًا يشير إلى رابط ضوئي باسم OC. يمكن القول بأنّ إطار STS-N يتكوّن من N إطار من النّوع STS-1، حيث تكون البايتات في هذه الإطارات متداخلة (interleaved)، أي يرسَل بايت من الإطار الأول، ثمّ يرسَل بايت من الإطار الثاني وهكذا. والسبّب في تداخل (interleaving) البايتات في كلّ إطار STS-N هو التأكّد من أنّ البايتات في كلّ إطار STS-1 موجودة بالتساوي، أي أنّ البايتات تظهر في المستقبل بسرعة تبلغ 51 ميجابت في الثانية بسهولة، بدلًا من تجميعها بالكامل خلال 1 / Nth من الفاصل الزّمني 125 ميكرو ثانية. يمكن ربط حِمل إطارات STS-1 معًا لتكوين حِمل STS-N أكبر على الرّغم من صحّة افتراض أنّ إشارة STS-N تُستخدم لتجميع N إطار من النّوع STS-1، مثل الإشارة إلى هذا الرّابط باستخدام STS-Nc ،على التّسلسل (concatenated)، حيث يُستخدَم أحد الحقول الموجودة في القسم الإضافي للإطار لهذا الغرض. يبين الشّكل السّابق بيانيًا التسلسل (concatenation) في حالة ثلاثة إطارات STS-1 متسلسلة في إطار STS-3c واحد. تكمن أهميّة رابط SONET في كونه من النوع STS-3c بدلًا من النوع STS-3، فيمكن لمستخدم هذا الرّابط في الحالة الأولى مشاهدته على أنه أنبوب واحد بسرعة 155.25 ميجابت في الثانية، بينما يجب عرض الرابط STS-3 على شكل ثلاثة روابط بسرعة 51.84 ميجابت في الثانية تُستخدَم لمشاركة الألياف. وأخيرًا، وصفُ معيار SONET السابق بسيطٌ جدًا فهو يفترض أنّ حِمل كلّ إطار موجود بالكامل داخل الإطار، إذًا لماذا لا يحدث ذلك؟ يجب في الواقع عرض إطار STS-1 الموصوف للتّو على أنه مجرّد عنصرٍ نائب للإطار، حيث قد يطفو الحمل الفعلي (actual payload) خارج حدود الإطار، ويوضح الشّكل السابق هذه الحالة، فيطفو هنا كلٌ من حِمل الإطار STS-1 بين إطاري STS-1 و يزيح الحِمل عددًا من البايتات إلى اليمين، وبالتالي تلتفّ هذه البايتات. يشير أحد الحقول الموجودة في قسم الإطار الإضافي إلى بداية الحِمل، فتكمن أهميّة هذه الإمكانية في أنها تبسّط مهمّة مزامنة الساعات المستخدمة عبر شبكات شركات الاتصالات، حيث تقضي شركات الاتصالات الكثير من وقتها قلقةً بشأن مزامنة الساعات. ترجمة -وبتصرّف- للقسمين Encoding و Framing من فصل Direct Links من كتاب Computer Networks: A Systems Approach
  5. المشكلة: الاتصال بشبكة (Connecting to a Network) تتكوّن الشبّكات الحاسوبيةّ من عُقدٍ (nodes) متّصلة عن طريق روابط (links) كما لاحظتَ في المقال الأوّل من هذه السلسلة، أساسيات الشبكات، فمن بين المشاكل الأساسيةّ التي يمكن مواجهتها هي كيفية توصيل عقدتين مع بعضهما، ولاحظت أيضًا تقديم تجريد السّحابة (cloud abstraction) لتمثيل الشّبكة دون كشف تعقيدات الشّبكة الدّاخلية، لذلك يجب أيضًا معالجة مشكلة مشابهة تتمثّل في توصيل مضيفٍ بسحابة، وهذه هي المشكلة التي يواجهها كلّ مزوّد خدمة إنترنت (Internet Service Provider ويختصَر إلى ISP) عندما يريد توصيل عميلٍ جديد بشبكته. يجب معالجة مجموعة شائعة من المشاكل سواءً عند بناء شبكة بسيطة مؤلّفة من عقدتين ورابطٍ بينهما، أو عند توصيل المضيف المليار إلى شبكة موجودة مسبقًا مثل الإنترنت، حيث تحتاج أوّلًا إلى وسيط فيزيائي (physical medium) يمكنك إجراء الاتّصال من خلاله، فقد يكون هذا الوسيط عبارةً عن جزءٍ من سلك (wire)، أو قطعة من ألياف ضوئيّة (optical fiber) أو وسيطًا غير ملموس مثل الهواء الذي يمكن نقل الإشعاع الكهرومغناطيسي، مثل موجات الراديو، خلاله. وقد تغطّي هذه الشّبكة مساحةً صغيرةً، مثل: مبنى، أو مساحة واسعة مثل المساحة العابرة للقارّات (transcontinental). لا يُعدّ توصيل عقدتين بوسيطٍ مناسبًا إلّا الخطوةَ الأولى، فيجب معالجة خمس مشكلات إضافية قبل تمكُّن العقد من تبادل الرّزم (packets) بنجاح، وستوفرّ معالجة هذه المشاكل اتصال الطّبقة 2 (L2)، باستخدام مصطلحات من معماريّة OSI. المشكلة الأولى هي ترميز (encoding) البتات ضمن وسيط الإرسال (transmission medium)، بحيث قد تفهمها عقدة الاستقبال (receiving node)؛ أمّا والمشكلة الثّانية، فهي مسألة تحديد سلسلة البتات المنقولة عبر الرّابط (link) ضمن رسائل كاملة يمكن تسليمها إلى العقدة النّهائية، حيث تدعى هذه المشكلة بالتّأطير (framing)، وتدعى الرّسائل التي يمكن توصيلها إلى المضيفين النهّائيين أحيانًا بالإطارات (frames)، وبالرّزم (packets) أحيانًا أخرى؛ بينما المشكلة الثالثة، ونظرًا لتعرّض الإطارات أحيانًا للتّلف أثناء الإرسال، فمن الضّروري اكتشاف هذه الأخطاء واتخاذ الإجراء المناسب، وهذه هي مشكلة كشف الأخطاء (error detection)؛ في حين تتمثّل المشكلة الرّابعة في ظهور الرّابط على أساس رابطٍ موثوق، على الرّغم من حقيقة إفساده للإطارات من وقتٍ لآخر. وفي المشكلة الخامسة والأخيرة وهي في الحالات التي يتشارك فيها مضيفون متعدّدون بنفس الرّابط، مثل الرّوابط اللاّسلكية (wireless links)، فمن الضّروري التوسّط في الوصول إلى هذا الرّابط، وهذه هي مشكلة التحّكم بالوصول إلى الوسائط (media access control). على الرّغم من إمكانيّة مناقشة هذه المشاكل الخمس، التّرميز (encoding)، والتّأطير (framing)، وكشف الأخطاء (error detection) والتّسليم الموثوق (reliable delivery)، وتوسّط الوصول (access mediation)، إلّا أنها تُعدّ مشاكلًا حقيقيّة تُعالَج بطرقٍ مختلفة باستخدام تقنيّات شبكيّة مختلفة، وسيتناول هذا المقال هذه المشاكل في سياق تقنيّات شبكيّة محدّدة، مثل روابط الألياف نقطةً لنقطة (point-to-point fiber links)، والمثال السّائد عنها شبكات SONET، وشبكات الوصول المتعدّد باستشعار الحامل (Carrier Sense Multiple Access واختصارًا CSMA)، والمثال الأشهر عنها شبكات إيثرنت (Ethernet)، والشّبكات اللّاسلكية (Wi-Fi) التّقليديّة، وتقنيّة ليف إلى المنزل (fiber-to-the home)، التي تَعدّ تقنية PON هي المعيار السّائد عنها، وشبكات الهاتف المحمول اللّاسلكية (mobile wireless)، حيث تتحوّل الآن شبكة 4G إلى شبكة 5G بسرعة. الهدف من هذا المقال هو إجراء مسحٍ للتّقنيات المتاحة على مستوى الرّابط واكتشاف هذه المشاكل الخمس الأساسيّة، حيث سنختبر ما يتطلّبه الأمر لجعل مجموعة متنوّعة من الوسائط الفيزيائيّة المختلفة وتقنيّات الرّبط مفيدةً كلبناتٍ أساسيّة لبناء شبكات متينة وقابلة للتّوسع. المخطط التقني (Technology Landscape) من المفيد أوّلًا الحصول على موقعٍ على أرض الواقع قبل الخوض في التّحديات الموضّحة في عرض المشكلة في بداية هذا المقال، حيث يتضمّن هذا الموقع مصفوفةً واسعةً من تقنيّات الرّبط، وهذا يرجع جزئيًّا إلى الظّروف المتنوّعة التي يحاول المستخدمون توصيل أجهزتهم في ظلّها، ويجب على مشغّلي الشّبكات الذين يُنشئون شبكات عالميّة (global networks) في أحد طرفيّ الشّبكة، التّعامل مع الرّوابط (links) التي تمتدّ لمئات وآلاف الكيلومترات، والتي تربط موجّهاتٍ (routers) بحجم الثّلاجة؛ أمّا في الطّرف الآخر، فيستعمل المستخدم العاديّ الروابطَ غالبًا مثل وسيلةٍ لربط الحاسوب بالإنترنت الموجود، ويكون هذا الرّابط أحيانًا لاسلكيًّا (Wi-Fi) مثل الرّوابط الموجودة في المقاهي، ويكون أحيانًا رابط إيثرنت (Ethernet) مثل الرّوابط الموجودة في مبنى مكتبيّ، أو في الجامعة؛ أو قد يكون هاتفًا ذكيًا متّصلًا بشبكة خلويّة (cellular network)؛ كما قد يكون الراّبط أليافًا ضوئيّةً (fiber optic) يوفّرها مزوّد خدمة الانترنت ISP لشريحة كبيرة من النّاس، ويستخدم العديد من النّاس روابطًا ذات أسلاك نحاسيّة (copper wire) أو كابلات، وتوجد عدّة استراتيجيّات شائعة مستخدمة مع هذه الرّوابط المختلفة، بحيث يمكن جعلها موثوقةً ومفيدةً للطّبقات الأعلى من البروتوكول المستخدم، حيث سيشرح هذا المقال تلك الاستراتيجياّت جميعها. يوضّح الشّكل السّابق أنواع الرّوابط المختلفة التي يمكن وجودها ضمن شبكة الإنترنت اليوم، فتجد يسار الشّكل السّابق مجموعةً متنوّعةً من أجهزة المستخدم النّهائي (end-user) التي تتراوح من الهواتف الذّكية (smartphones) إلى الأجهزة اللّوحيّة (tablets)، وحتّى أجهزة الحاسوب الكاملة (full-fledged computers) المتّصلة بمزوّد خدمة الانترنت عبر وسائل مختلفة. قد تستخدم هذه الرّوابط تقنيّات مختلفة ولكنّها تبدو متشابهةً مع الشّكل السّابق، إذ يربط خطٌ مستقيم الجهاز بموجّه، وتوجد روابط تربط الموجّهات مع بعضها البعض داخل مزوّد خدمة الإنترنت، بالإضافة إلى وجود روابط تربط مزوّد خدمة الانترنت مع بقيةّ شبكة الانترنت (rest of the Internet) التي تتكون من الكثير من مزوّدي خدمة الإنترنت الآخرين، والمضيفين الذين يتّصلون بهم. تبدو جميع هذه الرّوابط متشابهةً، وهذا ليس فقط لأننا لسنا فنانين جيّدين جدًّا، بل لأن جزءًا من دور معماريّة الشّبكة هو توفير تجريدٍ مشترك لشيء معقّد ومتنّوع مثل الرّوابط (links)، فلا يجب على حاسوبك المحمول أو هاتفك الذّكي الاهتمام بنوع الرّابط المتصّل به، والشّيء الوحيد المهم هو امتلاكه لرابط بشبكة الإنترنت، ولا يتعيّن على الموجّه (router) أيضًا الاهتمام بنوع الرّابط الذي يربطه مع الموجهات الأخرى، حيث قد يرسل الموجّه رزمةً عبر الرّابط مع توقّعٍ جيّد بأن الرّزمة ستصل إلى الطّرف الآخر من الرّابط، لكن كيف يمكن جعل جميع هذه الأنواع المختلفة من الرّوابط متشابهةً بدرجة كافية للمستخدمين النّهائيين والموجّهات؟ يجب أوّلًا التّعامل مع جميع قيود الرّوابط الفيزيائيّة ونقاط ضعفها الموجودة في العالم الحقيقي، حيث أظهرت فقرة المشكلة في بداية هذا المقال بعضًا من هذه المشاكل، ولكن يجب أوّلًا تقديم بعضٍ من الفيزياء البسيطة قبل مناقشتها. تُصنع جميع هذه الرّوابط من بعض المواد الفيزيائيةّ التي يمكنها نشر الإشارات، مثل: الأمواج الراديويّة، أو أنواع أخرى من الإشعاع الكهرومغناطيسي. ولكنّ ما تحتاجه حقًّا هو إرسال البِتّات. سترى كيفية تشفير البتّات لإرسالها، من خلال وسيط فيزيائي في الأقسام اللّاحقة من هذا المقال وبعض المشاكل الأخرى ثم ستفهم كيفيّة إرسال رزمٍ كاملة عبر أيّ نوع من الرّوابط بنهاية هذا المقال بغضّ النّظر عن الوسيط الفيزيائيّ المُستخدَم. تتمثّل إحدى طرق توصيف الرّوابط في الوسيط (medium) الذي تستخدمه عادةً، مثل: الأسلاك النحاسيّة (copper wire)، ومنها مثلا: السّلك المزدوج الملتوي (twisted pair) الذي تستخدمه بعض شبكات الإيثرنت والهواتف الأرضيّة، أو السّلك المحوري (coaxial) الذي يستخدمه الكابل (cable)؛ أو مثل الألياف الضّوئية (optical fiber) التي تستخدمها تقنيّة ليف إلى منزل (fiber-to-the-home)، والعديد من الرّوابط بعيدة المدى ضمن بنية شبكة الإنترنت الأساسيّة (Internet’s backbone)؛ أو مثل الهواء (air)، أو الفراغ (free space) الذي تستخدمه الرّوابط اللّاسلكية (wireless links). يُعدّ التّردّد (frequency) من خصائص الرّوابط المهمّة الأخرى الذي يُقاس بوحدة الهرتز (hertz)، والذي تهتزّ به الموجات الكهرومغناطيسيّة، وتسمّى المسافة بين زوجٍ من حدود الموجة القصوى أو الصغرى المتجاورة بطول الموجة الموجيّ (wavelength) ويُقاس بالمتر. تنتقل جميع الموجات الكهرومغناطيسيّة بسرعة الضّوء، والتي تعتمد بدورها على الوسيط (medium)، حيث يساوي الطّول الموجيّ السّرعةَ مقسومةً على تردّد الموجة، فيحمل خطّ الهاتف للدّرجة الصّوتية (voice-grade telephone line) إشاراتٍ كهرومغناطيسيّة مستمرةً تتراوح بين 300 هرتز، و3300 هرتز، وقد يكون للموجة ذات التّردّد 300 هرتز التي تنتقل عبر النّحاس طولًا موجيًّا يساوي سرعة الضّوء في النّحاس مقسومةً على التّردد، بحيث يساوي: = 2/3 × 3 × 108 / 300 = 667 × 103 متر تمتدّ الموجات الكهرومغناطيسيّة على نطاق أوسع بكثير من التّرددات بدءًا بالموجات الراديويّة، ثم ضوء الأشعّة تحت الحمراء (infrared light)، ثم الضّوء المرئيّ (visible light)، ثم الأشعّة السّينيّة (x-rays)، وأشعّة جاما (gamma rays)، ويبيّن الشّكل التاّلي الطّيف الكهرومغناطيسي، ويوضح الوسائط الشّائعة المستخدمة لحمل نطاقات التّردد: لا يُظهر الشّكل السّابق المكانَ المناسب للشّبكة الخلويّة، فهذا أمر معقّد بعض الشّيء لأن نطاقات التّردد المحدّدة المرخَّصة للشّبكات الخلويّة تختلف حول العالم، وهو أمرٌ أعقد من حقيقة أن مشغّلي الشّبكات غالبًا يدعمون التّقنيات القديمة، أو السّابقة وتقنيّات الجيل الجديد أو التالي، حيث يشغَل كلّ منها نطاقَ تردّد مختلفًا. ويمكننا القول باختصار أنّ التّقنيات الخلويّة التّقليديّة تتراوح بين 700 ميجاهرتز إلى 2400 ميجاهرتز مع تخصيصاتٍ متوسّطة الطّيف وجديدةٍ تحدث عند 6 جيجاهرتز الآن، وتخصيصات الموجة المليمتريّة (millimeter-wave وتُختصر إلى mmWave) المفتوحة فوق 24 جيجاهرتز، حيث من المحتمل أن يصبح نطاق الموجة المليمتريّة هذا جزءًا مهمًّا من شبكة الهاتف المحمول 5G. يمكن فهم الرّابط على أنّه وسيط فيزيائي يحمل إشارات على شكل موجات كهرومغناطيسيّة، وتوفّر هذه الرّوابط أساس إرسال جميع أنواع المعلومات بما في ذلك نوع البيانات التي نهتمّ بنقلها، والتي هي البيانات الثّنائيّة (أصفار وواحدات)، ويمكن القول بأنّ البيانات الثنائية مشفّرةٌ في الإشارة، فمشكلة تشفير البيانات الثّنائية على الإشارات الكهرومغناطيسيّة موضوعٌ معقّد، ويمكن جعل ذلك أكثر قابليّةً للإدارة من خلال التّفكير في تشفير البيانات الثّنائية على أنّه مقسَّم إلى طبقتين، حيث تُعنى الطّبقة السّفلية بالتعّديل (modulation)، أيّ تغيير تردّد (frequency)، أو سعة (amplitude)، أو طور (phase) الإشارة للتّأثير على إرسال المعلومات، فتغيير قدرة (power) أو سعة (amplitude) طولٍ موجيّ هو مثالٌ بسيط عن التّعديل (modulation)، وهذا يكافئ تشغيل وإطفاء النور، فمسألة التّعديل ثانويّةً بالنّسبة للرّوابط التي تُعَد لبنةً أساسيّةً في الشّبكات الحاسوبيّة، لذلك يمكن الافتراض ببساطة أنّه من الممكن إرسال زوج من الإشارات القابلة للتمّييز، حيث يمكن عَد زوج الإشارات كإشارة مرتفعة (high signal)، وأخرى منخفضة (low)؛ أمّا الطّبقة العلويّة، وهي الطّبقة التي تهمّنا الآن، فتهتم بالمشكلة الأبسط بكثير وهي تشفير البيانات الثّنائية على هاتين الإشارتين. يمكن تصنيف الرّوابط بطريقة أخرى وذلك من حيث طريقة استخدامها، حيث تميل الأمور الاقتصاديّة وقضايا النّشر المختلفة إلى التّأثير على مكان وجود أنواع روابط مختلفة، فيتفاعل معظم المستخدمين مع الإنترنت إمّا من خلال الشّبكات اللّاسلكية الموجودة في المقاهي، والمطارات، والجامعات وما إلى ذلك؛ أو من خلال ما يسمى روابط الميل الأخير (last-mile links)؛ أو شبكات الوصول (access networks) بدلًا من ذلك، والتي يوفّرها مزود خدمة الإنترنت. لُخّصت أنواع هذه الرّوابط في الجدول الآتي، وقد اختيرت هذه الأنواع لأنها طرق فعّالة من حيث التّكلفة للوصول إلى ملايين المستخدمين، فتقنيّة خطّ المشترك الرّقمي (Digital Subscriber Line واختصارها DSL) على سبيل المثال هي تقنية قديمة نُشرت على الأسلاك النّحاسية المزدوجة الملتوية الموجودة مسبقًا لخدمات الهاتف القديمة العاديّة؛ أمّا تقنيّة G.Fast، فهي تقنيّة قائمة على النّحاس تُستخدم عادةً في المباني السّكنية متعدّدة المساكن، وتقنيّة الشّبكة الضّوئية السّلبية (Passive Optical Network واختصارها PON) هي تقنيّة أحدث تُستخدم عادةً لربط المنازل والشّركات عبر الألياف التي نُشرت مؤخّرًا. الخدمة (Service) حيّز النّطاق التراسلي (Bandwidth) تقنيّة DSL خطّ المشترك الرّقمي (أسلاك نحاسية) تصل إلى 100 ميجابت في الثّانية تقنيّة G.Fast (أسلاك نحاسيّة) تصل إلى 1 جيجابت في الثّانية تقنيّة PON الشّبكة الضّوئية السّلبية (ألياف ضوئيّة) تصل إلى 10 جيجابت في الثّانية table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } ويوجد أيضًا شبكة الهاتف المحمول (mobile) أو الشّبكة الخلويّة (cellular)، حيث يشار إليها باسم 4G ولكنّها تتطوّر بسرعة إلى شبكة 5G في وقت ترجمة هذه السلسلة، التي تربط أجهزة الهاتف المحمولة بالإنترنت، حيث يمكن لهذه التقّنية أيضًا العمل مثل وصلة الإنترنت الوحيدة في المنازل أو المكاتب، ولكنّها تأتي مع ميزة إضافية تتمثّل في السّماح بالحفاظ على الاتّصال بالإنترنت أثناء الانتقال من مكان إلى آخر. تُعدّ هذه التّقنيات خيارات شائعة لاتّصال الميل الأخير بمنزلك أو عملك، ولكنّها ليست كافيةً لبناء شبكة كاملة من البداية، لذلك ستحتاج أيضًا إلى بعض الرّوابط الأساسيّة أو روابط شبكة العمود الفقري بعيدة المدى (long-distance backbone links) لتوصيل المدن، فالرّوابط الأساسية الحديثة هي عبارة عن ألياف فقط تقريبًا اليوم، والتي تستخدم عادةً تقنيّةً تسمّى الشّبكة الضّوئية المتزامنة (Synchronous Optical Network وتختصر إلى SONET)، والتي طُوّرت في الأصل لتلبية المتطلّبات ذات الإدارة الصّعبة لشركات الهاتف. ويوجد، بالإضافة إلى روابط الميل الأخير (last-mile) والرّوابط الأساسيّة (backbone) وروابط شبكة الهاتف المحمول (mobile links)، روابطٌ تجدها داخل مبنى أو داخل جامعة ويشار إليها عمومًا باسم الشّبكات المحلّية (local area networks وتختصر إلى LANs)، حيث تُعَد تقنيّة الإيثرنت والتّقنية اللّاسلكية (Wi-Fi) من التّقنيات المهيمنة في هذا المجال. ليس هذا الاستطلاع لأنواع الرّوابط شاملًا، ولكن لابد له من منحك لمحةً عن تنوّع أنواع الرّوابط الموجودة وأسباب هذا التّنوع، سترى في الأقسام القادمة كيف يمكن لبروتوكولات الشّبكات الاستفادة من هذا التّنوع وتقديم رؤية مستقرّة للشّبكة للطّبقات الأعلى على الرّغم من كل التعّقيدات والعوامل الاقتصاديّة منخفضة المستوى. ترجمة -وبتصرّف- للقسم Technology Landscape من فصل Direct Links من كتاب Computer Networks: A Systems Approach
  6. متغيرات تقييد الوصول (Semaphores) طريقةٌ جيدة للتعرف على التزامن، ولكنها ليست مستخدمة على نطاق واسع من الناحية العملية كاستخدام كائنات المزامنة (mutexes) والمتغيرات الشرطية (condition variables)، ومع ذلك توجد بعض مشاكل المزامنة التي يمكن حلها ببساطة باستخدام متغيرات تقييد الوصول، مما يؤدي إلى الوصول إلى حلول صحيحة ودقيقة. تقدّم هذه المقالة واجهة برمجة التطبيقات بلغة C للعمل مع متغيرات تقييد الوصول، وكتابة تطبيق لمتغير تقييد الوصول (semaphore) باستخدام كائنات المزامنة (mutexes) والمتغيرات الشرطية (condition variables). POSIX Semaphores متغير تقييد الوصول (semaphore) هو بنية بيانات تُستخدم لمساعدة الخيوط أن تعمل مع بعضها البعض دون تداخلٍ فيما بينها، يحدد POSIX القياسي واجهةً لمتغيرات تقييد الوصول، وهي ليست جزءًا من الخيوط Pthreads، ولكن توفّر معظم نظم التشغيل التي تعتمد على يونكس والتي تطبق Pthreads متغيرات تقييد الوصول أيضًا. لمتغيرات تقييد الوصول POSIX نوع هو sem_t، ووضع مغلّف له لجعل استخدامه أسهل كالعادة: typedef sem_t Semaphore; Semaphore *make_semaphore(int value); void semaphore_wait(Semaphore *sem); void semaphore_signal(Semaphore *sem); Semaphore هو مرادف للنوع sem_t، ولكنني، يقول الكاتب، وجدت Semaphore أسهل للقراءة وذكّرني الحرف الكبير في أوله بمعاملته ككائن (object) وتمريره كمؤشر (pointer): Semaphore *make_semaphore(int value) { Semaphore *sem = check_malloc(sizeof(Semaphore)); int n = sem_init(sem, 0, value); if (n != 0) perror_exit("sem_init failed"); return sem; } تأخذ الدالة make_semaphore القيمة الابتدائية لمتغير تقييد الوصول كمعاملٍ لها، وتخصص حيزًا له وتهيئه ثم تعيد مؤشرًا إلى Semaphore. تعيد الدالة sem_init القيمة 0 إذا نجح تنفيذها وتعيد -1 إذا حدث خطأ ما. أحد الأمور الجيدة لاستخدام الدوال المغلّفة هو أنك تستطيع تغليف (encapsulate) شيفرة التحقق من الخطأ، مما يجعل الشيفرة التي تستخدم هذه الدوال أسهل للقراءة. يمكن تطبيق الدالة semaphore_wait كما يلي: void semaphore_wait(Semaphore *sem) { int n = sem_wait(sem); if (n != 0) perror_exit("sem_wait failed"); } والدالة semaphore_signal: void semaphore_signal(Semaphore *sem) { int n = sem_post(sem); if (n != 0) perror_exit("sem_post failed"); } أفضّل، يقول الكاتب، أن أسمي عملية تنبيه الخيط المتوقف بالمصطلح signal على أن أسميها بالمصطلح post على الرغم أن كلا المصطلحين شائعي الاستخدام. يظهر المثال التالي كيفية استخدام متغير تقييد الوصول ككائن مزامنة: Semaphore *mutex = make_semaphore(1); semaphore_wait(mutex); // protected code goes here semaphore_signal(mutex); يجب أن تهيئ متغير تقييد الوصول الذي تستخدمه ككائن مزامنة بالقيمة 1 لتحدد أن كائن المزامنة غير مقفل، أي يستطيع خيطٌ واحد تمرير متغير تقييد الوصول دون توقف. استخدم اسم المتغير mutex للدلالة على أن متغير تقييد الوصول استخدم ككائن مزامنة، ولكن تذكّر أن سلوك متغير تقييد الوصول مختلف عن كائن مزامنة الخيط Pthread. المنتجون والمستهلكون مع متغيرات تقييد الوصول (Producers and consumers with semaphores) يمكن كتابة حل لمشكلة منتج-مستهلك باستخدام دوال مغلّفة لمتغير تقييد الوصول، حيث يصبح التعريف الجديد للبنية Queue باستبدال كائن المزامنة والمتغيرات الشرطية بمتغيرات تقييد الوصول كما يلي: typedef struct { int *array; int length; int next_in; int next_out; Semaphore *mutex; //-- جديد Semaphore *items; //-- جديد Semaphore *spaces; //-- جديد } Queue; والنسخة الجديدة من الدالة make_queue هي: Queue *make_queue(int length) { Queue *queue = (Queue *)malloc(sizeof(Queue)); queue->length = length; queue->array = (int *)malloc(length * sizeof(int)); queue->next_in = 0; queue->next_out = 0; queue->mutex = make_semaphore(1); queue->items = make_semaphore(0); queue->spaces = make_semaphore(length - 1); return queue; } يُستخدم المتغير mutex لضمان الوصول الحصري إلى الطابور، حيث قيمته الابتدائية هي 1 وبالتالي كائن المزامنة غير مقفل مبدئيًا. المتغير items هو عدد العناصر الموجودة في الطابور والذي هو أيضًا عدد الخيوط المستهلكة التي يمكن أن تنفذ الدالة queue_pop دون توقف، ولا يوجد أي عنصر في الطابور مبدئيًا. المتغير spaces هو عدد المساحات الفارغة في الطابور وهو أيضًا عدد الخيوط المنتجة التي يمكن أن تنفّذ الدالة queue_push دون توقف، ويمثل عدد المساحات مبدئيًا سعة الطابور وتساوي length-1. النسخة الجديدة من الدالة queue_push التي تشغّلها الخيوط المنتجة هي كما يلي: void queue_push(Queue *queue, int item) { semaphore_wait(queue->spaces); semaphore_wait(queue->mutex); queue->array[queue->next_in] = item; queue->next_in = queue_incr(queue, queue->next_in); semaphore_signal(queue->mutex); semaphore_signal(queue->items); } لاحظ أنه لا ينبغي على الدالة queue_push استدعاء الدالة queue_full مرة أخرى، حيث بدلًا من ذلك يتتبّع متغير تقييد الوصول عدد المساحات المتاحة ويوقف الخيوط المنتجة إذا كان الطابور ممتلئًا. النسخة الجديدة من من الدالة queue_pop هي: int queue_pop(Queue *queue) { semaphore_wait(queue->items); semaphore_wait(queue->mutex); int item = queue->array[queue->next_out]; queue->next_out = queue_incr(queue, queue->next_out); semaphore_signal(queue->mutex); semaphore_signal(queue->spaces); return item; } شُرح هذا الحل باستخدام شيفرة عامة (pseudo-code) في الفصل الرابع من كتاب The Little Book of Semaphores. صناعة متغيرات تقييد وصول خاصة أية مشكلةٍ تُحَل باستخدام متغيرات تقييد الوصول تُحل أيضًا باستخدام المتغيرات الشرطية و كائنات المزامنة، ويمكن إثبات ذلك من خلال استخدام المتغيرات الشرطية وكائنات المزامنة لتطبيق متغير تقييد الوصول، حيث يمكن تعريف البنية Semaphore كما يلي: typedef struct { int value, wakeups; Mutex *mutex; Cond *cond; } Semaphore; المتغير value هو قيمة متغير تقييد الوصول، ويحصي المتغير wakeups عدد التنبيهات المعلقة (pending signals)، أي عدد الخيوط التي تنبّهت ولكنها لم تستأنف تنفيذها بعد، والسبب وراء استخدام wakeups هو التأكد من أن متغيرات تقييد الوصول الخاصة بك لديها الخاصية 3 المشروحة في كتاب The Little Book of Semaphores. يوفر المتغير mutex الوصول الحصري إلى لمتغيرين value و wakeups، المتغير cond هو المتغير الشرطي الذي تنتظره الخيوط إذا كانت تنتظر متغير تقييد الوصول. تمثل الشيفرة التالية شيفرة التهيئة للبنية Semaphore: Semaphore *make_semaphore(int value) { Semaphore *semaphore = check_malloc(sizeof(Semaphore)); semaphore->value = value; semaphore->wakeups = 0; semaphore->mutex = make_mutex(); semaphore->cond = make_cond(); return semaphore; } تطبيق متغير تقييد الوصول (Semaphore implementation) تطبيقي، يقول الكاتب، لمتغيرات تقييد الوصول باستخدام كائنات المزامنة POSIX والمتغيرات الشرطية كما يلي: void semaphore_wait(Semaphore *semaphore) { mutex_lock(semaphore->mutex); semaphore->value--; if (semaphore->value < 0) { do { cond_wait(semaphore->cond, semaphore->mutex); } while (semaphore->wakeups < 1); semaphore->wakeups--; } mutex_unlock(semaphore->mutex); } يجب على الخيط الذي ينتظر متغير تقييد الوصول أن يقفل كائن المزامنة قبل إنقاص قيمة المتغير value، وإذا أصبحت قيمة متغير تقييد الوصول سالبة سيتوقف الخيط حتى يصبح التنبه (wakeup) متاحًا، وطالما الخيط متوقف فإن كائن المزامنة غير مقفل، وبالتالي يمكن أن يتنبه (signal) خيطٌ آخر. شيفرة الدالة semaphore_signal هي: void semaphore_signal(Semaphore *semaphore) { mutex_lock(semaphore->mutex); semaphore->value++; if (semaphore->value <= 0) { semaphore->wakeups++; cond_signal(semaphore->cond); } mutex_unlock(semaphore->mutex); } يجب على الخيط مرة أخرى أن يقفل كائن المزامنة قبل زيادة قيمة المتغير value، وإذا كانت قيمة متغير تقييد الوصول سالبة فهذا يعني أن الخيوط منتظرة، وبالتالي يزيد تنبيه الخيط قيمة المتغير wakeups ثم ينبه المتغير الشرطي،وعند ذلك قد تتنبه أحد الخيوط المنتظرة ولكن يبقى كائن المزامنة مقفلًا حتى يفك الخيط المتنبه قفله، وعند ذلك أيضًا تعيد أحد الخيوط المنتظرة من الدالة cond_wait ثم تتحقق من أن التنبيه ما زال متاحًا، فإذا لم يكن متاحًا فإن الخيط يعود وينتظر المتغير الشرطي مرة أخرى، أما إذا كان التنبيه متاحًا فإن الخيط ينقص قيمة المتغير wakeups ويفك قفل كائن المزامنة ثم يغادر. قد يوجد شيء واحد ليس واضحًا في هذا الحل وهو استخدام حلقة do...while، هل يمكنك معرفة سبب عدم كونها حلقة while تقليدية؟ وما الخطأ الذي سيحدث؟ المشكلة مع حلقة while هي أنه قد لا يملك هذا التطبيق الخاصية 3، فمن الممكن أن يتنبه الخيط ثم يُشغّل ويلتقط تنبيهه الخاص. من المضمون مع حلقة do...while أن يلتقط أحد الخيوط المنتظرة التنبيه الذي أنشأه خيطٌ ما، حتى إذا شُغّل خيط التنبيه وحصل على كائن المزامنة قبل استئناف أحد الخيوط المنتظرة، ولكن اتضح أنه يمكن أن ينتهك التنبيه الزائف في الوقت المناسب (well-timed spurious wakeup) هذا الضمان. ترجمة -وبتصرّف- للفصل Semaphores in C من كتاب Think OS A Brief Introduction to Operating Systems
  7. يمكن حل العديد من مشاكل التزامن (synchronization) البسيطة باستخدام كائنات المزامنة (mutexes)، ولكن يوجد مشكلة أكبر هي مشكلة منتج-مستهلك (Producer-Consumer problem) التي تُحل باستخدام أداة جديدة هي المتغير الشرطي (condition variable). طابور العمل (The work queue) تُنظَّم الخيوط في بعض البرامج ذات الخيوط المتعددة لتجري عدة مهام، وتتواصل هذه الخيوط مع بعضها البعض غالبًا عن طريق طابور (queue)، حيث تدعى الخيوط التي تضع بيانات في الطابور بالخيوط المنتجة (producers)، وتدعى الخيوط التي تأخذ بيانات من الطابور بالخيوط المستهلكة (consumers). يمكن أن يوجد خيطٌ يشغّل واجهة المستخدم الرسومية (graphical user interface وتختصر إلى GUI) للاستجابة لأحداث المستخدم في التطبيقات التي لديها واجهة مستخدم رسومية على سبيل المثال، ويمكن أن يوجد خيطٌ آخر يعالج طلبات المستخدم، حيث يمكن أن يضع خيطُ واجهة المستخدم الرسومية الطلبات في طابورٍ ثم يأخذ الخيط المقابل هذه الطلبات ويعالجها. تحتاج تطبيق طابور لدعم هذا التنظيم، بحيث يحافظ تطبيق الطابور على الخيوط (thread safe)، وهذا يعني أنه يستطيع كلا الخيطين (أو أكثر من خيطين في بعض الأحيان) الوصول إلى الطابور في نفس الوقت، وتحتاج أيضًا أن تعالج الحالات الخاصة مثل أن يكون الطابور فارغًا (empty) وأن يكون حجم الطابور منتهٍ عندما يمتلئ (full). سأبدأ، يقول الكاتب، بطابورٍ بسيط لا يحافظ على الخيوط ثم ترى كيف يكون ذلك خاطئًا وكيف يُصحَّح ذلك الخطأ. شيفرة هذا المثال موجودة في المجلد queue حيث يتضمن الملف queue.c التطبيق الأساسي للمخزَن الدائري circular buffer. تجد تعريف البنية Queue فيما يلي: typedef struct { int *array; int length; int next_in; int next_out; } Queue; array هو المصفوفة التي تتضمن عناصر الطابور وهي أعداد صحيحة (ints) في هذا المثال، ولكنها يمكن أن تكون بنى (structures) تتضمن أحداث المستخدم وعناصر العمل وغير ذلك. length هو طول المصفوفة، و next_in هو دليل (index) المصفوفة الذي يحدد مكان إضافة العنصر التالي في الطابور، أما next_out هو دليل العنصر التالي الذي يجب حذفه من الطابور. تخصص الدالة make_queue حيزًا للبنية Queue وتهيئ حقولها كما يلي: Queue *make_queue(int length) { Queue *queue = (Queue *)malloc(sizeof(Queue)); queue->length = length + 1; queue->array = (int *)malloc(length * sizeof(int)); queue->next_in = 0; queue->next_out = 0; return queue; } تحتاج القيمة الابتدائية للمتغير next_out بعض الشرح، فبما أن الطابور فارغ مبدئيًا فلا وجود لعنصر تالٍ لحذفه، لذلك يكون المتغير next_out غير صالح (invalid)، وضبط next_out == next_in هي حالة خاصة تحدد أن الطابور فارغ، فيمكن كتابة ما يلي: int queue_empty(Queue *queue) { return (queue->next_in == queue->next_out); } يمكنك الآن إضافة عناصر إلى الطابور باستخدام الدالة queue_push: void queue_push(Queue *queue, int item) { if (queue_full(queue)) { perror_exit("queue is full"); } queue->array[queue->next_in] = item; queue->next_in = queue_incr(queue, queue->next_in); } إذا كان الطابور ممتلئًا فإن الدالة queue_push تطبع رسالة خطأ وتغادر، أما إذا كان الطابور غير ممتلئ فتدخِل الدالة queue_push عنصرًا جديدًا ثم تزيد قيمة المتغير next_in باستخدام الدالة queue_incr كما يلي: int queue_incr(Queue *queue, int i) { return (i + 1) % queue->length; } تعود قيمة الدليل i إلى الصفر عندما يصل الدليل إلى نهاية المصفوفة، وهذا هو المكان الذي نواجه فيه الجزء الصعب، فإذا واصلنا إضافة عناصر إلى الطابور فسيعود المتغير next_in ويلحق بالمتغير next_out، وإذا كان next_in == next_out ستستنتج بصورة غير صحيحة أن الطابور فارغ، لذلك يجب تعريف حالة خاصة أخرى لتحديد أن الطابور ممتلئ لتجنب ذلك: int queue_full(Queue *queue) { return (queue_incr(queue, queue->next_in) == queue->next_out); } حيث إذا واصلت زيادة المتغير next_in ليصل إلى قيمة المتغير next_out فهذا يعني أنك لا تستطيع إضافة عنصر آخر إلى الطابور بدون جعل الطابور يبدو فارغًا، لذلك يجب التوقف عن إضافة عناصر أخرى قبل نهاية الطابور بعنصر واحد (يجب أن تعرف أن نهاية الطابور يمكن أن تكون في أي مكان وليس بالضرورة عند نهاية المصفوفة). يمكن الآن كتابة الدالة queue_pop التي تحذف وتعيد العنصر التالي من الطابور كما يلي: int queue_pop(Queue *queue) { if (queue_empty(queue)) { perror_exit("queue is empty"); } int item = queue->array[queue->next_out]; queue->next_out = queue_incr(queue, queue->next_out); return item; } وإذا جربت سحب (pop) عنصر من طابور فارغ فستطبع الدالة queue_pop رسالة خطأ وتغادر. المستهلكون والمنتجون (Producers and consumers) تنشئ الآن بعض الخيوط لتصل إلى هذا الطابور، حيث شيفرة المنتج (producer) هي كما يلي: void *producer_entry(void *arg) { Shared *shared = (Shared *)arg; for (int i = 0; i < QUEUE_LENGTH - 1; i++) { printf("adding item %d\n", i); queue_push(shared->queue, i); } pthread_exit(NULL); } أما شيفرة المستهلك (consumer) هي: void *consumer_entry(void *arg) { int item; Shared *shared = (Shared *)arg; for (int i = 0; i < QUEUE_LENGTH - 1; i++) { item = queue_pop(shared->queue); printf("consuming item %d\n", item); } pthread_exit(NULL); } وشيفرة الخيط الأب الذي يبدأ الخيوط وينتظرها لتنتهي هي: pthread_t child[NUM_CHILDREN]; Shared *shared = make_shared(); child[0] = make_thread(producer_entry, shared); child[1] = make_thread(consumer_entry, shared); for (int i = 0; i < NUM_CHILDREN; i++) { join_thread(child[i]); } والبنية المشتركة التي تتضمن الطابور هي: typedef struct { Queue *queue; } Shared; Shared *make_shared() { Shared *shared = check_malloc(sizeof(Shared)); shared->queue = make_queue(QUEUE_LENGTH); return shared; } تمثل الشيفرة السابقة التي حصلت عليها حتى الآن بدايةً جيدة ولكن لديها بعض المشاكل هي: لا يحافظ الوصول إلى الطابور على الخيوط، حيث يمكن أن تصل خيوط متعددة إلى المتغيرات array و next_in و next_out في نفس الوقت، وهذا يترك الطابور تالفًا وفي حالة غير مستقرة. إذا جُدوِل الخيط المستهلك أولًا فسيجد الطابور فارغًا، وبالتالي يطبع رسالة خطأ وينتهي، لذلك من الأفضل أن يتوقف المستهلك حتى يصبح الطابور غير فارغ. وبالمثل يجب إيقاف المنتج إذا كان الطابور ممتلئًا. ستُحل المشكلة الأولى في الفقرة القادمة باستخدام Mutex، وستحل المشكلة الثانية في الفقرة التي بعدها باستخدام المتغيرات الشرطية. الإقصاء المتبادل (Mutual exclusion) يحافظ الطابور على الخيوط باستخدام كائن المزامنة (mutex)، حيث تضيف أولًا المؤشر Mutex إلى بنية الطابور: typedef struct { int *array; int length; int next_in; int next_out; Mutex *mutex; //-- هذا السطر جديد } Queue; ثم تهيئه في الدالة make_queue: Queue *make_queue(int length) { Queue *queue = (Queue *)malloc(sizeof(Queue)); queue->length = length; queue->array = (int *)malloc(length * sizeof(int)); queue->next_in = 0; queue->next_out = 0; queue->mutex = make_mutex(); //-- جديد return queue; } ثم تضيف شيفرة التزامن إلى الدالة queue_push: void queue_push(Queue *queue, int item) { mutex_lock(queue->mutex); //-- جديد if (queue_full(queue)) { mutex_unlock(queue->mutex); //-- جديد perror_exit("queue is full"); } queue->array[queue->next_in] = item; queue->next_in = queue_incr(queue, queue->next_in); mutex_unlock(queue->mutex); //-- جديد } يجب قفل Mutex قبل التحقق إذا كان الطابور ممتلئًا أم لا، فإذا كان ممتلئًا يجب فك قفل Mutex قبل المغادرة، وإلا سيتركه الخيط مقفلًا فلا يستطيع أي خيطٍ آخر أن يستأنف عمله. شيفرة التزامن للدالة queue_pop هي: int queue_pop(Queue *queue) { mutex_lock(queue->mutex); if (queue_empty(queue)) { mutex_unlock(queue->mutex); perror_exit("queue is empty"); } int item = queue->array[queue->next_out]; queue->next_out = queue_incr(queue, queue->next_out); mutex_unlock(queue->mutex); return item; } لاحظ أن دوال Queue الأخرى والتي هي queue_full و queue_empty و queue_incr لا تحاول قفل كائن المزامنة، فيجب على كل خيط يستدعي هذه الدوال أن يقفل كائن المزامنة أولًا. أصبح الطابور محافظًا على الخيوط باستخدام الشيفرة الجديدة التي أضيفت، ولا يجب أن ترى أخطاء تزامن إذا شغلت هذه الشيفرة، ولكنك سترى أن الخيط المستهلك يغادر أحيانًا لأن الطابور فارغ، أو قد ترى الخيط المنتج يغادر بسبب أن الطابور ممتلئ أو كلا الأمرين معًا، وبالتالي الخطوة القادمة هي إضافة المتغيرات الشرطية (condition variables). المتغيرات الشرطية (Condition variables) المتغير الشرطي هو عبارة عن بينة بيانات مرتبطة بشرط (condition)، ويسمح المتغير الشرطي بإيقاف الخيوط حتى يتحقق الشرط أو تصبح قيمته true، فقد تتحقق الدالة thread_pop على سبيل المثال فيما إذا كان الطابور فارغًا أم لا، فإذا كان فارغًا تنتظر شرطًا هو (الطابور غير فارغ). وقد تتحقق الدالة thread_push أيضًا فيما إذا كان الطابور ممتلئًا، فإذا كان ممتلئًا تتوقف حتى يصبح غير ممتلئ. تعالج الشيفرة التالية الشرط الأول، حيث تضيف أولًا متغيرًا شرطيًا إلى البنية Queue: typedef struct { int *array; int length; int next_in; int next_out; Mutex *mutex; Cond *nonempty; //-- جديد } Queue; ثم تهيئه في الدالة make_queue: Queue *make_queue(int length) { Queue *queue = (Queue *)malloc(sizeof(Queue)); queue->length = length; queue->array = (int *)malloc(length * sizeof(int)); queue->next_in = 0; queue->next_out = 0; queue->mutex = make_mutex(); queue->nonempty = make_cond(); //-- جديد return queue; } إذا وجدت الطابور فارغًا في الدالة queue_pop لا تغادر بل استخدم المتغير الشرطي لتوقف التنفيذ: int queue_pop(Queue *queue) { mutex_lock(queue->mutex); while (queue_empty(queue)) { cond_wait(queue->nonempty, queue->mutex); //-- جديد } int item = queue->array[queue->next_out]; queue->next_out = queue_incr(queue, queue->next_out); mutex_unlock(queue->mutex); cond_signal(queue->nonfull); //-- جديد return item; } الدالة cond_wait معقدة، فوسيطها الأول هو المتغير الشرطي والشرط الذي يجب انتظاره في هذه الحالة هو (الطابور غير فارغ)، أما وسيطها الثاني هو كائن المزامنة الذي يحمي الطابور. يفك الخيط قفل كائن المزامنة ثم يتوقف عندما يستدعي الخيط الذي قفل كائن المزامنة الدالةَ cond_wait، وهذا شيء مهم جدًا. إذا لم تقفل الدالة cond_wait كائن المزامنة قبل التوقف فلن يستطيع أي خيطٍ آخر أن يصل إلى الطابور ولن تضاف أي عناصر أخرى إلى الطابور، وبالتالي قد يبقى الطابور فارغًا دائمًا، فيمكن أن يشغّل المنتج بينما يكون المستهلك متوقفًا عند nonempty. تبين الشيفرة التالية ما يحدث عنما يشغّل المنتج الدالة queue_push: void queue_push(Queue *queue, int item) { mutex_lock(queue->mutex); if (queue_full(queue)) { mutex_unlock(queue->mutex); perror_exit("queue is full"); } queue->array[queue->next_in] = item; queue->next_in = queue_incr(queue, queue->next_in); mutex_unlock(queue->mutex); cond_signal(queue->nonempty); //-- جديد } تقفل الدالة queue_push المتغير Mutex وتتحقق فيما إذا كان الطابور ممتلئًا أم لا، وعلى فرض أن الطابور ليس ممتلئًا حيث تضيف الدالة queue_push عنصرًا جديدًا إلى الطابور ثم تفك قفل المتغير Mutex، ولكن تقوم هذه الدالة بشيء آخر قبل أن تعيد شيئًا، حيث تنبّه (signals) المتغير الشرطي nonempty، ويحدد تنبيه (Signalling) المتغير الشرطي أن الشرط صحيح (true)، وليس لإشارة التنبيه أي تأثير إذا لم يوجد خيوطٌ تنتظر المتغير الشرطي. إذا وجد خيوط تنتظر المتغير الشرطي فيعود أحد هذه الخيوط إلى العمل ويستأنف تنفيذ الدالة cond_wait، ولكن يجب على الخيط الذي استأنف عمله أن ينتظر المتغير Mutex ويقفله مرة أخرى قبل أن ينهي تنفيذ الدالة cond_wait. عُد الآن إلى الدالة queue_pop وشاهد ما يحدث عندما ينهي الخيط تنفيذ الدالة cond_wait، حيث يعود الخيط مرة أخرى إلى بداية حلقة while ويتحقق من الشرط مرة أخرى. افترض أنه تحقق الشرط أي أن الطابور غير فارغ، فعندما يغادر الخيط المستهلك حلقة while، هذا يؤدي إلى شيئين: (1) تحقق الشرط أي يوجد عنصر واحد على الأقل في الطابور، و (2) قُفِل المتغير Mutex أي أن الوصول إلى الطابور آمن. تفك الدالة queue_pop قفل كائن المزامنة وتنتهي بعد حذف عنصر من الطابور، سأبين، يقول الكاتب، كيفية عمل شيفرة Cond ولكن يجب أولًا الإجابة عن سؤالين مهمين هما: لماذا توجد الدالة cond_wait ضمن حلقة while بدلًا من وجودها ضمن عبارة if، أي لماذا يجب التحقق من الشرط مرة أخرى بعد انتهاء تنفيذ الدالة cond_wait؟ السبب الرئيسي لإعادة التحقق من الشرط هو إمكانية اعتراض إشارة تنبيه، حيث افترض أن الخيط A ينتظر nonempty، ويضيف الخيط B عنصرًا إلى الطابور ثم ينبه nonempty، فيستيقظ الخيط A ويحاول قفل كائن المزامنة، ولكن قبل أن يقوم الخيط A بذلك، يأتي الخيط الشرير C ويقفل كائن المزامنة ويسحب عنصرًا من الطابور ثم يفك قفل كائن المزامنة، وبالتالي أصبح الطابور فارغًا الآن مرة أخرى، ولكن لا يتوقف الخيط A مرة أخرى، ويستطيع الخيط A قفل كائن المزامنة وينهي تنفيذ الدالة cond_wait، فإذا لم يتحقق الخيط A من الشرط مرة أخرى فقد يحاول سحب عنصر من طابورٍ فارغ، وقد يسبب ذلك خطأ. أما السؤال الثاني الذي يظهر عندما يتعلم الناس المتغيرات الشرطية هو: كيف يعرف المتغير الشرطي الشرطَ الذي يتعلق به؟ هذا السؤال مفهوم لأنه لا يوجد اتصال صريح بين بنية Cond والشرط المتعلق بها، حيث يكون الاتصال مضمّنًا في طريقة استخدامه، وهذه إحدى الطرق للتفكير في ذلك: فالشرط المتعلق ب Cond هو الشي الذي تكون قيمته خاطئة (false) عندما تستدعي الدالة cond_wait، وتكون قيمته صحيحة (true) عندما تستدعي الدالة cond_signal. ليس من الضروري تمامًا استدعاء الدالة cond_signal فقط عندما يكون الشرط صحيحًا، نظرًا لأن الخيوط يجب أن تتحقق من الشرط عند انتهاء تنفيذها للدالة cond_wait. إذا كان لديك سبب للاعتقاد بأن الشرط قد يكون صحيحًا فيمكنك استدعاء الدالة cond_signal كاقتراحٍ للتحقق من ذلك. تطبيق المتغير الشرطي (Condition variable implementation) البنية Cond التي استخدمت في الفقرة السابقة هي مغلّف لنوعٍ يدعى pthread_cond_t المعرّف في واجهة برمجة التطبيقات للخيوط POSIX. البنية Cond شبيهة جدًا بالبنية Mutex والتي هي مغلّفة للنوع pthread_mutex_t، حيث تعريف النوع Cond كما يلي: typedef pthread_cond_t Cond; تخصص الدالة make_cond حيزًا وتهيئ المتغير الشرطي وتعيد مؤشرًا: Cond *make_cond() { Cond *cond = check_malloc(sizeof(Cond)); int n = pthread_cond_init(cond, NULL); if (n != 0) perror_exit("make_cond failed"); return cond; } أما الدالتان المغلّفتان للدالتين cond_wait و cond_signal: void cond_wait(Cond *cond, Mutex *mutex) { int n = pthread_cond_wait(cond, mutex); if (n != 0) perror_exit("cond_wait failed"); } void cond_signal(Cond *cond) { int n = pthread_cond_signal(cond); if (n != 0) perror_exit("cond_signal failed"); } ترجمة -وبتصرّف- للفصل Condition variables من كتاب Think OS A Brief Introduction to Operating Systems
  8. الخيط (Thread) هو نوع معين أو خاص من العمليات، حيث ينشئ نظام التشغيل حيز عناوين جديدًا عند إنشاء عملية، ويتضمن هذا الحيز جزء الشيفرة أو نص البرنامج (text segment) والجزء الساكن (static segment) وجزء الكومة (heap)، وينشئ نظام التشغيل أيضًا خيط تنفيذ (thread of execution) جديدًا يتضمن عداد البرنامج (program counter) وحالة عتاد أخرى واستدعاء المكدس. العمليات التي رأيتها لحد الآن هي عمليات ذات خيط وحيد (single-threaded) أي يوجد خيط تنفيذ واحد فقط يعمل في كل حيز عناوين، وستتعرف على العمليات ذات الخيوط المتعددة (multi-threaded)، أي التي تملك خيوطًا متعددة تعمل في نفس حيز العناوين. تتشارك كل الخيوط بنفس جزء الشيفرة ضمن العملية الواحدة أي أنها تشغّل نفس الشيفرة، ولكن تشغّل هذه الخيوط المختلفة أجزاءً مختلفة من تلك الشيفرة، وتتشارك الخيوط ضمن العملية الواحدة بنفس الجزء الساكن (static segment)، لذلك إذا غيّر أحد الخيوط متغيرًا عامًا (global variable) فإن بقية الخيوط ترى هذا التغيير، ويتشاركون أيضًا بالكومة (heap) لذلك تستطيع الخيوط التشارك بقطع الذاكرة المخصصة ديناميكيًا (dynamically-allocated chunks)، ولكن يكون لكل خيطٍ جزء المكدس الخاص به لذلك تستطيع الخيوط استدعاء دوالٍ دون التداخل مع بعضها البعض، ولا تصل الخيوط عادةً إلى المتغيرات المحلية لخيطٍ آخر، حيث لا تستطيع الوصول إليها في بعض الأحيان. إنشاء الخيوط (Creating threads) الخيوط القياسية الأكثر شيوعًا والمستخدمة مع C هي خيوط POSIX أو اختصارًا Pthreads. تعرّف خيوط POSIX القياسية نموذج خيط (thread model) وواجهةًَ (interface) لإنشاء الخيوط والتحكم بها، وتوفّر معظم نسخ UNIX تطبيقًا ل Pthreads. يشبه استخدامُ Pthreads استخدامَ معظم مكتبات لغة C حيث: تضمّن ملفات الترويسات (headers files) في بداية برنامجك. تكتب الشيفرة التي تستدعي دوالًا معرّفة باستخدام Pthreads. تربط (link) البرنامج عند تصريفه (compile) مع مكتبة Pthread. يضمِّن البرنامج ملفات الترويسات التالية: #include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <semaphore.h> أول اثنين من ملفات الترويسات السابقة هما تضمين لمكتبات قياسية، أما ملف الترويسات الثالث فيُستخدم من أجل Pthreads، ويُستخدم ملف الترويسات الرابع من أجل متغيرات تقييد الوصول (semaphores). يمكنك استخدام الخيار -l في سطر الأوامر لتصريف البرنامج مع مكتبة Pthread باستخدام الأداة gcc كما يلي: gcc -g -O2 -o array array.c -lpthread يصرّف الأمر السابق ملفًا مصدريًا يدعى array.c مع معلومات تنقيح الأخطاء (debugging info) والتحسين (optimization) ويربطه مع مكتبة Pthread ثم يولّد ملفًا تنفيذيًا يدعى array. إنشاء الخيوط (Creating threads) تدعى دالة Pthread التي تنشئ خيوطًا pthread_create، وتُظهر الدالة التالية كيفية استخدامها: pthread_t make_thread(void *(*entry)(void *), Shared *shared) { int n; pthread_t thread; n = pthread_create(&thread, NULL, entry, (void *)shared); if (n != 0) { perror("pthread_create failed"); exit(-1); } return thread; } الدالة make_thread هي دالة مغلّفة (wrapper) وكُتبت لجعل الدالة pthread_create سهلة الاستخدام ولتوفير التحقق من الأخطاء (error-checking). نوع القيمة المعادة من الدالة pthread_create هو pthread_t والذي يمكنك التفكير به كمعرّف (id) أو مِقبض (handle) للخيط الجديد. إذا نجح تنفيذ الدالة pthread_create فستعيد القيمة 0 وتعيد الدالة make_thread مقبض الخيط الجديد، وإذا ظهر خطأ فتعيد الدالة pthread_create شيفرة الخطأ وتطبع الدالة make_thread رسالة خطأ وتنتهي. Shared المعامل الثاني للدالة make_thread هو عبارة عن بنية (structure) عُرِّفت لتتضمن القيم المشتركة بين الخيوط، حيث يمكنك تعريف نوع جديد من خلال استخدام عبارة typedef كما يلي: typedef struct { int counter; } Shared; والمتغير المشترك الوحيد في هذه الحالة هو counter، وتخصص الدالة make_shared حيّزًا للبنية Shared وتهيئ محتوياتها كما يلي: Shared *make_shared() { Shared *shared = check_malloc(sizeof(Shared)); shared->counter = 0; return shared; } لديك الآن بنية بيانات مشتركة وإذا عدتَ إلى الدالة make_thread وتحديدًا المعامل الأول الذي هو عبارة عن مؤشر (pointer) إلى دالة، وتأخذ هذه الدالة مؤشر void وتعيد مؤشر void أيضًا. إذا أصبح نظرك مشوشًا بسبب صيغة تصريح هذا النوع فلست الوحيد في ذلك، على كل حال إن الهدف الأساسي من هذا المعامل هو أن يحدد للدالة مكان بدء تنفيذ الخيط الجديد، وتدعى هذه الدالة entry: void *entry(void *arg) { Shared *shared = (Shared *)arg; child_code(shared); pthread_exit(NULL); } يجب أن يُصرَّح عن معامل الدالة entry كمؤشر void، ولكنه في هذا البرنامج مؤشرٌ إلى بنية Shared لذلك يمكن تبديل نوعه (typecast) ثم تمريره إلى الدالة child_code التي تقوم بالعمل الحقيقي، حيث تطبع الدالة child_code قيمة المتغير المشترك counter ثم تزيد قيمته كما يلي: void child_code(Shared *shared) { printf("counter = %d\n", shared->counter); shared->counter++; } تستدعي الدالةُ entry الدالةََ pthread_exit بعد أن تنتهي الدالة child code وتعيد قيمةً، حيث يمكن أن تُستخدم الدالة pthread_exit لتمرير قيمة إلى الخيط الذي يُضم (join) مع الخيط الحالي، وبالتالي في هذه الحالة لا يبقى شيء للخيط الابن لعمله فتُمرَّر القيمة الخالية NULL، وأخيرًا تنشئ الشيفرة التالية الخيوط الأبناء (child threads) كما يلي: int i; pthread_t child[NUM_CHILDREN]; Shared *shared = make_shared(1000000); for (i = 0; i < NUM_CHILDREN; i++) { child[i] = make_thread(entry, shared); } NUM_CHILDREN هو ثابت وقت التصريف (compile-time constant) الذي يحدد عدد الخيوط الأبناء، و child هي مصفوفة مقابض الخيوط (thread handles). ضم الخيوط (Joining threads) إذا أراد خيطٌ انتظار خيطٍ آخر ليكتمل فإنه يستدعي الدالة pthread_join، وتجد فيما يلي الدالة المغلّفة للدالة pthread_join: void join_thread(pthread_t thread) { int ret = pthread_join(thread, NULL); if (ret == -1) { perror("pthread_join failed"); exit(-1); } } معامل الدالة المغلّفة هو مقبض الخيط الذي تنتظره ليكتمل، وعمل الدالة المغلّفة هو فقط استدعاء الدالة pthread_join والتحقق من النتيجة. يستطيع أي خيط أن يضم أي خيطٍ آخر، ولكن في النماذج الأكثر شيوعًا ينشئ الخيط الأب (parent thread) كل الخيوط الأبناء ويضمها (join). تجد فيما يلي الشيفرة التي تنتظر الخيوط الأبناء بها: for (i = 0; i < NUM_CHILDREN; i++) { join_thread(child[i]); } تنتظر هذه الحلقات أحد الخيوط الأبناء في كل مرة وذلك حسب ترتيب إنشائها، ولا يوجد ضمان أن تكتمل الخيوط الأبناء في هذا الترتيب ولكن تعمل هذه الحلقة بصورة صحيحة حتى في حال لم يحدث ذلك، فإذا تأخر أحد الخيوط الأبناء فيجب أن تنتظر الحلقة، ويمكن أن تكتمل الخيوط الأبناء الأخرى خلال وقت الانتظار هذا، حيث لا يمكن أن تنتهي هذه الحلقة إلا في حال اكتمال جميع الخيوط الأبناء. يمكنك الاطلاع على المثال ضمن counter/counter.c ثم تصريفه وتشغيله كما يلي: $ make counter gcc -Wall counter.c -o counter -lpthread $ ./counter فعند تشغيله مع 5 خيوط أبناء سينتج الخرج التالي: counter = 0 counter = 0 counter = 1 counter = 0 counter = 3 وسينتج خرج آخر عندما تشغله على حاسوبك، وإذا شغلته مرة أخرى سينتج خرج مختلف أيضًا، فماذا يحدث؟ الأخطاء المتزامنة (Synchronization errors) مشكلة البرنامج السابق أن الخيوط الأبناء تستطيع الوصول إلى المتغير المشترك counter بدون تزامن، لذلك تستطيع عدة خيوط قراءة نفس قيمة المتغير counter قبل أن يزيد أي خيطٍ قيمته. يمكن أن تشرح سلسلة الأحداث التالية الخرج الذي حصلت عليه سابقًا: Child A reads 0 Child B reads 0 Child C reads 0 Child A prints 0 Child B prints 0 Child A sets counter=1 Child D reads 1 Child D prints 1 Child C prints 0 Child A sets counter=1 Child B sets counter=2 Child C sets counter=3 Child E reads 3 Child E prints 3 Child D sets counter=4 Child E sets counter=5 يمكن أن تُقاطَع الخيوط في أماكن مختلفة في كل مرة تشغّل فيها البرنامج، أو قد يختار المجدول خيوطًا مختلفة ليشغّلها، لذلك ستكون سلسلة الأحداث والنتائج مختلفة. افترض أنك تريد فرض بعض الترتيب، أي مثلًا تريد أن يقرأ كل خيط قيمةً مختلفة للمتغير counter ثم يزيدها، وبالتالي تُظهر قيمة المتغير counter عدد الخيوط التي نفّذت الدالة child_code، ويمكنك استخدام كائن المزامنة (mutex) لتطبيق ذلك، حيث كائن المزامنة (mutex) هو عبارة عن كائن (object) يضمن حدوث إقصاء متبادل (mutual exclusion) لكتلة من الشيفرة، أي ينفّذ خيطٌ واحد فقط كتلة الشيفرة في نفس الوقت. كتبتُ، يقول الكاتب، نموذجًا يدعى mutex.c يوفر كائنات المزامنة، ستجد فيما يلي نسخةً من الدالة child_code التي تستخدم كائن المزامنة لتأمين تزامن الخيوط: void child_code(Shared *shared) { mutex_lock(shared->mutex); printf("counter = %d\n", shared->counter); shared->counter++; mutex_unlock(shared->mutex); } حيث يجب على كل خيط أن يقفل (lock) كائن المزامنة قبل أن يصل أي خيطٍ آخر إلى المتغير المشترك counter، وهذا يؤدي إلى حظر كل الخيوط الأخرى من الوصول إلى هذا المتغير. افترض أن الخيط A قفل كائن المزامنة وهو في منتصف الدالة child_code، فإذا وصل الخيط B ونفّذ الدالة mutex_lock يتوقف تنفيذ الخيط B. ينفّذ الخيط A الدالة mutex_unlock عندما ينتهي، وبالتالي يسمح للخيط B متابعة تنفيذه، أي تنفّذ الخيوط الدالة child_code على التوالي بحيث ينفّذها خيطٌ واحدٌ فقط في نفس الوقت، وبالتالي لا يتعارض أي خيط مع الخيوط الأخرى، وإذا شغّلت الشيفرة مع 5 خيوط أبناء سينتج: counter = 0 counter = 1 counter = 2 counter = 3 counter = 4 وهذا هو المطلوب. يجب إضافة كائن المزامنة Mutex إلى البنية Shared لكي يعمل هذا الحل بالصورة الصحيحة: typedef struct { int counter; Mutex *mutex; } Shared; وتهيئته في الدالة make_shared: Shared *make_shared(int end) { Shared *shared = check_malloc(sizeof(Shared)); shared->counter = 0; shared->mutex = make_mutex(); //-- هذا السطر جديد return shared; } كائن المزامنة (Mutex) تعريفي، يقول الكاتب، ل Mutex هو مغلّف لنوعٍ يدعى pthread_mutex_t وهو معرّفٌ في واجهة برمجة التطبيقات للخيوط POSIX، ولإنشاء كائن مزامنة POSIX يجب تخصيص حيزٍ للنوع pthread_mutex_t ثم استدعاء الدالة pthread_mutex_init. إحدى مشاكل واجهة برمجة التطبيقات هذه أن النوع pthread_mutex_t يتصرف كبنية (structure)، لذلك إذا مررته كوسيط سينشئ نسخةً تجعل كائن المزامنة يتصرف بصورة غير صحيحة، ويمكنك تجنب ذلك من خلال تمرير النوع pthread_mutex_t باستخدام عنوانه. تجعل الشيفرة التي كتبتها، يقول الكاتب، الأمور أسهل من خلال تعريف نوعٍ هو النوع Mutex الذي هو عبارة عن اسم للنوع pthread_mutex_t يمكن قراءته بطريقة أسهل: #include <pthread.h> typedef pthread_mutex_t Mutex; ثم تعريف دالةٍ هي الدالة make_mutex التي تخصص حيّزًا لكائن المزامنة وتهيئته: Mutex *make_mutex() { Mutex *mutex = check_malloc(sizeof(Mutex)); int n = pthread_mutex_init(mutex, NULL); if (n != 0) perror_exit("make_lock failed"); return mutex; } القيمة المعادة هي مؤشر يمكن أن تمرره كوسيط دون أن يسبب نسخًا غير مرغوبة. الدوال التي تستخدم لقفل وفك قفل كائن المزامنة هي دوالٌ مغلّفة بسيطة لدوال POSIX: void mutex_lock(Mutex *mutex) { int n = pthread_mutex_lock(mutex); if (n != 0) perror_exit("lock failed"); } void mutex_unlock(Mutex *mutex) { int n = pthread_mutex_unlock(mutex); if (n != 0) perror_exit("unlock failed"); } ترجمة -وبتصرّف- للفصل Threads من كتاب Think OS A Brief Introduction to Operating Systems
  9. يتضمن المعالج نوى متعددة في العديد من الحواسيب الحالية وهذا يعني أنه يستطيع تشغيل عدة عمليات في نفس الوقت، وكل نواةٍ لديها القدرة على القيام بتعدد المهام (multitasking) أي يمكنها التبديل من عملية لعمليةٍ أخرى بسرعة، وبذلك تخلق وهمًا بوجود عدة عمليات مُشغَّلة في الوقت ذاته. يسمى جزء نظام التشغيل الذي يطبّق تعدد المهام بالنواة (kernel) وهي الجزء الأعمق في نظام التشغيل وتكون محاطةً بالصدفة (shell) سواء كان نظام تشغيل يشبه الجوزة (nut) أو البذرة (seed)، فالنواة (kernel) هي المستوى الأدنى من البرمجيات (software) في نظام التشغيل وتكون هذه النواة محاطةً بطبقات أخرى متعددة، وإحدى هذه الطبقات واجهةٌ (interface) تسمى صدفة (shell) حيث تلاحظ أن الاختصاصيين في علوم الحاسوب يحبون الاستعارات (metaphors). عمل النواة الأساسي هو معالجة المقاطعات، والمقاطعة هي الحدث الذي يوقِف دورة التعليمة (instruction cycle) القياسية ويسبب قفز تدفق التنفيذ (flow of execution) إلى جزءٍ خاص من الشيفرة يدعى معالج المقاطعة (interrupt handler). للمقاطعة نوعان هما: مقاطعة عتادية (hardware interrupt) ومقاطعة برمجية (software interrupt)، حيث تحدث المقاطعة العتادية (hardware interrupt) عندما يرسل جهازٌ ما إشارات إلى المعالج مثل تسبُّب واجهة الشبكة (network interface) بحدوث مقاطعة عند وصول حزمة بيانات (packet of data) أو مثل المقاطعة التي يسببها القرص الصلب (disk drive) عند اكتمال عملية نقل البيانات، وتحوي معظم الأنظمة مؤقتات (timers) تسبِّب مقاطعات عند الفواصل الزمنية المنتظمة (regular intervals) أو بعد انتهاء الوقت المستغرَق (elapsed time). تحدث المقاطعة البرمجية (software interrupt) بسبب برنامجٍ قيد التشغيل مثل عدم اكتمال تعليمةٍ لسببٍ ما، فتُنبّه هذه التعليمة مقاطعةً وبالتالي يعالج نظام التشغيل الشرط الخاص بالعملية المُقاطَعة، حيث تُعالَج أخطاء الأعداد العشرية (floating-point errors) مثل خطأ القسمة على صفر (division by zero) باستخدام المقاطعات. ينشئ برنامج استدعاء نظام (system call) عندما يريد هذا البرنامج الوصول إلى جهاز عتادي، ويشبه استدعاءُ النظام استدعاءَ دالةٍ ولكن بدلًا من القفز إلى بداية الدالة ينفّذ استدعاءُ النظام تعليمةً خاصة، وتنبّهُ هذه التعليمة مقاطعةً مسببةً قفز تدفق التنفيذ إلى النواة، ثم تقرأ النواة معامِلات استدعاء النظام وتجري العمليات المطلوبة ثم تستأنف العملية المُقاطَعة. حالة العتاد (Hardware state) تتطلب معالجة المقاطعات تعاونًا بين العتاد والبرمجيات، حيث من الممكن وجود تعليمات متعددة قيد التشغيل ضمن المعالج (CPU) وبيانات مُخزَّنة في المسجلات بالإضافة إلى حالة عتادٍ (hardware state) أخرى عند حدوث مقاطعة. يكون العتاد عادةً مسؤولًا عن وصول المعالج إلى حالة الاستقرار (consistent state) فيجب أن تكتمل كل تعليمة أو أن تتصرف كأنها لم تبدأ من الأساس أي لا وجود لتعليمةٍ نصفها مكتملٌ على سبيل المثال، والعتاد مسؤولٌ أيضًا عن حفظ عدّاد البرنامج (program counter) ويختصر إلى PC الذي تستخدمه النواة (kernel) لتعرف من أين ستستأنف تنفيذ التعليمات، ثم يستلم معالج المقاطعة (interrupt handler) مسؤولية حفظ بقية حالة العتاد قبل أن يقوم بأي شيء آخر يعدّل حالة العتاد هذه ثم يستعيد حالة العتاد المحفوظة سابقًا قبل استئناف العملية المُقاطعَة، حيث يمكن اختصار سلسلة الأحداث السابقة كما يلي: يحفظ العتاد عدّاد البرنامج في مسجّلٍ خاص عند حدوث المقاطعة ثم يقفز العتاد إلى معالج المقاطعة المناسب. ثم يخزّن معالجُ المقاطعة عدادَ البرامج وحالة المسجل (status register) في الذاكرة إلى جانب محتويات مسجلات البيانات التي من المُخطَّط استخدامها. ثم يُشغّل معالج المقاطعة الشيفرة المطلوبة لمعالجة هذه المقاطعة. يستعيد معالج المقاطعة محتويات المسجلات التي خزّنها سابقًا ثم أخيرًا يستعيد عداد البرنامج للعملية المقاطعة وهذا يؤدّي إلى العودة إلى التعليمة المُقاطَعة. إذا استخدِمت هذه الآلية بصورة صحيحة فلا يمكن أن تعلم العملية المقاطَعة بحدوث المقاطعة أبدًا إلّا إذا اكتشفت تغيّرًا في الوقت الفاصل بين التعليمات. تبديل السياق (Context switching) يمكن أن تكون معالجات المقاطعة سريعةً لأنها غير ملزمةٍ بحفظ كامل حالة العتاد وإنما تحفظ المسجلات التي من المخطط استخدامها فقط، ولكن لا تستأنف النواةُ العمليةَ المقاطعة دائمًا عند حدوث مقاطعةٍ ما وبالتالي يكون للنواة حرية التبديل إلى عملية أخرى، وتدعى هذه الآلية بتبديل السياق (context switch). لا تعلم النواة أيّ مسجلات ستستخدمها العملية لذلك يجب أن تحفظ كل المسجلات، ويجب على النواة تصفير البيانات المخزنة في وحدة إدارة الذاكرة (memory management unit) عند التبديل إلى عملية جديدة، حيث يمكن أن يستغرق تحميل بيانات العملية الجديدة إلى الذاكرة المخبئية بعض الوقت بعد تبديل السياق إليها لذلك يكون تبديل السياق بطيئًا نسبيًا فقد يستغرق آلاف الدورات أو عدة ميكرو ثانية. يُسمَح لكل عملية في نظام متعدد المهام أن تُشغَّل لفترة زمنية قصيرة تدعى بشريحة زمنية (time slice) أو حصّة (quantum)، وتضبط النواة مؤقت العتاد (hardware timer) خلال عملية تبديل السياق، وهذا يسبب حدوث مقاطعة عند نهاية الشريحة الزمنية، وبالتالي تستطيع النواة عند حدوث مقاطعةٍ التبديلَ إلى عملية أخرى أو السماح للعملية المقاطَعة أن تستأنف عملها، وجزء نظام التشغيل الذي يقرّر اختيار أحد هذين الخيارين هو المجدول (scheduler). دورة حياة العملية (The process life cycle) يخصص نظام التشغيل للعملية عند إنشائها بنية بيانات تتضمن معلومات عن هذه العملية وتدعى بينة البيانات هذه بكتلة تحكم العملية (process control block) وتختصر إلى PCB التي تتتبّع حالة العملية (process state)، ويكون للعملية أربع حالات هي: التنفيذ (Running): عند تنفيذ العملية ضمن النواة (core). الاستعداد (Ready): عندما تكون العملية جاهزة للتنفيذ ولكنها لا تُنفَّذ ويجب عليها الانتظار لأن عدد العمليات القابلة للتنفيذ أكبر من عدد الأنوية (cores). الإيقاف (Blocked): إذا كان غير ممكن أن تُنفَّذ العملية لأنها تنتظر حدثًا مستقبليًا مثل اتصال شبكة أو قراءة من القرص الصلب. الاكتمال (Done): إذا اكتمل تنفيذ العملية ولكنها تملك معلومات حالة المغادرة (exit status information) التي لم تُقرَأ بعد. الأحداث التي تسبب انتقال العملية من حالة إلى أخرى هي: تُنشَأ العملية عندما ينفّذ البرنامج المُشغَّل استدعاء نظام مثل fork، حيث تصبح العملية المنشَأة أو الجديدة في نهاية استدعاء النظام في حالة الاستعداد ثم قد يستأنف المجدول العملية الأصلية التي تسمى العملية الأب (parent) أو يبتدئ المجدول العملية الجديدة التي تسمى العملية الابن (child). تتغير حالة العملية من حالة الاستعداد إلى حالة التنفيذ عندما يبتدئها المجدول أو يستأنفها. تتغير حالة العملية من حالة التنفيذ إلى الاستعداد عندما تُقاطَع العملية ويختار المجدول ألا يستأنفها. إذا نفّذت العملية استدعاء النظام الذي لا يكتمل على الفور وإنما يحتاج وقتًا مثل الطلب من القرص الصلب فتصبح العملية بحالة الإيقاف وعندها يختار المجدول عمليةً أخرى لتنفيذها. إذا اكتملت عمليةٌ ما مثل عملية طلب من القرص الصلب فإنها تسبب مقاطعة، ويحدد معالج المقاطعة العملية المنتظرة لعملية الطلب هذه ويبدّل حالتها من حالة الإيقاف إلى الاستعداد ثم يختار المجدول أن يستأنفها أم لا. إذا استدعت العملية الدالة exit فإن معالج المقاطعة يخزّن شيفرة المغادرة (exit code) في كتلة تحكم العملية (PCB) ثم يغير حالة العملية إلى حالة الاكتمال. الجدولة (Scheduling) من الممكن وجود مئات العمليات على الحاسوب ولكن معظمها في حالة إيقاف (blocked) ويكون عدد قليل منها في حالة استعداد أو تنفيذ، والمجدول هو الذي يقرر أية عمليةٍ تبدأ التنفيذ أو تستأنف عملها عند حدوث مقاطعة. هدف المجدول الرئيسي هو تقليل وقت الاستجابة (response time) قدر الإمكان على الحاسوب المحمول (laptop) أو على محطة العمل (workstation)، حيث يجب أن يستجيب الحاسوب بسرعة لإجراءات المستخدم. وقت الاستجابة مهمٌ أيضًا في المخدمات (servers) بالإضافة إلى أنه يجب على المجدول زيادة الإنتاجية (throughput) والتي هي عدد الطلبات المنجزة خلال واحدة الزمن، وفي الحقيقة لا يملك المجدول معلومات كثيرة عمّا تفعله العمليات لذلك تعتمد قراراته في اختيار العملية على عدة استنتاجات هي: يمكن أن تكون العمليات محدودةً بموارد مختلفة، فالعملية التي تقوم بعمليات حسابية كثيرة محدودةٌ بالمعالج (CPU-bound) أي أن وقت تشغيل هذه العملية يعتمد على كمية الوقت الذي تأخذه من وقت المعالج، أما العملية التي تقرأ بيانات من الشبكة أو من القرص الصلب فتكون محدودةً بعمليات الإدخال والإخراج (I/O-bound) أي تكون هذه العملية أسرع إذا كان إدخال أو إخراج البيانات أسرع، ولكنها لن تنفّذ أسرع إذا كان وقت المعالج الخاص بها أكبر، ويمكن أن تكون العملية التي تتفاعل مع المستخدم في حالة الإيقاف حيث ستبقى منتظرةً إجراءات المستخدم معظم الوقت. يصنّف نظام التشغيل العمليات أحيانًا تبعًا لسلوكها السابق ويجدولها بناءً على ذلك، فمن المحتمل أن تنفّذ العملية التفاعلية (interactive process) مباشرةً عندما تنتهي من حالة الإيقاف لأن المستخدم ينتظر ردًا منها، بينما تكون العملية المحدودة بالمعالج (CPU-bound) والتي ما زالت تنفّذ منذ مدة طويلة أقلَّ حساسيةً لعامل الوقت. إذا كان من المحتمل أن تُشغَّل العملية لفترة قصيرة ثم تطلب شيئًا يجعلها في حالة إيقاف، فيمكن أن تُشغَّل على الفور لسببين هما: (1) إذا استغرق الطلب بعض الوقت لإكماله فيجب أن يبدأ في أقرب وقت ممكن، و (2) من الأفضل أن تنتظر عملية ذات وقت تنفيذ طويل لفترةٍ قصيرة وليس العكس، بصورة مشابهة افترض أنك تصنع فطيرة تفاح، حيث يستغرق تحضير الطبقة الخارجية للفطيرة 5 دقائق ولكن يجب تركها لتبرد لمدة نصف ساعة ويستغرق تحضير حشوة الفطيرة 20 دقيقة، فإذا حضّرت الطبقة الخارجية أولًا فيمكنك تحضير الحشوة ريثما تبرد الطبقة الخارجية وبالتالي تنهي تحضير الفطيرة خلال 35 دقيقة، أما إذا حضّرت الحشوة أولًا فيستغرق تحضير الفطيرة 55 دقيقة. تستخدم معظم المجدولات بعض نماذج الجدولة المعتمدة على الأولوية (priority-based scheduling)، حيث يكون لكل عملية أولوية تزيد أو تنقص خلال الوقت ويختار المجدول العملية القابلة للتنفيذ ذات الأولوية العليا، وهناك عدة عوامل لتحديد أولوية العملية هي: تبدأ العملية عادةً برقم أولوية عالٍ نسبيًا لذلك تبدأ التنفيذ بسرعة. إذا طلبت العملية شيئًا ما جعلها في حالة إيقاف قبل انتهاء شريحتها الزمنية ضمن المعالج فمن المحتمل أن تكون عملية تفاعلية مع المستخدم (interactive) أو عملية محدودة بعمليات الإدخال والإخراج (I/O-bound) لذلك يجب أن تصبح أولويتها أعلى. إذا انتهت الشريحة الزمنية الخاصة بالعملية ضمن المعالج ولم ينتهِ تنفيذ هذه العملية فمن المحتمل أن تكون عملية ذات وقت تنفيذ طويل (long-running) ومحدودة بالمعالج (CPU-bound) لذلك يجب أن تصبح أولويتها أقل. إذا توقفت مهمةٌ لمدة طويلة ثم أصبحت بحالة استعداد فيجب أن تحصل على زيادة في الأولوية لتتمكن من الاستجابة على الشيء الذي انتظرته. إذا توقفت العملية A بسبب انتظارها للعملية B وهاتان العمليتان مرتبطتان عن طريق أنبوب (pipe) مثلًا فيجب أن تصبح أولوية العملية B أعلى. يسمح استدعاء النظام nice للعملية بتقليل أولويتها (ولا تسمح بزيادتها) مما يسمح للمبرمجين بتمرير معلومات محددة إلى المجدول. لا تؤثر خوازرميات الجدولة (scheduling algorithms) كثيرًا على أداء معظم أنظمة التشغيل التي تعمل بأحمال (workloads) عادية فسياسات الجدولة (scheduling policies) البسيطة جيدة كفاية لهذه الأنظمة. الجدولة في الوقت الحقيقي (Real-time scheduling) الجدولة مهمةٌ جدًا بالنسبة للبرامج التي تتفاعل مع العالم الحقيقي، فقد يضطر البرنامج الذي يقرأ بيانات من الحسّاسات (sensors) والذي يتحكم بالمحركات إلى إكمال المهام المتكررة بالحد الأدنى من التكرار وأن يتفاعل مع الأحداث الخارجية بالحد الأقصى من وقت الاستجابة. يُعبّر عن هذه المتطلبات بالمهام التي يجب إكمالها قبل المواعيد النهائية (deadlines). تدعى جدولة المهام من أجل الوفاء بالمواعيد النهائية بالجدولة في الوقت الحقيقي (real-time scheduling)، ويمكن تعديل أنظمة التشغيل التي تستخدم للأغراض العامة مثل Linux لتتعامل مع الجدولة في الوقت الحقيقي بالنسبة لبعض التطبيقات، وقد تشمل هذه التعديلات ما يلي: توفير واجهات برمجة تطبيقات (APIs) أثرى للتحكم في أولويات المهام. تعديل المجدول لضمان تشغيل العملية ذات الأولوية الأعلى خلال مدة زمنية محددة. إعادة تنظيم معالجات المقاطعة لضمان أكبر وقت لاكتمال العمليات. تعديل الأقفال (locks) وآليات المزامنة الأخرى (synchronization mechanisms) (سنتطرق إليها لاحقًا) للسماح لمهمة ذات أولوية عالية أن تسبق مهمة ذات أولوية أقل. اختيار تطبيق تخصيص الذاكرة الديناميكي الذي يضمن أكبر وقت لاكتمال العمليات. توفر أنظمة التشغيل في الوقت الحقيقي (real-time operating systems) إمكانيات متخصصة بالنسبة للتطبيقات الأكثر طلبًا وخاصة في المجالات التي تمثل فيها الاستجابة في الوقت الحقيقي مسألةَ حياة أو موت، وتكون هذه الأنظمة ذات تصميم أبسط بكثير من أنظمة التشغيل ذات الأغراض العامة. ترجمة -وبتصرّف- للفصل Multitasking من كتاب Think OS A Brief Introduction to Operating Systems
  10. كيف ينفذ البرنامج (How programs run) يجب أن تفهم كيف تنفّذ الحواسيب البرامج لفهم عملية التخبئة (caching)، وينبغي عليك دراسة معمارية الحاسوب لفهم التخبئة بصورةٍ أعمق. تكون الشيفرة أو النص البرمجي ضمن القرص الصلب (hard disk) أو ضمن SSD عند بدء تشغيل البرنامج، وينشئ نظام التشغيل عمليةً جديدة لتشغيل البرنامج ثم ينسخ المحمِّل (loader) نص البرنامج من التخزين الدائم إلى الذاكرة الرئيسية ثم يبدأ البرنامج من خلال استدعاء الدالة main. تُخزَّن معظم بيانات البرنامج في الذاكرة الرئيسية أثناء تنفيذه، ولكن تكون بعض هذه البيانات موجودةً في مسجّلات (registers) والتي هي وحدات صغيرة من الذاكرة موجودة في المعالج (CPU) وتتضمن هذه المسجلات ما يلي: عداد البرنامج (The program counter) واختصاره PC الذي يتضمن عنوان التعليمة التالية من البرنامج (العنوان في الذاكرة). مسجّل التعليمة (The instruction register) ويختصر إلى IR ويتضمن شيفرة الآلة للتعليمة التي تنفّذ حاليًا. مؤشر المكدس (The stack pointer) واختصاره SP الذي يتضمن عنوان إطار المكدس (stack frame) للدالة الحالية ويحتوي إطار المكدس معاملات الدالة ومتغيراتها المحلية. مسجلات ذات أغراض عامة (General-purpose registers) التي تحتفظ بالبيانات التي يعمل عليها البرنامج حاليًا. مسجل الحالة (status register) أو مسجل الراية (flag register) الذي يتضمن معلومات عن العمليات الحسابية الحالية، حيث يتضمن مسجل الراية عادةً بتًا، ويُضبَط هذا البت إذا كانت نتيجة العملية السابقة صفرًا على سبيل المثال. ينفّذ المعالج الخطوات التالية عند تشغيل البرنامج وتدعى هذه الخطوات بدورة التعليمة (instruction cycle): الجلب (Fetch): تُجلَب التعليمة التالية من الذاكرة ثم تُخزَّن في مسجّل التعليمة. فك التشفير (Decode): يَفك جزءُ المعالج الذي يدعى وحدة التحكم (control unit) تشفيرَ التعليمة ثم يرسل إشاراتٍ إلى الأجزاء الأخرى من المعالج. التنفيذ (Execute): تسبب إشارات وحدة التحكم ظهور العمليات الحسابية المناسبة. تستطيع معظم الحواسيب تنفيذ بضع مئات من التعليمات المختلفة تدعى بمجموعة التعليمات (instruction set) ولكن تندرج معظم التعليمات ضمن فئات عامة هي: تعليمات التحميل (Load): تنقل قيمةً من الذاكرة إلى المسجل. التعليمات الحسابية أو المنطقية (Arithmetic/logic): تحمّل المعامَلات (operands) من المسجّلات ثم تجري عمليات رياضية ثم تخزّن النتيجة في مسجّل. تعليمات التخزين (Store): تنقل قيمةً من المسجّل إلى الذاكرة. تعليمات القفز وتعليمات الفرع (Jump/branch): تسبب تغييراتُ عداد البرنامج قفزَ تدفق التنفيذ (flow of execution) إلى موقعٍ آخر من البرنامج. تكون الفروع مشروطة عادةً وهذا يعني أن الفروع تتحقق من رايةٍ ما في مسجل الراية ثم تقفز إلى موقع آخر من البرنامج في حال ضُبطت هذه الراية فقط. توفر بعضُ مجموعات التعليمات الموجودة ضمن معمارية نظام التشغيل واسعة الانتشار x86 تعليماتٍ تجمع بين عمليةٍ حسابيةٍ وعملية تحميل. تُقرَأ تعليمةٌ واحدةٌ من نص البرنامج خلال كل دورة تعليمة، ويُحمَّل حوالي نصف تعليمات البرنامج أو تخزن بياناتها، وتكمن هنا واحدة من المشاكل الأساسية في معمارية الحاسوب وهي مشكلة عنق زجاجة الذاكرة أو اختناق الذاكرة (memory bottleneck). نواة الحواسيب الحالية قادرةٌ على تنفيذ تعليمةٍ في أقل من 1 نانو ثانية، ولكن يُقدّر الوقت اللازم لنقل بيانات من وإلى الذاكرة بحوالي 100 نانو ثانية، وإذا توجّب على المعالج الانتظار 100 نانو ثانية لجلب التعليمة التالية و100 نانو ثانية أخرى لتحميل البيانات فسيكمل المعالج التعليمات بصورة أبطأ ب 200 مرة مما هو متوقع، لذلك تُعَد الذاكرة في العديد من العمليات الحسابية هي عامل تحديد السرعة ولا يُعَد المعالج كذلك. أداء الذاكرة المخبئية (Cache performance) الذاكرة المخبئية هي الحل لمشكلة اختناق الذاكرة أو على الأقل حلٌ جزئي لها، حيث أن الذاكرة المخبئية (cache) ذاكرةٌ صغيرة الحجم وسريعة ومتواجدة قرب المعالج على نفس الشريحة عادةً. تحوي الحواسيب الحديثة مستويات متعددة من الذاكرة المخبئية هي: ذاكرة مخبئية ذات مستوى أول (Level 1 cache) وهي الأصغر حجمًا والأسرع حيث يتراوح حجمها بين 1 و 2 ميبي بايت مع وقت وصول 1 نانو ثانية تقريبًا، أما الذاكرة المخبئية ذات المستوى الثاني (Level 2 cache) التي تملك وقت وصول يساوي 4 نانو ثانية تقريبًا، وتملك الذاكرة المخبئية ذات المستوى الثالث وقت وصول يساوي 16 نانو ثانية. يخزّن المعالج نسخةً من القيمة التي يحمّلها من الذاكرة في الذاكرة المخبئية، وإذا حُمّلت تلك القيمة مرةً أخرى يجلب المعالج نسخة هذه القيمة من الذاكرة المخبئية وبالتالي لا يضطر المعالج إلى الانتظار لجلب القيمة من الذاكرة، ولكن من الممكن أن تمتلئ الذاكرة المخبئية وبالتالي يجب إخراج بعض القيم من الذاكرة المخبئية عند إحضار قيم أخرى، لذلك إذا حمّل المعالج قيمةً ثم عاد لتحميلها مرةً أخرى ولكن بعد وقت طويل فقد لا تكون هذه القيمة موجودةً ضمن الذاكرة المخبئية. إن أداء العديد من البرامج محدودٌ بمقدار فعالية الذاكرة المخبئية، فإذا كانت التعليمات والبيانات التي يحتاجها المعالج موجودةً في الذاكرة المخبئية فإن البرنامج يمكن أن ينفّذ بسرعةٍ قريبة من سرعة المعالج الكاملة، بينما إذا احتاج المعالج بياناتٍ غير موجودةٍ في الذاكرة المخبئية مرارًا فسيكون المعالج محدودًا بسرعة الذاكرة. معدّل الإصابة (hit rate) للذاكرة المخبئية الذي يرمز له h هو جزء عمليات الوصول للذاكرة التي تجد البيانات في الذاكرة المخبئية، أما معدل الإخفاق (miss rate) والذي يرمز له m هو جزء عمليات الوصول للذاكرة التي يجب أن تذهب إلى الذاكرة لأنها لم تجد البيانات التي تريدها ضمن الذاكرة المخبئية، فإذا كان وقت إصابة الذاكرة المخبئية هو Th ووقت إخفاق الذاكرة المخبئية هو Tm فإن متوسط وقت كل عملية وصولٍ للذاكرة هو: h Th + m Tm، ويمكن تعريف عقوبة الإخفاق (miss penalty) كوقتٍ إضافي لمعالجة إخفاق الذاكرة المخبئية والذي يساوي: Tp =Tm-Th وبالتالي متوسط وقت الوصول هو: Th+mTp، يساوي متوسط وقت الوصول تقريبًا Th عندما يكون معدل الإخفاق منخفضًا، وبالتالي يؤدي البرنامج عمله وكأن الذاكرة تعمل بسرعة الذاكرة المخبئية. المحلية (Locality) تحمّل الذاكرة المخبئية عادةً كتلةً أو سطرًا من البيانات الذي يتضمن البايت المطلوب وبعضًا من البايتات المجاورة له وذلك عندما يقرأ البرنامج بايتًا للمرة الأولى، وبالتالي إذا حاول البرنامج قراءة إحدى تلك البايتات المجاورة للبايت المطلوب لاحقًا فستكون موجودةً في الذاكرة المخبئية مسبقًا. افترض أن حجم الكتلة هو 64 بايتًا مثل قراءة سلسلة طولها 64 بايتًا، حيث يمكن أن يقع أول بايت من السلسلة في بداية الكتلة، فستتحمّل عقوبة إخفاق (miss penalty) إذا حمّلت أول بايت ولكن ستوجد بقية السلسلة في الذاكرة المخبئية بعد ذلك، أي يساوي معدل الإصابة 63/64 بعد قراءة كامل السلسلة أي ما يعادل 98%، أما إذا كان حجم السلسلة يعادل كتلتين فستتحمّل عقوبتي إخفاق ويكون معدل الإصابة مساويًا 62/64 أو 97%، ويصبح معدل الإصابة 100% إذا قرأت السلسلة ذاتها مرة أخرى، ولكن سيصبح أداء الذاكرة المخبئية سيئًا إذا قفز البرنامج بصورة غير متوقعة محاولًا قراءة بيانات من مواقع متفرقة في الذاكرة أو إذا كان الوصول إلى نفس الموقع مرتين نادرًا. يدعى ميل البرنامج لاستخدام البيانات ذاتها أكثر من مرة بالمحلية الزمانية (temporal locality)، ويدعى ميل البرنامج لاستخدام البيانات المتواجدة في مواقع قريبة من بعضها بعضًا بالمحلية المكانية (spatial locality)، حيث تقدّم البرامج نوعي المحلية كما يلي: تحوي معظم البرامج كتلًا من الشيفرة بدون تعليمات قفزٍ أو فروع، حيث تنفّذ التعليمات في هذه الكتل تسلسليًا، وبالتالي يملك نموذج الوصول (access pattern) محليةً مكانية (spatial locality). تنفّذ البرامج نفس التعليمات مرات متعددة في الحلقات التكرارية (loop)، وبالتالي يملك نموذج الوصول (access pattern) محليةً زمانية (temporal locality). تُستخدم نتيجة تعليمةٍ ما كمُعامَل (operand) للتعليمة التالية مباشرةً، وبالتالي يملك نموذج وصول البيانات محليةً زمانية (temporal locality). تُخزَّن معامِلات دالةٍ ومتغيراتها المحلية معًا في جزء المكدس عندما ينفّذ البرنامج هذه الدالة، أي يملك الوصول إلى هذه القيم محليةً مكانية (spatial locality). أحد أكثر نماذج المعالجة شيوعًا هو قراءة أو كتابة عناصر مصفوفةٍ تسلسليًا وبالتالي تملك هذه النماذج محليةً مكانية (spatial locality). قياس أداء الذاكرة المخبئية (Measuring cache performance) أحد برامجي المفضلة، يقول الكاتب، هو البرنامج الذي يتكرر خلال مصفوفة ويقيس متوسط وقت قراءة وكتابة عنصر، ويمكن استنتاج حجم الذاكرة المخبئية وحجم الكتلة وبعض الخصائص الأخرى من خلال تغيير حجم تلك المصفوفة، والجزء الأهم من هذا البرنامج هو الحلقة التكرارية (loop) التالية: iters = 0; do { sec0 = get_seconds(); for (index = 0; index < limit; index += stride) array[index] = array[index] + 1; iters = iters + 1; sec = sec + (get_seconds() - sec0); } while (sec < 0.1); حيث تعبُر (traverses) حلقة for الداخلية المصفوفة، ويحدد المتغير limit مقدار قيم المصفوفة التي تريد عبورها، ويحدد المتغير stride الخطوة أو عدد العناصر التي يجب تجاوزها في كل تكرار، فمثلًا إذا كانت قيمة المتغيرlimit هي 16 وقيمة المتغير stride هي 4 فإن الحلقة ستصل إلى القيم التالية: 0 و 4 و 8 و 12. يتتبّع المتغير sec وقت المعالج الذي تستخدمه حلقة for الداخلية، وتنفّذ الحلقة الخارجية حتى يتخطى المتغير sec حاجز 0.1 ثانية والذي هو وقتٌ كافٍ لحساب الوقت الوسطي بدقةٍ كافية. تستخدم الدالة get_seconds استدعاء النظام clock_gettime وتحوّل الوقت إلى ثوانٍ ثم تعيد النتيجة كعدد عشري مضاعف double: double get_seconds() { struct timespec ts; clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts); return ts.tv_sec + ts.tv_nsec / 1e9; } يشغّل البرنامج حلقة أخرى لعزل وقت الوصول لعناصر المصفوفة، وهذه الحلقة مطابقة للحلقة في المثال السابق باستثناء أن الحلقة الداخلية لا تمس المصفوفة وإنما تزيد نفس المتغير كما يلي: iters2 = 0; do { sec0 = get_seconds(); for (index = 0; index < limit; index += stride) temp = temp + index; iters2 = iters2 + 1; sec = sec - (get_seconds() - sec0); } while (iters2 < iters); حيث تلاحظ أن الحلقة الثانية تنفّذ نفس عدد تكرارات الحلقة الأولى، وتطرح الوقت المستغرق بعد كل تكرار من المتغير sec، يتضمن المتغير sec عند اكتمال الحلقة الوقت الكلي لكل عمليات الوصول إلى المصفوفة مطروحًا منه الوقت الإجمالي لازدياد المتغير temp، وتتحمّل كل عمليات الوصول هذا الاختلاف الذي يمثّل عقوبة الإخفاق الكلية، ثم تُقسَم عقوبة الإخفاق الكلية على عدد عمليات الوصول للحصول على متوسط عقوبات الإخفاق في كل وصول مقدّرًا بالنانو ثانية كما يلي: sec * 1e9 / iters / limit * stride إذا صرّفت البرنامج السابق ثم شغّلته سيظهر الخرج التالي: Size: 4096 Stride: 8 read+write: 0.8633 ns Size: 4096 Stride: 16 read+write: 0.7023 ns Size: 4096 Stride: 32 read+write: 0.7105 ns Size: 4096 Stride: 64 read+write: 0.7058 ns إذا كان لديك Python و matplotlib على جهازك فيمكنك استخدام graph_data.py لتحصل على رسمٍ بياني للنتائج كما في الشكل التالي الذي يظهر نتائج البرنامج عندما أشغّله، يقول الكاتب، على Dell Optiplex 7010: لاحظ أن حجم المصفوفة والخطوة (stride) مقدران بالبايت وليس بأعداد عناصر المصفوفة. إذا تمعنت النظر في الرسم البياني السابق سيمكنك استنتاج بعض الأشياء عن الذاكرة المخبئية والتي هي: يقرأ البرنامج من المصفوفة مراتٍ متعددة لذلك يكون لديه كثيرٌ من المحلية الزمانية (temporal locality)، فإذا كانت المصفوفة بكاملها في الذاكرة المخبئية فهذا يعني أن متوسط عقوبة الإخفاق (miss penalty) تساوي 0 تقريبًا. يمكنك قراءة كل عناصر المصفوفة إذا كانت قيمة الخطوة (stride) هي 4 بايتات أي يكون لدى البرنامج كثيرٌ من المحلية المكانية (spatial locality)، وإذا كان حجم الكتلة مناسبًا لتضمين 64 عنصرًا على سبيل المثال فإن معدل الإصابة (hit rate) يساوي 63/64 على الرغم من عدم وجود المصفوفة في الذاكرة المخبئية. إذا كانت الخطوة الواحدة مساويةً لحجم الكتلة أو أكبر منها فإن قيمة المحلية المكانية صفر عمليًا، لأنك تصل إلى عنصرٍ واحد فقط في كل مرة تقرأ فيها كتلةً، أي من المتوقع أن تشاهد الحد الأعلى من عقوبة الإخفاق (miss penalty) في هذه الحالة. من المتوقع الحصول على أداء جيد للذاكرة المخبئية إذا كانت المصفوفة أصغر من حجم الذاكرة المخبئية أو إذا كانت الخطوة أصغر من حجم الكتلة، بينما يقل الأداء فقط إذا كانت المصفوفة أكبر من الذاكرة المخبئية والخطوة كبيرة. أداء الذاكرة المخبئية في الرسم البياني السابق جيد بالنسبة لجميع الخطوات طالما أن المصفوفة أصغر من 222 بايتًا، وبالتالي يمكنك استنتاج أن حجم الذاكرة المخبئية يقارب 4 ميبي بايت ولكن اعتمادًا على المواصفات فإن حجمها هو 3 ميبي بايت. يكون أداء الذاكرة المخبئية جيدًا مع قيم الخطوة 8 و 16 و 32 بايتًا، ولكن يبدأ الأداء بالانخفاض عندما تصبح قيمة الخطوة 64 بايتًا، ويصبح متوسط عقوبة الإخفاق 9 نانو ثانية تقريبًا عندما تصبح الخطوة أكبر، وبالتالي يمكنك استنتاج أن حجم الكتلة يقارب 128 بايتًا. تستخدم العديد من المعالجات الذواكر المخبئية ذات المستويات المتعددة (multi-level caches) والتي تتضمن ذواكر مخبئية صغيرة وسريعة وذواكر مخبئية كبيرة وبطيئة، ويُلاحَظ أن عقوبة الإخفاق تزيد قليلًا عندما يصبح حجم المصفوفة أكبر من 214 بايتًا، لذلك من المحتمل أن يكون للمعالج ذاكرةٌ مخبئية حجمها 16 كيلو بايت وبوقت وصول أقل من 1 نانو ثانية. برمجة أداء الذاكرة المخبئية (Programming for cache performance) تُطبَّق تخبئة الذاكرة في العتاد لذلك لا يتوجّب على المبرمجين معرفة الكثير عنها ولكن إذا عرفت كيفية عمل الذواكر المخبئية فيمكّنك ذلك من كتابة برامجٍ تستخدم الذاكرة المخبئية بفعاليةٍ أكثر، وإذا عملت مع مصفوفة كبيرة على سبيل المثال فمن الممكن أن تعبُر المصفوفة بسرعةٍ مرة واحدة ثم إجراء عملياتٍ متعددة على كل عنصر من المصفوفة وذلك أفضل من عبور المصفوفة مراتٍ متعددة، وإذا تعاملت مع مصفوفة ثنائية الأبعاد (2D array) فيمكن أن تُخزّن هذه المصفوفة كمصفوفة من الصفوف (rows)، وإذا عبرت خلال عناصر المصفوفة فسيكون عبور عناصر هذه المصفوفة من خلال الصفوف (row-wise) أسرع وذلك مع خطوةٍ مساويةٍ لحجم العنصر وهذا أفضل من عبورها من خلال الأعمدة (column-wise) مع خطوة مساوية لطول الصف (row) الواحد. لا تقدّم بنى البيانات المترابطة (Linked data structures) محليةً مكانية وذلك لأنه ليس ضروريًا أن تكون عقد هذه البنى المترابطة متجاورةً في الذاكرة، ولكن إذا خصصت عدة عقد في نفس الوقت فستكون موجودة في مواقع مشتركة من الكومة، وإذا خصصت مصفوفة العقد كلها مرةً واحدةً فستكون في مواقع متجاورة حتمًا. تملك الاستراتيجيات التعاودية (Recursive strategies) مثل خوارزمية الفرز بالدمج (mergesort) سلوك ذاكرةٍ مخبئية جيدًا لأنها تقسّم المصفوفات الكبيرة إلى أجزاء صغيرة ثم تعمل على هذه الأجزاء الصغيرة، وتُضبَط هذه الخوارزميات في بعض الأحيان للاستفادة من سلوك الذاكرة المخبئية. يمكن تصميم خوارزمياتٍ في التطبيقات التي يكون الأداء فيها مهمًا جدًا بحيث تكون هذه الخوارزميات مضبوطة من حيث حجم الذاكرة المخبئية وحجم الكتلة وخصائص عتاد أخرى، ويدعى هذا النوع من من الخوارزميات بالخوارزميات ذات الإدراك حسب الذاكرة المخبئية (cache-aware)، ولكن العائق الأهم لهذا النوع من الخوارزميات هو أنها خاصة بالعتاد (hardware-specific). هرمية الذاكرة (The memory hierarchy) لا بدّ أنه خطر على بالك السؤال التالي: لماذا لم تُصنّع ذاكرة مخبئية كبيرة وبالتالي تُلغى الذاكرة الرئيسية نهائيًا بما أن الذاكرة المخبئية أسرع بكثير من الذاكرة الرئيسية؟ وجواب هذا السؤال هو وجود سببين أساسيين هما: سبب متعلق بالالكترونيات والآخر سبب اقتصادي، فإن الذواكر المخبئية سريعة لأنها صغيرة وقريبة من المعالج وهذا يقلل التأخير بسبب سعة وانتشار الإشارة، وبالتالي إذا صنعت ذاكرة مخبئية كبيرة فستكون بطيئة حتمًا، وتشغَل الذواكر المخبئية حيّزًا على شريحة المعالج وكلما كانت الشريحة أكبر فسيصبح سعرها أكبر. أما الذاكرة الرئيسية فهي ذاكرة عشوائية ديناميكية (dynamic random-access memory واختصارها DRAM) والتي تستخدم ترانزستورًا ومكثفًا واحدًا لكل بت، وبالتالي يمكن ضغط مزيد من الذاكرة في نفس الحيّز، ولكن هذا التطبيق يجعل الذاكرة أبطأ من الطريقة التي تُطبَّق فيها الذواكر المخبئية. تُحزَم الذاكرة الرئيسية عادةً ضمن وحدة الذاكرة ثنائية الخط (dual in-line memory module) واختصارها DIMM والتي تتضمن 16 شريحة أو أكثر، فشرائح صغيرة متعددة أرخص من شريحة واحدة كبيرة الحجم. وجود تقايض بين السرعة والحجم والكلفة هو السبب الأساسي لصنع الذواكر المخبئية، فإذا وجدت تقنية سريعة وكبيرة ورخيصة للذاكرة فلسنا بحاجة أي شيء آخر. يطبّق نفس المبدأ بالنسبة للتخزين الدائم كما في الذاكرة الرئيسية، فالأقراص من النوع SSD سريعة ولكنها أغلى من الأقراص الصلبة HDD وبالتالي يجب أن تكون أصغر من أجل التوفير في الكلفة، ومحركات الأشرطة (Tape drives) أبطأ من الأقراص الصلبة ولكنها تخزّن كميات كبيرة من البيانات وبسعر رخيص نسبيًا. يظهر الجدول التالي وقت الوصول والحجم والكلفة لكل من هذه التقنيات: الجهاز (Device) وقت الوصول (Access time) الحجم (Typical size) الكلفة (Cost) المسجل (Register) يقدر ب 0.5 نانو ثانية 256 بايت ؟ الذاكرة المخبئية (Cache) يقدر ب 1 نانو ثانية 2 ميبي بايت ؟ الذاكرة العشوائية الديناميكية (DRAM) يقدر ب 100 نانو ثانية 4 جيبي بايت 10 دولار / جيبي بايت قرص التخزين ذو الحالة الثابتة (SDD) يقدر ب 10 ميكرو ثانية 100 جيبي بايت 1 دولار / جيبي بايت القرص الصلب (HDD) يقدر ب 5 ميلي ثانية 500 جيبي بايت 0.25 دولار / جيبي بايت محرك الأشرطة (Tape) دقائق من 1 إلى 2 تيبي بايت 0.02 دولار / جيبي بايت table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } يعتمد عدد وحجم المسجلات على تفاصيل معمارية الحواسيب حيث تملك الحواسيب الحالية 32 مسجلًا ذو أغراضٍ عامة (general-purpose registers) ويخزن كل مسجل منها كلمة (word) واحدة حيث تساوي الكلمة 32 بتًا أو 4 بايتات في حواسيب 32 بت وتساوي 64 بتًا أو 8 بايتات في حواسيب 64 بت، وبالتالي يتراوح الحجم الإجمالي لملف المسجلات بين 100 إلى 300 بايت. من الصعب تحديد كلفة المسجلات والذواكر المخبئية لأنه تُجمَل كلفتهم مع كلفة الشرائح الموجودين عليها، وبالتالي لا يرى المستهلك كلفتهم بطريقة مباشرة، وبالنسبة للأرقام الأخرى الموجودة في الجدول فقد ألقيت النظر، يقول الكاتب، على العتاد النموذجي الموجود في المتاجر الالكترونية لعتاد الحاسوب، وربما ستكون هذه الأرقام غير مستعملة في الوقت الذي ستقرأ فيه هذا المقال ولكنها ستعطيك فكرةً عن الفجوة التي كانت موجودة بين الأداء والكلفة في وقتٍ ما. تشكل التقنيات الموجودة في الجدول السابق هرمية الذاكرة (memory hierarchy) -والذي يتضمن التخزين الدائم (storage) أيضًا- حيث يكون كل مستوى من هذه الهرمية أكبر وأبطأ من المستوى الذي فوقه، وبمعنىً آخر يمكن عدّ كل مستوى هو ذاكرة مخبئية للمستوى الذي تحته، فيمكنك عدّ الذاكرة الرئيسية كذاكرة مخبئية للبرامج والبيانات التي تُخزَّن على أقراص SSD و HHD بصورة دائمة، وإذا عملت مع مجموعات بيانات كبيرة جدًا ومخزنة على محرك أشرطة (tape) فيمكنك استخدام القرص الصلب كذاكرة مخبئية لمجموعة بيانات فرعية واحدة في كل مرة. سياسة التخبئة (Caching policy) تقدّم هرمية الذاكرة إطار عملٍ للتخبئة (caching) من خلال الإجابة على أربعة أسئلة أساسية عن التخبئة في كل مستوٍ من هذه الهرمية وهي: من ينقل البيانات إلى الأعلى والأسفل ضمن هذه الهرمية؟ يخصص المصرّفُ (compiler) المسجّلَ في قمة هذه الهرمية، ويكون العتاد الموجود على المعالج مسؤولًا عن الذاكرة المخبئية، ينقل المستخدمون البيانات من التخزين الدائم إلى الذاكرة ضمنيًا عندما ينفّذون برامجًا وعندما يفتحون ملفات، ولكن ينقل نظام التشغيل البيانات أيضًا ذهابًا وإيابًا بين الذاكرة والتخزين الدائم، وينقل مسؤولو نظام التشغيل البيانات بين القرص الصلب ومحرك الأشرطة بوضوح وليس ضمنيًا في أسفل هذه الهرمية. ماذا يُنقَل؟ تكون أحجام الكتل صغيرةً في قمة الهرمية وكبيرة في أسفلها، فحجم الكتلة في الذاكرة المخبئية هو 128 بايتًا ويقدّر حجم الصفحات في الذاكرة ب 4 كيبي بايت، ولكن عندما يقرأ نظام التشغيل ملفًا من القرص الصلب فهو بذلك يقرأ عشرات أو مئات الكتل في كل مرة. متى تُنقَل البيانات؟ تُنقَل البيانات إلى الذاكرة المخبئية عندما تُستخدَم للمرة الأولى في معظم أنواع الذواكر المخبئية، ولكن تستخدم العديد من الذواكر المخبئية ما يسمّى بالجلب المسبق (prefetching) والذي يعني تحميل البيانات قبل أن تُطلَب صراحةً. وقد رأيت مسبقًا نموذجًا من الجلب المسبق هو تحميل الكتلة بأكملها عندما يُطلب جزء منها فقط. أين تذهب البيانات في الذاكرة المخبئية؟ لا تستطيع جلب أي شيء آخر إلى الذاكرة المخبئية عندما تمتلئ إلا إذا أخرجت شيئًا ما منها، لذلك يجب أن تبقي البيانات التي ستُستخدم مرة أخرى قريبًا وتستبدل البيانات التي لن تُستخدم قريبًا. تشكّل أجوبة الأسئلة الأربعة السابقة ما يدعى بسياسة الذاكرة المخبئية (cache policy)، فيجب أن تكون سياسات الذاكرة المخبئية بسيطة في قمة هرمية الذاكرة لأنها يجب أن تكون سريعة وتُطبّق ضمن العتاد، أما في أسفل الهرمية فيوجد وقتٌ أكثر لاتخاذ القرارات حيث تصنع السياسات المصمَّمة جيدًا اختلافًا كبيرًا. معظم سياسات الذاكرة المخبئية قائمةٌ على المبدأ الذي يقول أن التاريخ يعيد نفسه، فإذا ملكت معلومات عن الماضي القريب فيمكنك استخدامها لتنبؤ المستقبل القريب أيضًا، أي إذا اُستخدمت كتلة بيانات مؤخرًا فيمكنك توقع أنها ستُستخدم مرة أخرى قريبًا، وبالتالي يقدّم هذا المبدأ سياسة بديلة تدعى الأقل استخدامًا مؤخرًا (least recently used) واختصارها LRU والتي تحذف كتلة بيانات من الذاكرة المخبئية التي لم تُستخدم مؤخرًا (تعرف على خوارزميات الذاكرة المخبئية). تبديل الصفحات (Paging) يستطيع نظام التشغيل الذي يملك ذاكرة وهمية أن ينقل الصفحات ذهابًا وإيابًا بين الذاكرة والتخزين الدائم وتدعى هذه الآلية بتبديل الصفحات (paging) أو بالتبديل (swapping) أحيانًا، وتجري هذه الآلية كما يلي: افترض أن العملية A تستدعي الدالة malloc لتخصيص قطعةٍ من الذاكرة، فإذا لم يوجد حيّز حر في الكومة بنفس الحجم المطلوب فإن الدالة malloc تستدعي الدالة sbrk من أجل طلب المزيد من الذاكرة من نظام التشغيل. يضيف نظام التشغيل صفحةً إلى جدول الصفحات الخاص بالعملية A عند وجود صفحةٍ حرّة في الذاكرة الحقيقية منشئًا بذلك مجالًا جديدًا من العناوين الوهمية الصالحة. يختار نظام الصفحات صفحةً ضحية (victim page) تابعة للعملية B وذلك عند عدم وجود صفحات حرة، ثم ينسخ محتوى هذه الصفحة الضحية من الذاكرة إلى القرص الصلب ثم يعدّل جدول الصفحات الخاص بالعملية B ليشير إلى أن هذه الصفحة بُدِّلت (swapped out). يمكن إعادة تخصيص صفحة العملية B للعملية A وذلك بعد نقل بيانات العملية B، حيث يجب أن تصفّر الصفحة قبل إعادة تخصيصها لمنع العملية A من قراءة بيانات العملية B. عندها يستطيع استدعاء الدالة sbrk إرجاع نتيجة وهي إعطاء الدالة malloc حيّزًا إضافيًا في الكومة، ثم تخصص الدالة malloc قطعة الذاكرة المطلوبة وتستأنف العملية A عملها. قد يسمح مجدول نظام التشغيل (scheduler) استئناف العملية B عند اكتمال العملية A أو عند مقاطعتها. تلاحظ وحدة إدارة الذاكرة أن الصفحة التي بُدّلت والتي تحاول العملية B الوصول إليها غير صالحة (invalid) ثم تسبب حدوث مقاطعة. يرى نظام التشغيل أن الصفحة بُدّلت عند استلامه للمقاطعة فيقوم بنقل الصفحة من القرص الصلب إلى الذاكرة. ثم تستطيع العملية B استئناف عملها عندما تُبدَّل الصفحة. يحسّن تبديل الصفحات من استخدامية (utilization) الذاكرة الحقيقية كثيرًا عندما يعمل جيدًا وبذلك يسمح لعمليات أكثر أن تُشغَّل في حيّز أصغر والسبب هو: لا تستخدم معظم العمليات كامل ذاكرتها المخصصة ولا تنفَّذ أقسامٌ كثيرة من جزء نص البرنامج أبدًا أو قد تنفَّذ مرة واحدة ولا تنفَّذ مرة أخرى، ولكن يمكن تبديل هذه الصفحات بدون أن تسبب مشاكلًا. إذا سرّب البرنامج ذاكرةً فقد يترك حيّزًا مخصصًا وراءه ولا يصل إليه أبدًا مرةً أخرى ولكن يستطيع نظام التشغيل إيقاف هذا التسرب بفعالية عن طريق تبديل هذه الصفحات. يوجد على معظم أنظمة التشغيل عملياتٍ تشبه العفاريت (daemons) التي تبقى خاملة معظم الوقت وتتنبّه أحيانًا لتستجيب للأحداث ويمكن تبديل هذه العمليات عندما تكون خاملة. قد يفتح المستخدم نوافذ متعددة ولكن يكون عدد قليل منها فاعلًا في نفس الوقت وبالتالي يمكن تبديل هذه العمليات غير الفاعلة. يمكن وجود عدة عملياتٍ مشغِّلةٍ لنفس البرنامج بحيث تتشارك هذه العمليات في جزء نص البرنامج والجزء الساكن لتجنب الحاجة إلى إبقاء نسخ متعددة في الذاكرة الحقيقية. إذا أضفت مجمل الذاكرة المخصصة إلى كل العمليات فهذا سيزيد من حجم الذاكرة الحقيقة بصورة كبيرة ومع ذلك لا يزال بإمكان النظام العمل جيدًا. يجب على العملية التي تحاول الوصول إلى صفحة مبدَّلة أن تعيد البيانات من القرص الصلب والذي يستغرق عدة ميلي ثواني، ويكون هذا التأخير ملحوظًا غالبًا، فإذا تركت نافذةً خاملة لمدة طويلة ثم عدت إليها فستكون بطيئةً في البداية وقد تسمع القرص الصلب يعمل ريثما تُبدّل الصفحات. إن مثل هذه التأخيرات العرضية مقبولة ولكن إذا كان لديك عدة عمليات تستخدم حيّزًا كبيرًا فستتواجه هذه العمليات مع بعضها بعضًا، حيث تطرد العملية A عند تشغيلها الصفحات التي تحتاجها العملية B، ثم تطرد العملية B عند تشغيلها الصفحات التي تحتاجها العملية A، وبالتالي تصبح كلا العمليتين بطيئتين إلى حدٍ كبير ويصبح النظام غير مستجيب، يدعى هذا السيناريو بالتأزّم (thrashing). يمكن أن يتجنّب نظام التشغيل هذا التأزُّم من خلال اكتشاف زيادة في تبديل الصفحات (paging) ثم ينهي أو يوقف عملياتٍ حتى يستجيب النظام مرة أخرى، ولكن يمكن القول أن نظام التشغيل لا يقوم بذلك أو لا يقوم بذلك بصورة جيدة وإنما يترك الأمر أحيانًا للمستخدمين من خلال الحد من استخدامهم للذاكرة الحقيقية أو محاولة استرجاع النظام عند ظهور التأزّم. ترجمة -وبتصرّف- للفصل Caching من كتاب Think OS A Brief Introduction to Operating Systems
  11. توفّر لغة البرمجة C أربع دوال تخصيصٍ ديناميكي للذاكرة هي: malloc: التي تأخذ وسيطًا نوعه عدد صحيح ويمثّل حجمًا بالبايتات وتعيد مؤشرًا إلى قطعة ذاكرةٍ مخصصة حديثًا حجمها يساوي الحجم المعطى على الأقل، وإذا لم تستوفِ الحجم المطلوب فإنها تعيد قيمة مؤشرٍ خاص هو NULL. calloc: وهي شبيهة بالدالة malloc باستثناء أنها تصفّر قطعة الذاكرة المخصصة حديثًا أيضًا أي أنها تضبط كل قيم بايتات القطعة بالقيمة 0. free: التي تأخذ وسيطًا هو مؤشر إلى قطعة ذاكرةٍ مخصصة سابقًا وتلغي تخصيصها (deallocated) أي تجعل حيز الذاكرة المشغول سابقًا متوفرًا لأي تخصيصٍ مستقبلي. realloc: والتي تأخذ وسيطين هما مؤشرٌ لقطعة ذاكرة مخصصة سابقًا وحجمٌ جديد، أي تخصص قطعة ذاكرة بحجمٍ جديد وتنسخ بيانات القطعة القديمة إلى القطعة الجديدة وتحرّر قطعة الذاكرة القديمة ثم تعيد مؤشرًا إلى قطعة الذاكرة الجديدة. واجهة برمجة التطبيقات (API) لإدارة الذاكرة معرضةٌ للخطأ (error-prone) ولكنها غير متسامحة مع الخطأ في نفس الوقت، فإدارة الذاكرة هي أحد أهم التحديات التي تواجه تصميم أنظمة البرمجيات الكبيرة، وهي أحد أهم الأسباب التي تجعل لغات البرمجة الحديثة توفّر خصائصًا عالية المستوى لإدارة الذاكرة مثل خاصية كنس المهملات (garbage collection). أخطاء الذاكرة (Memory errors) تشبه واجهة برمجة التطبيقات لإدارة الذاكرة في لغة البرمجة C إلى حدٍ ما Jasper Beardly وهو شخصية ثانوية في برنامج الرسوم المتحركة التلفزيوني The Simpsons الذي ظهر في بعض الحلقات كمعلّمٍ بديل حازمٍ حيث فرض عقوبةً جسدية لكل المخالفات أسماها paddlin. هناك بعض الأمور التي يحاول البرنامج تنفيذها ليستحق بمحاولته تلك هذه العقوبة (paddling)، أي بمعنىً آخر إنها أمورٌ ممنوعة وهي: محاولة الوصول لقطعة ذاكرة لم تُخصّص بعد سواءً للقراءة أو للكتابة. محاولة الوصول إلى قطعة ذاكرة مخصَّصة محررةٌ مسبقًا. محاولة تحرير قطعة ذاكرة لم تُخصّص بعد. محاولة تحرير قطعة ذاكرة أكثر من مرة. استدعاء الدالة realloc مع قطعة ذاكرة لم تُخصّص بعد أو خُصصت ثم حُرّرت. يمكن أن تجد أن اتباع القواعد السابقة ليس أمرًا صعبًا، ولكن يمكن أن تُخصّص قطعة ذاكرة في جزء من برنامجٍ كبير وتُستخدم في أجزاء أخرى وتُحرّر في جزءٍ آخر من البرنامج، حيث يتطلب التغيير في أحد الأجزاء تغييرًا في الأجزاء الأخرى أيضًا. ويمكن أن يوجد أيضًا العديد من الأسماء البديلة (aliases) أو المراجع (references) التي تشير إلى نفس قطعة الذاكرة المخصصة في أجزاء مختلفة من البرنامج، لذلك يجب ألّا تُحرر تلك القطعة حتى تصبح كل المراجع التي تشير إليها غير مستخدَمة. يتطلب تحقيق ذلك تحليلًا لكل أجزاء البرنامج بعناية، وهو أمرٌ صعب ومخالفٌ لمبادئ هندسة البرمجيات الأساسية. يجب أن تتضمن كل الدوال التي تخصص الذاكرة معلوماتٍ عن كيفية تحرير تلك الذاكرة كجزءٍ من الواجهة الموثقّة (documented interface) في الحالة المثالية، حيث تقوم المكتبات الناضجة (Mature libraries) بذلك جيدًا ولكن لا ترقى ممارسة هندسة البرمجيات الواقعية إلى تلك المثالية. يمكن أن يكون العثور على أخطاء الذاكرة صعبًا لأن أعراض تلك الأخطاء غير متنبأٍ بها مما يزيد الطين بلةً فمثلًا: إذا قرأت قيمةً من قطعة ذاكرةٍ غير مخصصة فقد يكتشف نظام التشغيل الخطأ ثم ينبّه (trigger) عن خطأ وقتٍ تشغيلي والذي يدعى خطأ تجزئة (segmentation fault) ثم يوقف البرنامج، أو قد يقرأ البرنامج تلك القطعة غير المخصصة دون اكتشاف الخطأ، وفي هذه الحالة ستُخزّن القيمة التي حصل عليها البرنامج مهما كانت في موقع الذاكرة الذي وصل إليه، ولا يمكن التنبؤ بهذا الموقع لأنه سيتغير في كل مرة يُشغّل بها البرنامج. أما إذا كتبت قيمةً في قطعة ذاكرة غير مخصصة ولم تحصل على خطأ تجزئة فستكون الأمور أسوأ، وسيمر وقتٌ طويل قبل أن تُقرأ تلك القيمة التي كتبتها في موقع غير صالح لعملية أخرى أو جزء ما مسببةً مشاكل، وبالتالي سيكون إيجاد مصدر المشكلة صعبًا جدًا. ويمكن أن تصبح الأمور أسوأ من ذلك أيضًا، فأحد أكثر مشاكل أسلوب C لإدارة الذاكرة شيوعًا هو أن بنى البيانات المستخدمة لتنفيذ الدالتين malloc و free تُخزّن مع قطع الذاكرة المخصصة غالبًا، لذلك إذا كتبت خارج نهاية قطعة الذاكرة المخصصة ديناميكيًا عن طريق الخطأ فهذا يعني أنك شوّهت (mangle) بنى البيانات تلك. ولن يكتشف النظام المشكلة حتى وقت متأخر وذلك عندما تستدعى الدالة malloc أو الدالة free وبالتالي تفشل هاتان الدالتان بطريقة مبهمة. هناك استنتاجٌ يجب أن تستخلصه من ذلك وهو أن الإدارة الآمنة للذاكرة تتطلب تصميمًا وانضباطًا أيضًا، فإذا كتبت مكتبةً (library) أو نموذجًا (module) يخصّص ذاكرةً فيجب أن توفّر واجهةً (interface) لتحريرها، وينبغي أن تكون إدارة الذاكرة جزءًا من تصميم واجهة برمجة التطبيقات (API) منذ البداية. إذا استخدمت مكتبةً تخصص ذاكرةً فيجب أن تكون منضبطًا في استخدامك لواجهة برمجة التطبيقات (API)، وإذا وفّرت المكتبة دوالًا لتخصيص وإلغاء تخصيص التخزين فيجب أن تستخدم تلك الدوال وألّا تستدعي الدالتين free و malloc لتحرير قطعة ذاكرة وتخصيصها على سبيل المثال، وينبغي أن تتجنب الاحتفاظ بمراجع متعددة تشير للقطعة ذاتها في أجزاء مختلفة من البرنامج. توجد مقايضة (trade-off) بين الإدارة الآمنة للذاكرة والأداء أي لا يمكننا الحصول على الاثنين معًا بصورة تامة فمثلًا مصدر أخطاء الذاكرة الأكثر شيوعًا هو الكتابة خارج حدود مصفوفة، ويُستخدم التحقق من الحدود (bounds checking) لتلافي هذه المشكلة أي يجب التحقق فيما إذا كان الدليل (index) موجودًا خارج حدود المصفوفة في كل وصولٍ إلى تلك المصفوفة. تُجري المكتباتُ عالية المستوى (High-level libraries) والتي توفّر المصفوفات الشبيهة بالبنى (structures) تحققًا من الحدود على المصفوفات، ولكن لا تجري لغة البرمجة C ومعظم المكتبات منخفضة المستوى (low-level libraries) ذلك التحقق. تسريب الذاكرة (Memory leaks) يوجد خطأ ذاكرةٍ يمكن أن يستحق عقوبة ويمكن ألّا يستحقها وهو تخصيص قطعة ذاكرةٍ ثم عدم تحريرها نهائيًا وهذا ما يدعى بتسريب الذاكرة (memory leak). تسريب الذاكرة في بعض البرامج أمرٌ عادي فإذا خصص برنامجك ذاكرةً وأجرى حساباتٍ معينة عليها ثم غادر الذاكرة المخصصة، فمن الممكن أن يكون تحرير تلك الذاكرة المخصصة غير ضروري، حيث يلغي نظام التشغيل تخصيص ذاكرة برنامجٍ ما عند مغادرة هذا البرنامج من الذاكرة المخصصة له. وقد يؤدي تحرير الذاكرة مباشرةً أي قبل مغادرة البرنامج لذاكرته إلى الشعور بأن كل الأمور تحت السيطرة ولكنه مضيعةٌ للوقت على الأغلب. ولكن إذا اشتغل البرنامج لوقت طويل وسرّب ذاكرةً فإن مجمل ذاكرته المستخدمة ستزيد بصورةٍ غير محددة، وبالتالي قد تحدث مجموعة من الأمور هي: قد تَنفَد ذاكرة نظام التشغيل الحقيقية (physical memory) وبالتالي سيفشل استدعاء الدالة malloc التالي في أنظمة التشغيل التي لا تملك ذاكرة وهمية (virtual memory)، ثم تعيد الدالة القيمة NULL. بينما تستطيع أنظمة التشغيل التي تملك ذاكرةً وهمية نقلَ صفحات عملية أخرى من الذاكرة إلى القرص الصلب لتخصص حيّز ذاكرة أكبر للعملية المسرّبة. من الممكن أن يوجد حدٌّ لكمية الذاكرة التي تسطيع عمليةٌ ما تخصيصها، وبالتالي تعيد الدالة malloc القيمة NULL عند تجاوز هذا الحد. وقد تملأ عمليةٌ ما حيز العنونة الوهمية الخاص بها أي لا توجد عناوين أخرى لتخصيصها، وبالتالي تعيد الدالة malloc القيمة NULL أيضًا. إذا أعادت الدالة malloc القيمة NULL ولكنك استمريت في تنفيذ البرنامج وحاولت الوصول إلى قطعة الذاكرة التي اعتقدت أنك خصصتها فستحصل على خطأ تجزئة (segmentation fault)، لذلك من الأفضل أن تتحقق من نتيجة تنفيذ الدالة malloc قبل استخدامها. أحد الخيارات هو أن تضيف شرطًا (condition) بعد كل استدعاء للدالة malloc كما يلي: void *p = malloc(size); if (p == NULL) { perror("malloc failed"); exit(-1); } يُصرّح عن الدالة perror في ملف الترويسات stdio.h ومهمتها طباعة رسالة خطأ ومعلومات إضافية أيضًا عن آخر خطأ قد ظهر. أما الدالة exit فيصرّح عنها في ملف الترويسات stdlib.h والتي تسبب إنهاء العملية، ويدعى وسيط الدالة برمز الحالة (status code) الذي يحدد طريقة إنهاء العملية، حيث يحدد رمز الحالة 0 أنه إنهاءٌ عادي أما رمز الحالة -1 يدل على وجود خطأ في الشرط، ويوجد رموز حالة أخرى تدل على أنواع أخرى من الأخطاء الموجودة في الشرط. الشيفرة المستخدمة للتحقق من الأخطاء (Error-checking code) مزعجةٌ وتجعل البرنامج صعب القراءة ولكن يمكنك التخفيف من ذلك من خلال استدعاء دوال المكتبة المغلّفة (wrapping library function) وشيفرات التحقق من الأخطاء الخاصة بها في دوالك الخاصة. ستجد مغلّف الدالة malloc الذي يتحقق من القيمة المعادة كما يلي: void *check_malloc(int size) { void *p = malloc(size); if (p == NULL) { perror("malloc failed"); exit(-1); } return p; } تسرّب معظمُ البرامج الكبيرة مثل متصفحات الويب الذاكرةَ وذلك لأن إدارة الذاكرة أمر صعبٌ جدًا، ويمكنك استخدام أداتي UNIX وهما ps و top لمعرفة البرامج التي تستخدم أكبر قدرٍ من الذاكرة على نظامك. التطبيق (Implementation) يخصص نظام التشغيل حيّزًا لجزء نص البرنامج (text segment) وللبيانات المخصصة بصورة ساكنة (statically allocated data) وحيزًا آخر لجزء المكدس (stack) وحيزًا أيضًا للكومة (heap) والذي يتضمن البيانات المخصصة ديناميكيًا (dynamically allocated data)، وذلك عند بدء تشغيل عمليةٍ ما. لا تخصص جميعُ البرامج البياناتِ الديناميكية لذلك يمكن أن يكون الحجم الابتدائي للكومة صغيرًا أو صفرًا، حيث تتضمن الكومة قطعةً واحدة حرّة فقط مبدئيًا. تتحقق الدالة malloc عند استدعائها فيما إذا كان هناك قطعةُ ذاكرةٍ حرة وكبيرة كفاية لها، فإذا لم تجد طلبها فإنها تطلب مزيدًا من الذاكرة من نظام التشغيل، حيث تُستخدم الدالة sbrk لهذا الغرض، وتضبط الدالة sbrk نهاية البرنامج (program break) الذي يُعَد مؤشرًا إلى نهاية الكومة. يخصص نظامُ التشغيل صفحاتٍ جديدة من الذاكرة الحقيقية عند استدعاء الدالة sbrk ثم يحدّث جدول صفحات العملية ويضبط نهاية البرنامج، ويستطيع البرنامج استدعاء الدالة sbrk مباشرةً دون استخدام الدالة malloc وإدارة الكومة بنفسه، ولكن استخدام الدالة malloc أسهل كما أنها سريعة التنفيذ وتستخدم الذاكرة بكفاءة في معظم نماذج استخدام الذاكرة. تستخدم معظم أنظمة تشغيل Linux الدالة ptmalloc لتطبيق واجهة برمجة التطبيقات لإدارة الذاكرة (وهذه الواجهة هي الدوال malloc و free و calloc و realloc)، حيث أن الدالة ptmalloc التي كتبها Doug Lea مرتكزةٌ على الدالة dlmalloc. يتوفر بحثٌ قصير يشرح العناصر الأساسية للتطبيق (implementation)، ولكن يجب أن يكون المبرمجون على دراية بالعناصر المهمة التالية: لا يعتمد الوقت التشغيلي للدالة malloc على حجم قطعة الذاكرة ولكنه يعتمد على عدد قطع الذاكرة الحرّة الموجودة. الدالة free سريعة عادةً بغض النظر عن عدد القطع الحرّة. يعتمد وقت التشغيل على حجم القطعة وعلى عدد القطع الحرّة لأن الدالة calloc تجعل جميع قيم بايتات القطعة أصفارًا. الدالة realloc سريعة إذا كان الحجم الجديد أصغر من الحجم الحالي أو إذا كان حيّز الذاكرة متوفرًا من أجل توسيع قطعة الذاكرة الحالية، وإذا لم يتحقق ذلك فيجب على الدالة realloc نسخ البيانات من قطعة الذاكرة القديمة إلى قطعة الذاكرة الجديدة وبالتالي يعتمد وقت التشغيل في هذه الحالة على حجم قطعة الذاكرة القديمة. علامات الحدود (Boundary tags): تضيف الدالة malloc حيّزًا في بداية ونهاية القطعة عند تخصيص هذه القطعة وذلك لتخزين معلومات عن القطعة التي تتضمن حجم القطعة وحالتها (مخصصة أو حرّة) وتدعى هذه المعلومات بعلامات الحدود (Boundary tags)، حيث تستطيع الدالة malloc باستخدام هذه العلامات الانتقال من أية قطعة ذاكرة إلى القطعة السابقة وإلى القطعة التالية من الذاكرة، بالإضافة إلى أن قطع الذاكرة الحرّة تكون موصولة ببعضها بعضًا ضمن لائحة مترابطة مضاعفة (doubly-linked list) حيث تتضمن كل قطعة ذاكرة حرّة مؤشرًا إلى القطعة التي تسبقها ومؤشرًا إلى القطعة التي تليها ضمن لائحة قطع الذاكرة الحرّة. تشكّل علامات الحدود ومؤشرات لائحة القطع الحرة بنى البيانات الداخلية للدالة malloc، وتكون بنى البيانات هذه مبعثرةً مع بيانات البرنامج لذلك يكون من السهل أن يتلفها خطأ برنامجٍ ما. كلفة حيز الذاكرة (Space overhead): تشغَل علامات الحدود ومؤشرات لائحة القطع الحرّة حيزًا من الذاكرة، فالحد الأدنى لحجم قطعة الذاكرة هو 16 بايتًا في معظم أنظمة التشغيل، لذلك ليست الدالة malloc فعّالةً من حيث حيزالذاكرة بالنسبة لقطع الذاكرة الصغيرة جدًا، فإذا تتطلب برنامجك عددًا كبيرًا من بنى البيانات الصغيرة فيكون تخصيصهم ضمن مصفوفات فعالًا أكثر. التجزئة (Fragmentation): إذا خصصت وحررت قطع ذاكرة بأحجام مختلفة فإن الكومة تميل لأن تصبح مجزّأة، وبالتالي يصبح حيز الذاكرة الحر مجزّأ إلى العديد من الأجزاء الصغيرة. تضيّع التجزئة حيز الذاكرة وتبطّئ البرنامج أيضًا من خلال جعل الذواكر المخبئية أقل فعالية. التصنيف والتخبئة (Binning and caching): تُخزّن لائحة القطع الحرة ضمن صناديق (bins) بحيث تكون مرتبة حسب الحجم، حيث تعرف الدالة malloc في أي صندوقٍ تبحث عندما تريد الحصول على قطعةٍ ذات حجم معين. وإذا حررت قطعةً ما ثم خصصت قطعة أخرى بنفس الحجم مباشرةً فستكون الدالة malloc أسرع عادةً. ترجمة -وبتصرّف- للفصل Memory management من كتاب Think OS A Brief Introduction to Operating Systems
  12. تمثيل الأعداد الصحيحة (Representing integers) لا بدّ أنك تعلم أن الحواسيب تمثّل الأعداد بنظام العد ذو الأساس 2 (base 2) والمعروف أيضًا بالنظام الثنائي (binary). التمثيل الثنائي للأعداد الموجبة واضحٌ فعلى سبيل المثال التمثيل الثنائي للعدد 510 (أي للعدد 5 في نظام العد العشري) هو b101 (أي 101 بنظام العد الثنائي (binary)). بينما يستخدم التمثيلُ الأوضح للأعداد السالبة بتًا للإشارة (sign bit) لتحديد فيما إذا كان العدد موجبًا أو سالبًا، ولكن يوجد تمثيلٌ آخر يدعى بالمتمّم الثنائي (two's complement) وهو التمثيل الأكثر شيوعًا لأن العمل معه أسهل ضمن العتاد. لإيجاد المتمم الثنائي للعدد السالب ‎-x تجد التمثيل الثنائي للعدد x أولًا، ثم تقلب (flip) كل البتات أي تقلب الأصفار واحداتٍ والواحدات أصفارًا، ثم تجمع 1 لناتج القلب، فلتمثيل العدد ‎-510 بالنظام الثنائي على سبيل المثال، تبدأ بتمثيل العدد 510 بالنظام الثنائي بكتابته بنسخة 8 بت (8bit version) وهو b00000101، ثم تقلب (flip) كل البتات وتضيف له 1 فينتج b11111011. يتصرف البت الموجود أقصى اليسار في المتمم الثنائي كبت إشارة، فهو 0 في الأعداد الموجبة و1 في الأعداد السالبة. يجب إضافة أصفار للعدد الموجب وواحدات للعدد السالب عند تحويل عدد من النوع 8 بت إلى 16 بتًا، أي يجب نسخ قيمة بت الإشارة إلى البتات الجديدة وتدعى هذه العملية بامتداد الإشارة (sign extension). كل أنواع الأعداد الصحيحة في لغة البرمجة C لها إشارة أي أنها قادرة على تمثيل الأعداد السالبة والموجبة إلّا إذا صرّحت عنهم كأعداد صحيحة بِلا إشارة (unsigned)، والاختلاف الذي يجعل هذا التصريح (declaration) مهمًا هو أن عمليات الأعداد الصحيحة التي لا تملك إشارة (unsigned integers) لا تستخدم امتداد الإشارة (sign extension). العاملات الثنائية (Bitwise operators) يُصاب متعلمو لغة البرمجة C بالارتباك أحيانًا بالنسبة للعاملين الثنائيين (& و |)، حيث يعامِل هذان العامِلان الأعدادَ الصحيحة (integers) كمتجهات من البتات (bit vectors) وتُحسَب العمليات المنطقية (logical operations) بتطبيقها على البتات المتناظرة (corresponding bits).، حيث تَحسب & عملية AND بحيث ينتج 1 إذا كانت قيمة كلا المُعامَلين (operands) هي 1 وينتج 0 بخلاف ذلك. المثال التالي هو تطبيق العامِل & على عددين مكونين من 4 بتات: 1100 & 1010 ---- 1000 وهذا يعني في لغة البرمجة C أن قيمة التعبير 12 & 10 هي 8. ويحسب العامِل | عملية OR بحيث ينتج 1 إذا كانت قيمة أحد المعامَلين 1 وينتج 0 بخلاف ذلك كما في المثال التالي: 1100 | 1010 ---- 1110 أي قيمة التعبير 12 | 10 هي 14. ويحسب العامل ^ عملية XOR بحيث ينتج 1 إذا كانت قيمة أحد المعامَلين 1 وليس كلاهما كما في المثال التالي: 1100 ^ 1010 ---- 0110 أي قيمة التعبير 12 ^ 10 هي 6. يُستخدَم العامل & لتصفير (clear) مجموعة بتات من متجهة بتات، بينما يُستخدم العامل | لضبط (set) البتات، ويُستخدم العامل ^ لقلب (flip) أو تبديل (toggle) البتات كما يلي: تصفير البتات (Clearing bits): قيمة x&0 هي 0 و x&1 هي x مهما كانت قيمة x، لذلك عند تطبيق العملية AND على متجهة مع العدد 3 فسيُختار البتان الموجودان أقصى اليمين فقط وتُضبَط بقية البتات بالقيمة 0 كما يلي: xxxx & 0011 ---- 00xx حيث تدعى القيمة 3 العشرية أو 0011 الثنائية بقناع (mask) لأنها تختار بعض البتات وتقنّع البتات الباقية. ضبط البتات (Setting bits): قيمة x|0 هي x و x|1 هي 1 مهما كانت قيمة x، لذلك عند تطبيق OR على متجهة مع العدد 3 فستُضبط البتات الموجودة أقصى اليمين بينما ستُترَك بقية البتات كما هي: xxxx | 0011 ---- xx11 تبديل أو عكس البتات (Toggling bits): إذا طبّقت XOR مع العدد 3 فستُقلب البتات الموجودة أقصى اليمين وتُترك بقية البتات كما هي. جرّب حساب المتمم الثنائي للعدد 12 باستخدام ^ كتمرينٍ لك، تلميح: ما هو تمثيل المتمم الثنائي للعدد ‎-1؟ توفر لغة البرمجة C عاملات إزاحة أيضًا مثل << و >> التي تزيح البتات يمينًا ويسارًا، حيث تضاعف الإزاحةُ لليسار العددَ، فناتج 5 << 1 هو 10 أما ناتج 5 << 2 هو 20، وتقسم الإزاحة لليمين العدد على 2 (ويكون الناتج مُقرّبًا (rounding down)) حيث ناتج 5 >> 1 هو 2 وناتج 2 >> 1 هو 1. تمثيل الأعداد العشرية (Representing floating-point numbers) تُمثَّل الأعداد العشرية باستخدام النسخة الثنائية (binary version) للصيغة العلمية (scientific notation)، وتُكتَب الأعداد الكبيرة في الصيغة العشرية (decimal notation) كحاصل ضرب معامِلٍ (coefficient) مع 10 مرفوعة لأسّ، فسرعة الضوء المقدّرة بالمتر/ ثانية تساوي تقريبًا ‎2.998 . 108 على سبيل المثال. تستخدم معظم الحواسيب معيار (IEEE standard) لحساب الأعداد العشرية (معيار IEEE للأعداد العشرية)، حيث يقابل النوع float في C المعيار IEEE المكون من 32 بتًا أما النوع double يقابل المعيار 64 بتًا. البت الموجود أقصى اليسار (leftmost bit) هو بت الإشارة (sign bit) ويرمز له s في معيار 32 بت، و تمثل ال8 بتات التالية الأسَّ (exponent) و يرمز له q وآخر 23 بت هي المعامِل (coefficient) ويرمز له c، وبالتالي قيمة العدد العشري هي ‎(-1)sc.2q. هذه القيمة صحيحة تقريبًا ولكن هناك شيء بسيط أيضًا، فالأعداد العشرية موحّدة بحيث يوجد رقمٌ واحد قبل الفاصلة، لذلك يُفضَّل في النظام العشري على سبيل المثال الصيغة ‎2.998 . 108 على الصيغة ‎2998 . 105 أو على أي تعبيرٍ آخر مكافئ. أما بالنظام الثنائي فالأعداد موحّدة بحيث يوجد الرقم 1 قبل الفاصلة الثنائية دومًا، وبما أن الرقم في هذا الموقع هو 1 دومًا لذلك يمكن توفير مساحة وذلك بعدم إدخال هذا الرقم ضمن التمثيل. فتمثيل العدد الصحيح 1310 هو b1101 على سبيل المثال، أما التمثيل العشري هو ‎1.101 . 23 حيث 3 هو الأس (exponent) وجزء المعامِل الذي يمكن أن يُخزَّن هو 101 (متبوعًا ب 20 صفرًا). هذا صحيحٌ تقريبًا ولكن يوجد شيءٌ آخر أيضًا، وهو أن الأس يُخزّن مع معدل انحياز (bias)، وقيمة معدل الانحياز في معيار 32 بت هي 127، وبالتالي يمكن تخزين الأس 3 كَ 130. تُستخدَم عمليات الاتحاد (union operations) والعمليات الثنائية (bitwise operations) لضغط (pack) وفك ضغط (unpack) الأعداد العشرية في C كما في المثال التالي: union { float f; unsigned int u; } p; p.f = -13.0; unsigned int sign = (p.u >> 31) & 1; unsigned int exp = (p.u >> 23) & 0xff; unsigned int coef_mask = (1 << 23) - 1; unsigned int coef = p.u & coef_mask; printf("%d\n", sign); printf("%d\n", exp); printf("0x%x\n", coef); يسمح النوع union بتخزين القيمة العشرية باستخدام المتغير p.f ثم قراءته كعدد صحيح بلا إشارة (unsigned integer) باستخدام المتغير p.u. وللحصول على بت الإشارة تُزاح البتات يمينًا بمقدار 31 ثم يُستخدم قناع 1 بت (أي تطبيق & على ناتج الإزاحة مع العدد 1) وذلك لاختيار البت الموجود أقصى اليمين فقط. وللحصول على الأس تُزاح البتات بمقدار 23 ثم تُختار ال8 بتات الموجودة أقصى اليمين، حيث تملك القيمة الست عشرية 0xff ثمانية واحدات. وللحصول على المعامِل (coefficient) تحتاج لإزالة 23 بتًا الموجودين أقصى اليمين وتجاهل بقية البتات، ويمكن تحقيق ذلك من خلال إنشاء قناع مكون من واحدات في البتات ال23 الموجودة أقصى اليمين وأصفارًا في البتات الموجودة على اليسار، والطريقة الأسهل لإنشاء هذا القناع هي إزاحة 1 يسارًا بمقدار 23 ثم يُطرَح من ناتج الإزاحة 1. خرج البرنامج السابق هو كما يلي: 1 130 0x500000 بت الإشارة للعدد السالب هو 1 كما هو متوقع، والأس هو 130 متضمنًا معدل الانحياز، والمعامل هو 101 متبوعًا ب20 صفرًا ولكنه طُبع بالنظام الست عشري (0x500000). أخطاء الاتحادات وأخطاء الذاكرة (Unions and memory errors) يوجد استخدامان شائعان لاتحادات لغة البرمجة C أحدهما هو الوصول إلى التمثيل الثنائي للبيانات كما ذُكر سابقًا. والاستخدام الآخر هو تخزين البيانات غير المتجانسة (heterogeneous data)، فيمكنك استخدام اتحاد (union) لتمثيل عددٍ والذي من الممكن أن يكون عددًا صحيحًا (integer) أو عشريًا (float) أو مركبًا (complex) أو كسريًا (rational). الاتحادات معرضةٌ للخطأ على كل حال، ويعود الأمر للمبرمج لتتبع نوع البيانات الموجودة في الاتحاد، فإذا كتبت قيمة عشرية ثم فُسِّرت كعدد صحيح فستكون النتيجة لامعنىً لها، وهو أمرٌ مماثل لقراءة موقع من الذاكرة بصورة خاطئة مثل قراءة قيمة موقع من مصفوفة في حين تكون هذه المصفوفة انتهت أي قراءة قيمة من خارج حدود المصفوفة. تخصص الدالة التالية مكانًا للمصفوفة في المكدس وتملؤه بأعداد من 0 إلى 99: void f1() { int i; int array[100]; for (i=0; i<100; i++) { array[i] = i; } } ثم تعرّف الدالة التالية مصفوفةً أصغر وتدخل عناصرًا قبل بداية المصفوفة وبعد نهايتها عمدًا: void f2() { int x = 17; int array[10]; int y = 123; printf("%d\n", array[-2]); printf("%d\n", array[-1]); printf("%d\n", array[10]); printf("%d\n", array[11]); } تكون نتيجة استدعاء الدالة f1 ثم استدعاء الدالة f2 ما يلي: 17 123 98 99 تعتمد التفاصيل على المصرّف (compiler) الذي يرتّب المتغيرات في المكدّس، ويمكنك من خلال النتائج السابقة استنتاج أن المصرّف يضع المتغيرين x و y قرب بعضهما البعض أسفل المصفوفة (أي في عناوين أسفل عنوان المصفوفة)، وعندما تقرأ قيمةً خارج المصفوفة فكأنك تريد الحصول على قيمٍ متروكة من استدعاء دالة سابقة في المكدس. كل المتغيرات في المثال السابق أعدادٌ صحيحة (integers) لذلك سيكون سهلًا معرفة ما يحدث إلى حدٍ ما، ولكن يمكن أن يكون للقيم التي تقرؤها من خارج حدود المصفوفة أيّ نوع. إذا غيّرت في الدالة f1 بحيث تستخدم مصفوفة أعداد عشرية (array of floats) فالنتيجة هي: 17 123 1120141312 1120272384 آخر قيمتين من الخرج السابق هما ما تحصل عليه عندما تفسّر قيمة عشرية كعدد صحيح، وإذا صادفت هذا الخرج خلال عملية تنقيح الأخطاء (debugging) سيكون تفسير ما يحصل صعبًا جدًا. تمثيل السلاسل (Representing strings) سلاسل لغة البرمجة C هي سلاسلٌ منتهية بالقيمة الخالية (null-terminated)، لذلك لا تنسَ البايت الإضافي في نهاية السلسلة وذلك عند تخصيص مكان لهذه السلسلة. تُرمّز الحروف والأرقام في سلاسل C بواسطة ترميز ASCII، فترميز ASCII للأرقام من 0 إلى 9 هو من 48 إلى 57 وليس ترميزها من 0 إلى 9، فالرمز الآسكي 0 يمثل الحرف الخالي (NUL) الذي يحدد نهاية السلسلة، أما الرموز الآسكية من 1 إلى 9 فهي محارف خاصة تُستخدم في بعض بروتوكولات الاتصالات، والرمز الآسكي 7 هو جرس (bell) فينتج عن طباعته إصدارُ صوت في بعض الطرفيات. 65 هو الرمز الآسكي للحرف A وللحرف a هو 97 والتي تُكتب ثنائيًا كما يلي : 65 = b0100 0001 97 = b0110 0001 حيث ستلاحظ أنهما مختلفان فقط ببتٍ واحد إذا تمعّنت النظر قليلًا، ويُستخدم هذا النمط أيضًا لبقية الحروف، فيتصرّف البت السادس (إذا ابتدأت العد من اليمين) كبت حالة الحرف (case bit) فهو 0 للحروف الكبيرة و 1 للحروف الصغيرة. جرّب كتابة دالة تحوّل الحرف الصغير إلى حرفٍ كبير وذلك من خلال قلب (flipping) البت السادس فقط، ويمكنك أيضًا صنع نسخة أسرع من الدالة وذلك من خلال قراءة سلسلة مكونة من 32 بتًا أو 64 بتًا وهذا أفضل من قراءة حرفٍ واحد فقط في كل مرة، حيث ينشَأ هذا التحسين بسهولة أكثر إذا كان طول السلسلة من مضاعفات 4 أو 8 بايتات. إذا قرأت قيمةً خارج حدود المصفوفة ستظهر لك محارف غريبة، ولكن إذا كتبت سلسلةً ثم قرأتها كعدد صحيح (int) أو عشري (float) فسيكون تفسير (interpret) النتيجة صعبًا. وإذا شغلّت البرنامج التالي: char array[] = "allen"; float *p = array; printf("%f\n", *p); ستجد أن التمثيل الآسكي لأول 8 حروف من اسمي، يقول الكاتب، التي فُسّرت كعدد عشري مضبوط بالنوع double (double-precision floating point number) هو 69779713878800585457664. ترجمة -وبتصرّف- للفصل More bits and bytes من كتاب Think OS A Brief Introduction to Operating Systems
  13. تُفقََد البيانات المخزَّنةٌ في الذاكرة الرئيسية لعمليةٍ ما عندما تكمل هذه العملية عملها أو تتعطل لسببٍ ما، ويُطلَق على البيانات المخزّنة في القرص الصلب (hard disk drive واختصاره HDD) والبيانات المخزّنة على أقراص التخزين ذات الحالة الثابتة (solid state drive وتختصر إلى SSD) ببياناتٍ دائمة (persistent)، أي أنها لا تُفقََد بعد اكتمال العملية حتى لو أغلِق الحاسوب. القرص الصلب (Hard disk drive) معقد، حيث تخزَّن البيانات ضمن كتل (blocks) التي تتواجد ضمن قطاعات (sectors)، وتشكّل القطاعاتُ مساراتٍ (tracks) ثم تنظَّم المسارات في دوائر متحدة المركز على أطباق (platters) القرص الصلب. أما أقراص التخزين ذات الحالة الثابتة (Solid state drives) فهي أبسط إلى حدٍ ما لأنّ الكتل مرقّمة تسلسليًا، ولكنها تثير تعقيدًا مختلفًا فيمكن أن تُكتَب كل كتلة عددًا محدودًا من المرات قبل أن تصبح غير موثوقة للاستخدام مرةً أخرى. والمبرمج غير مجبرٍ للتعامل مع تلك التعقيدات ولكن ما يحتاجه حقًا هو تجريدٌ مناسب لعتاد التخزين الدائم (persistent storage hardware)، وتجريد التخزين الدائم الأكثر شيوعًا هو نظام الملفات (file system)، فيمكن القول بتجريد أن: نظام الملفات ما هو إلا ربط (mapping) بين اسم الملف ومحتوياته، فإذا عُدَّت أسماء الملفات مفاتيحًا (keys) ومحتويات الملف قيمًا (values) فإن نظام الملفات مشابه لقاعدة البيانات ذات النوع مفتاح-قيمة (key-value database) الملف هو سلسلة من البايتات تكون أسماء الملفات عادةً من النوع سلسلة (string) وتكون بنظام هرمي (hierarchical) حيث يكون اسم الملف عبارة عن مسار يبدأ من المجلد الأعلى مستوى (top-level directory or folder) مرورًا بمجلدات فرعية حتى الوصول إلى الملف المطلوب. الاختلاف الرئيسي بين الآلية الأساسية (underlying mechanism) والتي هي التخزين الدائم وتجريدها والذي هو نظام الملفات هو أن الملفات تعمل على أساس البايت (byte-based) أما التخزين الدائم يعمل على أساس الكتلة (block-based). يترجم نظام التشغيل عمليات الملف ذات الأساس البايتي في مكتبة C إلى عمليات ذات أساس كتلي على أجهزة التخزين الدائم، ويتراوح حجم الكتلة بين 1 و 8 كيبي بايت (KiB التي تساوي 210 أو 1024 بايت). تفتح الشيفرة التالية ملفًا وتقرأ أول بايت: FILE *fp = fopen("/home/downey/file.txt", "r"); char c = fgetc(fp); fclose(fp); يحدث ما يلي عند تشغيل الشيفرة السابقة: تستخدم الدالة fopen اسم الملف لإيجاد المجلد الأعلى مستوىً (top-level directory) وهو / ثم المجلد الفرعي home ثم المجلد الفرعي المتواجد ضمن home وهو downey. لتجد بعد ذلك ملفًا اسمه file.txt وتفتحه للقراءة، وهذا يعني أن fopen تنشئ بنية بيانات (data structure) تمثل الملف المقروء، حيث تتتبّع بينةُ البيانات الكميةَ المقروءة من الملف، وتدعى هذه الكمية المقروءة بموضع الملف (file position). وتدعى بنية البيانات تلك بكتلة تحكم الملف (File Control Block) في DOS، ولكنني أريد، يقول الكاتب، تجنّب ذلك المصطلح لأن له معنىً آخر في UNIX، وبالتالي لا يوجد اسمٌ جيد لبنية البيانات تلك في UNIX، وبما أنها مدخلة في جدول الملف المفتوح لذلك سأسميها، يقول الكاتب، بمدخلة جدول الملف المفتوح (OpenFileTableEntry). يتحقق نظام التشغيل من وجود الحرف التالي من الملف مسبقًا في الذاكرة عند استدعاء الدالة fgetc، إذا كان موجود فإن الدالة fgetc تقرأ الحرف التالي وتقدّم موضع الملف إلى الحرف الذي بعده ثم تعيد النتيجة. أما إذا لم يوجد الحرف التالي في الذاكرة فيصدر نظام التشغيل طلب إدخال/إخراج (I/O request) للحصول على الكتلة التالية. القرص الصلب بطيء لذلك تُقاطَع العملية التي تنتظر وصول بياناتٍ من القرص الصلب عادةً وتشغَّل عملية أخرى ريثما تصل تلك البيانات. تُخزَّن الكتلة الجديدة من البيانات في الذاكرة عند اكتمال عملية الإدخال/الإخراج (I/O operation) ثم تستأنف العملية عملها، حيث يُقرأ أول حرف ويُخزَّن كمتغير محلي. يكمل نظام التشغيل أية عملية معلّقة (pending operations) أو يلغيها ثم يزيل البيانات المخزنة في الذاكرة ويحرر مدخلة جدول الملف المفتوح (OpenFileTableEntry) عندما تغلق العمليةُ الملف. عملية الكتابة في ملف مشابهة لعملية القراءة من ملف ولكن مع وجود خطوات إضافية، حيث يفتح المثال التالي ملفًا للكتابة ويغير أول حرف من الملف: FILE *fp = fopen("/home/downey/file.txt", "w"); fputc('b', fp); fclose(fp); يحدث ما يلي عند تشغيل الشيفرة السابقة: تستخدم الدالة fopen اسم الملف لإيجاده، فإذا كان الملف غير موجود مسبقًا فتنشئ الدالة fopen ملفًا جديدًا وتضيف مدخلةً في المجلد الأب home/downey/. ينشئ نظام التشغيل مدخلة جدول الملف المفتوح (OpenFileTableEntry) التي تحدد أن الملف مفتوح للكتابة وتهيئ موضع الملف بالقيمة 0. تحاول الدالة fputc كتابة أو إعادة كتابة البايت الأول من الملف، حيث إذا كان الملف موجودًا مسبقًا فيجب على نظام التشغيل تحميل الكتلة الأولى من الملف إلى الذاكرة، وإذا لم يوجد الملف فسيخصّص نظام التشغيل مكانًا للكتلة الجديدة في الذاكرة ويطلب كتلة جديدة من القرص الصلب. يمكن ألّا تُنسَخ الكتلة المعدّلة في الذاكرة إلى القرص الصلب بعد تعديلها مباشرةً، حيث تُخزَّن البيانات المكتوبة في الملف تخزينًا مؤقتًا (buffered) أي أنها تُخزَّن في الذاكرة، ولكنها لا تُكتَب في القرص الصلب إلّا عند وجود كتلة واحدة على الأقل لكتابتها. تُكتَب البيانات المخزنة تخزينًا مؤقتًا في القرص الصلب وتُحرَّر مدخلة جدول الملف المفتوح عند إغلاق الملف. باختصار توفر مكتبة C تجريدًا هو نظام الملفات الذي يربط أسماء الملفات بمجرىً من البايتات، ويُبنى هذا التجريد على أجهزة التخزين الدائم التي تنظَّم ضمن كتل. أداء القرص الصلب (Disk performance) الأقراص الصلبة بطيئة حيث إن الوقت الوسطي لقراءة كتلة من القرص الصلب إلى الذاكرة يتراوح بين 5 و 25 ميلي ثانية على الأقراص الصلبة الحالية HDDs (تعرّف على خصائص أداء القرص الصلب). أما SSDs فهي أسرع من HDDs، حيث تستغرق قراءة كتلة حجمها 4 كيبي بايت 25 ميكرو ثانية وتستغرق كتابتها 250 ميكرو ثانية (تعرّف على متحكم قرص التخزين ذات الحالة الثابتة). وإذا وازنت الأرقام السابقة مع دورة ساعة المعالج (clock cycle of the CPU)، حيث إن المعالج الذي يملك معدل ساعة (clock rate) مقداره 2 جيجا هرتز يكمل دورةَ ساعةٍ كل 0.5 نانو ثانية، والوقت اللازم لجلب بايت من الذاكرة إلى المعالج هو حوالي 100 نانو ثانية، وبالتالي إذا أكمل المعالج تعليمةً واحدةً في كل دورة ساعة (التي مقدارها 0.5 نانو ثانية) فإنه سيكمل 200 تعليمة خلال انتظاره وصول بايت من الذاكرة إليه (100/0.5=200). وسيكمل المعالج 2000 تعليمة بدورة ساعة 1 ميكرو ثانية، وبالتالي يكمل المعالج 50,000 تعليمة خلال وقت انتظار جلب بايت من SSD والذي يقدر ب 25 ميكرو ثانية. ويستطيع المعالج إكمال 2,000,000 تعليمة خلال ميلي ثانية وبذلك يستطيع إكمال 40 مليون تعليمة خلال وقت انتظار جلب بايت من القرص الصلب HDD والمقدّر ب 20 ميلي ثانية. إذا لم يكن لدى المعالج أي عملٍ للقيام به خلال عملية انتظار جلب بيانات من القرص الصلب فإنه يبقى خاملًا بلا عمل، لذلك ينتقل المعالج لتنفيذ عملية أخرى ريثما تصل البيانات من القرص الصلب. أحد أهم التحديات التي تواجه عملية تصميم نظام التشغيل هو الفجوة في الأداء بين الذاكرة الرئيسية والتخزين الدائم، لذلك توفر أنظمة التشغيل والعتاد مجموعة خاصيات الهدف منها سد هذه الفجوة وهذه الخاصيات هي: تحويلات الكتلة (Block transfers): يتراوح الوقت اللازم لتحميل بايت واحد من القرص الصلب بين 5 و 25 ميلي ثانية، بالمقابل فإن الوقت الإضافي لتحميل كتلة حجمها 8 كيبي بايت (KiB) هو وقت مهمل، لذلك تحاول أنظمة التشغيل قراءة كتل كبيرة الحجم في كل عملية وصول إلى القرص الصلب. الجلب المسبق (Prefetching): يستطيع نظام التشغيل في بعض الأحيان توقّعَ أن العملية ستقرأ كتلةً ما ثم يبدأ بتحميل تلك الكتلة من القرص الصلب قبل أن تُطلَب. فإذا فتحتَ ملفًا وقرأت أول كتلة على سبيل المثال فهذا يؤدي إلى وجود احتمال كبير أنك ستقرأ الكتلة الثانية، لذلك سيحمّل نظام التشغيل كتل إضافية قبل أن تُطلَب. التخزين المؤقت (Buffering): يخزّن نظام التشغيل بيانات الكتابة في ملفٍ ما في الذاكرة ولا يكتبها في القرص الصلب مباشرةً، لذلك إذا عدّلت الكتلة مراتٍ متعددة عند وجودها في الذاكرة فلن يكتبها نظام التشغيل في القرص الصلب إلّا مرةً واحدة. التخبئة (Caching): إذا استخدمت عمليةٌ كتلةً ما مؤخرًا فإنها ستستخدمها مرة أخرى قريبًا، وإذا احتفظ نظام التشغيل بنسخة من هذه الكتلة في الذاكرة فإنه سيتعامل مع الطلبات المستقبلية لهذه الكتلة بسرعة الذاكرة. تُطبَّق بعض الخاصيات السابقة عن طريق العتاد أيضًا، حيث توفر بعض الأقراص الصلبة ذاكرةً مخبئيةً على سبيل المثال وتُخزَّن فيها الكتل المستخدمة مؤخرًا، وتقرأ العديد من الأقراص الصلبة عدة كتل في نفس الوقت على الرغم من وجود كتلة واحدة مطلوبة فقط. تحسّن الآليات السابقة من أداء البرامج ولكنها لا تغير شيئًا من سلوكها، ولا يتوجب على المبرمجين معرفة الكثير عن تلك الآليات باستثناء حالتين هما: إذا أصبح أداء البرنامج سيئًا بشكل غير متوقع فيجب عليك معرفة هذه الآليات لتشخيص المشكلة. يصبح تنقيح أخطاء (debug) البرنامج أصعب عندما تُخزَّن البيانات تخزينًا مؤقتًا (buffered)، فإذا طبع البرنامج قيمة ثم تعطّل البرنامج على سبيل المثال، فإن تلك القيمة لن تظهر لأنها يمكن أن تكون في مخزَن مؤقت (buffer). وإذا كتب برنامجٌ ما بياناتٍ في القرص الصلب ثم أُغلق الحاسوب فجأةً قبل كتابة البيانات في القرص الصلب فيمكن أن تُفقَد تلك البيانات إذا كانت في الذاكرة المخبئية (cache) ولم تنقَل بعد إلى القرص الصلب. بيانات القرص الصلب الوصفية (Disk metadata) تكون الكتل التي تشكّل الملف منظمةً في القرص الصلب بحيث قد تكون هذه الكتل مجاورةً لبعضها البعض وبذلك يكون أداء نظام الملفات أفضل، ولكن قد لا تحوي معظم أنظمة التشغيل تخصيصًا متجاورًا (contiguous allocation) للكتل، حيث يكون لأنظمة التشغيل كامل الحرية بوضع كتلةٍ ما في أي مكان تريده على القرص الصلب وتستخدم بنى بيانات مختلفة لتتبع تلك الكتل. تُدعى بنية البيانات في العديد من أنظمة ملفات UNIX ب inode والتي ترمز إلى عقدة دليل (index node). تدعى معلومات الملفات مثل موقع كتل هذه الملفات بالبيانات الوصفية (metadata)، فمحتوى الملف هو بيانات (data) ومعلومات الملف بيانات أيضًا ولكنها بيانات توصف بيانات أخرى لذلك تدعى وصفية (meta). بما أن inodes تتوضع على القرص الصلب مع بقية البيانات لذلك فهي مصممة لتتوافق مع كتل القرص الصلب بدقة. تتضمن inode لنظام UNIX معلومات عن الملف وهذه المعلومات هي معرف (ID) المستخدم مالك الملف، ورايات الأذونات (permission flags) والتي تحدد مَن المسموح له قراءة أو كتابة أو تنفيذ الملف، والعلامات الزمنية (timestamps) التي تحدد آخر تعديل وآخر دخول إلى الملف، وتتضمن أيضًا أرقام أول 12 كتلة من الكتل المشكّلة للملف. فإذا كان حجم الكتلة الواحدة 8 كيبي بايت (KiB) فإن حجم أول 12 كتلة من الملف هو 96 كيبي بايت وهذا الرقم كبير كفاية لأغلبية حجوم الملفات ولكنه ليس بكافٍ لجميع الملفات بالتأكيد، لذلك تحتوي inode مؤشرًا إلى كتلة غير موجهة (indirection block) التي تتضمن مؤشّرات إلى كتلٍ أخرى فقط. يعتمد عدد المؤشرات في كتلةٍ غير موجهةٍ على أحجام الكتل وعددها وهو 1024 كتلةً عادةً، حيث تسطيع كتلةٌ غير موجهة عنونة 8 ميبي بايت (MiB التي تساوي 220 بايتًا) باستخدام 1024 كتلة وبحجم 8 كيبي بايت لكل كتلة، وهذا رقمٌ كافٍ لجميع الملفات باستثناء الملفات الكبيرة أي أنه لا يزال غير كافٍ لجميع الملفات، لذلك تتضمن inode أيضًا مؤشرًا إلى كتلة غير موجهة مضاعفة (double indirection block) التي تتضمن بدورها مؤشرات إلى كتل غير موجهة، وبالتالي يمكننا عنونة 8 جيبي بايت (GiB التي تساوي 230 بايتًا) باستخدام 1024 كتلة غير موجهة. وإذا لم يكن ذلك كافيًا أيضًا فتوجد كتلة غير موجهة ثلاثية (triple indirection block) أخيرًا، والتي تتضمن مؤشرات إلى كتل غير موجهة مضاعفة وبذلك تكون كافية لملف حجمه 8 تيبي بايت (TiB التي تساوي 240 بايتًا) كحدٍ أعلى. بدا ذلك كافيًا لمدة طويلة عندما صُمِّمت inodes لنظام UNIX، ولكنها أضحت قديمةً الآن. تستخدم بعض أنظمة الملفات مثل FAT جدول تخصيص الملف (File Allocation Table) كبديل عن الكتل غير الموجهة، حيث يتضمن جدول التخصيص مدخلةً لكل كتلة وتدعى الكتلة هنا بعنقود (cluster)، ويتضمن المجلد الجذر (root directory) مؤشرًا لأول عنقود في كل ملف، وتشير مدخلة جدول FAT والتي تمثل عنقودًا إلى العنقود التالي في الملف بشكل مشابه للائحة المترابطة (linked list). تخصيص الكتلة (Block allocation) ينبغي على أنظمة الملفات تتبّع الكتل التابعة لكل ملف وتتبّع الكتل المتاحة للاستخدام أيضًا، حيث يجد نظام الملفات كتلةً متوفرة لملفٍ ما ثم يخصصها له عندما يُنشَأ هذا الملف، ويجعل نظام الملفات كتل ملفٍ ما متاحةً لإعادة التخصيص عندما يُحذَف ذلك الملف. حيث أهداف تخصيص الكتلة هي ما يلي: السرعة (Speed): يجب أن يكون تخصيص الكتل وتحريرها سريعًا. الحد الأدنى من استهلاك المساحة (Minimal space overhead): يجب أن تكون بنى البيانات التي يستخدمها المخصّص (allocator) صغيرة الحجم بحيث تترك أكبر قدر ممكن من المساحة للبيانات. الحد الأدنى من التجزئة (Minimal fragmentation): إذا وُجدت كتل غير مستخدمة نهائيًا أو مستخدمة جزئيًا فإن هذه المساحة غير المستخدمة تدعى تجزئة (fragmentation). الحد الأعلى من التجاور (Maximum contiguity): يجب أن تكون البيانات التي تُستخدم في الوقت ذاته مجاورة لبعضها البعض فيزيائيًا إذا كان ممكنًا وذلك لتحسين الأداء. تصميم نظام ملفات يحقق هذه الأهداف أمرٌ صعب خاصةً أن أداء نظام الملفات معتمدٌ على خواص الحِمل (workload characteristics) مثل حجم الملفات وأنماط الوصول (access patterns) وغير ذلك، فنظام الملفات المتماشي جيدًا مع نوع حِمل قد لا يكون أداؤه جيدًا مع نوع حِملٍ آخر، ولهذا السبب تدعم معظم أنظمة التشغيل أنواعًا متعددة من أنظمة الملفات. يُعَد تصميم نظام الملفات مجالًا نشطًا للبحث والتطوير، حيث انتقلت أنظمة تشغيل Linux خلال العشر سنوات الماضية من ext2 والذي كان نظام ملفات UNIX التقليدي إلى ext3 والذي هو نظام ملفات مزودٌ بسجل (journaling) ومُعَد لتحسين السرعة (speed) والتجاور (contiguity)، ثم انتقلت بعد ذلك إلى ext4 الذي يستطيع التعامل مع ملفات وأنظمة ملفات أكبر، وقد يكون هناك انتقال (migration) آخر إلى نظام ملفات B-tree واختصاره Btrfs خلال السنوات القليلة القادمة. هل كل شيء هو ملف؟ إن تجريد الملف حقيقةً هو تجريدٌ لمجرىً من البايتات (stream of bytes) والذي اتضح أنه مفيدٌ لكثيرٍ من الأشياء وليس لأنظمة الملفات فقط، أنبوب (pipe) نظام UNIX هو مثالٌ على ذلك والذي هو نموذجٌ بسيط للاتصالات بين العمليات (inter-process communication)، فتكون العمليات مُعدّةً بحيث يكون خرج عمليةٍ ما دخلًا لعمليةٍ أخرى. يتصرف الأنبوب على أساس أن أول عملية هي ملفٌ مفتوحٌ للكتابة بالتالي يمكنه أن يستخدم دوال مكتبة C مثل fputs و fprintf وأن العملية الأخرى ملفٌ مفتوحٌ للقراءة أي يمكنه استخدام الدوال fgets و fscanf. وتستخدم شبكة الاتصالات تجريد مجرى البايتات أيضًا، فمِقبس (socket) نظام UNIX هو بنية بيانات تمثل قناة اتصال بين العمليات الموجودة على حواسيب مختلفة عادةً، حيث تستطيع العمليات قراءة بيانات وكتابتها في مِقبس باستخدام دوال تتعامل مع الملفات. تجعل إعادة استخدام تجريد الملف الأمور أسهل بالنسبة للمبرمجين، حيث إنهم غير ملزمين إلا بتعلم واجهة برمجة تطبيقات واحدة (application program interface واختصارها API). كما أنها تجعل البرامج متعددة الاستعمال بما أن البرنامج المجهّز ليعمل مع الملفات قادرٌ على العمل مع بيانات قادمة من الأنابيب (pipes) ومصادر أخرى أيضًا. ترجمة -وبتصرّف- للفصل Files and file systems من كتاب Think OS A Brief Introduction to Operating Systems
  14. نظرية بت المعلومات (A bit of information theory) البت هو رقم ثنائي ووحدة معلومات أيضًا، فبت واحد يعني احتمالًا من اثنين إما 0 أو 1، أما وجود بتين يعني وجود 4 تشكيلات محتملة: 00 و 01 و 10 و 11. وإذا كان لديك b بت فهذا يعني وجود 2b قيمة محتملة، حيث يتكون البايت مثلًا من 8 بتات أي 256 (28=256) قيمة محتملة. في الاتجاه المقابل، أي إذا علمت عدد القيم المحتملة ولكنك لا تعلم عدد البتات المناسبة، افترض أنك تريد تخزين حرف واحد من حروف الأبجدية التي تتكون من 26 حرفًا فكم بتًا تحتاج؟ لديك 16 قيمة محتملة ب 4 بتات (24 = 16) وبالتالي هذا غير كافٍ لتخزين 26 حرفًا. وتحصل على 32 قيمة محتملة ب 5 بتات وهو كافٍ لتخزين كل الحروف مع قيم فائضة أيضًا. لذلك إذا أردت الحصول على قيمة واحدة من أصل N قيمة محتملة يجب عليك اختيار أصغر قيمة ل b التي تحقق (2b ≥ N)، وبأخد اللوغاريتم الثنائي للطرفين ينتج (b ≥ log2(N)). تعطيك نتيجة رمي قطعة نقود بتًا واحدًا من المعلومات (لأن قطعة النقود تملك وجهين وبالتالي احتمالين فقط). أما نتيجة رمي حجر نرد فتعطيك log2(6)‎ بتًا من المعلومات (لأن حجر النرد له ستة أوجه). حيث إذا كان احتمال النتيجة هو 1 من N فذلك يعني أن النتيجة تحوي log2(N)‎ بتًا من المعلومات عمومًا، وإذا كان احتمال النتيجة هو p مثلًا فبذلك تحوي النتيجة log2(p)‎ من المعلومات. تدعى هذه الكمية من المعلومات بالمعلومات الذاتية (self-information) للنتيجة، وهي تقيس مقدار التفاجؤ الذي تسببه تلك النتيجة، ويدعى هذا المقدار أيضًا surprisal. فإذا كان حصانك مشاركًا في سباق خيل على سبيل المثال ويملك فرصةً واحدة للفوز من أصل 16 فرصة ثم يفوز بالفعل، وبالتالي تعطيك تلك النتيجة 4 بتات من المعلومات (log2(16)=4)، أما إذا فاز حصان ما بنسبة 75% من المرات، فيتضمن ذلك الفوز الأخير 0.42 بتًا من المعلومات فقط. حيث تحمل النتائج غير المتوقعة معلومات أكثر، أما عند تأكّدك من حدوث شيء ما فلن يعطيك حدوثه بالفعل إلّا كمية قليلة من المعلومات. ينبغي عليك أن تكون على معرفة بالتحويل بين عدد البتات الذي نرمز له ب b وعدد القيم N التي تشفّرها (encode) تلك البتات بحيث N=2b. الذاكرة والتخزين (Memory and storage) تُحفَظ معظم بيانات عملية ما في الذاكرة الرئيسية (main memory) ريثما تنفّذ تلك العملية، حيث أن الذاكرة الرئيسية هي نوع من الذواكر العشوائية (random access memory وتختصر إلى RAM). الذاكرة الرئيسية هي ذاكرة متطايرة (volatile) على معظم الحواسيب، والتي تعني أن محتوياتها تُفقد عند إغلاق الحاسوب. يملك الحاسوب المكتبي النموذجي ذاكرة تتراوح بين 4 و 8 جيبي بايت وربما أكثر بكثير من ذلك، حيث GiB تشير إلى جيبي بايت وهي 230 بايتًا. إذا قرأت وكتبت عملية ما ملفات فإن هذه الملفات تُخزّن على القرص الصلب (hard disk drive ويختصر إلى HDD) أو على (solid state drive ويختصر إلى SSD). وسائط التخزين هذه غير متطايرة (non-volatile)، لذلك تُستخدم للتخزين طويل الأمد. يحتوي الحاسوب المكتبي حاليًا HDD بسعة تتراوح بين 500 جيجا بايت و 2 تيرا بايت، حيث GB هي جيجا بايت وتقابل 109 بايتًا بينما تشير TB إلى تيرا بايت وتساوي 1012 بايتًا. لابد أنك لاحظت استخدام وحدة النظام الثنائي الجيبي بايت، أي التي تعد الكيلوبايت مثلًا مساويًا 1024 بايتًا أو 210 حيث أساسها العدد 2، لقياس حجم الذاكرة الرئيسية واستخدام وحدتي النظام العشري الجيجا بايت و التيرا بايت، أي التي تعد الكيلو بايت مثلًا مساويًا 1000 بايتًا حيث أساسها العدد 10، لقياس حجم HDD. يقاس حجم الذاكرة بالوحدات الثنائية وحجم القرص الصلب بالوحدات العشرية وذلك لأسباب تاريخية وتقنية، ولكن تُستخدم الجيجا بايت واختصارها GB استخدامًا مبهمًا لذلك يجب أن تنتبه لذلك. يُستخدم مصطلح ذاكرة (memory) أحيانًا للدلالة على HDDs و SSDs و RAM، ولكن خصائص هذه الأجهزة الثلاث مختلفة جدًا. يشار إلى HDDs و SSDs بتخزين دائم (storage). أحياز العنونة (Address spaces) يُحدد كل بايت في الذاكرة الرئيسية بعدد صحيح يدعى عنوانًا حقيقيًا (physical address)، حيث تدعى مجموعة العناوين الحقيقية الصالحة بحيز العنونة الحقيقية (physical address space) وتتراوح تلك العناوين بين 0 و N - 1 حيث N هو حجم الذاكرة الرئيسية. أعلى قيمة عنوان صالحة في نظام ب 1 جيبي بايت ذاكرة حقيقية هو 230- 1 ، أي 1,073,741,823 في نظام العد العشري و 0x3f fffff في نظام العد الست عشري حيث تحدد السابقة 0x أنه عدد ست عشري. توفر معظم أنظمة التشغيل ذاكرةً وهميةً (virtual memory) أيضًا، والتي تعني أن البرامج لا تتعامل أبدًا مع عناوين حقيقية (physical addresses) وليست ملزمة بمعرفة كمية الذاكرة الحقيقية المتوفرة. وبدلًا من ذلك تتعامل البرامج مع الذاكرة الوهمية والتي تتراوح قيمها بين 0 و M - 1، حيث M هو عدد العناوين الوهمية الصالحة. ويحدد نظام التشغيل والعتاد الذي يعمل عليه حجم حيّز العنونة الوهمية. لا بد أنك سمعت الناس يتحدثون عن نظامي التشغيل 32 بت و 64 بت، حيث يحدد هذان المصطلحان حجم المسجلات والذي هو حجم العنوان الوهمي أيضًا. فيكون العنوان الوهمي 32 بتًا على نظام 32 بت والذي يعني أن حيز العنونة الوهمية يتراوح بين 0 و 0xffff ffff، أي حجم العنونة الوهمية هو 232 بايتًا أو 4 جيبي بايت. أما على نظام 64 بت فحجم حيز العنونة الوهمية هو 264 بايتًا أو 24.10246 بايتًا، أي 16 اكسبي بايت (exbibytes) والذي هو أكبر من حجم الذاكرة الحقيقية الحالية بمليار مرة تقريبًا. يولّد البرنامج عناوينًا وهمية بكل عملية قراءة أو كتابة في الذاكرة، ويترجمها العتاد إلى عناوين حقيقية بمساعدة نظام التشغيل قبل الوصول إلى الذاكرة الرئيسية، وتقوم هذه الترجمة على أساس per-process أي تعامَل كل عملية باستقلالية عن العمليات الأخرى، حيث حتى لو ولّدت عمليتان نفس العنوان الوهمي فسترتبطان بمواقع مختلفة من الذاكرة الحقيقية. إذًا الذاكرة الوهمية هي إحدى طرق نظام التشغيل لعزل العمليات عن بعضها البعض، حيث لا تستطيع عملية ما الوصول إلى بيانات عملية أخرى، فلا وجود لعنوان وهمي يستطيع توليد ارتباطات (maps) بذاكرة حقيقية مخصصة لعملية أخرى. أجزاء الذاكرة (Memory segments) تُنظّم بيانات العملية المُشغّلة ضمن خمسة أجزاء: جزء الشيفرة (code segment): ويتضمن نص البرنامج (program text) وهو تعليمات لغة الآلة التي تبني البرنامج. الجزء الساكن (static segment): يتضمن القيم غير القابلة للتغيير، قيم السلاسل النصية (string literals) مثلًا، حيث إذا احتوى برنامجك على سلسلة مثلًا "Hello, World" فستُخزَّن هذه الحروف في الجزء الساكن من الذاكرة. الجزء العام (global segment): يتضمن المتغيرات العامة (global variables) والمتغيرات المحلية (local variables) التي يُصرَّح عنها كساكنة static. جزء الكومة (heap segment): يتضمن قطع الذاكرة المخصصة في زمن التشغيل وذلك باستدعاء دالة مكتبة في لغة C هي malloc في أغلب الأحيان. جزء المكدس (stack segment): يتضمن استدعاء المكدس وهو سلسلة من إطارات المكدس (stack frames). يُخصَّص إطار المكدس ليتضمن المعاملات والمتغيرات المحلية الخاصة بالدالة في كل مرة تّستدعى فيها الدالة، ويزال إطار المكدس ذاك التابع لتلك الدالة من المكدس عندما تنتهي الدالة من عملها. يتشارك المصرِّف مع نظام التشغيل في تحديد ترتيب الأجزاء السابقة، حيث تختلف تفاصيل ذلك الترتيب من نظام تشغيل لآخر ولكن الترتيب الشائع هو: يوجد جزء نص البرنامج أو جزء الشيفرة قرب قاع (bottom) الذاكرة أي عند العناوين القريبة من القيمة 0. يتواجد الجزء الساكن غالبًا فوق جزء الشيفرة عند عناوين أعلى من عناوين جزء الشيفرة. ويتواجد الجزء العام فوق الجزء الساكن غالبًا. ويتواجد جزء الكومة فوق الجزء العام وإذا احتاج للتوسع أكثر فسيتوسع إلى عناوين أكبر. ويكون جزء المكدس قرب قمة الذاكرة (top of memory) أي قرب العناوين الأعلى في حيز العنونة الوهمية، وإذا احتاج المكدس للتوسع فسيتوسع للأسفل باتجاه عناوين أصغر. لتعرف ترتيب هذه الأجزاء على نظامك، نفّد البرنامج التالي: #include <stdio.h> #include <stdlib.h> int global; int main() { int local = 5; void *p = malloc(128); char *s = "Hello, World"; printf("Address of main is %p\n", main); printf("Address of global is %p\n", &global); printf("Address of local is %p\n", &local); printf("p points to %p\n", p); printf("s points to %p\n", s); } main هو اسم دالة ولكن عند استخدامها كمتغير فهي تشير إلى عنوان أول تعليمة لغة آلة في الدالة main والتي من المتوقع أن تكون في جزء الشيفرة (text segment). أما global فهو متغير عام (global variable) وبالتالي يُتوقع تواجده في الجزء العام (global segment)، و local هو متغير محلي أي يتواجد في جزء المكدس. ترمز s إلى سلسلة نصية (string literal) وهي السلسلة التي تكون جزءًا من البرنامج، على عكس السلسلة التي تُقرَأ من ملف أو التي يدخلها المستخدم. ومن المتوقع أن يكون موقع هذه السلسلة هو الجزء الساكن (static segment)، في حين يكون المؤشر s الذي يشير إلى تلك السلسلة متغيرًا محليًا. أما p فيتضمن العنوان الذي يعيده تنفيذ الدالة malloc حيث أنها تخصص حيزًا في الكومة، وترمز malloc إلى memory allocate أي تخصيص حيز في الذاكرة. يخبر التنسيق التسلسلي format sequence %pالدالة printf بأن تنسق كل عنوان كمؤشر (pointer) فتكون النتيجة عبارة عن عدد ست عشري (hexadecimal). عند تنفيذ البرنامج السابق سيكون الخرج كما يلي: Address of main is 0x 40057d Address of global is 0x 60104c Address of local is 0x7ffe6085443c p points to 0x 16c3010 s points to 0x 4006a4 عنوان main هو الأقل كما هو متوقع، ثم يتبعه موقع سلسلة نصية (string literal)، ثم موقع global، وبعده العنوان الذي يشير إليه المؤشر p، ثم عنوان local أخيرًا وهو الأكبر. يتكون عنوان local وهو العنوان الأكبر من 12 رقمًا ست عشريًا حيث يقابل كل رقم ست عشري 4 بتات وبالتالي يتكون هذا العنوان من 48 بتًا، ويدل ذلك أن القسم المُستخدم من حيز العنونة الوهمية هو 248 بايتًا لأن حجم حيز العنونة الوهمية يكون مساويًا 2x بايتًا (تمثل x حجم العنوان الوهمي)، وأكبر عنوان مستخدم في هذا المثال هو 48 بتًا. جرب تنفيذ البرنامج السابق على حاسوبك وشاهد النتائج، أضف استدعاءً آخر للدالة malloc وتحقق فيما إذا أصبح عنوان كومة نظامك أكبر، وأضف أيضًا دالة تطبع عنوان متغير محلي وتحقق إذا أصبح عنوان المكدس يتوسع للأسفل. يوضح المخطط التالي عملية ترجمة العناوين: المتغيرات المحلية الساكنة (Static local variables) تدعى المتغيرات المحلية في المكدس بمتغيرات تلقائية (automatic) لأنها تُخصص تلقائيًا عند استدعاء الدالة وتحرر مواقعها تلقائيًا أيضًا عندما ينتهي تنفيذ الدالة. أما في لغة البرمجة C فيوجد نوع آخر من المتغيرات المحلية، تدعى ساكنة (static)، تخصص مواقعها في الجزء العام (global segment) وتُهيّأ عند بدء تنفيذ البرنامج وتحافظ على قيمتها من استدعاءٍ لآخر للدالة. تتتبّع الدالة التالية عدد مرات استدعائها على سبيل المثال: int times_called() { static int counter = 0; counter++; return counter; } تحدد الكلمة المفتاحية static أن المتغير counter هو متغير محلي ساكن، حيث أن تهيئة المتغير المحلي الساكن تحدث مرة واحدة فقط عند بدء تنفيذ البرنامج، ويخصص موقع المتغير counter في الجزء العام مع المتغيرات العامة وليس في جزء المكدس. ترجمة العناوين (Address translation) كيف يُترجم العنوان الوهمي (VA) إلى عنوان حقيقي (PA)؟ العملية الأساسية لتحقيق ذلك بسيطة، ولكن يمكن أن يكون التنفيذ البسيط أيضًا بطيئًا ويأخذ مساحة أكثر، لذلك يكون التنفيذ الحقيقي أعقد. توفر معظم العمليات وحدة إدارة الذاكرة (memory management unit وتختصر إلى MMU) التي تتموضع بين المعالج CPU والذاكرة الرئيسية وتطبّق ترجمة سريعة بين العناوين الوهمية والعناوين الحقيقية كما يلي: يولّد المعالج CPU عنوانًا وهميًا (VA) عندما يقرأ أو يكتب البرنامج متغيرًا . تقسّم MMU العنوان الوهمي إلى قسمين هما رقم الصفحة والإزاحة (page number و offset). الصفحة (page) تعني قطعة ذاكرة ويعتمد حجم هذه الصفحة على نظام التشغيل والعتاد ولكن حجوم الصفحات الشائعة هي بين 1 و 4 كيبي بايت. تبحث MMU عن رقم الصفحة في مخزَن الترجمة الجانبي المؤقت (translation lookaside buffer)، يُختصر إلى TLB، لتحصل MMU على رقم الصفحة الحقيقي المقابل ثم تدمج رقم الصفحة الحقيقي مع الإزاحة لينتج العنوان الحقيقي PA. يمرَّر العنوان الحقيقي إلى الذاكرة الرئيسية التي تقرأ أو تكتب الموقع المطلوب. يتضمن TLB نسخ بيانات مخبئية من جدول الصفحات (page table) والتي تُخزن في ذاكرة النواة، ويحتوي جدول الصفحات ربطًا بين أرقام الصفحات الوهمية و أرقام الصفحات الحقيقية. وبما أن لكل عملية جدول صفحاتها الخاص لذلك يجب على TLB أن يتأكد من أنه يستخدم مدخلات جدول صفحات العملية التي تنفّذ فقط. لفهم كيف تتم عملية الترجمة افترض أن العنوان الوهمي VA هو 32 بتًا والذاكرة الحقيقية 1 جيبي بايت مقسّمة إلى صفحات وكل صفحة ب 1 كيبي بايت: بما أن 1 جيبي بايت هي 230 بايتًا و 1 كيبي بايت هي 210 بايت لذلك يوجد 220 صفحةً حقيقية تدعى أحيانًا إطارات (frames). حجم حيز العنونة الوهمية هو 232 بايتًا وحجم الصفحة هو 210 بايتًا لذلك يوجد 222 صفحة وهمية. يحدد حجم الصفحة حجمَ الإزاحة وفي هذا المثال حجم الصفحة هو 210 بايتًا لذلك يتطلب 10 بتات لتحديد بايت من الصفحة. وإذا كان العنوان الوهمي 32 بتًا والإزاحة 10 بت فتشكّل ال22 بتًا المتبقية رقم الصفحة الوهمية. وبما أنه يوجد 220 صفحة حقيقية فكل رقم صفحة حقيقية هو 20 بتًا وأضف عليها إزاحة ب 10 بت فتكون العناوين الحقيقية الناتجة ب 30 بتًا. يبدو كل شيء معقولًا حتى الآن، ولكن التنفيذ الأبسط لجدول الصفحات هو مصفوفة بمدخلة واحدة لكل صفحة وهمية، وتتضمن كل مدخلة رقم الصفحة الفعلية وهي 20 بتًا في هذا المثال، بالإضافة إلى بعض المعلومات الإضافية لكل إطار أو صفحة حقيقية، وبالتالي من 3 إلى 4 بايتات لكل مدخلة من الجدول ولكن مع 222 صفحة وهمية يكون حجم جدول الصفحات 224 بايتًا أو 16 ميبي بايت. تحتاج كل عمليةٍ إلى جدول صفحات خاص بها لذلك إذا تضمن النظام 256 عملية مشغّلة فهو يحتاج 232 بايتًا أو 4 جيبي بايت لتخزين جداول الصفحات فقط! وذلك مع عناوين وهمية ب 32 بتًا وبالتالي مع عناوين وهمية ب 48 و 64 بتًا سيكون حجم تخزين جداول الصفحات كبيرًا جدًا. لا نحتاج لتلك المساحة كلها لتخزين جدول الصفحات لحسن الحظ، لأن معظم العمليات لا تستخدم إلا جزءًا صغيرًا من حيز العنونة الوهمية الخاص بها، وإذا لم تستخدم العملية صفحةً وهمية فلا داعي لإنشاء مدخلة لها في جدول الصفحات. يمكن القول بأن جداول الصفحات قليلة الكثافة (أو مخوَّخَة sparse) والتي تطبيقها باستخدام التنفيذات البسيطة، مثل مصفوفة مدخلات جدول الصفحات، هو أمر سيء. ولكن يمكن تنفيذ المصفوفات قليلة الكثافة أو المخوَّخَة (sparse arrays) بطرق أخرى أفضل لحسن الحظ. فأحد الخيارات هو جدول صفحات متعدد المستويات (multilevel page table) الذي تستخدمه العديد من أنظمة التشغيل، Linux مثلًا. الخيار الآخر هو الجدول الترابطي (associative table) الذي تتضمن كل مدخلة من مدخلاته رقم الصفحة الحقيقية والوهمية. يمكن أن يكون البحث في الجدول الترابطي بطيئًا برمجيًا، أما عتاديًا يكون البحث في كامل الجدول على التوازي (parallel)، لذلك تستخدم المصفوفات المترابطة (associative arrays) غالبًا في تمثيل مدخلات جدول الصفحات في TLB. يمكنك قراءة المزيد عن ذلك من خلال: https://en.wikipedia.org/wiki/Page_table كما ذُكر سابقًا أن نظام التشغيل قادر على مقاطعة أية عملية مُشغّلة ويحفظ حالتها ثم يشغّل عملية أخرى. تدعى هذه الآلية بتحويل السياق (context switch). وبما أن كل عملية تملك جدول الصفحات الخاص بها فيجب على نظام التشغيل بالتعاون مع MMU أن يتأكد من أن كل عملية تحدث على جدول الصفحات الصحيح. وفي الآلات القديمة كان ينبغي أن تُستبدل معلومات جدول الصفحات الموجودة في MMU خلال كل عملية تحويل سياق، وبذلك كانت التكلفة باهظة. أما في الأنظمة الجديدة فإن كل مدخلة في جدول الصفحات ضمن MMU تتضمن معرّف عملية (process ID) لذلك يمكن أن تكون جداول صفحات عمليات متعددة موجودة في نفس الوقت في MMU. ترجمة -وبتصرّف- للفصل Virtual memory من كتاب Think OS A Brief Introduction to Operating Systems
  15. التجريد (Abstraction) والوهمية (Virtualization) ينبغي معرفة مصطلحين مهمين قبل الخوض في الحديث عن العمليات (processes) هما: التجريد (abstraction): هو تمثيلٌ مُبسّط لشيء معقد. عند قيادة سيارة ما على سبيل المثال يُفَهم أن توجيه عجلة القيادة يسارًا يوجه السيارة يسارًا والعكس صحيح. ترتبط عجلة القيادة بسلسلة من الأنظمة الميكانيكية والهيدروليكية، حيث توجّه هذه الأنظمة العجلات لتتحرك على الطريق. يمكن أن تكون هذه الأنظمة معقدة، ولكنّ السائق غير ملزَمٍ بالاكتراث بأي من تلك التفاصيل التي تجري داخل أنظمة السيارة، فالسائق يقود السيارة بالفعل وذلك بسبب امتلاكه نموذجًا ذهنيًا (mental model) بسيطًا عن عملية قيادة السيارة، وهذا النموذج البسيط هو التجريد (abstraction) بعينه. استخدام متصفح الويب (web browser) هو مثال آخر عن التجريد، فعند النقر على ارتباط (link) يعرض المتصفح الصفحة المرتبطة بهذا الارتباط. لا شكّ أن البرمجيات (software) وشبكات الاتصال (network communication) التي تجعل ذلك ممكنًا معقدة، ولكن لا يتوجب على المستخدم معرفة تفاصيل تلك الأمور المعقدة. جزء كبير من هندسة البرمجيات هو تصميم التجريدات التي تسمح للمستخدمين والمبرمجين استخدام أنظمة معقدة بطريقة سهلة دون الحاجة إلى معرفة تفاصيل تنفيذ هذه الأنظمة. الوهمية (Virtualization): وهي نوع من التجريد (abstraction) الذي يخلق وهمًا (illusion) بوجود شيء فعليًا في حين أنه موجود وهميًا فقط. فمثلًا تشارك العديد من المكتبات العامة في تعاون بينها يسمح باستعارة الكتب من بعضها البعض. عندما تطلب كتابًا يكون الكتاب على رف من رفوف مكتبتك أحيانًا، ولكن يجب نقله من مكان آخر عند عدم توافره لديك، ثم سيصلك إشعار عندما يُتاح الكتاب للاستلام في كلتا الحالتين. ليس هناك حاجة أن تعرف مصدر الكتاب ولا أن تعرف الكتب الموجودة في مكتبتك. إذًا يخلق النظام وهمًا بأن مكتبتك تحتوي على كتب العالم جميعها. قد تكون مجموعة الكتب الموجودة في مكتبتك فعليًا صغيرة، لكن مجموعة الكتب المتاحة لك وهميًا هي كل كتاب موجود في تلك المكتبات المتشاركة. الإنترنت (Internet) هو مثال آخر عن الوهمية وهو مجموعة من الشبكات والبروتوكولات التي تعيد توجيه (forward) الحزم (packets) من شبكةٍ لأخرى. يتصرف نظام كل حاسوب كأنه متصل بكل حاسوب آخر على الإنترنت من وجهة نظر المستخدم أو المبرمج، حيث يكون الاتصال الفعلي أو الفيزيائي بين كل حاسوب وآخر قليلًا، أما الاتصال الوهمي كبير جدًا. يُستخدم المصطلح وهمي (virtual) ضمن عبارة آلة وهمية (virtual machine) أكثر الأحيان. والآلة الوهمية تعني البرمجية التي تمنح المستخدم القدرة على إنشاء نظام تشغيل على حاسوب يشغّل نظام تشغيل مختلف، وبذلك تخلق هذه الآلة وهمًا بأن هذا النظام المنشَأ يعمل على حاسوب مستقل بذاته، ولكن في الحقيقة يمكن تشغيل عدة أنظمة تشغيل وهمية على حاسوب واحد بنفس الوقت وكأنّ كل نظام تشغيل يعمل على حاسوب مختلف. وبالتالي يُدعى ما يحدث فعليًا physical وما يحدث وهميًا logical أو abstract. العزل (Isolation) العزل هو أحد أهم مبادئ الهندسة، فعزل المكونات عن بعضها البعض هو أمر جيد عند تصميم نظام متعدد المكونات من أجل ألّا يؤثر مكون على المكونات الأخرى تأثيرًا غير مرغوب به. عزل كل برنامج قيد التشغيل عن البرامج الأخرى من أهم أهداف نظام التشغيل، فبذلك لا يضطر المبرمجون إلى الاهتمام باحتمالية حدوث تفاعلات بين البرامج المشغّلة، وبالتالي يحتاج نظام التشغيل إلى كائن برمجي يحقق هذا العزل ألا وهو العملية (process). ولكن ما هي العملية؟ العملية (process) هي كائنٌ برمجي يمثل برنامجًا مشغلًا، وبالتالي يُمثَّل كل برنامج بعملية معينة. حيث يُقصَد بعبارة «كائن برمجي» بكائنٍ له روح البرمجة كائنية التوجه (object-oriented programming)، حيث يتضمن كل كائن بيانات (data) وتوابع (methods) تعمل على هذه البيانات. فالعملية هي كائن ولكن يتضمن البيانات التالية: نص البرنامج (text of the program): وهو سلسلة من تعليمات لغة الآلة عادةً. بيانات مرتبطة بالبرنامج (Data associated with the program): والتي تنقسم إلى نوعين: بيانات ساكنة (static data) (تُخصَّص مواقعها في الذاكرة في وقت التصريف) و بيانات ديناميكية (dynamic data) (تُخصَّص مواقعها في الذاكرة في وقت التشغيل). حالة عمليات الإدخال/الإخراج المعلَّقة (The state of any pending input/output operations): مثل انتظار العملية قراءة بيانات من القرص، أو انتظار وصول حزمة عن طريق الشبكة، فحالات عمليات الانتظار هذه هي جزء من العملية نفسها (process). حالة عتاد البرنامج (The hardware state of the program): التي تتضمن البيانات المخزنة في المسجلات (registers)، ومعلومات الحالة (status information)، وعداد البرنامج الذي يحدد أية تعليمة ستنفّذ حاليًا. تشغّل كل عملية برنامجًا واحدًا في أغلب الأحيان، ولكن يمكن لعملية ما أن تحمّل وتشغّل برنامجًا آخر أيضًا. ويمكن أن تشغّل عدة عمليات نفس البرنامج، ففي هذه الحالة تتشارك العمليات بنص البرنامج ولكن ببيانات وحالات عتاد مختلفة. توفر معظم أنظمة التشغيل مجموعة أساسية من القدرات لعزل العمليات عن بعضها البعض هي: تعدد المهام (Multitasking): تتمتع معظم أنظمة التشغيل بالقدرة على مقاطعة تنفيذ أية عملية في أي وقت تقريبًا مع حفظ حالة عتاد العملية المُقاطعة، ثم استئناف تشغيل العملية لاحقًا. على كل حال لا يضطر المبرمجون إلى التفكير كثيرًا في هذه المقاطعات (interruptions)، حيث يتصرف البرنامج كما لو أنه يعمل باستمرار على معالج مخصص له فقط دون غيره، ولكن ينبغي التفكير بالوقت الفاصل بين كل تعليمة وأخرى من البرنامج فهو وقت لا يمكن التنبؤ به. الذاكرة الوهمية (Virtual memory): تخلق معظم أنظمة التشغيل وهمًا بأن كل عملية لها قطعتها (chunk) من الذاكرة وهذه القطعة معزولة عن قطع العمليات الأخرى. يمكن القول مرة أخرى بأن المبرمجين غير مضطرين لمعرفة كيفية عمل الذاكرة الوهمية، فيمكنهم المتابعة في كتابة برامجهم معتبرين كل برنامجٍ له جزءٌ مخصص من الذاكرة. تجريد الجهاز (Device abstraction): تتشارك العمليات العاملة على نفس الحاسوب بمحرك الأقراص أو قرص التخزين (disk drive) وبطاقة الشبكة (network interface) وبطاقة الرسوميات (graphics card) ومكونات عتادية أخرى أيضًا. إذا تعاملت العمليات مع تلك المكونات العتادية للحاسوب مباشرةً ودون تنسيق من نظام التشغيل، سيؤدي ذلك إلى فوضى عارمة. فيمكن لعملية ما أن تقرأ بيانات شبكة عمليةٍ أخرى على سبيل المثال، أو يمكن أن تحاول عمليات متعددة تخزين بيانات في الموقع نفسه على محرك القرص الصلب (hard drive). الأمر متروك لنظام التشغيل في النهاية ليحافظ على النظام من خلال توفير تجريدات مناسبة. لا يحتاج المبرمج لمعرفة الكثير عن كيفية عمل قدرات نظام التشغيل لعزل العمليات عن بعضها البعض، ولكن إذا دفعك الفضول لمعرفة المزيد عن ذلك فهو أمر جيد، فالإبحار في معرفة مزيد من التفاصيل يصنع منك مبرمجًا أفضل. عمليات UNIX تخيل السيناريو التالي: العملية التي تعيها عند استخدامك الحاسوب للكتابة هي محرر النصوص، وإذا حرّكت الفأرة على الطرفية (terminal) يتنبه مدير النوافذ (window manager) وينبّه terminal التي بدورها تنبّه الصدفة (shell)، فإذا كتبت الأمر make فإن shell تنشئ عملية جديدة لتنفيذ الأمر Make التي بدورها تنشئ عملية أخرى لتنفيذ LaTeX وهكذا يستمر إنشاء العمليات حتى عرض نتائج تنفيذ الأمر make. يمكن أن تبدل إلى سطح المكتب (desktop) إذا أردت البحث عن شيء ما، فيؤدي ذلك إلى تنبيه مدير النوافذ أيضًا. ويتسبب نقرك على أيقونة متصفح الويب في إنشاء عملية تشغّل المتصفح. تنشئ بعض المتصفحات، Chrome مثلًا، عمليات لكل نافذة وتبويب جديدين. ولكن توجد عمليات أخرى تعمل في الخلفية (background) في ذات الوقت، تتعلق تلك العمليات في معظمها بنظام التشغيل. يمكن استخدام الأمر ps في UNIX لمعرفة معلومات عن العمليات التي تعمل حاليًا، فيظهر الخرج التالي عند تنفيذ الأمر: PID TTY TIME CMD 2687 pts/1 00:00:00 bash 2801 pts/1 00:01:24 emacs 24762 pts/1 00:00:00 ps يمثل العمود الأول معرِّف العملية الفريد ID، بينما يمثل العمود الثاني الطرفية (terminal) التي أنشأت العملية حيث يشير (TTY) إلى عبارة teletypewriter (وهي جهاز قديم اُستخدِم لإرسال و استقبال الرسائل المكتوبة من خلال قنوات اتصال مختلفة)، ويمثل العمود الثالث الزمن الإجمالي المستغرَق خلال استخدام العملية للمعالج ويكون بالشكل التالي: ساعات، دقائق، ثواني، ويمثّل العمود الرابع والأخير اسم البرنامج المشغَّل، حيث bash هو اسم الصدفة (Shell) التي قاطعت الأوامر المكتوبة في الطرفية (terminal)، و emacs هو محرر النصوص المستخدَم، و ps هو البرنامج الذي ولّد الخرج السابق. فخرج الأمر ps هو قائمة تحوي العمليات المتعلقة بالطرفية (terminal) الحالية فقط، ولكن باستخدام الراية -e مع ps (أو باستخدام الراية aux التي هي خيارٌ آخر وشائع) فستظهر كل العمليات بما في ذلك عمليات المستخدمين الآخرين والذي برأيي، يقول الكاتب، هو ثغرة أمنية . يوجد على نظامي التشغيلي مثلًا، يقول الكاتب، 233 عملية حاليًا فيما يلي بعض منها: PID TTY TIME CMD 1 ? 00:00:17 init 2 ? 00:00:00 kthreadd 3 ? 00:00:02 ksoftirqd/0 4 ? 00:00:00 kworker/0:0 8 ? 00:00:00 migration/0 9 ? 00:00:00 rcu_bh 10 ? 00:00:16 rcu_sched 47 ? 00:00:00 cpuset 48 ? 00:00:00 khelper 49 ? 00:00:00 kdevtmpfs 50 ? 00:00:00 netns 51 ? 00:00:00 bdi-default 52 ? 00:00:00 kintegrityd 53 ? 00:00:00 kblockd 54 ? 00:00:00 ata_sff 55 ? 00:00:00 khubd 56 ? 00:00:00 md 57 ? 00:00:00 devfreq_wq أول عملية تنشأ عند بدء نظام التشغيل هي init التي تنشئ العديد من العمليات ثم تبقى خاملة بلا عمل حتى تنتهي تلك العمليات التي أنشأتها. أما kthreadd فهي العملية التي يستخدمها نظام التشغيل لإنشاء خيوط (threads) جديدة (سنتكلم عن الخيوط لاحقًا ولكن يمكن القول أن الخيط هو نوع معين من العمليات)، ويشير k في بداية kthreadd إلى نواة (kernel)، وهي جزء نظام التشغيل المسؤول عن قدرات نظام التشغيل الأساسية مثل إنشاء الخيوط threads، ويشير حرف d الإضافي إلى عفريت (daemon)، وهو اسم آخر للعملية التي تعمل في الخلفية وتوفر خدمات نظام التشغيل. وبالنسبة للعملية ksoftirqd فهي عفريت للنواة (kernel daemon) أيضًا وعملها معالجة طلبات المقاطعة البرمجية software interrupt requests أو soft IRQ. أما kworker فهي عملية تنشئها النواة للعمل على عمليات معالجة خاصة بها. توجد عمليات متعددة تشغّل خدمات النواة، ففي حالتي، يقول الكاتب، توجد 8 عمليات ksoftirqd و35 عملية kworker. لن نخوض في تفاصيل العمليات المتبقية، ولكن إذا كنت مهتمًا يمكنك البحث عن معلومات عنها وتطبيق الأمر ps لترى العمليات المشغلة على نظامك. ترجمة -وبتصرّف- للفصل Processes من كتاب Think OS A Brief Introduction to Operating Systems