عندما تكون goroutine الأداة الخاطئة

تقرير ما بعد الحادثة عن إلغاء الـ context في Go: كتابة cache من نوع «أطلق وانسَ» ورثت سياق الطلب فماتت معه — التشخيص، الإصلاح الأول المرفوض، والإصلاح النهائي الممل.

أحب حذف الكود الذكي أكثر مما أحب كتابته، وهذا الخطأ هو السبب. في خدمة بوابة تتقدم واجهةَ API خارجية بطبقة cache — ‏Go على Cloud Run، جزء من منصة خدمات مصغّرة أكبر — كان مسار القراءة يجلب قائمة من الـ API الأصلية ثم يحفظها في الـ cache. وخطرت لأحدهم فكرة تبدو معقولة: لا داعي لأن ينتظر المستدعي كتابة الـ cache، فلنطلقها في goroutine ونعُد فورًا. النتيجة كانت cache لا يبدو أنه يسخن أبدًا.

العَرَض

غرابة متقطعة منخفضة الحدة — أسوأ الأنواع:

  • معدلات إصابة cache أدنى بكثير مما يبرره نمط الحركة؛ مدخلات «كان يجب» أن تُكتب ليست موجودة عند الطلب التالي.
  • أخطاء إلغاء context في السجلات، بطوابع زمنية بعد اكتمال طلباتها بنجاح.
  • لا فشل مرئي للمستدعين. كل استجابة صحيحة؛ النظام فقط يدفع بهدوء تكلفة استدعاءات أصلية زائدة، إلى الأبد.

الآلية

شكل الخطأ، مختزلًا:

func (o *Operation) GetListWithCache(ctx context.Context, key string) (*List, error) {
    list, err := o.upstream.GetList(ctx, key)
    if err != nil {
        return nil, err
    }
    go func() {
        // الخطأ: ctx ملك للطلب — يموت لحظة عودة المعالج
        _ = o.cache.Set(ctx, key, list)
    }()
    return list, nil // يعود المعالج ← يُلغى ctx ← الكتابة أعلاه تخوض سباقًا تخسره غالبًا
}

في حزمة HTTP الخاصة بـ Go، يُلغى سياق الطلب فور عودة المعالج. ورثت الـ goroutine ذلك السياق، فلحظة خروج الاستجابة — وهي بالضبط الغاية من عدم الانتظار — أُلغيت كتابة الـ cache التي تنفذها من تحت قدميها. نجاة الكتابة صارت سباقًا بين جولة شبكة وجملة return. وجملة return تفوز عادةً.

ما جعله ماكرًا أن الكود يبدو منضبطًا تجاه الـ context — يمرر ctx كما تفعل بقية الكود المحيط. العرف الصحيح على مسار الطلب (مرّر سياق المستدعي) هو الخطأ بعينه لعملٍ يُفترض أن ينفصل عن الطلب.

طريقتان خاطئتان للانفصال

لهذا الخطأ توأم معكوس، وتسمية الاثنين تستحق العناء، لأن إصلاح أحدهما يلد الآخر:

  • وراثة سياق الطلب (خطؤنا): العمل المنفصل يموت مع الطلب. «أطلق وانسَ» تصير «أطلق وغالبًا انسَ أن تُكمل».
  • استبداله بـ context.Background(): العمل الآن يعيش أطول من الطلب — لا شيء يحدّه، لا أحد يملكه، وهو ينفّذ آثارًا جانبية بسعادة لصالح مستدعين انتهت مهلتهم وأعادوا المحاولة واستسلموا منذ زمن.

في الحالتين، مشكلة الـ goroutine الحقيقية واحدة: لا أحد يستطيع أن يقول من يملك هذا العمل ومتى ينتهي.

الإصلاح — بعد بداية خاطئة

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

الإصلاح النهائي، بروح سجل الـ commits حرفيًا: السياق يُلغى فور انتهاء استدعاء الـ API، والـ goroutine لا تناسب حالة كهذه. إذن — لا goroutine:

func (o *Operation) GetListWithCache(ctx context.Context, key string) (*List, error) {
    list, err := o.upstream.GetList(ctx, key)
    if err != nil {
        return nil, err
    }
    _ = o.cache.Set(ctx, key, list) // تزامني؛ كمونه كتابة cache واحدة
    return list, nil
}

الكمون الذي «وفّرته» الـ goroutine كان كتابة cache واحدة — مللي ثوانٍ على نقطة نهاية دفعت للتو ثمن جولة كاملة إلى الأصل. النسخة اللاتزامنية وفّرت خطأَ تقريب ودفعت مقابله الـ cache بأكمله.

القواعد التي خرجت منه

حوّلنا التقرير إلى بنود في قائمة المراجعة بدل محاضرة:

  1. لكل goroutine مالك مُعلن ونهاية مُعلنة. إذا لم تستطع قول ما الذي يوقفها ومن ينتظرها، فهي تسريب بخطوات إضافية.
  2. فصل العمل عن الطلب قرارُ context يُتخذ بصوت عالٍ. ورِّث ctx فيموت العمل مع الطلب؛ استخدم context.Background() فيعيش أطول من مالكه. إن لم يبدُ أي من الجوابين صائبًا، فالعمل لا ينتمي إلى goroutine — بل إلى مسار الطلب، أو إلى طابور حقيقي له دورة حياته ومهلته.
  3. على التزامن أن يكسب مكانه. سؤال المراجعة ليس «هل هذا التزامن صحيح؟» بل «ماذا يشتري هذا التزامن؟». الجواب الصادق هنا كان مللي ثوانٍ — والنسخة المتسلسلة صحيحة بحكم بنائها.
  4. اختبر الإلغاء، لا النتائج فقط. اختبار يُكمل الطلب ثم يؤكد أن الأثر الجانبي حدث فعلًا — أو يلغي في المنتصف ويؤكد أنه لم يحدث — كان سيصطاد هذا. اختبارات القيمة المعادة لن تفعل أبدًا.

الدرس الأوسع رافقني إلى كل قاعدة Go بعدها: معظم أخطاء التزامن الإنتاجية التي قابلتها لم تكن سباقات دقيقة في أعماق آلات ذكية — بل كودًا عاديًا جُعل متزامنًا بلا سبب، ممسكًا بسياق لم يفكر فيه أحد. حزمة context تُقنّن الانضباط أصلًا؛ العمل هو رفض الانسحاب منه باستخفاف.