Modern işlemciler artık saat hızını sınırsız artırmak yerine birden fazla çekirdekle güçleniyor. Bir uygulamanın bu gücü kullanabilmesi için işleri bağımsız parçalara bölmesi, bu parçaları iş parçacıklarında (thread) çalıştırması ve ortak belleğe erişimi dikkatle düzenlemesi gerekir. Ancak multithreading, uygulamayı otomatik olarak hızlandıran sihirli bir değnek değildir; doğru tasarlanmadığında yarış koşulları, kilitlenmeler ve zor tekrarlanan hatalar üretir. ``
Thread, süreç ve eşzamanlılık
Süreç (process), kendi bellek alanına sahip çalışan program örneğidir. Aynı süreçteki thread’ler ise kodu, heap alanını ve açık kaynakları paylaşır; yalnızca çağrı yığını ve register durumları özeldir. Bu paylaşım, thread oluşturmayı hafifletir ve veri alışverişini hızlandırır. Bedeli ise ortak verinin korunmasıdır.
| Kavram | Bellek modeli | Güçlü yanı | Temel risk |
|---|---|---|---|
| Process | İzole bellek | Hata yalıtımı | IPC maliyeti |
| Thread | Ortak bellek | Hızlı iletişim | Data race |
| Async I/O | Genellikle tek/az thread | I/O verimliliği | Akış karmaşıklığı |
Eşzamanlılık (concurrency), birden çok işin zaman içinde ilerlemesidir. Paralellik (parallelism) ise işlerin gerçekten aynı anda, ayrı çekirdeklerde yürütülmesidir. Tek çekirdekli bir makine eşzamanlı davranabilir; fakat gerçek paralellik için birden fazla yürütme kaynağı gerekir.
Hızlanmanın matematiği: Amdahl kanunu
Programın yalnızca paralelleştirilebilen kısmı hızlanır. Amdahl kanunu, teorik üst sınırı şöyle verir:
\[S(N) = \frac{1}{(1-P) + \frac{P}{N}}\]Burada $P$ paralelleştirilebilir oran, $N$ çekirdek sayısıdır. Örneğin işin %90’ı paralelse ve 8 çekirdek kullanılıyorsa, $S(8) \approx 4.7$ olur; yani sekiz kat değil. Seri bölüm, thread oluşturma maliyeti, cache etkileri ve senkronizasyon bu sonucu daha da düşürebilir. Bu nedenle önce profil çıkarın: darboğaz CPU mu, disk mi, ağ mı, yoksa kilit bekleme süresi mi?
Ortak belleğin tehlikesi: yarış koşulu
Aşağıdaki sayaç artırma işlemi masum görünür; fakat counter++ tek ve bölünemez bir işlem değildir. Okuma, artırma ve yazma adımlarına ayrılabilir.
#include <thread>
#include <mutex>
int counter = 0;
std::mutex counterMutex;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(counterMutex);
++counter;
}
}
int main() {
std::thread a(increment), b(increment);
a.join();
b.join();
}
std::lock_guard, mutex’i kapsam boyunca kilitler ve kapsam bitince otomatik serbest bırakır. Böylece iki thread aynı anda sayaç güncelleyemez. Basit sayaçlar için std::atomic<int> çoğu zaman daha uygun ve daha düşük maliyetli bir seçenektir.
| Araç | Ne zaman tercih edilir? | Dikkat edilmesi gereken |
|---|---|---|
| Mutex | Birden çok veriyi tutarlı güncelleme | Uzun kritik bölge performansı düşürür |
| Atomic | Sayaç, bayrak, tekil atomik durum | Karmaşık invariant’ları korumaz |
| Condition variable | Bir olayın oluşmasını bekleme | Her zaman koşulla birlikte kullanılmalı |
| Semaphore | Sınırlı kaynak havuzu | İzin sayısı doğru yönetilmeli |
Sağlam mimari için pratik ilkeler
Kritik bölgeleri kısa tutun; kilit altındayken ağ isteği, dosya işlemi veya ağır hesaplama yapmayın. Kilitleri birden fazlaysa her yerde aynı sırayla alın; bu, iki thread’in birbirini sonsuza dek beklediği deadlock riskini azaltır. Paylaşılan değişken sayısını azaltmak için mesaj kuyruğu, immutable veri ve iş kuyruğu (thread pool) gibi yaklaşımları değerlendirin.
Son olarak thread sayısını çekirdek sayısının rastgele katı yapmayın. CPU ağırlıklı işler için çekirdek sayısına yakın bir havuz, I/O ağırlıklı işler için ise bekleme sürelerini tolere eden daha geniş bir havuz mantıklıdır. Başarılı multithreading tasarımı, en çok thread’i açmak değil; en az paylaşım ve en net senkronizasyonla doğru işi paralelleştirmektir.
Yorumlar