await Satırının Altında: Swift Concurrency Gerçekte Nasıl Çalışıyor?
Bir await satırı yazmak bir saniye sürer. O satırın altındaki mekanizmayı anlamak ise eşzamanlı kod hakkındaki sezgini kökten değiştirir. State machine'ler, heap'teki gizli çerçeveler ve 'askıya alma' ile 'bloklama' arasındaki o ince ama her şeyi belirleyen fark — hepsi bu yazıda.

Masum bir satır
let user = await loadUser() — sıradan bir fonksiyon çağrısına benziyor. Ama await, basit bir anahtar kelime değil; bir kapı. O kapının ardında derleyici senin fonksiyonunu parçalara böldü, durumunu RAM'in bambaşka bir bölgesine taşıdı ve üzerinde çalıştığın thread'i bir başkasına devretmeye hazırlandı. Bu yazı o görünmeyen mekanizmayı — state machine dönüşümünü, heap'teki async frame'i, suspend ile block farkını — baştan sona açıyor. Örnekler Swift; ama mekanizma C#, Rust ve Kotlin'de kavramsal olarak aynı.
Neden beklerken thread'i bloklayamayız
Bir thread'i (üzerinde sırayla kod koşan yürütme hattı) "tek elli bir işçi" gibi düşün. Pahalıdır: her biri megabaytlarca bellek tutar, aralarında geçiş maliyetlidir ve aynı anda gerçekten paralel koşabilen sayı CPU çekirdeğin kadardır.
Mobil ve sunucu kodu ise zamanının çoğunu beklemekle geçirir: ağ, disk, veritabanı. Klasik yöntem, beklerken thread'i bloklamaktır — durdurup işin bitmesini beklemek. Sorun şu ki bloklanan thread meşgul sayılır: hiçbir iş yapamaz, sadece boş boş bekler. Her bekleyen iş için bir thread bloklarsan, binlerce eşzamanlı istek binlerce ölü thread'e dönüşür; bellek şişer, zamanlayıcı tıkanır. async/await'in tek amacı bu: beklerken thread'i serbest bırakmak.
Büyük fikir: async bir state machine'dir
Bir fonksiyonu async yapınca, derleyici onu sıradan bir fonksiyon gibi derlemez. Onu, her await noktasından bölünmüş bir state machine'e — sonlu sayıda durumu olan, durumdan duruma geçen bir yapıya — çevirir.
func loadUser() async -> User {
let data = await fetchData() // durak 1
let user = await parse(data) // durak 2
return user
}
Bu üç parçaya bölünür: baştan fetchData'ya kadar (S0), fetchData döndükten sonra parse'a kadar (S1), parse döndükten sonra return (S2). Her await bir suspension point (askıya alma noktası): fonksiyonun thread'i bırakıp sonra kaldığı yerden devam edebildiği yer. Zihinsel model olarak şöyle düşün:
switch state {
case 0: data hazırla; state = 1; if suspend { return } // thread'i bırak
case 1: user = parse; state = 2; if suspend { return }
case 2: return user
}
Buradaki return "bittim" değil, "şimdilik askıdayım, beni resume ile geri çağır" demek. C# ve Rust async'i tam olarak bu switch'e derler; Swift aynı davranışı fiziksel olarak ayrı "devam fonksiyonlarına" bölerek (LLVM coroutine splitting) yapar.
Durum nerede yaşıyor? Stack'in iki kuralı
Sıradan bir fonksiyonun local değişkenleri stack'te (yığın — çağrıların hızlı, otomatik çalışma alanı) bir çerçevede durur ve fonksiyon dönünce yok olur. Bu işler, çünkü senkron fonksiyon tek thread'de, kesintisiz çalışır.
Ama stack'in iki demir kuralı var: (1) o stack o thread'e aittir, (2) çerçeve "dönünce" ya da thread başka işe geçince yeri silinip üstüne yazılır. Async fonksiyon await'te tam da bunları çiğner: thread'i bırakır ve belki başka bir thread'de devam eder. O yüzden suspension'ı aşan durum stack'te tutulamaz.
Çözüm, durumu heap'te (öbek — uzun ömürlü bellek) yaşayan bir async frame'de saklamaktır. "Heap pahalı" diye düşünme: bu normal malloc değil; her görevin (Task) kendine ait, stack disipliniyle (LIFO) çalışan ucuz bir tahsisçisi var — neredeyse işaretçiyi ilerletmek kadar hafif. Az thread + ucuz frame = on binlerce eşzamanlı bekleyen iş.
En ince detay: hangi local hayatta kalır?
İşte çoğu kişinin bilmediği kısım. Async frame, bütün local'lerini saklamaz — sadece bir await'i aşanları, yani askıya alındıktan sonra hâlâ kullanılacak olanları.
func loadProfile(id: Int) async -> Profile {
let token = makeToken(id) // senkron
let user = await fetchUser(id, token) // await #1
let avatar = await fetchAvatar(user.url) // await #2
return Profile(user: user, avatar: avatar)
}
| değişken | hangi await'i aşar? | frame'de saklanır mı? |
|---|---|---|
id, token | hiçbiri (satır 2'de tüketilir) | hayır |
user | await #2 (return'de lazım) | evet |
avatar | hiçbiri (sonra suspend yok) | hayır |
Yani await #1'de frame'de senin hiçbir değişkenin yoktur; await #2'de yalnızca user vardır. Kural net: bir local bir await'i aşıyorsa frame'e konur, aşmıyorsa konmaz. Bunun yanında frame her durakta birkaç "defter" alanı taşır: nereden devam edeceğini söyleyen continuation (resume), seni await eden çağırana işaretçi (caller link), döneceğin bağlam (executor) ve beklenen sonucun yazılacağı boşluk (result slot).
Suspend ≠ Block
Tüm konunun kalbi bu ayrım:
- Block: thread durur ama meşgul kalır; başka iş yapamaz, boşa bekler.
- Suspend: fonksiyon durur ama thread serbest kalır; başka işlere koşar.
await = thread'i ödünç ver, gerekince geri al — bloklamak değil, paylaşmak.
Bunu mümkün kılan altyapı, sabit boyutlu bir cooperative thread pool'dur (kabaca çekirdek başına bir thread). Tek bir sözleşmesi vardır: bu thread'leri asla bloklama. Thread.sleep, senkron blocking I/O veya kilit beklemesi bu sözleşmeyi bozar ve havuzu kilitler; await, Task.sleep ve async I/O ise thread'i serbest bırakarak kurala uyar.
Bedavaya gelen armağan: stack trace
Bütün bu tasarımın güzel bir yan etkisi var. Callback (closure) tabanlı eski kodda, sonuç geldiğinde callback çalışırken onu başlatan çağrı zinciri stack'ten çoktan silinmiştir; bu yüzden hata anında "beni kim çağırdı" göremezsin, stack trace kopuktur. (async/await'in dile eklenme gerekçelerinden biri de buydu.)
Async frame'de ise her frame, caller link ile çağıranının frame'ine bağlıdır. Bu zincir, mantıksal çağrı yığınının heap'teki bir kopyasıdır. Thread'in stack'i await sınırında boşalsa bile, runtime bu zinciri yürüyerek düzgün bir async stack trace üretebilir. Yani "durumu nereye koyalım" sorusunun cevabı (async frame zinciri), aynı anda "temiz backtrace'i nasıl kurarız" sorusunun da cevabıdır.
İki ek incelik: resume, doğru executor'da olur — bu yüzden @MainActor bir fonksiyonda await sonrası main thread'e dönmen garantidir. Ve actor'lar reentrant olduğu için, sen askıdayken aynı actor'da başka bir çağrı araya girebilir; await'ten önce doğruladığın bir varsayım sonrasında geçerli olmayabilir — kritik durumu await sonrası tekrar kontrol et.
Zihinsel model
Tek cümleye sığdırırsak: async, derleyicinin her await'ten bölüp durup-devam edebilen bir makineye çevirdiği fonksiyondur; durumu stack'te değil heap'teki ucuz async frame'de yaşar; await thread'i bloklamaz, serbest bırakır; ve bu serbest bırakma sayesinde tek bir çekirdek, binlerce bekleyen işi taşıyabilir. Bir daha await yazdığında, o satırın altında dönen makinenin tamamını görüyor olacaksın.