الصفحات

2021/04/02

التعامل مع البرمجة اللامتزامنة AsyncIO و المولدات generators

مقدمة

نتعامل في لغات البرمجة مع أحداث تأخذ وقت وفي هذا الوقت لا يكون المعالج يعمل شيء. مثلا لما نقرأ من ملف أو نكتب في ملف أو لما نرسل استعلام لخادوم قاعدة بيانات بعيد وننتظر الرد أو لما نرسل طلب إلى واجهة برمجية بعيدة API وفي هذه الأثناء بعد إرسال الاستعلام أو الطلب ننتظر الرد. وبدل أن تكون الخدمة معطلة تنتظر الرد يمكنها استغلال الوقت الضائع في الإنتظار في معالجة طلبات أخرى. قديما كان النموذج الوحيد للتعامل مع أكثر من عملية في نفس الوقت هو تعدد العمليات وتعدد خيوط المعالجة (multi-process و multi-thread) سواء كل طلب جديد في عملية أو مسار جديد منفصل أو بعمل pool مجهز مسبقا وتحويل الطلبات عليه عبر طابور queue. لكن هذين النموذجين لا يناسبان هذه الحالة التي شرحناها لأن عنق الزجاج هنا ليس المعالج نحن في هذه الأمثلة لا ننتظر حسابات معينة فنريد نواة معالج أخرى تحسب بينما الأولى مشغولة في الحسابات الأولى. نحن فعليا ننتظر. ننتظر IO. سواء كان disk io أو network io. ومن هنا ظهرت عائلة من اللغات أو من العبارات الخاصة في اللغات التي تمكنك من معالجة أكثل من طلب ضمن عملية واحدة دون الحاجة لتعدد العمليات وطبعا الشرط الوحيد هنا أن لا تكون هذه الطلبات عبارة عن حسابات cpu intensive بل تكون io intensive هذه العائلة تسمى asyncio أو event driven.

وأيضا نتعامل في مع استعلامات لقواعد بيانات تعيد عدد كبير من النتائج. لنفرض أن لدي قاعدة بيانات فيها سجلات مليون طالب وأريد جلبها وعمل شيء معين عليها وليس من المنطق وضع المليون سجل في الذاكرة رام كلهم دفعة واحدة. توفر قواعد البيانات شيء يسمى cursor وتوفر لغات البرمجيات أدوات للتعامل مع تدفق سيالات البيانات التي تأتي تباعا تسمى generators. أو لنفرض أن لدي ملف نصي به مليون سطر وأريد أقصر أو أطول سطر في الملف. لا داع لرفع الملف كله في الذاكرة رام. كل ما يلزمني هو سطر واحد وكلما انتقلت للسطر التالي نسيت الذي قبله.

مع الأسف الكثير من المبرمجين لا يحسنون التعامل هذه المفاهيم وكثيرا ما يضعون الكثير من البيانات في الذاكرة رام أو يضيعون الموارد بحجز عدد كبير جدا من العمليات processes لم يكن لها داع أو مبرر. لذا صممت تمرين أوضح في هذه المفاهيم.

التمرين

من فترة نشرت "تمرين" بلغة جافاسكربت أو asyncio في بايثون 3. لدينا متوالية معلينة ولنقل متوالية فبوناتشي التي تبدأ ب 1 ثم 1 ثم مجموع آخر رقمين وهي لا تنتهي. هكذا 1 ثم 1 ثم 2 ثم 3 ثم 5 ثم 8 ثم 13 ...إلخ نريد دالة تعيد أرقام متسلسلة فبوناتشي واحدا واحدا لكنه ينتظر 10 ميليثانية بين الرقم والرقم اللي بعده (في محاكاة لكونه يرجع من استدعاء بعيد API call).  ونريد عمل دالة تعيدها في صورة حزم bulks من حجم معين مثلا حجمها 10 عناصر. يعني تعيد أول 10 عناصر ثم ثاني 10 عناصر في المتسلسلة ثم اللي بعدهم ثم اللي بعدها ويجب أن تقبل أي متسلسلة نعطيها إياها (وليس بالضرورة فبوناتشي). ثم نريد عمل برنامج يطبع أول ثلاث حزم. 

المطلوب تكون فبوناشي دالة منفصلة حتى نستبدلها بمتوالية أخرى مثلا تعيد 1 ثم 2 مكرر مرتين ثم 3 مكرر 3 مرات ثم 4 مكرر 4 مرات هكذا 1, 2, 2 , 3,3,3 , 4,4,4,4 ... وطبعا نريد 10 ميلي-ثانية بين كل رقمين.

جميع المحددات هنا مثل ال 10 ميلي-ثانية وال 10 عناصر وال 3 حزم يجب أن تكون متغيرات يمكن تمريرها sleep_ms و bulk_size و iteration_count

الحل المطلوب يجب أن لا يبتلع الرام ولا يتسبب في فيضان stack overflow

الهدف من التمرين

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

بعض المشاكل الشائعة

تسلسل التنفيذ

من عمل في الواجهة الأمامية لا بد وأنه يذكر مشكلة تشبه هذه

  • يقوم بعملية تأخذ وقت (اطلب مورد ما لا-تزامني مثلا طلب AJAX أو رفع أو تنزيل صورة)
  • يعمل شيء على هذا المورد قبل أن يجهز فيحصل خطأ
مثلا 

var user=null;
$.ajax({
  dataType: "json",
  url: "/user/123",
  success: function(data) {
    user=data.user;
  },
});
console.log(user.name);

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

الفيض overflow

الكثير من الناس متعودين على كتابة فبوناتشي بطريقة الاستدعاء الذاتي. يعمل دالة تأخذ رقم الذي هو ترتيب العدد المطلوب وتعيد نتيجة جمعه من الاثنين اللي قبله.

function fibonacci(n) {
    if (n<=2) return 1;
    return fibonacci(n-1)+fibonacci(n-2);
}

لكن هذا يؤدي إلى فيض مثلا عند حساب fibonacci للعدد 100 فإننا نستدعيها لل 99 و 98 وهما بدورهما يستدعيان 98 و 97 وأيضا 97 و 96. كلما دخلنا في دالة فإنها تعمل تحجز مساحة من المكدس stack push (تحفظ كل المتغيرات المحلية وتحجز مكان للقيمة المعادة وعنوان العودة) وكلما خرجنا من دالة فإنها تحرر تلك المساحة وتعيد القيمة. لكن ما يحصل في هكذا دالة أن الدالة التي استدعيناها هي الأخرى تستدعي ولما تعود فإنها لا تحررها بل تنتظر عودة الدالة على الطرف الآخر من عملية الجمع. أي أن تحرير المكدس يتأخر ويظل يتراكم حتى يبتلع كل الذاكرة.

أحد الحلول لتجنب ذلك هي التذكر memoization بمعنى أن الدالة الأولى تحسب معها كل ما قبلها وتتذكره فلما نأتي للطرف الآخر في عملية الجمع تكون النتيجة محسوبة مسبقا. لكن هذا الحل غير عملي لأنها متوالية لا نهائية وأيضا هذه الدالة لا تحتاج إلا آخر رقمين كي تجمعمها يعني لما توصل لل 100 يلزمك 99 و 98 ولما تطلع إلى 101 يلزمك 100 و 99 ولا يلزمك تذكر الأرقام من 1 إلى 98 كلها مساحة مهدورة لا داع لتذكرها. الفرق جوهري وكبير لأن الاحتفاظ برقمين يعتبر ال O هو 1 في ال space complexity أما الاحتفاظ بكل ما سبق يعتبر O هو n وشتان بينهما.

مفاهيم مرتبطة بالحل

المولدات Generators


لو فتحنا جافاسكربت تفاعلية (سواء نود أو في المتصفح) وكتبنا الأسطر

> let a=1;
> let b=1;
> [a, b] = [b, a+b];
[ 1, 2 ]
> [a, b] = [b, a+b];
[ 2, 3 ]
> [a, b] = [b, a+b];
[ 3, 5 ]
> [a, b] = [b, a+b];
[ 5, 8 ]
> [a, b] = [b, a+b];
[ 8, 13 ]
لاحظ أن a تمثل حالة بينية و b تمثل الرقم التالي في متوالية فبوناشي ولا يوجد استهلاك غير مبرر للذاكرة رام. لكن كيف نصيغ هذه المتوالية اللانهائية في شكل دالة؟ الجواب باستعمال المولدات generators وهي دوال تعرف عبر وضع علامة نجمة على اسمها وتستخدم yield مكان return لإعادة كل قيمة جديدة. هي لا تعيد قائمة list ولا منظومة array لكن ما تعيده هو كائن يتصرف بطريقة مشابهة هي أنه iterable أي يمكن عمل حلقة تكرارية تدور على محتوياته. لكن الجميل فيها أنه لا يحتفظ بها كلها في الذاكرة. فكلما يعيد رقم ينسى الذي قبله.

> function *fibonacci() {
  let a=1;
  let b=1;
  yield a;
  yield b;
  while(true) {
    [a, b] = [b, a+b];
    yield b;
  }
}
> let gen=fibonacci();
> gen.next()
{ value: 1, done: false }
> gen.next()
{ value: 1, done: false }
> gen.next()
{ value: 2, done: false }
> gen.next()
{ value: 3, done: false }
> gen.next()
{ value: 5, done: false }
> gen.next()
{ value: 8, done: false }
> gen.next()
{ value: 13, done: false }
> gen.next()
{ value: 21, done: false }
> gen.next()
{ value: 34, done: false }


في بايثون نفس المفهوم موجود
def fibonacci():
  a=1
  b=1
  yield a
  yield b
  while(True):
    a, b = b, a+b
    yield b
# interactive shell
>>> gen=fibonacci()
>>> next(gen)
1
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
3
>>> next(gen)
5
>>> next(gen)
8
>>> next(gen)
13
>>> next(gen)
21
>>> next(gen)
34
البعض قد يظن أن موضوع yield هو مجرد "سكر صياغة". بالعكس تماما الموضوع جدا مهم حيث أننا داخل دالة والدالة لا تزال تعمل. تعيد قيمة وتظل تعمل دون استدعاء جديد. وتعمل بتسلسل سهل الفهم. لا يوجد فيضان stack overflow ولا يوجد استهلاك غير مبرر للذاكرة.

الدوال اللامتزامنة async function

لنعد إلى مثال ال ajax ولنتخيل أنه عوضا عن استقبال callback اسمه success والدخول في جحيم callback hell لنتخيل أنه يعيد كائن من نوع وعد Promise وهو يستقر على رد الخادوم عندما يصل. هذا كود وهمي بلغة جافاسكربت لتوضيح المفهوم

async function main() {
  let user = null;
  user = await get_ajax_user_promise(123);
  console.log(user.name);
}
main();

الفارق هنا أننا نبرمج بطريقة متسلسلة مفهومة السطر الأول ثم السطر الثاني وهكذا. ال control flow البسيط لا يشتت أفكارنا بين ما سيحصل لما يأتي الرد من الخادوم للاستدعاء ajax وتنفيذ السطر التالي قبل وصول الرد كما كان الحال في جحيم ال callback hell. تخيل معي أننا نريد تنفيذ شيء ما do_something بعد انتظار عدد من أجزاء الثانية ms عبر  setTimeout التي تنفذ ال callback بعد الوقت المطلوب. ونريد ذلك 3 مرات. سينتج معنا جحيم callback hell يسميه البعض سرب الحمام. 
setTimeout(function() {
  do_something1();
  setTimeout(function() {
    do_something2();
    setTimeout(function() {
       do_something3();
    }, ms);
  }, ms);
}, ms);
بالمقابل لو استعملنا الدوال اللامتزامنة لأصبح الكود متسلسلا ومسطحا وسهل الفهم دون الدخول في مستويات تعقيد nesting level أعمق.
function sleep(ms) {
  return new Promise(function(resolve){
    setTimeout(resolve, ms);
  });
}
function do_something(a) {
  console.log("got ", a);
}
(async function() {
  let ms=100;
  await sleep(ms);
  do_something(1);
  await sleep(ms);
  do_something(2);
  await sleep(ms);
  do_something(3);
})();
وكون الكود متسلسل ومسطح في دالة واحدة فإنه يمكن استعمال الحلقات التكرارية وغيرها. في بايثون 3 هذا المفهوم موجود أيضا
#! /usr/bin/python3
import asyncio

def do_something(a): print("got", a)

async def main():
  ms = 0.01
  await asyncio.sleep(ms)
  do_something(1);
  await asyncio.sleep(ms)
  do_something(2);
  await asyncio.sleep(ms)
  do_something(3);

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
ربما لمن قدم من بايثون 2 قد يرى هذا تعقيد لا داع له إذ أن الدالة ستعمل تماما لو استعملنا time.sleep وبدون await. الفرق الجوهري هنا أن await تنقل التحكم من الدالة التي نحن فيها وتعيد التحكم للحلقة الرئيسية event loop وهي بذلك تحررها كي تفعل أشياء أخرى في هذا الوقت بعكس sleep التي ستحجب البرنامج block في هذه الأثناء ولا تتيحه كي يفعل أشياء أخرى. لنتخل أن لدينا موظف في دائرة الأحوال استقبل أول مراجع وطلب منه تعبئة النموذج في الوقت الذي يقوم فيه المراجع بالبحث عن قلم وملئ النموذج يكون الموظف يعمل مع مراجع آخر مثلا ويطبع له الوثيقة وبينما الطابعة تطبع الوثيقة يأتي مراجع ثالث ...إلخ وقارنه مع موظف ينتظر المراجع الاول حتى يمكمل كامل معاملته ثم يبدأ بالمراجع الثاني وهكذا. هذا الفرق بين الموظف السوبرمان الذي والموظف الإنسان الطبيعي هو الفرق بين الدوال التقلدية والدوال اللامتزامنة. وأهم تطبيق لها هو عمل خوادم تعالج أكثر من طلب في نفس الوقت. بينما أول طلب يعمل استدعاء لقاعدة البيانات وقبل أن تعود النتائج يتعامل الخادم مع طلب ثاني وهو ما يلبث أن يطلب فتح ملف على القرص وبينما الملف يتم قراءته يتعامل الخادم مع طلب ثالث وهكذا.

الحل

إليكم الحل
كنت أود أن أتحدث عن كيف تتحقق من أن الكود يعمل بالتوازي ويمكنه بالفعل تشغيل أكثر من طلب معا. وكيفية استعمال ال contextlib.asynccontextmanager في بايثون. وما هي عيوب الدوالة اللامتزامنة وكيف تجنب هذه العيوب. لكني سأترك كل هذا لمقالة لأخرى لأن هذه المقالة صارت أطول مما كنت أتوقع.


ليست هناك تعليقات:

إرسال تعليق