مقدمه

در درس قبل، با الگوی «ارسال پیام» (Message Passing) به عنوان یک روش برای ارتباط بین ترِدها آشنا شدیم. این الگو بسیار کارآمد است، اما تنها روش ممکن نیست. یک رویکرد دیگر که در بسیاری از زبان‌های برنامه‌نویسی رایج است، «همزمانی با وضعیت مشترک» یا Shared-State Concurrency است. در این مدل، چندین ترِد به صورت همزمان به یک بخش مشترک از حافظه دسترسی دارند.

همانطور که اشاره شد، این رویکرد می‌تواند منجر به مشکلاتی مانند «وضعیت رقابتی» (race condition) شود. برای مدیریت ایمن حافظه مشترک، Rust ابزارهای قدرتمندی را در کتابخانه استاندارد خود فراهم می‌کند که مهم‌ترین آنها Mutex<T> است.

استفاده از Mutex برای دسترسی انحصاری

Mutex که مخفف mutual exclusion (انحصار متقابل) است، یک ابزار همزمانی است که در هر لحظه، تنها به یک ترِد اجازه دسترسی به داده‌های داخل خود را می‌دهد. برای دسترسی به داده‌ها، یک ترِد باید ابتدا «قفل» (lock) را به دست آورد. هر ترِد دیگری که بخواهد به داده‌ها دسترسی پیدا کند، باید منتظر بماند تا ترِد فعلی قفل را آزاد کند.

در Rust، ما این کار را با استفاده از اشاره‌گر هوشمند Mutex<T> انجام می‌دهیم.

Copy Icon src/main.rs
use std::sync::Mutex;

fn main() {
    // Create a new Mutex guarding an i32 value
    let m = Mutex::new(5);

    {
        // Acquire the lock to access the data. This blocks the current thread.
        let mut num = m.lock().unwrap();
        *num = 6;
    } // The lock is automatically released when `num` goes out of scope.

    println!("m = {:?}", m);
}

در این کد، ما یک Mutex می‌سازیم که از یک مقدار i32 محافظت می‌کند. برای دسترسی به این مقدار، ما متد .lock() را فراخوانی می‌کنیم. این متد قفل را به دست آورده و یک اشاره‌گر هوشمند به نام MutexGuard را در داخل یک Result برمی‌گرداند. ما با unwrap به این اشاره‌گر دسترسی پیدا می‌کنیم. MutexGuard به ما اجازه می‌دهد تا داده‌های داخل Mutex را به صورت تغییرپذیر (&mut) قرض بگیریم. مهم‌تر از همه، وقتی MutexGuard (در اینجا متغیر num) از حوزه خارج می‌شود، قفل به صورت خودکار آزاد می‌شود. این قابلیت، به لطف Drop Trait، از بروز خطاهای رایج مانند فراموش کردن آزادسازی قفل، جلوگیری می‌کند.

اشتراک‌گذاری Mutex بین چندین ترِد

حالا بیایید سعی کنیم یک Mutex را بین چندین ترِد به اشتراک بگذاریم تا یک شمارنده مشترک را افزایش دهند.

کد زیر کامپایل نخواهد شد:

Copy Icon src/main.rs
// This code has a compile error!
/*
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());
}
*/

مشکل اینجاست که در اولین تکرار حلقه، مالکیت counter به ترِد جدید منتقل (move) می‌شود. در تکرارهای بعدی، counter دیگر معتبر نیست و نمی‌توان آن را دوباره move کرد. این همان مشکل مالکیت چندگانه است که در درس قبل با آن مواجه شدیم.

مالکیت چندگانه اتمیک با Arc<T>

راه‌حل این مشکل، ترکیب Mutex<T> با Rc<T> است. اما به یاد دارید که گفتیم Rc<T> برای استفاده در محیط‌های چند-ریسمانی ایمن نیست؟ کتابخانه استاندارد یک نوع دیگر به نام Arc<T> را برای این منظور فراهم می‌کند.

Arc مخفف Atomically Reference Counted است. این نوع دقیقاً مانند Rc<T> عمل می‌کند، با این تفاوت که شمارنده ارجاع خود را به صورت «اتمیک» تغییر می‌دهد. این یعنی عملیات افزایش و کاهش شمارنده به صورت ایمن در محیط‌های چند-ریسمانی انجام می‌شود و از بروز race condition روی خود شمارنده جلوگیری می‌کند.

با ترکیب Arc<T> و <Mutex<T>، می‌توانیم یک حافظه مشترک و قابل تغییر داشته باشیم که به صورت ایمن بین چندین ترِد به اشتراک گذاشته می‌شود.

Copy Icon 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());
}

در این کد صحیح، ما Mutex را در یک Arc قرار می‌دهیم. قبل از ایجاد هر ترِد، ما Arc را clone می‌کنیم که شمارنده ارجاع را افزایش داده و مالکیت جدیدی را برای ترِد فراهم می‌کند. اکنون هر ۱۰ ترِد به صورت ایمن شمارنده را افزایش داده و نتیجه نهایی (که باید ۱۰ باشد) به درستی چاپ می‌شود.

در این درس با مدل «همزمانی با وضعیت مشترک» و نحوه استفاده از Mutex<T> و Arc<T> برای به اشتراک‌گذاری ایمن داده‌های قابل تغییر بین ترِدها آشنا شدیم. این الگوها، در کنار ارسال پیام، ابزارهای بنیادی برای نوشتن برنامه‌های همزمان در Rust هستند. در درس پایانی این فصل، به بررسی Traitهای Sync و Send خواهیم پرداخت که به کامپایلر Rust اجازه می‌دهند تا ایمنی همزمانی را در زمان کامپایل تضمین کند.