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 ele alınmaktadır. Bu kılavuz, Android uygulama geliştiricileri için bir giriş niteliğindedir ve konuyla ilgili eksiksiz bir tartışma değildir.

Giriş

SMP, "Simetrik Çoklu İşlemci"nin kısaltmasıdır. İki veya daha fazla aynı CPU çekirdeğinin ana belleğe erişimi paylaştığı bir tasarımı tanımlar. Birkaç yıl öncesine kadar tüm Android cihazlar UP (tek işlemcili) idi.

Tüm Android cihazlarda olmasa da çoğunda her zaman birden fazla CPU vardı ancak geçmişte bunlardan yalnızca biri uygulamaları çalıştırmak için kullanılırken diğerleri cihaz donanımının çeşitli parçalarını (ör. radyo) yönetiyordu. CPU'ların mimarileri farklı olabiliyordu ve bu CPU'larda ç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ı temel alınarak üretilir. Bu da yazılım geliştiriciler için işleri biraz daha karmaşık hale getirir. Çok iş parçacıklı bir programdaki yarışma durumları, tek işlemcili bir sistemde görünür sorunlara neden olmayabilir ancak iki veya daha fazla iş parçacığınız farklı çekirdeklerde aynı anda çalışırken düzenli olarak başarısız olabilir. Ayrıca kod, farklı işlemci mimarilerinde, hatta aynı mimarinin farklı uygulamalarında çalıştırıldığında hatalara daha fazla veya daha az eğilimli olabilir. x86'da kapsamlı bir şekilde test edilmiş kod, ARM'de ciddi şekilde bozulabilir. Kod, daha modern bir derleyiciyle yeniden derlendiğinde başarısız olmaya başlayabilir.

Bu belgenin geri kalanında bunun nedeni açıklanacak ve kodunuzun doğru şekilde davranmasını sağlamak için yapmanız gerekenler anlatılacaktır.

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

Bu, karmaşık bir konuya hızlı ve yüzeysel bir genel bakıştır. Bazı alanlar eksik olabilir ancak 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.

Konunun daha ayrıntılı ele alındığı kaynaklar için belgenin sonundaki Ek kaynaklar bölümüne bakın.

Bellek tutarlılığı modelleri veya genellikle yalnızca "bellek modelleri", programlama dilinin veya donanım mimarisinin bellek erişimleri hakkında verdiği garantileri açıklar. Örneğin, A adresine bir değer yazıp ardından B adresine bir değer yazarsanız model, her CPU çekirdeğinin bu yazma işlemlerinin bu sırayla gerçekleştiğini görmesini garanti edebilir.

Çoğu programcının alışık olduğu model, sıralı tutarlılıktır. Bu model şu şekilde açıklanır (Adve ve Gharachorloo):

  • Tüm bellek işlemleri tek tek yürütülüyor gibi görünür.
  • Tek bir iş parçacığındaki tüm işlemler, söz konusu işlemcinin programında açıklanan sırayla yürütülüyor gibi görünür.

Geçici olarak, beklenmedik durumlar yaratmayan çok basit bir derleyicimiz veya yorumlayıcımız olduğunu varsayalım: Kaynak kodundaki atamaları, tam olarak karşılık gelen sırada, erişim başına bir talimat olacak şekilde yükleme ve depolama talimatlarına çevirir. Ayrıca, basitlik açısından her iş parçacığının kendi işlemcisinde çalıştığını varsayacağız.

Bir kod parçasına baktığınızda bellekten okuma ve yazma işlemleri yaptığını görürseniz sıralı tutarlı bir CPU mimarisinde kodun bu okuma ve yazma işlemlerini beklenen sırada yapacağını bilirsiniz. CPU'nun talimatları yeniden sıralaması ve okuma ve yazma işlemlerini geciktirmesi mümkündür ancak cihazda çalışan kodun, CPU'nun talimatları basit bir şekilde yürütmekten başka bir şey yaptığını anlaması mümkün değildir. (Bellek eşlemeli cihaz sürücüsü G/Ç'sini yoksayacağız.)

Bu noktaları göstermek için genellikle turnusol testleri olarak adlandırılan küçük kod snippet'lerini incelemek faydalı olacaktır.

İki iş parçacığında çalışan kodla ilgili basit bir örnek:

1. ileti dizisi Thread 2
A = 3
B = 5
reg0 = B
reg1 = A

Bu ve gelecekteki tüm litmus örneklerinde bellek konumları büyük harflerle (A, B, C) gösterilir ve CPU kayıtları "reg" ile başlar. Başlangıçta tüm bellek sıfırdır. Talimatlar yukarıdan aşağıya doğru yürütülür. Burada, 1. iş parçacığı A konumunda 3 değerini, ardından B konumunda 5 değerini depolar. 2. iş parçacığı, B konumundaki değeri reg0'a, ardından A konumundaki değeri reg1'e yükler. (Bir sırayla yazıp başka bir sırayla okuduğumuza dikkat edin.)

1. ve 2. iş parçacıklarının farklı CPU çekirdeklerinde yürütüldüğü varsayılır. Çok iş parçacıklı kodlar üzerinde çalışırken her zaman bu varsayımı yapmanız gerekir.

Sıralı tutarlılık, her iki iş parçacığı da yürütmeyi tamamladıktan sonra kayıtların aşağıdaki durumlardan birinde olmasını garanti eder:

Kaydol Eyaletler
reg0=5, reg1=3 mümkün (önce 1. iş parçacığı çalıştırıldı)
reg0=0, reg1=0 mümkün (önce 2. iş parçacığı çalıştırıldı)
reg0=0, reg1=3 mümkün (eşzamanlı yürütme)
reg0=5, reg1=0 hiçbir zaman

A'ya yazma işlemini görmeden önce B=5'i gördüğümüz bir durumun ortaya çıkması için okuma veya yazma işlemlerinin sırasız gerçekleşmesi gerekir. Sıralı tutarlı bir makinede bu gerçekleşemez.

x86 ve ARM dahil olmak üzere tek işlemciler normalde sıralı olarak tutarlıdır. İşletim sistemi çekirdeği iş parçacıkları arasında geçiş yaptığından, iş parçacıkları dönüşümlü olarak yürütülüyor gibi görünür. x86 ve ARM dahil olmak üzere çoğu SMP sistemi sıralı olarak tutarlı değildir. Örneğin, donanımın belleğe giden depolama alanlarını arabelleğe alması yaygındır. Böylece belleğe hemen ulaşmazlar ve diğer çekirdekler tarafından görülebilir hale gelirler.

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'ın imkansız kalacağını garanti eder. Mağazalar arabelleğe alınır ancak sıraları korunur. ARM ise bunu yapmaz. Arabelleğe alınan depolama işlemlerinin sırası korunmaz ve depolama işlemleri diğer tüm çekirdeklere aynı anda ulaşmayabilir. Bu farklılıklar, assembly 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 programlama yapabilir ve yapmalıdır.

Şimdiye kadar, talimatları yeniden sıralayanın yalnızca donanım olduğunu varsaydık. Bu gerçekçi bir varsayım değil. Aslında derleyici, performansı artırmak için talimatları da yeniden sıralar. Örneğimizde derleyici, Thread 2'deki daha sonraki bir kodun reg0'dan önce reg1 değerine ihtiyaç duyduğuna karar verebilir ve bu nedenle önce reg1'i yükleyebilir. Ya da ö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 yüklemeler yeniden sıralanabilir.

Donanımda veya derleyicide farklı bellek konumlarına erişimlerin yeniden sıralanmasına izin verilir. Bu, tek bir iş parçacığının yürütülmesini etkilemez ve performansı önemli ölçüde artırabilir. Göreceğimiz gibi, biraz dikkatle, çok iş parçacıklı programların sonuçlarını etkilemesini de önleyebiliriz.

Derleyiciler de bellek erişimlerini yeniden sıralayabildiğinden bu sorun aslında SMP'ler için yeni değildir. Tek işlemcili bir sistemde bile derleyici, örneğimizdeki reg0 ve reg1'e yüklemeleri yeniden sıralayabilir ve Thread 1, yeniden sıralanan talimatlar arasında planlanabilir. Ancak derleyicimiz yeniden sıralama yapmazsa bu sorunu hiç görmeyebiliriz. Çoğu ARM SMP'de, derleyici yeniden sıralaması olmasa bile yeniden sıralama muhtemelen çok sayıda başarılı yürütmeden sonra görülecektir. Assembly dilinde programlama yapmıyorsanız SMP'ler genellikle başından beri var olan sorunları görme olasılığınızı artırır.

Veri yarışından bağımsız programlama

Neyse ki bu ayrıntıların hiçbirini düşünmenize gerek kalmadan kolayca yapabileceğiniz bir şey var. Birkaç basit kurala uyarsanız "sıralı tutarlılık" bölümü hariç önceki bölümün tamamını unutmanız genellikle güvenlidir. Maalesef bu kuralları yanlışlıkla ihlal ederseniz diğer komplikasyonlar görünür hale gelebilir.

Modern programlama dilleri, "veri yarışından uzak" programlama stilini teşvik eder. "Veri yarışları" oluşturmayacağınıza söz verdiğiniz ve derleyiciye aksini söyleyen birkaç yapıdan kaçındığınız sürece derleyici ve donanım, sıralı olarak tutarlı sonuçlar sağlama sözü verir. Bu, bellek erişiminin yeniden sıralanmasını önledikleri anlamına gelmez. Ancak kurallara uyarsanız bellek erişimlerinin yeniden sıralandığını fark edemezsiniz. Bu, sosis fabrikasını ziyaret etmeyeceğinize söz verdiğiniz sürece sosisin lezzetli ve iştah açıcı bir yiyecek olduğunu söylemeye benzer. Veri yarışları, bellek yeniden sıralamasıyla ilgili çirkin gerçeği ortaya çıkarır.

"Veri yarışı" nedir?

Veri çakışması, en az iki iş parçacığı aynı anda aynı sıradan verilere eriştiğinde ve bunlardan en az biri verileri değiştirdiğinde meydana gelir. "Sıradan veriler" derken, iş parçacığı iletişimi için tasarlanmış bir senkronizasyon nesnesi olmayan verileri kastediyoruz. Karşılıklı dışlamalar, koşul değişkenleri, Java değişkenleri veya C++ atomik nesneleri sıradan veriler değildir ve erişimlerinin yarışmasına izin verilir. Aslında diğer nesnelerdeki veri yarışlarını önlemek için kullanılırlar.

İki iş parçacığının aynı bellek konumuna aynı anda 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 çakışması olmaz:

1. ileti dizisi Thread 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 değişkenlerin hiçbiri güncellenmez. Bu nedenle veri çakışması olamaz. 1. iş parçacığında A kaynağından yükleme ve B hedefine depolama işlemlerinin sırası bir şekilde değişirse ne olabileceğini düşünmenize gerek yoktur. Derleyicinin, 1. iş parçacığını "B = true; if (!A) B = false" olarak yeniden yazarak yeniden sıralamasına izin verilmez. Bu, herkesin gözü önünde, şehrin ortasında sosis yapmaya benzer.

Veri yarışları, resmi olarak tam sayılar ve referanslar veya işaretçiler gibi temel yerleşik türlerde tanımlanır. Bir int değerine atama yaparken aynı anda başka bir iş parçacığında bu değeri okumak açıkça bir veri yarışıdır. Ancak hem C++ standart kitaplığı hem de Java Collections kitaplıkları, kitaplık düzeyinde veri yarışları hakkında da akıl yürütmenize olanak tanıyacak şekilde yazılmıştır. Aynı kapsayıcıya eşzamanlı erişimler olmadığı sürece veri yarışları oluşturmayacaklarını taahhüt ederler. Bu erişimlerden en az biri kapsayıcıyı günceller. Bir iş parçacığında set<T> güncellenirken aynı anda başka bir iş parçacığında okunması, kitaplığın veri yarışına girmesine neden olur ve bu nedenle gayriresmi olarak "kitaplık düzeyinde veri yarışı" olarak düşünülebilir. Buna karşılık, bir iş parçacığında bir set<T> güncellenirken başka bir iş parçacığında farklı bir okunması veri yarışına neden olmaz. Çünkü kitaplık, bu durumda (düşük seviyeli) bir veri yarışı oluşturmayacağını taahhüt eder.

Normalde bir veri yapısındaki farklı alanlara eşzamanlı erişimler, veri yarışına neden olamaz. Ancak bu kuralın önemli bir istisnası vardır: C veya C++'taki bit alanlarının bitişik dizileri tek bir "bellek konumu" olarak değerlendirilir. Böyle bir dizideki herhangi bir bit alanına erişmek, veri yarışının varlığını belirlemek amacıyla hepsine erişmek olarak kabul edilir. Bu durum, yaygın donanımların bitişik bitleri okuyup yeniden yazmadan tek tek bitleri güncelleyememesinden kaynaklanır. Java programcılarının benzer endişeleri yoktur.

Veri yarışlarını önleme

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

Kilitler veya Mutex'ler
Mutex'ler (C++11 std::mutex veya pthread_mutex_t) ya da Java'daki synchronized blokları, kodun belirli bir 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. Bu ve benzeri tesisleri genel olarak "kilitleme" olarak adlandıracağız. Paylaşılan bir veri yapısına erişmeden önce belirli bir kilidi tutarlı bir şekilde edinmek ve sonrasında serbest bırakmak, veri yapısına erişirken veri yarışlarını önler. Ayrıca, güncellemelerin ve erişimlerin atomik olmasını sağlar. Yani veri yapısında başka bir güncelleme çalıştırılamaz. Bu, veri yarışlarını önlemek için açık ara en yaygın kullanılan araçtır. Java synchronized bloklarının veya C++ lock_guard ya da unique_lock kullanımının, bir istisna durumunda kilitlerin düzgün şekilde serbest bırakılmasını sağladığından emin olun.
Geçici/atomik değişkenler
Java, veri yarışlarına neden olmadan eşzamanlı erişimi destekleyen volatile alanlar sağlar. 2011'den beri C ve C++, benzer semantiklere sahip atomicdeğişkenleri ve alanları desteklemektedir. Bunların kullanımı genellikle kilitlere göre daha zordur. Çünkü yalnızca tek bir değişkene yapılan bireysel erişimlerin atomik olmasını sağlarlar. (C++'ta bu genellikle artırma gibi basit okuma-değiştirme-yazma işlemlerini de kapsar. Java'da bunun için özel yöntem çağrıları gerekir.) Kilitlerin aksine, 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 ifadesinin C++ ve Java'da çok farklı anlamları olduğunu belirtmek önemlidir. C++'ta volatile, veri yarışlarını engellemez ancak eski kodlar genellikle atomic nesnelerinin eksikliğini gidermek için bu anahtar kelimeyi kullanır. Bu artık önerilmemektedir. C++'ta, birden fazla iş parçacığı tarafından eşzamanlı olarak erişilebilen değişkenler için atomic<T> kullanın. C++ volatile, cihaz kayıtları ve benzeri için tasarlanmıştır.

C/C++ atomic değişkenleri veya Java volatile değişkenleri, diğer değişkenlerde veri yarışlarını önlemek için kullanılabilir. flag, atomic<bool> veya atomic_bool (C/C++) ya da volatile boolean (Java) türünde tanımlanmışsa ve başlangıçta yanlışsa aşağıdaki snippet'te veri çakışması yoktur:

1. ileti dizisi Thread 2
A = ...
  flag = true
while (!flag) {}
... = A

Thread 2, flag değerinin ayarlanmasını beklediğinden Thread 2'deki A erişimi, Thread 1'deki A atamasından sonra gerçekleşmelidir, aynı anda gerçekleşmemelidir. Bu nedenle A üzerinde veri yarışı olmaz. flag üzerindeki yarış, değişken/atomik erişimler "sıradan bellek erişimleri" olmadığından veri yarışı olarak sayılmaz.

Uygulamanın, yukarıdaki turnusol testi gibi kodların beklendiği gibi davranmasını sağlayacak şekilde bellek yeniden sıralamasını yeterince önlemesi veya gizlemesi gerekir. Bu durum, değişken/atomik bellek erişimlerini normal erişimlere kıyasla önemli ölçüde daha pahalı hale getirir.

Önceki örnekte veri yarışması olmasa da Java'da Object.wait() ile birlikte kilitler veya C/C++'ta koşul değişkenleri genellikle pil gücünü tüketirken bir döngüde beklemeyi gerektirmeyen daha iyi bir çözüm sunar.

Anıları yeniden sıralama özelliği kullanıma sunulduğunda

Veri çakışması içermeyen programlama, normalde bellek erişimini yeniden sıralama sorunlarıyla açıkça ilgilenmemizi gerektirmez. Ancak yeniden sıralamanın görünür hale geldiği birkaç durum vardır:
  1. Programınızda istenmeyen bir veri yarışına neden olan bir hata varsa derleyici ve donanım dönüşümleri görünür hale gelebilir ve programınızın davranışı şaşırtıcı olabilir. Örneğin, önceki örnekte flag değişkenini volatile olarak tanımlamayı unuttuysak Thread 2, başlatılmamış bir A görebilir. Ya da derleyici, Thread 2'nin döngüsü sırasında flag'in değişemeyeceğine karar verip programı şu şekilde dönüştürebilir:
    1. ileti dizisi Thread 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    Hata ayıklama yaparken flag doğru olmasına rağmen döngünün sonsuza kadar devam ettiğini görebilirsiniz.
  2. C++, yarış olmasa bile sıralı tutarlılığı açıkça gevşetmek için olanaklar sağlar. Atomik işlemler, açık memory_order_... bağımsız değişkenleri alabilir. Benzer şekilde, java.util.concurrent.atomic paketi de benzer olanaklardan oluşan daha kısıtlı bir grup (özellikle lazySet()) sunar. Java programcıları da benzer bir etki için bazen kasıtlı veri yarışları kullanır. Tüm bunlar, programlama karmaşıklığı açısından büyük bir maliyetle performans iyileştirmeleri sağlar. Bu konulara aşağıda kısaca değineceğiz.
  3. Bazı C ve C++ kodları, mevcut dil standartlarıyla tamamen tutarlı olmayan eski bir tarzda yazılmıştır. Bu tarzda, atomic değişkenleri yerine volatile değişkenleri kullanılır ve fence veya barrier adı verilen öğeler eklenerek bellek sıralaması açıkça engellenir. Bu, erişim yeniden sıralaması hakkında açık bir akıl yürütme ve donanım bellek modellerinin anlaşılmasını gerektirir. Bu tarz bir kodlama stili Linux çekirdeğinde hâlâ kullanılıyor. Yeni Android uygulamalarında kullanılmamalıdır ve burada daha fazla ele alınmayacaktı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 hata ayıklayıcıyla bellek dökümlerini inceleyerek bunun nedenini bulamayabilirsiniz. Hata ayıklayıcı sorgusu gönderebileceğiniz zamana kadar CPU çekirdeklerinin tümü, erişimlerin tamamını gözlemlemiş olabilir ve bellek ile CPU kayıtlarının içeriği "imkansız" bir durumda görünebilir.

C'de yapılmaması gerekenler

Burada, hatalı kod örnekleri ve bunları düzeltmenin basit yolları verilmiştir. Bunu yapmadan önce, temel bir dil özelliğinin kullanımını ele almamız gerekir.

C/C++ ve "volatile"

C ve C++ volatile bildirimleri çok özel amaçlı bir araçtır. Derleyicinin, volatile erişimlerini yeniden sıralamasını veya kaldırmasını engellerler. Bu, donanım cihazı kayıtlarına erişen kod, birden fazla konuma eşlenen bellek veya setjmp ile bağlantılı olarak yararlı olabilir. Ancak C ve C++ volatile, Java'nın aksine volatile, iş parçacığı iletişimi için tasarlanmamıştır.

C ve C++ dillerinde, volatile verilere erişimler, geçici olmayan verilere erişimlerle yeniden sıralanabilir ve atomiklik garantisi yoktur. Bu nedenle volatile, tek işlemcili sistemlerde bile taşınabilir kodda iş parçacıkları arasında paylaşılan veriler için kullanılamaz. C volatile genellikle donanımın erişim sıralamasını değiştirmesini engellemez. Bu nedenle, çok iş parçacıklı SMP ortamlarında tek başına daha da az kullanışlıdır. C11 ve C++11'in atomic nesnelerini desteklemesinin nedeni budur. Bunun yerine bu seçenekleri kullanmanız gerekir.

Eski C ve C++ kodlarının çoğu, iş parçacığı iletişimi için volatile anahtar kelimesini hâlâ yanlış kullanıyor. Bu genellikle, açık sınırlayıcılarla veya bellek sıralamasının önemli olmadığı durumlarda kullanılması koşuluyla, makine kaydına sığan veriler için doğru şekilde çalışır. Ancak gelecekteki derleyicilerle doğru çalışacağı garanti edilmez.

Örnekler

Çoğu durumda atomik işlem yerine kilit (ör. pthread_mutex_t veya C++11 std::mutex) kullanmak daha iyi olur. Ancak pratik bir durumda nasıl kullanılacağını göstermek için atomik işlemi 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ı tahsis etmemiz, alanlarını başlatmamız ve en sonunda bunu global bir değişkende depolayarak "yayınlamamızdır". Bu noktada, diğer tüm iş parçacıkları bunu görebilir ancak tamamen başlatıldığı için bu sorun olmaz, değil mi?

Sorun, alanlar başlatılmadan önce gGlobalThing için depolamanın gözlemlenebilmesidir. Bu durum genellikle derleyicinin veya işlemcinin gGlobalThing ve thing->x için depolamaları yeniden sıralamasından kaynaklanır. thing->x öğesinden okuma yapan başka bir iş parçacığı 5, 0 veya başlatılmamış verileri görebilir.

Buradaki asıl sorun, gGlobalThing üzerinde bir veri yarışının olmasıdır. İş Parçacığı 1 initGlobalThing() yöntemini, İş Parçacığı 2 ise useGlobalThing() yöntemini çağırırsa gGlobalThing yazılırken okunabilir.

Bu sorun, gGlobalThing değişkenini atomik olarak tanımlayarak düzeltilebilir. C++11'de:

atomic<MyThing*> gGlobalThing(NULL);

Bu sayede, yazma işlemleri diğer iş parçacıklarına doğru sırada görünür. Ayrıca, normalde izin verilen ancak gerçek Android donanımlarında meydana gelme olasılığı düşük olan diğer bazı hata modlarını önlemeyi de garanti eder. Örneğin, yalnızca kısmen yazılmış bir işaretçiyi görmemizi engeller.gGlobalThing

Java'da yapılmaması gerekenler

İlgili bazı Java dili özelliklerini ele almadığımız için önce bunlara kısaca göz atacağız.

Java, teknik olarak kodun veri yarışından bağımsız olmasını gerektirmez. Veri yarışları olduğunda doğru şekilde çalışan, çok dikkatli bir şekilde yazılmış az miktarda Java kodu vardır. Ancak bu tür bir kod yazmak son derece zordur ve aşağıda yalnızca kısaca ele alınmıştır. Daha da kötüsü, bu tür kodların anlamını belirten uzmanlar artık bu spesifikasyonun doğru olduğuna inanmıyor. (Spesifikasyon, veri yarışından bağımsız kod için uygundur.)

Şimdilik, Java'nın temelde C ve C++ ile aynı garantileri sağladığı veri yarışından bağımsız modeli kullanacağız. Bu dil de sıralı tutarlılığı açıkça esneten bazı temel öğeler sağlar. Özellikle java.util.concurrent.atomic içindeki lazySet() ve weakCompareAndSet() çağrıları buna örnektir. C ve C++'ta olduğu gibi, bunları şimdilik yoksayacağız.

Java'nın "synchronized" ve "volatile" anahtar kelimeleri

"synchronized" anahtar kelimesi, Java dilinin yerleşik kilitleme mekanizmasını sağlar. Her nesnenin, karşılıklı olarak birbirini dışlayan erişim sağlamak için kullanılabilecek ilişkili bir "monitörü" vardır. İki iş parçacığı aynı nesne üzerinde "senkronize" olmaya çalışırsa biri diğerinin tamamlanmasını bekler.

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

Özellikle, 1. iş parçacığı bir volatile alanına yazarsa ve 2. iş parçacığı daha sonra aynı alandan okuma yapıp yeni yazılan değeri görürse 2. iş parçacığının, daha önce 1. iş parçacığı tarafından yapılan tüm yazma işlemlerini de göreceği garanti edilir. Bellek etkisi açısından, geçici bir değişkene yazmak, monitör serbest bırakmaya benzer ve geçici bir değişkenden okumak, monitör edinmeye benzer.

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

Örnekler

Monoton bir sayacın basit ve yanlış bir uygulaması: (Java teorisi ve pratiği: Değişkenliği yönetme).

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

get() ve incr() işlevlerinin birden fazla iş parçacığından çağrıldığını ve get() çağrıldığında her iş parçacığının geçerli sayıyı gördüğünden emin olmak istediğimizi varsayalım. En belirgin sorun, mValue++ ifadesinin aslında üç işlemden oluşmasıdır:

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

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

Ancak özellikle SMP'de hâlâ sorunlu. get(), incr() ile eşzamanlı olarak mValue öğesine erişebileceğinden veri yarışması hâlâ devam ediyor. Java kuralları uyarınca get() çağrısı, diğer kodlara göre yeniden sıralanmış gibi görünebilir. Örneğin, arka arkaya iki sayacı okursak sonuçlar tutarsız görünebilir. Bunun nedeni, get() çağrılarının donanım veya derleyici tarafından yeniden sıralanmasıdır. get() öğesini senkronize edilmiş olarak tanımlayarak sorunu düzeltebiliriz. Bu değişiklikle birlikte kodun doğru olduğu açıkça görülüyor.

Maalesef bu durum, performansı olumsuz etkileyebilecek kilit çekişmesi olasılığını ortaya çıkarır. get() öğesini senkronize edilmiş olarak tanımlamak yerine mValue öğesini "volatile" olarak tanımlayabiliriz. (incr(), synchronize kullanmaya devam etmelidir. Aksi takdirde mValue++ tek bir atomik işlem olmaz.) Bu sayede tüm veri yarışları da önlenir ve sıralı tutarlılık korunur. incr(), hem monitör giriş/çıkış ek yüküne hem de geçici depolamayla ilişkili ek yüke neden olduğu için biraz daha yavaş olacaktır ancak get() daha hızlı olacaktır. Bu nedenle, çekişme olmasa bile okuma sayısı yazma sayısından çok daha fazlaysa bu bir kazançtır. (Senkronize edilen bloğu tamamen kaldırmanın bir yolu için AtomicInteger bölümüne de bakın.)

Önceki C örneklerine benzer başka bir örnek:

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 kodda da C kodundakiyle aynı sorun vardır. Yani sGoodies üzerinde bir veri yarışı gerçekleşir. Bu nedenle, sGoodies = goods ataması, goods içindeki alanların başlatılmasından önce gözlemlenebilir. sGoodies değişkenini volatile anahtar kelimesiyle tanımlarsanız sıralı tutarlılık geri yüklenir ve her şey beklendiği gibi çalışır.

Yalnızca sGoodies referansının kendisinin değişken olduğunu unutmayın. İçindeki alanlara erişimler ise izlenmez. sGoodies, volatile olduğunda ve bellek sıralaması doğru şekilde korunduğunda alanlara aynı anda erişilemez. z = sGoodies.x ifadesi, MyClass.sGoodies için geçici bir yükleme ve ardından sGoodies.x için kalıcı bir yükleme gerçekleştirir. Yerel bir referans MyGoodies localGoods = sGoodies yaparsanız sonraki z = localGoods.x, geçici yükleme yapmaz.

Java programlamada daha yaygın bir deyim, meşhur "çift kontrollü kilitleme"dir:

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

Buradaki amaç, bir MyClass örneğiyle ilişkili tek bir Helper nesnesi örneği oluşturmaktır. Yalnızca bir kez oluşturmamız gerektiğinden, özel bir getHelper() işlevi aracılığıyla oluşturup döndürürüz. İki iş parçacığının örneği oluşturduğu bir yarıştan kaçınmak için nesne oluşturmayı senkronize etmemiz gerekir. Ancak her çağrıda "synchronized" bloğunun ek yükünü ödemek istemediğimiz için bu kısmı yalnızca helper şu anda null ise yaparız.

Bu, helper alanında bir veri yarışına neden olur. Başka bir mesaj dizisindeki helper == null ile aynı anda ayarlanabilir.

Bunun nasıl başarısız olabileceğini görmek için aynı kodun C benzeri bir dilde derlenmiş gibi biraz yeniden yazılmış halini düşünün (Helper’s yapıcı 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, helper alanına yapılan depolama işlemini x/y alanlarına yapılan depolama işlemleriyle yeniden sıralamasını engelleyecek bir şey yoktur. Başka bir ileti dizisi helper değerini boş olmayan bir değer olarak bulabilir ancak alanları henüz ayarlanmamış ve kullanıma hazır olmayabilir. Daha fazla bilgi ve daha fazla hata modu için ekteki "'Double Checked Locking is Broken' Declaration" bağlantısını veya Josh Bloch'un Effective Java, 2nd Edition kitabındaki 71. maddeyi ("Use lazy initialization judiciously") inceleyin.

Bu sorunu çözmenin iki yolu vardır:

  1. Basit olanı yapıp dıştaki kontrolü silin. Bu sayede, helper değerini hiçbir zaman senkronize edilmiş bir blok dışında incelemeyiz.
  2. helper değişkenini geçici olarak tanımlayın. Bu küçük değişiklikle, Örnek J-3'teki kod Java 1.5 ve sonraki sürümlerde doğru şekilde çalışır. (Bunun doğru olduğuna ikna olmak için bir dakikanızı ayırabilirsiniz.)

volatile davranışına dair başka bir örnek:

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() örneğine bakarsak 2. iş parçacığı vol1 güncellemesini henüz gözlemlemediyse data1 veya data2 değerinin ayarlanıp ayarlanmadığını bilemez. vol1 güncellemesini gördüğünde, data1 değerine güvenli bir şekilde erişilebileceğini ve veri yarışına neden olmadan doğru şekilde okunabileceğini bilir. Ancak data2 hakkında herhangi bir varsayımda bulunamaz çünkü bu depolama işlemi, geçici depolama işleminden sonra gerçekleştirilmiştir.

volatile, birbiriyle yarışan diğer bellek erişimlerinin yeniden sıralanmasını önlemek için kullanılamaz. Makine bellek engeli talimatı oluşturacağı 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.

Yapılması gerekenler

C/C++'ta, std::mutex gibi C++11 senkronizasyon sınıflarını tercih edin. Aksi takdirde ilgili pthread işlemlerini kullanın. Bunlar arasında, tüm Android platformu sürümlerinde doğru (aksi belirtilmedikçe sıralı olarak tutarlı) ve verimli davranış sağlayan uygun bellek sınırları yer alır. Bunları doğru kullandığınızdan emin olun. Örneğin, koşul değişkeni beklemelerinin sinyal verilmeden yanlışlıkla geri dönebileceğini ve bu nedenle bir döngüde görünmesi gerektiğini unutmayın.

Uyguladığınız veri yapısı, sayaç gibi son derece basit bir yapı değilse atomik işlevleri doğrudan kullanmaktan kaçınmanız önerilir. pthread mutex'i kilitlemek ve kilidini açmak için tek bir atomik işlem gerekir ve çekişme yoksa genellikle tek bir önbellekte yok durumundan daha az maliyetlidir. Bu nedenle, mutex çağrılarını atomik işlemlerle değiştirerek çok fazla tasarruf edemezsiniz. Önemsiz veri yapıları için kilitsiz tasarımlar, veri yapısı üzerindeki daha yüksek düzeyli işlemlerin atomik (yalnızca açıkça atomik parçaları değil, bir bütün olarak) görünmesini sağlamak için çok daha fazla dikkat gerektirir.

Atomik işlemler kullanıyorsanız memory_order... veya lazySet() ile sıralamayı gevşetmek performans avantajları sağlayabilir ancak bu, şimdiye kadar aktardığımızdan daha derin bir anlayış gerektirir. Bunları kullanan mevcut kodların büyük bir kısmında sonradan hatalar olduğu tespit edilmiştir. Mümkünse bunlardan kaçının. Kullanım alanınız bir sonraki bölümde yer alanlardan birine tam olarak uymuyorsa uzman olduğunuzdan veya bir uzmana danıştığınızdan emin olun.

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

Java'da eşzamanlılık sorunları genellikle java.util.concurrent paketindeki uygun bir yardımcı sınıf kullanılarak çözülür. Kod iyi yazılmış ve SMP'de iyi test edilmiş.

Yapabileceğiniz en güvenli şey, nesnelerinizi değişmez hale getirmek olabilir. Java'nın String ve Integer gibi sınıflarından nesneler, nesne oluşturulduktan sonra değiştirilemeyen veriler barındırır ve bu nesnelerde veri yarışları olasılığını ortadan kaldırır. Effective Java, 2nd Ed. kitabında "Item 15: Minimize Mutability" bölümünde özel talimatlar yer alır. Özellikle Java alanlarını "final" olarak tanımlamanın önemine dikkat edin (Bloch).

Bir nesne değişmez olsa bile, herhangi bir senkronizasyon olmadan başka bir iş parçacığına iletilmesinin veri yarışına neden olacağını unutmayın. Bu, Java'da bazen kabul edilebilir (aşağıya bakın) ancak çok dikkatli olunması gerekir ve muhtemelen kırılgan kodlara neden olur. Performans açısından son derece kritik değilse volatile beyanı ekleyin. C++'ta, uygun senkronizasyon olmadan sabit bir nesneye işaretçi veya referans iletmek, tüm veri yarışları gibi bir hatadır. Bu durumda, örneğin, alıcı iş parçacığı, depolama yeniden sıralaması nedeniyle başlatılmamış bir yöntem tablosu işaretçisi görebileceğinden, aralıklı kilitlenmelere neden olması makul bir olasılıktı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 tüm alanlara erişimi korumak için Java synchronized ifadesi veya C++ lock_guard / unique_lock kullanılmalıdır. Mutex'ler durumunuza uygun değilse paylaşılan alanları volatile veya atomic olarak tanımlamanız gerekir. Ancak iş parçacıkları arasındaki etkileşimleri anlamak için çok dikkatli olmalısınız. Bu bildirimler sizi yaygın eşzamanlı programlama hatalarından kurtarmaz ancak derleyicileri optimize etme ve SMP aksaklıklarıyla ilişkili gizemli hatalardan kaçınmanıza yardımcı olur.

Bir nesneye referans "yayınlamaktan", yani nesneyi oluşturucusunda diğer iş parçacıklarının kullanımına sunmaktan kaçınmalısınız. Bu durum, C++'ta veya Java'da "veri yarışması yok" tavsiyemize uyarsanız 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ırsa ve güvenilmeyen kod, bu "sızdırılan" nesne referansına erişerek veri yarışına neden olabilirse kritik hale gelir. Ayrıca, uyarılarımızı dikkate almayıp sonraki bölümde yer alan tekniklerden bazılarını kullanmayı tercih ederseniz de bu önemlidir. Ayrıntılar için (Java'da Güvenli Yapı Teknikleri) bölümüne bakın.

Zayıf bellek sıralamaları hakkında daha fazla bilgi

C++11 ve sonraki sürümler, veri çakışması içermeyen programlar için sıralı tutarlılık garantilerini esnetmeye yönelik açık mekanizmalar sağlar. Atomik işlemler için açık memory_order_relaxed, memory_order_acquire (yalnızca yüklemeler) ve memory_order_release (yalnızca depolamalar) bağımsız değişkenlerinin her biri, varsayılan (genellikle örtük) memory_order_seq_cst bağımsız değişkenine kıyasla 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 garantisi sağlar. memory_order_consume henüz yeterince iyi tanımlanmamış veya uygulanmamış olduğundan şu an için yoksayılmalıdır.

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

Kullanılmalarını gerektiren önemli performans nedenleri yoksa genellikle bunlardan kaçınmalısınız. ARM gibi zayıf sıralı makine mimarilerinde bunları kullanmak, her atomik işlem için genellikle birkaç düzine makine döngüsü tasarrufu sağlar. x86'da performans artışı, depolama alanlarıyla sınırlıdır ve muhtemelen daha az fark edilir. Bellek sistemi daha sınırlayıcı bir faktör haline geldiğinden, daha büyük çekirdek sayılarında avantaj azalabilir.

Zayıf sıralı atomiklerin tam semantiği karmaşıktır. Genel olarak, dil kurallarının tam olarak anlaşılmasını gerektirirler. Bu konuya burada girmeyeceğiz. Örneğin:

  • Derleyici veya donanım, memory_order_relaxed erişimlerini kilit alma ve bırakma ile sınırlanan kritik bir bölüme taşıyabilir (ancak bu bölümden çıkaramaz). Bu, iki memory_order_relaxed mağazanın kritik bir bölümle ayrılmış olsalar bile sırayla görünmeyebileceği anlamına gelir.
  • Paylaşılan bir sayaç olarak kötüye kullanılan sıradan bir Java değişkeni, yalnızca başka bir iş parçacığı tarafından artırılsa bile başka bir iş parçacığına azalıyor gibi görünebilir. Ancak bu durum C++ atomik için geçerli değildir memory_order_relaxed.

Bu uyarıyı yaptıktan sonra, zayıf sıralı atomiklerin kullanım alanlarının çoğunu kapsayan birkaç deyimden bahsedelim. Bunların çoğu yalnızca C++ için geçerlidir.

Yarış dışı erişimler

Bir değişkenin atomik olması oldukça yaygındır çünkü bazen bir yazma işlemiyle eşzamanlı olarak okunur ancak tüm erişimlerde bu sorun yaşanmaz. Örneğin, bir değişken kritik bir bölümün dışında okunduğu için atomik olması gerekebilir ancak tüm güncellemeler bir kilitle korunur. Bu durumda, aynı kilit tarafından korunan bir okuma işlemi, eşzamanlı yazma işlemleri olamayacağından yarışamaz. Bu durumda, yarış dışı erişim (bu örnekte yükleme), C++ kodunun doğruluğu değiştirilmeden memory_order_relaxed ile açıklanabilir. Kilit uygulaması, diğer iş parçacıklarının erişimiyle ilgili olarak gerekli bellek sıralamasını zaten zorunlu kılar ve memory_order_relaxed, atomik erişim için temelde ek sıralama kısıtlamalarının uygulanması gerekmediğini belirtir.

Java'da bunun gerçek bir karşılığı yoktur.

Sonucun doğruluğuna güvenilmiyor

Yarış yükünü yalnızca ipucu oluşturmak için kullandığımızda, yük için herhangi bir bellek sıralaması uygulamamak da genellikle sorun olmaz. Değer güvenilir değilse diğer değişkenler hakkında çıkarım yapmak için sonucu güvenilir bir şekilde kullanamayız. Bu nedenle, bellek sıralaması garanti edilmiyorsa ve yükleme işlemi memory_order_relaxed bağımsız değişkeniyle sağlanıyorsa sorun olmaz.

Bunun yaygın bir örneği, x değerini atomik olarak f(x) ile değiştirmek için C++ compare_exchange işlevinin kullanılmasıdır. f(x) değerini hesaplamak için x değerinin ilk yüklemesinin güvenilir olması gerekmez. Yanlış tahmin edersek compare_exchange başarısız olur ve tekrar deneriz. x öğesinin ilk yüklemesinde memory_order_relaxed bağımsız değişkeni kullanılabilir. Yalnızca gerçek compare_exchange için bellek sıralaması önemlidir.

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

Bazen veriler birden fazla iş parçacığı tarafından paralel olarak değiştirilir ancak paralel hesaplama tamamlanana kadar incelenmez. Buna iyi bir örnek, birden fazla iş parçacığı tarafından paralel olarak atomik şekilde artırılan (ör. C++'ta fetch_add() veya C'de atomic_fetch_add_explicit() kullanarak) ancak bu çağrıların sonucunun her zaman yoksayıldığı bir sayaçtır. Sonuç değeri, tüm güncellemeler tamamlandıktan sonra yalnızca en sonunda okunur.

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

Basit etkinlik sayaçları buna yaygın bir örnektir. Bu durum çok yaygın olduğu için bu konuda bazı gözlemler yapmakta fayda var:

  • memory_order_relaxed kullanımı performansı artırır ancak en önemli performans sorununu çözmeyebilir: Her güncelleme, sayacı tutan önbellek satırına özel erişim gerektirir. Bu durum, yeni bir iş parçacığı sayaca her eriştiğinde önbellekte yok hatasına neden olur. Güncellemeler sık yapılıyorsa ve iş parçacıkları arasında dönüşümlü olarak gerçekleşiyorsa paylaşılan sayacı her seferinde güncellemek yerine, örneğin iş parçacığına özel sayaçlar kullanıp bunları sonunda toplamak çok daha hızlıdır.
  • Bu teknik, önceki bölümle birleştirilebilir: Tüm işlemler memory_order_relaxed kullanırken yaklaşık ve güvenilir olmayan değerleri güncellenirken aynı anda okumak mümkündür. Ancak sonuçta elde edilen değerlerin tamamen güvenilmez olduğunu varsaymak önemlidir. Sayının bir kez artırılmış gibi görünmesi, başka bir iş parçacığının artırma işleminin gerçekleştirildiği noktaya ulaştığının kesin olduğu anlamına gelmez. Artış, daha önceki 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üklemeden daha düşük bir değer döndürmeyeceğini garanti eder. (Tabii ki sayaç taşmadıysa.)
  • Tek tek atomik (veya atomik olmayan) okuma ve yazma işlemleri gerçekleştirerek yaklaşık sayaç değerlerini hesaplamaya çalışan ancak artışı bir bütün olarak atomik hale getirmeyen kodlara sıkça rastlanır. Genellikle performans sayaçları vb. için bunun "yeterince yakın" olduğu söylenir. Genellikle değildir. Güncellemeler yeterince sık olduğunda (muhtemelen önem verdiğiniz bir durum) sayıların büyük bir kısmı genellikle kaybolur. Dört çekirdekli bir cihazda, sayıların yarısından fazlası genellikle kaybolabilir. (Kolay bir alıştırma: Sayacın bir milyon kez güncellendiği ancak nihai sayaç değerinin bir olduğu iki iş parçacıklı bir senaryo oluşturun.)

Basit bayrak iletişimi

A memory_order_release depolama (veya okuma-değiştirme-yazma işlemi), daha sonra bir memory_order_acquire yükleme (veya okuma-değiştirme-yazma işlemi) yazılı değeri okursa A memory_order_release depolamadan önceki tüm depolamaları (normal veya atomik) da gözlemlemesini sağlar. Buna karşılık, memory_order_release işleminden önceki yüklemeler, memory_order_acquire yüklemesinden sonraki depolamaları gözlemlemez. memory_order_relaxed'ten farklı olarak bu, bir iş parçacığının ilerleme durumunu diğerine iletmek için bu tür atomik işlemlerin kullanılmasına olanak tanır.

Örneğin, yukarıdaki çift kontrollü kilitleme örneğini C++ dilinde şu şekilde yeniden yazabiliriz:

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;
    }
};

Alma yükü ve serbest bırakma deposu, boş olmayan bir helper görürsek alanlarının da doğru şekilde başlatıldığını görmemizi sağlar. Ayrıca, yarış dışı yüklerin memory_order_relaxed kullanabileceğine dair önceki gözlemi de dahil ettik.

Bir Java programcısı helper öğesini java.util.concurrent.atomic.AtomicReference<Helper> olarak temsil edebilir ve yayın mağazası olarak lazySet() kullanabilir. Yükleme işlemleri, basit get() çağrılarını kullanmaya devam eder.

Her iki durumda da performans ayarlamamız, performans açısından kritik olma olasılığı düşük olan başlatma yoluna odaklandı. Daha okunabilir bir alternatif şu 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, aynı hızlı yolu sağlar ancak performans açısından kritik olmayan yavaş yolda varsayılan, sıralı olarak tutarlı işlemlere başvurur.

Burada bile helper.load(memory_order_acquire), mevcut Android destekli mimarilerde helper için düz (sıralı olarak tutarlı) bir referansla aynı kodu oluşturabilir. Buradaki en faydalı optimizasyon, ikinci bir yüklemeyi ortadan kaldırmak için myHelper'ın kullanılması olabilir. Ancak gelecekteki bir derleyici bunu otomatik olarak yapabilir.

Alma/bırakma sıralaması, depolama işlemlerinin görünür şekilde gecikmesini engellemez ve depolama işlemlerinin diğer iş parçacıklarına tutarlı bir sırada görünür olmasını sağlamaz. Bu nedenle, Dekker'ın karşılıklı dışlama algoritmasıyla örneklendirilen, karmaşık ancak oldukça yaygın bir kodlama modelini desteklemez: Tüm iş parçacıkları önce bir şey yapmak istediklerini belirten bir işaret ayarlar; bir iş parçacığı t başka hiçbir iş parçacığının bir şey yapmaya çalışmadığını fark ederse herhangi bir müdahale olmayacağını bilerek güvenle devam edebilir. t'nin işareti hâlâ ayarlandığından başka hiçbir iş parçacığı devam edemez. İşaretçiye alma/bırakma sıralaması kullanılarak erişilirse bu işlem başarısız olur. Çünkü bu, bir iş parçacığının işaretçisinin diğerlerine geç görünmesini engellemez. Bu durumda diğerleri yanlışlıkla devam eder. Varsayılan memory_order_seq_cst bunu engeller.

Değiştirilemeyen alanlar

Bir nesne alanı ilk kullanımda başlatılır ve daha sonra hiç değiştirilmezse, zayıf sıralı erişimler kullanılarak başlatılması ve ardından okunması mümkün olabilir. C++'ta atomic olarak tanımlanabilir ve memory_order_relaxed kullanılarak erişilebilir. Java'da ise volatile olmadan tanımlanabilir ve özel önlemler alınmadan erişilebilir. Bunun için aşağıdaki koşulların tümünün karşılanması gerekir:

  • Alan değerinin başlatılıp başlatılmadığı, alanın değerinden anlaşılabilmelidir. Alana erişmek için hızlı yol test et ve döndür değeri, alanı yalnızca bir kez okumalıdır. Java'da ikincisi çok önemlidir. Alan, başlatılmış olarak test edilse bile ikinci bir yükleme, daha önceki başlatılmamış değeri okuyabilir. C++'ta "bir kez oku" kuralı yalnızca iyi bir uygulamadır.
  • Hem başlatma hem de sonraki yüklemeler atomik olmalıdır. Yani kısmi güncellemeler görünmemelidir. Java için alan, long veya double olmamalıdır. C++ için atomik atama gerekir. atomic oluşturma işlemi atomik olmadığından yerinde oluşturma işe yaramaz.
  • Birden fazla iş parçacığı, başlatılmamış değeri aynı anda okuyabileceğinden yinelenen başlatmalar güvenli olmalıdır. C++'ta bu genellikle tüm atomik türler için uygulanan "kolayca kopyalanabilir" şartından kaynaklanır. İç içe yerleştirilmiş sahip olunan işaretçilere sahip türler, kopyalama oluşturucusunda ayırmanın kaldırılmasını gerektirir ve kolayca kopyalanamaz. Java için belirli referans türleri kabul edilebilir:
  • Java referansları, yalnızca nihai alanlar içeren sabit türlerle sınırlıdır. Değişmez türün oluşturucusu, nesneye referans yayınlamamalıdır. Bu durumda Java final alanı kuralları, okuyucu referansı görürse başlatılan final alanlarını da göreceğini garanti eder. C++'ta bu kuralların benzeri yoktur ve sahip olunan nesnelere yönelik işaretçiler de bu nedenle kabul edilemez ("kolayca kopyalanabilir" şartlarını ihlal etmenin yanı sıra).

Kapanış notları

Bu doküman, konuyu yüzeysel olarak ele almaktan daha fazlasını yapsa da derinlemesine bir inceleme sunmaz. Bu çok geniş ve derin bir konu. Daha fazla keşfedilecek bazı alanlar:

  • Gerçek Java ve C++ bellek modelleri, iki işlemin belirli bir sırada gerçekleşmesinin garanti edildiği zamanı belirten bir önce gerçekleşir ilişkisiyle ifade edilir. Veri yarışını tanımlarken, iki bellek erişiminin "eş zamanlı" gerçekleşmesinden bahsetmiştik. Resmi olarak bu, birinin diğerinden önce gerçekleşmemesi olarak tanımlanır. Java veya C++ bellek modelinde happens-before ve synchronizes-with ifadelerinin gerçek tanımlarını öğrenmek faydalı olacaktır. "Eşzamanlı" kavramının sezgisel tanımı genellikle yeterli olsa da bu tanımlar, özellikle C++'ta zayıf sıralı atomik işlemler kullanmayı düşünüyorsanız faydalıdır. (Mevcut Java spesifikasyonu, lazySet() kavramını yalnızca çok gayriresmi bir şekilde tanımlar.)
  • Derleyicilerin kodu yeniden sıralarken neler yapmasına izin verildiğini ve neleri yapmasına izin verilmediğini keşfedin. (JSR-133 spesifikasyonunda, beklenmedik sonuçlara yol açan yasal dönüşümlerle ilgili bazı iyi örnekler verilmiştir.)
  • Java ve C++ dillerinde sabit sınıfların nasıl yazılacağını öğrenin. (Bu, "oluşturma işleminden sonra hiçbir şeyi değiştirmemekten" daha fazlasını gerektirir.)
  • Effective Java, 2nd Edition kitabının Concurrency bölümündeki önerileri iyice öğrenin. (Örneğin, senkronize edilmiş bir blok içindeyken geçersiz kılınması amaçlanan yöntemleri çağırmaktan kaçınmalısınız.)
  • Kullanabileceğiniz API'leri görmek için java.util.concurrent ve java.util.concurrent.atomic API'lerini inceleyin. @ThreadSafe ve @GuardedBy (net.jcip.annotations'tan) gibi eşzamanlılık ek açıklamalarını kullanabilirsiniz.

Ek bölümündeki Daha Fazla Bilgi kısmında, bu konuları daha iyi açıklayan dokümanların ve web sitelerinin bağlantıları yer alır.

Ek

Senkronizasyon depolarını uygulama

(Bu, çoğu programcının uygulayacağı bir şey değildir ancak tartışma aydınlatıcıdır.)

int gibi küçük yerleşik türler ve Android tarafından desteklenen donanımlar için sıradan yükleme ve depolama talimatları, bir depolama alanının aynı konumu yükleyen başka bir işlemciye tamamen görünür olmasını veya hiç görünür olmamasını sağlar. Böylece "bölünmezlik" kavramı ücretsiz olarak sağlanır.

Daha önce gördüğümüz gibi, bu yeterli değildir. Sıralı tutarlılığı sağlamak için işlemlerin yeniden sıralanmasını da önlememiz ve bellek işlemlerinin diğer işlemler tarafından tutarlı bir sırada görülebilmesini sağlamamız gerekir. Android destekli donanımlarda, ilkini uygulamak için akıllıca seçimler yaptığımız sürece ikincisinin otomatik olarak gerçekleştiğini görüyoruz. Bu nedenle, burada bunu büyük ölçüde göz ardı ediyoruz.

Bellek işlemlerinin sırası, hem derleyici hem de donanım tarafından yeniden sıralamanın engellenmesiyle korunur. Burada ikincisine odaklanacağız.

ARMv7, x86 ve MIPS'te bellek sıralaması, kabaca "fence" talimatından sonraki talimatların "fence" talimatından önceki talimatlardan önce görünür olmasını engelleyen "fence" talimatlarıyla uygulanır. (Bunlar genellikle "bariyer" talimatları olarak da adlandırılır ancak bu, pthread_barrier tarzı bariyerlerle karıştırılma riski taşır. tarzı bariyerler bundan çok daha fazlasını yapar.) Sınır talimatlarının tam anlamı, birden fazla farklı sınır türü tarafından sağlanan garantilerin etkileşim şeklini ve bunların genellikle donanım tarafından sağlanan diğer sıralama garantileriyle nasıl birleştiğini ele alması gereken oldukça karmaşık bir konudur. Bu genel bir bakış olduğu için bu ayrıntıları geçeceğiz.

En temel sıralama garantisi, C++ memory_order_acquire ve memory_order_release atomik işlemleri tarafından sağlanır: Serbest bırakma depolama işleminden önceki bellek işlemleri, alma yükleme işleminden sonra görünür olmalıdır. ARMv7'de bu, şunlar tarafından zorunlu kılınır:

  • Mağaza talimatından önce uygun bir sınır talimatı ekleme. Bu, önceki tüm bellek erişimlerinin depolama talimatıyla yeniden sıralanmasını önler. (Ayrıca, daha sonraki depolama talimatıyla yeniden sıralamayı gereksiz yere engeller.)
  • Yükleme talimatını uygun bir sınır talimatıyla takip ederek yüklemenin sonraki erişimlerle yeniden sıralanmasını önleme. (Ayrıca, en azından daha önceki yüklemelerle gereksiz sıralama sağlar.)

Bunlar birlikte C++ alma/bırakma sıralaması için yeterlidir. Java volatile veya C++ sıralı tutarlı atomic için gereklidir ancak yeterli değildir.

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

1. ileti dizisi Thread 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

Sıralı tutarlılık, flagn'ye yapılan atamalardan birinin önce yürütülmesi ve diğer iş parçacığındaki test tarafından görülmesi gerektiği anlamına gelir. Bu nedenle, bu iş parçacıklarının "kritik-işler"i aynı anda yürüttüğünü asla görmeyiz.

Ancak alma-bırakma sıralaması için gereken bariyerler, yalnızca her iş parçacığının başına ve sonuna bariyer ekler. Bu da burada işe yaramaz. Ayrıca, bir volatile/atomic depolama işlemini bir volatile/atomic yükleme işlemi takip ediyorsa bu ikisinin yeniden sıralanmadığından emin olmamız gerekir. Bu, normalde yalnızca sıralı tutarlı bir depolama işleminden önce değil, sonra da bir engel eklenerek uygulanır. (Bu, genellikle tüm önceki bellek erişimlerini sonraki tüm bellek erişimlerine göre sıraladığı için yine gerekenden çok daha güçlüdür.)

Bunun yerine, ek çiti sıralı olarak tutarlı yüklerle ilişkilendirebiliriz. Mağazalar daha az olduğu için bahsettiğimiz kural daha yaygındır ve Android'de kullanılır.

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

değişken yük geçici depolama alanı
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ürlerini sıralayan ve farklı maliyetlere sahip olabilen birden fazla engel türü sağlar. Bunlar arasındaki seçim hassastır ve mağazaların diğer çekirdeklere tutarlı bir sırada görünür hale getirilmesi ve birden fazla sınırın birleşimiyle uygulanan bellek sıralamasının doğru şekilde oluşturulması ihtiyacından etkilenir. Daha fazla bilgi için lütfen Cambridge Üniversitesi'nin atomiklerin gerçek işlemcilerle toplanmış eşlemelerini içeren sayfasını inceleyin.

Donanım her zaman örtülü olarak yeterli sıralamayı zorunlu kıldığından, özellikle x86 gibi bazı mimarilerde "edinme" ve "serbest bırakma" engelleri gereksizdir. Bu nedenle x86'da yalnızca son engel (3) oluşturulur. Benzer şekilde x86'da atomik okuma-değiştirme-yazma işlemleri dolaylı olarak güçlü bir engel içerir. Bu nedenle, bu tür alanlarda hiçbir zaman çit gerekmez. ARMv7'de, yukarıda bahsettiğimiz tüm bariyerler gereklidir.

ARMv8, Java volatile veya C++ sıralı tutarlı yükleme ve depolama gereksinimlerini doğrudan uygulayan LDAR ve STLR talimatları sağlar. Bu sayede, yukarıda bahsettiğimiz gereksiz yeniden sıralama kısıtlamaları önlenir. ARM'deki 64 bit Android kodu bunları kullanır. Gerçek gereksinimleri daha iyi açıkladığı için burada ARMv7 sınır yerleşimine odaklanmayı tercih ettik.

Daha fazla bilgi

Daha ayrıntılı veya kapsamlı bilgi sağlayan web sayfaları ve dokümanlar Genel olarak daha faydalı olan makaleler listenin üst sıralarında yer alır.

Paylaşılan Bellek Tutarlılık Modelleri: Eğitim
1995 yılında Adve ve Gharachorloo tarafından yazılan bu makale, bellek tutarlılığı modellerini daha ayrıntılı bir şekilde incelemek istiyorsanız iyi bir başlangıç noktasıdır.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Bellek Bariyerleri
Sorunları özetleyen güzel bir makale.
https://en.wikipedia.org/wiki/Memory_barrier
Threads ile ilgili temel bilgiler
Hans Boehm'in C++ ve Java'da çok iş parçacıklı programlamaya giriş makalesi. Veri yarışları ve temel senkronizasyon yöntemleri hakkında tartışma.
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice
2006'da yayınlanan bu kitap, çok çeşitli konuları ayrıntılı bir şekilde ele alıyor. Java'da çok iş parçacıklı kod yazan herkes için kesinlikle önerilir.
http://www.javaconcurrencyinpractice.com
JSR-133 (Java Bellek Modeli) SSS
Senkronizasyon, geçici değişkenler ve nihai alanların oluşturulmasıyla ilgili açıklamalar da dahil olmak üzere Java bellek modeline basit bir giriş. (Özellikle diğer dillerden bahsettiği kısımlar biraz eskidir.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Java Bellek Modelinde Program Dönüşümlerinin Geçerliliği
Java bellek modeliyle ilgili kalan sorunların teknik açıklaması. Bu sorunlar, veri yarışması 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 paketinin belgeleri. Sayfanın alt kısmına yakın bir yerde, çeşitli sınıflar tarafından verilen garantileri açıklayan "Memory Consistency Properties" (Bellek Tutarlılığı Özellikleri) başlıklı bir bölüm bulunur.
java.util.concurrent Paket Özeti
Java Theory and Practice: Safe Construction Techniques in Java (Java Teorisi ve Pratiği: Java'da Güvenli Yapı Teknikleri)
Bu makalede, nesne oluşturma sırasında referansların kaçmasının tehlikeleri ayrıntılı olarak incelenmekte ve iş parçacığı açısından güvenli yapıcılar için yönergeler sunulmaktadır.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Java Theory and Practice: Managing Volatility (Java Teorisi ve Uygulaması: Değişkenliği Yönetme)
Java'da geçici alanlarla neler yapabileceğinizi ve yapamayacağınızı açıklayan güzel bir makale.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
"Double-Checked Locking is Broken" (Çift Kontrollü Kilitleme Bozuk) Beyanı
Bill Pugh'un, volatile veya atomic olmadan çift kontrollü kilitlemenin bozulduğu çeşitli yöntemlerle 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 Turnusol Testleri ve Yemek Kitabı
ARM SMP sorunlarının, kısa ARM kodu snippet'leriyle açıklandığı bir tartışma. Bu sayfadaki örnekleri çok genel bulduysanız veya DMB talimatının resmi açıklamasını okumak istiyorsanız bu makaleyi okuyun. Ayrıca, yürütülebilir kodda bellek bariyerleri için kullanılan talimatları da açıklar (anında kod oluşturuyorsanız faydalı olabilir). Bu, ARMv8'den önceydi. ARMv8, ek bellek sıralama talimatlarını da destekler ve biraz daha güçlü bir bellek modeline geçmiştir. (Ayrıntılar için "ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile" kılavuzuna bakın.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Linux Çekirdeği Bellek Bariyerleri
Linux çekirdeği bellek bariyerleri ile ilgili belgeler. Yararlı ö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 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/techreports/2008/HPL-2008-56.pdf)
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 işlem özellikleri için taslak standart. Ayrıntılar için daha sonraki kusur raporlarını da inceleyin.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
C/C++11 mappings to processors (University of Cambridge)
Jaroslav Sevcik ve Peter Sewell'in C++ atomiklerinin çeşitli yaygın işlemci talimat gruplarına çevirilerinden oluşan koleksiyonu.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Dekker algoritması
"Eşzamanlı programlamada karşılıklı dışlama sorununun bilinen ilk doğru çözümü." Wikipedia makalesinde algoritmanın tamamı yer alıyor ve modern optimizasyon derleyicileri ile SMP donanımlarıyla çalışması için nasıl güncellenmesi gerektiği tartışılıyor.
https://en.wikipedia.org/wiki/Dekker's_algorithm
ARM ve alfa ile ilgili yorumlar ve adres bağımlılıkları
Catalin Marinas'ın arm-kernel posta listesine gönderdiği bir e-posta. Adres ve kontrol bağımlılıklarının iyi bir özetini içerir.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory (Her Programcının Bellek Hakkında Bilmesi Gerekenler)
Ulrich Drepper tarafından yazılan, farklı bellek türleri (özellikle CPU önbellekleri) hakkında çok uzun ve ayrıntılı bir makale.
http://www.akkadia.org/drepper/cpumemory.pdf
ARM zayıf tutarlı bellek modeli hakkında akıl yürütme
Bu makale, ARM, Ltd.'den Chong ve Ishtiaq tarafından yazılmıştır. ARM SMP bellek modelini titiz ancak erişilebilir bir şekilde açıklamaya çalışmaktadır. Burada kullanılan "gözlemlenebilirlik" tanımı bu makaleden alınmıştır. Bu da ARMv8'den önceye dayanır.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
Derleyici Yazarları için JSR-133 Yemek Kitabı
Doug Lea, bu makaleyi JSR-133 (Java Bellek Modeli) belgelerine ek olarak yazmıştır. Bu makale, birçok derleyici yazarı tarafından kullanılan Java bellek modeli için ilk uygulama yönergelerini içerir ve hâlâ yaygın olarak alıntılanır ve muhtemelen fikir verir. Maalesef burada ele alınan dört engel türü, Android tarafından desteklenen mimariler için uygun değildir ve yukarıdaki C++11 eşlemeleri, Java için bile daha iyi bir kesin tarif kaynağıdır.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: x86 Çoklu İşlemciler İçin Titiz ve Kullanılabilir Bir Programcı Modeli
x86 bellek modelinin kesin bir açıklaması. ARM bellek modelinin tam açıklamaları maalesef çok daha karmaşıktır.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf