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

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

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

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

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

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

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

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

برای یک تمثیل دنیای واقعی برای 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>>`, which is required by `{closure@src/main.rs:11:36: 11:43}: Send`
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`
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/std/src/thread/mod.rs:675:8
    |
672 | pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    |        ----- required by a bound in this function
...
675 |     F: Send + 'static,
    |        ^^^^ required by this bound in `spawn`

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 صحبت خواهیم کرد: یکی از ویژگی‌هایی که اطمینان می‌دهد نوع‌هایی که با Threadها استفاده می‌کنیم برای استفاده در شرایط همزمان طراحی شده‌اند.

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

شمارش ارجاع اتمی با 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 و نحوه استفاده از آن‌ها با نوع‌های سفارشی تکمیل خواهیم کرد.