İlk betadan beri Java'da kod yazıyorum; o zamanlar bile konular favori özellikler listemin başında yer alıyordu. Java, iş parçacığı desteğini kendi diline getiren ilk dildi; o zamanlar tartışmalı bir karardı.
Geçtiğimiz on yılda, her dil eşzamansız/beklemeyi içerecek şekilde yarıştı ve Java'nın bile bunun için bazı üçüncü taraf desteği vardı … Ancak Java zagging yerine zikzak çizdi ve çok daha üstün sanal iş parçacıklarını (Loom projesi) tanıttı. Bu yazı bununla ilgili değil.
Bence bu harika ve Java'nın temel gücünü kanıtlıyor. Sadece dil olarak değil kültür olarak da. Moda trendine acele etmek yerine, değişiklikleri kasıtlı olarak yapma kültürü.
Bu yazıda Java'da iş parçacığı oluşturmanın eski yollarını yeniden gözden geçirmek istiyorum. Ben synchronized
, wait
, notify
vb. işlemlere alışkınım. Ancak bunların Java'da iş parçacığı oluşturma konusunda üstün bir yaklaşım olmasından bu yana uzun zaman geçti.
Ben sorunun bir parçasıyım; Bu yaklaşımlara hâlâ alışkınım ve Java 5'ten beri var olan bazı API'lere alışmakta zorlanıyorum. Bu bir alışkanlıktır. T
Buradaki videolarda tartıştığım iş parçacıklarıyla çalışmak için birçok harika API var, ancak temel ama önemli olan kilitler hakkında konuşmak istiyorum.
Senkronize ayrılma konusunda yaşadığım isteksizlik, alternatiflerin çok daha iyi olmamasıydı. Bugün bunu bırakmanın temel motivasyonu, şu anda senkronize olmanın Loom'da iplik sabitlemeyi tetikleyebilmesidir ki bu da ideal değildir.
JDK 21 bunu düzeltebilir (Loom GA'ya geçtiğinde), ancak onu bırakmak yine de mantıklıdır.
Senkronize edilenin doğrudan değiştirilmesi ReentrantLock'tur. Ne yazık ki, ReentrantLock'un senkronizeye göre çok az avantajı vardır, bu nedenle geçişin faydası en iyi ihtimalle şüphelidir.
Aslında çok önemli bir dezavantajı var; Bunu anlamak için bir örneğe bakalım. Senkronize edilmiş kullanımı şu şekilde kullanırız:
synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }
ReentrantLock
ilk dezavantajı ayrıntıdır. Blok içinde bir istisna meydana geldiğinde kilit kalacağı için try bloğuna ihtiyacımız var. Bizim için sorunsuz bir şekilde senkronize edilmiş kollar.
Bazı kişilerin kilidi AutoClosable
ile sarmak için kullandığı bir numara var ve kabaca şuna benziyor:
public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }
İdeal olan Kilit arayüzünü uygulamadığıma dikkat edin. Bunun nedeni, lock yönteminin void
yerine otomatik kapatılabilir uygulamayı döndürmesidir.
Bunu yaptıktan sonra, bunun gibi daha kısa bir kod yazabiliriz:
try(LOCK.lock()) { // safe code }
Azaltılmış ayrıntı hoşuma gidiyor, ancak kaynakla deneme temizleme amacıyla tasarlandığından ve kilitleri yeniden kullandığımızdan bu sorunlu bir kavramdır. Close'u çağırıyor ama biz bu metodu aynı nesne üzerinde tekrar çağıracağız.
Kilit arayüzünü desteklemek için kaynak sözdizimi ile denemeyi genişletmenin güzel olabileceğini düşünüyorum. Ancak bu gerçekleşene kadar bu değerli bir numara olmayabilir.
ReentrantLock
kullanmanın en büyük nedeni Loom desteğidir. Diğer avantajları da güzel ama hiçbiri “mükemmel özellik” değil.
Sürekli bir blok yerine yöntemler arasında kullanabiliriz. Kilit alanını en aza indirmek istediğiniz için bu muhtemelen kötü bir fikirdir ve arıza bir sorun olabilir. Bu özelliği bir avantaj olarak görmüyorum.
Adil olma seçeneği vardır. Bu, ilk önce kilitte duran ilk iş parçacığına hizmet edeceği anlamına gelir. Bunun önemli olacağı, karmaşık olmayan, gerçekçi bir kullanım durumu düşünmeye çalıştım ve boşluklar çiziyorum.
Bir kaynakta sürekli olarak kuyruğa alınan birçok iş parçacığının bulunduğu karmaşık bir zamanlayıcı yazıyorsanız, diğer iş parçacıkları gelmeye devam ettiğinden iş parçacığının "aç kaldığı" bir durum yaratabilirsiniz. Ancak bu tür durumlar muhtemelen eşzamanlılık paketindeki diğer seçeneklerle daha iyi karşılanır. .
Belki burada bir şeyleri özlüyorum…
lockInterruptibly()
bir kilitlenmeyi beklerken bir iş parçacığını kesmemizi sağlar. Bu ilginç bir özellik ama yine de gerçekçi bir fark yaratacağı bir durum bulmak zor.
Kesintiye karşı çok duyarlı olması gereken bir kod yazarsanız, bu yeteneği kazanmak için lockInterruptibly()
API'sini kullanmanız gerekir. Ancak lock()
yönteminde ortalama ne kadar zaman harcıyorsunuz?
Bunun muhtemelen önemli olduğu ancak gelişmiş çok iş parçacıklı kod yazarken bile çoğumuzun karşılaşmayacağı uç durumlar vardır.
Çok daha iyi bir yaklaşım ReadWriteReentrantLock
. Kaynakların çoğu sık okuma ve az yazma işlemi ilkesini izler. Bir değişkeni okumak iş parçacığı açısından güvenli olduğundan, değişkene yazma sürecinde olmadığımız sürece kilide gerek yoktur.
Bu, yazma işlemlerini biraz daha yavaşlatırken okumayı en üst düzeye çıkarabileceğimiz anlamına gelir.
Bunun sizin kullanım durumunuz olduğunu varsayarsak, çok daha hızlı kod oluşturabilirsiniz. Okuma-yazma kilidiyle çalışırken iki kilidimiz olur; Aşağıdaki resimde görebileceğimiz gibi bir okuma kilidi. Birden fazla iş parçacığının geçmesine izin verir ve etkili bir şekilde "herkes için ücretsizdir".
Değişkene yazmamız gerektiğinde aşağıdaki görselde göreceğimiz gibi bir yazma kilidi elde etmemiz gerekiyor. Yazma kilidini istemeye çalışıyoruz, ancak değişkenden hâlâ okunan iş parçacıkları var, bu yüzden beklememiz gerekiyor.
Konuların okunması bittiğinde tüm okumalar engellenecek ve aşağıdaki resimde görüldüğü gibi yazma işlemi yalnızca tek bir iş parçacığından gerçekleşebilecektir. Yazma kilidini serbest bıraktığımızda ilk görseldeki “herkes için ücretsiz” durumuna geri döneceğiz.
Bu, koleksiyonları çok daha hızlı hale getirmek için kullanabileceğimiz güçlü bir modeldir. Tipik bir senkronize liste oldukça yavaştır. Tüm işlemlerde (okuma veya yazma) senkronize olur. Okumak için hızlı olan bir CopyOnWriteArrayList'imiz var, ancak yazma işlemleri çok yavaştır.
Yöntemlerinizden yineleyicileri döndürmekten kaçınabileceğinizi varsayarsak, liste işlemlerini kapsülleyebilir ve bu API'yi kullanabilirsiniz.
Örneğin, aşağıdaki kodda isim listesini salt okunur olarak gösteriyoruz, ancak bir isim eklememiz gerektiğinde yazma kilidini kullanıyoruz. Bu, synchronized
listelerden kolayca daha iyi performans gösterebilir:
private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }
StampedLock
hakkında anlamamız gereken ilk şey, onun yeniden girişli olmamasıdır. Diyelim ki bu bloğa sahibiz:
synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }
Bu çalışacak. Senkronize edilmiş yeniden giriş olduğundan. Kilidi zaten tutuyoruz, dolayısıyla methodB()
dan methodA()
ye geçmek engellemez. Bu, aynı kilidi veya aynı senkronize nesneyi kullandığımızı varsayarak ReentrantLock ile de çalışır.
StampedLock
kilidi açmak için kullandığımız bir damgayı döndürür. Bu nedenle bazı sınırları vardır. Ama yine de çok hızlı ve güçlü. Aynı zamanda paylaşılan bir kaynağı korumak için kullanabileceğimiz bir okuma ve yazma damgasını da içerir.
Ancak ReadWriteReentrantLock,
farklı olarak kilidi yükseltmemize olanak tanır. Bunu neden yapmamız gerekiyor?
Daha önceki addName()
yöntemine bakın… Peki ya onu "Shai" ile iki kez çağırırsam?
Evet, bir Set kullanabilirim… Ancak bu alıştırmanın amacı için diyelim ki bir listeye ihtiyacımız var… Bu mantığı ReadWriteReentrantLock
ile yazabilirim:
public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }
Bu berbat. Bazı durumlarda yalnızca contains()
'u kontrol etmek için yazma kilidi için "ödeme yaptım" (çok sayıda kopya olduğunu varsayarak). Yazma kilidini almadan önce isInList(name)
işlevini çağırabiliriz. O zaman şunları yaparız:
Her iki kapma durumunda da kuyruğa girebiliriz ve bu fazladan uğraşmaya değmeyebilir.
StampedLock
ile okuma kilidini yazma kilidine güncelleyebilir ve gerekirse değişikliği yerinde yapabiliriz:
public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }
Bu durumlar için güçlü bir optimizasyondur.
Yukarıdaki video serisinde buna benzer pek çok konuyu ele alıyorum; bir göz atın ve ne düşündüğünüzü bana bildirin.
Senkronize koleksiyonlara sıklıkla ikinci kez düşünmeden ulaşıyorum. Bu bazen makul olabilir, ancak çoğu için muhtemelen optimalin altındadır. İş parçacığıyla ilgili temel öğelerle biraz zaman harcayarak performansımızı önemli ölçüde artırabiliriz.
Bu, özellikle temeldeki çekişmenin çok daha hassas olduğu Loom ile uğraşırken geçerlidir. 1 milyon eşzamanlı iş parçacığında okuma işlemlerini ölçeklendirdiğinizi hayal edin… Bu durumlarda, kilit çekişmesini azaltmanın önemi çok daha fazladır.
synchronized
koleksiyonların neden ReadWriteReentrantLock
ve hatta StampedLock
kullanamadığını düşünebilirsiniz.
API'nin yüzey alanı o kadar büyük olduğundan, onu genel bir kullanım durumu için optimize etmek zor olduğundan bu sorunludur. Düşük seviyeli temel öğeler üzerindeki kontrolün, yüksek verim ile engelleme kodu arasındaki farkı yaratabileceği yer burasıdır.