همانطور که همه ما میدانیم، تمام Component ها در یک اپلیکیشن اندرویدی، به صورت پیشفرض از یک Thread یکسان برای اجرا شدن استفاده میکنند که Main-Thread نامیده میشود.
از آنجایی که اپلیکیشن Single-Thread هست، این Main-Thread وظایف زیادی برای اجرا کردن دارد از جمله:
به دلیل این Single-Thread بودن اپلیکیشن، این وظیفه ما است تا اطمینان حاصل کنیم که Main-Thread را بلاک نکردهایم.
برای بهبود این وضعیت، ما به یک رویکرد قویتر به نام Multi-Thread در بعضی از موارد نیازمندیم که این موارد شامل:
این تسک های کوچک و بزرگ نیاز به زمان برای تکمیل شدن دارند، بنابراین ما باید این عملیات را در Thread غیر از Main-Thread انجام دهیم، تا باعث Freeze شدن اپلیکیشن و غیر قابل استفاده شدن آن نشود.
در اندروید Multithreading بخاطر مکانیزم Callback همیشه یک چالش محسوب میشد. چرا چالش؟ چون Switch کردن بین Thread ها و Resume کردن تسکها کار سادهای نبود. این کار در ابتدا با AsyncTask شروع شد و بعد با Rx-Java ادامه پیدا کرد و الان Coroutines پلنگ :) این ماجراست.
در این مقاله به صورت مشخص میخواهیم در مورد Multithreading با استفاده از Coroutines در کاتلین صحبت کنیم و موارد مربوط به آن را بیان میکنیم.
تعریف Coroutine با توجه به داکیومنتی که کاتلین ارائه داده، به این صورت است:
کوروتین را میتوان به عنوان یک Lightweight Thread در نظر گرفت. به مشابه یک Thread، کوروتینها میتوانند به صورت موازی (Parallel) اجرا شوند، منتظر یکدیگر باشند و با هم ارتباط برقرار کنند. مهمترین تفاوت بین Coroutine و Thread این است که Coroutine بسیار Cheap و Free هست. یعنی ما میتوانیم هزاران Coroutine را بدون محدودیت و با Performance بالا ایجاد و اجرا کنیم، اما در Thread ها این قابلیت دارای محدودیت است و ایجاد هزاران Thread یک چالش جدی برای حتی Device های با Config بالا است.
در نتیجه ما میتوانیم بگوییم که Coroutine ها چیزی نیستند جز یک Lightweight-Thread و برای ما یک روش ساده برای برنامهنویسی Synchronous و Asynchronous را فراهم میکنند.
کوروتینها اجازه میدهند که اجرای یک Task به حالت Suspend در آید و بعداً در نقطهای در آینده Resumed شود، که این حالت بهترین حالت برای اجرای Non-Blocking Operations در مورد Multithreading هست. منظور ازSuspend شدن یک Task به این معناست که تا زمان به پایان رسیدن آن Task منتظر نتیجه میماند. Coroutine از نسخه 1.1 به کاتلین اضافه شد.
به صورت خلاصه مزایای استفاده از Coroutine ها شامل این موارد میشود:
کوروتین را میتوان به ۴ قسمت اصلی تقسیم کرد:
در ادامه اجزای یه کوروتین را به صورت کامل توضیح خواهیم داد.
در بدنه کوروتین میتوان هم از Regular function و هم Suspending function استفاده کرد. یک suspend function چیزی نیست جز یک Function که قابلیت Pause و Resume شدن را دارد. یعنی میتوانیم یک Task طولانی (Long-Running Operations) را بدون بلاک کردن Thread اجرا کنیم و منتظر کامل شدن آن Task بمانیم.
خب اینجا سوالی بوجود میآید که مهم است:
با توجه به توضیح قبل، suspend function ها Thread را Lock نمیکنند، آیا این روش درست است که ما فقط یک suspend function تعریف کنیم و در آن یک Task سنگین خود را اجرا کنیم؟ پاسخ منفی است. با توجه به تعریف داکیومنت رسمی:
یک suspend function میتواند اجرای کد را بدون مسدود ساختن Thread جاری و با فراخوانی suspend function های دیگر، suspend سازد.
اما همچنان به Dispatchers که suspend function ها را با استفاده از آن اجرا میکنیم نیاز داریم.
Syntax یک suspending function مشابه syntax یک function معمولی هست، به علاوه یک کلمه کلیدی suspend در ابتدای آن:
یک suspend function نیز syntax مشابه یک function معمولی دراد، به علاوه یک کلمه کلیدی suspend در ابتدای آن:
suspend fun sample() { }
یک قانون مهم در مورد suspend function این هست که باید از یک Coroutine یا یک suspend function دیگر Call شود.
به لطف یادآوری اندروید استودیو، با توجه به آیکون فلش در سمت چپ پنل، متوجه میشویم که این یک suspend function است. زمانی که delay(1_000) را در کوروتین فرا میخوانیم، اجرا را به مدت 1 ثانیه بدون مسدودسازی thread تعلیق میکند و سپس به کوروتین بازمیگردد تا تابع ()doSomething را اجرا کند.
حال با توجه به دانشی که در مورد suspend function بدست آوردیم، ترتیب print ها را حدس بزنید؟
در داخل یک Activity دو suspend function ایجاد کردیم که با کمی تاخیر اجرا میشوند و در داخل فانکشن OnCreate آنها را فراخوانی کردیم.
از آنجایی که یک suspend function نمیتواند به صورت مستقیم داخل یک normal function فراخوانی شود، با استفاده از GlobalScope.launch یک coroutine را ایجاد و launch کردیم.
در کد فوق delay چیزی نیست جز یک suspend function دیگر که برای یک زمان معین بدون block کردن یک thread، آن را به pause میکند و سپس resume میکند.
خروجی کد به این صورت است:
همانطور که مشاهده میکنید ابتدا کد داخل متد onCreate و خارج از GlobalScope اجرا میشود، سپس فانکشن sampleOne و sampleTwo به صورت sequentially اجرا میشوند. به دلیل وجود delay داخل این فانکشنها، و ماهیت suspend function، منتظر میماند تا کار فانکشن تمام شود. به محض کامل شدن عملیات، خط بعد از فانکشن ها فراخوانی میشود و در نهایت زمان سپری شده چاپ میشود.
نتیجه ای که میتوانیم از این مثال بگیریم این است که suspend function ها اجرای یک فرایند را suspend میکنند تا زمانی که task تکمیل شود و مجدد فرآیند را resume میکنند.
همه Coroutine ها درون یک Scope اجرا میشوند که یک CoroutineContext به عنوان پارامتر میگیرد. یک Scope با استفاده از builder های launch یا async می تواند Coroutine خود را Track کند. Track کردن یک Coroutine توسط CoroutineScope یعنی چه؟ یعنی این امکان را فراهم میکند تا یک Coroutine را در هر نقطه از زمان cancel کنید.
چندین Scope وجود دارد که میتوان مورد استفاده قرار داد:
این Scope با Custom CoroutineContext ساخته میشود. برای تعریف آن از یک Dispatchers، یک Parent-Job و Exception Handler استفاده میشود.
یک Scope برای کامپوننتهای UI ایجاد میکند. این Scope روی Main-Thread با SupervisorJob() اجرا میشود، یعنی failed شدن یکی از child های آن، تاثیری روی قسمت های دیگر نخواهد داشت.
این Scope به هیچ Job محدود نمیشود. از آن برای اجرای کوروتینهای سطح بالا استفاده میشود که روی Application Lifecycle عمل میکنند. یعنی این نوع Scope تا زمانی که Application زنده است، به صورت دائمی به کار خود ادامه میدهد و cancel نمیشود.
کد زیر نشان میدهد چگونه می توان Scope را ایجاد کرد و کوروتین را launch کرد و در زمان destroy شدن Component، آن را cancel کرد.
ابتدا با Delegate Design Pattern یک Scope روی IO-Thread تعریف کردیم و SupervisorJob به آن اختصاص دادیم.
اساسا، CoroutineScope یک CoroutineContext را به عنوان یک argument می گیرد که در ادامه تمام بخش های یک CoroutineContext را توضیح خواهیم داد.
یک interface است که رفتار یک Coroutine را با استفاده از مجموعه عناصر زیر تعریف میکند:
ا Job: مکانیزمی برای کنترل یک Coroutine است.
ا CoroutineDispatcher : که Thread یک Coroutine را تعیین میکنند.
ا CoroutineExceptionHandler : که Exceptions های رخ داده را Handle میکند.
در ادامه این سه بخش توضیح داده میشود.
در واقع Job یک مکانیزم کنترل Lifecycle برای Coroutine هست. یک Job یک چیز cancellable با یک Lifecycle است که با complete یا cancel یا fail شدن به پایان میرسد.
تعریف Job با توجه به داکیومنت کاتلین عبارت است از:
یک Job را می توان در Parent-Child Hierarchies دسته بندی کرد، که در آن cancel شدن یک Parent منجر به cancel شدن فوری همه Child هایش میشود. fail شدن یک Child با Exception ای به غیر از Cancellation Exception، بلافاصله Parent ها و در نتیجه همه Child های دیگر آن را cancel میکند.
هر Job دارای حالتهای زیر است:
برای دانستن Current State یک Job میتوانیم از Job.isActive استفاده کنیم. گردش تغییر حالتها نیز به صورت زیر است:
هر Coroutine که ما با launch و async ایجاد میکنیم، یک Job object برمیگردانند. که برای آن کوروتین Unique است و Lifecycle آن را مدیریت می کند. به این نوع Job همچنین Normal Job Instance هم میگویند.
با توجه به توضیحات داده شده، گفته شد که اگر یک Normal Job Instance داشته باشیم و داخل یک Child یک Exception بغیر از CancellationException رخ دهد، این اتفاق باعث cancel شدن Parent و به طبع آن تمام Child های آن میشود. برای اینکه ما بتوانیم جلوی این سناریو را بگیریم میتوانیم از SupervisorJob استفاده کنیم.
بر این اساس Childهای یک SupervisorJob می توانند مستقل از یکدیگر fail شوند. fail شدن یا cancel شدن هیچ Child ای باعث fail شدن SupervisorJob نمیشود و بر Child های دیگر آن تأثیر نمیگذارد، بنابراین supervisor میتواند یک Logic برای Handle کردن fail شدن Child های خود داشته باشد.
پس از آشنایی با State های کوروتین، اکنون باید طرز کار سلسله مراتب والد-فرزند را بشناسیم. فرض کنید میخواهیم کدی مانند زیر بنویسیم:
در این حالت، سلسلهمراتب parent-child باید مانند زیر باشد:
میتوانیم parent job را در زمان اجرا به صورت زیر تغییر دهیم:
در این حالت سلسلهمراتب والد-فرزند به صورت زیر خواهد بود:
بر اساس دانش فوق، مفاهیم مهم زیادی وجود دارند که برای کار با یک job باید بدانیم.
اگر یک CancellationException صادر کنید، تنها job زیر childJob1 لغو خواهد شد.
اگر IOException را در یکی از job-های child صادر کنیم، همه job-های مرتبط cancel خواهند شد:
اگر از ()job.cancel استفاده کنیم، job والد شروع به cancel شدن میکند، یعنی وارد حالت Cancelling میشود. پس از اینکه همه job-های فرزند لغو شدند، job والد نیز خود را لغو میکند.
اگر از ()job.cancelChildren استفاده کنیم، job والد همچنان active خواهد بود و همچنان میتوانیم از آن برای اجرای کوروتینهای دیگر بهره بگیریم:
اگر از یک job ساده به عنوان job والد استفاده کنیم، در صورت cancel شدن یکی از job های فرزند با exception به غیر از CancellationException، همه فرزندان cancel میشوند:
اگر از ()SupervisorJob به عنوان job والد استفاده کنیم، شکست یک job فرزند تأثیری روی job-های فرزند دیگر نمیگذارد:
یک Dispatcher مشخص می کند که این operation روی کدام thread باید انجام شود.
در کوروتین Dispatcher همان schedulers در Rx هستند. در اندروید عمدتاً سه Dispatcher داریم:
که در ادامه توضیح هر کدام داده میشود.
این dispatcher امکان اجرای عملیات روی Main-Thread را فراهم میکند و میتواند به صورت مستقیم در داخل CoroutineContext یا از طریق MainScope Factory استفاده شود.
اگر این dispatcher را جایی تعریف کنید که دسترسی به Main-Thread وجود نداشته باشد، با خطای IllegalStateException مواجه میشویم و به معنی آن است که دسترسی به Main-Thread در ClassPath جاری وجود ندارد.
این dispatcher برای کارهای سنگینی که نیازمند CPU هستند طراحی و بهینه سازی شده است و محدود به تعداد core های CPU است، به این معنی که فقط تعداد N تسک، که N = CPU CORE است، میتواند به صورت parallel در این dispatcher اجرا شود.
این نوع dispatcher برای کارهای سبک تر از جنس IO مانند Networking Operations یا Database Operation یا … که نیازمند یک Thread-Pool هستند استفاده میشود. Thread ها داخل این Pool بر حسب تقاضایی که میآید created و shut down میشوند.
به صورت پیش فرض تعداد 64 عدد Thread در این dispatcher تعریف شده است، در نتیجه در این dispatcher نهایتا 64 عدد task به صورت parallel قابل اجراست.
اگر هیچ dispatcher برای coroutine تعیین نشود، به صورت پیش فرض از این dispatcher یا به عبارتی از context مربوط به CoroutineScope یی که از آن launch شده استفاده میکند.
داخل یک Coroutine ما از withContext برای Switch کردن بین Dispatcher های مختلف استفاده میکنیم که یک suspend function است و یک CoroutineContext به عنوان پارامتر دریافت میکند و یک suspending block در اختیار ما قرار میدهد.
شاید برای شما سوال بوجود بیاید که suspending block چیست؟ یعنی اینکه تا زمانی که complete نشده باشد در حالت suspend باقی میماند و در نهایت یک مقدار به عنوان result برمیگرداند.
یک CoroutineBuilder یک extension function ساده است که به وسیله آن میتوان یک coroutine را create و start کرد. از آنجایی که suspend function ها از داخل normal function ها قابل دسترسی نیستند، بنابراین CoroutineBuilder به عنوان پلی بین normal function و suspend function عمل می کند.
لازم به ذکر است که CoroutineBuilder از نوع suspend function نیست، در نتیجه حتی در normal functions نیز می توان به آنها دسترسی داشت.
ایجاد یک Coroutine بسیار ساده است. برای پیاده سازی آن باید از یکی از روش های زیر استفاده کرد:
در ادامه هر یک از این روش ها را توضیح خواهیم داد.
ساده ترین روش برای ساخت یک coroutine فراخوانی launch builder بر روی یک scope مشخص است که یک coroutine جدید را بدون block کردن thread جاری launch میکند و یک reference به job یی که به آن متصل شده است برمیگرداند. هیچ مقدار برگشتی ندارد و برای سناریوهایی که به اصطلاح “fire and forget” هستند مورداستفاده قرار میگیرد. یعنی جایی که ما میخواهیم یک task را روی یک thread جدید اجرا کنیم و نتیجه کار برایمان اهمیتی ندارد.
با async builder ما میتوانیم یک coroutine جدید بسازیم و با فراخوانی await که یک suspend function است منتظر نتیجه کار باشیم. مقدار برگشتی این نوع builder از جنس deferred است، یک چیزی شبیه به future در Java یا promise در JavaScript.
ما نمیتوانیم از async در یک normal function استفاده کنیم، زیرا برای دریافت result باید suspend function یی به نام await را call کند. بنابراین ما معمولاً از launch builder در داخل یک normal function استفاده می کنیم و سپس از async در داخل آن استفاده می کنیم.
شاید سوال پیش بیاید که چه زمانی از async استفاده کنیم؟
ما معمولاً از async فقط زمانی استفاده میکنیم که نیاز به اجرای موازی (parallel execution) برای tasks مختلف داشته باشیم زیرا منتظر میماند تا همه task ها کامل شوند و deferred result را برمیگرداند.
با توجه به دانش بدست آمده از async builder، ترتیب عبارات چاپ شده را حدس بزنید:
خروجی کد به این صورت است:
با توجه به اینکه اجرای این دو task به صورت parallel انجام میشود، نه یکی پس از دیگری، در نتیجه منتظر میماند تا همه task ها complete شوند. زمان تمام شدن زمانی است که یک task سنگینتر برای تکمیل شدن طول میکشد. در function دوم با توجه به delay که وجود دارد، ۳ ثانیه طول میکشد تا جواب آماده شود و ارسال شود.
مکانیزم runBlocking به این صورت است که thread فعلی که coroutine در آن فراخوانی شده است را block می کند تا زمانی که coroutine کامل شود. runBlocking کدش را در همان thread یی که در آن فراخوانی شده است اجرا میکند.
با توجه به تعاریف، ترتیب print ها به چه صورت است؟
خروجی به این صورت است:
ابتدا runBlocking میاد thread فعلی را block میکند و اجازه اجرا شدن خط بعدی را نمیدهد و به این ترتیب، کد داخل runBlocking علیرغم delay که دارد با اولویت بیشتری نسبت به خط بعد آن که بیرون از block هست اجرا میشود.
مثال دیگری از await و مقایسه آن با launch:
کد زیر یک فراخوانی sequential (پشت سر هم) از دو suspend function را نشان میدهد. در این کد برخی وظایف زمانبر را اجرا کردهایم که در هر دو مورد ()fetchDataFromServerOne و ()fetchDataFromServerTwo یک ثانیه طول میکشد. سپس آنها را در سازنده launch فرامیخوانیم. هزینه زمان نهایی برابر با مجموع هزینه زمانی 2 ثانیه خواهد بود.
این اجرا تا زمانی که ()fetchDataFromServerOne پایان نیافته suspend میشود و سپس ()fetchDataFromServerTwo را اجرا میکند.
و خروجی به صورت زیر خواهد بود:
اگر بخواهیم هر دو function را به صورت parallel (همزمان) اجرا کنیم تا هزینه زمانی را کاهش دهیم، میتوانیم از await استفاده کنیم. async کاملاً شبیه به launch است. یک کوروتین دیگر آغاز میکند که به صورت همزمان با دیگر کوروتینها کار میکند و Deferred بازگشت میدهد که یک job با مقدار بازگشتی است.
خروجی به صورت زیر خواهد بود:
در بحث کوروتین ها ما باید مراقب exceptions نیز باشیم، و آنها را با try/catch یا با مکانیزم مدیریت خطای کوروتین CoroutineExceptionHandler مدیریت کنیم.
زمانی که از بیلدر launch استفاده میکنیم، در صورتی که exception رخ دهد، بلافاصله thrown می شود. بنابراین بهتر است آن را با try/catch مدیریت کنیم.
با این حال، در مورد async، این روش مناسب نیست. تا زمانی که await فراخوانی نشود، exception را نگه میدارد. بنابراین میتوانیم فراخوانی ()await. را در داخل try/catch قرار دهیم.
یکی دیگر از پارامترهای اختیاری CoroutineContext یک CoroutineExceptionHandler است که به ما امکان می دهد exception های نامشخص را مدیریت کنیم. ما باید یک handler تعریف کنیم و آن را به CoroutineScope پاس دهیم:
و در نهایت اینگونه از آن استفاده کنیم:
در صورتی که از RxJava در پروژه خود استفاده کنیم یک library به نام kotlinx-coroutines-rx وجود دارد که میتواند RxJava را به کوروتین تبدیل کند. آن را با کد زیر ایمپورت کنید:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$VERSION_CODE"
در تصویر زیر همه constructor های کوروتین را میبینید:
برای مثال اگر از Single در RxJava استفاده کنیم، ()Single.await به ما کمک میکند که RxJava را به suspendCancellableCoroutine تبدیل کنیم.
همچنان که در تصویر فوق میبینید، ()await حالت موفق را به ()cancellableContinuation.resume و حالت شکست را به ()cancellableContinuation.resumeWithException ارسال میکند. در ادامه اقدام به پیادهسازی کد دموی خود میکنیم:
خروجی به صورت زیر خواهد بود:
کد ()fetchUserFromServer().await موجب suspend کوروتین میشود و تا زمانی که RxJava نتیجه را بازگشت دهد صبر میکند. حال سؤال این است که اگر Single در RxJava شکست بخورد و یک exception صادر شود چه میشود؟
در این حالت exception در try-catch مدیریت میشود. خروجی به صورت زیر است:
کوروتین در کاتلین بوجود آمد تا Multi-Threading را بسیار آسان کند، اما دانستن تمام concept ها در هنگام استفاده از کوروتین ها بسیار مهم است. Scope ها و Job ها به کنترل Coroutines ها از جهت cancellation کمک می کند. از CoroutineBuilder برای create کردن آسان Coroutines ها استفاده میشود و در نتیحه نوشتن کد Asynchronous با استفاده از suspend function ها و coroutine builder ها آسان شده است.
1. https://kotlinlang.org/docs/coroutines-basics.html
2. https://developer.android.com/kotlin/coroutines
3. https://www.youtube.com/watch?v=ZTDXo0-SKuU
4. https://proandroiddev.com/kotlin-coroutines-in-andriod-ff0b3b399fa0
5. https://blog.faradars.org/kotlin-coroutines-in-android/
5. و کتاب بسیار ارزشمند Kotlin Coroutines by Tutorial: Mastering Coroutines in Kotlin and Android گردآوری شده به وسیله تیم raywenderlich میباشد.