چرا Async ؟

ما همه عاشق قدرتی هستیم که Rust در نوشتن نرم افزار های سریع و امن میده، اما برنامه نویسی Async چجوری میتونه تو این دیدگاه جا داشته باشه ؟

برنامه نویسی Asynchronous یا Async، یک مدل برنامه نویسی همزمان هست که توسط تعداد زیادی از زبان های برنامه نویسی پشتیبانی میشه، که این امکان رو به شما میده که تعداد زیادی عملیات رو روی تعداد کمی thread سیستم عاملی انجام بدید. این در حالیه که کدی که مینویسید به خاطر کلید واژه های async/await از نظر ظاهری و حسی خیلی شبیه برنامه های عادی sync هست.

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

برنامه نویسی همزمان نسبت به برنامه نویسی معمولی و متوالی هنوز پخته نشده و استاندارد هاش کمتره، در نتیجه بسته به اینکه اون زبان برنامه نویسی از کدوم مدل همزمانی پشتیبانی میکنه، همزمانی رو به صورت های مختلفی میشه بیان کرد. یه مرور سریع روی محبوب ترین مدل های برنامه نویسی همزمان میتونه بهتون در فهمیدن اینکه برنامه نویسی Async چه جایگاهی در این حوزه وسیع داره کمک کنه:

  • Thread های سیستم عاملی: تو این روش نیازی به تغییر تو مدل برنامه نویسی وجود نداره، که همین موضوع رسیدن به مدل همزمانی رو خیلی آسون تر میکنه. البته sync کردن و همزمان کردن thread ها خودش میتونه در مواقعی خیلی سخت باشه، و در ضمن تو این روش سربار و افت سرعت قابل توجه هست. روش هایی مثل Thread Pool میتونه یکم تو کم کردن این سربار ها و افزایش سرعت کارساز باشه، ولی در نهایت برای حجم زیاد عملیات هایی که محدود به I/O هستند کافی نیست.

  • برنامه نویسی Event محور: این روش با استفاده از callback ها میتونه سرعت رو افزایش بده، ولی منجر به ایجاد یه جریان غیر خطی توی برنامه میشه که پیگیری و عیب یابی از این نوع برنامه رو سخت میکنه.

  • روش Coroutine: مثل thread ها تغیری توی مدل برنامه نویسی نیاز ندارن، که استفاده ازشون رو راحت تر میکنه. مثل Async میتونن تعداد زیادی عملیات رو به طور همزمان پوشش بدن. با این وجود یه سری جزئیات سطح پایین که برای برنامه نویسی سیستم و runtime های خاص مهم هستند رو در نظر نمیگیره.

  • مدل بازیگر (Actor): در این روش تمام محاسبات همزمان به صورت واحد هایی به اسم بازیگر تقسیم میشن، که با هم در ارتباط هستن، دقیقا شبیه سیستم های توزیع شده. این روش میتونه خیلی بهینه پیاده سازی بشه اما بسیاری از مسائل کاربردی مثل کنترل جریان و منطق مجدد (مثلا بعد از یک خطا) رو بی پاسخ میزاره.

به طور خلاصه، برنامه نویسی Async این اجازه رو به شما میده که برنامه های فوق العاده بهینه و با سرعت بالا برای زبان های سطح پایینی مثل Rust پیاده سازی کنید، در حالی که بسیاری از مزایای thread ها و دیگر روش ها رو هم پوشش میدن.

Async در Rust در مقایسه با بقیه زبان ها

اگرچه برنامه نویسی Async توسط خیلی از زبان ها پشتیبانی میشه، ولی تو بعضی از جزئیات وقتی به قسمت پیاده سازی میرسیم با هم دیگه فرق دارن. پیاده سازی Async ولی روشی که تو Rust ازش استفاده میشه با بیشتر زبان ها تو زمینه های زیر فرق داره:

  • Future های بی جان: Future ها توی Rust تا وقتی صداشون نزنید و چیزی که قرار بوده بهتون بدن رو ازشون نگیرید پردازشی رو جلو نمیبرن. نگه نداشتن Future ها باعث میشه عملیاتی که داشتن انجام میدادن دیگه تا آخر انجام نشه و متوقف بشه.

  • Async بدون هزینه هست: توی Rust استفاده از Async هزینه ای نداره، این یعنی شما از نظر سخت افزاری فقط هزینه چیزایی که استفاده میکنید رو میدید، نکته قابل توجهش اینه که میتونید بدون گرفتن فضای heap از ram از Async استفاده کنید و به صورت پویا نتیجه عملیات Async رو انجام بدید که برای سرعت سیستم خیلی چیزه خوبیه.

  • Runtime حاظر و آماده ای وجود نداره: توی Rust برای داستان Async هیچ runtime آماده ای وجود نداره. در عوض runtime ها توسط جامعه برنامه نویسان Rust نوشته شده و به صورت پکیج های قابل نصب crate موجوده.

  • runtime های single-thread و multi-thread موجوده: توی Rust هر دو نوع runtime های single-thread ای و multi-thread ای موجوده، که البته هر کدوم مزایا و معایب خودشونو دارن.

تفاوت Async و استفاده از Thread ها در Rust

جایگزین اصلی برای Async در Rust استفاده از thread های سیستم عاملی هست، چه به صورت مستقیم با استقاده از std::thread یا به صورت غیر مستقیم با استفاده از thread pool. مهاجرت از thread ها به Async و برعکس معمولا نیاز به بازنویسی اساسی توی کد داره، هم از نظر پیاده سازی و هم نظر ساختن یک راه ارتباطی عمومی برای قسمت های مختلف (وقتی مثلا یک کتابخانه میسازید). به همین خاطر انتخاب مدلی که دقیقا طبق نیاز های سیستم شما باشه میتونه تو زمان پیاده سازی خیلی صرفه جویی کنه.

Thread های سیستم عاملی برای انجام task ها و عملیات مختلف تو مقیاس کم مناسب هستن، چون thread ها یه سربار اضافه تری برای CPU و حافظه (RAM) هستن. ساخت و تعویض بین thread هااز نظر سخت افزاری خیلی هزینه بره حتی thread هایی که استفاده نمیشن یا به اصطلاح idle هستن هم منابع مصرف میکنن. استفاده از thread pool میتونه تو کم کردن این هزینه ها تاثیر داشته باشه، ولی نه تو همه چی. اگرچه thread ها این اجازه رو به شما میدن که از همون کد عادی sync بدون تغییرات خیلی اساسی بتونید استفاده کنید و هیچ مدل برنامه نویسی نیاز نداره. همچنین توی بعضی سیستم عامل ها میتونید اولویت اجرای thread ها رو عوض کنید، که میتونه خیلی چیزه مفیدی برای driver ها یا برنامه هایی که خیلی حساس به زمان اجرا و latency هستن باشه.

روش Async به طور چشمگیری استفاده از CPU و حافظه رو کاهش میده، مخصوصا برای کارهایی که به طور خاص I/O زیادی دارن، مثل سرور ها یا دیتابیس ها. به همین خاطر میتونید task ها و عملیات بیشتری نسبت به thread های سیستم عاملی داشته باشید، چون یک runtime ای که به صورت async هست از تعداد thread های کمتر (که برای ما هزینه بر بودن) برای اجرای عملیات و task های بیشتر (که از نظر منابعی که استفاده میکنن کم هزینه تر هستن) استفاده میکنه.

یک نکته ای که آخر باید اشاره کنیم اینه که برنامه نویسی Async بهتر از thread ها نیست، در واقع تفاوتشونه که مهمه. اگه به Async برای رسیدن به نتایج بهتر توی سرعت نیاز ندارید، thread ها معمولا جایگزین های راحت تری هستن.

مثالی از دانلود فایل ها به صورت همزمان

تو این مثال هدف ما دانلود دو تا صفحه وب به صورت همزمانه. توی یه برنامه معمول برای اینکار نیاز داریم که thread های جدید ایجاد کنیم تا به همزمانی برسیم:

fn get_two_sites() {
    // ایجاد دو تا ترد جدید که کار دانلود رو انجام بده.
    let thread_one = thread::spawn(|| download("https://www.foo.com"));
    let thread_two = thread::spawn(|| download("https://www.bar.com"));

    // صبر میکنیم تا هردو ترد کارشون تموم شه
    thread_one.join().expect("thread one panicked");
    thread_two.join().expect("thread two panicked");
}

این درحالیه که دانلود کردن یک صفحه وب یک کار خیلی کوچیکه و ایجاد thread جدید برای همچین کار کوچیکی واقعا هزینه سخت افزاری زیادی از ما میگیره. برای برنامه های بزرگتر این مسئله خیلی راحت میتونه تبدیل به یه معضل بزرگ بشه، توی برنامه نویسی Async در Rust میتونیم همین کار رو بکنیم بدون نیاز به ساختن thread های اضافی:

async fn get_two_sites_async() {
    // ایجاد دو تا فیوچر جدید که پس از تموم شدن کارشون
    // صفحات وب رو به صورت ناهمزمان دانلود میکنن
    let future_one = download_async("https://www.foo.com");
    let future_two = download_async("https://www.bar.com");

    // هر دو فیوچر رو اجرا میکنیم که در یک زمان کارشون تموم شه
    join!(future_one, future_two);
}

اینجا هیچ thread اضافه ای ساخته نشده. تمام توابعی که صداشون زدیم به صورت ثابت استفاده شدن، و هیچ استفاده ای از قسمت heap توی ram نکردیم! اگرچه نیاز داریم که کد رو به صورت async بنویسم در وحله اول، که این کتاب دقیقا میخواد به شما تو رسیدن این هدف کمک کنه.

مدل های همزمانی شخصی سازی شده در Rust

در آخر باید بگیم که Rust شما رو اجبار به انتخاب بین دو مدل thread و async نمیکنه. شما میتونید از هر دو مدل توی یه برنامه استفاده کنید، که میتونه خیلی هم مفید باشه وقتی thread هایی دارید که به عملیات های async وابستگی دارن. در حقیقت، شما میتونید حتی از مدل های همزمانی مختلف دیگه هم استفاده کنید مثل مدل Event محور یا چیزای دیگه، تا وقتی کتابخانه هایی دارید که اینا رو پیاده سازی کردن.