اذهب إلى المحتوى

استخدام السياقات Contexts في لغة Go


هدى جبور

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

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

المتطلبات

إنشاء سياق context

تستخدم العديد من الدوال في لغة جو حزمة context لجمع معلومات إضافية حول البيئة التي تُنفّذ فيها، وعادةً ما يُقدّم هذا السياق للدوال التي تستدعيها أيضًا. ستتمكن البرامج من خلال واجهة context.Context التي توفرها حزمة السياق، وتمريرها من من دالة إلى أخرى، من نقل معلومات السياق من أعلى نقطة تنفيذ في البرنامج (دالة main) إلى أعمق نقطة تنفيذ في البرنامج (دالة ضمن دالة أخرى أو ربما أعمق). مثلًا، تُقدّم الدالة Context من النوع http.Request سياقًا context.Context يتضمن معلومات عن العميل الذي أرسل الطلب، ويُحذف أو ينتهي هذا السياق في حالة قُطع الاتصال مع العميل، حتى لو لم يكن الطلب قد انتهت معالجته. بالتالي، إذا كانت هناك دالة ما تستدعي الدالة QueryContext التابعة إلى sql.DB، وكان قد مُرر لهذه الدالة قيمة context.Context، وقُطع الاتصال مع العميل، سيتوقف تنفيذ الاستعلام مباشرةً في حالة لم يكن قد انتهى من التنفيذ.

سننشئ في هذا القسم برنامجًا يتضمن دالة تتلقى سياقًا مثل معامل، ونستدعي هذه الدالة باستخدام سياق فارغ نُنشئه باستخدام الدالتين context.TODO و Context.Background. كما هو معتاد، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه، ويمكن وضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه:

$ mkdir projects
$ cd projects

الآن من داخل هذا المجلد، سنشغّل الأمر mkdir لإنشاء مجلد "contexts" ثم سنستخدم cd للانتقال إليه:

$ mkdir contexts
$ cd contexts

يمكننا الآن فتح ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده:

$ nano main.go

سنُنشئ الآن دالة doSomething داخل ملف "main.go". تقبل هذه الدالة context.Context مثل معامل، ثم نضيف دالة main التي تُنشئ سياقًا وتستدعي doSomething باستخدام ذلك السياق. نضيف ما يلي داخل ملف main.go:

package main

import (
    "context"
    "fmt"
)

func doSomething(ctx context.Context) {
    fmt.Println("Doing something!")
}

func main() {
    ctx := context.TODO()
    doSomething(ctx)
}

استخدمنا الدالة context.TODO داخل الدالة main، لإنشاء سياق فارغ ابتدائي. يمكننا استخدام السياق الفارغ مثل موضع مؤقت placeholder عندما لا نكون متأكدين من السياق الذي يجب استخدامه. لدينا أيضًا الدالة doSomething التي تقبل معاملًا وحيدًا هو context.Context -له الاسم ctx- وهو الاسم الشائع له، ويُفضل أن يكون أول معامل في الدالة في حال كان هناك معاملات أخرى، لكن الدالة لا تستخدمه الآن فعليًّا.

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج على النحو التالي:

Doing something!

نلاحظ أن الخرج الذي أظهرته الدالة fmt.Println هو !Doing something نتيجةً لاستدعاء الدالة doSomething. لنعدّل البرنامج الآن ونستخدم الدالة context.Background التي تُنشئ سياقًا فارغًا:

...

func main() {
    ctx := context.Background()
    doSomething(ctx)
}

تنشئ الدالة context.Background سياقًا فارغًا مثل context.TODO، ومن حيث المبدأ تؤدي كلتا الدالتين السابقتين نفس الغرض. الفرق الوحيد هو أن context.TODO تُستخدم عندما تُريد أن تُخبر المطورين الآخرين أن هذا السياق هو مجرد سياق مبدأي وغالبًا يجب تعديله، أما context.Background تتُستخدم عندما لا نحتاج إلى هكذا إشارة إلى المطورين الآخرين، أي نحتاج ببساطة إلى سياق فارغ لا أكثر ولا أقل، وفي حال لم تكن متأكدًا أيهما تستخدم، ستكون الدالة context.Background خيارًا افتراضيًا.

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج:

Doing something!

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

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

إضافة معلومات إلى السياق

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

يمكن استخدم الدالة Context.WithValue من حزمة السياق لإضافة قيمة جديدة إلى السياق. تقبل الدالة ثلاث معاملات: السياق الأب (الأصلي) context.Context والمفتاح والقيمة. السياق الأب هو السياق الذي يجب إضافة القيمة إليه مع الاحتفاظ بجميع المعلومات الأخرى المتعلقة بالسياق الأصلي. يُستخدم المفتاح لاسترداد القيمة من السياق. يمكن أن يكون المفتاح والقيمة من أي نوع بيانات، وفي هذا المقال سيكونان من نوع سلسلة نصية string.

تعيد الدالة Context.WithValue قيمةً من النوع context.Context تتضمن السياق الأب مع المعلومات المُضافة. يمكن الحصول على القيمة التي يُخزنها السياق context.Context من خلال استخدام التابع Value مع المفتاح.

لنفتح الآن ملف "main.go" ولنضِف قيمة إلى السياق باستخدام الدالة السابقة، ثم نحدِّث دالة doSomething، بحيث تطبع تلك القيمة باستخدام دالة fmt.Printf:

...

func doSomething(ctx context.Context) {
    fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey"))
}

func main() {
    ctx := context.Background()

    ctx = context.WithValue(ctx, "myKey", "myValue")

    doSomething(ctx)
}

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

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج على النحو التالي:

doSomething: myKey's value is myValue

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

عند استخدام السياقات من المهم معرفة أن القيم المخزنة في سياقٍ context.Context ما تكون ثابتة immutable. بالتالي عندما استدعينا الدالة context.WithValue ومررنا لها السياق الأب، حصلنا على سياق جديد وليس السياق الأب أو نسخة منه، وإنما حصلنا على سياق جديد يضم المعلومات الجديدة إضافةً إلى سياق الأب مُغلّفًا wrapped ضمنه.

لنفتح الآن ملف "main.go" لإضافة دالة جديدة doAnother تقبل سياقًا context.Context وتطبع قيمة السياق من خلال المفتاح، ونعدّل أيضًا الدالة doSomething، بحيث نُنشئ داخلها سياقًا جديدًا يُغلّف السياق الأب ويضيف معلومات جديدة ولتكن anotherValue، ثم نستدعي الدالة doAnother على السياق anotherCtx الناتج، ونطبع في السطر الأخير من الدالة قيمة السياق الأب.

...

func doSomething(ctx context.Context) {
    fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey"))

    anotherCtx := context.WithValue(ctx, "myKey", "anotherValue")
    doAnother(anotherCtx)

    fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey"))
}

func doAnother(ctx context.Context) {
    fmt.Printf("doAnother: myKey's value is %s\n", ctx.Value("myKey"))
}

...

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج:

doSomething: myKey's value is myValue
doAnother: myKey's value is anotherValue
doSomething: myKey's value is myValue

نلاحظ في الخرج سطرين من الدالة doSomething وسطر من الدالة doAnother. لم نغير شيئًا داخل الدالة main؛ إذ أنشأنا سياقًا فارغًا وغلّفناه مع القيمة myValue والمفتاح myKey ومرّرنا السياق الناتج إلى doSomething، ويمكننا أن نلاحظ أن هذه القيمة مطبوعة على أول سطر من الخرج.

يظهر السطر الثاني من الخرج أنه عند استخدام context.WithValue داخل doSomething لتغليف السياق الأب ctx وتوليد السياق anotherCtx بالقيمة anotherValue والمفتاح myKey (المفتاح نفسه للأب لم يتغير) وتمرير هذا السياق الناتج إلى doAnother، فإن القيمة الجديدة تتخطى القيمة الابتدائية.

بالنسبة للسطر الأخير، نلاحظ أنه يطبع القيمة المرتبطة بالمفتاح myKey والمرتبطة بالسياق الأب وهي myValue. نظرًا لأن الدالة context.WithValue تغلّف السياق الأب فقط، سيبقى السياق الأب محتفظًا بنفس القيم الأصلية نفسها. عندما نستدعي التابع Value على سياق ما، فإنه يُعيد القيمة المرتبطة بالمفتاح من المستوى الحالي للسياق. عند استدعاء anotherCtx.Value من أجل مفتاح myKey، سيعيد القيمة anotherValue لأنها القيمة المغلّفة للسياق، وبالتالي يتجاوز أي قيم أخرى مُغلّفة للمفتاح، وعند استدعاء anotherCtx داخل doSomething للمرة الثانية، لن تغلِّف anotherCtx السياق ctx، وستُعاد القيمة الأصلية myValue.

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

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

إنهاء سياق

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

سنعدّل في هذا القسم البرنامج ليكون قادرًا على معرفة وقت انتهاء السياق، وسنتعرّف على 3 توابع لإنهاء السياق.

تحديد انتهاء السياق

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

يُمكن إنشاء تعليمة select من خلال الكلمة المفتاحية select متبوعةً بقوسين {} مع تعليمة case واحدة أو أكثر ضمن القوسين. يمكن أن تكون كل تعليمة case عملية قراءة أو كتابة على قناة، وتنتظر تعليمة select حتى تُنفّذ إحدى حالتها (تبقى منتظرة حتى تُنفذ إحدى تعليمات case)، وفي حال أردنا ألا تنتظر، يمكننا أن نستخدم التعليمة default، وهي الحالة الافتراضية التي تُنفّذ في حال عدم تحقق شرط تنفيذ إحدى الحالات الأخرى (تشبه تعليمة switch).

توضّح الشيفرة التالية كيف يمكن استخدام تعليمة select ضمن دالة، بحيث تتلقى نتائج من قناة وتراقب انتهاء السياق من خلال التابع Done:

ctx := context.Background()
resultsCh := make(chan *WorkResult)

for {
    select {
    case <- ctx.Done():
        // The context is over, stop processing results
        return
    case result := <- resultsCh:
                    // عالج النتائج
    }
}

تُمرر عادةً قيم كل من ctx و resultsCh إلى دالة مثل معاملات، إذ يكون ctx سياقًا من النوع context.Context، بينما resultsCh هي قيمة من قناة يمكننا القراءة منها فقط داخل الدالة، وغالبًا ما تكون هذه القيمة نتيجة من عامل worker (أو خيوط معالجة جو) في مكانٍ ما. في كل مرة تُنفذ فيها تعلمية select سيوقف جو تنفيذ الدالة ويراقب جميع تعليمات case، وحالما تكون هناك إمكانية لتنفيذ أحدها (قراءة من قناة كما في في حالة resultsCh، أو كتابة أو معرفة حالة القناة عبر Done) يُنفذ فرع هذه الحالة، ولا يمكن التنبؤ بترتيب تنفيذ تعلميات case هذه، إذ من الممكن أن تُنفذ أكثر من حالة بالتزامن.

نلاحظ في الشيفرة أعلاه أنه يمكننا استمرار تنفيذ الحلقة إلى الأبد طالما أن السياق لم ينتهي، أي طالما ctx.Done لم تُشر إلى إغلاق القناة، إذ لاتوجد أي تعليمات break أو return إلا داخل عبارة case. بالرغم من عدم إسناد الحالة case <- ctx.Done أي قيمة لأي متغير، إلا أنه سيظل بالإمكان تنفيذها عند إغلاق ctx.Done، لأن القناة تحتوي على قيمة يمكن قراءتها حتى لو لم نُسنِد تلك القيمة وتجاهلناها. إذا لم تُغلق القناة ctx.Done (أي لم يتحقق فرع الحالة case <- ctx.Done)، فسوف تنتظر تعليمة select حتى تُغلق أو حتى يُصبح بالإمكان قراءة قيمة من resultsCh.

إذا كانت resultsCh تحمل قيمة يمكن قراءتها يُنفّذ فرع الحالة الذي يتضمنها ويجري انتظارها ريثما تنتهي من التنفيذ، وبعدها يجري الدخول في تكرار آخر للحلقة (تُنفّذ حالة واحدة في الاستدعاء الواحد لتعليمة select)، وكما ذكرنا سابقًا إذا كان بالإمكان تنفيذ الحالتان، يجري الاختيار بينهما عشوائيًا.

في حال وجود تعليمة default، فإن الأمر الوحيد الذي يتغير هو أنه يُنفذ حالًا في حال لم تكن إحدى الحالات الأخرى قابلة للتنفيذ، وبعد تنفيذ default يجري الخروج من select ثم يجري الدخول إليها مرةً أخرى بسبب وجود الحلفة. يؤدي هذا إلى تنفيذ حلقة for بسرعة كبيرة لأنها لن تتوقف أبدًا وتنتظر القراءة من قناة. تُسمى الحلقة في هذه الحالة "حلقة مشغولة busy loop" لأنه بدلًا من انتظار حدوث شيء ما، تكون الحلقة مشغولة بالتكرار مرارًا وتكرارًا. يستهلك ذلك الكثير من دورات وحدة المعالجة المركزية CPU، لأن البرنامج لا يحصل أبدًا على فرصة للتوقف عن التشغيل للسماح بتنفيذ التعليمات البرمجية الأخرى. عمومًا تكون هذه العملية مفيدة أحيانًا، فمثلًا إذا كنا نريد التحقق مما إذا كانت القناة جاهزة لفعل شيء ما قبل الذهاب لإجراء عملية أخرى غير متعلقة بالقناة.

كما ذكرنا، تتجلى الطريقة الوحيدة للخروج من الحلقة في هذا المثال بإغلاق القناة المُعادة من التابع Done، والطريقة الوحيدة لإغلاق هذه القناة هي إنهاء السياق، بالتالي نحن بحاجة إلى طريقة لإنهائه. توفر لغة جو عدة طرق لإجراء ذلك وفقًا للهدف الذي نبتغيه، والخيار المباشر هو استدعاء دالة "إلغاء cancel" السياق.

إلغاء السياق

يعد إلغاء السياق Cancelling context أكثر طريقة مباشرة ويمكن التحكم بها لإنهاء السياق. يمكننا -بأسلوب مشابه لتضمين قيمة في سياق باستخدام دالة context.WithValue- ربط دالة إلغاء سياق مع سياق باستخدام دالة context.WithCancel، إذ تقبل هذه الدالة السياق الأب مثل معامل وتعيد سياقًا جديدًا إضافةً إلى دالة يمكن استخدامها لإلغاء السياق المُعاد؛ وكذلك يؤدي استدعاء دالة الحذف المُعادة فقط إلى إلغاء السياق الذي أُعيد مع جميع السياقات الأخرى التي تستخدمه مثل سياق أب، وذلك بطريقة مشابهة أيضًا لأسلوب عمل دالة context.WithValue. هذا لا يعني طبعًا أنه لا يمكن إلغاء السياق الأب الذي مررناه إلى دالة context.WithCancel، بل يعني أنه لا يُلغى إذا استدعيت دالة الإلغاء بهذا الشكل.

لنفتح ملف "main.go" لنرى كيف نستخدم context.WithCancel:

package main

import (
    "context"
    "fmt"
    "time"
)

func doSomething(ctx context.Context) {
    ctx, cancelCtx := context.WithCancel(ctx)

    printCh := make(chan int)
    go doAnother(ctx, printCh)

    for num := 1; num <= 3; num++ {
        printCh <- num
    }

    cancelCtx()

    time.Sleep(100 * time.Millisecond)

    fmt.Printf("doSomething: finished\n")
}

func doAnother(ctx context.Context, printCh <-chan int) {
    for {
        select {
        case <-ctx.Done():
            if err := ctx.Err(); err != nil {
                fmt.Printf("doAnother err: %s\n", err)
            }
            fmt.Printf("doAnother: finished\n")
            return
        case num := <-printCh:
            fmt.Printf("doAnother: %d\n", num)
        }
    }
}
...

أضفنا استيرادًا للحزمة time وجعلنا الدالة doAnother تقبل قناةً جديدة لطباعة أرقام على شاشة الخرج. استخدمنا تعليمة select ضمن حلقة for للقراءة من تلك القناة والتابع Done الخاص بالسياق. أنشأنا ضمن الدالة doSomething سياقًا يمكن إلغاؤه إضافةً إلى قناة لإرسال الأرقام إليها. أضفنا استدعاءً للدالة doAnother سبقناه بالكلمة المفتاحية go ليُنفّذ مثل خيوط معالجة جو goroutine، ومررنا له السياق ctx والقناة printCh. أخيرًا أرسلنا بعض الأرقام إلى القناة ثم ألغينا السياق.

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج على النحو التالي:

doAnother: 1
doAnother: 2
doAnother: 3
doAnother err: context canceled
doAnother: finished
doSomething: finished

تعمل الدالة doSomething في الشيفرة السابقة على إرسال العمل إلى خيط معالجة واحد أو أكثر يقرؤون الأرقام من القناة ويطبعوها، وتكون في هذه الحالة الدالة doAnother هي العامل worker وعملها هو طباعة الأرقام، وحالما يبدأ خيط معالجة جو doAnother، تبدأ دالة doSomething بإرسال الارقام للطباعة.

تنتظر تعليمة select -داخل الدالة doAnother- إغلاق قناة ctx.Done أو استقبال رقم على القناة printCh. تبدأ الدالة doSomething عملية إرسال الأرقام إلى القناة بعد بدء تنفيذ doAnother كما نرى في الشيفرة أعلاه، إذ ترسل 3 أرقام إلى القناة، وبالتالي تُفعّل 3 عمليات طباعة fmt.Printf لكل رقم (فرع الحالة الثانية داخل تعليمة select)، ثم تستدعي دالة cancelCtx لإلغاء السياق. بعد أن تقرأ الدالة doAnother الأرقام الثلاثة من القناة، ستنتظر العملية التالية من القناة، أي تبقى منتظرة ولن يُنفّذ الفرع المقابل في select، وبما أن doSomething في هذه الأثناء استدعت cancelCtx، بالتالي يُستدعى فرع ctx.Done، الذي يستخدم الدالة Err التي يوفرها النوع context.Context لتحديد كيفية إنهاء السياق. بما أننا ألغينا السياق باستخدام cancelCtx، بالتالي سيكون الخطأ الذي نراه هو context canceled.

ملاحظة: إذا سبق وشغّلت برامج لغة جو من قبل ونظرت إلى الخرج، فربما سبق وشاهدت الخطأ context canceled من قبل، فهو خطأ شائع عند استخدام حزمة http من لغة جو، ويهدف لمعرفة وقت قطع اتصال العميل بالخادم، قبل أن يعالج الخادم الاستجابة.

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

تكون الدالة context.WithCancel ودالة الإلغاء التي تُعيدها مفيدةً أكثر عندما يتطلب الأمر قدرًا كبيرًا من التحكم عند إنهاء السياق، لكن في كثير من الأحيان قد لا نحتاج إلى هذا القدر من التحكم. الدالة التالية المتاحة لإنهاء السياقات في حزمة السياق context هي Context.WithDeadline، إذ تنهي هذه الدالة السياق تلقائيًا نيابةً عنا.

إعطاء السياق مهلة زمنية للانتهاء

يمكننا تحديد مهلة زمنية Deadline يجب خلالها أن ينتهي السياق باستخدام الدالة Context.WithDeadline، وبعد انتهاء هذه المهلة سوف ينتهي السياق تلقائيًا. الأمر أشبه بأن نكون في امتحان، ويكون هناك مهلة محددة لحل الأسئلة، وتُسحب الورقة منا عند انتهائها تلقائيًا، حتى لو لم ننتهي من حلها. لتحديد موعد نهائي لسياق ما، نستخدم الدالة Context.WithDeadline مع تمرير السياق الأب وقيمة زمنية من النوع time.Time تُشير إلى الموعد النهائي. تُعيد هذه الدالة سياقًا جديدًا ودالة لإلغاء السياق، وكما رأينا في Context.WithCancel تُطبق عملية الإلغاء على السياق الجديد وعلى أبنائه (السياقات التي تستخدمه). يمكننا أيضًا إلغاء السياق يدويًا عن طريق استدعاء دالة الإلغاء كما في دالة context.WithCancel.

لنفتح ملف البرنامج لنحدثه ونستخدم دالة Context.WithDeadline بدلًا من context.WithCancel:

...

func doSomething(ctx context.Context) {
    deadline := time.Now().Add(1500 * time.Millisecond)
    ctx, cancelCtx := context.WithDeadline(ctx, deadline)
    defer cancelCtx()

    printCh := make(chan int)
    go doAnother(ctx, printCh)

    for num := 1; num <= 3; num++ {
        select {
        case printCh <- num:
            time.Sleep(1 * time.Second)
        case <-ctx.Done():
            break
        }
    }

    cancelCtx()

    time.Sleep(100 * time.Millisecond)

    fmt.Printf("doSomething: finished\n")
}

...

تستخدم الشيفرة الآن الدالة Context.WithDeadline ضمن الدالة context.WithDeadline لإلغاء السياق تلقائيًا بعد 1500 ميلي ثانية (1.5 ثانية) من بدء تنفيذ الدالة، إذ حددنا الوقت من خلال دالة time.Now. إضافةً إلى استخدام الدالة Context.WithDeadline، أجرينا بعض التعديلات الأخرى؛ فنظرًا لأنه من المحتمل أن ينتهي البرنامج الآن عن طريق استدعاء cancelCtx مباشرةً أو الإلغاء التلقائي وفقًا للموعد النهائي، حدّثنا دالة doSomething، بحيث نستخدم تعليمة select لإرسال الأرقام على القناة. بالتالي، إذا كانت doAnother لا تقرأ من printCh وكانت قناة ctx.Done مُغلقة، ستلاحظ ذلك doSomething وتتوقف عن محاولة إرسال الأرقام.

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

لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run:

$ go run main.go

سيكون الخرج على النحو التالي:

doAnother: 1
doAnother: 2
doAnother err: context deadline exceeded
doAnother: finished
doSomething: finished

نلاحظ هذه المرة إلغاء السياق بسبب خطأ تجاوز الموعد النهائيdeadline exceeded قبل طباعة جميع الأرقام الثلاثة، وهذا منطقي فالمهلة الزمنية هي 1.5 ثانية بدءًا من لحظة تنفيذ doSomething، وبما أن doSomething تنتظر ثانية واحدة بعد إرسال رقم، فستنتهي المهلة قبل طباعة الرقم الثالث. بمجرد انقضاء المهلة الزمنية، ينتهي تنفيذ كل من doSomething و doAnother، وذلك لأنهما يراقبان لحظة إغلاق قناة ctx.Done. لو عدّلنا مدة المهلة وجعلناها أكثر من 3 ثوان، فربما سنشاهد الخطأ context canceled يظهر من جديد، وذلك لأن المهلة طويلة.

قد يكون الخطأ context deadline exceeded مألوفًا أيضًا، ولا سيما للمبرمجين الذين يستخدمون تطبيقات لغة جو ويقرأون رسائل الخطأ التي تظهر. هذا الخطأ شائع في خوادم الويب التي تستغرق وقتًا في إرسال الاستجابات إلى العميل. مثلًا، إذا استغرق استعلام من قاعدة البيانات أو عملية ما وقتًا طويلًا، فقد يتسبب ذلك بإلغاء سياق الطلب وظهور هذا الخطأ لأن الخادم لا يسمح بتجاوز مهلة معينة في معالجة طلب ما. يسمح لنا إلغاء السياق باستخدام context.WithCancel بدلًا من cont4ext.WithCancel بإلغاء السياق تلقائيًا بعد انتهاء مهلة نتوقع أنها كافية، دون الحاجة إلى تتبع ذلك الوقت. بالتالي: إذا كنا نعرف متى يجب أن ينتهي السياق (أي نعرف المهلة الكافية)، ستكون هذه الدالة خيارًا مناسبًا.

أحيانًا ربما لا نهتم بالوقت المحدد الذي ينتهي فيه السياق، وتريد أن ينتهي بعد دقيقة واحدة من بدئه. هنا يمكننا أيضًا استخدام الدالة context.WithDeadline مع بعض توابع الحزمة time لتحقيق الأمر، لكن لغة جو توفر لنا الدالة context.WithTimeout لتبسيط الأمر.

إعطاء السياق وقت محدد

تؤدي دالة context.WithTimeout نفس المهمة التي تؤديها الدالة السابقة، والفرق الوحيد هو أننا في context.WithDeadline نمرر قيمة زمنية محددة من النوع time.Time لإنهاء السياق، أما في context.WithTimeout نحتاج فقط إلى تمرير المدة الزمنية، أي قيمة من النوع time.Duration. إذًا، يمكننا استخدام context.WithDeadline إذا أردنا تحديد وقت معين time.Time. ستحتاج -بدون context.WithTimeout- إلى استخدام الدالة ()time.Now والتابع Add لتحديد المهلة الزمنية، أما مع context.WithTimeout، فيمكنك تحديد المهلة مباشرةً.

لنفتح ملف البرنامج مجددًا ونعدله، بحيث نستخدم context.WithTimeout بدلًا من context.WithDeadline:

...

func doSomething(ctx context.Context) {
    ctx, cancelCtx := context.WithTimeout(ctx, 1500*time.Millisecond)
    defer cancelCtx()

    ...
}

...

لنُشغّل ملف البرنامج main.go من خلال الأمر go run:

$ go run main.go

ليكون الخرج:

doAnother: 1
doAnother: 2
doAnother err: context deadline exceeded
doAnother: finished
doSomething: finished

نلاحظ أن الخرج ورسالة الخطأ هي نفسها التي حصلنا عليها في الخرج السابق عندما استخدمنا الدالة context.WithDeadline، ورسالة الخطأ أيضًا نفسها التي تظهر أن context.WithTimeout هي فعليًا مغلّّف يجري عمليات رياضية نيابةً عنك.

استخدمنا في هذا القسم 3 طرق مختلفة لإنهاء السياق context.Context.؛ إذ بدأنا بالدالة context.WithCancel التي تسمح لنا باستدعاء دالة لإلغاء السياق؛ ثم الدالة context.WithDeadline مع قيمة time.Time لإنهاء السياق في وقت محدد؛ ثم الدالة context.WithTimeout مع قيمة time.Duration لإنهاء السياق بعد مدة معينة.

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

الخاتمة

أنشأنا خلال هذا المقال برنامجًا يستخدم حزمة السياق context التي تقدمها لغة جو بطرق مختلفة. أنشأنا دالةً تقبل سياقًا context.Context مثل معامل، واستخدمنا الدالتين context.TODO و context.Background لإنشاء سياق فارغ. بعد ذلك، استخدمنا الدالة context.WithValue لإنشاء سياق جديد يُغلّف سياقًا آخر ويحمل قيمة جديدة، وتعرّفنا على كيفية قراءة هذه القيمة لاحقًا من خلال التابع Value من داخل الدوال الأخرى التي تستخدم هذا السياق. بعد ذلك، تعرفنا على التابع Done الذي يُساعدنا في معرفة الوقت الذي تنتفي فيه الحاجة إلى إبقاء السياق. تعلمنا أيضًا كيف نلغي السياق بطرق مختلفة من خلال الدوال context.WithCancel و context.WithDeadline و context.WithTimeout وكيف نضع حدًا للمدة التي يجب أن تُنفّذ بها التعليمات البرمجية التي تستخدم تلك السياقات.

ترجمة -وبتصرف- للمقال How To Use Contexts in Go لصاحبه Kristin Davidson.

اقرأ أيضًا


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

أفضل التعليقات

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



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...