Software · Jul 6, 2023 · 10 dk okuma
Concurrency ve Parallelism: Aynı Anda Çalışan İşler Gerçekte Nasıl Yönetilir?
Concurrency ile parallelism aynı şey değildir. Race condition, lock, mutex, semaphore ve deadlock gibi kavramları gerçek bir sipariş sistemi üzerinden öğrenin.

Concurrency ve parallelism, yazılımın birden fazla işi nasıl yürüttüğünü açıklayan iki farklı kavramdır. Concurrency, birden fazla işin yürütülmesinin aynı zaman aralığında ilerleyecek şekilde düzenlenmesidir. Parallelism ise bu işlerin fiziksel olarak aynı anda çalışmasıdır. Bir uygulama concurrent olup parallel olmayabilir. Asıl mühendislik problemi, işlerin kaçının aynı anda çalıştığından çok, birbirlerinin durumunu nasıl etkilediğidir.
Bu ayrım yanlış kurulduğunda sonuç yalnızca performans kaybı olmaz. Aynı ürün iki kez satılabilir, bir ödeme iki defa işlenebilir veya bir işlem diğerini süresiz bekletebilir. Üstelik bu hatalar geliştirme ortamında hiç görünmeyip production ortamında, yük arttığında ortaya çıkabilir. Concurrency konusunu anlamanın en yararlı yolu, CPU kullanımından başlayıp paylaşılan verinin nasıl korunduğuna kadar ilerlemektir.
Concurrency ile parallelism farklı problemleri çözer
Tek kasası bulunan bir market düşünelim. Kasiyer bir müşterinin ödemesini beklerken başka bir müşterinin ürünlerini hazırlayabilir. İki müşterinin işi aynı zaman aralığında ilerler, fakat kasiyer fiziksel olarak aynı anda iki ödeme işlemi yapmaz. Bu düzen concurrency için iyi bir benzetmedir.
İki kasa açıldığında durum değişir. Farklı kasiyerler iki müşterinin işlemini gerçekten aynı anda gerçekleştirebilir. Bu da parallelism kavramına karşılık gelir.
Yazılımda concurrency, birden fazla işin execution sürelerinin örtüşmesi ve sistemin bu işleri birlikte yönetebilmesidir. Parallelism ise farklı execution birimlerinin aynı anda çalışmasıdır. Örneğin tek CPU core üzerinde çalışan bir scheduler, thread'ler arasında geçiş yaparak concurrent execution sağlayabilir. Birden fazla CPU core ise uygun koşullarda farklı thread'leri parallel çalıştırabilir.
Burada kritik ayrım şudur: Concurrency programın yürütülmesini düzenleme biçimidir; parallelism ise aynı anda işlem yapabilme durumudur. Bir programın concurrent olması, parallel çalışacağını garanti etmez.
Thread, işletim sistemi tarafından planlanabilen execution birimidir. Bir process birden fazla thread barındırabilir ve bu thread'ler aynı process içindeki belleğe erişebilir. Bu ortak erişim güçlüdür, fakat concurrency hatalarının önemli bir kısmı da buradan doğar.
Async/await ise başka bir mekanizmadır. Özellikle I/O bekleyen uygulamalarda, bir işlem tamamlanana kadar thread'in bloke edilmesini önleyebilir. Ancak async kullanmak yeni bir CPU core oluşturmaz ve işlemleri otomatik olarak parallel hale getirmez.
Bir web sunucusunun veritabanından cevap beklerken başka request'leri işleyebilmesi concurrency örneğidir. Büyük bir görüntünün farklı parçalarını farklı CPU core'larda hesaplamak ise parallelism örneğidir.
İlk durumda amaç bekleme süresinde kaynakları verimli kullanmaktır. İkinci durumda amaç hesaplama işini bölerek tamamlanma süresini azaltmaktır. İkisi birlikte kullanılabilir, ancak aynı optimizasyon değildir.
Race condition, yanlış zamanda gelen işlemden doğar
Concurrency hakkında sık duyulan açıklamalardan biri, problemin iki thread'in aynı değişkene aynı anda erişmesi olduğudur. Bu açıklama eksiktir. Asıl mekanizma, işlemin sonucunun execution sırasına bağlı hale gelmesidir. Buna race condition denir.
Basit bir sayaç düşünelim:
int counter = 0;
Parallel.For(0, 1000, _ =>
{
counter++;
});İlk bakışta sonuç 1000 olmalıdır. Fakat counter++ tek ve bölünemez bir işlem değildir. Kavramsal olarak mevcut değerin okunması, artırılması ve tekrar yazılması gerekir.
İki thread aynı anda counter değerini 10 olarak okuyabilir. İkisi de sonucu 11 olarak hesaplar ve belleğe 11 yazar. İki artırma yapılmasına rağmen sayaç yalnızca bir artmıştır.
Bu durum lost update olarak bilinir. Sorun CPU'nun yanlış hesap yapması değildir; iki doğru hesaplamanın ortak veri üzerinde yanlış birleşmesidir.
Thread safety, bir bileşenin birden fazla thread tarafından eşzamanlı kullanıldığında tanımlanmış davranışını korumasıdır. Ancak thread-safe olmak, her kullanımın otomatik olarak doğru olduğu anlamına gelmez. Bir collection üzerindeki tekil işlemler güvenli olsa bile bu işlemlerden oluşturulan daha büyük bir iş akışı race condition içerebilir.
Örneğin bir ürünün stokta olup olmadığını kontrol edip ardından stok miktarını azaltmak iki ayrı işlemdir. Her işlem kendi başına thread-safe olsa bile aralarındaki boşlukta başka bir işlem aynı stoku tüketebilir.
Bu nedenle concurrency tasarımında korunması gereken şey yalnızca değişken değildir. Bazen korunması gereken, birkaç işlem boyunca doğru kalması gereken bir iş kuralıdır.
Bu noktada atomicity kavramı devreye girer. Atomic operation, diğer execution birimlerine bölünmüş ara durumları görünmeyen işlem olarak değerlendirilir. Stok azaltma işlemi atomic değilse, kontrol ve güncelleme arasındaki boşluk veri bütünlüğünü bozabilir.
Bellekteki race condition ile veritabanındaki race condition aynı temel probleme dayanır. İki farklı işlem aynı durumu okuyup birbirinin yaptığı değişikliği hesaba katmadan karar verir.
Lock, mutex ve semaphore farklı sınırlar koyar
Paylaşılan duruma erişimi düzenlemenin yaygın yöntemlerinden biri lock kullanmaktır. Lock, belirli bir critical section içinde aynı anda yalnızca izin verilen sayıda execution bulunmasını sağlar. Critical section, ortak durumun tutarlılığını korumak için kontrollü erişim gerektiren kod bölümüdür.
C# içinde basit bir örnek şöyledir:
private readonly object _sync = new();
private int _counter;
public void Increment()
{
lock (_sync)
{
_counter++;
}
}Bu örnekte aynı _sync nesnesini kullanan thread'ler critical section'a aynı anda giremez. Bir thread işlem yaparken diğerleri bekler.
Lock kullanırken önemli olan, aynı veriyi koruyan bütün erişimlerin aynı synchronization kuralına uymasıdır. Bir yerde lock kullanıp başka bir yerde aynı değişkeni korumasız değiştirmek problemi geri getirir.
Mutex de mutual exclusion sağlar. Temel amacı, belirli bir anda yalnızca tek execution biriminin korunan bölgeye girmesine izin vermektir. .NET gibi ortamlarda mutex, işletim sistemi tarafından desteklenen bir synchronization primitive olarak kullanılabilir ve uygun şekilde adlandırıldığında process'ler arasında koordinasyon sağlayabilir.
Her problem için mutex seçmek doğru değildir. Yalnızca aynı process içindeki kısa bellek işlemlerinde daha hafif synchronization mekanizmaları genellikle yeterlidir.
Semaphore farklı bir ihtiyacı karşılar. Mutex erişimi bire indirirken semaphore belirli sayıda eşzamanlı erişime izin verir.
Örneğin aynı anda en fazla beş dosya yükleme işlemi yürütmek istiyorsak semaphore başlangıç kapasitesi beş olacak şekilde ayarlanabilir.
private readonly SemaphoreSlim _semaphore = new(5);
public async Task UploadAsync()
{
await _semaphore.WaitAsync();
try
{
await SendFileAsync();
}
finally
{
_semaphore.Release();
}
}Buradaki semaphore paylaşılan bir sayacı veri yarışı olmadan artırmak için kullanılmıyor. Aynı anda çalışan operasyon sayısını sınırlıyor. Bu ayrım önemlidir: concurrency limit koymak ile veri bütünlüğünü korumak aynı problem değildir.
Basit sayaç işlemlerinde Interlocked daha uygun olabilir. Interlocked, belirli değişken işlemlerini atomic gerçekleştiren bir API ailesidir.
Interlocked.Increment(ref _counter);Bu işlem lost update problemini önler. Ancak Interlocked ile iki farklı değişken arasında karmaşık bir iş kuralını otomatik olarak koruyamayız.
Benzer şekilde concurrent collections, ortak collection kullanımındaki synchronization ihtiyacını azaltır. .NET içindeki ConcurrentDictionary ve ConcurrentQueue bunun örnekleridir.
Örneğin birden fazla worker aynı queue'ya iş ekleyip çıkarabilir. Collection kendi iç yapısını concurrent erişime karşı korur. Buna rağmen queue'dan alınan işin veritabanında tam olarak bir kez uygulanacağını garanti etmez.
Thread-safe collection ile thread-safe business operation birbirinden farklıdır.
Bir sipariş sisteminde sorun nerede ortaya çıkar?
Bir e-ticaret uygulamasında son bir adet ürünün kaldığını düşünelim. İki müşteri neredeyse aynı anda sipariş oluşturuyor. API iki request alıyor ve her request farklı bir application instance tarafından işlenebiliyor.
Veritabanındaki ürün kaydı başlangıçta şöyledir:
ProductId: 42
AvailableStock: 1İlk request stok değerini okur ve 1 olduğunu görür. İkinci request de ilk işlem güncellemeyi tamamlamadan aynı değeri okur. Her iki request kendi kontrolünü başarıyla geçer.
Ardından ikisi de sipariş oluşturur. Stok alanının nasıl güncellendiğine bağlı olarak sistem negatif stok gösterebilir veya stok sıfır görünürken iki sipariş kabul edilmiş olabilir.
Burada application içindeki lock yeterli değildir. Çünkü request'ler farklı process'lerde, farklı sunucularda çalışabilir. Bir process içindeki lock nesnesi başka process'in execution akışını kontrol etmez.
Bu, concurrency tasarımındaki en önemli sınırlardan biridir: synchronization mekanizmasının kapsamı, korumaya çalıştığımız verinin bulunduğu sistemin kapsamıyla uyuşmalıdır.
Bu senaryoda veritabanı seviyesinde atomic update kullanılabilir:
UPDATE Products
SET AvailableStock = AvailableStock - 1
WHERE ProductId = 42
AND AvailableStock > 0;Ardından etkilenen satır sayısı kontrol edilir. Bir satır güncellendiyse stok azaltma başarılıdır. Sıfır satır güncellendiyse ürün mevcut değildir veya stok tükenmiştir.
Bu yaklaşımda kontrol ve güncelleme aynı SQL statement içinde yer alır. Veritabanının transaction ve locking mekanizmaları, aynı satır üzerindeki çakışan güncellemelerin tutarlı biçimde değerlendirilmesini sağlar.
Ancak sipariş kaydı da aynı iş kuralının parçasıdır. Stok azaltılıp sipariş oluşturulamazsa başka bir tutarsızlık oluşur. Bu nedenle stok güncellemesi ve sipariş kaydı çoğu ilişkisel veritabanı tasarımında aynı transaction içinde yürütülmelidir.
Burada optimistic concurrency ile pessimistic concurrency ayrımı ortaya çıkar.
Optimistic concurrency, çakışmaların seyrek olacağını varsayar. İşlem mevcut kaydı ve genellikle bir version bilgisini okur. Güncelleme sırasında version değişmemişse işlem başarılı olur; değişmişse conflict tespit edilir.
Örneğin:
UPDATE Products
SET AvailableStock = AvailableStock - 1,
Version = Version + 1
WHERE ProductId = 42
AND AvailableStock > 0
AND Version = 7;Buradaki 7 örnek bir version değeridir. Gerçek uygulamada okunan kayıtla birlikte taşınır. Güncellenen satır sayısı sıfırsa işlem yeniden değerlendirilir; körlemesine tekrar denemek her zaman doğru değildir.
Pessimistic concurrency ise çakışmayı önceden sınırlamayı tercih eder. Örneğin transaction sırasında ilgili satır kilitlenebilir ve rakip işlemlerin belirli erişimleri bekletilebilir. Kullanılacak SQL ve kilitleme davranışı veritabanına ve isolation level'a bağlıdır.
Optimistic yaklaşım beklemeyi azaltabilir, ancak conflict durumunda retry veya yeniden karar verme gerektirir. Pessimistic yaklaşım çakışma yönetimini kolaylaştırabilir, fakat lock süresini uzatıp throughput'u düşürebilir.
Stok gibi yüksek rekabet içeren kayıtlarda tek bir koşullu atomic update çoğu zaman genel amaçlı application lock kullanımından daha uygun bir başlangıçtır. Fakat ödeme servisleri, mesaj kuyrukları ve harici API çağrıları aynı transaction sınırına doğal olarak girmez.
Ödeme için ayrıca idempotency gerekir. Idempotency, aynı mantıksal isteğin tekrar gelmesinin ikinci bir iş etkisi oluşturmamasıdır. Örneğin aynı sipariş için tekrarlanan ödeme isteği, kalıcı olarak kaydedilmiş bir idempotency key üzerinden eşleştirilebilir.
Bu özellik synchronization yerine geçmez. Network timeout nedeniyle tekrar gönderilen bir request, hiçbir thread race condition oluşmasa bile ikinci kez işlenebilir.
Güvenli concurrency için önce sınırı seçin
Lock kullanımı her zaman güvenli ilerleme anlamına gelmez. İki thread farklı lock'ları ters sırayla almaya çalışırsa deadlock oluşabilir.
Örneğin ilk thread önce lockA, sonra lockB almaya çalışırken ikinci thread önce lockB, sonra lockA alıyorsa her ikisi de diğerinin bırakacağı lock'ı bekleyebilir. Bekleme döngüsü kırılmadığı sürece işler ilerlemez.
Bunu önlemenin temel yollarından biri bütün execution akışlarında tutarlı lock ordering uygulamaktır. Birden fazla lock gerekiyorsa hangi lock'ın önce alınacağı açık bir kurala bağlanmalıdır.
Starvation farklıdır. Bir iş deadlock içinde olmayabilir fakat sürekli başka işlere öncelik verildiği için ihtiyaç duyduğu kaynağa uzun süre erişemeyebilir. Priority scheduling, yoğun lock rekabeti veya adil olmayan synchronization politikaları starvation üretebilir.
Bu nedenle sistemin hata vermemesi tek başına yeterli değildir. İşlerin makul sürede ilerlemesi de tasarımın parçasıdır. Timeout, cancellation ve bekleme süresi ölçümleri burada işe yarar; fakat timeout tek başına tutarlılık sağlamaz.
Synchronization ihtiyacını azaltmanın etkili yollarından biri immutable objects kullanmaktır. Immutable object oluşturulduktan sonra durumu değişmeyen nesnedir. Birden fazla thread aynı immutable veriyi güvenle okuyabilir; çünkü ortada yarışılacak bir değişiklik yoktur.
Örneğin ürün fiyatlandırma kuralları immutable bir snapshot olarak tutulabilir. Her request aynı snapshot üzerinden hesaplama yapar. Yeni kurallar geldiğinde mevcut nesneyi değiştirmek yerine yeni snapshot oluşturulur.
Bunun da sınırı vardır. Yeni snapshot'ın diğer thread'lere güvenli biçimde yayımlanması gerekir. Ayrıca immutable bir ürün nesnesi kullanmak, veritabanındaki stok güncellemesini atomic hale getirmez.
Pratik kararım şu: Önce shared mutable state miktarını azaltırım. İş bağımsızsa immutable veriyle çalıştırırım. Tek değişkenlik atomic işlem için Interlocked, aynı process içindeki kısa critical section için lock, sınırlı eşzamanlı erişim için semaphore kullanırım. Ortak collection gerekiyorsa concurrent collections seçerim; fakat collection'ın koruduğu alanın dışındaki business operation için ayrı bir tutarlılık mekanizması tasarlarım.
Veri birden fazla application instance tarafından değiştiriliyorsa tutarlılığı verinin sahibi olan sistemde kurarım. İlişkisel veritabanında bunun karşılığı çoğu zaman transaction, conditional update veya version kontrolüdür. Sadece application seviyesindeki mutex ya da lock ile distributed veri bütünlüğü sağlanamaz.
Bu öneri, tek bir tutarlılık otoritesi bulunduğu sürece geçerlidir. Bir işlem birden fazla bağımsız veritabanına veya harici servise yayıldığında tek transaction yeterli olmayabilir. O durumda idempotency, durable messaging, outbox pattern veya saga gibi mekanizmalar gerekir; bunlar da başarısızlığın nasıl telafi edileceğini açıkça tanımlamalıdır.
Son olarak parallelism eklemeyi bir doğruluk çözümü olarak görmemek gerekir. Daha fazla thread veya CPU core, yanlış synchronization tasarımını düzeltmez. Hatta daha fazla execution overlap yaratarak daha önce nadiren görülen race condition'ları görünür hale getirebilir.
Doğru sıra, önce hangi verinin kim tarafından değiştirilebildiğini belirlemek, ardından bu değişikliklerin atomic sınırını kurmak ve ancak sonra concurrency seviyesini artırmaktır. Performans testleri throughput ve latency'yi ölçer; concurrency doğruluğu ise ayrıca conflict testleri, kontrollü eşzamanlı istekler ve hata senaryolarıyla doğrulanmalıdır.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.