.NET 4.0'dan önce, çok iş parçacıklı bir ortamda Sözlük sınıfını kullanmamız gerekiyorsa, iş parçacıklarını güvende tutmak için iş parçacığı senkronizasyonunu kendimiz uygulamak zorunda kalmıştık.
Birçok geliştirici kesinlikle benzer bir iş parçacığı güvenli çözümü uygulamıştır; ya tamamen yeni bir iş parçacılığı güvenli sözlük türü oluşturarak ya da bir Sözlük nesnesini bir sınıfa kapsülleyip tüm yöntemlere kilitleme mekanizması ekleyerek buna "Sözlük + Kilitler" adını veriyoruz.
Ama şimdi, ConcurrentDictionary var. MSDN'deki Sözlük sınıfı dokümantasyonunun iş başlıklı güvenli açıklaması, iş parçacığı güvenli bir uygulama kullanmanız gerekiyorsa ConcurrentDictionary kullanmanızı belirtir.
Artık iş başlığı güvenli bir sözlük sınıfımız olduğu için artık kendimiz uygulamamıza gerek yok. Harika, değil mi?
Sorunun kökeni
Aslında, CocurrentDictionary'yi daha önce sadece bir kez, yanıt vermesini test etmek için testimde kullandım. Sınavlarda iyi performans gösterdiği için hemen sınıfımla değiştirdim, biraz test yaptım ve sonra bir şeyler ters gitti.
Peki, ne yanlış gitti? İplik güvenli demiyor muydun?
Daha fazla test yaptıktan sonra sorunun kökenini buldum. Ama nedense, MSDN sürüm 4.0, delege tipi parametresi geçmeyi gerektiren GetOrAdd yöntem imzası tanımını içermiyor. 4.5 sürümüne baktıktan sonra şu notu buldum:
Farklı iş parçacıklarında aynı anda GetOrAdd çağırırsanız, addValueFactory birden fazla kez çağrılabilir, ancak anahtar/değer çifti her çağrı için sözlüğe eklenmeyebilir. Karşılaştığım sorun buydu. Daha önce belgelerde açıklanmadığı için, sorunu doğrulamak için daha fazla test yapmam gerekti. Tabii ki, karşılaştığım sorun kullanımımla ilgili, genel olarak bazı verileri önbelleklemek için sözlük tipini kullanıyorum:
Bu verilerin oluşturulması çok yavaştır; Bu veri yalnızca bir kez oluşturulabilir, çünkü ikinci yaratım bir istisna oluşturur veya birden fazla yaratım kaynak sızıntısına yol açabilir; İkinci koşulla ilgili bir sorunum vardı. Her iki iş parçacığı da bir veri parçasının var olmadığını tespit ederse, veri bir kez oluşturulur, ancak sadece bir sonuç başarılı şekilde kaydedilir. Peki ya diğeri?
Oluşturduğunuz süreçte istisna varsa, try kullanabilirsiniz: yakalama (yeterince zarif değil ama sorunu çözüyor). Peki ya bir kaynak yaratılıp geri dönüştürülmüyorsa?
Bir nesne yaratılmış ve artık referans verilmezse çöp toplanacağını söyleyebilirsiniz. Ancak, aşağıda açıklanan durum gerçekleşirse ne olacağını düşünün:
Kod dinamik olarak Emit ile oluşturun. Bu yaklaşımı uzaktan bir çerçevede kullandım ve tüm uygulamaları geri dönüştürülemeyen bir montaja koydum. Bir tür iki kez oluşturulursa, ikincisi her zaman var olur, hiç kullanılmamış olsa bile. Doğrudan veya dolaylı olarak bir iş başlığı oluşturun. Örneğin, asenkron mesajları işlemek için özel bir iş parçacığı kullanan ve alınma sırasına bağlı bir bileşen oluşturmamız gerekiyor. Bileşen oluşturulduğunda, bir iş parçacağı oluşturulur. Bu bileşen örneği yok edildiğinde, iş parçacığı da sonlandırılır. Ama bileşeni yok ettikten sonra nesneye olan referansı silersek, iplik bir sebepten dolayı bitmez ve nesneye referansı tutar. Sonra, iplik ölmezse, nesne de geri dönüştürülmez. P/Invoke işlemi gerçekleştirin. Alınan sap için kapalı zaman sayısının, açılış sayısıyla aynı olması gerek. Elbette, benzer birçok durum var. Örneğin, bir sözlük nesnesi uzak sunucudaki bir hizmete bağlantı tutar; bu bağlantı yalnızca bir kez istenebilir ve ikinci kez istenirse, diğer hizmet bir hata olduğunu düşünüp bunu günlüğe kaydeder. (Çalıştığım bir şirkette bu durum için bazı yasal cezalar vardı.) ) Bu yüzden, Sözlük + Kilitler'in aceleyle ConcurrentDictionary ile değiştirilemeyeceği kolayca anlaşılıyor, belgelerde iş başlığı güvenli olduğu belirtilse bile.
Sorunu analiz edin
Hâlâ anlamıyor musun?
Bu sorunun Sözlük + Kilitler yaklaşımında ortaya çıkmayabileceği doğrudur. Bu, uygulamaya bağlı olduğundan, şu basit örneğe bakalım:
Yukarıdaki kodda, anahtar değerini sorgulamaya başlamadan önce sözlüğün kilidini tutuyoruz. Belirtilen anahtar-değer çifti yoksa, doğrudan oluşturulur. Aynı zamanda, zaten o sözlükte bir kilitimiz olduğu için, anahtar-değer çiftlerini doğrudan sözlüğe ekleyebiliriz. Sonra sözlük kilidini açın ve sonucu geri verin. Eğer iki iş parçacığı aynı anahtar değerini aynı anda sorguluyorsa, sözlük kilidini alan ilk iş parçacığı nesnenin oluşturulmasını tamamlar, diğer iş parçacığı ise bu oluşturmanın tamamlanmasını bekleyip sözlük kilidini aldıktan sonra oluşturulan anahtar değeri sonucunu alır.
Bu iyi, değil mi?
Gerçekten öyle değil! Böyle paralel bir nesne oluşturmak, sonunda sadece bir nesne kullanılması, anlattığım sorunu yaratmaz diye düşünüyorum.
Detaylandırmak istediğim durum ve sorun her zaman tekrarlanabilir olmayabilir; paralel bir ortamda sadece iki nesne yaratıp birini atabiliriz. Peki, Dictionary + Locks ile ConcurrentDictionary'yi tam olarak nasıl karşılaştırıyoruz?
Cevap şu: kilit kullanım stratejisine ve sözlüğün nasıl kullanıldığına bağlıdır.
Oyun 1: Aynı nesneyi paralel olarak yaratın
Öncelikle, bir nesnenin iki kez oluşturulabileceğini varsayalım, peki iki iş parçacığı aynı anda bu nesneyi oluşturursa ne olur?
İkinci olarak, benzer eserlere ne kadar süre harcıyoruz?
Bir nesneyi örneklemenin 10 saniye sürdüğü bir örnek oluşturabiliriz. İlk iş parçacığı nesneyi 5 saniye sonra oluşturduğunda, ikinci uygulama nesneyi almak için GetOrAdd metodunu çağırmaya çalışır ve nesne hâlâ var olmadığı için nesneyi oluşturmaya da başlar.
Bu durumda, paralel olarak 5 saniye çalışan 2 CPU var ve ilk iş parçacığı çalışmayı bitirdiğinde, ikinci iş parçacığı nesnenin inşasını tamamlamak için 5 saniye çalışmaya devam etmelidir. İkinci iş parçacığı nesneyi inşa etmeyi bitirdiğinde, bir nesnenin zaten var olduğunu görür ve mevcut nesneyi kullanıp yeni oluşturulan nesneyi doğrudan atmayı seçer.
İkinci iş parçacığı sadece beklerse ve ikinci CPU başka bir iş yaparsa (başka iş parçacıkları veya uygulamalar çalıştırır, biraz güç tasarrufu yaparsa), istenen nesneyi 10 saniye yerine 5 saniye içinde alır.
Bu koşullar altında, Dictionary + Locks küçük bir oyunu kazanır.
Oyun 2: Farklı nesneleri paralel olarak ziyaret edin
Hayır, söylediğin durum hiç doğru değil!
Yukarıdaki örnek biraz tuhaf ama sorunu açıklıyor, sadece bu kullanım daha aşırı. Yani, ilk iş parçacığı bir nesne oluşturuyorsa ve ikinci iş parçacığı başka bir anahtar-değer nesnesine erişmesi gerekiyorsa ve o anahtar-değer nesnesi zaten varsa, ne olacağını düşünün?
ConcurrentDictionary'de, kilitsiz tasarım okumayı çok hızlı yapar çünkü okumada kilitlenme yoktur. Sözlük + Kilitler durumunda, okuma işlemi tamamen farklı bir anahtar olsa bile karşılıklı olarak kilitlenir, bu da okuma işlemini açıkça yavaşlatacaktır.
Bu şekilde ConcurrentDictionary bir oyunu geri çekti.
Not: Burada sözlük sınıfında Bucket/Node/Entry gibi birkaç kavramı anladığınızı düşünüyorum, eğer anlamıyorsa, bu kavramları iyi açıklayan Ofir Makmal'ın "Understanding Generic Dictionary in-depth" adlı makalesini okumanız tavsiye edilir.
Oyunun üçüncü oyunu: daha fazla okuyun ve tek yaz
Dictionary + Locks'ta sözlüğün tam kilidi yerine Çoklu Okuyucu ve Tek Yazar kullanırsanız ne olur?
Bir iş parçacağı bir nesne oluşturuyorsa ve nesne oluşturulana kadar yükseltilebilir bir kilit tutarsa, kilit yazma kilidi olarak yükseltilir ve okuma işlemi paralel olarak gerçekleştirilebilir.
Sorunu ayrıca okuma işlemini 10 saniye boşta bırakarak çözebiliriz. Ancak yazmadan çok daha fazla okuma varsa, ConcurrentDictionary'nin kilitsiz mod okumaları uyguladığı için hâlâ hızlı olduğunu göreceğiz.
Sözlükler için ReaderWriterLockSlim kullanmak okumayı daha kötü yapar ve genellikle ReaderWriterLockSlim yerine Full Lock for Dictionarys kullanılması önerilir.
Bu koşullar altında, ConcurrentDictionary bir maç daha kazandı.
Not: Önceki makalelerde YieldReaderWriterLock ve YieldReaderWriterSlim derslerini ele aldım. Bu okuma-yazma kilidi kullanılarak hız önemli ölçüde artırılmış (şimdi SpinReaderWriterLockSlim'e dönüşmüştür) ve birden fazla okumanın paralel olarak çok az veya hiç etki olmadan gerçekleştirilmesine olanak tanır. Hâlâ bu şekilde kullanıyor olsam da, kilitsiz bir ConcurrentDictionary elbette daha hızlı olurdu.
Maç 4: Birden fazla anahtar-değer çifti ekleyin
Karşılaşma henüz bitmedi.
Ya eklemesi gereken birden fazla anahtar değerimiz varsa ve hepsi çarpışmıyor ve farklı kovalara atanıyorsa?
İlk başta bu soru merak etti ama tam olarak uymayan bir test yaptım. <int, int> tipli bir sözlük kullandım ve nesnenin yapım fabrikası doğrudan anahtar olarak negatif sonuç veriyordu.
ConcurrentDictionary'nin en hızlı olacağını bekliyordum ama en yavaş olduğu ortaya çıktı. Dictionary + Locks ise daha hızlı performans gösteriyor. Neden böyle?
Bunun nedeni, ConcurrentDictionary'nin düğümleri tahsis edip farklı kovalara yerleştirmesi; bu da okuma işlemleri için kilitsiz tasarıma uygun optimize edilmiştir. Ancak, anahtar değerli öğeler eklendiğinde, bir düğüm oluşturma süreci pahalı hale gelir.
Paralel koşullarda bile, bir Node kilidi tahsis etmek tam kilitle göre daha fazla zaman alır.
Yani, Dictionary + Locks bu oyunu kazanıyor.
Beşinci oyunu oynamak: Okuma işlemlerinin sıklığı daha yüksektir
Açıkçası, nesneleri hızlıca ortaya çıkarabilen bir delegemiz olsaydı, bir Sözlüğe ihtiyacımız olmazdı. Nesneyi almak için doğrudan delegayı arayabiliriz, değil mi?
Aslında, cevap da duruma bağlı olduğudur.
Anahtar tipinin string olduğunu ve web sunucusunda çeşitli sayfalar için yol haritalarını içerdiğini ve karşılık gelen değerin, sunucu açıldıktan sonra sayfaya erişimi olan mevcut kullanıcıların kaydını ve sayfaya yapılan tüm ziyaret sayısını içeren bir nesne tipi olduğunu hayal edin.
Böyle bir nesne yaratmak neredeyse anında gerçekleşir. Ve bundan sonra yeni bir nesne yaratmanıza gerek yok, sadece içindeki değerleri değiştirin. Yani bir yolun iki kez oluşturulmasına izin vermek mümkündür, ta ki sadece bir örnek kullanılana kadar. Ancak ConcurrentDictionary Node kaynaklarını daha yavaş tahsis ettiği için, Dictionary + Locks kullanmak daha hızlı oluşturulma sürelerine yol açar.
Bu örnek çok özeldir, ayrıca Dictionary + Locks'un bu koşullarda daha iyi performans gösterdiğini ve daha az zaman aldığını görüyoruz.
ConcurrentDictionary'deki düğüm tahsisi daha yavaş olsa da, zamanı test etmek için 100 milyon veri öğesi eklemeye çalışmadım. Çünkü bu elbette çok zaman alıyor.
Ancak çoğu durumda, bir veri öğesi oluşturulduktan sonra her zaman okunur. Veri öğesinin içeriğinin nasıl değiştiği ise başka bir konudur. Yani bir veri öğesi oluşturmak için kaç milisaniye daha sürdüğü önemli değil, çünkü okumalar daha hızlı (sadece birkaç milisaniye daha hızlı), ama okumalar daha sık gerçekleşiyor.
Yani, ConcurrentDictionary oyunu kazandı.
Oyun 6: Farklı zamanları tüketen nesneler yaratın
Farklı veri öğeleri oluşturma süresi değişirse ne olur?
Farklı zamanlarda tüketen birden fazla veri öğesi oluşturun ve bunları paralel olarak sözlüğe ekleyin. Bu, ConcurrentDictionary'nin en güçlü yanı.
ConcurrentDictionary, veri öğelerinin eşzamanlı eklenmesine izin vermek için çeşitli kilitleme mekanizmaları kullanır, ancak hangi kilidin kullanılacağına karar vermek, bir kilidin kovanın boyutunu değiştirmek gibi mantıklar yardımcı olmaz. Veri öğelerinin bir kovaya konma hızı makine hızındadır. ConcurrentDictionary'yi gerçekten kazandıran şey, nesneleri paralel olarak yaratabilme yeteneğidir.
Ancak aslında aynı şeyi yapabiliriz. Nesneleri paralel oluşturuyor mu yoksa bazıları atılmış olsa da umursamıyorsak, veri öğesinin zaten var olup olmadığını tespit etmek için bir kilit ekleyebiliriz, sonra kilidi açabilir, veri öğesini oluşturabilir, kilidi almak için basabiliriz, veri öğesinin var olup olmadığını tekrar kontrol edebiliriz, yoksa veri öğesini ekleyebiliriz. Kod şöyle görünebilir:
* Ben <int, int> tipinde bir sözlük kullanıyorum.
Yukarıdaki basit yapıda, Dictionary + Locks, paralel koşullarda veri öğeleri oluşturup eklerken neredeyse ConcurrentDictionary kadar iyi performans gösterir. Ama aynı sorun da var; bazı değerler üretilebilir ama hiç kullanılmaz.
son
Peki, bir sonuç var mı?
Şu anda hâlâ bazıları var:
Tüm sözlük dersleri çok hızlı. Milyonlarca veri oluşturmuş olmama rağmen hâlâ hızlı. Normalde sadece az sayıda veri öğesi oluştururuz ve okumalar arasında bazı zaman aralıkları vardır, bu yüzden genellikle veri öğelerini okuma süresini fark etmiyoruz. Aynı nesne iki kez oluşturulamazsa, ConcurrentDictionary kullanmayın. Performans konusunda gerçekten endişeleniyorsanız, Dictionary + Locks hâlâ iyi bir çözüm olabilir. Önemli bir faktör, eklenen ve çıkarılan veri öğesi sayısıdır. Ancak çok sayıda okuma işlemi varsa, ConcurrentDictionary'den daha yavaştır. Ben tanıtmasam da, aslında Sözlük + Kilitler şemasını kullanma özgürlüğü daha fazla. Örneğin, bir kez kilitleyebilir, birden fazla veri öğesi ekleyebilir, birden fazla veri öğesini silebilir veya birden fazla kez sorgulayabilir vb. ve ardından kilidi açabilirsiniz. Genel olarak, yazılardan çok daha fazla okuma varsa ReaderWriterLockSlim kullanmaktan kaçının. Sözlük türleri, okuma-yazma kilidinde okuma kilidi almaktan çok daha hızlıdır. Tabii ki, bu aynı zamanda bir kilitte nesne yaratmak için harcanan zamana da bağlıdır. Bu yüzden verilen örnekler biraz aşırı ama ConcurrentDictionary kullanmanın her zaman en iyi çözüm olmadığını gösteriyorlar.
Farkı hisset
Bu makaleyi daha iyi bir çözüm aramak amacıyla yazdım.
Zaten belirli bir sözlük dersinin nasıl çalıştığını daha derinlemesine anlamaya çalışıyorum (şimdi çok net olduğumu hissediyorum).
Tartışmalı olarak, ConcurrentDictionary'deki Bucket ve Node çok basittir. Ben de benzer bir şey yaptım, bir sözlük sınıfı oluşturmaya çalıştığımda da öyle. Normal Sözlük dersi daha basit görünebilir, ancak aslında daha karmaşıktır.
ConcurrentDictionary'de her Düğüm tam bir sınıftır. Sözlük sınıfında, Node bir değer tipiyle uygulanır ve tüm düğümler büyük bir dizide tutulurken, Bucket diziyi indekslemek için kullanılır. Ayrıca, bir düğümün bir sonraki düğüme basit referansı yerine kullanılır (sonuçta, yapı tipi bir düğüm olarak, yapı tipinden bir düğüm üyesi içeremez).
Bir sözlük ekleyip kaldırırken, Sözlük sınıfı sadece yeni bir düğüm oluşturamaz, silinmiş bir düğümü işaretleyen bir indeks olup olmadığını kontrol etmeli ve sonra tekrar kullanmalı. Ya da "Count" dizideki yeni düğümün konumunu almak için kullanılır. Aslında, dizi dolu olduğunda, Dictionary sınıfı boyut değişikliğini zorunlu kılar.
ConcurrentDictionary için, bir düğüm yeni bir nesne olarak düşünülebilir. Bir düğümü kaldırmak, sadece referansını kaldırmaktır. Yeni bir Node eklemek ise basitçe yeni bir Node örneği oluşturabilir. Boyutu değiştirmek sadece çatışmaları önlemek içindir, ancak zorunlu değildir.
Yani, eğer Sözlük sınıfı bunu kasıtlı olarak daha karmaşık algoritmalar kullanıyorsa, ConcurrentDictionary çok iş parçacıklı bir ortamda daha iyi performans göstermesini nasıl sağlayacak?
Gerçek şu ki: tüm düğümleri tek bir diziye koymak, veri öğelerinin nerede bulunacağını takip etmek için başka bir diziye ihtiyaç duysak bile, en hızlı tahsis ve okuma yoludur. Yani aynı sayıda kovaya sahip olmak daha fazla bellek kullanacak gibi görünüyor, ama yeni veri öğelerinin yeniden tahsis edilmesine gerek yok, yeni nesne senkronizasyonuna gerek yok ve yeni çöp toplama gerçekleşmiyor. Çünkü her şey zaten yerinde.
Ancak, bir düğümde içerik değiştirmek atomik bir işlem değildir ve bu da iş parçacılığını güvensiz kılan faktörlerden biridir. Düğümlerin hepsi nesne olduğundan, başlangıçta bir düğüm oluşturulur ve ardından ona işaret etmek için ayrı bir referans güncellenir (burada atomik işlem). Yani, okuma başlığı sözlük içeriğini kilitsiz okuyabilir ve okuma eski ve yeni değerlerden biri olmalı, eksik bir değer okuma ihtimali yoktur.
Gerçek şu ki: Eğer kilide ihtiyacınız yoksa, Sözlük sınıfı okumalarda daha hızlıdır, çünkü okumayı yavaşlatan kilit.
Bu makale, Paulo Zemek'in CodeProject'teki "Dictionary + Locking versus ConcurrentDictionary" makalesinden çevrilmiştir ve bazı ifadeler anlama nedenleriyle değişecektir.
|