Paylaşımlı Durum Eşzamanlılığı
Mesaj iletimi, eşzamanlılığı yönetmenin iyi yollarından biridir; ama tek yol bu değildir. Başka bir yöntem de birden fazla iş parçacığının aynı paylaşılan veriye erişmesidir. Go belgelerindeki şu sözün bu bölümünü yeniden düşünün: “Belleği paylaşarak iletişim kurmayın.”
Peki belleği paylaşarak iletişim kurmak nasıl bir şeye benzerdi? Ayrıca mesaj iletimini savunanlar neden bellek paylaşımından özellikle kaçınmayı öneriyor?
Bir bakıma, herhangi bir programlama dilindeki kanallar tek sahipliğe benzer; çünkü bir değeri kanaldan gönderdiğinizde artık onu kullanmamanız gerekir. Paylaşımlı bellekle eşzamanlılık ise çoklu sahipliğe benzer: Birden çok iş parçacığı aynı anda aynı bellek konumuna erişebilir. 15. bölümde akıllı işaretçilerin çoklu sahipliği nasıl mümkün kıldığını görmüştünüz. Çoklu sahiplik, farklı sahiplerin yönetilmesini gerektirdiği için doğal olarak karmaşıklık da getirir. Rust’ın tür sistemi ve sahiplik kuralları bu yönetimi doğru yapma konusunda çok yardımcı olur. Bir örnek olarak, paylaşımlı bellek eşzamanlılığında sık kullanılan temel yapılardan biri olan mutex’e bakalım.
Mutex<T> ile Erişimi Denetlemek
Mutex, mutual exclusion ifadesinin kısaltmasıdır; yani bir mutex, belli bir anda yalnızca tek bir iş parçacığının bazı verilere erişmesine izin verir. Mutex içindeki veriye erişmek için, iş parçacığı önce erişim istediğini bildirmeli ve mutex’in kilidini (lock) almalıdır. Kilit, mutex’in bir parçası olan ve o anda veriye kimin özel erişimi olduğunu takip eden veri yapısıdır. Bu yüzden mutex’in tuttuğu veriyi kilitleme sistemiyle koruduğu söylenir.
Mutex’lerin kullanımı zor olmakla ünlüdür; çünkü iki kuralı sürekli akılda tutmanız gerekir:
- Veriyi kullanmadan önce kilidi almaya çalışmalısınız.
- Mutex’in koruduğu veriyle işiniz bittiğinde, diğer iş parçacıkları da kilidi alabilsin diye kilidi bırakmalısınız.
Bunu gündelik bir benzetmeyle düşünelim: Tek mikrofonu olan bir panel oturumunu hayal edin. Konuşmacılardan biri konuşmadan önce mikrofonu istediğini belirtmelidir. Mikrofonu alınca istediği kadar konuşur; sonra da sıradaki kişiye verir. Bir konuşmacı işini bitirince mikrofonu devretmeyi unutursa, başka kimse konuşamaz. Paylaşılan mikrofonun yönetimi bozulursa panel planlandığı gibi ilerlemez.
Mutex yönetimini doğru yapmak gerçekten zordur; işte bu yüzden birçok kişi kanalları daha heyecan verici bulur. Ama Rust’ın tür sistemi ve sahiplik kuralları sayesinde, kilitleme ile kilidi bırakma işlerini yanlış yapmanız çok daha zordur.
Mutex<T> API’si
Mutex’in nasıl kullanıldığını görmek için önce tek iş parçacıklı çok basit bir örnekle başlayalım; 16-12 numaralı liste bunu gösteriyor.
use std::sync::Mutex;
fn main() {
let kilit = Mutex::new(5);
{
let mut sayi = kilit.lock().unwrap();
*sayi = 6;
}
println!("kilit = {kilit:?}");
}
Mutex<T> API’sini tek iş parçacıklı bağlamda incelemekBirçok türde olduğu gibi Mutex<T> değerini de new ilişkili fonksiyonuyla
oluştururuz. İçindeki veriye erişmek için lock metoduyla kilidi alırız. Bu
çağrı, kilidi alma sırası bize gelene kadar mevcut iş parçacığını bloklar.
Kilidi elinde tutan başka bir iş parçacığı paniklerse lock çağrısı hata
verebilir. Böyle bir durumda artık hiç kimse kilidi alamayacağı için, burada
unwrap kullanıp bu iş parçacığının da paniklemesini seçtik.
Kilidi aldıktan sonra, burada sayi adını verdiğimiz dönüş değerini içerideki
veriye ait değiştirilebilir referans gibi kullanabiliriz. Tür sistemi, kilit
değerinin içindeki veriyi kullanmadan önce gerçekten kilit aldığımızdan emin
olur. kilit değişkeninin türü i32 değil Mutex<i32> olduğu için, içerideki
i32 değeri kullanabilmek adına zorunlu olarak lock çağırırız. Bunu
unutamayız; tür sistemi başka türlü izin vermez.
lock çağrısı, bizim unwrap ile ele aldığımız LockResult içine sarılmış
bir MutexGuard döndürür. MutexGuard, Deref uygular; böylece içerideki
veriyi işaret eder. Ayrıca Drop uygulaması sayesinde bir MutexGuard kapsam
dışına çıktığında kilit otomatik olarak bırakılır. Bu da içteki kapsamın sonunda
olur. Sonuç olarak kilidi bırakmayı unutup mutex’i başka iş parçacıklarının
kullanmasına engel olma riski yaşamayız; kilit bırakma işi kendiliğinden olur.
Kilidi bıraktıktan sonra mutex değerini ekrana yazdırabilir ve içerideki i32
değerini 6 yaptığımızı görebiliriz.
Mutex<T> İçin Paylaşımlı Erişim
Şimdi Mutex<T> kullanarak bir değeri birden fazla iş parçacığı arasında
paylaştırmayı deneyelim. 10 iş parçacığı oluşturup her birinin sayacı 1
artırmasını isteyeceğiz; böylece sayaç 0’dan 10’a çıkacak. 16-13 numaralı
listedeki örnek derleyici hatası verecek ve bu hata üzerinden Mutex<T> ile
çalışırken Rust’ın bize nasıl yardım ettiğini daha iyi anlayacağız.
use std::sync::Mutex;
use std::thread;
fn main() {
let sayac = Mutex::new(0);
let mut tutamaclar = vec![];
for _ in 0..10 {
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> ile korunan sayacı artıran on iş parçacığı16-12 numaralı listedekine benzer şekilde, Mutex<T> içindeki bir i32
değerini tutmak için sayac değişkeni oluşturuyoruz. Ardından bir sayı aralığı
üzerinde dönerek 10 iş parçacığı başlatıyoruz. thread::spawn çağrısına,
sayacı iş parçacığına taşıyan, lock metoduyla Mutex<T> kilidini alan ve
sonra içerdeki değere 1 ekleyen aynı kapanışı veriyoruz. Bir iş parçacığı
kapanışı bitirdiğinde sayi kapsam dışına çıkar ve kilit bırakılır; böylece
başka bir iş parçacığı kilidi alabilir.
Ana iş parçacığında bütün tutamaçları bir vektörde topluyoruz. Sonra 16-2
numaralı listedeki gibi her tutamaç üzerinde join çağırarak tüm iş
parçacıklarının bitmesini bekliyoruz. En sonunda ana iş parçacığı kilidi alıp
programın sonucunu yazdırıyor.
Bu örneğin derlenmeyeceğini söylemiştik. Şimdi nedenine bakalım:
$ cargo run
Compiling paylasimli-durum v0.1.0 (file:///projects/paylasimli-durum)
error[E0382]: borrow of moved value: `sayac`
--> src/main.rs:21:29
|
5 | let sayac = Mutex::new(0);
| ----- move occurs because `sayac` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let tutamac = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Sonuç: {}", *sayac.lock().unwrap());
| ^^^^^ value borrowed here after move
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = sayac.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `paylasimli-durum` (bin "paylasimli-durum") due to 1 previous error
Hata mesajı sayac değerinin döngünün önceki yinelemesinde taşındığını
söylüyor. Yani Rust bize, kilitli sayac değerinin sahipliğini birden fazla iş
parçacığına taşıyamayacağımızı anlatıyor. Bunu, 15. bölümde gördüğümüz çoklu
sahiplik yaklaşımıyla düzeltmeye çalışalım.
Birden Fazla İş Parçacığıyla Çoklu Sahiplik
- bölümde bir değere birden fazla sahip vermek için
Rc<T>akıllı işaretçisini kullanmıştık. Aynı şeyi burada da yapıp ne olacağına bakalım. 16-14 numaralı listedeMutex<T>değeriniRc<T>içine sarıyor ve iş parçacığına taşımadan önceRc<T>değerini klonluyoruz.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let sayac = Rc::new(Mutex::new(0));
let mut tutamaclar = vec![];
for _ in 0..10 {
let sayac = Rc::clone(&sayac);
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> değerine sahip olabilmesi için Rc<T> kullanmaya çalışmakYine derliyoruz ve… bu kez farklı hatalar alıyoruz. Derleyici bize çok şey öğretiyor:
$ cargo run
Compiling paylasimli-durum v0.1.0 (file:///projects/paylasimli-durum)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `paylasimli-durum` (bin "paylasimli-durum") due to 1 previous error
Bu mesajın en önemli kısmı şudur:
`Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely.
Derleyici bunun nedenini de söylüyor:
the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`.
Bir sonraki bölümde Send trait’ini ayrıntılı konuşacağız. Şimdilik şunu
bilin: Send, iş parçacıklarıyla kullanılacak türlerin eşzamanlı bağlamlara
uygun olduğunu garanti eden trait’lerden biridir.
Ne yazık ki Rc<T> iş parçacıkları arasında paylaşmak için güvenli değildir.
Rc<T> referans sayısını yönettiğinde, her clone çağrısında sayıyı artırır,
her klon düşürüldüğünde de azaltır. Fakat bu sayacın güncellenmesi sırasında
başka bir iş parçacığının araya girmesini önleyecek bir eşzamanlılık ilkelini
kullanmaz. Bu da yanlış sayımlara, dolayısıyla çok sinsi hatalara, bellek
sızıntılarına ya da işimiz bitmeden bir değerin düşürülmesine yol açabilir.
Burada bize gereken şey, Rc<T>ye çok benzeyen ama referans sayısını iş
parçacığı güvenli biçimde güncelleyen bir türdür.
Arc<T> ile Atomik Referans Sayımı
Neyse ki Arc<T>, Rc<T> gibi davranan ama eşzamanlı ortamlarda güvenle
kullanılabilen bir türdür. Buradaki a, atomic sözcüğünden gelir; yani bu
tür atomik referans sayımlıdır (atomically reference-counted). Atomikler,
burada ayrıntısına girmeyeceğimiz ayrı bir eşzamanlılık ilkelidir. Daha fazla
bilgi için standart kütüphanedeki std::sync::atomic
belgelerine bakabilirsiniz. Şimdilik bilmeniz gereken, atomik türlerin ilkel
türler gibi çalıştığı ama iş parçacıkları arasında güvenle paylaşılabildiğidir.
Şu soru akla gelebilir: Madem öyle, neden bütün ilkel türler atomik değil ya da
standart kütüphane türleri varsayılan olarak neden Arc<T> kullanmıyor?
Cevap şu: İş parçacığı güvenliği bir performans bedeli getirir ve bu bedeli
yalnızca gerçekten gerektiğinde ödemek istersiniz. Tek bir iş parçacığında
çalışıyorsanız, atomik güvenceleri zorunlu kılmak zorunda kalmayan kod daha
hızlı çalışabilir.
Örneğimize geri dönelim: Arc<T> ile Rc<T> aynı API’yi sunar. Bu nedenle
programı düzeltmek için yalnızca use satırını, new çağrısını ve clone
çağrısını değiştirmemiz yeterlidir. 16-15 numaralı listedeki kod sonunda
derlenir ve çalışır.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let sayac = Arc::new(Mutex::new(0));
let mut tutamaclar = vec![];
for _ in 0..10 {
let sayac = Arc::clone(&sayac);
let tutamac = thread::spawn(move || {
let mut sayi = sayac.lock().unwrap();
*sayi += 1;
});
tutamaclar.push(tutamac);
}
for tutamac in tutamaclar {
tutamac.join().unwrap();
}
println!("Sonuç: {}", *sayac.lock().unwrap());
}
Mutex<T> değerini Arc<T> ile sarmalamakBu kod aşağıdaki çıktıyı üretir:
Sonuç: 10
Başardık! 0’dan 10’a kadar saydık. Bu çok etkileyici görünmeyebilir; ama
Mutex<T> ve iş parçacığı güvenliği hakkında pek çok şey öğrenmiş olduk.
Aslında aynı yapı, yalnızca sayaç artırmak değil çok daha karmaşık hesaplar
için de kullanılabilir. Bu stratejiyle bir hesabı bağımsız parçalara bölebilir,
parçaları iş parçacıklarına dağıtabilir ve sonunda her parçanın sonucunu
Mutex<T> aracılığıyla ortak sonuca yansıtabilirsiniz.
Şunu da unutmayın: Basit sayısal işlemler yapıyorsanız, standart
kütüphanedeki std::sync::atomic modülü altında
Mutex<T>den daha sade türler bulunur. Bu türler ilkel verilere güvenli,
eşzamanlı ve atomik erişim sağlar. Biz burada Mutex<T>yi ilkel bir türle
kullanmayı özellikle seçtik; çünkü amacımız öncelikle Mutex<T>nin nasıl
çalıştığını göstermekti.
RefCell<T>/Rc<T> ile Mutex<T>/Arc<T> Karşılaştırması
sayac değişkeninin değiştirilemez tanımlandığını ama içindeki değere
değiştirilebilir referans alabildiğimizi fark etmiş olabilirsiniz. Bu da
Mutex<T>nin, Cell ailesindeki türler gibi içsel değiştirilebilirlik
sağladığını gösterir. 15. bölümde Rc<T> içindeki değeri değiştirebilmek için
RefCell<T> kullanmıştık; burada da aynı işi Arc<T> içindeki değer için
Mutex<T> ile yapıyoruz.
Dikkat edilmesi gereken başka bir nokta daha var: Mutex<T> kullanırken Rust
sizi her türlü mantık hatasından koruyamaz. 15. bölümde, Rc<T> kullanırken
iki değerin birbirini işaret etmesiyle referans döngüsü kurulabileceğini ve
bunun bellek sızıntısına yol açabileceğini görmüştünüz. Benzer biçimde
Mutex<T> de kilitlenme (deadlock) riskini taşır. Bu durum, bir işlemin iki
kaynak için kilit alması gerektiğinde ve iki iş parçacığının bu kilitlerden
birer tanesini alıp birbirini sonsuza kadar beklemesiyle ortaya çıkar.
Kilitlenmeler ilginizi çekiyorsa, bir kilitlenme içeren küçük bir Rust programı
yazmayı deneyin. Ardından başka dillerde mutex kullanan sistemlerde uygulanan
kilitlenme azaltma stratejilerini araştırın ve benzerini Rust’ta kurmayı
deneyin. Standart kütüphanedeki Mutex<T> ve MutexGuard API belgeleri bu
konuda yararlı bilgiler içerir.
Bölümü Send ve Sync trait’lerinden ve bunları özel türlerle nasıl
kullanabileceğimizden söz ederek tamamlayacağız.