Modern OpenGL ile çalışan 3D Grafik ve Oyun Motoru geliştirmesi için çevrimiçi indirilebilir bir video eğitimi üzerinde çalışırken. volatile
Sınıflarımızdan birinde kullandık . Öğretici web sitesi burada bulunabilir ve volatile
anahtar kelime ile çalışan Shader Engine
video 98 dizisinde bulunur. Bu çalışmalar bana ait değildir ancak Marek A. Krzeminski, MASc
onaylıdır ve bu video indirme sayfasından bir alıntıdır.
Web sitesine abone olduysanız ve videolarına bu videoda erişiyorsanız , programlamayla kullanımıyla ilgili bu makaleye atıfta bulunur .Volatile
multithreading
uçucu: Çok İş Parçacıklı Programcının En İyi Arkadaşı
Andrei Alexandrescu, 01 Şubat 2001
Volatile anahtar sözcüğü, belirli eşzamansız olayların varlığında kodu yanlış hale getirebilecek derleyici optimizasyonlarını önlemek için tasarlanmıştır.
Ruh halinizi bozmak istemem ama bu sütun çok iş parçacıklı programlamanın korkunç konusuna değiniyor. Generic'in önceki bölümünde söylediği gibi, istisna korumalı programlama zorsa, çok iş parçacıklı programlamaya kıyasla çocuk oyuncağıdır.
Birden çok iş parçacığı kullanan programların genel olarak yazılması, doğruluğunun kanıtlanması, hata ayıklaması, bakımı ve evcilleştirilmesi zordur. Yanlış çok iş parçacıklı programlar, bir aksaklık olmadan yıllarca çalışabilir, yalnızca beklenmedik bir şekilde hata yapar çünkü bazı kritik zamanlama koşulları karşılandı.
Söylemeye gerek yok, çok iş parçacıklı kod yazan bir programcının alabileceği tüm yardıma ihtiyacı var. Bu sütun, çok iş parçacıklı programlarda ortak bir sorun kaynağı olan yarış koşullarına odaklanır ve bunlardan nasıl kaçınılacağına dair içgörüler ve araçlar sağlar ve şaşırtıcı bir şekilde, derleyicinin size bu konuda yardımcı olmak için çok çalışmasını sağlar.
Sadece Küçük Bir Anahtar Kelime
İş parçacıkları söz konusu olduğunda hem C hem de C ++ Standartları bariz bir şekilde sessiz olsalar da, uçucu anahtar kelime biçiminde çok iş parçacıklı okumaya biraz taviz veriyorlar.
Tıpkı daha iyi bilinen karşılığı const gibi, volatile bir tür değiştiricidir. Farklı iş parçacıklarında erişilen ve değiştirilen değişkenlerle birlikte kullanılması amaçlanmıştır. Temel olarak, uçucu olmadan, çok iş parçacıklı programlar yazmak imkansız hale gelir veya derleyici büyük optimizasyon fırsatlarını boşa harcar. Sırayla bir açıklama var.
Aşağıdaki kodu göz önünde bulundurun:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Yukarıdaki Gadget :: Wait işleminin amacı, flag_ üye değişkenini her saniye kontrol etmek ve bu değişken başka bir evre tarafından true olarak ayarlandığında geri dönmektir. En azından programcısının istediği buydu, ama ne yazık ki Bekle yanlış.
Derleyicinin Sleep (1000) 'in, flag_ üye değişkenini değiştiremeyecek bir harici kütüphaneye yapılan bir çağrı olduğunu anladığını varsayalım. Daha sonra derleyici flag_'i bir kayıtta önbelleğe alabileceği ve daha yavaş yerleşik belleğe erişmek yerine bu kaydı kullanabileceği sonucuna varır. Bu, tek iş parçacıklı kod için mükemmel bir optimizasyondur, ancak bu durumda, doğruluğa zarar verir: Bazı Gadget nesnelerini bekledikten sonra, başka bir iş parçacığı Uyanma çağırsa da, Bekle sonsuza kadar döngüye girer. Bunun nedeni, flag_ değişikliğinin flag_'i önbelleğe alan kayıtta yansıtılmamasıdır. Optimizasyon çok ... iyimser.
Kayıtlarda değişkenleri önbelleğe almak, çoğu zaman geçerli olan çok değerli bir optimizasyondur, bu yüzden onu boşa harcamak yazık olur. C ve C ++ size bu tür önbelleğe almayı açıkça devre dışı bırakma şansı verir. Bir değişken üzerinde uçucu değiştiriciyi kullanırsanız, derleyici bu değişkeni yazmaçlarda önbelleğe almaz - her erişim o değişkenin gerçek bellek konumuna ulaşır. Dolayısıyla, Gadget'ın Bekle / Uyandır kombinasyonunun çalışması için yapmanız gereken tek şey flag_'i uygun şekilde nitelendirmektir:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
Uçucu maddenin mantığı ve kullanımının çoğu açıklaması burada durur ve birden çok iş parçacığında kullandığınız ilkel türleri geçici olarak nitelendirmenizi önerir. Bununla birlikte, uçucu ile yapabileceğiniz çok daha fazla şey var çünkü C ++ 'ın harika tip sisteminin bir parçası.
Kullanıcı Tanımlı Türlerle uçucu kullanma
Yalnızca ilkel türleri değil, aynı zamanda kullanıcı tanımlı türleri de geçici olarak niteleyebilirsiniz. Bu durumda volatile, türü const'a benzer şekilde değiştirir. (Aynı türe eşzamanlı olarak sabit ve uçucu da uygulayabilirsiniz.)
Sabit'ten farklı olarak, uçucu, ilkel türler ve kullanıcı tanımlı türler arasında ayrım yapar. Yani, sınıfların aksine, ilkel türler uçucu nitelikte olduklarında hala tüm işlemlerini (toplama, çarpma, atama vb.) Destekler. Örneğin, uçucu olmayan bir int atayabilirsiniz, ancak uçucu olmayan bir nesneyi uçucu bir nesneye atayamazsınız.
Kullanıcı tanımlı türlerde volatile'in nasıl çalıştığını bir örnek üzerinde gösterelim.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Uçucu nesnelerin nesneler için o kadar yararlı olmadığını düşünüyorsanız, biraz sürpriz için hazırlanın.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
Nitelikli olmayan bir türden değişken muadiline dönüşüm önemsizdir. Ancak, const'ta olduğu gibi, geçici bir durumdan niteliksiz duruma geri dönemezsiniz. Bir alçı kullanmalısınız:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Uçucu nitelikte bir sınıf, arabiriminin yalnızca bir alt kümesine, sınıf uygulayıcısının denetimi altındaki bir alt kümeye erişim sağlar. Kullanıcılar bu türün arayüzüne yalnızca bir const_cast kullanarak tam erişim sağlayabilir. Ek olarak, tıpkı constness gibi, uçuculuk da sınıftan üyelerine yayılır (örneğin, volatileGadget.name_ ve volatileGadget.state_ uçucu değişkenlerdir).
uçucu, Kritik Bölümler ve Yarış Koşulları
Çok iş parçacıklı programlarda en basit ve en sık kullanılan senkronizasyon cihazı muteks'tir. Bir muteks, Acquire ve Release ilkellerini ortaya çıkarır. Bir iş parçacığında Acquire'ı çağırdığınızda, Acquire'ı çağıran diğer herhangi bir iş parçacığı engellenecektir. Daha sonra, iş parçacığı Release'i çağırdığında, bir Acquire çağrısında engellenen bir iş parçacığı serbest bırakılacaktır. Diğer bir deyişle, belirli bir muteks için, bir Acquire çağrısı ile bir Release çağrısı arasında yalnızca bir iş parçacığı işlemci süresini alabilir. Bir Acquire çağrısı ile bir Release çağrısı arasındaki yürütme koduna kritik bölüm adı verilir. (Windows terminolojisi biraz kafa karıştırıcıdır çünkü muteksin kendisini kritik bir bölüm olarak adlandırırken, "muteks" aslında süreçler arası bir mutekstir. Evre muteksi ve proses muteksi olarak adlandırılsalar güzel olurdu.)
Muteksler, verileri yarış koşullarına karşı korumak için kullanılır. Tanım gereği, daha fazla iş parçacığının veriler üzerindeki etkisi iş parçacığının nasıl programlandığına bağlı olduğunda bir yarış koşulu oluşur. Yarış koşulları, iki veya daha fazla iş parçacığı aynı verileri kullanmak için rekabet ettiğinde ortaya çıkar. İş parçacıkları zaman içinde rastgele anlarda birbirlerini kesebildikleri için veriler bozulabilir veya yanlış yorumlanabilir. Sonuç olarak, değişiklikler ve bazen verilere erişim kritik bölümlerle dikkatlice korunmalıdır. Nesne yönelimli programlamada, bu genellikle bir muteksi bir sınıfta üye değişken olarak depoladığınız ve o sınıfın durumuna her eriştiğinizde onu kullandığınız anlamına gelir.
Deneyimli çok iş parçacıklı programcılar yukarıdaki iki paragrafı okurken esnemiş olabilirler, ancak amaçları entelektüel bir çalışma sağlamaktır, çünkü şimdi uçucu bağlantıyla bağlantı kuracağız. Bunu, C ++ türlerinin dünyası ile iş parçacığı semantik dünyası arasında bir paralel çizerek yapıyoruz.
- Kritik bir bölümün dışında, herhangi bir iş parçacığı herhangi bir zamanda herhangi bir diğerini kesebilir; kontrol yoktur, dolayısıyla birden çok iş parçacığından erişilebilen değişkenler uçucudur. Bu, geçici olayın orijinal amacına uygundur - derleyicinin, aynı anda birden çok iş parçacığı tarafından kullanılan değerleri istemeden önbelleğe almasını engellemek.
- Bir muteks tarafından tanımlanan kritik bir bölümün içinde, yalnızca bir iş parçacığının erişimi vardır. Sonuç olarak, kritik bir bölümün içinde, yürütme kodunun tek iş parçacıklı semantiği vardır. Kontrollü değişken artık uçucu değildir - uçucu niteleyiciyi kaldırabilirsiniz.
Kısacası, iş parçacıkları arasında paylaşılan veriler kavramsal olarak kritik bir bölümün dışında uçucudur ve kritik bir bölümün içinde uçucu değildir.
Bir muteksi kilitleyerek kritik bir bölüme girersiniz. Bir türden geçici niteleyiciyi bir const_cast uygulayarak kaldırırsınız. Bu iki işlemi bir araya getirmeyi başarırsak, C ++ 'ın tip sistemi ile bir uygulamanın threading semantiği arasında bir bağlantı oluşturuyoruz. Derleyicinin bizim için yarış koşullarını kontrol etmesini sağlayabiliriz.
LockingPtr
Bir mutex edinimi ve bir const_cast toplayan bir araca ihtiyacımız var. Uçucu bir nesne objesi ve bir mutex mtx ile başlattığınız bir LockingPtr sınıfı şablonu geliştirelim. Kullanım ömrü boyunca, bir LockingPtr mtx'i elde etmeye devam eder. Ayrıca LockingPtr, uçucu-soyulmuş nesneye erişim sağlar. Erişim, operatör-> ve operatör * aracılığıyla akıllı bir işaretçi tarzında sunulur. Const_cast, LockingPtr içinde gerçekleştirilir. Çevirme anlamsal olarak geçerlidir çünkü LockingPtr, muteksi ömrü boyunca edinilmiş olarak tutar.
Öncelikle, LockingPtr'in çalışacağı bir Mutex sınıfının iskeletini tanımlayalım:
class Mutex {
public:
void Acquire();
void Release();
...
};
LockingPtr kullanmak için, Mutex'i işletim sisteminizin yerel veri yapılarını ve ilkel işlevlerini kullanarak uygularsınız.
LockingPtr, kontrollü değişkenin türü ile şablonlanır. Örneğin, bir Widget'ı kontrol etmek istiyorsanız, değişken tipte uçucu Widget ile başlattığınız bir LockingPtr kullanırsınız.
LockingPtr'in tanımı çok basittir. LockingPtr karmaşık olmayan bir akıllı işaretçi uygular. Yalnızca bir sabit yayın ve kritik bir bölüm toplamaya odaklanır.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Basitliğine rağmen, LockingPtr doğru çok iş parçacıklı kod yazmada çok yararlı bir yardımcıdır. Evreler arasında paylaşılan nesneleri uçucu olarak tanımlamalısınız ve bunlarla asla const_cast kullanmamalısınız - her zaman LockingPtr otomatik nesneleri kullanın. Bunu bir örnekle açıklayalım.
Bir vektör nesnesini paylaşan iki iş parçacığınızın olduğunu varsayalım:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
Bir iş parçacığı işlevinin içinde, buffer_ üye değişkenine kontrollü erişim sağlamak için bir LockingPtr kullanmanız yeterlidir:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
Kodu yazmak ve anlamak çok kolaydır - ne zaman buffer_ kullanmanız gerekirse, ona işaret eden bir LockingPtr oluşturmanız gerekir. Bunu yaptıktan sonra, vektörün tüm arayüzüne erişebilirsiniz.
İşin güzel yanı, bir hata yaparsanız, derleyicinin bunu göstermesidir:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Bir const_cast uygulayana veya LockingPtr kullanana kadar buffer_ işlevinin herhangi bir işlevine erişemezsiniz. Aradaki fark, LockingPtr'nin geçici değişkenlere const_cast uygulamak için sıralı bir yol sunmasıdır.
LockingPtr dikkat çekici şekilde ifade edicidir. Yalnızca bir işlevi çağırmanız gerekiyorsa, adsız bir geçici LockingPtr nesnesi oluşturabilir ve onu doğrudan kullanabilirsiniz:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
İlkel Türlere Geri Dön
Nesneleri kontrolsüz erişime karşı ne kadar iyi uçucu koruduğunu ve LockingPtr'in iş parçacığı için güvenli kod yazmanın basit ve etkili bir yolunu nasıl sağladığını gördük. Şimdi uçucu tarafından farklı şekilde ele alınan ilkel türlere dönelim.
Birden çok iş parçacığının int türünde bir değişkeni paylaştığı bir örneği ele alalım.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Artış ve Azaltma farklı iş parçacıklarıyla çağrılacaksa, yukarıdaki parça buggy'dir. İlk olarak, ctr_ uçucu olmalıdır. İkinci olarak, ++ ctr_ gibi görünüşte atomik bir işlem bile aslında üç aşamalı bir işlemdir. Belleğin kendisinin aritmetik yeteneği yoktur. Bir değişkeni artırırken işlemci:
- Bir kayıttaki değişkeni okur
- Kayıttaki değeri artırır
- Sonucu belleğe geri yazar
Bu üç aşamalı işleme RMW (Oku-Değiştir-Yaz) adı verilir. Bir RMW işleminin Değiştir bölümü sırasında, çoğu işlemci, diğer işlemcilerin belleğe erişmesine izin vermek için bellek veriyolunu boşaltır.
O sırada başka bir işlemci aynı değişken üzerinde bir RMW işlemi gerçekleştirirse, bir yarış durumumuz vardır: ikinci yazma, ilkinin etkisinin üzerine yazar.
Bundan kaçınmak için LockingPtr'a tekrar güvenebilirsiniz:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Şimdi kod doğrudur, ancak kalitesi SyncBuf'un koduyla karşılaştırıldığında daha düşüktür. Neden? Çünkü Counter ile, derleyici, yanlışlıkla ctr_'ye doğrudan erişirseniz (kilitlemeden) sizi uyarmaz. Derleyici, oluşturulan kod basitçe yanlış olsa da, ctr_ uçucu ise ++ ctr_ derler. Derleyici artık müttefikiniz değil ve yalnızca dikkatiniz yarış koşullarından kaçınmanıza yardımcı olabilir.
O halde ne yapmalısın? Basitçe, üst düzey yapılarda kullandığınız ilkel verileri kapsülleyin ve bu yapılarla uçucu kullanın. Paradoksal olarak, başlangıçta bu uçucu maddenin kullanım amacı olmasına rağmen, doğrudan yerleşik öğelerle uçucu kullanmak daha kötüdür!
uçucu Üye İşlevleri
Şimdiye kadar, değişken veri üyelerini bir araya getiren sınıflarımız oldu; şimdi daha büyük nesnelerin parçası olacak ve evreler arasında paylaşılan sınıflar tasarlamayı düşünelim. Uçucu üye işlevlerinin çok yardımcı olabileceği yer burasıdır.
Sınıfınızı tasarlarken, yalnızca iş parçacığı için güvenli olan üye işlevleri geçici olarak nitelendirirsiniz. Dışarıdan gelen kodun herhangi bir zamanda herhangi bir koddan geçici işlevleri çağıracağını varsaymalısınız. Unutmayın: uçucu, ücretsiz çok iş parçacıklı koda eşittir ve kritik bölüm yoktur; uçucu olmayan, tek iş parçacıklı senaryoya veya kritik bir bölümün içine eşittir.
Örneğin, iki varyantta bir işlem uygulayan bir sınıf Widget'ı tanımlarsınız - iş parçacığı güvenli ve hızlı, korumasız olan.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Aşırı yükleme kullanımına dikkat edin. Artık Widget'ın kullanıcısı, uçucu nesneler için tek tip bir sözdizimi kullanarak İşlemi başlatabilir ve iş parçacığı güvenliği elde edebilir ya da normal nesneler için hız alabilir. Kullanıcı, paylaşılan Widget nesnelerini geçici olarak tanımlama konusunda dikkatli olmalıdır.
Uçucu bir üye işlevini uygularken, ilk işlem genellikle bunu bir LockingPtr ile kilitlemektir. Daha sonra uçucu olmayan kardeş kullanılarak çalışma yapılır:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Özet
Çok iş parçacıklı programlar yazarken, kendi yararınıza uçucu kullanabilirsiniz. Aşağıdaki kurallara uymalısınız:
- Paylaşılan tüm nesneleri uçucu olarak tanımlayın.
- İlkel türlerle doğrudan uçucu kullanmayın.
- Paylaşılan sınıfları tanımlarken, iş parçacığı güvenliğini ifade etmek için geçici üye işlevlerini kullanın.
Bunu yaparsanız ve basit genel bileşen LockingPtr kullanırsanız, iş parçacığı güvenli kod yazabilir ve yarış koşulları hakkında çok daha az endişe edebilirsiniz, çünkü derleyici sizin için endişelenecek ve hatalı olduğunuz noktaları özenle işaret edecektir.
Dahil olduğum birkaç proje, büyük bir etki için uçucu ve LockingPtr kullanıyor. Kod temiz ve anlaşılır. Birkaç kilitlenme anımsıyorum, ancak kilitlenmeleri yarış koşullarına tercih ederim çünkü hata ayıklaması çok daha kolay. Yarış koşullarıyla ilgili neredeyse hiçbir sorun yoktu. Ama asla bilemezsin.
Teşekkürler
Anlayışlı fikirlerle yardımcı olan James Kanze ve Sorin Jianu'ya çok teşekkürler.
Andrei Alexandrescu, Seattle, WA merkezli RealNetworks Inc.'de (www.realnetworks.com) Geliştirme Müdürü ve beğenilen Modern C ++ Tasarım kitabının yazarıdır. Kendisiyle www.moderncppdesign.com adresinden temasa geçilebilir. Andrei ayrıca C ++ Semineri'nin (www.gotw.ca/cpp_seminar) öne çıkan eğitmenlerinden biridir.
Bu makale biraz eski olabilir, ancak derleyicinin bizim için yarış koşullarını kontrol etmesini sağlarken olayları eşzamansız tutmaya yardımcı olmak için çok iş parçacıklı programlamanın kullanımında uçucu değiştiricinin mükemmel bir şekilde kullanılmasına yönelik iyi bir fikir veriyor. Bu, OP'nin bellek çiti oluşturma konusundaki orijinal sorusuna doğrudan cevap vermeyebilir, ancak bunu, çok iş parçacıklı uygulamalarla çalışırken uçucu maddenin iyi bir kullanımına yönelik mükemmel bir referans olarak başkaları için bir yanıt olarak göndermeyi seçiyorum.