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

من موسوعة حسوب

ملاحظة: ما تزال الروتينات المساعدة تجريبيةً في الإصدار 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> {
    ...
}

العلميات الداخليّة (Inner Workings) في الروتينات المساعدة

يُعرَّف استخدام (implement) الروتينات المساعدة كليًا عبر تقنيةٍ مُخصَّصةٍ أثناء الترجمة (لا تتطلَّب أي دعمٍ من VM أو من طرف نظام التشغيل)، إذ يكون الإيقاف المؤقت (suspension) أثناء عملية تحويل الشيفرة (code transformation)، حيث تُحوَّلُ كلُّ دالة إيقافٍ مؤقتٍ إلى آلة حالةٍ (state machine) حيث ستتوافق الحالات فيها مع استدعاءات الإيقاف المؤقت، وتُخزَّن الحالة التالية -قبل القيام بالإيقاف المؤقت- في حقلٍ من صنفٍ يُولِّده المترجم خصّيصًا لذلك بالإضافة إلى تخزين كل المتحولات المحليّة (local variables) اللازمة، وعند استئناف ذلك الروتين ستُسترجَع تلك المتحولات وتتابع حالةُ الآلة بدءًا من الحالة التالية لعملية الإيقاف المؤقت مباشرةً.

كما ويمكن تخزين الروتين المساعد وتمريره ككائنٍ (object) يحفظ حالاته المؤقتة ومتحولاته المحليّة، إذ تكون مثل هذه الكائنات من النوع Continuation ، وتخضع كلُّ عمليات تحويل الشيفرات (code transformation) في Kotlin لنمط تمرير كائنات الاستمرار (Continuation-passing style)، لذلك تتطلَّب دوال الإيقاف المؤقت وجود متحولٍ إضافيٍّ من النوع Continuation.

الحالة التجريبيّة (Experimental Status) للروتينات المساعدة

إن تصميم الروتينات المساعدة ما يزال تجريبيًا في Kotlin أي أنّه قد يتغيّر في الإصدارات القادمة منها، لذلك سينتُج تحذيرٌ عند ترجمة الروتينات المساعدة في الإصدار Kotlin 1.1 وما يليه، وهو بالصيغة: The feature "coroutines" is experimental (أي أنّ الروتينات المساعدة تجريبيّة)، ولتجاوز ذلك التحذير يجب تخصيص علم opt-in.

وبما أن الروتينات المساعدة تجريبيّةٌ تُوضَع الواجهات API المتعلقة بها في المكتبة القياسية للغة Kotlin تحديدًا في الحزمة kotlin.coroutines.experimental ،إذ ستُنقَل عند إنهاء تصميمها واختبارها إلى الحزمة المُخصَّصة لها kotlin.coroutines ، وستبقى الحزمة السابقة المؤقتة متاحةً لضمان عدم حدوث المشاكل في التوافقية (compatibility).

ملاحظة: ننصح مُنشِئي المكتبات باتباع الأسلوب ذاته، وذلك بإضافة اللاحقة "experimental" (مثل: com.example.experimental) إلى الحزم المتعلِّقة بالواجهات API المعتمِدة على الروتينات المساعدة كي تبقى المكتبات متوافقةً، وعند إنهاء الواجهة النهائية وإطلاقها، تُجرَى الخطوات الآتية:

  1. نسخ كلِّ الواجهات إلى الحزمة com.example (بدون اللاحقة experimental)
  2. الإبقاء على الحزمة التجريبية لبعض الوقت لضمان أمر التوافقيّة.

وهذا سيسهِّل كثيرًا عمليات الانتقال للمستخدمين.

الواجهات القياسية (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)
}

بعض الواجهات الأخرى عالية المستوى (high-level) في kotlinx.coroutines

تُتاح الواجهات الأساسيّة (core API) المرتبطة فقط بالروتينات المساعدة من المكتبة القياسيّة في Kotlin، وهذا يشمل الأساسيّات الجوهريّة (core primitives) والواجهات (interfaces) التي تستخدمها كلُّ المكتبات المعتمدة على الروتينات المساعدة.

وتُوضَع الواجهات (بمستوى التطبيق [application-level]) والمعتمِدة على الروتينات المساعدة كمكتبةٍ مستقلةٍ وهي kotlinx.coroutines وتشمل:

  • البرمجة غير التزامنيّة (asynchronous) وغير التابعة لمنصّة عمل مُحدَّدة (platform) باستخدام kotlinx-coroutines-core:
    • تحتوي هذه الوحدة (module) على قنواتٍ مماثلةٍ للغة Go والتي تدعم select وبعض الأساسيّات (primitives) الأخرى.
  • واجهات API المعتمدة على CompletableFuture من بيئة JDK 8 وهي: kotlinx-coroutines-jdk8
  • عمليات الدخل والخرج غير التجميديّة (NIO) بالاعتماد على الواجهات API من بيئة JDK 7 وما يليها، وهي: kotlinx-coroutines-nio
  • الدعم لكلٍ من Swing (عبر kotlinx-coroutines-swing) و JavaFx (عبر kotlinx-coroutines-javafx)
  • الدعم المتوفر لبيئة RxJava عبر kotlinx-coroutines-rx.

وتُعدُّ هذه المكتبات واجهاتٍ مساعدةً تسهِّل القيام بالمهام المتكررة، ومثالًا شاملًا لكيفية بناء المكتبات المعتمِدة على الروتينات المساعدة.

مصادر