C# ile Paralel Programlama-2 (Task Senkronizasyonu ve Veri Paylaşımı)

İlker Erhalim
4 min readJan 5, 2021

Tasklerin eş zamansız çalıştığını ve hangi taskin daha önce tamamlanacağını bilmiyoruz, bir geliştirici için bu senaryo başlı başına bir problemken bir de ne zaman çalışacağını bilmediğiniz iki ayrı task içerisinde aynı veriye erişme ihtiyacını düşünün.

Başlamadan önce bahsetmek istediğim iki terim var.

  • Atomic Operation
  • Critical Section

Atomic Operation

Antik Yunanca’daki ‘atomos’ kelimesinden gelir. Kesilemez/bölünemez anlamına gelen bu sözcük yazılımda da tam olarak bu durumu ifade etmek için kullanılır.

Bir işlem yapılırken araya başka bir işlemin girmesi mümkün değilse bu bir atomik operasyondur.

value = 5; // Bir atomik operasyondur, bu tanım yapılırken araya başka bir işlemin girmesi mümkün değildir.value++; value+= 5; // işlemleri atomik operasyon değildir. Bunun sebebi atama işleminin iki adımda yapılmasıdır.{ 
int temp = value+5;
value = temp;
}
// Bu iki satır arasında başka bir işlem yapılabilinir.

Senkron bir programda çalışıyorken value +=5 atamasının detayı önemsizdir, ancak aynı anda aynı değişkene erişen birden fazla iş parçacığı olduğunda yukarıdaki kadar basit bir operasyonda dahi karmaşa çıkar.

Atomik Operasyonlar

  • Referans atamaları
  • 32 bit sistemlerde 32 bit veya daha küçük değer okuma/yazma işlemleri
  • 64 bit sistemlerde 64 bit veya daha küçük değer okuma/yazma işlemleri

Farklı taskler içerisinde, aynı değişkene, atomik operasyon harici bir işlem uygulanması programın sağlıklı çalışmasını engeller.

Yukarıdaki örnekte ana program üzerinde tanımlanmış olan bir değişkene (number) iki farklı task içerisinde atomik olmayan değer atamaları yapıyoruz.

15 farklı task içerisinde 20 kere sayının değeri 1 arttırıldı, 20 kere 1 azaltıldı. Ancak uygulama her çalıştırıldığında çıkan sonuçlar farklı olabilir.

Ben denediğimde aldığım çıktılar 24, -3, -22, -11 oldu.

Bunun sebebi number++ işleminin atomik olmamasıdır. Değişkenin yeni değeri hesaplanıp geçici bir değişkene atanırken diğer task tarafından değeri değişitirilmiş olabilir.

Critical Section

Farklı taskler içerisinde aynı kaynaklara erişilen alanlara critical section adı verilir. Critical section implementasyonlarının genel mantığı bir task bir kaynağa erişirken diğer task(ler) o kaynağa erişmek için bekler.

Taskleri eş zamanlı çalıştırmanın ihtiyaca göre kullanılabilinecek birden fazla yolu vardır.

Lock objesini temsil eden bir görsel (Ahşap üzerinde paslanmış bir kilit)
Pixabay adlı kişinin Pexels’daki fotoğrafı

Lock/Monitoring

Bu yöntemde, bir değer farklı taskler içerisinde kullanılırken, dummy bir obje oluşturulup lock scope’u içerisinde işlem yapılır.

lock(dummyObject)
{
...
}

Bir önceki örneği yukarıdaki gibi düzenlediğinizde sonucun her zaman 0 olduğunu göreceksiniz. Bunun sebebi, task’lerden biri locker isimli objeyi kilitlediğinde diğer task işleme devam etmek için kilidin açılmasını bekler.

Lock keywordü Monitor implementasyonunun basitleştirilmiş halidir. Lock kullanımının karşılığı aşağıdaki gibidir.

try
{
Monitor.Enter(locker);
}
finally
{
Monitor.Exit(locker);
}

Monitor sınıfı ile çalışıldığında maximum bekleme süresi(timeout) tanımlanabilinir. Critical section içerisindeki iş yükü azaldığında diğer tasklere bilgi verip daha hızlı çalışmaları sağlanabilir.

SpinLock (BusyWaiting)

Standart bir critical section implementasyonunda işletim sistemi threadi wait-state moduna alır ve o çekirdek üzerinde başka bir threadin koşmasına izin verir, eğer bekleme süresi çok kısa ise bu işlem verimliliğini olumsuz etkiler çünkü çalışmaya devam etmesi gereken threadin tekrar ayağa kalkması gerekecektir.

SpinLock bekleme işlemini bir döngü ile yapar ve çekirdek başka bir threadi çalıştıramaz, ancak sizi preemtion maliyetinden kurtarır.

Modern işletim sistemlerinde Monitor implemantasyonu hybrid olarak yapılır, bir süre busy waiting yapıldıktan sonra thread core waiting e geçer.

İlk örneğin SpinLock kullanarak implementasyonu…

ReaderWriterLock

Bir kaynağa yalnızca okuma ya da yazma işlemi için erişilirken kullanılır, Reader locklar birbirilerini kilitlemezler ancak writer lock diğerlerini bekletir. Olduçka basit bir implementasyonu vardır.

Bir read locktan çıkış yapmadan bir write lock oluşturulamaz, ancak genel olarak okuma işlemi yapacak olan ve belli koşullarda yazma işlemi yapacak olan bir task için UpgradeableReadLock oluşturulabilinir.

Interlocked

System.Threading namespace te yer alan bu sınıf yardımıyla herhangi bir lock kullanmadan primitive tiplere müdahale edebiliriz.

lock örneğindeki lock(locker){...} kısımlarını silip;


// Sayıynın arttırılmak istenildiği yeri
Interlocked.Increment(ref number);
// Sayının azaltılmak istenildiği yeri
Interlocked.Decrement(ref number);

Olacak şekilde güncellediğinizde sonuçta bir değişiklik olmadığını göreceksiniz. Ancak program artık lock-free bir program. Interlocked içerisindeki metodlar yaptıkları işleri atomik seviyelerde yaparlar ve herhangi bir şekilde taski teorik olarak kilitlemezler.

Ancak pratikte işler değişebilir; lock-free cezbedici bir kavram olsa da arka planda işlem yapıldıktan sonra verilen değerin değişip değişmediği kontrol edilir ve eğer bir değişiklik varsa işlem tekrar yapılır.

Interlocked metotları ile yapabileceğiniz atomik operasyonlar.

  • Add: Referansı verilen sayıya ikinci parametrede verilen sayıyı ekler, çıkarma işlemi yapmak için negatif değer verilebilinir.
  • CompareExchange: İkinci ve üçüncü parametre eşit ise referansın değerini eşit olan parametre olarak olarak günceller
  • Increment: Referansı verilen sayıyı bir arttırır.
  • Decrement: Referansı verilen sayıyı bir azaltır.
  • Exchange: Referansı verilen değişkeni ya da instance ı ikinci parametredeki değer ya da instance olarak değiştirir.

Mutex(Mutual Exclution)

Bazı ek özellikleri dışında Monitör ile benzer bir yapıdadır. Monitor process bazlı çalışırken Mutex processler arası çalışabilir (birden fazla uygulamanın aynı kaynağa eriştiği senaryolarda) ve birden fazla mutex objesi mergelenip eş zamanlı çalıştırılabilinir.

Mutex mutex = new Mutex();
Task.Run(() =>
{
bool lockTaken = mutex.WaitOne();
try
{
// İşlem
}finally
{
if (lockTaken)
{
mutex.ReleaseMutex();
}
}
});

Birden fazla Mutex’in mergelenmesi.

İki mutex ile iki ayrı kaynak üzerine erişim sağyalan birden fazla taskimiz olduğunu hayal edelim ve bu iki kaynak üzerinde hesaplama yapan başka bir taskimiz daha olsun. Bunun gibi senaryolarda WaitHandle sınıfındaki WaitAll metodu ile iki kaynak için bir lock oluşturabiliriz.

bool lockTaken = WaitHandle.WaitAll(new[] { mutex1, mutex2 });
Yukarıdaki örnekte;
On farklı task içerisinde
player1 e bin defa 1 Coin ekleniyor.
player2 ye beş yüz defa 1 Coin ekleniyor.
player1 den player2 ye beş yüz defa 1 Coin transfer ediliyor.

Bu tasklerin tamamı aynı anda anda başlayacaktır, ancak player1Mutex ve player2Mutexleri kilitli olduğu sürece transfer işlemi bekleyecektir. Yani bir player a coin ekleme işlemi yapılırken aynı anda aktarma işlemi yapılmayacaktır.

Mutexin processler arası paylaşım özelliği kullanılarak uygulamanın cihaz üzerinde iki kere çalışması engellenebilinir. Bunun için Mutex sınıfı içerisinde static tanımlanmış olan OpenExisting methodu kullanılır, mutex daha önce oluşturulmadıysa WaitHandleCannotBeOpennedException fırlatacaktır, bu exceptionı yakaladığımızda mutexin diğer uygulamada zaten tanımlanmış olduğunu anlayabiliriz.

Yukarıdaki örnek üzerinden build aldıktan sonra oluşan .exe yi iki kere çalıştırdığınızda ilk exe için “Program…” çıktısını alacaksınız, ancak ikinci .exe de “ExampleApp is running in another windows.” çıktısı alınacaktır.

Mutex ile aynı dosyaya farklı uygulamalardan erişme örneği için bir sonraki yazımı okuyabilirsiniz.

--

--