Command Palette

Search for a command to run...

Minik Swift İpuçları: Sahipsiz (Orphan) Task Tuzağı

Yayınlama tarihi:
Okuma süresi:10 dk okuma

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: Sahipsiz (Orphan Task Tuzağı).

Kategori: Swift / Performance

Okuma Süresi: ~10 dakika

İçinde arama çubuğu olan bir Swift uygulaması geliştirdiğimizi hayal edelim. Kullanıcı bu arama çubuğuna bir şeyler yazarken, her harfi yazdığında uygulamamızın hemen arama yapmasını programatik olarak istemeyiz çünkü bu çok fazla gereksiz ağ isteği (network call) anlamına gelir. Bunun yerine, kullanıcı yazmayı bitirene kadar biraz beklemeyi ve sonra ağ çağrısı yapmayı tercih ederiz. "debounce" olarak da bilinen bu teknik arama ve sonuç tamamlama (autocompletion) özelliklerinin altyapısında çokça kullanılır.

Bu konsepti yüzeysel olarak tarif etmek gerekirse, kullanıcının "elma" yazdığı bir sorguda, "e", "el" ve "elm" sorgusu için arama yapmazken, kullanıcı yazmayı bıraktıktan 400 milisaniye sonra "elma" sorgusu için arama yapmaya "debounce" tekniği diyebiliriz.

Bu makalede anlatacağım problem de tam olarak kullanıcı arama yapmaya devam ederken bir anlığına uygulamadan çıktığında oluşacak olan olaylar ile ilgili. Eğer ekranda görüntülenen View'in ViewModel'i bellekten silinirse, ve o sırada hala çalışan bir Task mevcutsa bu durum bellek sızıntısına yol açabilir. Bu da bu problemi en nihayetinde uygulamanın doyumsuz bir şekilde bellek tüketmesi sebebiyle işletim sistemi tarafından sonlandırılmasına kadar götürebilir.

Problem: Sahipsiz Kalan Task'lar (Orphaned Tasks)

Aşağıdaki kod örneğinde @Observable kullanılarak yazılmış bir arama debounce yapısını görüyoruz.

@Observable @MainActor
final class FeedViewModel {
    private var searchTask: Task<Void, Never>?

    func onSearchChanged() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(400))
            guard !Task.isCancelled else { return }
            await performSearch()
        }
    }
}

Bu kodun ne yaptığına adım adım bakmak gerekirse:

  • Kullanıcı arama kutusuna bir harf yazdığında *onSearchChanged() *fonksiyonu çağrılır.
  • Bu fonksiyon ilk olarak eğer daha önceden başlatılmış bir arama Task'ı varsa onu iptal eder (yani searchTask?.cancel() satırı bunu yapar).
  • Sonra yeni bir Task çalışmaya başlar.
  • Bu yeni Task önce 400 milisaniye bekler (yani kullanıcının yazmaya devam edip etmeyeceğini kontrol eder).
  • Bekleme süresi bittikten sonra Task iptal edilip edilmediğini kontrol eder (çünkü kullanıcı beklerken başka bir harf yazmış olabilir ve o zaman bu Task iptal edilmiş olur).
  • Eğer Task iptal edilmemişse, o zaman gerçek arama işlemini yapar (performSearch() fonksiyonu çağrılır).

Bu Task hala çalışırken (mesela ağ isteği yapıyorken veya 400 milisaniye bekleme süresindeyken) kullanıcı geri tuşuna basıp arama ekranından çıktığında; yani SwiftUI'de bir View ekrandan kaldırıldığında, o View'in *@State *ile tuttuğu nesnelerin deinit fonksiyonu çağrılır ve nesne bellekten silinmeye çalışılır. Bu sırada *searchTask *değişkeni de *nil *olur (yani hiçbir Task'a referans göstermez hale gelir). Ama burada çok önemli bir nokta var: *searchTask *değişkeninin nil olması, Task'ın kendisinin de nil olacağı anlamına gelmez.

Swift'te bir Task oluşturduğumuzda (yani *Task { } *yazıp içine bir kod bloğu yazdığımızda), bu Task Swift'in cooperative thread pool denilen bir havuza kaydedilir ve çalışmaya başlar. Bu Task'a olan referansı kaybetsek bile (yani değişkeni nil yapsak bile), Task kendi işini yapmaya devam eder. Task ancak ya işi biterse ya da açıkça iptal edilirse çalışmayı durdurur.

Burada dikkat edilmesi gereken nokta, Task içindeki closure (kod bloğu) *performSearch() *fonksiyonunu çağırırken aslında örtülü olarak (implicitly) *self.performSearch() *demektedir. Yani closure, ViewModel nesnesine (yani self'e) güçlü bir referans (strong reference) tutar. Bu nedenle Task çalışmaya devam ettiği sürece, ViewModel nesnesini de bellekte tutar. İşte bu yüzden ViewModel'in deinit fonksiyonu çağrılsa bile, eğer Task iptal edilmemişse, Task'ın closure'ı ViewModel'i tutmaya devam eder ve nesne bellekten tam olarak silinmez. Bu teknik olarak bir "retain cycle" değildir (çünkü ViewModel Task'ı tutar, Task da ViewModel'i tutar şeklinde çift yönlü bir durum yoktur) ama sonuç olarak benzer bir bellek sızıntısı yaratır.

Bahsettiğim senaryoda ise, ViewModel bellekten silindiğinde *searchTask *değişkeni de otomatik olarak nil olur ama Task'ın kendisini iptal etmediğimiz için Task arka planda çalışmaya devam eder. Karşılaştığımız bu duruma *"orphaned task" *yani "sahipsiz kalan task" diyoruz. Kısacası bu Task'a erişebileceğimiz bir bellek referansımız kalmaz ama Task hala bellekte çalışmaya devam eder.

Sonuç: Sessiz ve Fark Edilmesi Güç Bir Bellek Sızıntısı

Bu "sahipsiz kalan Task" problemi uygulamanın hemen çökmesine neden olmaz. Hatta Apple'ın geliştirici aracı olan Instruments içindeki "Leaks" *(yani bellek sızıntılarını bulan araç) *bile bu problemi her zaman gösteremeyebilir. Bunun nedeni teknik olarak burada bir "retain cycle" (yani iki nesnenin birbirini tutup hiç silinmemesi) olmamasıdır. Task bir closure (bir kod bloğu) yakalıyor, bu closure ViewModel'e referans tutuyor ama ViewModel zaten bellekten silinmiş durumda olduğu için klasik bellek sızıntısı tanımına tam olarak uymuyor.

Asıl sorun ise Task'ın içindeki *performSearch() *fonksiyonunun başlattığı ağ isteğinin (yani internetten bir sunucuya bağlanıp veri çekmesi), ViewModel bellekten silindikten sonra da devam etmesi ve bu istek tamamlanana kadar Task'ın bellekte kalması.

Kullanıcı uygulama üzerinde ekranlar arasında hızlıca geçiş yapıyorsa ve bazı aramalar yapıp geri dönüyorsa *(ki birçok kullanıcı böyle davranır), *her seferinde yeni bir sahipsiz Task oluşur ve bunlar bellekte birikmeye başlar.

Apple Instruments aracında bellek kullanım grafiğine bakıldığında bu durum genellikle küçük ama sürekli artan bir çizgi olarak görünür. Her bir Task tek başına çok büyük değildir, belki sadece birkaç kilobyte yer kaplar. Ancak bu Task sadece bir kod bloğu değildir. Task aynı zamanda şunları da kapsar:

  • URLSession *(ağ isteği yapan nesne) *içindeki response buffer'ları (yani sunucudan gelen verinin geçici olarak saklandığı bellek alanı)
  • JSON formatındaki verinin decode edilmiş hali (yani Swift nesnelerine dönüştürülmüş veri), ve bunların geçici kopyaları.

Tüm bunlara bir arada baktığımızda her bir Task aslında düşündüğümüzden çok daha fazla bellek kullanır. Büyük ölçekli uygulamalarda bu durum out of memory (bellek yetersizliği) durumuna ve en nihayetinde uygulamanın process'inin işletim sistemi tarafından sonlandırılmasına neden olabilir.

Çözüm: deinit Fonksiyonunda Task'ı Direkt İptal Etmek

Bu problemin çözümü aslında şaşırtıcı derecede basit. Sadece ViewModel'in deinit fonksiyonunda Task'ı iptal etmemiz yeterli:

@Observable @MainActor
final class FeedViewModel {
    nonisolated(unsafe) private var searchTask: Task<Void, Never>?

    deinit {
        searchTask?.cancel()
    }

    func onSearchChanged() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(400))
            guard !Task.isCancelled else { return }
            await performSearch()
        }
    }
}

Bu kodda yapılan iki önemli değişikliğe göz atmak gerekirse:

*nonisolated(unsafe) searchTask:

Bizim ViewModel'imiz @MainActor ile işaretli. Swift'in concurrency (eşzamanlılık) sisteminde, MainActor ana thread'i temsil eder ve UI ile ilgili tüm işler ana thread'de yapılmalıdır. @MainActor ile işaretlenmiş bir class'ın property'lerine (değişkenlerine) sadece ana thread'den erişilebilir. Ama burada önemli bir durum var: deinit fonksiyonu (yani nesne bellekten silinirken çağrılan temizleme fonksiyonu) *"nonisolated context" *denen bir ortamda çalışır. *"Nonisolated" *demek MainActor'a bağlı olmayan demektir. Dolayısıyla deinit içinden normalde MainActor'a bağlı bir property'ye erişemeyiz. Swift 6.0'ın strict concurrency kontrolü bunu derleme zamanında hata olarak gösterir ve kodumuz derlenmez.

Bu sorunu çözebilmek için de searchTask değişkeninin önüne nonisolated(unsafe) kelimesi eklenerek Swift derleyicisine "Bu değişken MainActor isolation'ından muaf, yani MainActor'a bağlı olmayan yerlerden de erişilebilir." demiş oluyoruz. deinit çalışırken nesne zaten ölmekte olduğu için ve başka hiçbir kod bu property'ye aynı anda erişmeyeceği için bu yöntemin bir race condition (yarış durumu) riski yoktur. deinit fonksiyonu ve ***cancel() çağrısı

deinit fonksiyonu Swift'te bir nesne bellekten silinmeden hemen önce otomatik olarak çağrılan özel bir fonksiyondur. Bu fonksiyon çağrısı ViewModel bellekten silinmeden önce eğer bir searchTask varsa onu iptal ederek Task'a bir iptal sinyali gönderir. Bu sinyal "cooperative" (işbirlikçi) bir sinyal olduğu için Task'ı zorla durdurmaz, ama Task'a *"iptal edildin, lütfen işini bırak" *şeklinde haber gönderir.

Task içindeki kod bu iptal sinyalini *Task.isCancelled kontrolü aracılığıyla (guard !Task.isCancelled else { return } *satırı) kontrol eder. Bu kontrol sayesinde eğer Task iptal edildiyse fonksiyondan erken çıkış yapar (return). Buna bir ek yapmak gerekirse, eğer bir Task uyurken (sleep çağrısı) iptal edilirse, sleep durumundan çıkar ve bir hata fırlatır (throw eder). Ayrıca eğer Task bir ağ isteği beklerken iptal edilirse, URLSession'ın kendi iptal mekanizması devreye girer ve ağ isteği de iptal edilir.

Sonuç olarak, ViewModel bellekten silindiğinde deinit çağrılır, deinit içinde Task iptal edilir, Task iptal sinyalini alır ve işini bırakır, böylece sahipsiz Task kalma problemi ortadan kalkar ve bellek sızıntısı olmaz.

Daha Geniş Perspektif: Yapılandırılmış ve Yapılandırılmamış Eşzamanlılık (Structured vs Unstructured Concurrency)

Bu problem aslında Swift'in eşzamanlılık modelinin (concurrency model) temel bir tasarım kararından kaynaklanıyor. Bunu anlamak için iki farklı eşzamanlılık türüne göz atabiliriz.

Unstructured Concurrency (Yapılandırılmamış Eşzamanlılık):

Task { } constructor'u aracılığıyla oluşturulan Task'lar bu kategoriye girer. "Unstructured" kelimesi "yapılandırılmamış" demektir ve Task'ların yaşam döngüsünün (lifecycle) otomatik olarak yönetilmeyeceği anlamına gelir. Yani bir Task { } oluşturulduğunda, bu Task kendi başına bir iş parçası gibi çalışmaya başlar ve parent scope (yani Task'ı oluşturan kodun bulunduğu yer) bittiğinde otomatik olarak iptal edilmez. Mesela bir fonksiyon içinde Task oluşturulduğunda, o fonksiyonun çalışması bitse bile Task çalışmaya devam eder. Bu Task'ı durdurmak tamamen bizim sorumluluğumuzdadır, yani manuel olarak iptal etmemiz gerekir. Structured Concurrency (Yapılandırılmış Eşzamanlılık):

Structured Concurrency ise otomatik yönetilen Task'lardır. Swift'te *async let veya TaskGroup gibi yapılar structured concurrency sağlar. Bu yapılarla oluşturulan Task'lar parent scope bittiğinde otomatik olarak iptal edilir. Yani siz hiçbir şey yapmasanız bile, fonksiyon bittiğinde Task'lar otomatik temizlenir.

SwiftUI'de çok kullandığımız *.task *modifier'ı structured concurrency kullanır.

.task {
    await viewModel.load()
}

Örneğin bir View'da bulunan bu .task { } bloğuna göz atacak olursak, View ekrandan kaldırıldığında (yani hiyerarşiden çıktığında), .task modifier'ının oluşturduğu Task otomatik olarak iptal edilir. SwiftUI bunu otomatik halleder.

Ama ViewModel içinde doğrudan *Task { } *ile oluşturulan Task'lar unstructured'dır. ViewModel'in yaşam döngüsünden bağımsızdırlar. ViewModel bellekten silinse bile bu Task'lar çalışmaya devam eder. İşte bu yüzden ViewModel içinde manuel olarak Task'ları yönetmemiz gerekir, yani deinit içinde bu Task'ı bu yüzden iptal etmeliyiz.

Eğer yapılacak iş View'ın yaşam döngüsüne sıkı sıkıya bağlıysa *(yani View ekranda olduğu sürece devam etmeli, View kapandığında durmalı) *o zaman *.task *modifier'ını kullanmak çok daha güvenlidir. Ama eğer yapacağımız iş kullanıcının etkileşimlerine bağlıysa *(mesela kullanıcı arama kutusuna yazıyor, butona tıklıyor gibi) *ve ViewModel içinde bu işi yönetiyorsanız, o zaman Task { } kullanılabilir ama deinit içinde mutlaka iptal edilmesi gerekir.

Özellikle büyük ölçekli uygulamalarda bu tarz durumları yakalamak çok güç olduğu için, düzenli olarak (örneğin main'e açılan her Pull Request'te) otomatik çalışan testler oluşturmak kod stabilitesi için çok büyük katkıda bulunur. Bu tipteki bellek sızıntılarını da bu otomatik testlerle (unit test) yakalayabiliriz. Çünkü manuel olarak her seferinde tüm uygulamayı test etmek hem zor hem de hataya açıktır. Şimdi bir test örneğine bakalım:

func testSearchTaskCancelledOnDeinit() async {
    var viewModel: FeedViewModel? = FeedViewModel(client: MockClient())
    viewModel?.searchQuery = "test"
    viewModel?.onSearchChanged()

    weak var weakRef = viewModel
    viewModel = nil

    // ViewModel bellekten düşmeli
    XCTAssertNil(weakRef)
}

Bu testi satır satır incelemek gerekirse:

1. Satır:

var viewModel: FeedViewModel? = FeedViewModel(client: MockClient()) -

Burada bir FeedViewModel nesnesi oluşturuyoruz. MockClient gerçek bir ağ isteği yapmayan, test için kullanılan sahte (mock) bir istemcidir (böylece testimiz gerçek sunuculara bağlanmaz ve hızlı çalışır). viewModel değişkeninin tipi optional (yani ? işareti var) çünkü bir sonraki adımda bunu nil yapacağız.

2-3. Satır:

viewModel?.searchQuery = "test" ve viewModel?.onSearchChanged()

Burada arama işlemini başlatıyoruz. Kullanıcının "test" kelimesini yazdığını simüle ediyoruz ve arama fonksiyonunu çağırıyoruz. Bu fonksiyon bir Task başlatır (debounce mekanizması).

4. Satır:

weak var weakRef = viewModel

Normal bir referans (strong reference) bir nesneyi bellekte tutar. Yani bir nesneye strong reference olduğu sürece o nesne bellekten silinmez. Ama weak referans bir nesneyi bellekte tutmak için yeterli değildir. Weak referans sadece nesneyi "gösterir" ama "tutmaz" (retain etmez). Eğer bir nesneye sadece weak referanslar varsa ve hiç strong referans yoksa, o nesne bellekten silinir. Burada weakRef değişkeni viewModel'e weak referans tutuyor. Bu kod bize viewModel'in gerçekten bellekten silinip silinmediğini gösterir.

5. Satır:

viewModel = nil

Burada viewModel değişkenini nil yapıyoruz. Bu, viewModel'e olan strong referansımızı kaldırmak demektir. Normal şartlarda, eğer başka strong referans yoksa, viewModel nesnesi bellekten silinmelidir.

6-7. Satır:

XCTAssertNil(weakRef)

Burada weakRef referansının nil olup olmadığını kontrol ediyoruz. Eğer weakRef nil ise, bu demektir ki viewModel gerçekten bellekten silindi. Eğer weakRef nil değilse, yani hala viewModel'e işaret ediyorsa, bu durumda viewModel bellekten silinememiştir, yani bir bellek sızıntımız vardır.

Eğer *deinit *içinde Task'ı iptal etmezsek ve Task içindeki closure (kod bloğu) ViewModel'e strong reference tutuyorsa, Task bellekte kalmaya devam eder ve ViewModel'i de tutar (retain eder). Bu durumda viewModel'in referansını tutan başka bir nesne olduğu için bellekten silinemez ve weakRef nil olmaz. Test başarısız olur ve biz de böylece bellek sızıntısı olduğunu anlarız.

Bu testi projeye ekleyip her kod değişikliğinden sonra otomatik olarak çalıştırarak bellek sızıntısı oluşup oluşmadığını görebilir, böylece yazdığımız kod production ortamına (yani son kullanıcılara) gitmeden önce problemleri yakalayabiliriz. Swift 6.0 ile ne değişti?

Swift 6.0'ın strict concurrency (katı eşzamanlılık) kuralları bu tür sorunları daha görünür kıldı. Yani derleyici bazı durumlarda bizi uyarıp ve hataları derleme zamanında yakalamamıza yardımcı oluyor diyebiliriz. Ama derleyici her senaryoyu yakalayamadığı için, özellikle *Task<Void, Never> *gibi hata fırlatmayan *(yani throws kullanmayan) *Task'lar hakkında bilinçli olmak ve doğru pratikleri uygulamak bizim sorumluluğumuzdur.

Apple Instruments aracının "Allocations" (bellek tahsisi) bölümünü kullanarak "transient" nesnelerin (yani geçici nesnelerin) büyüme trendine bakılarak bu problem tespit edilebilir. Eğer bir ViewModel'in allocation count *(bellek tahsis sayısı) *her ekran geçişinde artıyorsa, bu deinit'te iptal edilmeyen bir Task olduğuna işaret edebilir.

Bu yöntemle daha az bellek gereksinimleriyle çalışan uygulamalar yazabilir ve uygulamaların kullanıcı deneyemini gözeterek daha performanslı çalışmasını sağlayabiliriz. Özellikle kullanıcılar ekranlar arasında hızlıca geçiş yaptığında uygulama böylece yavaşlamaz ve bellek dolmaz.

Paylaş:

A
Yazan
Software Engineer

Tartışma

Henüz yorum yok

İlk yorumu siz yapın!