Rust'ta yeni bir iş parçacığı (thread) açmak için std::thread::spawn fonksiyonuna çalışacak kodu bir closure olarak verirsiniz; fonksiyon size bir JoinHandle<T> döndürür. Bu tutamaç üzerinde join() çağırdığınızda mevcut iş parçacığı, açtığınız iş parçacığı bitene kadar bekler ve closure'ın döndürdüğü değeri Ok içinde alırsınız. Ana iş parçacığındaki bir veriyi kullanmak istiyorsanız closure'ın önüne move yazarak sahipliği yeni iş parçacığına taşırsınız; yerel değişkenleri taşımadan ödünç vermek istiyorsanız thread::scope kullanırsınız.
Bu yazıda standart kütüphanenin iş parçacığı modelini, spawn imzasındaki Send ve 'static kısıtlarının neden orada olduğunu, E0373 hatasını, kapsamlı (scoped) iş parçacıklarını, Builder ile ad ve yığın boyutu vermeyi ve bir iş parçacığındaki paniği join ile yakalamayı anlatıyoruz. Tüm örnekler Rust 1.99.1 ve edition 2024 ile derlenip çalıştırıldı. Rust'ın genel tanıtımı için Rust nedir yazısına, konu sırası için Rust öğrenme rehberi sayfasına bakabilirsiniz.
Rust iş parçacığı modeli: 1:1 ve ana iş parçacığının önemi

Çalışan bir Rust programı, her birinin kendi yığını (stack) ve yerel durumu olan işletim sistemi iş parçacıklarından oluşur. The Rust Book, standart kütüphanenin 1:1 modelini kullandığını belirtir: dilde açtığınız her iş parçacığı için işletim sisteminde tam olarak bir iş parçacığı oluşur. Başka modelleri uygulayan crate'ler vardır, ancak bunlar standart kütüphanenin parçası değildir ve bu yazının kapsamı dışındadır. Async yaklaşımı ise farklı bir eşzamanlılık modeli sunar; onu Rust async/await ve Future yazısında ele alıyoruz.
Akılda tutmanız gereken ilk kural şudur: ana iş parçacığı bittiğinde program tamamen kapanır. O anda çalışmaya devam eden diğer iş parçacıkları da sonlandırılır ve std belgesine göre bu iş parçacıklarının yığınındaki yıkıcılar (destructor) çalışmayabilir. Bu yüzden işini bitirmesini istediğiniz her iş parçacığını bir yerde join etmeniz gerekir. İkinci kural da sık gözden kaçar: iş parçacığını açan ile açılan arasında ebeveyn ve çocuk ilişkisi yoktur. Ana iş parçacığı dışındaki bir iş parçacığı kendi açtığı iş parçacığından önce bitebilir; açılan iş parçacığı yaşamaya devam eder.
Birden çok iş parçacığının hangi sırayla çalışacağı garanti edilmez. Sıra işletim sisteminin zamanlayıcısına bağlıdır. Kitap, bundan doğan üç tipik sorun sınıfını sayar: iş parçacıklarının veriye tutarsız sırayla eriştiği yarış durumları (race condition), iki iş parçacığının birbirini sonsuza kadar beklediği kilitlenmeler (deadlock) ve yalnızca belirli koşullarda ortaya çıkıp tekrar üretilmesi zor hatalar. Rust'ın tip sistemi bunlardan veri yarışını derleme anında engeller, ancak kilitlenme ve mantık hataları yine sizin sorumluluğunuzdadır.
thread::spawn, JoinHandle ve join
spawn fonksiyonunun imzası şöyledir: pub fn spawn<F, T>(f: F) -> JoinHandle<T>, burada F: FnOnce() -> T + Send + 'static ve T: Send + 'static kısıtları geçerlidir. Closure bir kez çağrılır (FnOnce), döndürdüğü T değeri de tutamaç üzerinden geri alınır. Aşağıdaki örnek bir toplama işini üç parçaya bölüp her parçayı ayrı iş parçacığında hesaplıyor ve sonuçları join ile topluyor.
use std::thread;
fn main() {
let mut tutamaclar = Vec::new();
for parca in 0..3u64 {
// Her iş parçacığı kendi aralığının toplamını hesaplar ve döndürür.
let tutamac = thread::spawn(move || {
let bas = parca * 1000 + 1;
let son = (parca + 1) * 1000;
(bas..=son).sum::<u64>()
});
tutamaclar.push(tutamac);
}
let mut genel = 0;
for (i, t) in tutamaclar.into_iter().enumerate() {
// join, iş parçacığı bitene kadar bekler ve dönüş değerini Ok içinde verir.
let ara = t.join().unwrap();
println!("parça {i}: {ara}");
genel += ara;
}
println!("toplam: {genel}");
}
// çıktı: parça 0: 500500
// çıktı: parça 1: 1500500
// çıktı: parça 2: 2500500
// çıktı: toplam: 4501500join metodu self değerini tükettiği için her tutamacı yalnızca bir kez join edebilirsiniz. Dönüş tipi std::thread::Result<T> türüdür: iş parçacığı normal biterse Ok(T), panik ile biterse panic! çağrısına verilen değeri taşıyan Err gelir. İş parçacığı zaten bitmişse join hemen döner; bitmemişse, iş parçacığının yerel değişkenleri için çalışan yıkıcılar dahil her şey bitene kadar bekler. Belgeye göre iş parçacığının yaptığı tüm işlemler, join döndükten sonraki işlemlerden önce gerçekleşmiş sayılır; yani sonucu okurken ek bir senkronizasyona ihtiyacınız yoktur.
Örnekte sonuçların sırası her çalıştırmada aynıdır, çünkü yazdırma işini iş parçacıkları değil, tutamaçları sırayla join eden ana iş parçacığı yapıyor. Hesaplamalar paralel yürüse bile sonuçları belirli bir sırayla toplamak için bu desen çok kullanışlıdır. Tutamaçları bir Vec içinde biriktirip sonra döngüyle join etmek, ilk tutamacı hemen join edip ikinciyi sonra açmaktan farklıdır: ikinci durumda iş parçacıkları aynı anda değil, sırayla çalışır. Kitap da join çağrısının yerinin iş parçacıklarının eşzamanlı çalışıp çalışmayacağını belirlediğini vurgular.
Tutamacı bir değişkene atamaz ya da düşürürseniz (drop) iş parçacığı ayrılmış (detached) hale gelir: hâlâ çalışır, ama artık onu join etmenin bir yolu kalmaz. JoinHandle klonlanamaz, çünkü belgeye göre bir iş parçacığını join etme yetkisi tek bir sahibe aittir. Engellemeden kontrol etmek isterseniz is_finished() metodu iş parçacığının ana fonksiyonunun bitip bitmediğini söyler; true döndükten sonra yapılan join çağrısının kayda değer süre beklememesi beklenir.
move closure ve E0373 hatası

Closure içinde ana iş parçacığındaki bir değişkeni kullandığınızda Rust, değişkeni nasıl yakalayacağına kendisi karar verir. println! yalnızca bir referansa ihtiyaç duyduğu için closure değişkeni ödünç almaya çalışır. Ancak derleyici yeni iş parçacığının ne kadar süre çalışacağını bilemez; ana fonksiyon değişkeni düşürdükten sonra da iş parçacığı çalışıyor olabilir. Bu yüzden aşağıdaki kod derlenmez:
use std::thread;
fn main() {
let isimler = vec!["Ayşe", "Göksel", "İlkin"];
let t = thread::spawn(|| {
println!("{isimler:?}");
});
t.join().unwrap();
}
// error[E0373]: closure may outlive the current function, but it borrows `isimler`, which is owned by the current functionHatanın devamında derleyici iki önemli ipucu verir: fonksiyonun argüman tipinin 'static ömründen uzun yaşamasını istediğini söyleyen bir not ve move anahtar kelimesini eklemenizi öneren bir yardım satırı. 'static kısıtı, std belgesinin açıkladığı gibi iş parçacığının onu oluşturan kapsamdan daha uzun yaşayabilmesinden gelir: ne zaman biteceği bilinmediği için closure'ın yakaladığı her şeyin programın sonuna kadar geçerli kalabilmesi gerekir. Kendi sahip olduğu veriyi taşıyan bir closure bu koşulu sağlar.
use std::thread;
fn main() {
let isimler = vec!["Ayşe", "Göksel", "İlkin"];
// move: closure, isimler vektörünün sahipliğini iş parçacığına taşır.
let t = thread::spawn(move || {
println!("{isimler:?}");
isimler.len()
});
let adet = t.join().unwrap();
println!("adet: {adet}");
// println!("{isimler:?}"); // derlenmez: isimler artık taşındı
}
// çıktı: ["Ayşe", "Göksel", "İlkin"]
// çıktı: adet: 3move, Rust'ın varsayılan olarak ödünç alma eğilimini geçersiz kılar ama sahiplik kurallarını delmenize izin vermez. Değeri closure'a taşıdıktan sonra ana iş parçacığında kullanmaya çalışırsanız bu kez E0382 (use of moved value) hatası alırsınız. Değeri iki tarafta da kullanmanız gerekiyorsa taşımadan önce clone() ile kopyalayabilir, salt okunur paylaşım için Arc kullanabilir ya da aşağıda anlatılan thread::scope yapısına geçebilirsiniz. Taşıma kurallarının ayrıntısı Rust ownership ve Rust closure yazılarında, bu hata ailesinin çözümleri de E0382 borrow of moved value yazısında.
Send kısıtı ise closure'ın değer olarak başka bir iş parçacığına aktarılmasından ve dönüş değerinin geri taşınmasından gelir. Send uygulamayan bir tipi, örneğin Rc<T> değerini yakalayan closure ile spawn çağırırsanız E0277 hatası alırsınız. Bu konuyu paylaşılan durum bağlamında Rust Arc ve Mutex yazısında örnekle gösteriyoruz.
thread::scope: yerel veriyi ödünç veren iş parçacıkları
thread::scope, içinde açılan iş parçacıklarının kapsam bitmeden önce mutlaka join edileceğini garanti eder. Bu garanti sayesinde kapsamlı iş parçacıkları 'static olmayan verileri, yani yığındaki yerel değişkenlerinizi doğrudan ödünç alabilir; move ya da Arc gerekmez. Fonksiyona verdiğiniz closure bir Scope nesnesi alır ve iş parçacıklarını s.spawn ile açarsınız.
use std::thread;
fn main() {
let mut veriler = vec![3, 8, 1, 9, 4, 7];
let mut en_buyuk = 0;
// scope içinde açılan iş parçacıkları yerel değişkenleri ödünç alabilir.
thread::scope(|s| {
let (sol, sag) = veriler.split_at_mut(3);
s.spawn(|| sol.sort());
s.spawn(|| sag.sort());
s.spawn(|| en_buyuk = 100); // &mut ödünç: başka kimse kullanmıyor
}); // burada üç iş parçacığı da otomatik olarak join edilir
// scope bittiği için değişkenlere yeniden erişebiliriz.
veriler.push(en_buyuk);
println!("{veriler:?}");
}
// çıktı: [1, 3, 8, 4, 7, 9, 100]Örnekte split_at_mut vektörü çakışmayan iki değiştirilebilir dilime ayırıyor; iki iş parçacığı bu dilimleri aynı anda sıralıyor, üçüncüsü de en_buyuk değişkenine &mut ile yazıyor. Ödünç alma kuralları burada da aynen geçerlidir: aynı veriye iki iş parçacığından değiştirilebilir erişim isterseniz derleyici reddeder. Kapsam döndükten sonra ödünçler sona erdiği için veriler üzerinde yeniden push yapabiliyoruz. Dilimler ve ödünç alma için Rust slice ve Rust referans ve borrowing yazılarına bakabilirsiniz.
Belge iki ömür tanımlar: 'scope kapsamın kendisini, yani yeni iş parçacıklarının açılabildiği ve hâlâ çalışıyor olabildiği süreyi temsil eder; 'env ise iş parçacıklarının ödünç aldığı verinin ömrüdür ve scope çağrısından uzun sürmek zorundadır. Bu yüzden kapsamdan hemen önce tanımlanmış yerel değişkenler ödünç verilebilir. Otomatik join edilen iş parçacıklarından biri panik yaparsa scope fonksiyonu da panik yapar; panikleri kendiniz ele almak istiyorsanız s.spawn çağrısının döndürdüğü ScopedJoinHandle üzerinde kapsam bitmeden join çağırmalısınız. Belgedeki bir ayrıntı daha: otomatik join yalnızca iş parçacıklarının ana fonksiyonunu bekler, iş parçacığına yerel değişkenlerin yıkıcıları scope döndüğünde hâlâ çalışıyor olabilir.
Builder ile ad, yığın boyutu ve panik yakalama
Varsayılan ayarlar yetmediğinde thread::Builder kullanırsınız. Builder şu an iki şeyi ayarlar: iş parçacığının adı (name) ve yığın boyutu (stack_size). Önemli bir fark vardır: thread::spawn işletim sistemi iş parçacığı oluşturamazsa panik yapar, Builder::spawn ise bu durumda hata döndürür; yani io::Result<JoinHandle<T>> verir ve hatayı ele almanıza izin verir. Aşağıdaki örnek adlandırılmış bir iş parçacığı açıyor, iş parçacığı içinde bilerek panik üretiyor ve ana iş parçacığında bu paniği join sonucundan okuyor.
use std::thread;
fn main() {
let tutamac = thread::Builder::new()
.name("rapor".to_string())
.stack_size(4 * 1024 * 1024)
.spawn(|| {
let ad = thread::current().name().map(str::to_owned);
println!("çalışan: {ad:?}");
if ad.as_deref() == Some("rapor") {
panic!("rapor dosyası bulunamadı");
}
0
})
.expect("iş parçacığı oluşturulamadı");
match tutamac.join() {
Ok(deger) => println!("sonuç: {deger}"),
Err(yuk) => {
// Yük &str ya da String olabilir; hangisi olacağı garanti değil.
let mesaj = yuk
.downcast_ref::<&str>()
.copied()
.or_else(|| yuk.downcast_ref::<String>().map(String::as_str))
.unwrap_or("bilinmeyen panik");
println!("iş parçacığı çöktü: {mesaj}");
}
}
println!("ana iş parçacığı devam ediyor");
}
// çıktı: çalışan: Some("rapor")
// çıktı: iş parçacığı çöktü: rapor dosyası bulunamadı
// çıktı: ana iş parçacığı devam ediyorProgram çalışırken standart hata akışına (stderr) ayrıca thread 'rapor' (...) panicked at t5.rs:11:17: biçiminde bir satır ve panik mesajı yazılır. Parantez içindeki sayı her çalıştırmada değişir; önemli olan iş parçacığının adının mesajda görünmesidir. std belgesi, adlandırılmış bir iş parçacığında panik olduğunda adın panik mesajına yazıldığını ve mümkün olan platformlarda adın işletim sistemine de iletildiğini belirtir. Üretimde iş parçacıklarına anlamlı adlar vermek log okumayı kolaylaştırır.
Panik yükü (payload) Box<dyn Any + Send + 'static> olarak gelir ve normal hatalardan farklı olarak Error trait'ini uygulamaz. Sıradan bir panic! çağrısında yük &str ya da String olur; panic! belgesine göre hangisinin geleceği belirtilmemiştir ve değişebilir. Bu yüzden örnekte önce &str, sonra String deniyoruz. Paniği olduğu gibi yukarı taşımak isterseniz std::panic::resume_unwind kullanabilirsiniz. Panik, std belgesinin deyişiyle bir try/catch mekanizması olarak düşünülmemiştir; beklenen hatalar için Result kullanmanız gerekir. Hata yönetimi için Rust Result ve hata yönetimi yazısına bakın. Ana iş parçacığı yakalanmamış bir panikle biterse uygulama sıfır olmayan bir çıkış koduyla kapanır.
Yığın boyutu konusunda belge şunu söyler: varsayılan boyut platforma bağlıdır ve değişebilir; şu an tüm Tier-1 platformlarda 2 MiB'dir. Boyutu Builder::stack_size ile ya da bayt cinsinden RUST_MIN_STACK ortam değişkeniyle ayarlayabilirsiniz; Builder ayarı ortam değişkenini geçersiz kılar. Ana iş parçacığının yığın boyutunu ise Rust belirlemez.
Diğer yararlı fonksiyonlar

- thread::sleep(Duration): mevcut iş parçacığını en az verilen süre kadar uyutur. Örneklerde sıralamayı kanıtlamak için uyku kullanmak yanıltıcıdır; sırayı join ya da kanal ile kurun.
- thread::current(): çağıran iş parçacığının
Threadtutamacını verir;name()veid()buradan okunur. Bu fonksiyon bu modülün API'leriyle açılmamış iş parçacıklarında da çalışır. - thread::park() ve Thread::unpark(): bir iş parçacığını jeton (token) kullanılabilir olana kadar bekletir. Async yazısındaki basit yürütücü bu ikiliyle kuruluyor.
- thread::yield_now(): zaman dilimini işletim sistemi zamanlayıcısına gönüllü olarak bırakır.
- thread::available_parallelism(): programın varsayılan olarak kullanması gereken paralellik miktarının bir tahminini
io::Result<NonZero<usize>>olarak döndürür. Belge bunun basitleştirilmiş bir yaklaşım olduğunu, sanal makine veya konteyner sınırlarına göre değişebileceğini ve sık çağrılan koda konmaması gerektiğini söyler, çünkü değer her çağrıda yeniden hesaplanır. - thread_local!: her iş parçacığının kendi kopyasını tuttuğu global değer tanımlar. Değere yalnızca paylaşılan referansla erişilebildiği için değiştirmek için genellikle
CellveyaRefCellile birlikte kullanılır.
| Araç | Ne zaman | Ödünç veri | Hata davranışı |
|---|---|---|---|
| thread::spawn | Bağımsız, uzun yaşayabilecek iş | Hayır, move ve static gerekir | OS iş parçacığı açamazsa panik |
| thread::Builder::spawn | Ad veya yığın boyutu gerektiğinde | Hayır, spawn ile aynı kısıtlar | io::Result döndürür |
| thread::scope ve s.spawn | Yerel veriyi paralel işlemek | Evet, kapsam sonunda join garanti | Join edilmemiş iş parçacığı panikse scope panik yapar |
Sık yapılan hatalar
- Tutamacı join etmemek: main bittiğinde iş parçacığı yarıda kesilir. Kitaptaki örnek çalıştırmada açılan iş parçacığı 9'a kadar saymak isterken ana iş parçacığı bittiği için 5'te kalıyor.
- join çağrısını yanlış yere koymak: her iş parçacığını açtığınız satırda hemen join ederseniz paralellik kaybolur ve işler sırayla yürür.
- Sırayı garanti sanmak: farklı iş parçacıklarından gelen çıktıların sırası her çalıştırmada değişebilir. Belirli bir sıra gerekiyorsa sonuçları toplayıp ana iş parçacığında sıralayın.
- move'u her şeyi çözen sihirli kelime sanmak: taşıdığınız değeri sonra kullanamazsınız. İki tarafta da gerekiyorsa clone, Arc ya da thread::scope kullanın.
- join sonucunu düşünmeden unwrap etmek: iş parçacığı panik yaptıysa unwrap paniği ana iş parçacığına da yayar. Bu bazen istenen davranıştır, ama sunucu gibi uzun yaşayan programlarda hatayı ele almak daha doğrudur.
- Bir iş parçacığının kendini join etmesi: belge, bir iş parçacığının kendini join etmeye çalışmasının bazı platformlarda panik yapabileceğini belirtir.
Sık Sorulan Sorular
Rust iş parçacıkları Go goroutine gibi hafif mi?
Hayır. Standart kütüphane 1:1 model kullanır, yani her Rust iş parçacığı bir işletim sistemi iş parçacığıdır ve varsayılan yığın boyutu Tier-1 platformlarda 2 MiB'tir. Çok sayıda eşzamanlı ve çoğunlukla bekleyen iş için Kitap async modelini önerir. Go'nun yaklaşımı için Go nedir yazısına bakabilirsiniz.
Kaç iş parçacığı açmalıyım?
CPU ağırlıklı işler için thread::available_parallelism() iyi bir başlangıç tahmini verir. Belge bu değerin NUMA bölgelerini, sistem yükünü ve işlemci farklılıklarını hesaba katmadığını, Windows ve Linux'ta bazı durumlarda gerçek değerden az ya da çok çıkabileceğini açıkça belirtir; ölçerek karar verin.
İş parçacıkları arasında veri nasıl paylaşılır?
İki yol vardır: mesaj göndermek ve durumu paylaşmak. Mesajlaşma için Rust mpsc kanal yazısına, ortak veri için Rust Arc, Mutex ve RwLock yazısına bakın. Yalnızca okuma yapılacak ve iş belirli bir kapsamda bitecekse thread::scope en sade çözümdür.
Testler de ayrı iş parçacıklarında mı çalışır?
Evet. Kitaba göre cargo test testleri varsayılan olarak paralel iş parçacıklarında çalıştırır ve bir test, test iş parçacığı panik yaptığında başarısız sayılır. Ayrıntılar Rust test yazma yazısında.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: Rust öğrenme rehberi · Rust mpsc kanalları · Rust Arc ve Mutex · Rust async/await · Rust closure · Rust ownership · Rust nedir