عند تطوير التطبيقات الكبيرة، وخصوصًا برمجيات الخادم - يكون من المفيد أحيانًا لدالةٍ ما معرفة بعض المعلومات عن البيئة التي تُنفّذ بها إلى جانب المعلومات اللازمة لعمل الدالة نفسها. لنأخذ مثلًا دالة خادم ويب تتعامل مع طلب HTTP لعميل معين، هنا قد تحتاج الدالة إلى معرفة عنوان URL الذي يطلبه العميل فقط لتحقيق الاستجابة، وفي هذه الحالة ربما تحتاج فقط إلى تمرير العنوان مثل معامل إلى الدالة. المشكلة أن هناك بعض الأشياء المفاجئة التي يمكن أن تحدث مثل انقطاع الاتصال مع العميل قبل تحقيق الاستجابة وتلقيه الرد. بالتالي، إذا كانت الدالة التي تؤدي الاستجابة لا تعرف أن العميل غير متصل، لن يصل الرد والعمليات التي يجريها الخادم ستكون مجرد هدر للموارد الحاسوبية على استجابة لن تُستخدم. لتفادي هكذا حالات يجب أن يكون بمقدور الخادم معرفة سياق الطلب (مثل حالة اتصال العميل)، وبالتالي إمكانية إيقاف معالجة الطلب بمجرد انقطاع الاتصال مع العميل. هذا من شأنه الحفاظ على الموارد الحاسوبية ويحد من الهدر ويتيح للخادم التحرر أكثر من الضغط وتقديم أداء أفضل. تظهر فائدة هذا النوع من المعلومات أكثر في الحالات التي تتطلّب فيها الدوال وقتًا طويلًا نسبيًا في التنفيذ، مثل إجراء استدعاءات قاعدة البيانات. لمعالجة هذه القضايا ومنح إمكانية الوصول الكامل لمثل هذه المعلومات تُقدم لغة جو حزمة السياق context
في المكتبة القياسية.
سنُنشئ خلال هذا المقال برنامجًا يستخدم سياقًا داخل دالة. سنعدّل بعدها البرنامج لتخزين بيانات إضافية في السياق واستردادها من دالة أخرى. بعد ذلك سنستفيد من فكرة السياق لإرسال إشارة للدالة التي تُجري عملية المعالجة، لتوقف تنفيذ أي عمليات معالجة مُتبقية.
المتطلبات
- إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده.
- تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS.
- تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز.
- فهم لخيوط معالجة جو goroutines والقنوات channels. يمكنك الاطلاع على مقالة كيفية تشغيل عدة دوال على التساير في لغة جو Go.
- معرفة بكيفية التعامل مع التاريخ والوقت في لغة جو. يمكنك الاطلاع على مقالة استخدام التاريخ والوقت في لغة جو Go.
-
معرفة كيفية التعامل مع تعليمة
switch
في لغة جو. يمكنك الاطلاع على مقالة التعامل مع التعليمة Switch في لغة جو Go.
إنشاء سياق 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.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.