Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

همزمانی با حالت مشترک (Shared-State Concurrency)

ارسال پیام (Message passing) روش مناسبی برای مدیریت هم‌زمانی (concurrency) است، اما تنها روش موجود نیست. روش دیگری نیز وجود دارد که در آن چندین ترد (thread) به داده‌ی مشترک یکسانی دسترسی دارند. دوباره این بخش از شعار مستندات زبان Go را در نظر بگیرید: «با به‌اشتراک‌گذاری حافظه ارتباط برقرار نکنید.»

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

به‌نوعی، کانال‌ها (channels) در هر زبان برنامه‌نویسی مشابه مالکیت تکی (single ownership) هستند، چرا که وقتی یک مقدار را از طریق کانال انتقال می‌دهید، دیگر نباید از آن مقدار استفاده کنید. هم‌زمانی با حافظه‌ی مشترک (shared-memory concurrency) شبیه به مالکیت چندگانه است: چندین ترد می‌توانند به‌طور هم‌زمان به یک محل حافظه دسترسی داشته باشند. همان‌طور که در فصل ۱۵ دیدید، جایی که smart pointerها امکان مالکیت چندگانه را فراهم کردند، مالکیت چندگانه می‌تواند پیچیدگی‌هایی را به همراه داشته باشد، چرا که این مالکان مختلف نیاز به مدیریت دارند. سیستم نوع‌دهی و قواعد مالکیت در Rust کمک شایانی به مدیریت درست این وضعیت می‌کنند. به عنوان یک مثال، بیایید به mutexها نگاه کنیم، که یکی از ابتدایی‌ترین سازوکارهای هم‌زمانی برای حافظه‌ی مشترک هستند.

استفاده از Mutex‌ها برای اجازه دسترسی به داده‌ها توسط یک نخ در هر زمان

واژه‌ی Mutex مخفف mutual exclusion به‌معنای «ممانعت متقابل» است؛ به این معنا که یک mutex فقط به یک ترد اجازه می‌دهد تا در هر لحظه به داده‌ای دسترسی داشته باشد. برای دسترسی به داده درون یک mutex، یک ترد ابتدا باید اعلام کند که قصد دسترسی دارد، با درخواست قفل (lock) آن mutex. Lock یک ساختار داده‌ای است که بخشی از mutex به‌شمار می‌رود و مسئول پیگیری این است که در حال حاضر چه کسی به‌صورت انحصاری به داده دسترسی دارد. بنابراین، mutex به‌عنوان ابزاری توصیف می‌شود که از داده‌ای که در خود نگه می‌دارد از طریق سیستم قفل‌گذاری محافظت می‌کند.

Mutex‌ها به دلیل این که باید دو قانون را به خاطر بسپارید، به سخت بودن شهرت دارند:

  1. پیش از استفاده از داده، باید تلاش کنید تا قفل (lock) آن را به‌دست آورید.
  2. زمانی که کارتان با داده‌ای که mutex از آن محافظت می‌کند تمام شد، باید قفل را آزاد (unlock) کنید تا سایر تردها بتوانند قفل را به‌دست آورند.

برای یک تمثیل دنیای واقعی برای mutex، یک بحث پانل در یک کنفرانس را تصور کنید که فقط یک میکروفون وجود دارد. قبل از اینکه یک عضو پانل بتواند صحبت کند، باید درخواست دهد یا سیگنال دهد که می‌خواهد از میکروفون استفاده کند. وقتی میکروفون را می‌گیرد، می‌تواند هر چقدر که بخواهد صحبت کند و سپس میکروفون را به عضو بعدی که درخواست صحبت کرده است بدهد. اگر یک عضو پانل فراموش کند که میکروفون را پس دهد، هیچ کس دیگری نمی‌تواند صحبت کند. اگر مدیریت میکروفون مشترک اشتباه انجام شود، پانل مطابق برنامه پیش نخواهد رفت!

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

API Mutex<T>

به‌عنوان مثالی از نحوه استفاده از mutex، بیایید با استفاده از یک mutex در یک زمینه تک‌ریسمانی شروع کنیم، همانطور که در فهرست 16-12 نشان داده شده است:

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: بررسی API Mutex<T> در یک زمینه تک‌ریسمانی برای سادگی

همان‌طور که با بسیاری از نوع‌ها مشاهده می‌شود، یک Mutex<T> را با استفاده از تابع وابسته new ایجاد می‌کنیم. برای دسترسی به داده داخل Mutex، از متد lock استفاده می‌کنیم تا قفل را به دست آوریم. این فراخوانی Thread فعلی را متوقف می‌کند، بنابراین نمی‌تواند کاری انجام دهد تا زمانی که نوبت ما برای گرفتن قفل برسد.

فراخوانی lock در صورتی که یک Thread دیگر که قفل را نگه داشته دچار وحشت (panic) شود، شکست می‌خورد. در چنین حالتی، هیچ‌کس دیگر نمی‌تواند قفل را به دست آورد، بنابراین انتخاب کرده‌ایم که از unwrap استفاده کنیم و اگر در چنین وضعیتی قرار گرفتیم، این Thread نیز دچار وحشت شود.

بعد از گرفتن قفل، می‌توانیم مقدار بازگردانده‌شده را، که در اینجا به نام num است، به عنوان یک مرجع قابل تغییر به داده داخل در نظر بگیریم. سیستم نوع تضمین می‌کند که قبل از استفاده از مقدار داخل m قفل را به دست آوریم. نوع m برابر با Mutex<i32> است، نه i32، بنابراین باید برای استفاده از مقدار i32، متد lock را فراخوانی کنیم. نمی‌توانیم فراموش کنیم؛ سیستم نوع اجازه دسترسی به مقدار داخلی i32 را به ما نمی‌دهد.

همان‌طور که احتمالاً حدس می‌زنید، Mutex<T> یک اشاره‌گر هوشمند است. دقیق‌تر، فراخوانی lock یک اشاره‌گر هوشمند به نام MutexGuard را بازمی‌گرداند، که در یک LockResult بسته‌بندی شده است و آن را با فراخوانی unwrap مدیریت کردیم. اشاره‌گر هوشمند MutexGuard ویژگی Deref را پیاده‌سازی می‌کند تا به داده داخلی ما اشاره کند. همچنین، این اشاره‌گر هوشمند یک پیاده‌سازی از Drop دارد که به‌طور خودکار قفل را زمانی که یک MutexGuard از محدوده خارج می‌شود، آزاد می‌کند، که این اتفاق در انتهای محدوده داخلی رخ می‌دهد. در نتیجه، خطر فراموش کردن آزاد کردن قفل و جلوگیری از استفاده دیگر Threadها از Mutex وجود ندارد، زیرا آزادسازی قفل به صورت خودکار انجام می‌شود.

پس از آزاد کردن قفل، می‌توانیم مقدار Mutex را چاپ کنیم و ببینیم که توانستیم مقدار داخلی i32 را به ۶ تغییر دهیم.

اشتراک‌گذاری یک Mutex<T> بین چندین Thread

حالا، بیایید تلاش کنیم یک مقدار را بین چندین Thread با استفاده از Mutex<T> به اشتراک بگذاریم. ما ۱۰ Thread ایجاد خواهیم کرد و هرکدام مقدار شمارنده را ۱ واحد افزایش می‌دهند، بنابراین شمارنده از ۰ به ۱۰ می‌رسد. مثال بعدی در لیست ۱۶-۱۳ دارای خطای کامپایل خواهد بود، و از آن خطا برای یادگیری بیشتر در مورد استفاده از Mutex<T> و اینکه چگونه Rust به ما کمک می‌کند از آن به درستی استفاده کنیم، استفاده خواهیم کرد.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: ده نخ که هر کدام مقدار شمارنده محافظت‌شده توسط یک Mutex<T> را افزایش می‌دهند

ما یک متغیر counter ایجاد می‌کنیم تا یک مقدار i32 را در یک Mutex<T> نگه دارد، همان‌طور که در لیست ۱۶-۱۲ انجام دادیم. سپس، با تکرار روی یک بازه عددی، ۱۰ Thread ایجاد می‌کنیم. از thread::spawn استفاده می‌کنیم و به تمام Threadها یک Closure یکسان می‌دهیم: یک Closure که متغیر counter را به Thread منتقل می‌کند، قفل Mutex<T> را با فراخوانی متد lock به دست می‌آورد، و سپس ۱ واحد به مقدار داخل Mutex اضافه می‌کند. وقتی یک Thread اجرای Closure خود را تمام می‌کند، num از محدوده خارج شده و قفل را آزاد می‌کند تا Thread دیگری بتواند آن را به دست آورد.

در Thread اصلی، تمام handleهای join را جمع‌آوری می‌کنیم. سپس، همان‌طور که در لیست ۱۶-۲ انجام دادیم، متد join را روی هر handle فراخوانی می‌کنیم تا مطمئن شویم تمام Threadها تمام شده‌اند. در آن نقطه، Thread اصلی قفل را به دست می‌آورد و نتیجه این برنامه را چاپ می‌کند.

ما اشاره کردیم که این مثال کامپایل نخواهد شد. حالا بیایید ببینیم چرا!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

پیام خطا نشان می‌دهد که مقدار counter در تکرار قبلی حلقه منتقل شده است. Rust به ما می‌گوید که نمی‌توانیم مالکیت counter را به چندین Thread منتقل کنیم. بیایید این خطای کامپایلر را با استفاده از روش مالکیت چندگانه که در فصل ۱۵ بحث کردیم، برطرف کنیم.

مالکیت چندگانه با چندین Thread

در فصل ۱۵، ما با استفاده از اشاره‌گر هوشمند Rc<T> برای ایجاد یک مقدار شمارش‌شده توسط مرجع (reference-counted value) به یک مقدار چندین مالک دادیم. بیایید همین کار را اینجا انجام دهیم و ببینیم چه اتفاقی می‌افتد. ما Mutex<T> را در Rc<T> بسته‌بندی می‌کنیم (همان‌طور که در لیست ۱۶-۱۴ نشان داده شده است) و قبل از انتقال مالکیت به Thread، Rc<T> را کلون می‌کنیم.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: تلاش برای استفاده از Rc<T> برای اجازه مالکیت چندگانه Mutex<T> توسط چندین Thread

دوباره کامپایل می‌کنیم و… خطاهای متفاوتی دریافت می‌کنیم! کامپایلر چیزهای زیادی به ما یاد می‌دهد.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

وای، این پیام خطا واقعاً پرحرف است! اما بخش مهمی که باید روی آن تمرکز کنیم این است: Rc<Mutex<i32>>` cannot be sent between threads safely کامپایلر همچنین دلیل آن را نیز به ما می‌گوید: ```the trait Send is not implemented for Rc<Mutex<i32>>```` در بخش بعدی درباره‌ی Sendصحبت خواهیم کرد: این یکی ازtrait`هایی است که اطمینان حاصل می‌کند نوع‌هایی که با تردها استفاده می‌شوند، برای استفاده در موقعیت‌های هم‌زمان طراحی شده‌اند.

متأسفانه، استفاده از Rc<T> برای اشتراک‌گذاری داده‌ها بین تردها ایمن نیست. زمانی که Rc<T> شمارنده‌ی رفرنس را مدیریت می‌کند، با هر بار فراخوانی clone به شمارنده اضافه می‌شود و با از بین رفتن هر کلون، از شمارنده کم می‌شود. اما این عملیات از هیچ سازوکار هم‌زمانی‌ای استفاده نمی‌کند تا مطمئن شود که تغییرات روی شمارنده در میان اجرای ترد دیگری قطع نشوند. این موضوع می‌تواند منجر به شمارنده‌های اشتباه شود—باگ‌هایی ظریف که در ادامه ممکن است منجر به نشت حافظه یا از بین رفتن مقداری شوند در حالی که هنوز به آن نیاز داریم. چیزی که ما نیاز داریم، نوعی است که دقیقاً مانند Rc<T> عمل کند، اما تغییرات شمارنده‌ی رفرنس را به‌شکلی امن برای ترد انجام دهد.

شمارش ارجاع اتمی با Arc<T>

خوشبختانه، Arc<T> یک نوع مشابه Rc<T> است که برای استفاده در شرایط همزمان ایمن است. حرف a در Arc مخفف atomic است، به این معنا که یک نوع شمارش مرجع اتمی است. اتمیک‌ها نوع دیگری از عناصر ابتدایی همزمانی هستند که در اینجا به‌طور مفصل به آن‌ها نمی‌پردازیم؛ برای جزئیات بیشتر به مستندات کتابخانه استاندارد در مورد std::sync::atomic مراجعه کنید. در این مرحله، فقط باید بدانید که اتمیک‌ها مانند نوع‌های ابتدایی کار می‌کنند اما برای اشتراک‌گذاری بین Threadها ایمن هستند.

شاید از خود بپرسید چرا تمام نوع‌های ابتدایی اتمی نیستند و چرا نوع‌های کتابخانه استاندارد به‌طور پیش‌فرض از Arc<T> استفاده نمی‌کنند. دلیل این است که ایمنی Thread با یک هزینه عملکردی همراه است که فقط زمانی که واقعاً نیاز باشد، می‌خواهید آن را پرداخت کنید. اگر فقط روی مقادیر در یک Thread واحد عملیات انجام می‌دهید، کد شما می‌تواند سریع‌تر اجرا شود اگر مجبور به اعمال تضمین‌های اتمیک نباشد.

بیایید به مثال خود برگردیم: Arc<T> و Rc<T> API یکسانی دارند، بنابراین برنامه خود را با تغییر خط use، فراخوانی new، و فراخوانی clone اصلاح می‌کنیم. کد موجود در لیست ۱۶-۱۵ در نهایت کامپایل و اجرا می‌شود:

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: استفاده از Arc<T> برای بسته‌بندی Mutex<T> جهت اشتراک مالکیت بین چندین Thread

این کد خروجی زیر را چاپ خواهد کرد:

Result: 10

ما موفق شدیم! شمارنده را از ۰ به ۱۰ افزایش دادیم که ممکن است خیلی چشمگیر به نظر نرسد، اما چیزهای زیادی درباره Mutex<T> و ایمنی Thread یاد گرفتیم. همچنین می‌توانید از ساختار این برنامه برای انجام عملیات پیچیده‌تری به‌جز افزایش یک شمارنده استفاده کنید. با استفاده از این استراتژی، می‌توانید یک محاسبه را به بخش‌های مستقل تقسیم کنید، این بخش‌ها را بین Threadها تقسیم کنید، و سپس از یک Mutex<T> استفاده کنید تا هر Thread نتیجه نهایی را با بخش مربوط به خودش به‌روزرسانی کند.

توجه داشته باشید که اگر در حال انجام عملیات عددی ساده هستید، نوع‌های ساده‌تری نسبت به Mutex<T> در ماژول std::sync::atomic از کتابخانه استاندارد ارائه شده‌اند. این نوع‌ها دسترسی اتمی، ایمن و همزمان به نوع‌های ابتدایی فراهم می‌کنند. ما برای این مثال از Mutex<T> با یک نوع ابتدایی استفاده کردیم تا بتوانیم بر نحوه کار Mutex<T> تمرکز کنیم.

شباهت‌های بین RefCell<T>/Rc<T> و Mutex<T>/Arc<T>

ممکن است متوجه شده باشید که counter تغییرناپذیر است، اما توانستیم یک مرجع قابل تغییر به مقدار داخل آن بگیریم؛ این بدان معناست که Mutex<T> قابلیت تغییر داخلی (interior mutability) را فراهم می‌کند، همان‌طور که خانواده Cell این کار را می‌کنند. به همان شکلی که در فصل ۱۵ از RefCell<T> برای اجازه تغییر محتوا درون یک Rc<T> استفاده کردیم، از Mutex<T> برای تغییر محتوا درون یک Arc<T> استفاده می‌کنیم.

نکته دیگری که باید توجه کنید این است که Rust نمی‌تواند شما را از تمام انواع خطاهای منطقی هنگام استفاده از Mutex<T> محافظت کند. به یاد بیاورید که در فصل ۱۵ استفاده از Rc<T> با خطر ایجاد چرخه‌های مرجع همراه بود، جایی که دو مقدار Rc<T> به یکدیگر ارجاع می‌دادند و باعث نشت حافظه می‌شدند. به‌طور مشابه، Mutex<T> با خطر ایجاد بن‌بست (deadlock) همراه است. این وضعیت زمانی رخ می‌دهد که یک عملیات نیاز به قفل کردن دو منبع دارد و دو Thread هر کدام یکی از قفل‌ها را به دست آورده‌اند و باعث می‌شوند که برای همیشه منتظر یکدیگر بمانند. اگر به بن‌بست علاقه دارید، سعی کنید یک برنامه Rust ایجاد کنید که دچار بن‌بست شود؛ سپس استراتژی‌های کاهش بن‌بست برای Mutexها در هر زبانی را تحقیق کنید و آن‌ها را در Rust پیاده‌سازی کنید. مستندات API کتابخانه استاندارد برای Mutex<T> و MutexGuard اطلاعات مفیدی ارائه می‌دهد.

ما این فصل را با صحبت درباره ویژگی‌های Send و Sync و نحوه استفاده از آن‌ها با نوع‌های سفارشی تکمیل خواهیم کرد.