İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Swift async/await Rehberi: Task, Actor ve MainActor Kullanımı

Ahmet Balaman

5 dk okuma

Swiftasync awaitConcurrencyActorSwiftUIiOS
Swift async/await Rehberi: Task, Actor ve MainActor Kullanımı

Bir iOS uygulamasının neredeyse her ekranı beklemesi gereken bir iş yapar: ağdan veri çekmek, dosya okumak, görsel işlemek. Bu işleri ana iş parçacığında yaparsanız arayüz donar; arka plana atarsanız sonucu güvenli biçimde arayüze geri taşımanız gerekir. Swift'in async/await tabanlı eşzamanlılık modeli bu iki sorunu da dilin içine alıyor: bekleyen kod düz bir fonksiyon gibi okunuyor, hangi verinin hangi bağlamdan değiştirilebileceğini ise derleyici denetliyor.

Bu rehber completion handler'lardan başlayıp Task, async let, TaskGroup, @MainActor, actor'lar ve iptal konularını sırayla geçiyor, sonunda da hepsini bir SwiftUI ekranında birleştiriyor.

Completion Handler'dan async/await'e

Eski tarz bir ağ çağrısı şöyle görünür:

import Foundation

struct User: Codable, Identifiable, Sendable {
    let id: Int
    let name: String
    let email: String
}

func fetchUser(id: Int, completion: @escaping @Sendable (Result<User, Error>) -> Void) {
    let url = URL(string: "https://example.com/api/users/\(id)")!
    URLSession.shared.dataTask(with: url) { data, _, error in
        if let error {
            completion(.failure(error))
            return
        }
        guard let data else {
            completion(.failure(URLError(.badServerResponse)))
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

Sorunlar tanıdık: her dalda completion çağırmayı unutmamak sizin sorumluluğunuzda, derleyici bunu denetlemiyor. İki çağrıyı art arda yapmak iç içe girintiler üretiyor. Hata yönetimi throws ile değil Result ile yapılıyor. Closure'ların @escaping ve yakalama davranışına closures yazısında değinmiştim; burada o karmaşıklığın çoğu ortadan kalkıyor.

Aynı işin async hâli, genel bir get yardımcısıyla birlikte:

enum APIError: Error {
    case invalidResponse
    case badStatus(Int)
}

struct UserService: Sendable {
    let baseURL = URL(string: "https://example.com/api")!

    func fetchUser(id: Int) async throws -> User {
        try await get("users/\(id)")
    }

    func fetchUsers() async throws -> [User] {
        try await get("users")
    }

    private func get<T: Decodable>(_ path: String) async throws -> T {
        let url = baseURL.appendingPathComponent(path)
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let http = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }
        guard (200..<300).contains(http.statusCode) else {
            throw APIError.badStatus(http.statusCode)
        }
        return try JSONDecoder().decode(T.self, from: data)
    }
}

await bir askıya alma noktasıdır: fonksiyon orada durur, iş parçacığı başka işlere bırakılır, sonuç geldiğinde kaldığı yerden devam eder. İş parçacığı bloklanmaz. Fonksiyon ya bir değer döndürmek ya da hata fırlatmak zorunda olduğu için "completion çağrılmadı" türünden hatalar imkânsız hâle gelir. guard let kalıbı size yeni geliyorsa optionals ve guard let yazısına bakın.

Elinizde değiştiremeyeceğiniz callback tabanlı bir API varsa onu continuation ile sarabilirsiniz. Tek kural: continuation her yolda tam bir kez sürdürülmeli.

func fetchUserAsync(id: Int) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        fetchUser(id: id) { result in
            continuation.resume(with: result)
        }
    }
}

Task: Senkron Koddan async Dünyaya Geçiş

async bir fonksiyonu yalnızca başka bir async bağlamdan çağırabilirsiniz. Bir düğme eylemi gibi senkron bir yerden başlatmak için Task kullanılır:

Button("Yenile") {
    Task { await model.load() }
}

Task { } oluşturulduğu bağlamın actor'ını devralır; @MainActor bir view'dan başlattıysanız içindeki kod da ana actor'da başlar. Dönen değeri saklarsanız cancel() ile iptal edebilirsiniz. Bu tür görevler yapılandırılmamış görevlerdir: ömürlerini siz yönetirsiniz.

async let ve TaskGroup: İşleri Paralel Yürütmek

Birbirinden bağımsız iki isteği alt alta await ile yazarsanız ikincisi birincinin bitmesini bekler. async let ikisini aynı anda başlatır:

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

struct Dashboard: Sendable {
    let user: User
    let posts: [Post]
}

func loadDashboard(service: UserService, userID: Int) async throws -> Dashboard {
    async let user = service.fetchUser(id: userID)
    async let posts = service.fetchPosts(userID: userID)
    return try await Dashboard(user: user, posts: posts)
}

(fetchPosts, UserService içinde try await get("users/\(userID)/posts") şeklinde tek satırdır.)

İş sayısı derleme anında belli değilse TaskGroup kullanılır:

func fetchUsers(ids: [Int], service: UserService) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await service.fetchUser(id: id)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users.sorted { $0.id < $1.id }
    }
}

Sonuçlar ekleme sırasıyla değil bitiş sırasıyla gelir; sıra önemliyse sonradan sıralayın. Her iki yapı da yapılandırılmış eşzamanlılıktır: alt görevler fonksiyonun kapsamından dışarı taşamaz, biri hata fırlatırsa diğerleri iptal edilir.

@MainActor: Arayüzü Güvenle Güncellemek

Arayüz durumu yalnızca ana iş parçacığından değiştirilmelidir. DispatchQueue.main.async yazmak yerine tipi @MainActor ile işaretlersiniz ve kuralı derleyici uygular:

import Observation

@MainActor
@Observable
final class UserListModel {
    private(set) var users: [User] = []
    private(set) var errorMessage: String?
    private(set) var isLoading = false

    private let service = UserService()

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            users = try await service.fetchUsers()
            errorMessage = nil
        } catch is CancellationError {
            // Ekran kapandı, kullanıcıya hata göstermeye gerek yok
        } catch let error as URLError where error.code == .cancelled {
            // URLSession iptali bu hatayla bildirir
        } catch {
            errorMessage = "Liste yüklenemedi: \(error.localizedDescription)"
        }
    }
}

try await service.fetchUsers() satırında ağ işi ana actor'ın dışında yürür; await döndüğünde kod yeniden ana actor'dadır ve users ataması güvenlidir. @Observable ve modelin view'a nasıl bağlandığı SwiftUI veri akışı yazısının konusu.

Actor: Paylaşılan Değişken Durumu Korumak

Birden fazla görevin aynı sözlüğe yazması klasik bir veri yarışıdır. actor, içindeki duruma aynı anda yalnızca bir görevin erişmesini garanti eden bir referans tipidir; kilit yazmazsınız.

actor ImageCache {
    private var storage: [URL: Data] = [:]

    func data(for url: URL) -> Data? {
        storage[url]
    }

    func store(_ data: Data, for url: URL) {
        storage[url] = data
    }
}

func loadImageData(from url: URL, cache: ImageCache) async throws -> Data {
    if let cached = await cache.data(for: url) {
        return cached
    }
    let (data, _) = try await URLSession.shared.data(from: url)
    await cache.store(data, for: url)
    return data
}

Dışarıdan erişim await ister, çünkü sıra beklemeniz gerekebilir. Dikkat edilecek nokta: bir actor metodunun içindeki her await'te başka çağrılar araya girebilir. await'ten önce okuduğunuz bir değerin sonrasında hâlâ aynı olduğunu varsaymayın.

İptal İşbirliğine Dayanır

task.cancel() görevi zorla durdurmaz, yalnızca bir bayrak kaldırır. URLSession ve Task.sleep gibi sistem API'leri bu bayrağa kendiliğinden tepki verir ve hata fırlatır. Kendi uzun döngülerinizde kontrolü siz koyarsınız:

func exportAll(_ users: [User]) async throws {
    for user in users {
        try Task.checkCancellation()
        try await upload(user)
    }
}

SwiftUI'da .task

.task değiştiricisi view göründüğünde işi başlatır, view kaybolduğunda görevi iptal eder. .task(id:) ise değer her değiştiğinde önceki görevi iptal edip yenisini başlatır; arama kutusu için birebirdir:

struct SearchScreen: View {
    @State private var query = ""
    @State private var results: [User] = []

    var body: some View {
        List(results) { user in
            Text(user.name)
        }
        .searchable(text: $query)
        .task(id: query) {
            do {
                try await Task.sleep(for: .milliseconds(300))
                results = try await search(query)
            } catch {
                // Yeni harf yazıldı: önceki görev iptal edildi
            }
        }
    }

    private func search(_ text: String) async throws -> [User] {
        let all = try await UserService().fetchUsers()
        return text.isEmpty ? all : all.filter { $0.name.localizedCaseInsensitiveContains(text) }
    }
}

Kullanıcı yazmaya devam ettikçe bekleyen görev iptal olur, yalnızca yazmayı bıraktığı andaki sorgu ağa çıkar.

Swift 6 ve Veri Yarışı Güvenliği

Swift 6 dil modunda veri yarışı güvenliği derleme zamanında denetlenir. Temel kavram Sendable: bir değerin eşzamanlılık sınırları arasında (bir actor'dan diğerine, bir görevden ötekine) güvenle geçirilebileceğini söyler. Yalnızca Sendable alanlardan oluşan struct ve enum'lar, actor'lar ve değişmez final sınıflar bu koşulu sağlar; değişken alanları olan sıradan bir sınıf sağlamaz ve onu başka bir göreve vermeye çalıştığınızda derleyici itiraz eder. Yukarıdaki modellerin Sendable olarak işaretlenmesinin sebebi bu. Mevcut projelerde kontrolleri kademeli açıp uyarıları modül modül temizlemek, bir anda geçmeye çalışmaktan çok daha az acı verir. Hangi tipin değer, hangisinin referans olduğu burada doğrudan belirleyici; ayrıntısı struct ve class farkı yazısında.

Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?

Yeni yazdığınız her asenkron iş için async/await varsayılan olmalı: ağ, dosya, veritabanı, beklemeli her şey. Completion handler'ı yalnızca değiştiremediğiniz eski bir API sizi zorluyorsa kullanın ve onu da continuation ile sarın.

Zaman içinde akan değerler (metin alanı değişiklikleri, konum güncellemeleri) tek bir await ile ifade edilemez; bunlar için AsyncSequence ya da hâlâ Combine uygundur. Saf CPU işi olan senkron bir fonksiyonu async yapmak onu hızlandırmaz; async, "arka planda çalışır" demek değildir. Paylaşılan durum için de her zaman actor gerekmez: durum yalnızca arayüzden kullanılıyorsa @MainActor yeterlidir.

Sık Yapılan Hatalar

1. Arayüz durumunu actor dışından değiştirmek

Belirti: Derleyici main actor-isolated property 'users' can not be mutated from a nonisolated context hatası verir; eski ObservableObject kodunda ise çalışma anında arka plan iş parçacığından yayın yapıldığına dair mor uyarı çıkar. Çözüm: Modeli @MainActor ile işaretleyin ve değişikliği modelin kendi async metodunun içinde yapın; DispatchQueue.main.async ile yamamayın.

2. Bağımsız istekleri sırayla beklemek

let user = try await service.fetchUser(id: 1)
let posts = try await service.fetchPosts(userID: 1)   // öncekinin bitmesini bekler

Belirti: Ekran, isteklerin sürelerinin toplamı kadar geç açılır. Çözüm: İkinci istek birincinin sonucuna ihtiyaç duymuyorsa async let.

3. onAppear içinde Task başlatıp unutmak

Belirti: Ekrandan çıkıldıktan sonra istek sürer, ekrana her dönüşte aynı istek yeniden atılır, bazen eski yanıt yenisinin üstüne yazılır. Çözüm: .task { } kullanın; görevin ömrü view'ın ömrüne bağlanır.

4. İptali hata gibi göstermek

Belirti: Kullanıcı ekrandan çıkarken ya da arama kutusuna yazarken "cancelled" içerikli bir hata uyarısı yanıp söner. Çözüm: CancellationError ve URLError.cancelled durumlarını yukarıdaki load() örneğindeki gibi ayrı yakalayın ve sessizce geçin.

Mini Senaryo: Kullanıcı Listesi Ekranı

Parçaları birleştirelim: UserService ağ işini yapıyor, UserListModel durumu ana actor'da tutuyor, view yalnızca gösteriyor.

import SwiftUI

struct UserListScreen: View {
    @State private var model = UserListModel()

    var body: some View {
        List(model.users) { user in
            VStack(alignment: .leading) {
                Text(user.name)
                Text(user.email).font(.caption)
            }
        }
        .overlay {
            if model.isLoading && model.users.isEmpty {
                ProgressView()
            } else if let message = model.errorMessage {
                Text(message)
            }
        }
        .task {
            await model.load()
        }
        .refreshable {
            await model.load()
        }
    }
}

Ekran açıldığında .task yüklemeyi başlatır; kullanıcı geri dönerse görev iptal edilir ve load() içindeki iptal dalları sayesinde hata mesajı gösterilmez. .refreshable aynı async metodu çağırır ve çekme göstergesi await bitene kadar kendiliğinden görünür kalır. View'ın içinde tek bir DispatchQueue ya da callback yok; akış yukarıdan aşağı okunuyor.

Sık Sorulan Sorular

async/await arka planda mı çalışır?

Kendiliğinden hayır. await yalnızca fonksiyonun o noktada askıya alınabileceğini söyler. Kodun nerede çalıştığını actor yalıtımı belirler: @MainActor bir tipin metodu, await noktaları dışında ana actor'da yürür.

Task ile Task.detached arasındaki fark nedir?

Task { } oluşturulduğu bağlamın actor'ını ve önceliğini devralır. Task.detached hiçbir şeyi devralmaz. Günlük kodda neredeyse her zaman Task { } ya da daha iyisi yapılandırılmış eşzamanlılık (async let, TaskGroup, .task) yeterlidir.

Actor ile class arasındaki fark nedir?

İkisi de referans tipidir. Actor, değişken durumuna erişimi sıraya sokarak veri yarışını önler; bu yüzden dışarıdan erişim await gerektirir. Actor'lar kalıtımı desteklemez.

GCD ve completion handler artık kullanılmamalı mı?

Çalışmaya devam ediyorlar ve eski kodda karşınıza çıkacaklar. Yeni kodda async/await tercih edin; eski API'leri continuation ile sararak kademeli geçiş yapabilirsiniz.

Yorumlar