مدیریت همزمانی در کاتلین با کورتین ها و Flow به زبان ساده

برنامه نویسی اندروید
مدیریت همزمانی در کاتلین با کورتین ها و Flow به زبان ساده

در تاریخچه توسعه نرم افزار، مدیریت همزمانی (Concurrency) و پردازش های موازی همواره یکی از پیچیده‌ترین، خطاخیزترین و چالش‌برانگیزترین حوزه ها برای مهندسان بوده است.

در اکوسیستم کلاسیک جاوا و نسخه های اولیه اندروید، برنامه نویسان برای دور ماندن از قفل شدن رابط کاربری و خطای مشهور ANR (Application Not Responding)، ناچار بودند به نخ های خام سیستم‌عامل (Threads)، ابزار های پردردسری نظیر AsyncTask یا کتابخانه های واکنشی بسیار حجیم و پیچیده نظیر RxJava پناه ببرند.

هرچند RxJava قابلیت های غنی به ارمغان آورد، اما منحنی یادگیری بسیار شیب‌دار، کد نویسی پرپیچ‌وخم بر پایه زنجیره های کالبک (Callback Hell) و مدیریت دشوار استثناها، توسعه پروژه های را فرسایشی می‌ساخت.

با معرفی و تکامل زبان کاتلین (Kotlin)، تیم جت‌برینز شاهکاری مهندسی به نام کورتین ها (Coroutines) و خطوط لوله واکنشی فلو (Flow) را به دنیای فناوری هدیه داد.

کورتین ها به مهندسان اجازه می‌دهند کدهای ناهمگام و غیرمسدودکننده را دقیقا با همان ساختار ساده، خوانا و خط‌به‌خط کدهای همگام بنویسند، بدون اینکه هیچ نخ پردازشی قفل شود. امروزه کورتین ها به عنوان هسته تپنده توسعه مدرن در اندروید، سرور هایی Ktor و پروژه هایی چند سکویی Kotlin Multiplatform (KMP) شناخته می‌شوند.

مقدمه راهبردی و کالبدشکافی مفهوم نخ های سبک وزن (Lightweight Threads)

برای درک عظمت معماری کورتین ها، ابتدا باید تفاوت بنیادین یک نخ سیستم‌عامل (OS Thread) با یک کورتین را بررسی کنیم. در ماشین مجازی جاوا (JVM)، هر ترد معمولی به طور مستقیم به یک نخ هسته سیستم‌عامل (Kernel-level Thread) نگاشت داده می‌شود.

ساخت هر ترد سیستم‌عامل نیازمند تخصیص حدود ۱ مگابایت حافظه استک (Call Stack Memory) اختصاصی است و جابه‌جایی میان تردها مستلزم سوییچ زمینه پرهزینه در سطح پردازنده (CPU Context Switching) است. اگر در یک برنامه تلاش کنید همزمان ۱۰۰,۰۰۰ ترد معمولی بسازید، سرور یا گوشی موبایل بلافاصله با خطای مهلک OutOfMemoryError متلاشی خواهد شد.

در نقطه مقابل، کورتین ها تردهای بسیار سبک‌وزن نرم افزاری هستند که در فضای کاربری (User Space) مدیریت می‌شوند. هر کورتین تنها چند ده بایت حافظه مصرف می‌کند.

کورتین ها به جای درگیر کردن دائمی یک ترد سیستم‌عامل، می‌توانند اجرای خود را موقتا در یک نقطه مشخص تعلیق (Suspend) کنند؛ در زمان تعلیق، ترد فیزیکی زیرین آزاد می‌شود تا کارهای دیگر را پردازش کند.

پس از آماده شدن نتیجه (مثلا دریافت داده از شبکه)، کورتین می‌تواند بر روی همان ترد یا یک ترد کاملا متفاوت از سر گرفته (Resume) شود. این نبوغ معماری اجازه می‌دهد صدهزار کورتین به طور همزمان روی تنها ۴ هسته پردازنده با مصرف حافظه ناچیز به رقابت بپردازند.

تفاوت بنیادین مسدودسازی (Blocking) در برابر تعلیق (Suspending): در مدل سنتی، فراخوانی یک متد زمان بر ترد پردازشی سیستم عامل را قفل می کند. در نقطه مقابل، توابع تعلیق پذیر (suspend) ترد زیرین را بلافاصله آزاد می کنند تا برای سایر تسک ها در دسترس باشد و به محض آماده شدن داده ها، اجرای کورتین بر روی هر ترد آزادی از سر گرفته می شود.

کالبدشکافی توابع تعلیق پذیر (Suspending Functions) و ماشین وضعیت کامپایلر

شاید برایتان شگفت‌انگیز باشد که بدانید سیستم‌عامل و ماشین مجازی جاوا (JVM) ذاتا هیچ درک یا پشتیبانی سخت افزاری از مفهوم کورتین ندارند! تمام این جادو در زمان کامپایل توسط کامپایلر قدرتمند کاتلین رقم می‌خورد. هنگامی که یک تابع با پیشوند suspend نشاندار می‌شود، کامپایلر کاتلین امضای تابع را از طریق فرآیندی به نام Continuation Passing Style (CPS) تغییر می‌دهد.

به طور خلاصه، کامپایلر یک پارامتر مخفی به نام Continuation<T> را به انتهای آرگومان های تابع اضافه می‌کند. این شیء حاوی کل وضعیت فعلی، متغیرهای محلی و یک کالبک برای ازسرگیری تابع است.

علاوه بر این، بدنه تابع تعلیق پذیر به یک ماشین وضعیت (Finite State Machine) تبدیل می‌شود که نقاط تعلیق را با یک برچسب عددی (State Label) مشخص می‌کند.

هر زمان که تابع به یک دستور تعلیق می‌رسد، وضعیت جاری خود را در شیء Continuation ذخیره کرده و مقدار خاص COROUTINE_SUSPENDED را برمی‌گرداند تا ترد آزاد شود. پس از دریافت پاسخ، متد resumeWith() فراخوانی شده و ماشین وضعیت دقیقا از همان برچسب عددی اجرای کد را پی می‌گیرد.

همزمانی ساختاریافته (Structured Concurrency) و دامنه های اجرایی

در الگو های قدیمی ناهمگام، یکی از بزرگترین فجایع، نشت عملیات ناهمگام و ایجاد جاب های یتیم (Orphan Jobs) بود؛ به عنوان مثال کاربر صفحه‌ای را می‌بست، اما دانلود تصویر یا کوئری دیتابیس در پس‌زمینه همچنان به مصرف رم و باتری ادامه می‌داد. کاتلین با معرفی پارادایم همزمانی ساختاریافته (Structured Concurrency) این معضل را برای همیشه ریشه‌کن کرد.

همزمانی ساختاریافته و سلسله مراتب Job در کاتلین
شکل ۱: ساختار درختی همزمانی ساختاریافته و ایزولاسیون خطا با SupervisorJob در برابر انتشار آبشاری خطا در Job معمولی

همان طور که در ساختار درختی فوق مشاهده می کنید، استفاده از SupervisorJob مانع از سرایت خطای یک کورتین فرزند به والد و سایر فرزندان هم تراز می شود، در حالی که در یک Job معمولی، شکست یک فرزند بلافاصله کل دامنه اجرایی را لغو می نماید.

اصل همزمانی ساختاریافته حکم می‌کند که هر کورتین باید درون یک دامنه اجرایی (CoroutineScope) مشخص با چرخه حیات معین آغاز شود. کورتین ها یک درخت والد-فرزندی (Parent-Child Hierarchy) تشکیل می‌دهند:

  • انتشار لغو (Cancellation Propagation): اگر دامنه والد لغو شود (مانند خروج کاربر از اکتیویتی یا پاک شدن ViewModel)، تمامی صدها کورتین فرزند و نواده به صورت خودکار و آبشاری لغو می‌گردند.
  • انتظار برای فرزندان: یک کورتین والد هرگز به پایان نمی‌رسد مگر اینکه تمامی کورتین های فرزند آن اجرای خود را با موفقیت یا خطا تکمیل کرده باشند.
  • انتشار خطا در Job استاندارد: در یک Job معمولی، اگر یکی از فرزندان با استثنایی کنترل‌نشده شکست بخورد، این خطا بلافاصله والد را متوقف کرده و تمامی سایر برادران سالم را نیز از بین می‌برد!

برای سناریوهایی که شکست یک کار نباید کارهای دیگر را تخریب کند (مانند بارگذاری مستقل بخش نظرات و بخش مشخصات کالا در صفحه خرید)، معماران از SupervisorJob استفاده می‌کنند. در قطعه کد استاندارد زیر، نحوه تفکیک ایزوله خطاها با استفاده از SupervisorJob و ثبت با CoroutineExceptionHandler نشان داده شده است:

همزمانی ساختاریافته در کاتلین با SupervisorJob و مهار خطای فرزندان Kotlin
import kotlinx.coroutines.*
import java.io.IOException

// تعریف یک دامنه نظارتی ایزوله برای جلوگیری از سرایت خطا به سایر جاب ها
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
    println("Caught unhandled exception: ${exception.message}")
}

// استفاده از SupervisorJob تضمین می‌کند که شکست یک فرزند باعث لغو برادرانش نمی‌شود
val supervisorScope = CoroutineScope(Dispatchers.Default + SupervisorJob() + exceptionHandler)

fun executeResilientTasks() = supervisorScope.launch {
    // جاب فرزند اول که با شکست مواجه می‌شود
    val failingJob = launch {
        println("Child ۱ starting and throwing exception...")
        throw IOException("Simulated network timeout in Child ۱")
    }

    // جاب فرزند دوم که با وجود شکست فرزند اول، به اجرای سالم خود ادامه می‌دهد
    val successfulJob = launch {
        delay(۵۰۰)
        println("Child ۲ successfully completed independent of Child ۱.")
    }

    joinAll(failingJob, successfulJob)
}

در کد بالا، با این که جاب اول با خطای تایم‌اوت شبکه پرتاب می‌شود، دامنه ناظر مانع از انتشار خطا به والد شده و جاب دوم بدون کمترین تاخیری کار خود را تکمیل می‌نماید. برای بررسی استقرار کدهای فرانت اند و کاتلین در اکوسیستم های مختلف، مطالعه مقایسه عمیق Flutter ،React Native و KMP افق های چند سکویی را روشن می‌سازد.

دیسپچرها (Dispatchers) و تعویض هوشمند زمینه اجرایی

یک کورتین برای اجرا باید بداند کدهای آن بر روی کدام نخ های فیزیکی پردازش شوند. این وظیفه بر دوش دیسپچرها (CoroutineDispatchers) است که نقش ارکستراتور صف ها را دارند:

معماری دیسپچرهای کورتین در کاتلین
شکل ۲: معماری زمان‌بند کورتین ها و تفکیک نخ های پردازشی با Dispatchers.Default و Dispatchers.IO و Dispatchers.Main

نمودار بالا نحوه تقسیم کار هوشمند میان پول های نخی سیستم عامل را نشان می دهد؛ عملیات محاسباتی سنگین به Dispatchers.Default و کارهای ورودی/خروجی شبکه و دیسک به Dispatchers.IO واگذار می شوند تا ترد اصلی رابط کاربری هرگز قفل نشود.

  1. Dispatchers.Main: اجرای کدها بر روی ترد اصلی رابط کاربری (UI Thread). این دیسپچر مخصوص تغییر ویوها، به‌روزرسانی متغیرهای Compose و تعاملات فوری کاربر است و اجرای هرگونه کد سنگین روی آن باعث لگ زدن نرم افزار می‌شود.
  2. Dispatchers.IO: بهینه سازی‌شده برای عملیات ورودی/خروجی و مسدودکننده شبکه، خواندن دیسک، پایگاه داده Room و فایل ها. استخر نخ های این دیسپچر می‌تواند تا ۶۴ ترد یا تعداد هسته ها مقیاس یابد.
  3. Dispatchers.Default: بهینه سازی‌شده برای عملیات محاسباتی فشرده پردازنده (CPU-bound) نظیر مرتب سازی لیست های بزرگ، پارس کردن فایل های حجیم JSON یا پردازش تصویر. تعداد نخ های آن دقیقا برابر با تعداد هسته های فیزیکی CPU است.

برای مدیریت وظایف موازی، دو سازنده اصلی کورتین وجود دارند: تابع launch که برای کارهای آتش-و-فراموش (Fire-and-Forget) بدون مقدار بازگشتی استفاده می‌شود، و تابع async که یک شیء Deferred<T> برمی‌گرداند و برای تجزیه همزمان وظایف و ترکیب نتایج با await() به کار می‌رود:

پردازش همزمان و بهینه درخواست های شبکه با الگوی async و await در کاتلین Kotlin
import kotlinx.coroutines.*

data class UserProfile(val id: String, val name: String)
data class UserOrders(val count: Int, val totalAmount: Double)
data class DashboardData(val profile: UserProfile, val orders: UserOrders)

class UserRepository(private val apiService: RemoteApiService) {

    suspend fun fetchDashboardConcurrently(userId: String): DashboardData = coroutineScope {
        // اجرای همزمان دو درخواست شبکه مستقل با async بر بستر دیسپچر IO
        val profileDeferred: Deferred<UserProfile> = async(Dispatchers.IO) {
            println("Fetching profile on thread: ${Thread.currentThread().name}")
            apiService.getUserProfile(userId)
        }

        val ordersDeferred: Deferred<UserOrders> = async(Dispatchers.IO) {
            println("Fetching orders on thread: ${Thread.currentThread().name}")
            apiService.getUserOrders(userId)
        }

        // انتظار برای تجمیع موازی نتایج (Concurrent Decomposition)
        val profile = profileDeferred.await()
        val orders = ordersDeferred.await()

        DashboardData(profile, orders)
    }
}

در قطعه کد بالا، دو درخواست شبکه برای واکشی پروفایل و سفارشات به صورت همزمان (Concurrent) بر بستر دیسپچر IO ارسال می‌شوند؛ بنابراین زمان کل اجرای برنامه به جای جمع دو تاخیر، برابر با بیشینه زمان کندترین درخواست خواهد بود که کارایی سیستم را دو برابر می‌سازد.

جریان های واکنشی با کاتلین فلو (Kotlin Cold Flow)

در حالی که توابع تعلیق پذیر یک مقدار منفرد را پس از پایان پردازش برمی‌گردانند، در سناریوهای دنیای واقعی نظیر استریم وب‌سوکت ها، پایش تغییرات دیتابیس یا نوسانات موقعیت جغرافیایی GPS، نیازمند دریافت چندین مقدار ناهمگام در طول زمان هستیم. اینجاست که کاتلین فلو (Flow) به عنوان موتور پردازش واکنشی (Reactive Stream) وارد صحنه می‌شود.

مهم‌ترین ویژگی فلوهای پایه کاتلین، سرد بودن (Cold Streams) آنهاست. جریان سرد به این معناست که کدهای درون بلوک flow تا زمانی که یک مصرف‌کننده (Collector) اقدام به فراخوانی عملگر ترمینال نظیر collect نکند، مطلقا اجرا نخواهند شد. اگر ۵ کامپوننت مختلف یک Flow سرد را کالکت کنند، هر کدام یک خط لوله مستقل و مجزا با تولید داده های اختصاصی دریافت می‌نمایند.

پیاده سازی یک خط لوله جریان استاندارد در لایه مخزن داده (Repository) در کد زیر نمایش داده شده است:

خط لوله جریان سرد (Cold Flow) با عملگرهای تبدیل و تغییر زمینه با flowOn Kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*

class StockPriceRepository(private val socketDataSource: WebSocketDataSource) {

    // تولید جریان سرد (Cold Flow): تا زمانی که جمع‌آوری نشود هیچ پردازشی صورت نمی‌گیرد
    fun streamStockUpdates(symbol: String): Flow<FormattedStockPrice> {
        return socketDataSource.rawPriceFlow(symbol)
            .filter { rawPrice -> rawPrice.isValid }
            .map { rawPrice ->
                // تبدیل داده و قالب بندی قیمت
                FormattedStockPrice(
                    symbol = symbol,
                    displayPrice = "$${"%.۲f".format(rawPrice.value)}",
                    trend = if (rawPrice.delta >= ۰) Trend.UP else Trend.DOWN
                )
            }
            .flowOn(Dispatchers.Default) // اجرای تبدیلات محاسباتی سنگین در پس‌زمینه
            .catch { ex ->
                emit(FormattedStockPrice(symbol, "$۰.۰۰", Trend.ERROR))
            }
            .buffer() // تفکیک سرعت فرستنده و گیرنده جهت جلوگیری از Backpressure
    }
}

در کد بالا، عملگر فوق‌العاده flowOn(Dispatchers.Default) به کار رفته است. این عملگر زمینه اجرای عملگرهای بالادستی (Upstream نظیر فیلتر و مپ) را به دیسپچر محاسباتی منتقل می‌کند، در حالی که جمع‌آوری‌کننده پایین‌دستی می‌تواند با خیال راحت روی ترد اصلی اجرا شود. این قابلیت قانون حفظ زمینه (Context Preservation) را تضمین می‌نماید.

جریان های داغ در معماری نرم افزار: StateFlow در برابر SharedFlow

در لایه های بالاتر معماری نظیر ViewModel و رابط کاربری، جریان های سرد پاسخگو نیستند؛ چرا که ما خواهان حفظ آخرین وضعیت نمایش، اشتراک‌گذاری داده ها میان چندین صفحه و جلوگیری از تکرار درخواست های شبکه هستیم. برای این منظور، کاتلین جریان های داغ (Hot Streams) را معرفی می‌کند:

تفاوت جریان های سرد و داغ در کاتلین Flow
شکل ۳: تفاوت بنیادین جریان های سرد (Cold Flow) مبتنی بر تقاضا در برابر جریان های داغ برودکست (StateFlow و SharedFlow)

مطابق این طرح واره، جریان های سرد تنها با فراخوانی collect شروع به تولید داده می کنند، در حالی که جریان های داغ مستقل از وجود مشترک فعال، مقادیر جدید را به محض وقوع منتشر می سازند و امکان اشتراک گذاری با چندین ناظر را دارند.

  • جریان StateFlow: یک جریان داغ نگهدارنده وضعیت که همواره دارای یک مقدار اولیه است و آخرین مقدار خود را برای مشترکین جدید بازپخش (Replay = ۱) می‌کند. این جریان معادل مدرن، بهینه‌تر و بدون وابستگی اندرویدی برای LiveData قدیمی است و همخوانی کاملی با Jetpack Compose دارد.
  • جریان SharedFlow: یک بستر انتشار عمومی (Event Bus) بسیار انعطاف پذیر با قابلیت تنظیم بافر و تعداد بازپخش که برای ارسال رویدادهای گذرا و یک‌باره (One-Off Events) نظیر نمایش اسنک‌بار یا ناوبری میان صفحات به کار می‌رود.

پیاده سازی حرفه‌ای تبدیل Cold Flow به Hot StateFlow در معماری مدرن با کد زیر محقق می‌شود:

تبدیل جریان به StateFlow داغ با SharingStarted.WhileSubscribed در معماری اندروید Kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*

sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<String>) : UiState
    data class Error(val message: String) : UiState
}

class ProductCatalogViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    // تبدیل Cold Flow به Hot StateFlow امن برای چرخه حیات Jetpack Compose
    val uiState: StateFlow<UiState> = repository.getProductsFlow()
        .map<List<String>, UiState> { products -> UiState.Success(products) }
        .catch { emit(UiState.Error(it.localizedMessage ?: "Unknown Error")) }
        .stateIn(
            scope = viewModelScope,
            // توقف جریان با خروج برنامه به پس‌زمینه پس از ۵ ثانیه تاخیر جهت صرفه‌جویی باتری
            started = SharingStarted.WhileSubscribed(۵۰۰۰),
            initialValue = UiState.Loading
        )
}

توجه به استراتژی SharingStarted.WhileSubscribed(5000) بسیار حیاتی است. این الگو تضمین می‌کند که اگر کاربر از صفحه خارج شد یا گوشی را خاموش کرد، پس از ۵ ثانیه تاخیر (برای مهار چرخش صفحه)، کالکتور جریان متوقف شده و مصرف باتری و داده شبکه قطع می‌گردد.

الگو های همگام سازی و مدیریت وضعیت مشترک با Mutex آسنکرون

یکی از خطرات کلاسیک در برنامه نویسی همزمان، بروز پدیده مسابقه داده ها (Data Race) در زمان دسترسی همزمان چندین نخ به یک متغیر تغییرپذیر مشترک (Shared Mutable State) است. در جاوا، برنامه نویسان برای حل این مشکل از کلمه کلیدی synchronized یا کلاس های ReentrantLock استفاده می‌کردند. اما به‌کارگیری این قفل های کلاسیک در کورتین های کاتلین یک ضدالگوی مرگبار (Deadly Anti-Pattern) است!

همگام سازی وضعیت مشترک با Mutex در کورتین ها
شکل ۴: همگام سازی امن وضعیت مشترک با Mutex آسنکرون و جلوگیری از شرایط رقابتی (Race Conditions) بدون مسدودسازی ترد

دیاگرام معماری بالا مکانیزم قفل غیرمسدودکننده Mutex.withLock را به تصویر می کشد که بدون بلاک کردن تردهای سیستم عامل، صف منظمی از کورتین ها را برای دسترسی ایمن به متغیرهای مشترک سازماندهی می کند.

علت این است که synchronized ترد فیزیکی سیستم‌عامل را مسدود می‌کند. اگر کورتینی که قفل را در اختیار دارد تعلیق شود (مثلا با delay یا درخواست شبکه)، ترد زیرین قفل شده و سایر کورتین‌هایی که منتظر ورود به آن بلوک هستند، کل استخر تردها را به بن‌بست کامل (Deadlock) می‌کشانند. کاتلین برای حل این معضل، کلاس Mutex آسنکرون را در پکیج kotlinx.coroutines.sync ارائه داده است:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

val mutex = Mutex()
var accountBalance = 1000

suspend fun depositSafely(amount: Int) {
    // قفل کردن با مکانیزم تعلیق بدون مسدودسازی نخ زیرین
    mutex.withLock {
        accountBalance += amount
    }
}

تابع mutex.withLock کورتین های منتظر را بدون قفل کردن ترد سیستم‌عامل به حالت تعلیق درمی‌آورد تا ترد برای پردازش سایر امور آزاد بماند. علاوه بر این، در سناریوهای سبک‌تر می‌توان از انواع اتمیک نظیر AtomicInteger یا الگوی حبس در نخ (Thread Confinement) با دیسپچرهای تک‌تردی استفاده نمود. برای ارزیابی هزینه پردازشی الگوریتم ها، تحلیل تحلیل عمیق Big-O و پیچیدگی الگوریتم ها معیارهای دقیق ریاضی ارائه می‌دهد.

الگوی کانال ها Channels در برابر Flow بر پایه مدل CSP

در کنار Flow، کاتلین ابزار ارتباطی قدرتمند دیگری به نام کانال ها (Channels) را بر پایه تئوری مشهور تونی هور یعنی پردازه های ارتباطی ترتیبی (Communicating Sequential Processes - CSP) پیاده سازی کرده است. تفاوت فلسفی این دو ابزار برای معماران سیستم حیاتی است:

تفاوت بنیادین Flow و Channel در کاتلین جریان فلو (Flow) یک استریم داده سرد است؛ داده ها درون آن ذخیره نمی‌شوند و به ازای هر درخواست‌کننده یک پایپ لاین نو بازآفرینی می‌گردد. در مقابل، کانال (Channel) یک ساختار داغ و ارتباطی نقطه به نقطه (Pipe / Queue) است که مقادیر را میان کورتین های تولیدکننده (Producer) و مصرف‌کننده (Consumer) انتقال می‌دهد. هر مقداری که از کانال خوانده شود، مصرف شده و برای مصرف‌کننده بعدی وجود نخواهد داشت.

کانال ها با چهار استراتژی بافرینگ ارائه می‌شوند: بافر صفر یا قرارگاه ملاقات (Rendezvous) که در آن فرستنده تا زمان رسیدن گیرنده تعلیق می‌شود، بافر ظرفیت محدود (Buffered) برای مهار شوک های لحظه‌ای، بافر تلفیقی (Conflated) که فقط آخرین مقدار را نگه می‌دارد، و بافر نامحدود (UNLIMITED).

برای بررسی استقرار سرویس های در مقیاس های کلان، راهنمای راهنمای جامع طراحی سیستم در مقیاس میلیونی راهکارهای بسیار موثری را در دسترس مهندسان قرار می‌دهد.

استراتژی های تست واحد کورتین ها با TestDispatcher و زمان مجازی

تست کدهای ناهمگام همواره یکی از بزرگترین معضلات تیم های مهندسی بوده است. اگر کدی حاوی یک تاخیر ۵ ثانیه‌ای برای تلاش مجدد یا انیمیشن باشد، در تست های واحد سنتی مهندس باید ۵ ثانیه کامل منتظر بماند که اجرای پایپ لاین های CI/CD را به شدت کند و شکننده می‌کند. اکوسیستم کاتلین با ارائه کتابخانه تخصصی kotlinx-coroutines-test و تابع جادویی runTest این چالش را به زیبایی حل کرده است.

در دل این فریم ورک، دو مفهوم کلیدی قرار دارد:

  • زمان مجازی (Virtual Time): زمان در تست های کورتین به صورت مصنوعی شبیه سازی می‌شود؛ به این معنی که فراخوانی delay(60_000) (یک دقیقه تاخیر) در کسری از میلی‌ثانیه رد می‌شود بدون اینکه پردازنده حتی یک میلی‌ثانیه معطل بماند!
  • دیسپچر StandardTestDispatcher: کارهای ارسالی را در یک صف اولویت قرار می‌دهد و به مهندس اجازه می‌دهد با دستوراتی نظیر advanceUntilIdle() یا advanceTimeBy(1000) کنترل گام‌به‌گام گذر زمان و اجرای کورتین ها را در دست بگیرد.

همچنین با پیاده سازی قانون تست MainDispatcherRule در آزمون های اندروید، می‌توان Dispatchers.Main را با یک TestDispatcher جایگزین کرد تا تست های ViewModel بدون نیاز به شبیه ساز یا ایمولاتور اندروید با سرعتی باورنکردنی روی JVM محلی اجرا شوند.

بنچمارک مهندسی: کورتین ها در برابر تردهای خام JVM و RxJava

سنجش کمی و محاسباتی، برتری بلامنازع کورتین های کاتلین در مقایسه با روش های قدیمی را اثبات می‌نماید. جدول مهندسی زیر رفتار سامانه در ایجاد و اجرای ۱۰۰,۰۰۰ تسک موازی را نمایش می‌دهد:

شاخص عملکردی تردهای سیستم‌عامل (Java Threads) کتابخانه واکنشی RxJava کورتین های کاتلین (Coroutines)
مصرف حافظه رم برای ۱۰۰,۰۰۰ تسک کرش فوری با OutOfMemoryError حدود ۳۸۰ مگابایت رم کمتر از ۱۵ مگابایت رم خالص
زمان ساخت و آماده سازی بسیار کند و همراه با سربار سیستم‌عامل متوسط، به دلیل ایجاد اشیاء ارزیاب فوق‌سریع در مقیاس چند میلی‌ثانیه
سبک نگارش و خوانایی کد کالبک های تودرتو و پیچیده زنجیره های پیچیده اپراتوری واکنشی کد نویسی متوالی خط‌به‌خط طبیعی
مدیریت لغو و پاکسازی منابع بسیار دشوار و دستی (مستعد نشت حافظه) نیازمند مدیریت دستی CompositeDisposable خودکار بر پایه همزمانی ساختاریافته
سربار حجم فایل نهایی (APK / JAR) صفر (کتابخانه استاندارد جاوا) افزودن بیش از ۲.۵ مگابایت به باینری بسیار کم‌حجم (چند ده کیلوبایت)

۵ تله مرگبار در کورتین های کاتلین و چک لیست طلایی پروداکشن

با وجود سادگی ظاهری، استفاده نادرست از کورتین ها می‌تواند منجر به باگ های مخفی، بن‌بست های پردازشی و مصرف بی‌رویه باتری شود:

  1. مسدودسازی ترد با توابع مسدودکننده (Blocking the Dispatcher): استفاده از Thread.sleep() یا عملیات سنگین دیسک روی Dispatchers.Default یا Main. این کار تردهای پردازشی را قفل کرده و تمام کورتین های دیگر را در صف انتظار نگه می‌دارد.
  2. ایجاد کورتین در دامنه های سراسری بدون چرخه حیات (GlobalScope Abuse): استفاده از GlobalScope.launch به عنوان میان‌بری سریع. این کار همزمانی ساختاریافته را نقض کرده و به نشت حافظه دائم و مصرف باتری ختم می‌شود.
  3. نادیده گرفتن قابلیت لغو در حلقه های محاسباتی (Non-cancellable Loops): اجرای یک حلقه طویل محاسباتی بدون بررسی شرط isActive یا فراخوانی yield(). اگر کورتین لغو شود، حلقه تا ابد ادامه می‌یابد!
  4. بلعیدن خاموش استثنای لغو (Swallowing CancellationException): نوشتن بلوک catch (e: Exception) عمومی بدون پرتاب مجدد CancellationException. در کاتلین، لغو کورتین ها با پرتاب این استثنا صورت می‌گیرد؛ اگر آن را ببلعید، مکانیزم لغو فلج می‌شود.
  5. استفاده از synchronized سنتی به جای Mutex آسنکرون: قفل های سنتی جاوا ترد را مسدود می‌کنند، در حالی که در کورتین ها باید از kotlinx.coroutines.sync.Mutex استفاده شود که با تعلیق کار می‌کند.

برای تضمین استقرار بی‌خطر در سازمان، این چک‌لیست ۱۰ مرحله‌ای کورتین ها را در فرآیند بازبینی کدهای تیم الزامی کنید:

  • [ ] پرهیز کامل از GlobalScope و اجرای تمامی کورتین ها درون دامنه ساختاریافته (viewModelScope یا lifecycleScope).
  • [ ] استفاده از Dispatchers.IO برای تمامی فراخوانی های وب سرویس و پایگاه داده محلی.
  • [ ] بهره‌گیری از SupervisorJob در دامنه‌هایی که شکست یک وظیفه نباید سایر تسک ها را مختل کند.
  • [ ] پرتاب مجدد CancellationException در تمامی بلوک های catch عمومی برنامه.
  • [ ] بررسی شرط isActive در حلقه های طولانی محاسباتی جهت پذیرش فوری دستور لغو.
  • [ ] به‌کارگیری WhileSubscribed(۵۰۰۰) در تبدیل فلوها به StateFlow جهت توقف هوشمند در پس‌زمینه.
  • [ ] استفاده از async فقط در سناریوهایی که نیاز به دریافت مقدار همزمان است (نه به عنوان جایگزین launch).
  • [ ] استفاده از Mutex آسنکرون به جای کلمه کلیدی synchronized در محافظت از منابع اشتراکی.
  • [ ] تست کورتین ها با ابزار TestDispatcher و runTest در آزمون های واحد خودکار.
  • [ ] بررسی دوره‌ای عملکرد با پروفایلرهای حافظه جهت اطمینان از عدم وجود جاب های نشت‌یافته.

پرسش های متداول مهندسان ارشد کاتلین و توسعه دهندگان موبایل (FAQ)

تفاوت اساسی میان کلمه کلیدی suspend و یک کالبک سنتی چیست؟

در کالبک های سنتی، کنترل برنامه از دست می‌رود و زنجیره‌ای از توابع تودرتو (Callback Hell) ایجاد می‌شود که مدیریت استثناها را غیرممکن می‌سازد. تابع suspend در ظاهر کدی کاملا خط‌به‌خط و همگام دارد و استثناها با try/catch استاندارد مدیریت می‌شوند، اما کامپایلر در پشت‌صحنه آن را به کالبک های بهینه CPS تبدیل می‌کند.

چرا استفاده از GlobalScope در پروژه هایی پروداکشن اکیدا نهی شده است؟

کورتین‌هایی که با GlobalScope آغاز می‌شوند، چرخه حیات کل برنامه را پیدا می‌کنند و به هیچ صفحه یا کامپوننتی وابسته نیستند. اگر کاربر صفحه را ببندد، این کورتین ها همچنان در پس‌زمینه زنده مانده و منابع رم و اینترنت را مصرف می‌کنند که باعث نشت حافظه دائم و ناپایداری سیستم می‌شود.

تفاوت رفتار Job و SupervisorJob در صورت بروز خطای فرزند چیست؟

در یک Job معمولی، انتشار خطا دوطرفه است؛ بروز استثنا در یک فرزند باعث متوقف شدن والد و تمام فرزندان دیگر می‌شود. اما در SupervisorJob انتشار خطا یک‌طرفه (از بالا به پایین) است؛ بنابراین شکست یک فرزند تاثیری بر والد یا سایر برادران ندارد و خطا به صورت محلی کنترل می‌شود.

چرا در ViewModel باید از StateFlow به جای LiveData استفاده کرد؟

جریان StateFlow بخشی از کتابخانه خالص کاتلین است و هیچ وابستگی به فریم ورک اندروید ندارد، بنابراین در کدهای مشترک KMP و تست های واحد بسیار سریع‌تر اجرا می‌شود. همچنین دسترسی به غنای کامل عملگرهای Flow (مانند debounce, flatMapLatest) را فراهم کرده و هماهنگی کامل با Jetpack Compose دارد.

چه تفاوتی میان Dispatchers.Default و Dispatchers.IO وجود دارد؟

دیسپچر Default برای کارهای محاسباتی سنگین CPU طراحی شده و تعداد تردهای آن محدود به تعداد هسته های پردازنده است تا از سربار جابه‌جایی زمینه جلوگیری شود. دیسپچر IO برای کارهای شبکه و دیسک طراحی شده و استخر تردهای آن می‌تواند تا ۶۴ ترد رشد کند تا تعلیق های I/O مانع اجرای کارهای دیگر نشوند.

عملگر flowOn در خط لوله Flow دقیقا چه کاری انجام می‌دهد؟

عملگر flowOn زمینه اجرای عملگرهای بالادستی (کدهای پیش از خود) را تغییر می‌دهد بدون اینکه تاثیری بر جمع‌آوری‌کننده پایین‌دستی بگذارد. این کار تفکیک تمیز پردازش داده در پس‌زمینه و نمایش در ترد اصلی را میسر می‌سازد.

چرا در حلقه های محاسباتی طویل باید از yield() استفاده کرد؟

تابع yield() اجرای کورتین را برای یک لحظه کوتاه به سایر کورتین های منتظر روی همان دیسپچر واگذار می‌کند و همزمان بررسی می‌نماید که آیا کورتین جاری لغو شده است یا خیر. این کار عدالت پردازشی و پاسخگویی سریع به لغو را در کارهای سنگین تضمین می‌نماید.

چگونه می‌توان یک کورتین را در زمان تست های واحد (Unit Testing) اجرا کرد؟

با استفاده از تابع runTest و کتابخانه kotlinx-coroutines-test. این کتابخانه زمان شبیه سازی‌شده (Virtual Time) ارائه می‌دهد که تاخیرهای چند ثانیه‌ای delay را در کسری از میلی‌ثانیه رد می‌کند و اجرای تست ها را فوق‌العاده سریع می‌سازد.

امتیاز و نظرات

۰.۰ (۰ نظر)
برای ثبت دیدگاه باید وارد حساب کاربری شوید. ورود

هنوز نظری ثبت نشده است. اولین نفر باشید.