مدیریت Race Condition در Coroutines: راهنمای کامل همزمانی

مدیریت Race Condition در Coroutines: راهنمای کامل همزمانی
در این پست می‌خوانید:

شرایط رقابتی Race Condition یکی از چالش‌برانگیزترین مفاهیم در دنیای برنامه‌نویسی مدرن است که حتی در دنیای منظمِ کوروتین‌ها (Coroutines) نیز به وفور یافت می‌شود. اگرچه در توسعه اپلیکیشن‌های اندرویدی، استفاده از کوروتین‌ها برای مدیریت کارهای ناهمزمان (Asynchronous) به ابزاری حیاتی برای نوشتن کدهای خوانا و کارآمد تبدیل شده است، اما قدرتِ همزمانی (Concurrency) همیشه یک شمشیر دو لبه است. با وجود تمام مزایای مدیریت بهینه منابع، نادیده گرفتن چالش‌های این همزمانی می‌تواند منجر به بروز خطاهای پیچیده‌ای شود که «شرایط رقابتی» در صدر آن‌ها قرار دارد.

شرایط رقابتی Race Condition یک نقص اساسی در طراحی سیستم‌های همزمان است که در آن، نتیجه نهایی عملیات به طور غیرقابل پیش‌بینی به ترتیب یا زمان‌بندی دقیق اجرای رشته‌های مختلف (در اینجا کوروتین‌ها) بستگی پیدا می‌کند. این امر به ویژه هنگام دسترسی همزمان چندین کوروتین به داده‌های مشترک و قابل تغییر (Shared Mutable State) رخ می‌دهد.

مطالعه بیشتر :آموزش کوروتین ها در کامپوز و اندروید

در ادامه یک مثال برای این موضوع مطرح میکنیم و بعد به راحل های آن مپردازیم

مقدمه : مثالی از  Race Condition

بیایید تصور کنیم که باید یک متغیر را از چندین کوروتین در چنین کدی تغییر دهیم (منظورم افزایش داده مقدار متغییره ) :

var counter = 0

suspend fun increment() {
    withContext(Dispatchers.Default) {
        repeat(1000) {
            counter++
        }
    }
}

fun main() = runBlocking {
    List(2) {
        launch { increment() }
    }.onEach { it.join() }
    println(counter)
}

در این قطعه کد، ما یک متغیر سراسری به نام `counter` داریم و دو کوروتین را برای افزایش آن راه‌اندازی می‌کنیم.(یه لیست گذاشتیم با دوتا عضو که این روبرای ما انجام بده )

توضیح کد :

  • counter متغییر ماست
  • repeat 1000  : به انداز 1000 بار این دستور افزایش مقدار رو انجام میده
  • تابع main : اجرای عملیات رو بر عهده داره
  • list(2) : دوتا Launch که میشه دوتا کوروتین رو در خودش داره
  • onEach { it.join() } : تضمین میکنه که برنامه اصلی منتظر بمونه تا تمامی کارهای ناهمزمان (کوروتین‌ها)  تموم بشن و سپس نتیجه نهایی را چاپ کنه .

ما انتظار داریم که خروجی ۲۰۰۰ باشد. اما در واقع، نتیجه بسیار متفاوت خواهد بود – از ۱۰۰۰ تا ۲۰۰۰ متغیر خواهد بود. چرا ؟

ببین، داستان اینجاست که انتظار داری counter++ مثل یه دکمه باشه که هر بار می‌زنی یه عدد اضافه می‌شه و هیچ‌وقت وسط کار قطع نمی‌شه. ولی توی دنیای کامپیوتر، این counter++ در واقع یه فرایند سه مرحله‌ای کوچیکه:

ما فرض میکنیم که الان counter = ۵۰۰ است :

  1. برداشتن: اول مقدار فعلیِ کانتِر رو می‌خونه (مثلاً ۵۰۰).
  2. اضافه کردن: اون ۵۰۰ رو می‌کنه ۵۰۱.
  3. گذاشتن: اون ۵۰۱ رو برمی‌گردونه سر جاش توی حافظه.

 مثالی از شرایط رقابتی Race Condition

حالا دو تا کوروتین (بذار اسمشون رو بذاریم «آرش» و «میکائیل») دارن همزمان با همون متغیر کار می‌کنن. یه سناریوی خیلی بد که می‌تونه پیش بیاد اینه:

آرش: می‌ره مقدار ۵۰۰ رو از حافظه می‌خونه (مرحله ۱).
سیستم: درست وسط کار آرش، سیستم میگه: «بسه آرش، نوبت میکائیل شد!»
میکائیل: میکائیل هم می‌ره مقدار ۵۰۰ رو از حافظه می‌خونه (چون آرش هنوز کارش تموم نشده!).
میکائیل: مقدار رو می‌کنه ۵۰۱ و می‌نویسه توی حافظه. حالا کانتِر شده ۵۰۱.
سیستم: نوبت دوباره می‌رسه به آرش. آرش که هنوز فکر می‌کنه مقدار ۵۰۰ بوده، اون ۵۰۰ رو می‌کنه ۵۰۱ و می‌نویسه توی حافظه. کانتِر باز همون ۵۰۱ باقی می‌مونه!

دیدی ! دو بار دستور افزایش دادی، ولی چون تداخل شد، فقط یکی‌شون حساب شد!

این تداخل‌ها (اینکه کی سیستم وقفه بده) کاملاً تصادفیه، یه بار ممکنه فقط یکی از اون ۲۰۰۰ تا عملیات از دست بره، یه بار ممکنه ۵۰۰ تا از دست بره. به همین خاطر خروجی نهایی یه عدد شانسی بین ۱۰۰۰ تا ۲۰۰۰ می‌شه. این همون چیزیه که ما بهش میگیم شرایط رقابتی Race Condition!

چرا این قسمت تصادفیه ؟ این قسمت نیاز به توضیح داریم :

ببین، اصلِ ماجرا زیر سرِ چیزی به اسم Preemption یا «حقِ تقدم» هست که توسط OS Scheduler (زمان‌بند سیستم‌عامل) مدیریت می‌شه.

این یعنی ، سیستم‌عامل مثل یه داورِ خیلی سخت‌گیره که به هر کوروتین فقط چند میلی‌ثانیه وقت می‌ده تا از CPU استفاده کنه. وقتی وقتِ اون کوروتین تموم شد، داور(سیستم عامل) سوت می‌زنه، کوروتین رو همون‌جا که هست متوقف می‌کنه و نفر بعدی رو می‌فرسته توی زمین.

حالا چرا نتیجه «تصادفی» می‌شه؟ به این ۳ دلیل:

۱. توقف در بدترین زمان ممکن

سیستم‌عامل اصلاً خبر نداره که کدِ کاتلین تو چیه. اون نمی‌دونه که counter++ یک عملیات حساسه که باید تا تهش بره. ممکنه دقیقاً بعد از اینکه «آرش» مقدار رو برداشت (مرحله ۱)، داور سوت بزنه و متوقفش کنه. یا ممکنه ۱۰ بار پشت سر هم به آرش وقت بده و آرش ۵۰۰ تا اضافه کنه و هیچ تداخلی پیش نیاد. این “لحظه‌ی سوت زدن” دست تو نیست.

۲. بارِ پردازشی سیستم (System Load)

اینکه سیستم‌عامل کِی نوبت رو عوض می‌کنه، به هزار تا چیز بستگی داره:

  • آیا همزمان داری توی اینستاگرام چرخ می‌زنی؟

  • آیا یه نوتیفیکیشن همون لحظه اومده؟

  • دمای گوشی چقدره؟ همه این‌ها باعث می‌شه که زمان‌بندیِ اجرای کوروتین‌ها در هر بار اجرای برنامه، با بارِ قبلی فرق کنه.

۳. ماهیت موازی در گوشی‌های چند هسته‌ای

گوشی‌های اندرویدی معمولاً چندین هسته (Core) دارن. وقتی از Dispatchers.Default استفاده می‌کنی، ممکنه دو تا کوروتین واقعاً «همزمان» روی دو تا هسته‌ی مختلف اجرا بشن. سرعتِ دسترسی هر هسته به حافظه ممکنه در حد نانوثانیه با هم فرق کنه. این اختلافِ سرعتِ ناچیز باعث می‌شه که یک بار «آرش» زودتر برسه به حافظه، یک بار «میکائیل».

پس این کد یک مثال ساده و روشن از شرایط رقابتی Race Condition بود .

پس :

شرایط رقابتی Race Condition زمانی رخ می‌دهد که چندین کوروتین سعی کنند به طور همزمان به داده‌های مشترک دسترسی پیدا کنند یا آن‌ها را تغییر دهند که می‌تواند منجر به نتایج نادرست و غیرقابل پیش‌بینی شود.

شش راه برای حل مشکل شرایط رقابتی Race Condition

۱. میوتکس (Mutex)

میوتکس (برگرفته از عبارت Mutual Exclusion به معنای انحصار متقابل) ابزاری است که مانند یک نگهبان، از ورود همزمان چندین کوروتین به یک بخش حساس از کد (مثلاً جایی که یک متغیر مشترک تغییر می‌کند) جلوگیری می‌کند. وقتی یک کوروتین از میوتکس استفاده می‌کند، آن را «قفل» کرده و اجازه ورود به دیگران نمی‌دهد. اگر کوروتین دیگری در همین زمان قصد دسترسی به آن بخش را داشته باشد، به جای مسدود کردنِ کلِ سیستم، به حالت تعلیق (Suspend) درمی‌آید و تا زمانی که قفل توسط کوروتین اول آزاد نشود، منتظر می‌ماند. این کار تضمین می‌کند که در هر لحظه، فقط و فقط یک کوروتین تغییرات را اعمال کند و خروجی برنامه همیشه دقیق و قابل پیش‌بینی باشد.

بیا Mutex رو خیلی ساده و با یک مثال واقعی بررسی کنیم:

داستانِ «دستشویی هواپیما»! ✈️

بهترین راه برای درک Mutex، تصور کردن دستشویی یک هواپیماست:

  1. قفل (Lock): وقتی یک نفر وارد می‌شه، در رو قفل می‌کنه. حالا روی در علامت قرمز (Occupied) ظاهر می‌شه.

  2. انتظار (Suspend): اگر کس دیگه‌ای بیاد و ببینه در قفل شده، پشت در می‌ایسته و منتظر می‌مونه. اون شخص «بلاک» نمی‌شه (یعنی نمی‌میره یا کلاً از کار نمی‌افته)، فقط همون‌جا معلق می‌مونه تا در باز بشه.

  3. آزاد کردن (Unlock): نفر اول که کارش تموم شد، در رو باز می‌کنه. علامت روی در سبز (Vacant) می‌شه و نفر بعدی که توی صف بود، وارد می‌شه و بلافاصله در رو پشت سرش قفل می‌کنه.

در کوروتین‌ها، Mutex دقیقاً همین کار رو می‌کنه. اون اجازه نمی‌ده دو نفر (دو کوروتین) همزمان وارد اون «توالت» (قطعه کد حساس یا همان Critical Section) بشن.

چطور کد قبلی رو با Mutex اصلاح کنیم؟

توی اندروید استودیو، برای استفاده از Mutex در کاتلین، کد رو این‌طوری تغییر می‌دیم:

val mutex = Mutex() // ۱. تعریف میوتکس
var counter = 0

suspend fun increment() {
    withContext(Dispatchers.Default) {
        repeat(1000) {
            // ۲. استفاده از withLock برای محافظت از متغیر
            mutex.withLock { 
                counter++ 
            }
        }
    }
}

چرا Mutex برای کوروتین‌ها و Race Condition عالیه؟

نکته طلایی اینجاست: وقتی یک کوروتین پشتِ یک Mutexِ قفل شده منتظر می‌مونه، Thread (رشته) رو اشغال نمی‌کنه. یعنی اون Thread آزاد می‌شه تا بره کارهای دیگه‌ی اپلیکیشن رو انجام بده. وقتی Mutex آزاد شد، کوروتینِ منتظر، دوباره بیدار می‌شه و به کارش ادامه میده. این یعنی بهره‌وری ۱۰۰ درصد!

۲. عملیات اتمیک

در دنیای فیزیک، «اتم» به معنای چیزی است که تجزیه‌ناپذیر باشد. در برنامه‌نویسی هم، وقتی می‌گوییم یک عملیات «اتمیک» است، یعنی آن عملیات یا کامل انجام می‌شود یا کلاً انجام نمی‌شود؛ هیچ حالتِ میانی یا نیمه‌کاره‌ای ندارد که سیستم‌عامل بتواند وسط آن وقفه ایجاد کند.

همان‌طور که قبلاً گفتیم، counter++ یک عملیات ۳ مرحله‌ای بود (برداشتن، اضافه کردن، گذاشتن). اما وقتی از AtomicInteger استفاده می‌کنیم، تمام این ۳ مرحله به یک مرحله‌ی واحد و غیرقابل توقف تبدیل می‌شود. انگار که این سه مرحله در یک کپسولِ بسته انجام می‌شوند که هیچ عامل خارجی نمی‌تواند به داخل آن نفوذ کند.

این مثال رو میتونیم در این کد ببینیم :

var counter = AtomicInteger(0)

suspend fun increment() {
    withContext(Dispatchers.Default) {
        repeat(1000) {
            counter.incrementAndGet()
        }
    }
}

 

یک مثال ساده برای درک بهتر:

تصور کن می‌خواهی تعداد لایک‌های یک پست پرطرفدار را در لحظه بشماری:

  • با روش معمولی: دو نفر همزمان لایک می‌کنند، سیستم گیج می‌شود و فقط یک لایک ثبت می‌شود.

  • با Mutex: نفر اول لایک می‌کند، درِ دیتابیس را قفل می‌کند، نفر دوم پشت در می‌ماند تا نفر اول برود، بعد نفر دوم وارد می‌شود. (امن ولی کمی کُند)

  • با Atomic: انگار یک قلکِ جادویی داری که دهانه‌اش فقط به اندازه یک سکه جا دارد. مهم نیست ۱۰۰ نفر همزمان سکه پرتاب کنند، سکه‌ها یکی‌یکی و پشت سر هم بدون اینکه به هم گیر کنند، داخل قلک می‌افتند.

–عکس اتمیسک

نکته
اگر فقط می‌خواهی یک عدد ساده (مثل شمارنده یا وضعیت یک دکمه) را تغییر دهی، همیشه Atomic انتخاب هوشمندانه‌تری است.

 

۳. کانال‌ها (Channels)

کانال‌ها برای ارسال و دریافت داده‌ها بین کوروتین‌ها به‌طور ایمن و معمولاً برای الگوهای تولید-مصرف استفاده می‌شوند. یک کانال به کوروتین‌ها اجازه می‌دهد داده‌ها را به صورت ناهمزمان ارسال کنند. کوروتین دریافت‌کننده تا زمانی که داده موجود باشد، متوقف می‌شود.

به عنوان مثال میتونیم این طوری بگیم :

داستان کارخانه:

  • تولیدکننده‌ها (Producers): دو کارگر (کوروتین) داریم که فقط وظیفه دارند عدد ۱ را روی نوار نقاله بگذارند. آن‌ها کاری ندارند که در نهایت چه اتفاقی می‌افتد.

  • مصرف‌کننده (Consumer): یک کارگرِ مخصوص در انتهای خط نشسته است. او تنها کسی است که اجازه دارد متغیر counter را ببیند و تغییر دهد. او یکی‌یکی اعداد را از روی نوار نقاله برمی‌دارد و به کانتِر اضافه می‌کند.

— عکس : از گارگر ها داریم

چون فقط یک نفر (کوروتینِ مصرف‌کننده) به متغیر دسترسی دارد، عملاً هیچ تداخل یا شرایط رقابتی (Race Condition) پیش نمی‌آید!

و کد ما به این صورت تغییر میدیم !

val channel = Channel<Int>()
var counter = 0

// ۱. بخش مصرف‌کننده (تنها کسی که تغییر می‌دهد)
launch {
    for (value in channel) {
        counter += value
    }
}

// ۲. بخش تولیدکننده (درخواست می‌فرستد)
launch {
    increment(channel)
}

۴. اکترها (Actors)

یک Actor را مثل یک «کارمند باجه بانک» تصور کن که یک صندوق (متغیر counter) داخل باجه‌اش دارد. قانون بانک این است: هیچ‌کس حق ندارد وارد باجه شود یا به صندوق دست بزند.

تنها راه تعامل با این کارمند، یک دریچه کوچک (همان Channel) روی شیشه باجه است. مشتری‌ها (کوروتین‌های دیگر) فقط می‌توانند «نامه» یا «پیام» از دریچه بفرستند داخل.

  • اگر نامه‌ای بفرستی که رویش نوشته “Increment” (افزایش)، کارمند خودش یک عدد به صندوقش اضافه می‌کند.

  • اگر نامه‌ای بفرستی که نوشته “GetCounter” (مقدار رو بهم بگو)، کارمند موجودی را چک می‌کند و جواب را برایت می‌فرستد.

چون کارمند نامه‌ها را یکی‌یکی و به ترتیب برمی‌دارد، محال است که دو نفر همزمان بتوانند موجودی صندوق را تغییر دهند.

sealed class CounterMsg
object Increment : CounterMsg()
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()

fun CoroutineScope.counterActor(): SendChannel<CounterMsg> = actor<CounterMsg> {
    var counter = 0
    val receiver = launch {
        for (msg in channel) {
            when (msg) {
                is Increment -> counter++
                is GetCounter -> msg.response.complete(counter)
            }
        }
    }
    receiver.join()
}

تحلیل کد به زبان ساده:

  1. پیام‌ها (Sealed Class): این‌ها همان انواع نامه‌هایی هستند که مشتری‌ها اجازه دارند بنویسند. یا دستورِ افزایش (Increment) یا درخواستِ موجودی (GetCounter).

  2. صندوق (var counter = 0): این متغیر کاملاً شخصیِ اکتر است. هیچ‌کس از بیرونِ این تابع نمی‌تواند مستقیم به آن دست بزند.

  3. حلقه پردازش (for (msg in channel)): این همان کارمند است که پشت باجه نشسته و مدام دریچه (کانال) را چک می‌کند تا ببیند نامه‌ی جدیدی آمده یا نه.

چرا از Actor استفاده کنیم؟ (تفاوتش با بقیه چیست؟)

  • کپسوله‌سازی کامل (Encapsulation): در روش‌های قبلی (مثل Mutex یا Atomic)، متغیر counter یک‌جایی در فضای باز بود و ما فقط سعی می‌کردیم از آن محافظت کنیم. در مدل اکتر، متغیر حبس شده است! هیچ‌کس حتی چشمش هم به آن نمی‌افتد، چه برسد به اینکه بخواهد خرابش کند.

  • نظم مطلق: تمام درخواست‌ها در یک صف (Mailbox) قرار می‌گیرند و به نوبت پردازش می‌شوند.

نکته
توابع actor در کتابخانه کوروتین کاتلین در حال حاضر در وضعیت Obsolete (منسوخ شده) هستند و کاتلین پیشنهاد می‌دهد از روش‌های جایگزین (مثل کلاس‌های معمولی که داخلشان Mutex دارند) استفاده کنی. اما دانستن مفهوم آن برای درک سیستم‌های توزیع‌شده و همزمانی حرفه‌ای بسیار حیاتی است.

۵. سمفور (Semaphore)

برای توضیح سمفور های نیاز به یه مثال داریم :

داستان «پارکینگ عمومی»! 🚗

یک پارکینگ را تصور کن که فقط برای ۳ ماشین جا دارد.

  1. مجوز (Permit): ورودی پارکینگ یک دستگاه قرار دارد که تعداد جاهای خالی را نشان می‌دهد (مثلاً عدد ۳).

  2. ورود: هر ماشینی که می‌آید، یک مجوز می‌گیرد و عدد دستگاه می‌شود ۲. وقتی ۳ ماشین وارد شوند، عدد صفر می‌شود.

  3. انتظار (Suspend): ماشین چهارم که می‌رسد، می‌بیند عدد صفر است. او راه را نمی‌بندد (بلاک نمی‌کند)، بلکه همان‌جا خاموش می‌کند و منتظر می‌ماند تا یک ماشین از پارکینگ خارج شود.

  4. خروج: به محض اینکه یک ماشین خارج شد، عدد دستگاه ۱ می‌شود و ماشینِ منتظر بلافاصله مجوز را می‌گیرد و وارد می‌شود.

تفاوت اصلی سمفور با میوتکس چیست؟

در Mutex فقط یک نفر اجازه ورود داشت (مثل دستشویی هواپیما). اما در Semaphore، تو تعیین می‌کنی که چند نفر همزمان اجازه ورود داشته باشند.

  • اگر بنویسی Semaphore(1): دقیقاً مثل میوتکس عمل می‌کند (فقط یک نفر).

  • اگر بنویسی Semaphore(5): یعنی ۵ کوروتین می‌توانند همزمان وارد آن بخش از کد شوند.

var counter = 0
val semaphore = Semaphore(1)

suspend fun increment() {
    withContext(Dispatchers.Default) {
        repeat(1000) {
            semaphore.withPermit {
                counter++
            }
        }
    }
}

توضیح کد :

عدد ۱ به سمفور داده شده: Semaphore(1). این یعنی در هر لحظه فقط یک کوروتین می‌تواند counter++ را انجام دهد. به همین خاطر مشکل شرایط رقابتی Race Condition حل می‌شود و خروجی دقیقاً ۲۰۰۰ خواهد بود.

چرا و کجا از سمفور استفاده می‌کنیم؟

شاید بپرسی: «خب اگر قرار است عدد ۱ را بدهیم، چرا همان میوتکس را استفاده نکنیم؟» پاسخ این است که سمفور برای کنترل ترافیک است، نه فقط قفل کردن.

مثال‌های کاربردی:

  • محدود کردن درخواست‌های شبکه: فرض کن اپلیکیشن تو باید ۱۰۰ عکس را دانلود کند. اگر ۱۰۰ کوروتین همزمان این کار را انجام دهند، ممکنه سرور تو را مسدود کند یا حافظه گوشی پر شود. با یک Semaphore(3) می‌توانی بگویی: «در هر لحظه فقط ۳ عکس دانلود شود؛ هر کدام تمام شد، بعدی شروع شود.»

  • دسترسی به پایگاه داده: محدود کردن تعداد اتصال‌های همزمان به دیتابیس برای جلوگیری از سنگین شدن پردازش.

۶. SharedFlow یا StateFlow

رسیدیم به مدرن‌ترین و «اندرویدی‌ترین» روش! در توسعه اندروید با Jetpack Compose، استفاده از StateFlow و SharedFlow نان شبِ برنامه‌نویس‌هاست.

این مفهوم را هم مثل بقیه، با یک مثال ساده در دنیای واقعی شفاف کنیم:

جریان‌های اشتراکی (SharedFlow & StateFlow) چیست؟

اگر روش‌های قبلی مثل Mutex یا Semaphore را به «قفل» و «پارکینگ» تشبیه کردیم، Flow را باید مثل یک «ایستگاه رادیویی» تصور کنی.

در این روش، کوروتین‌های تولیدکننده (مانند تابع increment) خودِ متغیر را تغییر نمی‌دهند؛ آن‌ها فقط اطلاعات را پخش (Emit) می‌کنند. از طرف دیگر، یک یا چند کوروتینِ شنونده (Collector) وجود دارند که به این امواج گوش می‌دهند و بر اساس آن، وضعیت نهایی را آپدیت می‌کنند.

تفاوت این دو در یک نگاه:

  • StateFlow: مثل یک دماسنج دیجیتال است. همیشه «آخرین مقدار» را نشان می‌دهد. اگر همین الان نگاهش کنی، دمای فعلی را می‌بینی. در اندروید برای نمایش وضعیت رابط کاربری (UI State) عالی است.

  • SharedFlow: مثل یک پخش زنده رادیویی است. اگر رادیو را روشن کنی، فقط از همان لحظه به بعد را می‌شنوی و اتفاقات قبل از آن را از دست داده‌ای. برای فرستادن «اتفاقات لحظه‌ای» (مثل نمایش یک پیام Error یا باز شدن یک صفحه جدید) استفاده می‌شود.

چطور مشکل Race Condition را حل می‌کنند؟

در کد زیر ،  ما فقط در حال «ارسال» عدد ۱ هستیم. برای اینکه شمارنده درست کار کند، باید یک بخشِ جمع‌کننده (Collector) داشته باشیم:

val counterFlow = MutableSharedFlow<Int>()
var counter = 0

// کوروتینِ ناظر (تنها کسی که حق تغییر counter را دارد)
launch {
    counterFlow.collect { value ->
        counter += value
    }
}

// کوروتین‌های اجرا کننده
launch { increment() }
launch { increment() }

چرا این روش امن است؟ چون ما منطقِ «تغییر متغیر» را به یک محیطِ کنترل‌شده (داخل collect) منتقل کردیم. در واقع این روش شباهت زیادی به مدل Channel دارد، با این تفاوت که در اینجا می‌توانیم چندین «شنونده» داشته باشیم.

چرا در اندروید استودیو عاشق این روش هستیم؟

  1. همگام با چرخه حیات (Lifecycle Aware): این جریان‌ها می‌فهمند که اگر کاربر گوشی را چرخاند یا از اپلیکیشن خارج شد، پخش داده را متوقف کنند تا اپلیکیشن کرش نکند.

  2. واکنش‌گرا (Reactive): به محض اینکه counter تغییر کند، رابط کاربری (مثلاً یک Text در Jetpack Compose) خود‌به‌خود آپدیت می‌شود و نیازی نیست تو دستی کد بنویسی.

  3. جداسازی لایه‌ها: بخشِ پردازش (ViewModel) فقط داده ساطع می‌کند و بخشِ نمایش (UI) فقط آن را نشان می‌دهد. این یعنی یک کد تمیز و حرفه‌ای!

مقایسه روش‌های مدیریت Race Condition در یک نگاه

 

روش سطح پیچیدگی سرعت اجرا مورد استفاده اصلی وضعیت کوروتین
میوتکس (Mutex) متوسط معمولی حفاظت از بلوک‌های کد پیچیده و حساس تعلیق (Suspend)
اتمیک (Atomic) ساده بسیار بالا شمارنده‌های ساده و تغییرات تک‌متغیره بدون توقف (Lock-free)
کانال (Channel) متوسط خوب انتقال داده بین تولیدکننده و مصرف‌کننده تعلیق در صورت خالی/پر بودن
اکتر (Actor) بالا خوب مدیریت وضعیت‌های پیچیده و کپسوله‌شده تعلیق (بر پایه کانال)
سمفور (Semaphore) متوسط معمولی کنترل ترافیک و محدود کردن دسترسی همزمان تعلیق در صورت اتمام ظرفیت
جریان (Flow) متوسط خوب مدیریت وضعیت UI و برنامه‌نویسی واکنش‌گرا تعلیق (بر پایه جریان داده)

 

نتیجه‌گیری و پایان‌بندی مقاله

در نهایت، انتخاب ابزار مناسب برای مقابله با شرایط رقابتی (Race Condition)، به ماهیت چالشی که با آن روبرو هستید بستگی دارد. اگر تنها به یک شمارنده ساده نیاز دارید، Atomicها با کمترین هزینه و بالاترین سرعت در خدمت شما هستند. برای محافظت از قطعات حساس کد، Mutex همچنان یک استاندارد طلایی است و اگر به دنبال مدیریت ترافیک و منابع محدود هستید، Semaphore بهترین انتخاب است. اما در دنیای مدرن اندروید و کاتلین، تمایل به سمت الگوهای «ارتباط‌محور» مانند Flow و Channel است؛ چرا که علاوه بر حل مشکل تداخل، کدی تمیزتر، واکنش‌گرا و همگام با چرخه حیات اپلیکیشن به شما هدیه می‌دهند. به یاد داشته باشید که در دنیای موازی کوروتین‌ها، هنر برنامه‌نویس نه فقط در اجرای سریع کد، بلکه در تضمینِ «پیش‌بینی‌پذیری» و «امنیت» داده‌هاست.

و همچنین امیدوارم این مقاله براتون مفید باشه

موفق باشید 🌸

دیدگاه‌ها ۰
ارسال دیدگاه جدید