C#: Concurrency ve Parallelism Nedir? Thread Security Nedir?

Murat Dinç
Devops Türkiye☁️ 🐧 🐳 ☸️
6 min readAug 13, 2023

Selamlar,

Modern yazılım geliştirmede, çoklu işlemcili sistemlerde verimli çalışmak önemlidir. Concurrency ve Parallelism kavramları, bu bağlamda büyük önem taşır. Bu iki terim, farklı anlamlara gelmesine rağmen, bazen birbirlerinin yerine kullanılırlar. Ancak, bu iki kavramı ayırt etmek önemlidir 👇🏻

Concurrency

Concurrency, birden fazla işin aynı zaman diliminde gerçekleştirilmesini ifade eder. Bu, bu işlerin mutlaka aynı anda çalıştığı anlamına gelmez. Concurrency, özellikle tek bir işlemcinin bulunduğu sistemlerde, işlerin ara verilerek sırayla çalıştırılmasını ifade edebilir.

Bu, genellikle birden fazla işlemin birbiri ardına hızla geçiş yaparak gerçekleştirildiği, böylece kullanıcıya aynı anda gerçekleşiyormuş gibi bir izlenim veren bir durumu ifade eder.

Örnek olarak, bir web sunucusu düşünelim. Birden fazla kullanıcı aynı anda web sayfasına girmek ister ve sunucu bu taleplere sırayla cevap verir. Bu durumda farklı kullanıcılar arasında geçiş yaparak eşzamanlılık sağlanır.

public class Program
{
static void Main()
{
new Thread(DoTaskOne).Start();
new Thread(DoTaskTwo).Start();
}

static void DoTaskOne()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Task One: " + i);
Thread.Sleep(100);
}
}

static void DoTaskTwo()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Task Two: " + i);
Thread.Sleep(150);
}
}
}

Bu örnek üzerinde yer alan iki farklı Thread eş zamanlı olarak tetiklenecek olup birbirlerini beklemeden çalışacaklardır.

Parallelism

Parallelism ise birden fazla görevin veya işlemin aynı anda, gerçek zaman diliminde farklı işlemci çekirdeklerinde veya paralel kaynaklarda çalıştığı bir durumu ifade eder. Paralel işleme, aynı anda birden fazla işlemci çekirdeği veya işlem birimi kullanarak işleri paralel olarak yürütmeyi amaçlar.

Örnek olarak, bir image processing uygulamasını düşünelim. Bir resmin farklı bölümlerinin ayrı ayrı işlenerek sonuçlarının birleştirilmesi gerekebilir. Bu durumda farklı işlemci çekirdekleri veya işlem birimleri kullanılarak paralel işleme yapılabilir.

public class Program
{
static void Main()
{
Parallel.Invoke(DoTaskA, DoTaskB);
}

static void DoTaskA()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Task A: {i}");
Task.Delay(100).Wait();
}
}

static void DoTaskB()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Task B: {i}");
Task.Delay(150).Wait();
}
}
}

Bu örnek üzerinde Parallel.Invoke tetiklenerek DoTaskA ve DoTaskB görevlerinin paralel olarak çalıştırılması sağlanmaktadır.

Döngülerde Parallelism

Bu örnekte, Parallel.For metodu sayesinde 0'dan 9'a kadar olan değerler üzerinde eşzamanlı olarak çalışan 10 adet görev oluşturuluyor. Bu örnek, paralel işlem yaparak işlemciden daha etkili bir şekilde yararlanmayı gösterir.

using System;
using System.Threading.Tasks;

public class Program
{
static void Main()
{
Parallel.For(0, 10, i =>
{
Console.WriteLine($"Task {i} is running.");
});

Console.WriteLine("All tasks are completed.");
}
}

Asenkron İşlemlerde Parallelism

Asenkron işlemler ve Parallelism, bir kodun aynı anda birden fazla işi yapmasını sağlayan iki farklı yaklaşımdır. Ancak, bu iki yaklaşımın amacı ve kullanım şekli farklıdır. Bununla birlikte, asenkron işlemlerle birlikte Parallelism kullanarak çok daha hızlı ve etkili bir performans elde edebiliriz.

public class Program
{
static async Task Main()
{
Console.WriteLine("Starting...");

var task1 = ProcessAsync(1);
var task2 = ProcessAsync(2);

await Task.WhenAll(task1, task2);

Console.WriteLine("Completed");
}

static async Task ProcessAsync(int id)
{
await Task.Delay(id * 1000);

Console.WriteLine($"Task {id} completed");
}
}

Birden fazla asenkron işlemi paralel olarak çalıştırmak için Task.WhenAll kullanabilirsiniz.

Linq Sorgularında Parallelism

Sorgunun çalışma süresini azaltabilir, ancak büyük veri kümeleri ve karmaşık sorgular durumunda kullanımı dikkatli olunmalıdır. Paralel işlem yapmak, data shredding ve transaction coordination gibi zorlukları beraberinde getirebilir.

Bu örnekte, numbers adlı bir dizi oluşturuyoruz ve bu dizi üzerinde paralel LINQ sorgusu kullanıyoruz. AsParallel() ifadesi, LINQ sorgusunun paralel olarak çalıştırılmasını sağlar. Ardından, çift sayıları seçip karesini alan bir sorgu gerçekleştiriyoruz.

public class Program
{
static void Main()
{
int[] numbers = Enumerable.Range(1, 1000000).ToArray();

var result = numbers.AsParallel()
.Where(num => num % 2 == 0)
.Select(num => num * num)
.ToArray();

Console.WriteLine($"Result count: {result.Length}");
}
}

Performans Karşılaştırması

Örnek için, 550.000.000 sayısı üzerinde bazı hesaplamalar yapacağız. İlk olarak, bu işlemi sequential bir şekilde gerçekleştireceğiz. Ardından, aynı işlemi paralel olarak gerçekleştirip süreleri karşılaştıracağız.

using System.Diagnostics;

public class Program
{
static void Main()
{
int[] data = Enumerable.Range(0, 550000000).ToArray();

var stopwatch = new Stopwatch();

stopwatch.Start();
SequentialProcess(data);
stopwatch.Stop();

Console.WriteLine($"Sequential: {stopwatch.ElapsedMilliseconds} ms");

stopwatch = new Stopwatch();
stopwatch.Start();
ParallelProcess(data);
stopwatch.Stop();

Console.WriteLine($"Parallel: {stopwatch.ElapsedMilliseconds} ms");
}

static void SequentialProcess(int[] data)
{
foreach (var item in data)
{
Math.Sqrt(item);
}
}

static void ParallelProcess(int[] data)
{
Parallel.ForEach(data, item =>
{
Math.Sqrt(item);
});
}
}

Bu kodu çalıştırdığınızda, paralel işlemenin sıralı işlemeye göre daha hızlı olduğunu görebilirsiniz (elbette bu, kullandığınız donanıma da bağlıdır). Ancak genel olarak, CPU yoğun işlemler için paralel işlemenin önemli bir performans avantajı sağladığını gözlemleyebilirsiniz.

Sequential: 1916 ms
Parallel: 1832 ms

Paralel işlemler her durumda performans artışı sağlamayabilir. Özellikle I/O yoğun işlemler veya belirli cache senaryolarında paralellik bazen performansı olumsuz etkileyebilir.

Thread Security ve DeadLock Durumları

Concurrency ve Parallelism durumlarında thread secutiry ve deadlock sorunları, aynı kaynaklara birden fazla thread veya işlemin aynı anda erişmeye çalıştığı durumlarda ortaya çıkabilir ve bu durum istenmeyen sonuçlara yol açabilir.

Örnekler üzerinde inceleme yaparak Race Condition ve DeadLock durumlarını inceleyelim.

Race Condition

Eğer iki ya da daha fazla iş parçacığı aynı veriye erişmeye çalışırsa ve bu veriyi değiştirmeye çalışırsa, sonuç hangi iş parçacığının önce eriştiğine bağlı olarak değişkenlik gösterebilir.

Örnek: Bir banka hesabında 100 TL para bulunmaktadır. İki farklı iş parçacığı aynı anda 50 TL para çekmeye çalışırsa, her iki işlem de hesaptaki parayı kontrol ettiğinde 100 TL olduğunu görebilir. Her iki iş parçacığı da 50 TL çekerse, aslında toplamda 100 TL çekilmiş olur, fakat hesapta 50 TL olarak kalmış olacaktır.

public class Program
{
static int balance = 100;

static void Main()
{
Thread t1 = new Thread(Withdraw);
Thread t2 = new Thread(Withdraw);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine(balance);
}

static void Withdraw()
{
balance = balance - 50;
}
}

Cüzdan kontrolü neden yapmıyoruz sorusunu sorabilirsiniz fakat bu işlemden önce cüzdan kontrollerinin yapıldığını düşünün ve iki işleminde aynı anda cüzdanı okuduğunda bakiyenin 100 TL olduğunu düşünerek hareket edebilirsiniz.

Bu örnekte, iki farklı thread aynı anda cüzdandan para çekmeye çalışmaktadır. Thread’ler başladığı anda cüzdan bakiyesini 100 TL olarak gördüğü için çekim için engel bir durum bulunmamaktadır.. Bu işlemin sonucunda 50 TL bakiye oluşabilme riski oluşacaktır.

Bu sorunu yaşamamak için mutlaka Lock kullanarak kod güvence altına alınmalıdır. Cüzdan işlem anında kilitlenmeli ve bir sonraki işlem bitene kadar read edilmesine izin verilmemelidir.

class Program
{
static int balance = 100;

static object lockBalance = new object();

static void Main()
{
Thread t1 = new Thread(Withdraw);
Thread t2 = new Thread(Withdraw);

t1.Start();
t2.Start();

t1.Join();
t2.Join();
}

static void Withdraw()
{
lock (lockBalance)
{
balance = balance - 50;

Console.WriteLine($"Balance: {balance}");
}
}
}

Örnek üzerinde yaklaşım gibi bir yaklaşım ile gidilerek Race Condition problemi çözülebilir. Her işlem başladığında cüzdan kilitleniyor ve bir sonraki thread için bekletilerek cüzdan üzerinde işlem yapılması engelleniyor. Böylece bakiye doğru düşülerek cüzdan üzerinde istenmeyen bir durum oluşması engellenecektir.

DeadLock Durumu

İki ya da daha fazla iş parçacığı, birbirlerinin kaynaklarına erişimini beklerken sonsuz bir beklemeye girer ve bu durum Dead Lock olarak adlandırılır.

public class Program
{
static object lockOne = new object();
static object lockTwo = new object();

static void Main()
{
Thread threadOne = new Thread(DoWorkOne);
Thread threadTwo = new Thread(DoWorkTwo);

threadOne.Start();
threadTwo.Start();

threadOne.Join();
threadTwo.Join();

Console.WriteLine("Completed");
}

static void DoWorkOne()
{
lock (lockOne)
{
Console.WriteLine("Thread 1 locked resource 1");
Thread.Sleep(100);
Console.WriteLine("Thread 1 trying to lock resource 2");

lock (lockTwo)
{
Console.WriteLine("Thread 1 locked resource 2");
}
}
}

static void DoWorkTwo()
{
lock (lockTwo)
{
Console.WriteLine("Thread 2 locked resource 2");
Thread.Sleep(100);
Console.WriteLine("Thread 2 trying to lock resource 1");

lock (lockOne)
{
Console.WriteLine("Thread 2 locked resource 1");
}
}
}
}

Bu örnekte, iki farklı kaynağı kilitlemek için lock ifadesi kullanılmıştır. Ancak DoWorkOne ve DoWorkTwo fonksiyonlarının sıralaması nedeniyle iki thread, birbirinin kilidini bekleyerek bir deadlock durumu oluşturur.

Bir sonraki yazıda görüşmek üzere 😊

--

--