Bu en iyi şekilde bir örnekle gösterilmiştir.
Paralel olarak birden çok kez gerçekleştirmek istediğimiz basit bir görevimiz olduğunu ve örneğin bir web sayfasındaki isabetleri saymak gibi görevin küresel olarak gerçekleştirilme sayısını takip etmek istediğimizi varsayalım.
Her iş parçacığı sayımı artırdığı noktaya geldiğinde, yürütme aşağıdaki gibi görünecektir:
- Bellekten bir işlemci kaydına isabet sayısını okuyun
- Bu sayıyı artır.
- Bu numarayı tekrar belleğe yaz
Her iş parçacığının bu işlemin herhangi bir noktasında askıya alınabileceğini unutmayın. Bu nedenle, A iş parçacığı 1. adımı gerçekleştirir ve daha sonra askıya alınırsa, B iş parçacığının her üç adımı gerçekleştirmesini takiben, A iş parçacığı devam ettiğinde, kayıtlarında yanlış isabet sayısı olur: kayıtları geri yüklenir, eski sayıyı mutlu bir şekilde artırır. ve bu artan sayıyı saklayın.
Ayrıca, A iş parçacığının askıya alındığı sırada herhangi bir sayıda başka iş parçacığı çalıştırılmış olabilir, bu nedenle sonunda yazılan A iş parçacığı sayısı doğru sayımın çok altında olabilir.
Bu nedenle, bir iş parçacığı 1. adımı gerçekleştirirse, başka bir iş parçacığının 1. adımı gerçekleştirmesine izin verilmeden önce 3. adımı gerçekleştirmesi gerekir; bu işlem, bu işleme başlamadan önce tek bir kilit almak için bekleyen tüm iş parçacıkları tarafından gerçekleştirilebilir. ve kilidin yalnızca işlem tamamlandıktan sonra serbest bırakılması, böylece kodun bu "kritik bölümü" yanlış araya sokulamaz ve bu da yanlış saymaya neden olur.
Peki ya operasyon atomik olsaydı?
Evet, arttırma işleminin atomik olduğu büyülü tek boynuzlu atlar ve gökkuşağı topraklarında, yukarıdaki örnek için kilitleme gerekli değildir.
Bununla birlikte, büyülü tek boynuzlu atlar ve gökkuşağı dünyasında çok az zaman harcadığımızı fark etmek önemlidir. Hemen hemen her programlama dilinde, arttırma işlemi yukarıdaki üç adıma ayrılır. Bunun nedeni, işlemci bir atomik artış işlemini desteklese bile, bu işlem çok daha pahalıdır: bellekten okumak, numarayı değiştirmek ve tekrar belleğe yazmak zorundadır ... ve genellikle atomik artış işlemi, başarısız olabilir, yani yukarıdaki basit sıra bir döngü ile değiştirilmelidir (aşağıda göreceğimiz gibi).
Çok iş parçacıklı kodda bile, birçok değişken tek bir iş parçacığında yerel tutulduğu için, programlar her değişkenin tek bir iş parçacığında yerel olduğunu varsayarlar ve programcıların iş parçacıkları arasındaki paylaşılan durumu korumalarına izin verirlerse çok daha verimlidirler. Özellikle atomik işlemlerin, daha sonra göreceğimiz gibi, diş açma sorunlarını çözmek için genellikle yeterli olmadığı göz önüne alındığında.
Uçucu değişkenler
Bu sorun için kilitlerden kaçınmak istiyorsak, ilk örneğimizde tasvir edilen adımların aslında modern derlenmiş kodda gerçekleşen şey olmadığını fark etmeliyiz. Derleyiciler yalnızca bir iş parçacığının değişkeni değiştirdiğini varsaydığından, işlemci iş parçacığı başka bir şey için gerekli olana kadar her iş parçacığı değişkenin kendi önbelleğe alınmış kopyasını tutacaktır. Önbelleğe alınmış kopyaya sahip olduğu sürece, belleğe geri dönüp tekrar okumak zorunda olmadığını varsayar (bu pahalı olur). Ayrıca bir kayıt defterinde tutulduğu sürece değişkeni belleğe geri yazmazlar.
İlk örnekte verdiğimiz duruma (yukarıda tanımladığımız tüm aynı diş açma problemleriyle) değişkeni uçucu olarak işaretleyerek geri dönebiliriz , bu da derleyiciye bu değişkenin başkaları tarafından değiştirildiğini söyler ve bu nedenle okunması gerekir veya erişildiğinde veya değiştirildiğinde bellek belleğine yazılır.
Dolayısıyla, uçucu olarak işaretlenmiş bir değişken bizi atomik artış operasyonlarının ülkesine götürmeyecek, sadece bizi zaten düşündüğümüz kadar yaklaştırıyor.
Artışı atomik yapmak
Bir değişken değişken kullandığımızda, çoğu modern CPU'nun desteklediği (genellikle karşılaştırma ve ayarlama veya karşılaştırma ve takas adı verilir) düşük seviyeli bir koşullu ayar işlemi kullanarak artış operasyonumuzu atomik hale getirebiliriz . Bu yaklaşım örneğin Java'nın AtomicInteger sınıfında uygulanır:
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
Yukarıdaki döngü, 3. adım başarılı olana kadar aşağıdaki adımları tekrar tekrar gerçekleştirir:
- Geçici bir değişkenin değerini doğrudan bellekten okuyun.
- Bu değeri artırın.
- Değeri (ana bellekte), yalnızca ana bellekteki mevcut değeri, özel bir atomik işlem kullanarak ilk başta okuduğumuz değerle aynıysa değiştirin.
Adım 3 başarısız olursa (adım 1'den sonra değer farklı bir iş parçacığı tarafından değiştirildiğinden), değişkeni doğrudan ana bellekten yeniden okur ve yeniden dener.
Karşılaştırma ve değiştirme işlemi pahalı olsa da, bu durumda kilitleme kullanmaktan biraz daha iyidir, çünkü adım 1'den sonra bir iplik askıya alınırsa, adım 1'e ulaşan diğer ipliklerin ilk ipliği engellemesi ve beklemesi gerekmez. pahalı içerik geçişini önleyebilir. İlk iş parçacığı devam ettiğinde, değişkeni yazmak için ilk denemesinde başarısız olur, ancak yine de kilitleme ile gerekli olabilecek bağlam anahtarından daha ucuz olan değişkeni yeniden okuyarak devam edebilir.
Böylece, gerçek kilitleri kullanmadan karşılaştırma ve takas yoluyla atomik artışların (veya tek bir değişken üzerindeki diğer işlemlerin) ülkesine gidebiliriz.
Peki kilitleme ne zaman kesinlikle gereklidir?
Bir atomik işlemde birden fazla değişkeni değiştirmeniz gerekiyorsa, kilitleme gerekli olacaktır, bunun için özel bir işlemci talimatı bulamazsınız.
Tek bir değişken üzerinde çalıştığınız ve başarısız olmak ve değişkeni okumak ve yeniden başlamak zorunda kaldığınız her iş için hazır olduğunuz sürece, karşılaştırma ve takas yeterince iyi olacaktır.
Her bir iş parçacığının önce bir X değişkenine 2 eklediği ve ardından X'i ikiyle çarptığı bir örneği ele alalım.
X başlangıçta bir ve iki iş parçacığı çalışıyorsa, sonucun (((1 + 2) * 2) + 2) * 2 = 16 olmasını bekliyoruz.
Bununla birlikte, dişler serpiştirilirse, tüm işlemler atomik olsa bile, bunun yerine önce her iki ekleme de gerçekleşebilir ve çarpmalar sonra gelir (1 + 2 + 2) * 2 * 2 = 20.
Bunun nedeni, çarpma ve toplama işlemlerinin değişmeli işlemler olmamasıdır.
İşlemlerin kendileri atomik olmak yeterli değil, operasyonların birleşimini atomik yapmalıyız.
İşlemi serileştirmek için kilitleme kullanarak yapabiliriz, ya da hesaplamaya başladığımızda X değerini depolamak için bir yerel değişken, ara adımlar için ikinci bir yerel değişken kullanabilir ve daha sonra karşılaştır ve değiştir özelliğini kullanabiliriz. yalnızca X'in geçerli değeri X'in orijinal değeri ile aynıysa yeni bir değer belirleyin. Başarısız olursak, X'i okuyarak ve hesaplamaları tekrar yaparak yeniden başlamamız gerekir.
Dahil olan birkaç değiş tokuş vardır: hesaplamalar uzadıkça, devam eden iş parçacığının askıya alınması çok daha olası hale gelir ve devam etmeden önce değer başka bir iş parçacığı tarafından değiştirilir, yani hatalar çok daha olası hale gelir ve bu da israfa yol açar işlemci zamanı. Çok uzun çalışma hesaplamaları olan çok sayıda iş parçacığının aşırı durumunda, değişkeni okumak ve hesaplamalara katılmak için 100 iş parçacığımız olabilir, bu durumda yeni değeri yazarken yalnızca ilk bitirmeyi başarır, diğer 99 yine de hesaplamalarını tamamlayın, ancak tamamlandığında değeri güncelleyemediklerini keşfedin ... bu noktada her biri değeri okuyacak ve hesaplamaya yeniden başlayacaklardır. Kalan 99 iş parçacığının da aynı sorunu tekrar etmesini ve büyük miktarda işlemci zamanını boşa harcamasını isterdik.
Kritik bölümün kilitlerle tam serileştirilmesi bu durumda çok daha iyi olurdu: 99 diş, kilidi alamadıklarında askıya alınır ve her bir ipliği kilitleme noktasına varış sırasına göre çalıştırırdık.
Serileştirme kritik değilse (artan durumumuzda olduğu gibi) ve sayının güncellenmesi başarısız olursa kaybedilecek hesaplamalar minimumsa, karşılaştırma ve takas işleminin kullanılmasıyla kazanılması önemli bir avantaj olabilir, çünkü bu işlem kilitlemeden daha ucuzdur.