Görünüşe göre, iş parçacıkları arasında paylaşılan bir değişkeni mutasyona uğratan kod, neden bir yarış durumundan zarar görmüyor?


107

Cygwin GCC kullanıyorum ve şu kodu çalıştırıyorum:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

Çizgi ile Derleyen: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o.

Doğru olan 1000 yazdırır. Ancak, daha önce artırılmış bir değerin üzerine yazılan iş parçacıkları nedeniyle daha az bir sayı bekliyordum. Bu kod neden karşılıklı erişimden zarar görmüyor?

Test makinemde 4 çekirdek var ve bildiğim programa herhangi bir kısıtlama koymuyorum.

Paylaşılan içeriğin foodaha karmaşık bir şeyle değiştirilmesi durumunda sorun devam eder , örneğin

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

66
Intel CPU'ları, SMP sistemlerinde kullanılan (çift Pentium Pro makineleri gibi) çok eski x86 CPU'larla uyumluluğu korumak için bazı şaşırtıcı dahili "shoot down" mantığına sahiptir. Bize öğretilen birçok arıza durumu, x86 makinelerinde neredeyse hiçbir zaman gerçekleşmez. Diyelim ki bir çekirdek ubelleğe geri yazacak . CPU, bellek satırının uCPU'nun önbelleğinde olmadığını fark etmek gibi şaşırtıcı şeyler yapacak ve artırma işlemini yeniden başlatacaktır. Bu nedenle x86'dan diğer mimarilere geçmek, göz açıcı bir deneyim olabilir!
David Schwartz

1
Belki hala çok hızlı. Diğer iş parçacıklarının tamamlanmadan başlatılmasını sağlamak için herhangi bir şey yapmadan önce iş parçacığının vermesini sağlamak için kod eklemeniz gerekir.
Rob K

1
Başka bir yerde belirtildiği gibi, iş parçacığı kodu o kadar kısadır ki, bir sonraki evre kuyruğa alınmadan önce çalıştırılabilir. 100 sayım döngüsüne u ++ yerleştiren 10 iş parçacığı nasıl olur? Ve döngünün başlamasından önce kısa bir gecikme (veya hepsini aynı anda başlatmak için küresel bir "başla" bayrağı)
RufusVS

5
Aslında, programı bir döngü içinde tekrar tekrar üretmek, sonunda kırıldığını gösterir: while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;sistemimde 999 veya 998 yazdırması gibi bir şey .
Daniel Kamil Kozar

Yanıtlar:


266

foo()o kadar kısadır ki, her iş parçacığı muhtemelen bir sonraki daha ortaya çıkmadan önce biter. Eğer rastgele bir süre için uyku eklerseniz foo()önce u++, size beklediğiniz görmeye başlayabilir.


51
Bu gerçekten de çıktıyı beklenen şekilde değiştirdi.
mafu

49
Bunun genel olarak yarış koşullarını sergilemek için oldukça iyi bir strateji olduğunu belirtmek isterim. Herhangi iki işlem arasına bir duraklama enjekte edebilmelisiniz; değilse, bir yarış durumu var.
Matthieu M.

Son zamanlarda C # ile bu sorunu yaşadık. Kod neredeyse hiçbir zaman başarısız oldu, ancak arasına yakın zamanda eklenen bir API çağrısı, tutarlı bir şekilde değişmesi için yeterli gecikmeye neden oldu.
Obsidian Phoenix

@MatthieuM. Microsoft, hem yarış koşullarını tespit etme hem de onları güvenilir bir şekilde yeniden üretilebilir hale getirme yöntemi olarak tam olarak bunu yapan otomatik bir araca sahip değil mi?
Mason Wheeler

1
@MasonWheeler: Neredeyse sadece Linux üzerinde çalışıyorum, yani ... dunno :(
Matthieu M.

59

Bir yarış koşulunun kodun yanlış çalışacağını garanti etmediğini, sadece tanımlanmamış bir davranış olduğu için her şeyi yapabileceğini anlamak önemlidir. Beklendiği gibi çalıştırma dahil.

Özellikle X86 ve AMD64 makinelerinde yarış koşulları, bazı durumlarda nadiren sorunlara neden olur, çünkü talimatların çoğu atomiktir ve tutarlılık garantileri çok yüksektir. Bu garantiler, birçok talimatın atomik olması için kilit önekinin gerekli olduğu çoklu işlemcili sistemlerde biraz azaltılır.

Makinenizdeki artış atomik bir işlem ise, bu, dil standardına göre Tanımsız Davranış olsa bile muhtemelen doğru şekilde çalışacaktır.

Spesifik olarak bu durumda kodun, tek işlemcili sistemlerde gerçekten atomik olan atomik bir Fetch and Add komutuna (X86 montajında ​​ADD veya XADD) derlenebileceğini umuyorum, ancak çok işlemcili sistemlerde bunun atomik ve kilit olması garanti edilmez bunu yapmak için gerekli olacaktır. Çok işlemcili bir sistem üzerinde çalışıyorsanız, iş parçacıklarının karışabileceği ve yanlış sonuçlar üretebileceği bir pencere olacaktır.

Özellikle ben montaj kullanarak kodunuzu derlenmiş https://godbolt.org/ ve foo()karşı derler:

foo():
        add     DWORD PTR u[rip], 1
        ret

Bu, yalnızca tek bir işlemci için atomik olacak bir ekleme talimatı gerçekleştirdiği anlamına gelir (yukarıda belirtildiği gibi çok işlemcili bir sistem için böyle değildir).


41
"İstendiği gibi koşmanın" tanımlanmamış davranışın izin verilen bir sonucu olduğunu hatırlamak önemlidir.
Mark

3
Sizin de belirttiğiniz gibi, bu talimat bir SMP makinesinde atomik değildir (tüm modern sistemler budur). Hatta inc [u]atomik değil. LOCKÖnek bir talimat gerçekten atomik hale getirmek için gereklidir. OP basitçe şanslı olmaya başlıyor. CPU'ya "bu adresteki kelimeye 1 ekle" diyor olsanız bile, CPU'nun yine de bu değeri getirmesi, artırması, saklaması gerektiğini ve başka bir CPU'nun aynı şeyi aynı anda yaparak sonucun yanlış olmasına neden olabileceğini hatırlayın.
Jonathon Reinhart

2
Olumsuz oy verdim, ancak sonra sorunuzu tekrar okudum ve atomiklik ifadelerinizin tek bir CPU varsaydığını fark ettim. Sorunuzu daha net hale getirmek için düzenlerseniz ("atomik" dediğinizde, bunun sadece tek bir CPU'daki durum olduğunu açıkça belirtin), o zaman olumsuz oyumu kaldırabilirim.
Jonathon Reinhart

3
Olumsuz oy verildi, bu iddiayı biraz meh buluyorum "Özellikle X86 ve AMD64 makinelerinde yarış koşulları bazı durumlarda nadiren sorunlara neden oluyor çünkü talimatların çoğu atomiktir ve tutarlılık garantileri çok yüksektir." Paragraf, tek çekirdeğe odaklandığınızı açıkça varsaymaya başlamalıdır. Öyle olsa bile, çok çekirdekli mimariler günümüzde tüketici cihazlarında fiili standarttır ve bunu ilk olarak değil, son olarak açıklamak için bir köşe vakası olarak düşünürdüm.
Patrick Trentin

3
Oh, kesinlikle. x86, yanlış yazılmış kodun mümkün olduğu ölçüde çalıştığından emin olmak için tonlarca geriye dönük uyumluluğa sahiptir. Pentium Pro'nun sıra dışı uygulamayı başlatması gerçekten büyük bir olaydı. Intel, kurulu kod tabanının, özellikle yeni çipleri için yeniden derlenmeye gerek kalmadan çalıştığından emin olmak istedi . x86, bir CISC çekirdeği olarak başladı, ancak dahili olarak bir RISC çekirdeğine dönüştü, ancak yine de bir programcının bakış açısından CISC gibi birçok yönden sunuluyor ve davranıyor. Daha fazla bilgi için Peter Cordes'in cevabına buradan bakın .
Cody Grey

20

Daha önce ya da sonra uyumanızın pek bir şey olmadığını düşünüyorum u++. Daha ziyade, işlemin u++- çağıran iş parçacığı ek yüküne kıyasla foo- yakalanması pek olası olmayacak şekilde çok hızlı bir şekilde gerçekleştirilen koda dönüşmesidir. Ancak, operasyonu "uzatırsanız" u++, yarış durumu çok daha olası hale gelir:

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

sonuç: 694


BTW: Ben de denedim

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

ve bana çoğu kez verdi 1997, ama bazen 1995.


1
Aklı başında olan herhangi bir derleyiciden tüm işlevin aynı şeye optimize edilmesini beklerdim. Olmadığına şaşırdım. İlginç sonuç için teşekkür ederim.
Vality

Bu kesinlikle doğrudur. Bir sonraki iş parçacığı söz konusu minik işlevi yürütmeye başlamadan önce binlerce talimatın çalıştırılması gerekir. İşlevdeki yürütme zamanını iş parçacığı oluşturma ek yüküne yaklaştırdığınızda, yarış koşulunun etkisini görürsünüz.
Jonathon Reinhart

@Vality: Ayrıca O3 optimizasyonu altında sahte for-loop'u da silmesini bekliyordum. Değil mi?
user21820

Nasıl else u -= 1idam edilebilir? Paralel bir ortamda bile değer asla uymamalı %2, değil mi?
mafu

2
çıktıdan, else u -= 1u == 0 olduğunda, foo () ilk kez çağrıldığında, bir kez çalıştırılıyor gibi görünüyor . Kalan 999 kez u tektir ve u += 2u = -1 + 999 * 2 = 1997 ile sonuçlanarak çalıştırılır; yani doğru çıktı. Bir yarış durumu bazen + = 2'den birinin üzerine paralel bir iş parçacığı tarafından yazılmasına neden olur ve
Luke

7

Bir yarış durumundan muzdarip. Put usleep(1000);önce u++;de fooben farklı çıkış (<1000) her zaman görüyoruz.


6
  1. Yarış durumu olsa, sizin için apaçık niye muhtemel cevabı yok biri, yani foo()her iplik bitirir sonraki kutu bile başlamadan önce o, bir iş parçacığı başlatmak için gereken zaman ile karşılaştırıldığında, bu nedenle hızlı. Fakat...

  2. Orijinal sürümünüzle bile, sonuç sisteme göre değişir: Bir (dört çekirdekli) Macbook'ta kendi yönteminizle denedim ve on seferde 1000, altı kez 999 ve bir kez 998 aldım. Yani yarış biraz nadir ama açıkça mevcut.

  3. Sen derlenen '-g'böcek yok ederek bir yolu vardır ki,. Kodunuzu hala değişmeden ama değiştirmeden yeniden derledim '-g've yarış çok daha belirgin hale geldi: Bir kez 1000, üç kez 999, iki kez 998, iki kez 997, bir kez 996 ve bir kez 992 aldım.

  4. Yeniden. bir uyku ekleme önerisi - bu yardımcı olur, ancak (a) sabit bir uyku süresi, iş parçacıkları başlangıç ​​zamanına göre hala çarpık kalır (zamanlayıcı çözünürlüğüne bağlı olarak) ve (b) rastgele bir uyku, istediğimiz şey olduğunda onları yayar onları birbirine yaklaştırın. Bunun yerine, onları bir başlangıç ​​sinyalini beklemeleri için kodlardım, böylece çalışmalarına izin vermeden önce hepsini oluşturabilirim. Bu sürümle ( '-g'olsun veya olmasın ), 974 kadar düşük ve 998'den yüksek olmayan sonuçları her yerde alıyorum:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }

Sadece bir not. -gİşaretin herhangi bir şekilde değil "yapmak böcek kaybolur." -gHem GNU ve Clang derleyici bayrak sadece derlenmiş ikili ayıklama sembollerini ekler. Bu, GDB ve Memcheck gibi tanılama araçlarını, insan tarafından okunabilir bazı çıktılarla programlarınızda çalıştırmanıza olanak tanır. Örneğin, Memcheck bellek sızıntısı olan bir program üzerinden çalıştırıldığında, program -gbayrak kullanılarak oluşturulmadıkça size satır numarasını söylemez .
MS-DDOS

Verilen, hata ayıklayıcıdan hataların gizlenmesi genellikle daha çok derleyici optimizasyonu meselesidir; Denedim ve "kullanarak demeliydim -O2 yerine ait -g". Tezahür edecek bir hata avcılık sevincini hiç olmadı Fakat eğer söyledi sadece derlenmiş zaman olmadan -g kendinizi şanslı düşünün. En çirkin örtüşme hatalarının bazılarında olabilir . Ben var Sana inanıyorum edeceğiz yüzden GNU ve Clang modern versiyonları hakkında, geçici, eski bir özel derleyici bir cilvesi olsa son zamanlarda değil, onu görmüş, ve belki inanabilirim.
dgould

-goptimizasyonları kullanmaktan sizi alıkoymaz. örneğin hata ayıklama meta verileriyle gcc -O3 -gaynı asm yapar gcc -O3. Yine de bazı değişkenleri yazdırmaya çalışırsanız gdb "optimize edildi" diyecektir. -gekledikleri herhangi bir şey .textbölümün parçasıysa, bellekteki bazı şeylerin göreceli konumlarını değiştirebilir . Kesinlikle nesne dosyasında yer kaplıyor, ancak bence hepsini bağladıktan sonra, metin bölümünün bir ucunda (bölüm değil) ya da hiç bir bölümün parçası olmadığını düşünüyorum. Dinamik kitaplıklar için nesnelerin nerede haritalandığını etkileyebilir.
Peter Cordes
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.