چرا ادغام Event Loopها کار ساده‌ای نیست؟
ﺯﻣﺎﻥ ﻣﻄﺎﻟﻌﻪ: 14 دقیقه

چرا ادغام Event Loopها کار ساده‌ای نیست؟

اگر با Node.js کار کرده باشید، احتمالا می‌دانید که تقریبا همه چیز حول محور Event Loop می‌چرخد. این حلقه مسئول مدیریت درخواست‌های ورودی، عملیات ورودی و خروجی، تایمرها و اجرای کدهای ناهمگام است. به همین دلیل، هر چیزی که Event Loop را متوقف یا کُند کند، می‌تواند روی عملکرد کل برنامه تاثیر بگذارد.

حالا تصور کنید بخواهید یک رابط کاربری دسکتاپ نیز به چنین برنامه‌ای اضافه کنید. اینجاست که یک مشکل جالب به وجود می‌آید. ابزارهای ساخت رابط کاربری مانند Slint نیز Event Loop مخصوص خود را دارند تا بتوانند رویدادهای مربوط به پنجره، کلیک کاربر، رندر رابط کاربری و سایر تعاملات را مدیریت کنند.

اما یک برنامه نمی‌تواند دو Event Loop مستقل داشته باشد که هر کدام کنترل اجرای برنامه را در دست بگیرند. اگر یکی از آن‌ها مشغول اجرا باشد، دیگری فرصت پردازش رویدادهای خود را از دست می‌دهد. نتیجه این وضعیت می‌تواند افزایش مصرف پردازنده، تاخیر در پاسخ‌گویی رابط کاربری یا حتی از کار افتادن برخی قابلیت‌های برنامه باشد.

در نسخه ۱.۱۷، تیم توسعه Slint موفق شد این مشکل را در لینوکس و macOS تا حد زیادی برطرف کند و Event Loop خود را به شکل موثرتری با Event Loop مربوط به Node.js هماهنگ کند.

در این مطلب بررسی می‌کنیم چرا این مسئله تا این اندازه پیچیده است و Slint برای حل آن چه راهکاری را انتخاب کرده است.

Event Loop چیست؟

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

این اتفاق‌ها می‌توانند شامل مواردی مانند کلیک کاربر، دریافت یک بسته شبکه، تمام شدن یک تایمر یا تغییر اندازه پنجره باشند. برای مدیریت این رویدادها، بسیاری از برنامه‌ها از مفهومی به نام Event Loop استفاده می‌کنند.

Event Loop در ساده‌ترین تعریف، یک حلقه بی‌نهایت است که در تمام طول اجرای برنامه فعال باقی می‌ماند و فقط زمانی متوقف می‌شود که برنامه بسته شود. این حلقه منتظر دریافت رویدادهای جدید می‌ماند، آن‌ها را به بخش مربوطه ارسال می‌کند و دوباره به حالت انتظار بازمی‌گردد.

یک مدل ساده از عملکرد Event Loop را می‌توان به این شکل تصور کرد:

loop {
    let timeout = time_until_next_timer();
    // Block until an event arrives or until a timer expires.
    let events = wait_for_events(timeout);

    for event in events {
        dispatch(event); // deliver input, resize, ... to the UI
    }

    fire_due_timers();      // run expired timer callbacks
    run_posted_callbacks(); // callbacks posted from other threads or async tasks
    render_frame();         // repaint windows whose contents changed
}

در برنامه‌های واقعی، مرحله انتظار برای رویدادها معمولا مستقیما توسط سیستم‌عامل مدیریت می‌شود. سیستم‌عامل برای این کار APIهای مختلفی ارائه می‌دهد:

  • در لینوکس از epoll استفاده می‌شود.
  • در BSD و macOS از kqueue استفاده می‌شود.
  • در ویندوز از I/O Completion Ports استفاده می‌شود.

این ابزارها به برنامه اجازه می‌دهند بدون بررسی مداوم وضعیت منابع، منتظر رخ دادن رویدادها بماند. به همین دلیل، برنامه می‌تواند تعداد زیادی اتصال یا رویداد هم‌زمان را با مصرف منابع کمتر مدیریت کند.

در Node.js نیز همین ایده پایه اصلی معماری اجرا است. Event Loop باعث می‌شود Node.js بتواند تعداد زیادی عملیات ورودی و خروجی را بدون ایجاد Thread جداگانه برای هر درخواست مدیریت کند.

اما زمانی که یک فریمورک رابط کاربری مانند Slint نیز Event Loop مخصوص خود را داشته باشد، یک سوال مهم ایجاد می‌شود: کدام Event Loop باید کنترل برنامه را در اختیار داشته باشد؟ و آیا می‌توان این دو را بدون ایجاد اختلال با هم ترکیب کرد؟

دو Event Loop، یک Thread

در برنامه‌ای که از اتصال (Binding) مربوط به Slint برای Node.js استفاده می‌کند، در حقیقت دو Event Loop وجود دارد که باید یک Thread مشترک را مدیریت کنند.

از یک طرف، Node.js مسئول اجرای libuv است. libuv هسته اصلی Event Loop در Node.js محسوب می‌شود و وظیفه مدیریت بسیاری از عملیات‌های غیرهم‌زمان را بر عهده دارد، از جمله درخواست‌های شبکه، خواندن و نوشتن فایل، درخواست‌های DNS و اجرای پردازش‌های فرزند (Child Process).

از طرف دیگر، Slint نیز Event Loop مخصوص خود را دارد که بر پایه winit ساخته شده است. winit وظیفه ارتباط با سیستم پنجره‌ای سیستم‌عامل را بر عهده دارد و رویدادهایی مانند کلیک‌های کاربر، فشردن کلیدها، تغییر اندازه پنجره و به‌روزرسانی نمایش را مدیریت می‌کند.

مشکل اصلی اینجاست که این دو Event Loop باید روی یک Thread مشترک اجرا شوند.

در نگاه اول شاید راه‌حل ساده این باشد که Event Loop مربوط به Slint را روی یک Worker جداگانه اجرا کنیم و اجازه دهیم Node.js کنترل Thread اصلی را حفظ کند. اما این کار امکان‌پذیر نیست.

دلیل اول این است که ویژگی‌های رابط کاربری (Slint Properties) هم هنگام Render شدن رابط و هم داخل Callbackهای مربوط به رویدادهای رابط کاربری مورد استفاده قرار می‌گیرند. این Callbackها نیز در نهایت باید بتوانند به کد JavaScript دسترسی پیدا کنند و اجرای JavaScript در Node.js نیازمند استفاده از Thread اصلی است.

دلیل دوم به محدودیت‌های macOS مربوط می‌شود. در این سیستم‌عامل، رابط کاربری گرافیکی فقط می‌تواند از Thread اصلی اجرا شود. اما همین Thread از قبل توسط Node.js اشغال شده است.

بنابراین راه‌حل ساده جدا کردن Event Loopها و انتقال Slint به یک Thread دیگر، عملا امکان‌پذیر نیست. Slint و Node.js باید یاد بگیرند چگونه روی یک Thread واحد با یکدیگر همکاری کنند، و همین موضوع چالش اصلی طراحی این اتصال است.

راهکار Tick شانزده میلی‌ثانیه‌ای

ساده‌ترین راهکاری که می‌تواند روی هر Runtimeای کار کند، استفاده از یک تایمر تکرارشونده در JavaScript است. در این روش، برنامه یک setInterval(16) ایجاد می‌کند که هر ۱۶ میلی‌ثانیه یک‌بار به کد Rust فراخوانی ارسال می‌کند.

در هر فراخوانی، Rust فقط یک مرحله از Event Loop مربوط به Slint را اجرا می‌کند، آن هم به شکلی که مسدودکننده (Non-blocking) نباشد. سپس کنترل دوباره به Node.js بازمی‌گردد.

با این روش، هر دو Event Loop فرصت اجرا پیدا می‌کنند:

  • Rust یک بخش کوتاه از پردازش رویدادهای Slint را انجام می‌دهد.
  • سپس libuv در Node.js اجرا می‌شود و تایمرها، عملیات ورودی و خروجی و سایر وظایف JavaScript را پردازش می‌کند.

اما این راه‌حل ساده، مشکلاتی نیز دارد.

اولین مشکل، مصرف بی‌دلیل CPU است. حتی زمانی که هیچ رویداد جدیدی وجود ندارد، برنامه همچنان هر ۱۶ میلی‌ثانیه بیدار می‌شود و بخشی از Event Loop را اجرا می‌کند. در نتیجه، پردازنده بی‌دلیل درگیر می‌شود و برنامه هیچ‌وقت واقعا به حالت انتظار نمی‌رود.

مشکل دوم، افزایش تاخیر در تایمرهای JavaScript است. از آنجا که Slint فقط در فاصله‌های ۱۶ میلی‌ثانیه‌ای فرصت اجرا پیدا می‌کند، هر تایمر Node.js ممکن است تا ۱۶ میلی‌ثانیه دیرتر از زمان مورد انتظار اجرا شود.

این روش برای یک نمونه اولیه یا برنامه‌های ساده قابل قبول است، اما برای یک راهکار حرفه‌ای که باید هم مصرف منابع پایینی داشته باشد و هم پاسخ‌گویی سریع ارائه دهد، کافی نیست. به همین دلیل، تیم Slint به دنبال راهی بهتر برای هماهنگ کردن این دو Event Loop رفت.

استفاده از Prepare Hook

در نسخه Slint 1.17، تیم توسعه برای لینوکس و macOS راهکار قبلی یعنی Tick شانزده میلی‌ثانیه‌ای را کنار گذاشت و به جای آن یکپارچه‌سازی واقعی با libuv را پیاده‌سازی کرد.

ایده اصلی این روش این است که Event Loop مربوط به Slint در زمان مناسب داخل چرخه اجرای libuv اجرا شود، نه با یک زمان‌بندی ثابت و مصنوعی.

یک چرخه معمولی libuv به شکل زیر عمل می‌کند:

  1. به‌روزرسانی ساعت داخلی
  2. اجرای Timerهای آماده
  3. اجرای Callbackهای منتظر
  4. اجرای Prepare Hookها = Slint اینجا اجرا می‌شود
  5. انتظار برای رویدادهای I/O (تا مدت مشخصی منتظر می‌ماند)
  6. اجرای Check Hookها
  7. اجرای Callbackهای مربوط به بستن منابع

libuv یک قابلیت به نام uv_prepare_t ارائه می‌دهد. این Handle اجازه می‌دهد یک Callback در هر چرخه Event Loop اجرا شود، دقیقا بعد از اجرای Timerها و قبل از اینکه libuv وارد مرحله انتظار برای رویدادهای I/O شود.

این نقطه برای Slint بسیار مناسب است، چون:

  • Timerهای JavaScript در Node.js ابتدا فرصت اجرا پیدا کرده‌اند.
  • Slint می‌تواند رویدادهای رابط کاربری خود را پردازش کند.
  • سپس libuv می‌تواند برای دریافت رویدادهای بعدی وارد حالت انتظار شود.

در این روش، Callback مربوط به Slint ابتدا بررسی می‌کند که libuv تا چه زمانی می‌تواند بدون نگرانی در حالت انتظار باقی بماند:

fn prepare_callback() {
    let timeout = uv_backend_timeout(uv_loop);

    process_slint_events_with_timeout(timeout);
}

تابع uv_backend_timeout() در libuv به این سوال پاسخ می‌دهد:

حداکثر چه مدت می‌توانیم منتظر بمانیم تا قبل از اینکه یک Timer یا رویداد I/O جدید نیاز به پردازش داشته باشد؟

Slint از همین مقدار استفاده می‌کند تا دقیقا به اندازه لازم منتظر بماند. اگر هیچ رویدادی وجود نداشته باشد، پردازنده بی‌دلیل درگیر نمی‌شود. اگر یک رویداد رابط کاربری مانند کلیک یا تغییر اندازه پنجره رخ دهد، برنامه می‌تواند زودتر از زمان تعیین‌شده بیدار شود.

نکته مهم این طراحی این است که Slint دیگر مجبور نیست هر ۱۶ میلی‌ثانیه خود را اجرا کند. در عوض، با Event Loop اصلی Node.js هماهنگ می‌شود و فقط زمانی فعال می‌شود که واقعا کاری برای انجام دادن وجود داشته باشد.

این تغییر باعث می‌شود برنامه هم مصرف CPU کمتری داشته باشد و هم پاسخ‌گویی بهتری نسبت به روش Tick ثابت ارائه دهد.

بیدار کردن Slint هنگام آماده شدن I/O در libuv

Prepare Callback مشکل هماهنگ کردن Slint با چرخه اجرای libuv را از یک جهت حل می‌کند، اما یک مشکل دیگر نیز وجود دارد.

در حالت عادی، Slint ممکن است در حال انتظار برای یک رویداد رابط کاربری باشد، در حالی که libuv یک عملیات ورودی و خروجی (I/O) آماده پردازش دارد. در چنین شرایطی، Slint باید بتواند سریع متوجه این موضوع شود و کنترل را دوباره به Node.js بازگرداند.

برای حل این مشکل، Slint به فایل‌دسکریپتور اصلی libuv یعنی uv_backend_fd() نظارت می‌کند. این File Descriptor همان چیزی است که libuv برای دریافت رویدادهای I/O از سیستم‌عامل استفاده می‌کند.

در لینوکس، این مکانیزم معمولا بر پایه epoll و در macOS بر پایه kqueue کار می‌کند. وقتی هر نوع عملیات I/O آماده پردازش شود، وضعیت این File Descriptor تغییر می‌کند و Slint می‌تواند متوجه شود که باید Event Loop خود را متوقف کرده و کنترل را به libuv برگرداند.

برای این کار، Slint یک Future روی Event Loop خودش ایجاد می‌کند. تابع spawn_local یک Waker در اختیار این Future قرار می‌دهد. زمانی که File Descriptor قابل خواندن شود، این Waker فراخوانی می‌شود و Event Loop مربوط به Slint را بیدار می‌کند.

نمونه ساده‌شده این منطق:

let backend_fd = uv_backend_fd(uv_loop);

let async_fd = async_io::Async::new_nonblocking(backend_fd)?;

slint::spawn_local(async move {
    while async_fd.readable().await.is_ok() {}
})?;

نکته جالب این است که Slint واقعا داده‌ای از این File Descriptor نمی‌خواند. هدف فقط این است که بفهمد libuv یک رویداد جدید برای پردازش دارد.

وقتی این Future بیدار می‌شود، تابع process_slint_events_with_timeout که داخل Prepare Hook در حال انتظار است، پایان پیدا می‌کند و کنترل دوباره به libuv بازمی‌گردد.

سپس Slint با استفاده از uv_async_send() به libuv اطلاع می‌دهد که باید در چرخه بعدی اجرای خود Callback مربوطه را فراخوانی کند.

در نتیجه، دو Event Loop بدون اینکه یکی دیگری را مسدود کند، با یکدیگر همکاری می‌کنند:

  • وقتی Slint رویدادی برای رابط کاربری دارد، آن را پردازش می‌کند.
  • وقتی Node.js عملیات I/O آماده دارد، Slint سریع کنترل را بازمی‌گرداند.
  • Thread اصلی بین این دو محیط به شکل هماهنگ تقسیم می‌شود.

این همان چیزی است که باعث می‌شود ترکیب یک فریمورک رابط کاربری Rust مانند Slint با Runtimeای مانند Node.js بدون مصرف اضافی CPU و بدون ایجاد تاخیرهای غیرضروری امکان‌پذیر شود.

وضعیت در ویندوز

راهکاری که برای لینوکس و macOS استفاده شد، به‌صورت مستقیم در ویندوز قابل استفاده نیست. دلیل این تفاوت به معماری مدیریت I/O در این سیستم‌عامل برمی‌گردد.

در سیستم‌های Unix مانند لینوکس و macOS، معمولا یک File Descriptor مشخص وجود دارد که می‌توان آن را برای دریافت رویدادهای جدید زیر نظر گرفت. اما ویندوز از مکانیزمی به نام I/O Completion Ports استفاده می‌کند که معماری متفاوتی دارد و بر پایه یک File Descriptor قابل انتظار نیست.

از نظر فنی، قابلیت‌های لازم برای دسترسی به این اطلاعات در داخل libuv وجود دارد، اما این بخش‌ها در API عمومی libuv قرار نگرفته‌اند و Node.js نیز آن‌ها را در اختیار برنامه‌ها قرار نمی‌دهد.

برخی پروژه‌ها مانند Electron برای حل این مشکل، نسخه اصلاح‌شده‌ای از libuv استفاده می‌کنند. آن‌ها یک تغییر اختصاصی (Patch) ایجاد کرده‌اند که امکان دسترسی به Handle مربوط به Completion Port را فراهم می‌کند. اما این تغییر هنوز به نسخه اصلی libuv اضافه نشده است.

قرار است نسخه libuv 2.x این مشکل را به شکل استاندارد حل کند، اما عرضه آن در آینده نزدیک اتفاق نخواهد افتاد. علاوه بر این، حتی پس از انتشار libuv 2.x، مدتی زمان لازم است تا Node.js نیز از آن پشتیبانی کند.

به همین دلیل، در حال حاضر نسخه ویندوز همچنان از همان راهکار ساده‌تر یعنی Tick شانزده میلی‌ثانیه‌ای استفاده می‌کند.

با این حال، امیدوارکننده‌ترین راهکار آینده، ایجاد یک Runner اختصاصی به نام node-slint است که مانند Electron، نسخه‌ای سفارشی از libuv را همراه خود داشته باشد. این روش می‌تواند محدودیت‌های فعلی ویندوز را دور بزند و امکان یکپارچه‌سازی کامل‌تر بین Event Loopهای Slint و Node.js را فراهم کند.

وضعیت Deno و Bun

راهکاری که برای هماهنگ کردن Slint و Node.js استفاده شد، به وجود قابلیت‌های خاص libuv وابسته است. اما همه Runtimeهای JavaScript از libuv استفاده نمی‌کنند.

برای مثال، Deno اصلا بر پایه libuv ساخته نشده است. این Runtime با زبان Rust توسعه داده شده و از Tokio به عنوان Runtime اصلی عملیات غیرهم‌زمان استفاده می‌کند.

از طرف دیگر، Bun نیز معماری اختصاصی خود را دارد و از Runtime متفاوتی نسبت به Node.js استفاده می‌کند.

به همین دلیل، هیچ‌کدام از این دو Runtime همان Hookهایی را که Slint برای هماهنگی با Node.js استفاده می‌کند، در اختیار نمی‌گذارند.

راهکاری که تیم Slint برای آینده در نظر دارد، تغییر جهت معماری است، یعنی به جای اینکه Runtime JavaScript کنترل‌کننده اصلی باشد، Event Loop مربوط به Slint نقش اصلی را بر عهده بگیرد و عملیات غیرهم‌زمان Runtimeهای مختلف از طریق slint::spawn_local روی آن زمان‌بندی شوند.

در این مدل، Slint دیگر به جزئیات داخلی Runtime وابسته نخواهد بود و می‌تواند با محیط‌های مختلف JavaScript بهتر هماهنگ شود.

اما تا زمانی که این معماری پیاده‌سازی شود، Deno و Bun همچنان از همان فایل باینری .node استفاده می‌کنند که برای Node.js ساخته شده است.

برای جلوگیری از مشکل در زمان اجرا، Slint وابستگی‌های مربوط به libuv را به‌صورت پویا و با استفاده از libloading پیدا می‌کند. اگر Runtime میزبان این Symbolها را ارائه ندهد، برنامه به‌جای اینکه با خطا متوقف شود، به روش قدیمی یعنی Tick شانزده میلی‌ثانیه‌ای بازمی‌گردد.

این طراحی باعث می‌شود سازگاری با Runtimeهای مختلف حفظ شود، حتی اگر آن‌ها معماری داخلی متفاوتی نسبت به Node.js داشته باشند.

جمع‌بندی

هماهنگ کردن دو Event Loop در یک Thread، مسئله‌ای ساده نیست. Slint و Node.js هر دو برای مدیریت رویدادها به چرخه اجرای خودشان نیاز دارند، اما اجرای هم‌زمان آن‌ها بدون طراحی دقیق می‌تواند باعث مصرف بالای CPU، تاخیر در اجرای کدهای JavaScript یا کاهش پاسخ‌گویی رابط کاربری شود.

راهکار اولیه یعنی اجرای Slint در بازه‌های ثابت ۱۶ میلی‌ثانیه‌ای، اگرچه ساده و قابل استفاده بود، اما محدودیت‌هایی داشت. استفاده از Prepare Hook در libuv نشان داد که می‌توان Event Loopهای مختلف را به شکل هوشمندانه‌تری هماهنگ کرد؛ به‌طوری که Slint فقط زمانی فعال شود که واقعا رویدادی برای پردازش وجود داشته باشد.

این تجربه یک نکته مهم درباره توسعه نرم‌افزارهای مدرن نشان می‌دهد: ترکیب چند فناوری قدرتمند همیشه فقط به اتصال APIها محدود نمی‌شود. گاهی لازم است عمیق‌تر به نحوه کار Runtime، سیستم‌عامل و معماری داخلی ابزارها نگاه کنیم.

تلاش تیم Slint برای هماهنگ کردن با Node.js نمونه‌ای از همین چالش‌هاست؛ جایی که درک دقیق از Event Loop، مدیریت I/O و محدودیت‌های هر پلتفرم، تفاوت بین یک راهکار موقت و یک طراحی پایدار را رقم می‌زند.

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

چه امتیازی برای این مقاله میدهید؟

خیلی بد
بد
متوسط
خوب
عالی
در انتظار ثبت رای

/@arastoo
ارسطو عباسی
کارشناس تست نرم‌افزار و مستندات

...

دیدگاه و پرسش
برای ارسال دیدگاه لازم است وارد شده یا ثبت‌نام کنید ورود یا ثبت‌نام

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

ارسطو عباسی

کارشناس تست نرم‌افزار و مستندات

مقالات برگزیده

مقالات برگزیده را از این قسمت میتوانید ببینید

مشاهده همه مقالات