TekinAli ⎇ main

Main actor, @concurrent ve [weak self]: Swift 6.2 için concurrency alışkanlıkları

Swift 6.2'deki varsayılan main actor izolasyonu, nonisolated(nonsending) ve @concurrent kodun nerede çalıştığını nasıl değiştiriyor, async neden arka plan demek değil ve Task içinde [weak self] ne zaman hâlâ önemli.

GCD döneminden kalma iki alışkanlık Swift kodunda hâlâ her yerde: her UI güncellemesini DispatchQueue.main.async içine sarmak ve her closure'a [weak self] eklemek. Swift concurrency ile ikisi de çoğu zaman gereksiz, bazen de gerçek hataları gizliyor. async'in "arka plan" demek olduğu varsayıldığı için bir Task arayüzü dondurabiliyor. Uzun ömürlü bir döngü bir view controller'ı sonsuza kadar hayatta tutabiliyor.

Swift 6.2 (15 Eylül 2025'te yayınlandı) varsayılanları ikisini de yeniden düşünmeye değecek kadar değiştirdi. Swift 6.4 Eylül 2026'da çıktı, ama bu yazıdaki model 6.2 ile gelen model. Aşağıda kodun gerçekte nerede çalıştığını, 6.2'nin neyi değiştirdiğini, arayüz kodunu bugün nasıl yazmanızı önerdiğimi ve weak capture'ın ne zaman hâlâ gerekli olduğunu anlatıyorum.

Thread'lerden actor'lere

GCD döneminde kurallar basitti ve tamamen elle uygulanıyordu. UIKit'e main thread'de dokunmak gerekiyordu, dolayısıyla her completion handler DispatchQueue.main.async { self.label.text = ... } ile bitiyordu. Escaping closure'lar retain cycle yaratabiliyordu, dolayısıyla [weak self] bir reflekse dönüştü, ardından da her yerde guard let self ya da self?. geldi.

Swift concurrency (iOS 13'e kadar geriye kullanılabiliyor) "hangi thread" sorusunu "hangi actor" sorusuyla değiştirdi. @MainActor kod main thread'de çalışıyor ve derleyici izolasyon alanları arasındaki her geçişi kontrol ediyor. Swift 6 dil modu da birçok data race hatasını derleme hatasına çeviriyor. Eski snippet'ler bu dünyada da derleniyor. Sadece gürültülüler, bazen de ince bir şekilde yanlışlar.

async, arka plan demek değil

En yaygın yanlış anlama birkaç satıra sığıyor. CSV export hazırlayan bir view model düşünün:

final class LegacyExportModel: ObservableObject {
    @Published var csv = ""

    func buildCSV(rows: [Row]) async {
        var output = "id,name,amount\n"
        for row in rows {                       // büyük bir export'ta saniyelerce CPU işi
            output += "\(row.id),\(row.name),\(row.amount)\n"
        }
        csv = output
    }
}

// Bir SwiftUI buton aksiyonunda:
Task { await model.buildCSV(rows: rows) }

async olduğu için buildCSV'nin arka planda çalışacağını düşünmek kolay. Çalışıp çalışmadığı iki kurala bağlı:

  1. Task { }, onu oluşturan bağlamın actor'ünü miras alıyor. SwiftUI view'undaki bir buton aksiyonu main actor'de çalışıyor, dolayısıyla task da orada başlıyor.
  2. İzole olmayan bir async fonksiyonun nerede çalıştığı zaman içinde değişti. Swift 5.7'den önce bu net tanımlı değildi. SE-0338 o dönemin implementasyonunu, bu fonksiyonların "never intentionally change the current executor" diye anlatıyor. Yani böyle bir kod main actor'de çalışmaya devam edip arayüzü donduruyordu.

SE-0338 (Swift 5.7) kuralı netleştirdi: izole olmayan async fonksiyonlar actor'den çıkıp global concurrent executor'a geçiyor. Bu, bu donmayı önledi ama kodu anlamayı zorlaştırdı. İzole olmayan bir async fonksiyona yapılan her çağrı bir izolasyon sınırını geçiyordu ve Swift 6'da karşılaşılan Sendable hatalarının birçoğu tam da buradan geliyor.

Swift 6.2 neyi değiştiriyor

Swift 6.2 isteğe bağlı iki değişiklik getirdi. İkisi de kabul edilmiş ve implemente edilmiş proposal'lar.

SE-0461, nonisolated(nonsending) ve @concurrent. NonisolatedNonsendingByDefault upcoming feature'ı açıkken izole olmayan bir async fonksiyon varsayılan olarak çağıranın actor'ünde çalışıyor. Bunu açıkça nonisolated(nonsending) diye de yazabilirsiniz. Gerçekten actor'den çıkmak istediğinizde fonksiyonu @concurrent ile işaretliyorsunuz. Bu özellik açıkken yukarıdaki buildCSV yine main actor'de çalışır, ama bu sefer bu belgelenmiş davranış. Çözüm, ne istediğinizi açıkça söylemek:

nonisolated struct Row: Sendable {
    let id: Int
    let name: String
    let amount: Decimal
}

@concurrent
func makeCSV(from rows: [Row]) async -> String {
    var output = "id,name,amount\n"
    for row in rows {
        output += "\(row.id),\(row.name),\(row.amount)\n"
    }
    return output
}

@MainActor
@Observable
final class ExportModel {
    private(set) var csv = ""

    func export(_ rows: [Row]) async {
        csv = await makeCSV(from: rows)     // arayüz akıcı kalır, sonuç main actor'e döner
    }
}

SE-0466, varsayılan actor izolasyonu. Bir modül, -default-isolation MainActor compiler flag'iyle işaretlenmemiş bütün kod için @MainActor çıkarımına geçebiliyor. Swift 6.2 duyurusu bunu script'ler, UI kodu ve diğer executable target'lar için uygun bir seçenek olarak sunuyor. Xcode'da build setting'in adı Default Actor Isolation (SWIFT_DEFAULT_ACTOR_ISOLATION). Ayrı bir Approachable Concurrency ayarı (SWIFT_APPROACHABLE_CONCURRENCY) da NonisolatedNonsendingByDefault'u içeren bir grup upcoming feature'ı açıyor. SwiftPM'de:

// swift-tools-version: 6.2
.target(
    name: "AppFeature",
    swiftSettings: [
        .defaultIsolation(MainActor.self),
        .enableUpcomingFeature("NonisolatedNonsendingByDefault")
    ]
)

Önerim: uygulama target'ında ve UI feature modüllerinde ikisini de açın. Networking, persistence ve diğer kütüphane modüllerini varsayılan olarak nonisolated bırakın ve sınırı neyin geçtiğine bilinçli karar verin.

DispatchQueue.main olmadan arayüz kodu

Bugün önerdiğim tarzda yazılmış bir feed ekranı aşağıda. Önemli kısımlar açıkça işaretlendiği için varsayılan main actor izolasyonu açık da olsa kapalı da olsa çalışıyor:

import SwiftUI
import Observation

nonisolated struct Post: Decodable, Sendable, Identifiable {
    let id: Int
    let title: String
}

nonisolated struct FeedAPI: Sendable {
    func fetchData() async throws -> Data {
        let url = URL(string: "https://example.com/feed.json")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

@concurrent
func decodePosts(from data: Data) async throws -> [Post] {
    try JSONDecoder().decode([Post].self, from: data)   // main actor dışında
}

@MainActor
@Observable
final class FeedModel {
    private(set) var posts: [Post] = []
    private(set) var errorMessage: String?
    private let api = FeedAPI()

    func refresh() async {
        do {
            let data = try await api.fetchData()
            posts = try await decodePosts(from: data)   // yeniden main actor'de, dispatch yok
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct FeedView: View {
    @State private var model = FeedModel()

    var body: some View {
        List(model.posts) { Text($0.title) }
            .task { await model.refresh() }        // iOS 15+
            .refreshable { await model.refresh() } // iOS 15+
    }
}

Ne DispatchQueue.main var ne de MainActor.run. Model @MainActor olduğu için posts'a atama yapmak tasarım gereği güvenli. Bir tipte nonisolated kullanmak (SE-0449, Swift 6.1) onu modülün varsayılan main actor izolasyonunun dışında tutuyor, böylece main actor dışında rahatça kullanılabiliyor.

Callback köprüleri: MainActor.run mı, @MainActor closure mı

Bazen yine de bir köprüye ihtiyacınız oluyor, genelde kontrolünüzde olmayan completion handler'lı bir API'den gelirken:

  • Senkron bir callback içinde Task { @MainActor in ... } kullanın.
  • İzole olmayan bir async fonksiyonda await MainActor.run { ... } (iOS 13+) çalışır. Ama genelde daha temiz çözüm, çağırdığınız fonksiyonu @MainActor yapıp doğrudan await etmek.
  • Bir callback'in main thread'de geldiği belgelenmişse, MainActor.assumeIsolated { ... } (iOS 13+) bunu derleyiciye söylüyor. Varsayım yanlışsa uygulamayı fatal error ile durduruyor.

[weak self] ne zaman hâlâ önemli

Bir Task, closure'ını ve closure'ın yakaladığı her şeyi task bitene kadar tutuyor. Sonra bırakıyor. Bir saniyede biten bir istek için self'i strong yakalamak yalnızca nesnenin biraz daha uzun yaşaması demek. Bu bir leak değil, yani oradaki [weak self] çoğunlukla gürültü.

Task kendi kendine hiç bitmediğinde gerçek bir soruna dönüşüyor:

final class TickerViewController: UIViewController {
    private let service = PriceService()
    private let priceLabel = UILabel()
    private var tickerTask: Task<Void, Never>?

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        let stream = service.prices()               // AsyncStream<Price>
        tickerTask = Task { [weak self] in
            for await price in stream {
                guard let self else { return }      // sadece bu iterasyon boyunca strong
                self.priceLabel.text = price.value.formatted()
            }
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        tickerTask?.cancel()                        // döngüyü bitirir
        tickerTask = nil
    }
}

Task'ın sahibi view controller. Sonsuz bir döngü self'i strong yakalasaydı onu sonsuza kadar hayatta tutardı. İki detay önemli. guard let self döngünün içinde, yani self yalnızca bir eleman işlenirken tutuluyor. Task da açıkça iptal ediliyor. SwiftUI'da .task { for await ... } tercih edin: view kaybolduğunda otomatik iptal ediliyor.

Tuzaklar

  • Task'ın en başında guard let self. Task { [weak self] in guard let self else { return }; for await ... } yazmak self'i bütün döngü boyunca strong yapıyor, weak capture'ın hiçbir etkisi kalmıyor.
  • Sadece içteki closure'da weak. loader.load { result in Task { @MainActor [weak self] in ... } } yazınca dıştaki closure self'i yine strong yakalıyor. Yeni derleyiciler tam olarak bu durum için uyarı veriyor, örneğin Swift 6.4. [weak self]'i en dıştaki escaping closure'a koyun.
  • async'i arka plan sanmak. NonisolatedNonsendingByDefault açıkken açıkça öyle değil. CPU'yu yoran iş için @concurrent kullanın. Ben bunu Task.detached'a tercih ediyorum. Detached task, iptalini sizin yönetmeniz gereken unstructured bir task oluşturuyor ve Apple'ın dokümantasyonu da structured concurrency işi modelleyebiliyorsa detached task'tan kaçınmayı öneriyor.
  • @MainActor kod içinde DispatchQueue.main.async. Zaten main actor'desiniz. Dispatch işi sadece run loop'un sonraki turuna erteliyor ve actor bilgisini derleyiciden saklıyor.
  • Varsayılan main actor izolasyonunu her yerde açmak. Uygulama ve UI kodu için tasarlandı. Bir networking paketinde, hiç main actor'e ait olmayan işleri main actor'e itiyor.

Kontrol listesi

  1. UI tipleri @MainActor. Uygulama target'larında Default Actor Isolation = MainActor ayarını düşünün (Swift 6.2+).
  2. NonisolatedNonsendingByDefault'u (ya da Xcode'daki Approachable Concurrency ayarını) açın ve CPU'yu yoran fonksiyonları @concurrent ile işaretleyin.
  3. DispatchQueue.main.async yerine main actor metotlarını await ile çağırın. Task { @MainActor in }'i yalnızca callback sınırlarında kullanın.
  4. Kısa task'larda refleks [weak self]'i bırakın. Uzun ömürlü döngülerde iptalle birlikte koruyun.
  5. SwiftUI'da elle saklanan task'lar yerine .task {} ve .task(id:) (iOS 15+) kullanın.

GCD dönemindeki kurallar kendi zamanları için doğruydu. Swift 6.2 ile bu kuralların çoğunu derleyici sizin yerinize uygulayabiliyor, yeter ki kodunuzun nerede çalışması gerektiğini ona söyleyin.

tekinali.db · salt okunur · ● bağlı

SQLite 3.53.3 · 8 tablo, 2 view.

Soldaki bir tabloya tıkla, aşağıdan bir sorgu seç ya da kendin yaz. Yardım: \?

↑↓ seç · Enter aç · Esc kapat