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

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

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

  • شرایط رقابتی (Race conditions)، جایی که نخ‌ها داده‌ها یا منابع را به ترتیب ناسازگار دسترسی دارند
  • بن‌بست‌ها (Deadlocks)، جایی که دو نخ منتظر یکدیگر هستند و مانع از ادامه کار هر دو نخ می‌شوند
  • باگ‌هایی که فقط در شرایط خاص رخ می‌دهند و به سختی قابل بازتولید و رفع هستند

Rust تلاش می‌کند اثرات منفی استفاده از نخ‌ها را کاهش دهد، اما برنامه‌نویسی در یک زمینه چندنخی همچنان نیاز به تفکر دقیق و ساختاری متفاوت از برنامه‌های تک‌نخی دارد.

زبان‌های برنامه‌نویسی نخ‌ها را به چندین روش مختلف پیاده‌سازی می‌کنند و بسیاری از سیستم‌عامل‌ها API‌هایی ارائه می‌دهند که زبان می‌تواند برای ایجاد نخ‌های جدید فراخوانی کند. کتابخانه استاندارد Rust از یک مدل پیاده‌سازی نخ 1:1 استفاده می‌کند، به این معنا که برنامه یک نخ سیستم‌عامل به ازای هر نخ زبان استفاده می‌کند. جعبه‌ها (crates)یی وجود دارند که مدل‌های دیگر نخ را پیاده‌سازی می‌کنند و مبادله‌های متفاوتی نسبت به مدل 1:1 ارائه می‌دهند. (سیستم async در Rust، که در فصل بعدی آن را خواهیم دید، روش دیگری برای همزمانی ارائه می‌دهد.)

ایجاد یک نخ جدید با spawn

برای ایجاد یک نخ جدید، تابع thread::spawn را فراخوانی می‌کنیم و یک closure (که در فصل 13 در مورد آن صحبت کردیم) شامل کدی که می‌خواهیم در نخ جدید اجرا کنیم، به آن پاس می‌دهیم. مثال در لیستینگ 16-1 متنی را از نخ اصلی و متن دیگری را از یک نخ جدید چاپ می‌کند:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: ایجاد یک نخ جدید برای چاپ یک چیز در حالی که نخ اصلی چیز دیگری چاپ می‌کند

توجه داشته باشید که وقتی نخ اصلی یک برنامه Rust تکمیل می‌شود، تمام نخ‌های ایجادشده متوقف می‌شوند، چه آن‌ها اجرای خود را تکمیل کرده باشند یا نه. خروجی این برنامه ممکن است هر بار کمی متفاوت باشد، اما به صورت مشابه زیر خواهد بود:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

فراخوانی‌های thread::sleep باعث می‌شوند یک نخ اجرای خود را برای مدت کوتاهی متوقف کند و به نخ دیگری اجازه اجرا دهد. احتمالاً نخ‌ها نوبتی اجرا می‌شوند، اما این موضوع تضمین‌شده نیست: به نحوه زمان‌بندی نخ‌ها توسط سیستم‌عامل شما بستگی دارد. در این اجرای برنامه، نخ اصلی ابتدا چاپ کرد، حتی با اینکه دستور چاپ از نخ ایجادشده در کد ابتدا ظاهر می‌شود. و با اینکه به نخ ایجادشده گفتیم تا زمانی که مقدار i به 9 برسد چاپ کند، فقط تا مقدار 5 رسید قبل از اینکه نخ اصلی خاموش شود.

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

منتظر ماندن برای تکمیل همه نخ‌ها با استفاده از join Handles

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

می‌توانیم مشکل اجرا نشدن یا پایان زودهنگام نخ ایجادشده را با ذخیره مقدار بازگشتی thread::spawn در یک متغیر رفع کنیم. نوع بازگشتی thread::spawn یک JoinHandle است. یک JoinHandle یک مقدار مالکیت‌دار است که وقتی متد join را روی آن فراخوانی می‌کنیم، منتظر می‌ماند تا نخ مرتبط با آن تکمیل شود. لیستینگ 16-2 نشان می‌دهد چگونه از JoinHandle نخ ایجادشده در لیستینگ 16-1 استفاده کنیم و join را فراخوانی کنیم تا مطمئن شویم نخ ایجادشده قبل از خروج main تکمیل می‌شود:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listing 16-2: ذخیره یک JoinHandle از thread::spawn برای اطمینان از اینکه نخ تا پایان اجرا می‌شود

فراخوانی join روی handle نخ جاری را مسدود می‌کند تا زمانی که نخ نمایانده‌شده توسط handle خاتمه یابد. مسدود کردن یک نخ به این معناست که آن نخ از انجام کار یا خروج جلوگیری می‌شود. چون فراخوانی join را بعد از حلقه for نخ اصلی قرار داده‌ایم، اجرای لیستینگ 16-2 باید خروجی مشابه زیر تولید کند:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

دو نخ همچنان به صورت متناوب اجرا می‌شوند، اما نخ اصلی به دلیل فراخوانی handle.join() منتظر می‌ماند و تا زمانی که نخ ایجادشده تکمیل نشود پایان نمی‌یابد.

اما بیایید ببینیم چه اتفاقی می‌افتد اگر handle.join() را قبل از حلقه for در main منتقل کنیم، به این صورت:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

نخ اصلی منتظر می‌ماند تا نخ ایجادشده خاتمه یابد و سپس حلقه for خود را اجرا می‌کند، بنابراین خروجی دیگر به صورت متناوب نخواهد بود، همان‌طور که در اینجا نشان داده شده است:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

جزئیات کوچک، مانند مکان فراخوانی join، می‌توانند بر اینکه نخ‌های شما همزمان اجرا می‌شوند یا خیر تأثیر بگذارند.

استفاده از Closureهای move با نخ‌ها

ما اغلب از کلمه کلیدی move با closureهایی که به thread::spawn پاس داده می‌شوند استفاده می‌کنیم، زیرا این closure سپس مالکیت مقادیری را که از محیط استفاده می‌کند، می‌گیرد و بنابراین مالکیت آن مقادیر را از یک نخ به نخ دیگر منتقل می‌کند. در بخش “گرفتن ارجاع‌ها یا انتقال مالکیت” در فصل 13، move را در زمینه closureها مورد بحث قرار دادیم. اکنون بیشتر روی تعامل بین move و thread::spawn تمرکز خواهیم کرد.

توجه کنید که در لیستینگ 16-1، closureی که به thread::spawn پاس می‌دهیم هیچ آرگومانی نمی‌گیرد: ما از هیچ داده‌ای از نخ اصلی در کد نخ ایجادشده استفاده نمی‌کنیم. برای استفاده از داده‌های نخ اصلی در نخ ایجادشده، closure نخ ایجادشده باید مقادیری که نیاز دارد را بگیرد. لیستینگ 16-3 تلاشی برای ایجاد یک بردار در نخ اصلی و استفاده از آن در نخ ایجادشده را نشان می‌دهد. با این حال، این کد هنوز کار نخواهد کرد، همان‌طور که در لحظه‌ای خواهید دید.

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

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: تلاش برای استفاده از یک بردار ایجادشده توسط نخ اصلی در یک نخ دیگر

این closure از v استفاده می‌کند، بنابراین v را می‌گیرد و آن را بخشی از محیط closure می‌کند. از آنجا که thread::spawn این closure را در یک نخ جدید اجرا می‌کند، باید بتوانیم به v در داخل آن نخ جدید دسترسی داشته باشیم. اما وقتی این مثال را کامپایل می‌کنیم، خطای زیر را دریافت می‌کنیم:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

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

Rust به طور استنتاجی تعیین می‌کند که چگونه v را بگیرد، و چون println! فقط به یک ارجاع به v نیاز دارد، closure سعی می‌کند v را قرض بگیرد. با این حال، مشکلی وجود دارد: Rust نمی‌تواند بگوید نخ ایجادشده چه مدت اجرا خواهد شد، بنابراین نمی‌داند که ارجاع به v همیشه معتبر خواهد بود.

لیستینگ 16-4 سناریویی را ارائه می‌دهد که احتمال بیشتری برای داشتن یک ارجاع نامعتبر به v دارد:

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

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}
Listing 16-4: یک نخ با closureی که سعی می‌کند یک ارجاع به v را از نخ اصلی که v را حذف می‌کند بگیرد

اگر Rust به ما اجازه اجرای این کد را می‌داد، این احتمال وجود داشت که نخ ایجادشده بلافاصله به پس‌زمینه برود بدون اینکه اصلاً اجرا شود. نخ ایجادشده یک ارجاع به v داخل خود دارد، اما نخ اصلی بلافاصله v را حذف می‌کند، با استفاده از تابع drop که در فصل 15 مورد بحث قرار گرفت. سپس، وقتی نخ ایجادشده شروع به اجرا می‌کند، v دیگر معتبر نیست، بنابراین ارجاع به آن نیز نامعتبر است. اوه نه!

برای رفع خطای کامپایل در لیستینگ 16-3، می‌توانیم از مشاوره پیام خطا استفاده کنیم:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

با افزودن کلمه کلیدی move قبل از closure، ما closure را مجبور می‌کنیم که مالکیت مقادیری که استفاده می‌کند را بگیرد، به جای اینکه به Rust اجازه دهیم استنتاج کند که باید مقادیر را قرض بگیرد. تغییرات اعمال‌شده به لیستینگ 16-3 که در لیستینگ 16-5 نشان داده شده است، همان‌طور که انتظار داریم کامپایل و اجرا خواهد شد:

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

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-5: استفاده از کلمه کلیدی move برای مجبور کردن یک closure به گرفتن مالکیت مقادیری که استفاده می‌کند

ممکن است وسوسه شویم که همین کار را برای رفع کد در لیستینگ 16-4 که نخ اصلی drop را فراخوانی می‌کند با استفاده از یک closure move انجام دهیم. با این حال، این راه‌حل کار نخواهد کرد زیرا آنچه لیستینگ 16-4 تلاش می‌کند انجام دهد به دلیل دیگری مجاز نیست. اگر move را به closure اضافه کنیم، v را به محیط closure منتقل می‌کنیم و دیگر نمی‌توانیم drop را در نخ اصلی روی آن فراخوانی کنیم. در عوض، این خطای کامپایل را دریافت خواهیم کرد:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

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

قوانین مالکیت Rust دوباره ما را نجات دادند! ما از کد موجود در لیستینگ 16-3 خطا گرفتیم زیرا Rust محافظه‌کار بود و فقط v را برای نخ قرض گرفت، که به این معنا بود که نخ اصلی می‌توانست به‌صورت نظری مرجع نخ ایجادشده را نامعتبر کند. با گفتن به Rust که مالکیت v را به نخ ایجادشده منتقل کند، ما به Rust تضمین می‌دهیم که نخ اصلی دیگر از v استفاده نخواهد کرد. اگر لیستینگ 16-4 را به همان روش تغییر دهیم، آنگاه هنگام تلاش برای استفاده از v در نخ اصلی، قوانین مالکیت را نقض می‌کنیم. کلمه کلیدی move رفتار محافظه‌کارانه پیش‌فرض Rust در قرض‌گیری را لغو می‌کند؛ اما اجازه نمی‌دهد قوانین مالکیت را نقض کنیم.

با داشتن درک اولیه‌ای از نخ‌ها و API مربوط به نخ‌ها، بیایید ببینیم که با نخ‌ها چه کاری می‌توانیم انجام دهیم.