استفاده از نخها برای اجرای همزمان کد
در اغلب سیستمعاملهای امروزی، کدی که در یک برنامه اجرا میشود در قالب یک پروسه (process) اجرا میشود، و سیستمعامل بهطور همزمان چندین پروسه را مدیریت میکند.
در درون یک برنامه، میتوان بخشهای مستقلی نیز داشت که بهصورت همزمان اجرا میشوند. ویژگیهایی که این بخشهای مستقل را اجرا میکنند، ترد (thread) نام دارند.
برای مثال، یک وبسرور میتواند چندین ترد داشته باشد تا بتواند همزمان به چندین درخواست پاسخ دهد.
تقسیم محاسبات در برنامه شما به چندین نخ برای اجرای چندین کار به طور همزمان میتواند عملکرد را بهبود بخشد، اما همچنین پیچیدگی را افزایش میدهد. از آنجایی که نخها میتوانند به طور همزمان اجرا شوند، هیچ تضمینی برای ترتیب اجرای بخشهای کد در نخهای مختلف وجود ندارد. این موضوع میتواند به مشکلاتی منجر شود، مانند:
- شرایط رقابتی (Race conditions)، زمانی که تردها به دادهها یا منابع بهصورت نامنظم و ناسازگار دسترسی پیدا میکنند
- بنبستها (Deadlocks)، زمانی که دو ترد منتظر یکدیگر هستند و هیچکدام نمیتوانند به اجرای خود ادامه دهند
- باگهایی که تنها در شرایط خاصی رخ میدهند و بازتولید و رفع آنها بهصورت قابلاعتماد دشوار است
Rust تلاش میکند اثرات منفی استفاده از نخها را کاهش دهد، اما برنامهنویسی در یک زمینه چندنخی همچنان نیاز به تفکر دقیق و ساختاری متفاوت از برنامههای تکنخی دارد.
زبانهای برنامهنویسی، پیادهسازی تردها را به روشهای مختلفی انجام میدهند و بسیاری از سیستمعاملها یک API برای ایجاد تردهای جدید در اختیار زبان برنامهنویسی قرار میدهند. کتابخانه استاندارد Rust از مدل پیادهسازی ترد 1:1 استفاده میکند؛ بهعبارت دیگر، هر ترد زبان، متناظر با یک ترد سیستمعامل است. کتابخانههایی (crate) نیز وجود دارند که مدلهای دیگری از تردینگ را پیادهسازی میکنند و نسبت به مدل 1:1، مصالحهها و ویژگیهای متفاوتی دارند. (سیستم async در Rust، که در فصل بعدی آن را خواهیم دید، نیز رویکردی دیگر برای همزمانی ارائه میدهد.)
ایجاد یک نخ جدید با spawn
برای ایجاد یک ترد جدید، از تابع thread::spawn
استفاده میکنیم و یک closure (که در فصل ۱۳ درباره آن صحبت کردیم) را به آن میدهیم که حاوی کدی است که میخواهیم در ترد جدید اجرا شود. مثال موجود در لیستینگ 16-1، متنی را از ترد اصلی چاپ میکند و متنی دیگر را از یک ترد جدید.
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)); } }
توجه داشته باشید که وقتی نخ اصلی یک برنامه 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
باعث میشوند یک ترد اجرای خود را برای مدت کوتاهی متوقف کند و به ترد دیگری اجازه اجرای کد را بدهد. احتمالاً تردها به نوبت اجرا خواهند شد، اما این موضوع تضمینشده نیست: اینکه کدام ترد اجرا شود بستگی به نحوه زمانبندی (scheduling) تردها توسط سیستمعامل دارد. در این اجرا، ترد اصلی زودتر چاپ کرد، با اینکه دستور چاپ ترد جدید زودتر در کد آمده است. و حتی با اینکه به ترد جدید گفتیم تا زمانی که مقدار i
به 9
برسد چاپ کند، فقط تا مقدار 5
اجرا شد پیش از آنکه ترد اصلی متوقف شود.
اگر این کد را اجرا کردید و فقط خروجی نخ اصلی را دیدید یا هیچ تداخل زمانی مشاهده نکردید، سعی کنید اعداد موجود در بازهها را افزایش دهید تا فرصت بیشتری برای سیستمعامل ایجاد شود تا بین نخها جابهجا شود.
منتظر ماندن برای تکمیل همه نخها با استفاده از join
Handles
کد موجود در لیستینگ 16-1 نه تنها بیشتر اوقات نخ ایجادشده را به دلیل پایان نخ اصلی زودتر از موعد متوقف میکند، بلکه به دلیل اینکه هیچ تضمینی برای ترتیب اجرای نخها وجود ندارد، نمیتوانیم اطمینان حاصل کنیم که نخ ایجادشده اجرا خواهد شد!
ما میتوانیم مشکل اجرا نشدن ترد جدید یا پایان زودهنگام آن را با ذخیره مقدار بازگشتی thread::spawn
در یک متغیر حل کنیم. نوع بازگشتی thread::spawn
برابر است با JoinHandle<T>
. یک JoinHandle<T>
یک مقدار مالک (owned) است که وقتی متد join
را روی آن فراخوانی کنیم، منتظر میماند تا اجرای ترد مربوطه به پایان برسد. در فهرست 16-2 نشان داده شده است که چگونه از JoinHandle<T>
تردی که در فهرست 16-1 ایجاد کردیم استفاده کنیم و چگونه با فراخوانی join
اطمینان حاصل کنیم که ترد جدید پیش از خروج main
به پایان میرسد.
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(); }
JoinHandle<T>
از 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
منتقل کنیم، به این صورت:
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
همراه با closuresهایی که به thread::spawn
داده میشوند استفاده میکنیم، زیرا در این صورت closure مالکیت مقادیری که از محیط استفاده میکند را به خود میگیرد، و به این ترتیب مالکیت آن مقادیر از یک ترد به ترد دیگر منتقل میشود. در بخش «گرفتن رفرنس یا انتقال مالکیت» در فصل 13، move
را در زمینهی closures بررسی کردیم. اکنون تمرکز بیشتری بر تعامل بین move
و thread::spawn
خواهیم داشت.
در فهرست 16-1 توجه کنید که closureیی که به thread::spawn
میدهیم هیچ آرگومانی نمیگیرد: ما در کد ترد ایجاد شده از هیچ دادهای از ترد اصلی استفاده نمیکنیم. برای استفاده از دادههای ترد اصلی در ترد جدید، closure در ترد جدید باید مقادیری را که نیاز دارد capture کند. فهرست 16-3 تلاشی را برای ایجاد یک vector در ترد اصلی و استفاده از آن در ترد ایجاد شده نشان میدهد. با این حال، همانطور که در ادامه خواهید دید، این کد هنوز کار نخواهد کرد.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
این 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
را capture کند، و از آنجا که println!
تنها به یک رفرنس به v
نیاز دارد، closure تلاش میکند تا v
را قرض بگیرد (borrow کند). اما مشکلی وجود دارد: Rust نمیتواند تشخیص دهد که ترد ایجادشده چه مدت اجرا خواهد شد، بنابراین نمیداند که آیا رفرنس به v
همیشه معتبر خواهد ماند یا نه.
فهرست 16-4 سناریویی را نشان میدهد که احتمال نامعتبر بودن رفرنس به v
در آن بیشتر است.
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();
}
v
را از نخ اصلی که v
را حذف میکند بگیرداگر Rust اجازه اجرای این کد را به ما میداد، این احتمال وجود داشت که ترد ایجادشده بلافاصله به پسزمینه منتقل شود بدون آنکه اجرا شود. این ترد ایجادشده، یک رفرنس به v
در درون خود دارد، اما ترد اصلی بلافاصله v
را drop میکند، با استفاده از تابع drop
که در فصل ۱۵ دربارهاش صحبت کردیم. سپس، زمانی که ترد ایجادشده شروع به اجرا کند، دیگر 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 بهطور ضمنی نتیجه بگیرد که باید آن مقادیر را قرض بگیرد. اصلاحات اعمالشده روی Listing 16-3 که در Listing 16-5 نشان داده شدهاند، همانگونه که انتظار داریم کامپایل شده و اجرا خواهند شد.
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(); }
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 باز هم ما را نجات دادند! در کد موجود در Listing 16-3 خطا دریافت کردیم، زیرا Rust بهصورت محافظهکارانه عمل کرده و تنها v
را برای thread قرض گرفته بود، که این یعنی thread اصلی میتوانست بهطور نظری رفرنسی که thread ایجادشده به آن نیاز دارد را نامعتبر کند. با گفتن این موضوع به Rust که مالکیت v
را به thread جدید منتقل کند (move
)، ما این تضمین را به Rust میدهیم که thread اصلی دیگر از v
استفاده نخواهد کرد. اگر Listing 16-4 را هم به همین شکل تغییر دهیم، در واقع داریم قوانین مالکیت را با تلاش برای استفاده از v
در thread اصلی نقض میکنیم. کلمهی کلیدی move
رفتار پیشفرض محافظهکارانهی Rust را که قرضگیری است، لغو میکند؛ اما اجازه نمیدهد قوانین مالکیت را زیر پا بگذاریم.
اکنون که درک خوبی از چیستی threadها و متدهای ارائهشده توسط API مربوط به thread داریم، بیایید به بررسی برخی موقعیتها بپردازیم که میتوانیم در آنها از threadها استفاده کنیم.