مقدمه
یکی از چالشهای اصلی در برنامهنویسی همزمان، اطمینان از ارتباط ایمن و هماهنگ بین ترِدهای مختلف
است. اگر چندین ترِد به صورت همزمان به یک داده مشترک و قابل تغییر دسترسی داشته باشند، ممکن است با
«وضعیت رقابتی» (race condition) و دادههای خراب مواجه شویم.
یک رویکرد بسیار محبوب برای حل این مشکل، «ارسال پیام» (Message Passing) است. در این الگو، به جای
اینکه ترِدها حافظه را به اشتراک بگذارند، دادهها را از طریق یک «کانال» (channel) برای یکدیگر
ارسال میکنند. این رویکرد را میتوان با این شعار معروف خلاصه کرد: "Do not communicate by sharing
memory; instead, share memory by communicating." (با اشتراکگذاری حافظه ارتباط برقرار نکنید؛ در
عوض، با ارتباط برقرار کردن، حافظه را به اشتراک بگذارید). کتابخانه استاندارد Rust یک پیادهسازی
قدرتمند از کانالها را برای این منظور ارائه میدهد.
ارتباط با کانالها (Channels)
یک کانال در Rust از دو بخش تشکیل شده است: یک فرستنده (transmitter) و یک گیرنده (receiver). شما
میتوانید هر تعداد فرستنده که میخواهید داشته باشید، اما تنها یک گیرنده برای هر کانال وجود دارد
(این مدل به نام multiple producer, single consumer یا MPSC شناخته میشود).
ایجاد یک کانال
برای ایجاد یک کانال، از تابع mpsc::channel() استفاده میکنیم. این
تابع یک تاپل برمیگرداند که عضو اول آن فرستنده (tx) و عضو دوم آن گیرنده (rx) است.
src/main.rs
use std::{sync::mpsc, thread};
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
در این مثال، ما یک ترِد جدید ایجاد کرده و فرستنده (tx) را با استفاده از move به آن
منتقل میکنیم. ترِد جدید یک پیام را با متد send() ارسال میکند. این
متد مالکیت مقداری که ارسال میشود را به خود منتقل میکند تا از استفاده همزمان آن در دو ترِد
جلوگیری شود. در ترِد اصلی، ما متد recv() را روی گیرنده فراخوانی
میکنیم. این متد اجرای ترِد اصلی را تا زمانی که یک پیام از کانال دریافت شود، مسدود (block)
میکند.
کانالها و مالکیت
سیستم مالکیت Rust نقش مهمی در ایمنی کانالها دارد. وقتی یک مقدار را از طریق کانال send
میکنید، مالکیت آن مقدار به ترِد گیرنده منتقل میشود. این کار از بروز خطا به دلیل دسترسی همزمان
به داده جلوگیری میکند.
ارسال چندین مقدار
گیرنده (rx) را میتوان به عنوان یک تکرارگر (iterator) نیز استفاده کرد. این روش زمانی مفید
است که میخواهیم چندین پیام را از یک ترِد دریافت کنیم. حلقه for به صورت خودکار منتظر
دریافت پیامها میماند و زمانی که کانال بسته شود، حلقه نیز به پایان میرسد.
src/main.rs
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {}", received);
}
در این کد، ترِد اصلی به صورت یک حلقه روی rx پیمایش میکند و هر پیامی را که از ترِد دیگر
ارسال میشود، چاپ میکند. کانال زمانی بسته میشود که فرستنده (tx) از حوزه خارج شود.
چندین فرستنده با clone کردن
برای ایجاد چندین فرستنده، میتوانیم فرستنده اصلی (tx) را clone کنیم.
src/main.rs
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
tx.send(String::from("from thread 1")).unwrap();
});
thread::spawn(move || {
tx1.send(String::from("from thread 2")).unwrap();
});
for received in rx {
println!("Got: {}", received);
}
در این درس با الگوی ارسال پیام و استفاده از کانالها به عنوان یک روش ایمن و کارآمد برای ارتباط
بین ترِدها آشنا شدیم. این الگو با تکیه بر سیستم مالکیت، از بروز بسیاری از خطاهای رایج همزمانی
جلوگیری میکند. اما گاهی اوقات، اشتراکگذاری حافظه بین ترِدها اجتنابناپذیر است. در درس بعدی، با
«مدل Shared-State Concurrency» و ابزارهایی که Rust برای مدیریت ایمن حافظه مشترک ارائه میدهد،
آشنا خواهیم شد.