الروتينات المساعدة (Coroutines) في لغة Kotlin

من موسوعة حسوب
< Kotlin
مراجعة 03:07، 26 مارس 2018 بواسطة Nourtam (نقاش | مساهمات) (إضافة فقرات)
اذهب إلى التنقل اذهب إلى البحث

ملاحظة: ما تزال الروتينات المساعدة تجريبيةً في الإصدار Kotlin 1.1.

الروتينات المساعدة (Coroutines)

تُنشِئ بعض الواجهات API عملياتٍ طويلة التنفيذ (مثل عمليات الدخل والخرج للشبكة أو الدخل والخرج للملفات أو الأعمال المُعقَّدة في الوحدتين CPU أو GPU)، وهذا سيجبر المُستدعي على تجميد تنفيذه (block) إلى حين إتمامها، ولذا توفِّر الروتينات المساعدة طريقةً لمنع تجميد الخيوط (threads) وإجراء عمليةٍ أقلَّ كلفةً (بالنسبة لزمن المعالجة) وأكثر قابليةً للتحكم، ألا وهي الإيقاف المؤقت (suspension) للروتينات المساعدة.

تبسّط الروتينات المساعدة البرمجة غير المتزامنة (asynchronous programming) من خلال وضع التعقيدات البرمجية في مكتباتٍ، ويُعبَّر عن منطق البرنامج تسلسليًا (دون أخذ تعقيدات التنفيذ المتزامن في الحسبان) في الروتين المساعد وستقوم المكتبة بمهمة التنفيذ غير المتزامن، إذ تُغلِّف الأجزاء المرتبطة من شيفرة المستخدم مع دوال ردّ النداء (callbacks) وتصادق على أيّ أحداثٍ متعلِّقة، كما وتجدول التنفيذ ما بين الخيوط (threads) المختلفة (أو ما بين آلاتٍ مختلفة)، وتبقى الشيفرة بالنهاية بسيطةً كما لو أنها مُنفَّذة تسلسليًا.

وقد يُعرَّف استخدام عددٍ من الآليات غير التزامنيّة (asynchronous mechanisms) المتاحة في لغات البرمجة الأخرى كمكتباتٍ بالاعتماد على الروتينات المساعدة في Kotlin، مثل async/await في لغتيّ C#‎ و ECMAScript ، أو channels و select في لغة Go، وgenerators/yield في لغتيّ C#‎ و Python.

التجميد (Blocking) والإيقاف المؤقت (Suspending)

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

دوال الإيقاف المؤقت (Suspending Functions)

يحدث الإيقاف المؤقت عبر استدعاء دوالٍ مُحدَّدةٍ بالكلمة المفتاحية suspend كما في الشيفرة:

suspend fun doSomething(foo: Foo): Bar {
    ...
}

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

تقبل دوال الإيقاف المؤقت عددًا من المتحولات وقد تعيد قيمًا كما هو الحال في الدوال الأخرى، ولكن لا يمكن استدعاؤها إلا من الروتينات المساعدة أو دالة إيقافٍ مؤقتٍ أخرى أو قيمةٍ مباشرةٍ للدالة (inlined) ضمن أيِّ مما سبق.

للبدء بأيّ روتينٍ مساعدٍ يجب توافر دالة إيقافٍ مؤقتٍ واحدةٍ على الأقل (وغالبًا ما تكون lambda)، لنأخذ مثلًا الدالتين async/await، فإن كانت الدالة المُبسّطة async()‎ (من المكتبة kotlinx.coroutines) بالشكل الآتي:

fun <T> async(block: suspend () -> T)

وهي دالة نظاميّة (regular) وليست دالة إيقافٍ مؤقتٍ، أما المتحوّل الوسيط block فله نوع الدالة المُحدَّدة بالكلمة المفتاحيّة suspend (أي suspend () -> T)، وبالتالي فإنّه عند تمرير lambda إلى الدالةasync()‎ فإنها ستكون lambda الإيقاف المؤقت (suspending lambda) ويمكن عندئذٍ استدعاء دالة الإيقاف المؤقت منها بالشكل:

async {
    doSomething(foo)
    ...
}

ملاحظة: لا يُسمَح باستخدام دوال التأجيل كأصنافٍ عليا (supertypes) ولا يتوفَّر الدعم كذلك لدوال الإيقاف المؤقت المجهولة (anonymous suspending functions). أمّا الدالة await()‎ فقد تكون دالة إيقافٍ مؤقتٍ (ويمكن بالتالي استدعاؤها من داخل الدالة async) والتي ستوقف الروتين المساعد مؤقتًا إلى حين القيام ببعض العمليات وإعادة نتيجتها، أي:

async {
    ...
    val result = computation.await()
    ...
}

المزيد عن آلية عمل للدالتين async/await في kotlinx.coroutines ويُلاحَظ أنّه من غير الممكن استدعاء دالتي الإيقاف المؤقت await()‎ و doSomething()‎ من القيم الحرفيّة للدوال (function literals) غير المباشرة داخل بنية دالة الإيقاف المؤقت ولا من الدالة النظامية (مثل main()‎) كما في الشيفرة:

fun main(args: Array<String>) {
    doSomething() // من الخطأ استدعاء دوال التأخير من السياق خارج الروتينات المساعدة
    
    async { 
        ...
        computations.forEach { // دالة مباشرى
                               // أيضًا مباشرة lambda
            it.await() // شيفرة صحيحة
        }
            
        thread { // ليست دالة مباشرة
                 // غير مباشرة أيضًا lambda
            doSomething() // خطأ
        }
    }
}

وقد تكون دوال الإيقاف المؤقت وهميّةً (virtual) ويجب عند إعادة تعريفها وجود المُحدِّد suspend كما في الشيفرة:

interface Base {
    suspend fun foo()
}

class Derived: Base {
    override suspend fun foo() { ... }
}

التوصيف ‎@RestrictsSuspension

قد تُحدَّد الدوال الإضافيّة (extension functions) وتعابير lambdas بالمٌحدِّد suspend كما في الدوال النظاميّة (regular)، وهذا يسمح بإنشاء لغاتٍ مُخصَّصة المجال (DSL) وواجهات API قابلةٍ للتوسيع (extend) من المستخدِم، وقد يحتاج -ببعض الحالات- مُنشِئ المكتبة إلى منع المستخدِم من إضافة أيّة طريقة للإيقاف المؤقت للروتين المساعد، وذلك باستخدام التوصيف ‎@RestrictsSuspension للصنف المُستقبِل (receiver class) أو الواجهة R وحينها يجب تعميم (delegate) كل إضافات الإيقاف المؤقت إلى عناصر في الصنف R أو إلى إضافاتٍ له، ولأنه من غير الممكن تعميم الإضافات لبعضها الآخر بشكلٍ غير منتهٍ (ولن ينتهي البرنامج عندئذٍ) فهذا سيضمن حدوث كلّ عمليات الإيقاف المؤقت أثناء استدعاء عناصر R التي يتحكّم بها مُنشِئ المكتبة بشكل تامّ.

وهذا جيّد في حالاتٍ نادرةٍ عندما يُعالج كل إيقافٍ مؤقتٍ بطريقةٍ مختلفةٍ في المكتبة، فعند تعريف استخدام (implement) المُولِّدات مثلًا باستخدام الدالة buildSequence()‎ فيجب التأكد من أن أيّ استدعاءٍ للإيقاف المؤقت في الروتين المساعد سينتهي باستدعاء الدالة yield()‎ أو yieldAll()‎ وليس أيّ دالة أخرى، ولهذا يُوصَف الصنف SequenceBuilder بالتوصيف ‎@RestrictsSuspension كما يلي:

@RestrictsSuspension
public abstract class SequenceBuilder<in T> {
    ...
}

الواجهات القياسية (Standard APIs)

تشمل الروتينات المساعدة ثلاثة مكوّنات رئيسيّة:

- دعم اللغة (دوال الإيقاف المؤقت كما سبق شرحها)

-واجهة API محورية (core) منخفضة المستوى (low-level) في مكتبة Kotlin القياسية

- واجهات API عالية المستوى (high-level) والتي تُستخدَم مباشرة في شيفرة المستخدم

الواجهة API منخفضة المستوى (low-level) في kotlin.coroutines

وهي محدودةٌ نسبيًا ولا يُسمَح باستخدامها لأكثر من إنشاء المكتبات عالية المستوى، وتتألف من حزمتين رئيسيّتين:

  • kotlin.coroutines.experimental والتي تحتوي على الأنواع الرئيسيّة والأساسية (primitives) مثل:
    • createCoroutine()‎
    • startCoroutine()‎
    • suspendCoroutine()‎
  • kotlin.coroutines.experimental.intrinsics ببعض العناصر الجوهرية بمستوى أقل مثل suspendCoroutineOrReturn.

الواجهات API المُولِّدة في kotlin.coroutines

فيما يلي الدوال الوحيدة التي تكون بمستوى التطبيقات (application-level) في kotlin.coroutines.experimental هي:

  • buildSequence()‎
  • buildIterator()‎

وهي مُدمَجةٌ داخل kotlin-stdlib لأنها متعلَّقة بالمتتاليات (sequences)، إذ تعرِّف هذه الدوال (ويمكن الاعتماد على buildSequence()‎ وحدها هنا) المُولِّدات (generators)؛ أي أنّها تزوّد طريقةً لبناء متتاليةٍ كسولةٍ بكلفةٍ زهيدةٍ، مثل:

val fibonacciSeq = buildSequence {
    var a = 0
    var b = 1

    yield(1)

    while (true) {
        yield(a + b)

        val tmp = a + b
        a = b
        b = tmp
    }
}

تولِّد الشيفرة السابقة متتالية Fibonacci (قد تكون غير منتهيةٍ) من النوع الكسول، وذلك من خلال إنشاء روتينٍ مساعدٍ يُنتِج الأعداد التتابعية في المتتالية عبر استدعاء الدالة yield()‎، حيث أنّ في مثل هذه المتتالية ستنفِّذ كلُّ خطوةٍ جزءًا آخر من الروتين المساعد يؤدي لتوليد العدد التالي، ولذلك الحصول على أيّ قائمةٍ منتهيةٍ من أعداد هذه المتتالية، أي ستعطي الصيغة fibonacciSeq.take(8).toList()‎ الأعداد ‎[1, 1, 2, 3, 5, 8, 13, 21]‎ ، وتكون تكلفة الروتينات المساعدة رخصيةً مما يجعل استخدامها عمليًا. ولتتبُّع ما تقوم به الروتينات المساعدة سنضع بعض عبارات الخرج التوضيحيّة داخل استدعاء الدالة buildSequence()‎ كما في الشيفرة:

val lazySeq = buildSequence {
    print("START ")
    for (i in 1..5) {
        yield(i)
        print("STEP ")
    }
    print("END")
}

// طباعة العناصر الثلاثة الأولى من المتتالية
lazySeq.take(3).forEach { print("$it ") }

سينتج عن تنفيذ الشيفرة السابقة طباعة العناصر الثلاثة الأولى، حيث ستتداخل الأعداد مع عبارة "STEP" في الخرج الناتج عن الحلقة المُولِّدة، وهذا يعني أنّ العملية الحسابيّة كسولةٌ حقًا، فلطباعة العدد 1 يجب التنفيذ حتى الاستدعاء الأول فقط من الدالة yield(i)‎ وطباعة "START" أثناء ذلك، ولطباعة العدد 2 يجب الاستمرار حتى الوصول إلى الاستدعاء التالي للدالة yield(i)‎ وطباعة "STEP" وسيُطبَع العدد 3 بنفس الطريقة أيضًا، ولن تُطبع عبارة "STEP" التالية (أو"END") لعدم طلب أيّ قيمٍ إضافيةٍ في المتتالية، أمّا للحصول على مجموعةٍ (collection) (أو متتاليةٍ) من القيم كلها دفعةً واحدة، فتُستخدَم الدالة yieldAll()‎ :

val lazySeq = buildSequence {
    yield(0)
    yieldAll(1..10) 
}

lazySeq.forEach { print("$it ") }

وتعمل الدالة buildIterator()‎ بطريقةٍ مماثلةٍ للدالة buildSequence()‎ ولكنها تعيد تكرارًا (iterator) كسولًا. وقد يُضاف منطقٌ إنتاجيٌّ إلى buildSequence()‎ من خلال كتابة إضافاتٍ للإيقاف المؤقت إلى الصنف SequenceBuilder (والذي يحمل التوصيف ‎@RestrictsSuspension المشروح سابقًا)، بالشكل:

suspend fun SequenceBuilder<Int>.yieldIfOdd(x: Int) {
    if (x % 2 != 0) yield(x)
}

val lazySeq = buildSequence {
    for (i in 1..10) yieldIfOdd(i)
}

مصادر