Android için SMP yardımcı programı

Android 3.0 ve sonraki platform sürümleri, çok işlemcili mimarileri destekleyecek şekilde optimize edilmiştir. Bu belgede; C, C++ ve Java programlama dilinde (bundan sonra kısaca "Java" olarak anılacaktır) simetrik çok işlemcili sistemler için çok iş parçacıklı kod yazarken ortaya çıkabilecek sorunlar açıklanmaktadır. Bu metin, bir konu hakkında eksiksiz bir tartışma için değil, Android uygulama geliştiricileri için bir kılavuz niteliğindedir.

Giriş

SMP, "Ssimetrik Çoklu İşlemci" ifadesinin kısaltmasıdır. İki veya daha fazla özdeş CPU çekirdeğinin ana belleğe erişimi paylaştığı bir tasarımı ifade eder. Birkaç yıl öncesine kadar tüm Android cihazlar UP (Uni-İşlemci) modeliydi.

Çoğu (hepsi olmasa da) Android cihazların her zaman birden fazla CPU'su vardı. Ancak geçmişte, Android cihazlardan yalnızca biri uygulama çalıştırmak için kullanılıyordu. Diğerleri ise çeşitli cihaz donanımı parçalarını (ör. radyo) yönetiyordu. CPU'lar farklı mimarilere sahip olabilir ve üzerlerinde çalışan programlar birbirleriyle iletişim kurmak için ana belleği kullanamıyordu.

Günümüzde satılan çoğu Android cihaz SMP tasarımlarına dayanır. Bu da yazılım geliştiricileri için işleri biraz daha karmaşık hale getirir. Çok iş parçacıklı bir programdaki yarış koşulları, bir işlemcide görünür sorunlara neden olmayabilir ancak iş parçacıklarınızdan iki veya daha fazlası farklı çekirdeklerde aynı anda çalıştığında düzenli olarak başarısız olabilir. Dahası, kodlar farklı işlemci mimarilerinde veya aynı mimarinin farklı uygulamalarında çalıştırıldığında hata verme olasılığı daha az veya daha düşük olabilir. x86 üzerinde kapsamlı bir şekilde test edilmiş kod, ARM üzerinde kötü bir şekilde bozulabilir. Kod, daha modern bir derleyici ile yeniden derlendiğinde başarısız olmaya başlayabilir.

Bu belgenin geri kalanında bu nedenin açıklanması ve kodunuzun doğru bir şekilde çalışmasını sağlamak için ne yapmanız gerektiği açıklanacaktır.

Bellek tutarlılığı modelleri: SMP'ler neden biraz farklıdır?

Bu, karmaşık bir konuya yüksek hızlı ve parlak bir genel bakıştır. Bazı alanlar eksik olacak ancak hiçbiri yanıltıcı veya yanlış olmamalıdır. Bir sonraki bölümde göreceğiniz gibi, buradaki ayrıntılar genellikle önemli değildir.

Konuyla ilgili daha kapsamlı işlemlere yönelik işaretçiler için belgenin sonundaki Daha fazla okuma bölümüne bakın.

Bellek tutarlılığı modelleri veya genellikle sadece "bellek modelleri", programlama dili veya donanım mimarisinin bellek erişimleri hakkında sağladığı garantileri açıklar. Örneğin, A adresi için bir değer ve ardından B adresi için bir değer yazarsanız model, her CPU çekirdeğinin bu yazma işlemlerinin bu sırayla gerçekleştiğini görmeyi garanti edebilir.

Çoğu programcının alışık olduğu model, sıralı tutarlılıktır. Bu model (Adve ve Gharachorloo) şeklinde tanımlanır:

  • Tüm bellek işlemleri teker teker yürütülecek gibi görünüyor
  • Tek bir iş parçacığındaki tüm işlemler, söz konusu işlemciye ait program tarafından açıklanan sırada yürütülür gibi görünür.

Geçici olarak, hiçbir sürprizle karşılaşmayan çok basit bir derleyicimiz veya yorumlayıcımız olduğunu varsayalım. Bu, kaynak kodundaki atamaları, erişim başına bir talimat olmak üzere, talimatları tam olarak karşılık gelen sırada yüklemek ve depolamak için çevirir. Basit olması için her iş parçacığının kendi işlemcisinde çalıştığını da varsayacağız.

Bir koda baktığınızda bellekten bazı okuma ve yazma işlemleri yaptığını görüyorsanız sıralı tutarlı CPU mimarisinde, kodun bu okuma ve yazma işlemlerini beklenen sırada yapacağını bilirsiniz. CPU'nun talimatları doğrudan yeniden sıraladığı ve okuma ile yazma işlemlerini geciktirdiği durumlar olabilir. Ancak cihazda çalışan kodun, CPU'nun talimatları doğrudan uygulamak dışında bir şey yaptığını belirtmesi mümkün değildir. (Bellek eşlemesi yapılmış cihaz sürücüsü G/Ç'sini yok sayacağız.)

Bu noktaları açıklamak için, genellikle inceleme testleri olarak adlandırılan küçük kod snippet'lerini dikkate almak faydalı olur.

Aşağıda, kodun iki iş parçacığında çalıştığı basit bir örnek verilmiştir:

İş Parçacığı 1 İleti dizisi 2
A = 3
B = 5
reg0 = B
reg1 = A

Bu örnekte ve gelecekteki tüm değerlendirme örneklerinde, bellek konumları büyük harfle (A, B, C) temsil edilir ve CPU kayıtları "reg" ile başlar. Tüm bellek başlangıçta sıfırdır. Talimatlar yukarıdan aşağıya doğru yürütülmüştür. Burada iş parçacığı 1, 3 değerini A konumunda, 5 değerini ise B konumunda depolar. İş parçacığı, değeri B konumundan reg0'a ve daha sonra değeri A konumundan reg1'e yükler. (Bir sırayla okuyup başka bir sırayla okuduğumuzu unutmayın.)

İş parçacığı 1 ve iş parçacığı 2'nin farklı CPU çekirdeklerinde çalıştığı varsayılır. Çok iş parçacıklı kod konusunu düşünürken her zaman bu varsayımı yapmanız gerekir.

Sıralı tutarlılık, her iki iş parçacığının da yürütülmesi bittikten sonra kayıtların aşağıdaki durumlardan birinde olacağını garanti eder:

Kaydol Eyaletler
reg0=5, reg1=3 olası (öncelikle 1. ileti dizisi çalıştırıldı)
reg0=0, reg1=0 olası (öncelikle 2. ileti dizisi çalıştırıldı)
reg0=0, reg1=3 olası (eşzamanlı yürütme)
reg0=5, reg1=0 asla

Mağazayı A'ya yönlendirmeden önce B=5 değerini gördüğümüz duruma gelmek için, okuma veya yazma işlemlerinin sırayla gerçekleşmemesi gerekir. Sıralı tutarlı bir makinede bu mümkün değildir.

x86 ve ARM dahil olmak üzere tek işlemciler normalde sıralı olarak tutarlıdır. İşletim sistemi çekirdeği aralarında geçiş yaparken iş parçacıkları araya eklemeli şekilde yürütülür gibi görünür. x86 ve ARM dahil olmak üzere çoğu SMP sistemi ardışık olarak tutarlı değildir. Örneğin, donanımın belleğe giderken depoları arabelleğe alması yaygın bir uygulamadır. Böylece belleğe hemen ulaşmaz ve diğer çekirdekler tarafından görülebilir hale gelmez.

Ayrıntılar önemli ölçüde farklılık gösterir. Örneğin x86, sıralı olarak tutarlı olmasa da reg0 = 5 ve reg1 = 0 değerlerinin imkânsız kalacağını garanti eder. Mağazalar arabelleğe alınır ancak bunların sırası korunur. Öte yandan ARM bunu yapmaz. Arabelleğe alınan mağazaların sırası dikkate alınmaz ve mağazalar aynı anda diğer tüm çekirdeklere erişemeyebilir. Bu farklılıklar, montaj programcıları için önemlidir. Ancak, aşağıda göreceğimiz gibi C, C++ veya Java programcıları bu tür mimari farklılıkları gizleyecek şekilde program yapabilir ve bunu yapmalıdır.

Şu ana kadar, talimatların yalnızca donanımda olduğunu, gerçekçi olmayan bir şekilde varsaydık. Gerçekte, derleyici de performansı artırmak için talimatları yeniden sıralar. Örneğimizde derleyici, İş Parçacığı 2'de daha sonra bulunan bazı kodların, reg0 gerektirmeden önce reg1 değerine ihtiyacı olduğuna karar verebilir ve bu nedenle önce reg1'i yükler. Veya önceki bir kod A'yı zaten yüklemiş olabilir ve derleyici, A'yı tekrar yüklemek yerine bu değeri yeniden kullanmaya karar verebilir. Her iki durumda da reg0 ve reg1'e yapılan yüklemeler yeniden sıralanabilir.

Tek bir iş parçacığının yürütülmesini etkilemediği ve performansı önemli ölçüde artırabileceği için farklı bellek konumlarına erişimleri donanımda veya derleyicide yeniden sıralamaya izin verilir. Göreceğimiz gibi, biraz özenle, çok iş parçacıklı programların sonuçları etkilenmesini de engelleyebiliriz.

Derleyiciler bellek erişimlerini de yeniden sıralayabildiğinden, bu sorun SMP'ler için yeni bir sorun değildir. Tek işlemcide bile, derleyici örneğimizde yükleri reg0 ve reg1 olacak şekilde yeniden sıralayabilir ve Thread 1 yeniden sıralanan talimatlar arasında planlanabilir. Ancak derleyicimiz yeniden sıralanmazsa bu sorunu asla gözlemlemeyebiliriz. Çoğu ARM SMP'de, derleyicinin yeniden sıralanması olmasa bile yeniden sıralama muhtemelen çok sayıda başarılı yürütme işleminden sonra görülür. Assembly dilinde programlama yapmadığınız sürece, SMP'ler genellikle aslında devam eden sorunlarla karşılaşma ihtimalinizi artırır.

Yarış gerektirmeyen programlama

Neyse ki bu ayrıntıları düşünmekten kaçınmanın genellikle kolay bir yolu var. Bazı basit kuralları izlerseniz "sıralı tutarlılık" bölümü hariç önceki bölümlerin tümünü unutmak genellikle güvenlidir. Ne yazık ki bu kuralları yanlışlıkla ihlal ederseniz diğer sorunlar da görünebilir.

Modern programlama dilleri, "veri yarışı içermeyen" programlama stilini teşvik eder. "Veri yarışlarını" uygulamayacağınıza söz verdiğiniz ve derleyiciye aksini belirten birkaç yapıdan kaçındığınız sürece derleyici ve donanım, sıralı olarak tutarlı sonuçlar sunmayı taahhüt eder. Bu, bellek erişimi yeniden sıralamalarından kaçındıkları anlamına gelmez. Yani kurallara uyarsanız bellek erişimlerinin yeniden sıralandığını söyleyemezsiniz. Sosisin lezzetli ve lezzetli bir yemek olduğunu, sosis fabrikasına gitme sözü vermediğiniz sürece bunu söylemeye benzer. Veri yarışları, bellek yeniden sıralamayla ilgili çirkin gerçeği ortaya çıkarır.

"Veri yarışı" nedir?

En az iki iş parçacığı aynı sıradan verilere aynı anda eriştiğinde ve bunlardan en az biri verileri değiştirdiğinde bir veri yarışı meydana gelir. "Sıradan veriler" ifadesiyle özel olarak iş parçacığı iletişimi için tasarlanmış bir senkronizasyon nesnesi olmayan bir şeyi kastediyoruz. Zaman uyumsuzluklar, koşul değişkenleri, Java dalgalanmaları veya C++ atom nesneleri sıradan veri değildir ve bunların erişimlerinin yarışmasına izin verilir. Hatta diğer nesnelerde veri yarışlarını önlemek için kullanılırlar.

İki iş parçacığının aynı anda aynı bellek konumuna erişip erişmediğini belirlemek için yukarıdaki bellek yeniden sıralama tartışmasını yok sayabilir ve sıralı tutarlılık olduğunu varsayabiliriz. A ve B başlangıçta yanlış olan normal boole değişkenleriyse aşağıdaki programda veri yarışı yoktur:

İş Parçacığı 1 İleti dizisi 2
if (A) B = true if (B) A = true

İşlemler yeniden sıralanmadığı için, her iki koşul da yanlış olarak değerlendirilir ve hiçbir değişken güncellenmez. Bu nedenle bir veri yarışı olamaz. İş parçacığı 1'deki A ve B deposundaki yük bir şekilde yeniden sıralanırsa ne olacağını düşünmeye gerek yoktur. Derleyicinin Thread 1'i "B = true; if (!A) B = false" olarak yeniden yazarak yeniden sıralamasına izin verilmiyor. Bu, gün ışığında şehrin ortasında sosis yapmaya benzer.

Veri yarışları, tam sayılar, referanslar veya işaretçiler gibi temel yerleşik türlerde resmi olarak tanımlanır. Bunu başka bir iş parçacığında okurken aynı anda int öğesine atamak açıkça bir veri yarışıdır. Ancak hem C++ standart kitaplığı hem de Java Koleksiyonları kitaplıkları, kitaplık düzeyinde veri yarışları hakkında akıl yürütmenizi sağlamak için yazılmıştır. Aynı container'a en az biri güncelleyen bir erişim olmadığı sürece veri yarışları sunmayacağına söz verirler. Bir iş parçacığındaki set<T> öğesini güncellemek, aynı anda başka bir iş parçacığında okumak kitaplığın bir veri yarışı başlatmasına olanak tanır ve böylece resmi olmayan bir şekilde "kitaplık düzeyinde veri yarışı" olarak düşünülebilir. Öte yandan, bir iş parçacığındaki bir set<T> değerini güncellerken diğerinde farklı bir set<T> değeri güncellemek veri yarışıyla sonuçlanmaz. Çünkü kitaplık, böyle bir durumda (düşük seviyeli) bir veri yarışı uygulamamayı taahhüt eder.

Normalde bir veri yapısındaki farklı alanlara eşzamanlı erişimler bir veri yarışı başlatamaz. Ancak bu kuralın önemli bir istisnası vardır: C veya C++'taki bit alanı dizilerinin ardışık düzenleri, tek bir "bellek konumu" olarak kabul edilir. Böyle bir dizideki herhangi bir bit alanına erişmek, bir veri yarışının varlığını belirleme amacıyla tüm bit alanlarına erişmek olarak değerlendirilir. Bu durum, ortak donanımın bitişik bitleri de okuyup yeniden yazmadan bağımsız bitleri güncelleyemediğini gösterir. Java programcılarının benzer endişeleri yoktur.

Veri seçimlerinden kaçınma

Modern programlama dilleri, veri çakışmalarını önlemek için bir dizi senkronizasyon mekanizması sunar. En temel araçlar şunlardır:

Kilitler veya Muhafızlar
Sessizler (C++11 std::mutex veya pthread_mutex_t) ya da Java'daki synchronized bloklar, belirli bir kod bölümünün aynı verilere erişen diğer kod bölümleriyle eş zamanlı olarak çalışmamasını sağlamak için kullanılabilir. Bunlara ve diğer benzer tesislere genel olarak "kilitler" diyeceğiz. Paylaşılan veri yapısına erişmeden önce düzenli olarak belirli bir kilidin alınması ve daha sonra serbest bırakılması, veri yapısına erişirken veri yarışlarını önler. Ayrıca güncellemelerin ve erişimlerin atomik olmasını sağlar. Yani ortada başka veri yapısı güncellemesi çalıştırılamaz. Bu, veri yarışlarını önlemek için açık farkla en yaygın kullanılan araçtır. Java synchronized bloklarının veya C++ lock_guard ya da unique_lock'nin kullanılması, bir istisna durumunda kilitlerin düzgün şekilde kullanıma sunulmasını sağlar.
Uçucu/atom değişkenleri
Java, veri yarışları uygulamadan eşzamanlı erişimi destekleyen volatile alanları sunar. 2011'den beri C ve C++, benzer anlamlara sahip atomic değişkenlerini ve alanlarını desteklemektedir. Bunlar, tek bir değişkene erişimin tek bir atomik olmasını sağladığı için genellikle kilitlerden daha zordur. (C++'ta bu işlev normalde artımlar gibi basit okuma-değiştirme-yazma işlemlerine genişler. Java bunun için özel yöntem çağrıları gerektirir.) Kilitlerden farklı olarak volatile veya atomic değişkenleri, diğer iş parçacıklarının daha uzun kod dizilerine müdahale etmesini önlemek için doğrudan kullanılamaz.

volatile politikasının C++ ve Java'da çok farklı anlamları olduğunu unutmamak önemlidir. volatile, C++'ta veri yarışlarını önlemez ancak eski kod, atomic nesnesinin eksikliği konusunda genellikle geçici çözüm olarak bunu kullanır. Bu artık önerilmez. C++'ta, birden çok iş parçacığı tarafından eşzamanlı olarak erişilebilen değişkenler için atomic<T> kullanın. C++ volatile, cihaz kayıtları vb. için tasarlanmıştır.

Diğer değişkenlerde veri yarışlarını önlemek için C/C++ atomic değişkenleri veya Java volatile değişkenleri kullanılabilir. flag parametresinin atomic<bool> veya atomic_bool(C/C++) ya da volatile boolean (Java) türüne sahip olduğu belirtiliyorsa ve başlangıçta yanlış değerine ayarlanırsa aşağıdaki snippet veri içermez:

İş Parçacığı 1 İleti dizisi 2
A = ...
  flag = true
while (!flag) {}
... = A

İş parçacığı 2, flag öğesinin ayarlanmasını beklediğinden İş Parçacığı 2'deki A erişimi, İş Parçacığı 1'deki A atamasından sonra ve eş zamanlı olarak gerçekleşmemelidir. Dolayısıyla A üzerinde veri yarışı olmaz. Değişken/atomik erişimler "normal bellek erişimi" olmadığından, flag üzerindeki yarış veri yarışı olarak sayılmaz.

Uygulama, önceki değerlendirme testi gibi kodun beklendiği gibi davranmasını sağlayacak şekilde bellek yeniden sıralamalarını önlemek veya gizlemek için gereklidir. Bu durum, normalde değişken/atomik bellek erişimlerini sıradan erişimlerden çok daha pahalı hale getirir.

Yukarıdaki örnek veri yarışı içermiyor olsa da Java'da Object.wait() veya C/C++'ta koşul değişkenleri ile birlikte kilitlemeler genellikle pil gücünü tüketirken döngüyü beklemeyi gerektirmeyen daha iyi bir çözüm sunar.

Bellek yeniden sıralaması görünür hale geldiğinde

Veri yarışsız programlama özelliği, normalde bizi bellek erişimi yeniden sıralama sorunlarıyla açık bir şekilde uğraşmaktan kurtarır. Bununla birlikte, yeniden sıralamanın görünür hale geldiği birkaç durum vardır:
  1. Programınızda istenmeyen veri yarışına neden olan bir hata varsa derleyici ve donanım dönüşümleri görülebilir ve programınızın davranışı şaşırtıcı olabilir. Örneğin, önceki örnekte flag değişkenliğini belirtmeyi unutursak İş Parçacığı 2'de başlatılmamış bir A görebilirsiniz. Veya derleyici, iş parçacığı 2'nin döngüsü sırasında işaretin değiştirilemeyeceğine karar verip programı
    İş Parçacığı 1 İleti dizisi 2
    A = ...
      flag = true
    reg0 = flag; ise (!reg0) {}
    ... = A
    Hata ayıklama yaptığınızda, flag doğru olsa bile döngünün sonsuza kadar devam ettiğini görebilirsiniz.
  2. C++, ırklar olmasa bile sıralı tutarlılığı açık bir şekilde gevşetme olanağı sunar. Atomik işlemleri açık memory_order_... bağımsız değişkenlerini alabilir. Benzer şekilde, java.util.concurrent.atomic paketi benzer imkanlardan oluşan daha kısıtlı bir grup (özellikle lazySet()) sağlar. Java programcıları da bazen benzer bir etki için bilinçli veri yarışları kullanır. Tüm bunlar, programlama karmaşıklığı açısından büyük bir maliyetle performans iyileştirmeleri sağlar. Bunları aşağıda kısaca ele alacağız.
  3. Bazı C ve C++ kodları, geçerli dil standartlarıyla tamamen tutarlı olmayan daha eski bir stilde yazılmıştır. Burada atomic olanların yerine volatile değişkenleri kullanılır ve bellek sıralamasına çitler veya bariyerler eklenerek bellek sıralamasına açıkça izin verilmez. Bu, erişim yeniden sıralama ve donanım bellek modellerinin anlaşılmasıyla ilgili açık bir akıl yürütmeyi gerektirir. Linux çekirdeğinde bu satırlarla devam eden bir kodlama stili hâlâ kullanılmaktadır. Yeni Android uygulamalarında kullanılmamalıdır ve burada daha ayrıntılı bir şekilde ele alınmamıştır.

Alıştırma Yap

Bellek tutarlılığı sorunlarında hata ayıklamak çok zor olabilir. Eksik bir kilit, atomic veya volatile bildirimi bazı kodların eski verileri okumasına neden oluyorsa bellek dökümlerini hata ayıklayıcıyla inceleyerek bunun nedenini anlayamayabilirsiniz. Hata ayıklayıcı sorgusu yayınlamaya başlayana kadar CPU çekirdeklerinin tümü erişim grubunun tamamını gözlemlemiş olabilir. Ayrıca bellek ve CPU kayıtlarının içeriği "imkânsız" durumda görünür.

C'de yapılmaması gerekenler

Burada, hatalı kod örnekleri ve bunları düzeltmenin basit yolları sunulmaktadır. Bunu yapmadan önce, temel bir dil özelliğinin kullanımından bahsetmemiz gerekiyor.

C/C++ ve "değişken"

C ve C++ volatile bildirimleri çok özel amaçlı bir araçtır. Derleyicinin yeniden sıralama yapmasını veya değişken erişimleri kaldırmasını engellerler. Bu, donanım cihazı kayıtlarına koda erişirken, birden fazla konumla eşlenen bellekte veya setjmp ile bağlantılı olarak yararlı olabilir. Ancak Java volatile'in aksine C ve C++ volatile, iş parçacığı iletişimi için tasarlanmamıştır.

C ve C++'ta volatile verilerine erişim, değişken olmayan verilere erişilerek yeniden sıralanabilir ve atomiklik garantisi verilmez. Bu nedenle, taşınabilir koddaki iş parçacıkları arasında (tek işlemcide olsa bile) veri paylaşımı için volatile kullanılamaz. C volatile, genellikle donanımın erişimi yeniden sıralanmasını engellemez. Bu nedenle, çok iş parçacıklı SMP ortamlarında tek başına çok daha az yararlıdır. C11 ve C++11'in atomic nesnelerini desteklemesinin nedeni budur. Onun yerine bunları kullanmalısınız.

Eski C ve C++ kodlarının birçoğu hâlâ iş parçacığı iletişimi için volatile özelliğini kötüye kullanır. Bu yöntem, makine kaydına sığan verilerde, açıkça tanımlanmış sınırlarla veya bellek sırasının önemli olmadığı durumlarda doğru şekilde çalışır. Ancak gelecekteki derleyicilerle doğru çalışacağı garanti edilmez.

Örnekler

Çoğu durumda atomik işlem yerine kilit (pthread_mutex_t veya C++11 std::mutex gibi) kullanmanız daha iyi olur, ancak pratik bir durumda nasıl kullanılacağını göstermek için ikincisini kullanacağız.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

Buradaki fikir, bir yapıyı ayırmamız, alanlarını başlatmamız ve en sonda onu bir genel değişkende depolayarak "yayınlamamız"dır. Bu noktada diğer herhangi bir iş parçacığı tarafından görülebilir, ancak tamamen başlatıldığı için sorun değil, değil mi?

Sorun, alanlar başlatılmadan önce gGlobalThing deposunun gözlemlenebilmesidir. Bunun nedeni, genellikle derleyicinin veya işlemcinin mağazaları gGlobalThing ve thing->x olarak yeniden sıralamasıdır. thing->x kaynağından okunan başka bir iş parçacığı 5, 0 ve hatta başlatılmamış verileri görebilir.

Buradaki temel sorun, gGlobalThing tarihindeki veri yarışıdır. İş parçacığı 1'de initGlobalThing(), İş parçacığı 2'de ise useGlobalThing() çağrısı yapılırsa gGlobalThing yazılırken okunabilir.

Bu, gGlobalThing özelliğinin atomik olduğunun belirtilmesiyle düzeltilebilir. C++11'de:

atomic<MyThing*> gGlobalThing(NULL);

Bu, yazmaların doğru sırada diğer iş parçacıkları için görünür olmasını sağlar. Ayrıca, aksi halde izin verilen ancak gerçek Android donanımında gerçekleşme olasılığı düşük olan diğer hata modlarını önlemeyi de garanti eder. Örneğin, yalnızca kısmen yazılmış bir gGlobalThing işaretçisini göremememizi sağlar.

Java'da yapılmaması gerekenler

Java diliyle ilgili bazı özelliklerden bahsetmedik, bu nedenle önce ona hızlıca göz atacağız.

Java'nın veri yarışı içermemesi için teknik olarak kod gerektirmez. Ayrıca, veri yarışlarının varlığında doğru çalışan, son derece dikkatli bir şekilde yazılmış az miktarda Java kodu vardır. Ancak, böyle bir kodu yazmak son derece zordur. Bununla ilgili olarak aşağıda kısaca bahsedeceğiz. Durumu daha da kötüleştirmek amacıyla, bu tür bir kodun anlamını belirten uzmanlar artık spesifikasyonun doğru olduğuna inanmıyor. (Bu spesifikasyon, veri yarışı içermeyen kod için uygundur.)

Şimdilik, Java'nın C ve C++ ile temelde aynı garantileri sağladığı veri yarışı içermeyen modele bağlı olacağız. Dil de sıralı tutarlılığı, özellikle java.util.concurrent.atomic içindeki lazySet() ve weakCompareAndSet() çağrılarını açıkça gevşeten bazı temel öğeler sağlar. C ve C++'ta olduğu gibi, şimdilik bunları yok sayacağız.

Java'nın "senkronize edilmiş" ve "değişken" anahtar kelimeleri

"Senkronize edilmiş" anahtar kelime, Java dilinin yerleşik kilitleme mekanizmasını sağlar. Her nesnenin, karşılıklı özel erişim sağlamak için kullanılabilen ilişkili bir "izleyici"si vardır. İki iş parçacığı aynı nesne üzerinde "senkronize etmeye" çalışırsa bunlardan biri diğeri tamamlanana kadar bekler.

Yukarıda belirttiğimiz gibi, Java volatile T, C++11'in atomic<T> analogudur. volatile alanlarına eşzamanlı erişime izin verilir ve bu erişim veri yarışlarına neden olmaz. lazySet() ve diğerleri ile veri yarışları göz ardı edilerek sonucun sıralı olarak tutarlı görünmesi Java sanal makinesinin görevidir.

Özellikle, iş parçacığı 1'in bir volatile alanına yazması ve 2. iş parçacığının daha sonra aynı alandan okuma yapıp yeni yazılan değeri görmesi durumunda ileti dizisi 2'nin de daha önce iş parçacığı 1 tarafından yapılan tüm yazmaları görmesi garanti edilir. Bellek etkisi açısından, değişken bir sürüme yazma işlemi monitör sürümüne, değişkenlikten okuma ise monitör elde etmeye benzer.

C++ atomic değişkenlerinden önemli bir fark vardır: Java'da volatile int x; yazarsak x++, x = x + 1 ile aynı olur. Atom yükü gerçekleştirir, sonucu artırır ve ardından bir atom depolama işlemi gerçekleştirir. C++'tan farklı olarak, bir bütün olarak artım atomik değildir. Atomik artım işlemleri bunun yerine java.util.concurrent.atomic tarafından sağlanır.

Örnekler

Tek tip bir sayacın basit, yanlış bir uygulamasını aşağıda görebilirsiniz: (Java teorisi ve uygulaması: Değişkenliği yönetme).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

get() ve incr() değerlerinin birden fazla iş parçacığından çağrıldığını varsayalım ve get() çağrıldığında her iş parçacığının geçerli sayıyı gördüğünden emin olmak istiyoruz. En göze çarpan sorun, mValue++ işleminin aslında üç işlem olmasıdır:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

İki iş parçacığı incr() üzerinde aynı anda yürütülürse güncellemelerden biri kaybolabilir. Artışı atomik hale getirmek için incr() değerini "senkronize edildi" olarak tanımlamamız gerekir.

Ancak özellikle SMP'de hâlâ bozuk. get(), mValue öğesine incr() ile eş zamanlı olarak erişebilir. Bu nedenle hâlâ bir veri yarışı vardır. Java kuralları altında get() çağrısı diğer koda göre yeniden sıralanıyormuş gibi görünebilir. Örneğin, arka arkaya iki sayacı okursak donanım veya derleyici tarafından yeniden sıraladığımız get() çağrıları nedeniyle sonuçlar tutarsız görünebilir. get() cihazının senkronize edileceğini bildirerek sorunu düzeltebiliriz. Bu değişiklikle, kodun doğru olduğu açıktır.

Ne yazık ki, performansı olumsuz yönde etkileyebilecek kilit anlaşmazlığı olasılığını kullanıma sunduk. get() işlevinin senkronize edileceğini belirtmek yerine mValue özelliğini "değişken" ile bildirebiliriz. (mValue++ aksi halde tek bir atom işlemi olmadığından incr() yine synchronize kullanmalıdır.) Bu, tüm veri yarışlarını da önlediğinden sıralı tutarlılık korunur. incr() hem izleme giriş/çıkış genel masraflarına neden olduğu için hem de değişken bir mağazayla ilişkili ek yüke neden olduğu için biraz daha yavaş olur. Ancak get() daha hızlı olacaktır. Böylece, anlaşmazlık olmasa bile bu, yazma işlemlerinden çok fazla okunduğunda bir kazanç olur. (Senkronize edilen bloğu tamamen kaldırmanın bir yolu için bkz. AtomicInteger.)

Aşağıda, önceki C örneklerine benzer bir şekilde başka bir örnek verilmiştir:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

Bu, C koduyla aynı soruna sahiptir, yani sGoodies üzerinde bir veri yarışı vardır. Bu nedenle, goods içindeki alanların başlatılmasından önce sGoodies = goods ataması gözlemlenebilir. volatile anahtar kelimesiyle sGoodies özelliğini belirtirseniz sıralı tutarlılık geri yüklenir ve işler beklendiği gibi çalışır.

Yalnızca sGoodies referansının kendisinin değişken olduğunu unutmayın. İçindeki alanlara erişim geçerli değildir. sGoodies, volatile olduğunda ve bellek sıralaması düzgün bir şekilde korunduğunda alanlara eşzamanlı olarak erişilemez. z = sGoodies.x ifadesi, MyClass.sGoodies değişkenli yükleme ve ardından sGoodies.x değerinde geçici bir yükleme gerçekleştirecek. Yerel bir referans MyGoodies localGoods = sGoodies yaparsanız sonraki bir z = localGoods.x değişken yükleme gerçekleştirmez.

Java programlamasında daha yaygın olarak kullanılan bir deyim, kötü şöhretli "çift kontrolli kilit"tir:

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

Buradaki fikir, MyClass örneğiyle ilişkili bir Helper nesnesinin tek bir örneğini istememizdir. Yalnızca bir kez oluşturmamız gerektiğinden özel bir getHelper() işlevi üzerinden oluşturup döndürürüz. İki iş parçacığının örneği oluşturduğu bir yarışı önlemek için nesne oluşumunu senkronize etmemiz gerekir. Ancak her çağrıda "senkronize edilmiş" engellemenin ek yükünü ödemek istemiyoruz. Bu nedenle bu bölümü yalnızca helper şu anda null ise yaparız.

Bunun helper sahasında veri yarışı var. Başka bir iş parçacığındaki helper == null ile eş zamanlı olarak ayarlanabilir.

Bunun nasıl başarısız olabileceğini görmek için aynı kodu, C benzeri bir dilde derlenmiş gibi hafifçe yeniden yazıldığını düşünün (Helper’s oluşturucu etkinliğini temsil etmek için birkaç tam sayı alanı ekledim):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

Donanımın veya derleyicinin, mağazayı x/y alanlarıyla helper hale getirmesini engelleyebilecek herhangi bir şey yoktur. Başka bir iş parçacığı boş olmayan helper alanını bulabilir, ancak alanları henüz ayarlanmamış ve kullanıma hazır değil. Daha fazla ayrıntı ve daha fazla hata modu için ekteki "Çift Kontrollü Kilitleme Bozuk Bildirimi" bağlantısına veya Josh Bloch'un Effective Java, 2.Sürüm kitabındaki Item 71 ("Geç başlatma işlevini akıllıca kullanın") bölümüne bakın.

Bunu düzeltmenin iki yolu vardır:

  1. Basit işlemi yapın ve dış kontrolü silin. Bu, helper değerinin senkronize edilmiş bir blok dışında hiçbir zaman incelenmemesini sağlar.
  2. helper değişkenliği bildir. Bu küçük değişiklikle, Örnek J-3'teki kod Java 1.5 ve üzerinde doğru bir şekilde çalışacak. (Bunun doğru olduğuna inanmak için bir dakikanızı ayırın.)

volatile davranışını gösteren başka bir örneği aşağıda bulabilirsiniz:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

useValues() incelendiğinde, Thread 2 vol1 ile ilgili güncellemeyi henüz gözlemlemediyse data1 veya data2 ayarlanıp ayarlanmadığını bilemez. vol1 güncellemesini gördükten sonra, data1 uygulamasına bir veri yarışı başlamadan güvenli bir şekilde erişilebileceğini ve doğru şekilde okunabileceğini bilir. Bununla birlikte, söz konusu mağaza değişken depodan sonra performans gösterdiği için data2 hakkında herhangi bir varsayımda bulunamaz.

volatile, birbiriyle yarışan diğer bellek erişimlerinin yeniden sıralanmasını önlemek için kullanılamaz. Makine belleği sınırı talimatının üretileceği garanti edilmez. Yalnızca başka bir iş parçacığı belirli bir koşulu karşıladığında kod yürüterek veri yarışlarını önlemek için kullanılabilir.

Ne yapmalı?

C/C++'ta std::mutex gibi C++11 senkronizasyon sınıflarını tercih edin. Aksi halde karşılık gelen pthread işlemlerini kullanın. Bunlar arasında uygun bellek sınırları, aksi belirtilmedikçe doğru (aksi belirtilmedikçe sıralı olarak tutarlı) ve tüm Android platform sürümlerinde verimli çalışma biçimi bulunur. Bunları doğru kullandığınızdan emin olun. Örneğin, koşul değişkeninin beklediğinin sinyal olmadan sadece geri dönebileceğini ve dolayısıyla bir döngü içinde görünmesi gerektiğini unutmayın.

Uyguladığınız veri yapısı bir sayaç gibi son derece basit değilse doğrudan atomik fonksiyonları kullanmaktan kaçınmak en iyisidir. İş parçacığı mutex'ini kilitlemek ve kilidini açmak için her biri tek bir atomik işlem gerektirir ve çakışma yoksa genellikle tek bir önbellekten daha az maliyet olur. Bu nedenle, mutex çağrılarını atomik işlemlerle değiştirerek çok fazla tasarruf etmezsiniz. Basit olmayan veri yapıları için kilitsiz tasarımlar, veri yapısındaki üst düzey işlemlerin atomik (yalnızca açıkça atom parçaları olarak değil, bir bütün olarak) görünmesini sağlamak için çok daha fazla özen gerektirir.

Atomik işlemler kullanıyorsanız memory_order... veya lazySet() ile sıralamayı gevşetmek performans açısından avantajlar sağlayabilir ancak şu ana kadar aktardığımızdan daha derin bir anlayış gerektirir. Bunları kullanan mevcut kodların büyük bir bölümünde, olay sonrasında hatalar keşfedilir. Mümkünse bunlardan kaçının. Kullanım alanlarınız bir sonraki bölümde belirtilen alanlardan birine tam olarak uymuyorsa bir uzman olduğunuzdan veya bu alana danıştığınızdan emin olun.

C/C++'da iş parçacığı iletişimi için volatile kullanmaktan kaçının.

Java'da eşzamanlılık problemleri için en iyi çözüm, java.util.concurrent paketinden uygun bir yardımcı program sınıfının kullanılmasıdır. Kod, SMP'de iyi yazılmış ve iyi test edilmiştir.

Belki de yapabileceğiniz en güvenli şey nesneleri sabit yapmaktır. Java'nın Dizesi ve Tamsayı gibi sınıflardaki nesneler, bir nesne oluşturulduktan sonra değiştirilemeyen verileri barındırır. Böylece bu nesnelerde veri yarışlarının tüm potansiyelini ortadan kaldırır. Effective Java, 2. Ed. kitabının "Öğe 15: Değişkenliği En Aza İndirme" bölümünde özel talimatlar bulunmaktadır. Özellikle, Java alanlarının "nihai" (Bloch) olarak tanımlanmasının önemine dikkat edin.

Bir nesne sabit olsa bile, onu herhangi bir senkronizasyon olmaksızın başka bir iş parçacığına iletmenin bir veri yarışı olduğunu unutmayın. Bu, bazen Java'da kabul edilebilir (aşağıya bakın) ancak büyük özen gerektirir ve büyük olasılıkla kırılmaya yol açabilir. Performans çok önemli değilse volatile bildirimi ekleyin. C++'da, herhangi bir veri yarışında olduğu gibi, uygun bir senkronizasyon olmadan bir işaretçinin veya sabit bir nesneye referansın iletilmesi bir hatadır. Bu durumda, örneğin alıcı iş parçacığı, mağazanın yeniden sıralanması nedeniyle başlatılmamış bir yöntem tablosu işaretçisi görebileceğinden aralıklı kilitlenmelere neden olması makul ölçüde olasıdır.

Mevcut bir kitaplık sınıfı veya sabit bir sınıf uygun değilse birden fazla iş parçacığı tarafından erişilebilen alanlara erişimi korumak için Java synchronized ifadesi veya C++ lock_guard / unique_lock kullanılmalıdır. Karşılıklı dışlamalar sizin durumunuza uygun değilse paylaşılan volatile veya atomic alanlarını bildirmeniz gerekir, ancak iş parçacıkları arasındaki etkileşimleri anlamak için çok dikkatli olmanız gerekir. Bu bildirimler sizi eşzamanlı programlama hatalarından kurtarmaz ancak derleyicileri ve SMP hatalarını optimize etmeyle ilişkili gizemli hatalardan kaçınmanıza yardımcı olur.

Bir nesneye referansı "yayınlamaktan", yani bunu oluşturucuda diğer iş parçacıklarının kullanımına sunmaktan kaçınmalısınız. C++'ta veya Java'daki "veri yarışı yok" tavsiyemize uyarsanız bu daha az kritik olur. Ancak bu her zaman iyi bir tavsiyedir ve Java kodunuz Java güvenlik modelinin önemli olduğu diğer bağlamlarda çalıştırılıyorsa kritik hale gelir. Güvenilir olmayan kod, bu "sızdırılmış" nesne referansına erişerek veri yarışına yol açabilir. Uyarılarımızı yok saymayı ve bir sonraki bölümde yer alan tekniklerden bazılarını kullanmayı seçmeniz de önemlidir. Ayrıntılar için (Java'da Güvenli İnşaat Teknikleri) konusuna bakın.

Zayıf bellek siparişleri hakkında daha fazla bilgi

C++11 ve sonraki sürümler, veri yarışı içermeyen programlar için sıralı tutarlılık garantilerini gevşetmek için açık mekanizmalar sağlar. Atomik işlemler için açık memory_order_relaxed, memory_order_acquire (yalnızca yüklenir) ve memory_order_release (yalnızca depolar) bağımsız değişkenlerinin her biri, varsayılan (genellikle örtülü) memory_order_seq_cst değerinden kesinlikle daha zayıf garantiler sağlar. memory_order_acq_rel, atomik okuma-değiştirme yazma işlemleri için hem memory_order_acquire hem de memory_order_release garantileri sunar. memory_order_consume, henüz faydalı olması için yeterince iyi belirtilmemiş veya uygulanmamıştır ve şimdilik yoksayılmalıdır.

Java.util.concurrent.atomic içindeki lazySet yöntemleri C++ memory_order_release mağazalarına benzer. Java'nın normal değişkenleri bazen memory_order_relaxed erişimlerinin yerine kullanılır ancak aslında daha da zayıftır. C++'nın aksine, volatile olarak tanımlanan değişkenlere sınırsız erişim için gerçek bir mekanizma yoktur.

Bunları kullanmak için acil performans nedenleri olmadığı sürece genellikle bunlardan kaçınmalısınız. ARM gibi kötü sıralanmış makine mimarilerinde bunları kullanmak, genellikle her atomik işlem için birkaç düzine makine döngüsünden tasarruf etmenizi sağlar. x86'da performans kazancı yalnızca mağazalarla sınırlı ve muhtemelen daha az fark edilebilir. Bellek sistemi daha sınırlayıcı bir faktör haline geldiğinden, bu durumun faydası daha büyük çekirdek sayılarında azalmaya neden olabilir.

Zayıf sıralı atomiklerin tüm anlamı karmaşıktır. Genellikle, dil kurallarını iyice anlamaları gerekir. Bu konuda burada ele alamıyoruz. Örneğin:

  • Derleyici veya donanım, memory_order_relaxed erişimlerini kilit edinme ve sürümle sınırlı kritik bir bölüme taşıyabilir (ancak bu bölümün dışında tutmayabilir). Bu durumda, kritik bir bölümle ayrılmış olsalar bile iki memory_order_relaxed mağazasının sırası bozulabilir.
  • Normal bir Java değişkeni, paylaşılan bir sayaç olarak kötüye kullanıldığında, yalnızca tek bir iş parçacığı tarafından artırılsa bile azaltılmak üzere başka bir iş parçacığında görünebilir. Ancak bu durum C++ atomik memory_order_relaxed için geçerli değildir.

Bununla birlikte, burada zayıf sıralı atomik kullanım alanlarının çoğuna karşılık gelen az sayıda deyim veriyoruz. Bunların birçoğu yalnızca C++ için geçerlidir.

Yarış dışı erişimler

Bazen bir yazma ile eş zamanlı olarak okunduğu için bir değişkenin atomik olması oldukça yaygındır, ancak tüm erişimlerde bu sorun görülmez. Örneğin, kritik bir bölümün dışında okunduğu için bir değişkenin atomik olması gerekebilir, ancak tüm güncellemeler bir kilitle korunur. Bu durumda, eşzamanlı yazma işlemi olamayacağı için aynı kilitle korunan bir okuma işlemi yarışamaz. Böyle bir durumda, yarış dışı erişim (bu durumda yük) C++ kodunun doğruluğu değiştirilmeden memory_order_relaxed ile ek açıklama olarak eklenebilir. Kilit uygulaması, diğer iş parçacıklarının erişimi için gerekli bellek sıralamasını zaten uygular ve memory_order_relaxed, atom erişimi için aslında hiçbir ek sıralama kısıtlamasının uygulanması gerekmediğini belirtir.

Java'da bunun için gerçek bir benzerliği yoktur.

Sonucun doğruluğu güvenilmez

Bir yarış yükünü yalnızca ipucu oluşturmak için kullandığımızda, yük için herhangi bir bellek sıralamasını uygulamamak da genellikle sorun teşkil etmez. Değer güvenilir değilse diğer değişkenlerle ilgili çıkarımlarda bulunmak için de sonucu güvenilir bir şekilde kullanamayız. Bu nedenle, bellek sıralaması garanti edilmezse ve yük bir memory_order_relaxed bağımsız değişkeniyle sağlanmışsa sorun olmaz.

Bunun yaygın bir örneği, x öğesini f(x) ile atomsal olarak değiştirmek için C++ compare_exchange kullanımıdır. f(x) hesaplaması için x ürününün ilk yükü güvenilir olmalıdır. Hata yaparsak compare_exchange başarısız olur ve işlemi tekrar deneriz. x ilk yüklemesinde memory_order_relaxed bağımsız değişkeni kullanılabilir. Yalnızca gerçek compare_exchange için bellek sırası kullanılır.

Atomik olarak değiştirilmiş ancak okunmamış veriler

Veriler bazen birden çok iş parçacığı tarafından paralel olarak değiştirilir ancak paralel hesaplama tamamlanana kadar incelenmez. Bunun iyi bir örneği, paralel olarak birden fazla iş parçacığı tarafından atomik olarak artan (ör. C++'ta fetch_add() veya C'de atomic_fetch_add_explicit() kullanılarak) ancak bu çağrıların sonucu her zaman göz ardı edilen bir sayaçtır. Elde edilen değer yalnızca son olarak, tüm güncellemeler tamamlandıktan sonra okunur.

Bu durumda, bu verilere erişimlerin yeniden sıralanıp sıralanmadığını anlamanın bir yolu yoktur ve bu nedenle C++ kodu bir memory_order_relaxed bağımsız değişkeni kullanabilir.

Basit etkinlik sayaçları bunun yaygın bir örneğidir. Bu çok yaygın bir uygulama olduğundan, bu konuyla ilgili bazı gözlemlerde bulunmakta fayda var:

  • memory_order_relaxed kullanımı performansı artırır ancak en önemli performans sorununu çözmeyebilir: Her güncelleme sayacın bulunduğu önbellek satırına özel erişim gerektirir. Bu, yeni bir iş parçacığının sayaca her eriştiğinde önbellekte yok olmasına neden olur. Güncellemeler sık ve iş parçacıkları arasında alternatifse çok daha hızlı bir şekilde, örneğin iş parçacığı yerel sayaçlarını kullanarak ve bunları sonunda toplayarak paylaşılan sayacı her seferinde güncellemekten kaçınabilirsiniz.
  • Bu teknik önceki bölümle birleştirilebilir: memory_order_relaxed kullanan tüm işlemlerle, güncellenirken yaklaşık ve güvenilir olmayan değerleri eşzamanlı olarak okumak mümkündür. Ancak, sonuçta ortaya çıkan değerlerin tamamen güvenilir olmadığını ele almak önemlidir. Sayının bir kez artırılmış gibi görünmesi, artışın gerçekleştirildiği noktaya ulaşan başka bir iş parçacığının sayılabileceği anlamına gelmez. Ürün parçası, daha önceki bir kodla yeniden sıralanmış olabilir. (Daha önce bahsettiğimiz benzer durumda olduğu gibi, C++ bu tür bir sayacın ikinci yüklemesinin aynı iş parçacığındaki önceki yükten daha düşük bir değer döndürmeyeceğini garanti eder. Elbette sayacın taştığı durumlar dışında.)
  • Ayrı ayrı atomik (veya olmayan) okuma ve yazma işlemleri gerçekleştirerek yaklaşık sayaç değerlerini hesaplamaya çalışan, ancak artışı bütün bir atom olarak yapmayan kodları bulmak yaygın bir durumdur. Genellikle argüman, performans sayaçları veya benzerleri için "yeterince yakın" olmasıdır. Böyle bir durum genellikle yoktur. Güncellemeler yeterince sık olursa (muhtemelen önemsediğiniz bir durum) sayıların büyük bir kısmı genellikle kaybolur. Dört çekirdekli cihazlarda sayıların yarısından fazlası genellikle kaybolabilir. (Kolay alıştırma: Sayacın bir milyon kez güncellendiği ancak son sayaç değerinin 1 olduğu iki iş parçacığı senaryosu oluşturun.)

İşaretlemeyle ilgili basit iletişim

memory_order_release deposu (veya okuma-değiştirme-yazma işlemi), sonrasında bir memory_order_acquire yüklemesi (veya okuma-değiştirme-yazma işlemi) yazılı değeri okursa A memory_order_release deposundan önce gelen tüm mağazaları da (normal veya atomik) gözlemler. Buna karşılık, memory_order_release öğesinden önce gelen yüklemeler, memory_order_acquire yüklemesini izleyen hiçbir mağazayı gözlemlemez. memory_order_relaxed'in aksine bu yöntem, bu tür atom işlemlerinin bir iş parçacığının ilerlemesini diğerine iletmek için kullanılmasına olanak tanır.

Örneğin, tekrar kontrol edilen kilitleme örneğini C++'ta

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

Edinim yükleme ve sürüm deposu, null olmayan bir helper görürsek alanlarının doğru şekilde başlatıldığını da göreceğimizi sağlar. Ayrıca, yarış dışı yüklerin memory_order_relaxed kullanabileceği önceki gözlemi de hesaba kattık.

Bir Java programcısının helper ürününü java.util.concurrent.atomic.AtomicReference<Helper> olarak temsil etmesi ve lazySet()'yi sürüm deposu olarak kullanması kabul edilebilir. Yükleme işlemleri, düz get() çağrıları kullanmaya devam eder.

Her iki durumda da performans ince ayarlarımız, büyük olasılıkla performans açısından kritik öneme sahip olmayan başlatma yoluna odaklandı. Daha okunabilir bir uzlaşma fikri şöyle olabilir:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

Bu işlem de aynı hızlı yolu sağlar ancak performans açısından kritik olmayan yavaş yolda varsayılan, sıralı olarak tutarlı işlemlere olanak tanır.

Burada bile helper.load(memory_order_acquire), muhtemelen mevcut Android destekli mimarilerde helper için düz (sırayla tutarlı) bir referans olarak aynı kodu oluşturur. Buradaki en faydalı optimizasyon, ikinci bir yüklemeyi ortadan kaldırmak için myHelper özelliğinin kullanıma sunulması olabilir ancak gelecekteki bir derleyici bunu otomatik olarak yapabilir.

Satın alma/piyasaya sunma siparişleri, mağazaların görünür gecikme yaşanmasını engellemez ve mağazaların tutarlı bir sıradaki diğer iş parçacıkları tarafından görülebilmesini sağlamaz. Sonuç olarak, Dekker'ın karşılıklı hariç tutma algoritmasında örnek olarak verilen karmaşık, ancak oldukça yaygın olan bir kodlama kalıbını desteklemez: Tüm ileti dizileri, öncelikle bir şey yapmak istediklerini belirten bir işaret ayarlar. t iş parçacığı başka bir iş parçacığının bir şey yapmaya çalışmadığını fark ederse, herhangi bir müdahale olmayacağını bilerek güvenli bir şekilde devam edebilir. t işareti hâlâ ayarlandığından başka hiçbir iş parçacığı devam edemez. Bu işlem, işarete edinme/yayınlama sırası kullanılarak erişilirse başarısız olur. Çünkü bu, bir iş parçacığının bayrağının hatalı bir şekilde devam ettikten sonra diğerlerine geç görünür olmasını engellemez. Varsayılan memory_order_seq_cst bunu engeller.

Sabit alanlar

Bir nesne alanı ilk kullanımda başlatılır ve sonra hiç değişmezse zayıf sıralı erişimler kullanarak ilk kullanıma hazırlama ve ardından okuma işlemi gerçekleştirilebilir. C++'ta atomic olarak tanımlanabilir ve memory_order_relaxed kullanılarak veya Java'da erişilebilir, volatile olmadan tanımlanabilir ve özel önlemler olmadan erişilebilir. Bu, aşağıdaki muhafazanın tümünün kullanılmasını gerektirir:

  • Alanın önceden başlatılıp başlatılmadığı anlaşılabilir. Alana erişmek için hızlı yol test-ve döndür değeri alanı yalnızca bir kez okumalıdır. Java'da ikincisi çok önemlidir. Alan testleri başlatılmış olarak olsa bile ikinci bir yüklemede daha önceki başlatılmamış değer okunabilir. C++'ta "bir kez oku" kuralının kullanılması iyi bir uygulamadır.
  • Hem başlatma hem de sonraki yüklemeler atomik olmalıdır. Bu nedenle kısmi güncellemeler görünür olmamalıdır. Java için alan long veya double olmamalıdır. C++ için atomik atama gereklidir; atomic oluşturulması atomik olmadığından atomik atama yapılması işe yaramaz.
  • Birden fazla iş parçacığı, başlatılmamış değeri aynı anda okuyabileceği için tekrarlanan başlatmaların güvenli olması gerekir. C++'ta bu, genellikle tüm atom türleri için uygulanan "üç bakımından kopyalanabilir" gereksinimini izler. İç içe sahip sahip olduğu işaretçilere sahip türler, kopya oluşturucuda ayırma gerektirir ve bu kolayca kopyalanamaz. Java için belirli referans türleri kabul edilir:
  • Java referansları, yalnızca son alanlar içeren sabit türlerle sınırlıdır. Sabit türün oluşturucusu, nesneye bir başvuru yayınlamamalıdır. Bu durumda Java son alan kuralları, bir okuyucunun referansı görmesi durumunda başlatılan son alanları da görmesini sağlar. C++'nın bu kuralların hiçbir benzeri yoktur ve bu nedenle, sahip olunan nesnelere yapılan işaretçiler de (biraz kopyalanabilir" şartının ihlaline ek olarak) kabul edilemez.

Kapanış notları

Bu belge, sadece yüzeyleri kazımakla kalmaz, yüzeysel bir oymaktan daha fazlasını yönetmez. Bu, çok geniş ve derin bir konu. Daha ayrıntılı inceleme için bazı alanlar:

  • Gerçek Java ve C++ bellek modelleri, iki işlemin belirli bir sırada gerçekleşmesinin garanti edildiği zamanı belirten daha önce gerçekleşir ilişkisiyle ifade edilir. Bir veri yarışını tanımlarken, "eş zamanlı" olarak gerçekleşen iki bellek erişiminden gayri resmî bir şekilde söz ettik. Resmi olarak bu, bir etkinlikten önce gerçekleşmemiş iki etkinlik olarak tanımlanır. Java veya C++ Bellek Modeli'nde happens-before ve synchronizes-with ifadelerinin gerçek tanımlarını öğrenmek yol gösterici olur. Sezgisel "aynı anda" kavramı genellikle yeterince iyi olsa da, bu tanımlar, özellikle de C++'ta zayıf sıralı atomik işlemleri kullanmayı düşünüyorsanız yol göstericidir. (Mevcut Java spesifikasyonu lazySet() öğesini yalnızca çok resmi olmayan bir şekilde tanımlar.)
  • Kodu yeniden sıralarken derleyicilerin neler yaptığını ve neler yapmasına izin verilmediğini keşfedin. (JSR-133 spesifikasyonunda, beklenmedik sonuçlara yol açan yasal dönüşümlere ilişkin bazı mükemmel örnekler bulunmaktadır.)
  • Java ve C++'ta sabit sınıflar yazmayı öğrenin. (Bunun "inşaattan sonra hiçbir şeyi değiştirme"den ibaret değildir.)
  • Effective Java, 2. Sürüm'ün Eşzamanlılık bölümündeki önerileri dikkate alın. (Örneğin, senkronize edilmiş bir blokun içindeyken geçersiz kılınması istenen çağrı yöntemlerinden kaçınmanız gerekir.)
  • Sunulan özellikleri görmek için java.util.concurrent ve java.util.concurrent.atomic API'lerini okuyun. @ThreadSafe ve @GuardedBy gibi eşzamanlılık ek açıklamaları kullanmayı düşünün (net.jcip.annotations).

Ekte yer alan Daha Fazla Okuma bölümünde, bu konuları daha iyi aydınlatacak belgelere ve web sitelerine bağlantılar bulunmaktadır.

Ek

Senkronizasyon depolarını uygulama

(Bu, çoğu programcının kendiliğinden uyguladığı bir şey olmasa da tartışma aydınlatıcı oluyor.)

int gibi küçük yerleşik türler ve Android'in desteklediği donanımlar için normal yükleme ve mağaza talimatları, bir mağazanın aynı konumu yükleyen başka bir işlemciye tamamen görünür olmasını veya hiç görünmemesini sağlar. Böylece, temel "atomiklik" kavramı ücretsiz olarak sağlanmaktadır.

Daha önce gördüğümüz gibi, bu yeterli değil. Sıralı tutarlılık sağlamak için işlemlerin yeniden sıralanmasını önlememiz ve bellek işlemlerinin tutarlı bir sıradaki diğer işlemler tarafından görülebilmesini sağlamamız gerekir. İlkinin uygulanması için mantıklı seçimler yapmamız koşuluyla, ikinci sürümün Android destekli donanımlarda otomatik olduğu ortaya çıkıyor. Bu nedenle, söz konusu özelliği burada büyük ölçüde göz ardı ediyoruz.

Bellek işlemlerinin sırası, hem derleyici tarafından yeniden sıralama yapılmasını, hem de donanımın yeniden sıralamayı önleyerek korunur. Şimdi ikincisine odaklanıyoruz.

ARMv7, x86 ve MIPS'de bellek sıralaması, Çitin önündeki talimatların, duvarın önündeki talimatlardan önce görünür olmasını kabaca önleyen "çitleme" talimatlarıyla uygulanır. (Bunlara genellikle "bariyer" talimatlar da denir ancak bu, pthread_barrier tarzı bariyerlerle karıştırılmasına neden olabilir. Bu engeller, bundan çok daha fazlasını yapar.) Çit talimatlarının tam anlamı oldukça karmaşık bir konudur. Bu konu, çeşitli çit türleri tarafından sağlanan garantilerin nasıl etkileşim kurduğunu ve bunların genellikle donanım tarafından sağlanan diğer sipariş garantileriyle nasıl birleştirildiğini ele alır. Burada genel hatlarıyla değindiğimiz için buradaki ayrıntılara değineceğiz.

En temel sıralama garantisi türü, C++ memory_order_acquire ve memory_order_release atomik işlemleri tarafından sağlanır: Bir sürüm deposundan önceki bellek işlemleri, edinme yükleme sonrasında görünür olmalıdır. ARMv7'de bu, şun tarafından uygulanır:

  • Mağaza talimatlarını uygun bir çitle ilgili talimatla vermek. Bu işlem, önceki tüm bellek erişimlerinin mağaza talimatlarıyla yeniden düzenlenmesini önler. (Ayrıca daha sonraki mağaza talimatlarıyla yeniden sıralamayı da gereksiz yere önler.)
  • Yük talimatının uygun bir çit talimatıyla uygulanması, sonraki erişimlerde yükün yeniden sıralanmasını önler. (Yani, en azından daha erken yüklenen yüklemelerle gereksiz sipariş sırası da sağladık.)

Bunların tümü C++ satış/yayınlama siparişleri için yeterlidir. Bunlar, Java volatile veya C++ sıralı olarak tutarlı atomic için gerekli olsa da yeterli değildir.

Başka neye ihtiyacımız olduğunu görmek için, daha önce kısaca bahsettiğimiz Dekker algoritmasının parçasını ele alalım. flag1 ve flag2, başlangıçta "false" olan C++ atomic veya Java volatile değişkenleridir.

İş Parçacığı 1 İleti dizisi 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

Sıralı tutarlılık, ilk olarak flagn atamalarından birinin yürütülmesi ve diğer iş parçacığında test tarafından görülmesi gerektiği anlamına gelir. Bu nedenle, bu iş parçacıklarını hiçbir zaman "kritik öğeleri" aynı anda yürütmeyiz.

Ancak satın alma-iptal siparişi için gerekli olan çit, her iş parçacığının başına ve sonuna yalnızca çitler ekler. Bu yöntem burada işe yaramaz. Ayrıca bir volatile/atomic mağazanın ardından volatile/atomic yüklemesi gelirse ikisinin yeniden sıralanmadığından emin olmamız gerekir. Bu, normalde tutarlı bir mağazanın öncesinde değil, mağaza sonrasında bir çit ekleyerek uygulanır. (Bu çit, genellikle sonraki tüm bellek erişimlerine göre önceki tüm bellek erişimlerini sipariş ettiği için bu yine gerekenden çok daha güçlüdür.)

Bunun yerine, ekstra sınırı sıralı tutarlı yüklemelerle ilişkilendirebiliriz. Mağazalar daha seyrek görüldüğünden, açıkladığımız kural daha yaygındır ve Android'de kullanılmaktadır.

Önceki bölümlerde gördüğümüz gibi, iki işlem arasına bir depolama/yük engeli eklememiz gerekiyor. Geçici erişim için sanal makinede yürütülen kod şuna benzer:

değişken yükleme değişken mağaza
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Gerçek makine mimarileri genellikle farklı erişim türleri sipariş eden ve farklı maliyetleri olan çok sayıda çit türü sağlar. Bunlar arasındaki seçim zor fark edilir ve mağazaların tutarlı bir sırada diğer çekirdeklere görünür hale gelmesini sağlama ve birden fazla duvarın kombinasyonundan kaynaklanan bellek sırasının doğru şekilde içerik oluşturmasını sağlama ihtiyacından etkilenir. Daha fazla bilgi için lütfen atomiklerin gerçek işlemcilere toplanmış eşlemelerini içeren Cambridge Üniversitesi sayfasını inceleyin.

Donanım her zaman dolaylı yoldan yeterli siparişi zorunlu kıldığı için bazı mimarilerde (özellikle x86'da) "satın alma" ve "piyasaya sürme" bariyerleri gerekli değildir. Böylece x86'da sadece son çit (3) oluşturulur. Benzer şekilde x86'da atomik okuma-değiştirme-yazma işlemleri gizli olarak güçlü bir çit içerir. Bu nedenle bunlar için her zaman çit olması gerekmez. ARMv7'de, yukarıda ele aldığımız tüm Çitler gereklidir.

ARMv8, Java değişken veya C++ sıralı tutarlı yükleme ve depolama gereksinimlerini doğrudan uygulayan LDAR ve STLR talimatları sağlar. Bu kısıtlamalar, yukarıda bahsettiğimiz gereksiz yeniden sıralama kısıtlamalarını önler. ARM'deki 64 bit Android kodu bunları kullanır; gerçek gereksinimleri daha aydınlattığı için burada ARMv7 çit yerleşimine odaklanmayı seçtik.

Daha fazla bilgi

Daha fazla derinlik veya kapsamlı bilgi sağlayan web sayfaları ve dokümanlar. Genel olarak daha faydalı olan makaleler listenin başına daha yakındır.

Paylaşılan Bellek Tutarlılığı Modelleri: Bir Eğitim
Bu, Adve & Gharachorloo tarafından 1995'te yazılan bellek tutarlılığı modellerini derinlemesine incelemek için iyi bir başlangıç noktasıdır.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Bellek Bariyerleri
Sorunları özetleyen küçük bir makale.
https://en.wikipedia.org/wiki/Memory_barrier
İş Parçacığıyla İlgili Temel Bilgiler
C++ ve Java'da çok iş parçacıklı programlamaya giriş, Hans Boehm'dan. Veri yarışları ve temel senkronizasyon yöntemleriyle ilgili tartışmalar.
http://www.hboehm.info/c++mm/threadsintro.html
Pratikte Java Eşzamanlılığı
2006'da yayınlanan bu kitap, çok çeşitli konuları tüm ayrıntılarıyla ele alıyor. Java'da çok iş parçacıklı kod yazan herkes için önemle tavsiye edilir.
http://www.javaconcurrencyinpractice.com
JSR-133 (Java Bellek Modeli) SSS
Senkronizasyon, değişken değişkenler ve nihai alanların oluşturulmasıyla ilgili açıklama içeren Java bellek modeline yavaşça giriş. (Özellikle diğer dillerden bahsedildiğinde biraz eski.)
http://www.cs.umd.edu/~pugh/java/MemoryModel/jsr-133-faq.html
Java Bellek Modelindeki Program Dönüşümlerinin Geçerliliği
Java bellek modelinde kalan sorunların oldukça teknik bir açıklaması. Bu sorunlar, veri yarışı içermeyen programlar için geçerli değildir.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
java.util.concurrent paketine genel bakış
java.util.concurrent paketiyle ilgili dokümanlar. Sayfanın alt kısmına yakın bir yerde, çeşitli sınıfların sağladığı garantileri açıklayan "Bellek Tutarlılığı Özellikleri" başlıklı bir bölüm bulunur.
java.util.concurrent Paket Özeti
Java Teorisi ve Pratiği: Java'da Güvenli İnşaat Teknikleri
Bu makalede, nesne oluşturma sırasında çıkış yapan referansların tehlikeleri ayrıntılı olarak incelenmiş ve iş parçacığı güvenli kurucular için yönergeler sağlanacaktır.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Java Teorisi ve Pratiği: Değişkenliği Yönetme
Java'daki değişken alanlarla neleri yapıp neleri yapamayacağınızı açıklayan güzel bir makale.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
"Double-Checked Locking is Boken" (Çift İşaretli Kilitleme Bozuk) Bildirimi
Bill Pugh'un, ikinci kez kontrol edilen kilitlemenin volatile veya atomic olmadan nasıl kırıldığıyla ilgili ayrıntılı açıklaması. C/C++ ve Java'yı içerir.
http://www.cs.umd.edu/~pugh/java/MemoryModel/DoubleCheckedLocking.html
[ARM] Bariyer Litmus Testleri ve Çözüm Kitabı
ARM SMP sorunlarının ele alındığı ve ARM kodunun kısa snippet'leriyle aydınlatılan bir tartışma. Bu sayfadaki örnekleri çok spesifik değilse veya DMB talimatının resmi açıklamasını okumak istiyorsanız bu bölümü okuyun. Ayrıca, çalıştırılabilir koddaki bellek bariyerleri için kullanılan talimatları da açıklar (hareket halindeyken kod oluşturuyorsanız yararlı olabilir). Bunun, ek bellek sıralama talimatlarını destekleyen ve biraz daha güçlü bir bellek modeline taşınan ARMv8'den eski olduğunu unutmayın. (Ayrıntılı bilgi için "ARMv8-A mimari profili için ARM® Mimari Referansı ARMv8" bölümüne bakın.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Linux Çekirdek Bellek Bariyerleri
Linux çekirdek bellek bariyerleri ile ilgili dokümanlar. Bazı faydalı örnekler ve ASCII sanatı içerir.
http://www.kernel.org/doc/Documentation/Memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (C++ standartları) 14882 (C++ programlama dili), bölüm 1.10 ve madde 29 ("Atomik işlemler kitaplığı")
C++ atomik işlem özellikleri için taslak standart. Bu sürüm, bu alanda C++11'den itibaren küçük değişiklikler içeren C++14 standardına yakındır.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(giriş: http://www.hpl.hp.com/tech08/PL200pdf-PL-20reports)
ISO/IEC JTC1 SC22 WG14 (C standartları) 9899 (C programlama dili) bölüm 7.16 (“Atomics <stdatomic.h>”)
ISO/IEC 9899-201x C atomik çalışma özellikleri için taslak standart. Ayrıntılı bilgi için sonraki kusur raporlarını da inceleyin.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
İşleyenlerle C/C++11 eşlemeleri (Cambridge Üniversitesi)
Jaroslav Sevcik ve Peter Sewell'ın C++ atomik çevirilerini çeşitli yaygın işlemci talimat setlerine çevirdiği koleksiyon.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Dekker algoritması
"Eşzamanlı programlamada karşılıklı hariç tutma sorunu için bilinen ilk doğru çözüm". Vikipedi makalesinde, algoritmanın modern optimizasyon derleyicileri ve SMP donanımıyla çalışması için nasıl güncellenmesi gerektiği açıklanıyor.
https://en.wikipedia.org/wiki/Dekker's_algorithm
ARM ile Alfa ve adres bağımlılıkları hakkındaki yorumlar
Catalin Marinas'dan gelen kol çekirdeği posta listesinde bir e-posta var. Adres ve kontrol bağımlılıklarının güzel bir özetini içeriyor.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Her Programcının Bellek Hakkında Bilmesi Gerekenler
Farklı bellek türleri, özellikle de CPU önbellekleri hakkında, Ulrich Drepper tarafından hazırlanan çok uzun ve ayrıntılı bir makale.
http://www.akkadia.org/drepper/cpuMemory.pdf
ARM zayıf bir şekilde tutarlı bellek modeli hakkında gerekçe
Bu makale, ARM, Ltd'den Chong & Ishtiaq tarafından yazılmıştır. ARM SMP bellek modelini titiz ve erişilebilir bir şekilde tanımlamaya çalışmaktadır. Burada kullanılan "gözlemlenebilirlik" tanımı bu makalede kullanılmıştır. Yine ARMv8'den eskidir.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
Derleyici Yazarları için JSR-133 Çözüm Kitabı
Doug Lea, bunu JSR-133 (Java Bellek Modeli) belgelerine tamamlayıcı bir program olarak yazdı. Bu dosya, birçok derleyici yazarı tarafından kullanılan Java bellek modeli için ilk uygulama yönergelerini içerir, hâlâ alıntılanmıştır ve bilgi sağlama olasılığı yüksektir. Ne yazık ki burada bahsedilen dört çit çeşidi, Android destekli mimariler için iyi bir eşleşme değil. Yukarıdaki C++11 eşlemeleri de artık Java için bile kesin tarifler için daha iyi bir kaynak.
http://g.oswego.edu/dl/jmm/Recipebook.html
x86-TSO: x86 Çoklu İşlemciler İçin Titiz ve Kullanılabilir Bir Programcı Modeli
x86 bellek modelinin net bir açıklaması ARM bellek modelinin ayrıntılı açıklamaları maalesef çok daha karmaşıktır.
http://www.cl.cam.ac.uk/~pes20/weak getirmeniz/cacm.pdf