الدروس المستفادة من البرمجة بلغة Go


Mohamed Lahlah

تعلم كيفية حلّ المشاكل الشائعة في أنظمة المعالجة المتزامنة وتفادى صداع المرافق لحلّها في المستقبل.

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

الفحص المتزامن لمقاييس الارتباط

تعتمد خوارزمية HALO (وهي اختصارًا Hop-by-Hop Adaptive Link-State Optimal Routing) على التوجيه الديناميكي جزئيًا على مقاييس الارتباط لحساب جدول التوجيه الخاص بها. تُجمعُ هذه المقاييس من خلال مكون مستقل متواجد بكلّ PoP (نقطة مشاركة). أما نقاط المشاركة (PoPs) وهي آلات تمثل كيانًا موجهًا واحدًا في شبكاتنا، ومتصلة بارتباطات متعددة وتنتشر في مواقع كثيرة مشكلةً بذلك شبكتنا. يستكشف هذا المكون الأجهزة المجاورة باستخدام حزم الشبكة، وسيُردُ هؤلاء الجيران بجواب أولي. يمكن استخلاص قيم زمن تأخير الارتباط من الأجوبة المتلقاة من هذه النقاط. نظرًا لأن لكل نقطة اتصال أكثر من جار واحد، فإن طبيعة هذه المهمة متزامنة جوهريًا: نحتاج إلى قياس وقت الاستجابة لكل رابط مجاور في الوقت الفعلي. لا يمكننا تحمل المعالجة التسلسلية ويجب معالجة كلّ جواب بأقرب وقت ممكن لحساب مقياس الارتباط.

1.png

أرقام التسلسل وإعادة الضبط: حالة إعادة الترتيب

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

عملية تأسيس اتصال UDP وحالات الآلة المحدودة

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

لتطبيق هذا الأمر تطبيقًا صحيحًا، يجب علينا تحديد حالات الآلة المحدودة وكما يجب أن تكون الانتقالات واضحة بينهم. وهذا بدوره سيسمح لنا بإدارة جميع حالات الحرجة لتأسيس الاتصال تأسيسًا صحيحًا.

2.png

تُنشأ مُعرّفات الجلسة من خلال مؤسس الاتصالات. ويكون التسلسل الكامل لعملية الإنشاء على الشكل التالي:

  1. يرسل المرسل حزمة SYN(ID)‎.
  2. يخزن المُستقبل المُعرّف الواصل له (وهو ID) ويرسل SYN-ACK (ID)‎.
  3. يتلقى المرسل SYN-ACK (ID)‎ ويرسل ACK (ID)‎. كما يبدأ أيضًا في إرسال الحزم الّتي تبدأ بالتسلسل رقم 0.
  4. يتحقق المرسل من آخر معرّف ID استلمه، ويقبل ACK (ID)‎ إذا كان معرّفه مطابقًا للمعرّف (ID) السابق. كما يبدأ بقبول الحزم الّتي تبدأ برقم تسلسلي 0.

معالجة المُهلة الزمنية للحالة

ستحتاج كلّ حالة افتراضيًا لمعالجة ثلاثة أنواع من الأحداث على الأكثر: أحداث عمليات الارتباط، وأحداث تراسل الحزم، وأحداث انتهاء المُهلة. وتظهر هذه الأحداث في وقت واحد، لذلك عليك التعامل معها بطريقة متزامنة.

  • أحداث الارتباط: وهي تحديث لحالة الارتباط فإما عملية ارتباط أو عملية فصل الارتباط. يمكن لهذا إما بدء جلسة اتصال أو إنهاء جلسة موجودة.
  • أحداث تراسل الحزم: وهي لحزم التحكم مثل: (SYN/SYN-ACK/ACK) أو استقصاء الاستجابات فقط.
  • أحداث انتهاء المُهلة: هي الأحداث الّتي تُشغّل بعد انتهاء المهلة المُجدولة لحالة الجلسة الحالية.

يكمن التحدي الرئيسي الّذي نواجهه هنا هو كيفية التعامل مع انتهاء زمن المُهلة المتزامنة والأحداث الأخرى بنفس الوقت. وهنا يمكن للمرء أن يسقط بسهولة في مشاكلة القفل المستعصي (Deadlocks) ومشكلة السباق (Race conditions).

النهج الأول

سنستخدم في هذا المشروع لغة Golang لإنها توفر آليات مزامنة أصيلة مثل: القنوات والأقفال الأصيلة وهي قادرة أيضًا على تدوير الخيوط (Threads) الخفيفة لمعالجتهم بطريقة متزامنة (لمزيد من المعلومات يمكنك الاطلاع على مقال سابق أخذنا فيه نظرة سريعة على لغة Golang).

3.png

سنبدأ أولًا بتصميم هيكلًا (Struct) لتمثيل مُعالجات جلسات العمل والمُهلة.

type  Session  struct  {    
  State SessionState    
  Id SessionId    
  RemoteIp  string    
}  

type  TimeoutHandler  struct  {    
  callback  func(Session)    
  session Session    
  duration  int    
  timer  *timer.Timer    
}

تُحددُ (Session) جلسة الاتصال بمعرّف الجلسة (ID)، وعنوان IP للارتباط المجاور (RemoteIp)، وحالة الجلسة الحالية (State).

كما يحدد معالج المُهلة (TimeoutHandler) بدالّة رد النداء (callback)، والجلسة الّتي يجب أن يعمل من أجلها (session)، والمدة (duration)، ومؤشر للوقت المُجدول (timer).

ستُخزن خارطة (أو خريطة) عامة (Global Map) -الخرائط Maps في لغة Go هي مصفوفات ترابطية يمكن التعديل عليها ديناميكيًا وهي تشبه نوع القاموس او الهاش في اللغات البرمجية الأخرى- مُعالج المُهلة المُجدولة لكل جلسة ارتباط مجاورة.

SessionTimeout map[Session]*TimeoutHandler

تُسجّل مُهلةٌ ما وتُلغى بالدوالّ التالية:

// الدالّة المسؤولة عن جدولة المهلة لرد النداء
func (timeout* TimeoutHandler) Register() {  
  timeout.timer = time.AfterFunc(time.Duration(timeout.duration) * time.Second, func() {  
    timeout.callback(timeout.session)  
  })  
}

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.timer == nil {  
    return  
  }  
  timeout.timer.Stop()  
}

لإنشاء المُهل وتخزينها، يمكنك استخدام دالّة مشابه لهذه:

func CreateTimeoutHandler(callback func(Session), session Session, duration int) *TimeoutHandler {  
  if sessionTimeout[session] == nil {  
    sessionTimeout[session] := new(TimeoutHandler)  
  }  

  timeout = sessionTimeout[session]  
  timeout.session = session  
  timeout.callback = callback  
  timeout.duration = duration  
  return timeout  
}

بمجرد إنشاء معالج المُهلة وتسجيله بنجاح، سيُشغل عندها رد النداء بعد انقضاء المدة الزمنية (duration) المقدرة بالثانية. بينما بعض الأحداث الأخرى ستتطلبُ منك إعادة جدولة معالج المُهلة (كما يحدث في حالة SYN - كلّ 3 ثوان).

لذلك، يمكنك إعادة جدولة المُهلة لرد نداء معين. هكذا:

func synCallback(session Session) {  
  sendSynPacket(session)

  // reschedules the same callback.  
  newTimeout := NewTimeoutHandler(synCallback, session, SYN_TIMEOUT_DURATION)  
  newTimeout.Register()

  sessionTimeout[state] = newTimeout  
}

سيعيد رد النداء جدولة نفسه في معالج مهلة جديد ويحدّث الخارطة العامة بقيمة sessionTimeout الجديدة.

سباق البيانات والمراجع

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

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

  1. لديك معالج (handler) مجدول بمُهلة معينة.
  2. الخيط 1 (Thread 1):
    أ) تتلقى حزمة تحكم، وتريد الآن إلغاء المُهلة المسجلة والانتقال لحالة الجلسة التالية. (مثلًا، أن تتلقى SYN-ACK بعد إرسال ** SYN **).
    ب) يمكنك استدعاء الدالّة timeout.Cancel()‎، والّتي بدورها ستستدعي الدالّة timer.Stop()‎. (لاحظ أن توقف مؤقت لغة Golang لن يمنع تشغيل المؤقت الّذي انتهت مدته الزمنية بالفعل).
  3. الخيط 2:
    أ) قبل استدعاء دالة الإلغاء مباشرةً، انقضت المدة الزمنية للمؤقت، وكان رد النداء على وشك التنفيذ.
    ب) نفذَّ رد النداء، وسجُلّت المُهلة الجديدة وحُدّثت أيضًا الخارطة العامة.
  4. الخيط 1:
    أ) الانتقال لحالة الجلسة الجديدة وتسجيل مُهلة جديدة، وتحديث الخارطة العامة.

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

هل يعد استخدام القفل كافيًا لحل المشكلة؟

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

func (timeout* TimeoutHandler) Register() {  
  timeout.timer = time.AfterFunc(time.Duration(timeout.duration) * time._Second_, func() {  
    stateLock.Lock()  
    defer stateLock.Unlock()

    timeout.callback(timeout.session)  
  })  
}

إن الفرق الآن هو أن التحديثات على الخارطة العامة متزامنة، ولكن هذا لا يمنع رد النداء من العمل بعد استدعاء الدالّة timeout.Cancel()‎ - وتحدث هذه الحالة إذا انتهت المدة الزمنية للمؤقت المُجدول ولكنه لم يجلب القفل بعد. عندها يجب أن تُفقدُ مرة أخرى الإشارة لأحد المُهل المسجلة.

استخدام قنوات الإلغاء

بدلًا من الاعتماد على الدالّة timer.Stop()‎ في لغة Golang، الّذي لن يمنع رد النداء المنتهية مُهلته الزمنية من التنفيذ، سنعتمد على قنوات الإلغاء.

هذا النهج مختلف قليلًا عن سابقه. فالآن لن نعيد الجدولة بطريقة تعاودية من خلال ردود النداء وإنما سننشئ حلقة لا نهائية تنتظر إشارات الإلغاء أو أحداث انتهاء المُهل.

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

func (timeout *TimeoutHandler) Register() chan struct{} {  
  cancelChan := make(chan struct{})  

  go func () {  
    select {  
    case _ = <- cancelChan:  
      return  
    case _ = <- time.AfterFunc(time.Duration(timeout.duration) * time.Second):  
      func () {  
        stateLock.Lock()  
        defer stateLock.Unlock()

        timeout.callback(timeout.session)  
      } ()  
    }  
  } ()

  return cancelChan  
}

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.cancelChan == nil {  
    return  
  }  
  timeout.cancelChan <- struct{}{}  
}

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

الحل في هذه الحالة هو التحقق من قناة الإلغاء بداخل نطاق المُهلة بعد جلب القفل.

  case _ = <- time.AfterFunc(time.Duration(timeout.duration) * time.Second):  
    func () {  
      stateLock.Lock()  
      defer stateLock.Unlock()  

      select {  
      case _ = <- handler.cancelChan:  
        return  
      default:  
        timeout.callback(timeout.session)  
      }  
    } ()  
  }

أخيرًا، هذا سيضمنُ أن يُنفذّ رد النداء فقط بعد جلب القفل وقبل تفعيل الإلغاء.

احذر من مشكلة القفل المستعصي

يبدو أن هذا الحل يعمل عملًا جيدًا. إلا أنه بالرغم من ذلك نلاحظ وجود مشكلة القفل المستعصي المخفية.

حاول أن تعيد قراءة الشيفرة البرمجية أعلاه وأن تحاول العثور عليها بنفسك. فكر في الاستدعاءات المتزامنة للدوالّ الموضحة.

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

4.jpg

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

func (timeout* TimeoutHandler) Cancel() {  
  if timeout.cancelChan == nil {  
    return  
  }  

  select {  
  case timeout.cancelChan <- struct{}{}:  
  default:  
    // can’t send on the channel, someone has already requested the cancellation.  
  }  
}

الخلاصة

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

تحديث البيانات المشتركة بدون مزامنة

يبدو هذا الأمر جليًا، ولكن من الصعب تحديد ما إذا كانت التحديثات المتزامنة تحدث في مواقع مختلفة. والنتيجة هي حالة السباق على البيانات، حيث يمكن أن تؤدي التحديثات المتعددة لنفس البيانات لفقدان التحديث، بسبب تجاوز أحد التحديثات لتحديث آخر. في مثالنا، حدثنا مرجع المُهلة المُجدولة على نفس الخارطة المشتركة (Shared Map). (من المثير للاهتمام أنه إذا اكتشفت لغة Go عملية قراءة/كتابة متزامنة على نفس كائن الخارطة، فسترمي خطأ مُميت - يمكنك تشغيل مثال حي لكاشف سباق البيانات). يؤدي هذا في النهاية إلى فقدان مرجع المُهلة، ويجعل من المستحيل إلغاء المُهلة المحددة. تذكر دائمًا استخدام الأقفال عند الحاجة إليها.

5.jpeg

فقدان عمليات التحقق من الحالة

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

في مثالنا، تلقى مُعالج المُهلة استدعاء الاستيقاظ (لفك حالة السُبات) من مؤقت منتهية مُهلته الزمنية، ولكنه لا يزال بحاجة للتحقق من أن إشارة الإلغاء قد أرسلت إليه بالفعل قبل أن يتابع تنفيذ رد النداء.

6.png

مشكلة القفل المستعصي

تحدث هذه المشكلة عندما يكون هنالك خيط ما عالقًا، ويدخل في مرحلة انتظار غير منتهية للإشارة المعنية بإيقاظه، ولكن هذه الإشارة لن تصل أبدًا. وهذا بدوره يمكن أن يقضي على تطبيقك بالكامل عن طريق إيقاف تنفيذ البرنامج بالكامل.

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

ترجمة -وبتصرف- للمقال Lessons learned from programming in Go لكاتبه Eduardo Ferreira





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن