async/.await

توی فصل اول نگاه کوتاهی به ‍‍async/.await داشتیم. توی این فصل با جزئیات بیشتری روی async/.await بحث میکنیم و میبینم چجوری کد async با کد عادی که قبلا تو Rust میدیدیم فرق داره.

async/.await کلیدواژه های ویژه ای هستند که قدرت برنامه نویسی async رو به Rust میدن و اجازه میدن قدرت به thread برگرده و عملیات بعدی رو انجام بده در حالی که منتظر جواب یه کد async هست.

دو راه اصلی برای استفاده از async وجود داره: یکی async fn و اون یکی بلاک های async هستن. که هر کدوم مقداری رو بر میگردونن که trait ‍‍‍‍Future رو پیاده سازی کرده.


// تابع زیر تایپی رو برمیگردونه که "تریت" زیر رو پیاده سازی کرده:
// `Future<Output = u8>`
// که باعث میشه در نهایت تایپ زیر برگرده:
// u8
async fn foo() -> u8 { 5 }

fn bar() -> impl Future<Output = u8> {
    // بلاک زیر تایپی رو برمیگردونه از نوع:
    // `Future<Output = u8>`
    async {
        let x: u8 = foo().await;
        x + 5
    }
}

همونطور که توی فصل اول دیدیم بدنه async و بقیه Future ها تنبل هستن: به این معنی که تا زمانی که اجرا نشن هیچ کاری نمیکنن. معمول ترین روش اجرا کردن Future اینه که منتظرش بمونید یا .await اش کنید. زمانی که .await روی یه Future صدا زده میشه سعی میکنه اونو اجراش کنه تا با موفقیت تموم بشه. اگه Future بلاک کننده و مسدود کندده thread باشه کنترل رو به thread بر میگردونه. وقتی پردازش بیشتری میتونه انجام بشه Future دوباره توسط اجرا کننده برداشته میشه و ادامه پردازشش انجام میشه و این باعث میشه .await با موفقیت تکمیل بشه.

Lifetime های async

برعکس توابع معمول در Rust، توابع async fn ای که رفرنس ها یا آرگومان های غیر از static رو به عنوان ورودی میگیرن، Future ای بر میگردونن که lifetime شون دقیقا مثل lifetime آرگومان هاشون هست:

// این تابع:
async fn foo(x: &u8) -> u8 { *x }

// با این تابع برابره:
fn foo_expanded<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a {
    async move { *x }
}

که یعنی تابع async fn ای که یک Future بر میگردونه باید توسط .await صدا زده بشه در حالی که هنوز آرگومان های غیر static اش موجود هستن. تو حالت عادی موقعی که .await میکنید بعد از اینکه تابع رو صدا میزنید مثلا مثل foo(&x).await موردی به موجود نمیاد و مشکلی نیست. با این حال، ذخیره کردن Future ها یا ارسالشون به یک task یا thread دیگه ممکنه مشکل ساز بشه.

یکی از کارهایی که برای تبدیل asyn fn با آرگومان هایی از جنس رفرنس به Future های 'static میشه انجام داد، اینه که آرگومان ها رو با صدا کردن async fn داخل یک بلاک async جا بدیم:

fn bad() -> impl Future<Output = u8> {
    let x = 5;
    borrow_x(&x) // ارور: مقدار "ایکس" زمان زیادی زنده نیست یعنی لایف تایمش اجازه نمیده
}

fn good() -> impl Future<Output = u8> {
    async {
        let x = 5;
        borrow_x(&x).await
    }
}

با انتقال آرگومان ها به بلاک async، lifetime اون رو بسط دادیم به lifetime اون Future ای که از صدا زدن good برمگیرده.

async move

بلاک های async و clouser مثل clouser های عادی اجازه استفاده از کلیدواژه move رو میده. یه بلاک async move باعث میشه مالکیت تمام متغیر هایی که به عنوان رفرنس استفاده میشن رو بگیره و عمرشون دقیقا به اون scope محدود بشه که این قابلیت باعث میشه دیگه نشه ازشون تو قسمت های دیگه کد استفاده کرد:

/// `async` block:
///
/// چندین بلاک از نوع زیر میتونن به متغیر های لوکال دسترسی داشته باشن
/// البته تا وقتی که تو اون اسکوپ اجرا بشن
async fn blocks() {
    let my_string = "foo".to_string();

    let future_one = async {
        // ...
        println!("{my_string}");
    };

    let future_two = async {
        // ...
        println!("{my_string}");
    };

    // هر دو "فیوچر" اجرا میشه تا در نهایت دو بار مقدار زیر تپرینت میشه:
    // "foo"
    let ((), ()) = futures::join!(future_one, future_two);
}

/// `async move` block:
///
/// فقط یک بلاکی که کلیدواژه "موو" داره میتونه به مقادیر لوکال در لحظه دسترسی داشته باشه
/// به خاطر اینکه اون مقادیر به اسکوپ "فیوچر" منتقل شدن
/// البته همین باعث میشه اون "فیوچر" بیشتر از اسکوپ اصلی زنده بمونه و دووم بیاره:
fn move_block() -> impl Future<Output = ()> {
    let my_string = "foo".to_string();
    async move {
        // ...
        println!("{my_string}");
    }
}

.await کردن روی یک اجراکننده Multi-thread

دقت کنید که وقتی از یک اجرا کننده Future به صورت Multi-thread استفاده میکنیم، Future ها میتونن بین thread ها منتقل بشن پس هم این Future ها هم متغیر ها باید امکان انتقال بین thread ها رو داشته باشن، چون هر درخواست .await میتونه منجر به تعویض thread بشه.

که این یعنی استفاده از Rc یا &RefCell یا تایپ های دیگه ای که trait Send رو پیاده سازی نکردن و همچنین رفرنس هایی که به تایپ هایی اشاره دارن که trait Sync رو پیاده سازی نکردن، امن نیست.

(احتیاط: امکانش وجود داره از این تایپ ها استفاده کنید البته تا زمانی که توی scope ای که .await صدا زده میشه نباشن.)

به طور مشابه، ایده خوبی نیست مقادیر غیر Future ای lock شده رو توی .await ها استفاده کنید، چون باعث lock شدن یا قفل شدن thread pool میشه: مثلا ممکنه یه task باعث lock بشه و .await هم صدا زده بشه و قدرت کنترل برگرده به اجراکننده تا یه task دیگه انجام بشه که سعی کنه همون چیز رو lock کنه و همین باعث ایجاد deadlock بشه. برای جلوگیری از این مشکل از تایپ Mutex داخل future::lock به جای std::sync استفاده کنید.