Minik Swift İpuçları — async defer tuzağı
Bu seride, Swift ile iOS ve MacOS uygulamaları geliştirirken karşılaşılan bazı problemleri kısaca paylaşmayı düşünüyorum. Bu yazı için seçtiğim konu: async defer tuzağı.
iOS 26+ · Swift 6.2+
Derlediğim kaynaklar:
- SE-0493 — Support async calls in defer bodies
- SE-0504 — Task Cancellation Shields
- Swift Forums — SE-0493 Acceptance
- Swift Forums — SE-0504 Acceptance
- Swift Forums — Pitch: Task Cancellation Shields (ktoso)
- Beyond the basics of structured concurrency — WWDC23
- Swift with Majid — Task Cancellation in Swift Concurrency
- Donny Wals — Understanding unstructured and detached tasks
- Matt Massicotte — Problematic Swift Concurrency Patterns
Swift geliştiricileri yıllardır defer bloklarında await kullanamadığı için şikayetçiydi, ancak SE-0493 bunu nihayet çözdü. Artık *async fonksiyonlardaki defer *blokları *await *içerebiliyor. FranzBusch'un Swift developer forumundaki ifadesiyle: "While it doesn't solve the asynchronous resource management problem, I think it addresses an inconsistency in the language."
Topluluk bunu heyecanla karşıladı çünkü artık network kaynakları, dosya handle'ları, veritabanı bağlantıları, artık hepsini *defer *içinde async olarak temizlemek mümkün hale geldi. Ama bu durum sessiz ve tehlikeli bir tuzağı da beraberinde getirdi: async defer, iptal edilen bir task'ta çalıştığında defer kodu sessizce başarısız olabilir.
Ne Değişti?
Swift'in concurrency modelinde iptal cooperative'dir. Paul Hudson'ın ifadesiyle: "Swift's tasks use cooperative cancellation, which means that although we can tell a task to stop work, the task itself is free to completely ignore that instruction." Bu, *Task.checkCancellation() *(hata fırlatır) ve *Task.isCancelled *(boolean kontrol) arasındaki farkı anlamayı kritik kılar.
Senkron defer'in davranışı nettir: fonksiyon ne olursa olsun, hata fırlatsın veya erken dönüş yapsın *defer *bloğu *mutlaka *çalışır. Bu garanti, senkron dünyada kaynak yönetiminin temel taşıdır.
// Senkron defer — her zaman çalışır
func processFile() throws {
let file = openFile()
defer { file.close() } // ✓ Garanti: her zaman çalışır
try doWork(with: file)
}
SE-0493 ile artık aynı pattern'i artık async olarak da kullanabilirsiniz:
// Async defer — Swift 6.2+
func processResource() async throws {
let resource = await acquireResource()
defer { await resource.releaseOnServer() } // Yeni! await kullanılabiliyor
try await doExpensiveWork(with: resource)
}
Bu kod ilk bakışta doğru çalışıyor gibi görünüyor ama burada kritik bir fark var. *async defer *bloğunun içine yazılan kod çalışır. Burası sorun değil. Ancak asıl fark şurada, eğer bu bloğun içinde bir await kullanılıyorsa (yani bir async operasyon varsa), o operasyon task iptal edildiğinde durmayı bilir. Yani task iptal olursa, bekleyen async işlemler de iptal edilir.
Normal (senkron) defer bloğunda ise böyle bir şey yok, içindeki kod task iptal edilse bile sonuna kadar çalışır.
Task İptali ve Sessizce Başarısız Olan Operasyonlar
Swift'in Language Steering grubu, SE-0493 önerisini kabul ederken önemli bir tasarım kararı aldı: *"defer sadece bir syntax kolaylığıdır, içinde çalışan kodun davranışını değiştirmemelidir". *Başka bir deyişle, defer bloğunun içindeki koda özel bir iptal koruması yok. Kod, sanki defer dışında yazılmış gibi aynı kurallara tabi.
*defer *bloğu çalışmaya başlıyor evet. Ama defer bloğunun içindeki *async *çağrılar, eğer task iptal edilmişse, bu iptali gözlemleyip network çağrıları gibi çağrılarda *CancellationError *fırlatabilir. *Task.sleep *erken döner ve cooperative cancellation kullanan tüm API'lar bir kısa devre yapar.
SE-0493 proposal'ı bunu açıkça belirtiyor: "defer'in içinden çağrılan tüm kod, task iptalini gözlemler. Tıpkı aynı kod fonksiyonun ana gövdesinde çağrılsaydı gözlemleyeceği gibi."
func processResource() async throws {
let resource = await acquireResource()
defer {
// ⚠️ Task iptal edilmişse bu network çağrısı
// CancellationError fırlatır — kaynak ASLA serbest bırakılmaz!
await resource.releaseOnServer()
}
try await doExpensiveWork(with: resource) // ← Task burada iptal ediliyor
}
Kullanıcı ekranı kapatır → bu sırada çalışan Task iptal edilir → *doExpensiveWork *iptal hatası fırlatır. Buraya kadar her şey beklenen davranış. Sorun bundan sonra başlıyor: *defer *bloğu çalışmaya başlar ama içindeki *releaseOnServer() *da aynı iptal edilmiş task'ın içinde çalıştığı için bu network çağrısı da sessizce başarısız olur. Sonuç olarak sunucu tarafında ayrılmış olan kaynak asla serbest bırakılmaz.
En tehlikeli kısım ise ortada bir crash ya da hata mesajının olmayışı. Sunucuda biriken serbest bırakılmamış kaynaklar yavaş yavaş bir bellek sızıntısına *(memory leak) *yol açabileceği gibi, bu durumu tespit etmek de bir hayli zor olacaktır.
Production'da Ne Olur?
Bu pattern özellikle şu senaryolarda tehlikelidir:
- Sunucu tarafı kaynak kilitleri: Bir API içerisinde bulunan bir kaynağı kilit altına alır, işlem bitince kilidi async çağrıyla serbest bırakırsınız. Task iptal edilirse kilit sonsuza kadar kalabilir ve sunucu tarafında deadlock oluşabilir.
- Geçici dosya temizliği: Sunucudaki depolamaya yüklenen geçici dosyalar *
defer*içinde async olarak silinir. İptal durumunda dosyalar silinmez, sunucudaki depolama maliyeti birikir. - Veritabanı transaction'ları: Bir transaction açılır, *
defer*içinde async *commit/rollback *yapılır. İptal durumunda transaction açık kalır ve bu yüzden bağlantı havuzu *(connection pool) *tükenir.
Bu senaryoların ortak noktası sorunun anında ortaya çıkmayışıdır. Kaynak sızıntısı saatler, günler sonra, uygulamanın trafiği arttıkça veya sunucu limitlerine ulaşılınca kendini gösterir.
Neden Sadece Unstructured Task Kullanamıyoruz?
Bu problemle karşılaşınca akla ilk olarak hızlı bir çözüm gelebilir: *defer *içinde *Task { } *ile yeni bir unstructured task oluşturmak. Ama FranzBusch (Swift server team) bu yaklaşımın neden yetersiz olduğunu üç maddede açıklıyor:
- Temizlenecek kaynak
Sendableolmayabilir — yeni task'a aktaramazsınız. - Senkron kod: Senkron fonksiyonlar da iptal korumasına ihtiyaç duyabilir, ama unstructured task (Task ) sadece async bağlamda oluşturulabilir.
- Gereksiz maliyet: Her defer bloğu için yeni bir child task oluşturmak ekstra scheduling overhead getirir.
Çözüm: Task Cancellation Shield (SE-0504)
SE-0504, tam da bu boşluğu dolduruyor. Bu önerinin ana amacı şöyle: *"There is no great way to ignore cancellation, and some pieces of code may therefore by accident not execute to completion. This is especially problematic in clean-up or resource tear-down." withTaskCancellationShield *bloğu, içindeki kodun task iptalini *gözlemlememesini *sağlar:
func processResource() async throws {
let resource = await acquireResource()
defer {
// ✓ Shield içinde Task.isCancelled = false döner
// Network çağrıları iptalden etkilenmez
await withTaskCancellationShield {
await resource.releaseOnServer()
}
}
try await doExpensiveWork(with: resource)
}
Language Steering grubunun bu öneriyi kabul ederkenki en önemli gerekçesi ise şu; iptal aslında hemen gerçekleşir, sadece shield'ın içindeki kod bunu göremez.
Yani withTaskCancellationShield iptali engellemez sadece gizler.
Shield aktifken:
- *
Task.isCancelled*her zaman *false*döner. - *
Task.checkCancellation()*hata fırlatmaz. - İçerideki async operasyonlar, sanki task hiç iptal edilmemiş gibi çalışır.
*defer bloğundan çıkıldığında iptal durumu tekrar görünür hale gelir. Yani shield sadece kendi scope'u boyunca etkilidir.
Language Steering grubu ayrıca hasActiveTaskCancellationShield property'sini UnsafeCurrentTask'tan Task'a taşıdı. Bu sayede shield'ın aktif olup olmadığını kontrol etmek artık daha kolay:
if Task.hasActiveTaskCancellationShield {
// shield aktif, iptal gözlemlenmeyecek
}
Shield'ın Getirdiği Problemler
Shield kullanırken de dikkatli olunması gereken birkaç davranış var.
- Child task'lar otomatik olarak korunmaz:
// ⚠️ YANLIŞ: Shield addTask çağrısını korur, child task'ın gövdesini değil!
defer {
await withTaskCancellationShield {
await withDiscardingTaskGroup { group in
group.addTask { await cleanup() } // ← Bu hâlâ iptali gözlemler
}
}
}
// ✓ DOĞRU: Shield child task'ın içinde olmalı
defer {
await withDiscardingTaskGroup { group in
group.addTask {
await withTaskCancellationShield {
await cleanup()
}
}
}
}
2. Task.isCancelled *bağlama göre farklı sonuç verir:
let task = Task {
await withTaskCancellationShield {
print(Task.isCancelled) // false (shield aktif)
}
}
task.cancel()
print(task.isCancelled) // true (dışarıdan bakınca gerçek durum)
Aynı property, çağrıldığı yere göre farklı yanıt verir. Static *Task.isCancelled shield'ı dikkate alır; instance task.isCancelled *almaz.
- Shield içinde kendini iptal eden bir task gizlenir:
await withTaskCancellationShield {
withUnsafeCurrentTask { $0?.cancel() }
print(Task.isCancelled) // false — ⚠️ iptal gizleniyor!
}
// Buradan sonra Task.isCancelled = true
Ne Zaman Shield Gerekir?
Basit bir kural: *defer *bloğunuzdaki async çağrı *yan etki *içeriyorsa, *sunucuya istek, dosya silme, transaction kapatma v.b. *shield kullanın. *defer içinde sadece bellek temizliği veya loglama gibi operasyonlar varsa shield gereksiz olabilir.
// Shield GEREKMEZ — memoryde yapılan işlem
defer { cache.removeAll() }
// Shield GEREKMEZ — iptal edilse de önemli olmayan log
defer { await logger.flush() }
// Shield GEREKİR — sunucu tarafı kaynak serbest bırakma
defer {
await withTaskCancellationShield {
await server.releaseResource(id)
}
}
SE-0493 ve SE-0504 birbirini tamamlayan iki Swift gelişim önerisi olarak karşımıza çıktı. Ama derleyici bu konuda bizi bu iki öneriye dair uyarmıyor.
Özetle *defer *içinde *await *yazabiliyor olmak, *defer *kodunun tamamının garanti olarak çalışacağı anlamına gelmez. Ve async defer, senkron defer'in davranış garantisini taşımaz.
Task iptali, *defer *bloğunun başlamasını engellemez ama içindeki cooperative async operasyonlarını sessizce kısa devre yaptırır.
Production'da bellek sızıntısı istemiyorsanız, kritik async *defer *kodunu her zaman *withTaskCancellationShield *bloğunun içine alın.
Ek Okuma
- Paul Hudson — How to cancel a Task
- John Sundell — Using defer within async and throwing contexts