Swift Structured Concurrency interviewvragen: async/await, TaskGroup, Actors

Technische Swift Structured Concurrency interviewvragen: async/await, TaskGroup, actors en concurrency-patronen voor iOS 2026

Swift Structured Concurrency interviewvragen met async/await, TaskGroup en actors

De in Swift 5.5 geïntroduceerde Structured Concurrency heeft asynchrone programmering op iOS gerevolutioneerd. Recruiters toetsen tijdens technische interviews nu de beheersing van async/await, TaskGroup en actors. Hier zijn de essentiële vragen en verwachte antwoorden om uit te blinken in interviews.

In interviews getoetste kerncompetenties

Recruiters beoordelen drie competenties: begrip van fundamentele concepten (async/await, Task), beheersing van concurrency-patronen (TaskGroup, actor isolation) en het vermogen om veelvoorkomende fouten (data races, deadlocks) te diagnosticeren.

Wat is het verschil tussen async/await en DispatchQueue?

Verwacht antwoord: async/await biedt structured concurrency met leesbare sequentiële code, terwijl DispatchQueue callbacks gebruikt en kan leiden tot zogenaamde "callback hell". Swift beheert threads automatisch met async/await.

NetworkService.swiftswift
// Comparison: async/await vs DispatchQueue

// ❌ Old style: DispatchQueue with callbacks
func fetchUserOld(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        // Simulated network call
        let result = self.performNetworkRequest(id: id)
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

// ✅ New style: async/await more readable
func fetchUser(id: Int) async throws -> User {
    // Swift runtime handles threads automatically
    // No need to manually switch between queues
    return try await performNetworkRequest(id: id)
}

Kernpunten: async/await elimineert callback-piramides, vermindert thread-fouten (geen DispatchQueue.main.async nodig) en stelt de Swift runtime in staat om de uitvoering over de beschikbare CPU-cores te optimaliseren.

Performance-voordeel

De Swift runtime gebruikt een geoptimaliseerde threadpool die overmatige thread-creatie voorkomt. Anders dan bij DispatchQueue, waar elke .async een nieuwe thread kan aanmaken, hergebruikt async/await bestaande threads op intelligente wijze.

Hoe meerdere asynchrone bewerkingen parallel beheren?

Verwacht antwoord: async let gebruiken voor 2-3 eenvoudige taken, of TaskGroup voor een dynamisch aantal parallelle taken met resultaatverzameling.

DataFetcher.swiftswift
// Parallel async operations strategies

struct DataFetcher {
    // Strategy 1: async let for fixed tasks (2-4 operations)
    func loadDashboard() async throws -> Dashboard {
        // Launch 3 requests in parallel
        async let user = fetchUser()
        async let posts = fetchPosts()
        async let notifications = fetchNotifications()

        // Wait for results (parallel, not sequential)
        let (userData, postsData, notificationsData) = try await (user, posts, notifications)

        return Dashboard(user: userData, posts: postsData, notifications: notificationsData)
    }

    // Strategy 2: TaskGroup for dynamic number of tasks
    func downloadImages(urls: [URL]) async throws -> [UIImage] {
        // TaskGroup allows managing N tasks with result collection
        try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
            // Launch one task per URL
            for (index, url) in urls.enumerated() {
                group.addTask {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    guard let image = UIImage(data: data) else {
                        throw ImageError.invalidData
                    }
                    return (index, image) // Return index to preserve order
                }
            }

            // Collect results in order
            var images = [UIImage?](repeating: nil, count: urls.count)
            for try await (index, image) in group {
                images[index] = image
            }

            return images.compactMap { $0 }
        }
    }
}

Veelgemaakte fout: await sequentieel gebruiken in plaats van async let parallelliseert de aanroepen. let user = await fetchUser(); let posts = await fetchPosts() voert sequentieel uit (traag), terwijl async let beide gelijktijdig start.

Wat is een actor en waarom hem gebruiken?

Verwacht antwoord: Een actor is een type dat zijn muteerbare staat beschermt tegen data races door sequentiële toegang te garanderen. Het vervangt handmatige locks (NSLock, DispatchQueue) om gelijktijdige toegang te beveiligen.

CacheManager.swiftswift
// Actor for thread-safe state management

// ❌ Classic class: data race risk
class UnsafeCache {
    private var cache: [String: Data] = [:] // Not thread-safe!

    func store(_ data: Data, for key: String) {
        cache[key] = data // ⚠️ Race condition with concurrent access
    }
}

// ✅ Actor: automatic protection against races
actor SafeCache {
    private var cache: [String: Data] = [:]

    // Sequential access guaranteed by actor isolation
    func store(_ data: Data, for key: String) {
        cache[key] = data // ✅ Thread-safe automatically
    }

    func retrieve(for key: String) -> Data? {
        return cache[key] // ✅ Protected read
    }

    // Internal synchronous method (no await needed)
    nonisolated func clearAll() async {
        // nonisolated allows calling from any context
        await self.clear()
    }

    private func clear() {
        cache.removeAll()
    }
}

// Usage: await required to access actor
let cache = SafeCache()
await cache.store(data, for: "user_123") // Await mandatory
let cachedData = await cache.retrieve(for: "user_123")

Kernpunten: De actor garandeert dat slechts één thread tegelijk toegang heeft tot zijn staat. De compiler verplicht het gebruik van await voor externe aanroepen, waardoor potentiële suspension points expliciet worden.

MainActor-valkuil

@MainActor is een globale actor voor UI-bewerkingen. Een klasse markeren met @MainActor dwingt al haar methoden om op de main thread uit te voeren. Pas op voor blokkerende aanroepen die de interface kunnen bevriezen.

Klaar om je iOS gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Hoe fouten afhandelen in een TaskGroup?

Verwacht antwoord: withThrowingTaskGroup propageert de eerste tegengekomen fout en annuleert automatisch de overige taken. Om alle fouten te verzamelen, Result gebruiken in de TaskGroup.

BatchProcessor.swiftswift
// Error handling strategies in TaskGroup

struct BatchProcessor {
    // Strategy 1: First error propagation (fail-fast)
    func processItemsFastFail(items: [Item]) async throws -> [ProcessedItem] {
        try await withThrowingTaskGroup(of: ProcessedItem.self) { group in
            for item in items {
                group.addTask {
                    // If one task throws, group cancels others
                    try await self.process(item)
                }
            }

            // Collect results until first error
            var results: [ProcessedItem] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
        // ⚠️ If one task fails, others are cancelled
    }

    // Strategy 2: Collect all errors (resilience)
    func processItemsResilient(items: [Item]) async -> ([ProcessedItem], [Error]) {
        await withTaskGroup(of: Result<ProcessedItem, Error>.self) { group in
            for item in items {
                group.addTask {
                    // Wrap in Result to capture errors
                    do {
                        let result = try await self.process(item)
                        return .success(result)
                    } catch {
                        return .failure(error)
                    }
                }
            }

            // Separate successes/failures
            var successes: [ProcessedItem] = []
            var errors: [Error] = []

            for await result in group {
                switch result {
                case .success(let item):
                    successes.append(item)
                case .failure(let error):
                    errors.append(error)
                }
            }

            return (successes, errors)
        }
    }

    private func process(_ item: Item) async throws -> ProcessedItem {
        // Processing with possible error
        try await Task.sleep(nanoseconds: 100_000_000)
        return ProcessedItem(from: item)
    }
}

Kernpunt: withThrowingTaskGroup stopt bij de eerste fout (nuttig voor atomaire operaties), terwijl withTaskGroup + Result het mogelijk maakt door te gaan ondanks fouten (nuttig voor batch-verwerking).

Wat is het verschil tussen Task, Task.detached en async let?

Verwacht antwoord: Task erft de bovenliggende context (prioriteit, actor isolation), Task.detached creëert een onafhankelijke taak zonder overerving, en async let creëert een child task die automatisch wacht aan het einde van de scope.

TaskLifecycle.swiftswift
// Understanding Task creation patterns

@MainActor
class ViewModel {
    var isLoading = false

    // Scenario 1: Task inherits context (@MainActor here)
    func loadDataWithTask() {
        Task {
            // ✅ Inherits @MainActor from parent
            // No need for await MainActor.run
            self.isLoading = true
            let data = try await fetchData()
            self.isLoading = false // ✅ Always on MainActor
        }
    }

    // Scenario 2: Task.detached creates independent task
    func loadDataDetached() {
        Task.detached {
            // ⚠️ Does NOT inherit @MainActor
            let data = try await self.fetchData()

            // ❌ Error: isLoading not directly accessible
            // await MainActor.run {
            //     self.isLoading = false
            // }
        }
    }

    // Scenario 3: async let creates structured child task
    func loadMultipleData() async throws {
        // async let tasks are bound to current scope
        async let users = fetchUsers()
        async let posts = fetchPosts()

        // ⚠️ If leaving function before await, compilation error
        let (usersData, postsData) = try await (users, posts)

        // async let tasks automatically cancelled
        // if exiting scope (e.g., throw before await)
    }

    private func fetchData() async throws -> Data {
        try await URLSession.shared.data(from: URL(string: "https://api.example.com")!).0
    }

    private func fetchUsers() async throws -> [User] { [] }
    private func fetchPosts() async throws -> [Post] { [] }
}

Use cases:

  • Task: Operaties gebonden aan de huidige context (bijv. UI-update vanuit een ViewModel)
  • Task.detached: Onafhankelijke achtergrondtaken (bijv. logs, analytics)
  • async let: Parallelle operaties met resultaten nodig in de huidige scope

Hoe een timeout implementeren op een asynchrone operatie?

Verwacht antwoord: Task.sleep gebruiken in een race tussen hoofdtaak en timeout-taak met withThrowingTaskGroup, of een withTimeout-utility creëren.

AsyncTimeout.swiftswift
// Timeout implementation for async operations

enum TimeoutError: Error {
    case timedOut
}

// Generic utility to add timeout
func withTimeout<T>(
    seconds: TimeInterval,
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        // Task 1: main operation
        group.addTask {
            try await operation()
        }

        // Task 2: timeout
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw TimeoutError.timedOut
        }

        // First task to finish wins
        guard let result = try await group.next() else {
            throw TimeoutError.timedOut
        }

        // Cancel losing task (important for cleanup)
        group.cancelAll()

        return result
    }
}

// Usage example
struct NetworkService {
    func fetchUserWithTimeout(id: Int) async throws -> User {
        // 5-second timeout on network call
        try await withTimeout(seconds: 5) {
            try await self.fetchUser(id: id)
        }
    }

    private func fetchUser(id: Int) async throws -> User {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

Modern alternatief: Vanaf iOS 16, URLSession gebruiken met timeoutInterval geconfigureerd via URLSessionConfiguration specifiek voor HTTP-aanroepen.

Expliciete cancellation

group.cancelAll() is cruciaal om resources vrij te geven. Zonder dit zou de verliezende taak op de achtergrond doorgaan tot natuurlijke voltooiing en CPU en geheugen verspillen.

Hoe muteerbare staat veilig delen tussen meerdere taken?

Verwacht antwoord: Een actor gebruiken voor de gedeelde staat, of een AsyncStream om tussen taken te communiceren via een waardestroom.

SharedStateManager.swiftswift
// Safe state sharing between concurrent tasks

// Approach 1: Actor for shared state with sequential access
actor DownloadManager {
    private var activeDownloads: [String: Task<Data, Error>] = [:]
    private var cache: [String: Data] = [:]

    // Start download or return existing task
    func download(url: String) async throws -> Data {
        // Check cache first
        if let cachedData = cache[url] {
            return cachedData
        }

        // Check if download already in progress
        if let existingTask = activeDownloads[url] {
            return try await existingTask.value
        }

        // Create new download task
        let task = Task<Data, Error> {
            let data = try await self.performDownload(url: url)

            // Update cache (thread-safe via actor)
            await self.completeDownload(url: url, data: data)

            return data
        }

        activeDownloads[url] = task
        return try await task.value
    }

    private func performDownload(url: String) async throws -> Data {
        let urlObject = URL(string: url)!
        let (data, _) = try await URLSession.shared.data(from: urlObject)
        return data
    }

    private func completeDownload(url: String, data: Data) {
        cache[url] = data
        activeDownloads.removeValue(forKey: url)
    }
}

// Approach 2: AsyncStream for inter-task communication
struct EventStream {
    private let continuation: AsyncStream<Event>.Continuation
    let stream: AsyncStream<Event>

    init() {
        var continuation: AsyncStream<Event>.Continuation!
        stream = AsyncStream { cont in
            continuation = cont
        }
        self.continuation = continuation
    }

    func emit(_ event: Event) {
        continuation.yield(event)
    }

    func finish() {
        continuation.finish()
    }
}

// Example: shared progress monitoring
func processItemsWithProgress(items: [Item]) async {
    let eventStream = EventStream()

    // Task 1: Process items
    Task {
        for item in items {
            await processItem(item)
            eventStream.emit(.itemProcessed(item.id))
        }
        eventStream.finish()
    }

    // Task 2: Update UI with progress
    Task { @MainActor in
        for await event in eventStream.stream {
            switch event {
            case .itemProcessed(let id):
                print("Item \(id) processed")
            }
        }
    }
}

enum Event {
    case itemProcessed(String)
}

Architecturale keuze: Actor voor gecentraliseerde staat met businesslogica, AsyncStream voor event-driven communicatie tussen ontkoppelde componenten.

Klaar om je iOS gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Wat is Task cancellation en hoe ermee omgaan?

Verwacht antwoord: Task cancellation maakt het mogelijk lopende asynchrone operaties te annuleren. Taken moeten periodiek Task.isCancelled controleren of Task.checkCancellation() gebruiken die een fout gooit.

CancellableOperations.swiftswift
// Implementing proper task cancellation

struct ImageProcessor {
    // Cancellable processing with explicit checks
    func processImages(_ images: [UIImage]) async throws -> [ProcessedImage] {
        var results: [ProcessedImage] = []

        for (index, image) in images.enumerated() {
            // Check 1: Boolean check (continue or skip)
            if Task.isCancelled {
                print("Cancelled after \(index) images")
                break // Graceful stop
            }

            let processed = try await processImage(image)
            results.append(processed)

            // Check 2: Automatic throw if cancelled
            try Task.checkCancellation()
        }

        return results
    }

    private func processImage(_ image: UIImage) async throws -> ProcessedImage {
        // Simulate long processing
        for _ in 0..<10 {
            try await Task.sleep(nanoseconds: 100_000_000)

            // ✅ Check cancellation in long loops
            try Task.checkCancellation()
        }

        return ProcessedImage(from: image)
    }
}

// SwiftUI: Automatic cancellation when view disappears
struct ImageGalleryView: View {
    @State private var images: [ProcessedImage] = []

    var body: some View {
        ScrollView {
            // Display images
        }
        .task {
            // ✅ Task cancelled automatically when view disappears
            let processor = ImageProcessor()
            do {
                images = try await processor.processImages(sourceImages)
            } catch is CancellationError {
                print("Processing cancelled")
            }
        }
    }
}

// Manual cancellation of stored task
class DownloadViewModel {
    private var downloadTask: Task<Void, Never>?

    func startDownload() {
        downloadTask = Task {
            do {
                try await performLongDownload()
            } catch is CancellationError {
                print("Download cancelled by user")
            }
        }
    }

    func cancelDownload() {
        // Explicit cancellation of stored task
        downloadTask?.cancel()
        downloadTask = nil
    }

    private func performLongDownload() async throws {
        try Task.checkCancellation()
        // Download logic
    }
}

Kernpunten:

  • Task.isCancelled: niet-blokkerende controle (geeft een bool terug)
  • Task.checkCancellation(): gooit CancellationError als geannuleerd
  • SwiftUI-modifier .task { }: automatische cancellation bij verdwijnen van de view
Coöperatieve cancellation

Swift gebruikt een coöperatief cancellation-model: taken worden niet gedwongen beëindigd. De code moet actief Task.isCancelled of checkCancellation() controleren om op cancellation te reageren. Zonder deze controles loopt de taak onbeperkt door.

Hoe MainActor correct gebruiken in een SwiftUI-app?

Verwacht antwoord: ViewModels annoteren met @MainActor om te garanderen dat alle UI-state updates op de main thread plaatsvinden. @MainActor op individuele functies gebruiken als slechts bepaalde operaties UI raken.

MainActorPatterns.swiftswift
// Proper MainActor usage in SwiftUI architecture

// Pattern 1: Entire ViewModel @MainActor
@MainActor
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let repository: UserRepository

    init(repository: UserRepository) {
        self.repository = repository
    }

    // ✅ All methods implicitly @MainActor
    func loadUser(id: Int) async {
        isLoading = true // No need for await or MainActor.run
        errorMessage = nil

        do {
            // Network call done on background thread by runtime
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false // Always on MainActor
    }

    // Synchronous method also on MainActor
    func clearUser() {
        user = nil
        errorMessage = nil
    }
}

// Pattern 2: Selective methods with @MainActor
class DataSyncService {
    // ❌ Not @MainActor on class (no UI here)

    func syncData() async throws {
        // Background processing
        let data = try await fetchRemoteData()
        let processed = processData(data)

        // ✅ Switch to MainActor only for UI
        await updateUI(with: processed)
    }

    @MainActor
    private func updateUI(with data: ProcessedData) {
        // Update observable property
        NotificationCenter.default.post(
            name: .dataDidSync,
            object: data
        )
    }

    // Background work (not @MainActor)
    private func fetchRemoteData() async throws -> Data {
        // Network call
        Data()
    }

    private func processData(_ data: Data) -> ProcessedData {
        // CPU-intensive processing in background
        ProcessedData()
    }
}

// Pattern 3: Closure annotation
class ImageLoader {
    func loadImage(url: URL, completion: @MainActor @escaping (UIImage?) -> Void) async {
        let image = try? await downloadImage(from: url)

        // ✅ Completion guaranteed on MainActor
        await completion(image)
    }

    private func downloadImage(from url: URL) async throws -> UIImage {
        let (data, _) = try await URLSession.shared.data(from: url)
        return UIImage(data: data) ?? UIImage()
    }
}

Veelgemaakte fout: De hele klasse markeren als @MainActor wanneer slechts bepaalde methoden de UI raken. Dit dwingt alle code op de main thread, inclusief zware operaties die op de achtergrond zouden moeten draaien.

Hoe data races afhandelen met Sendable?

Verwacht antwoord: Het Sendable-protocol garandeert dat een type tussen taken kan worden gedeeld zonder risico op data races. Value types (struct, enum) zijn automatisch Sendable, klassen moeten final zijn met onveranderlijke of beschermde properties.

SendableCompliance.swiftswift
// Making types safe for concurrent access

// ✅ Struct: automatically Sendable (value type)
struct UserData: Sendable {
    let id: Int
    let name: String
    let email: String
}

// ✅ Enum: automatically Sendable
enum LoadingState: Sendable {
    case idle
    case loading
    case loaded(UserData)
    case failed(Error) // ⚠️ Error must also be Sendable
}

// ❌ Class with mutable state: not Sendable by default
class UnsafeCounter {
    var count = 0 // Mutable, unprotected

    func increment() {
        count += 1 // Data race possible
    }
}

// ✅ Immutable class: explicit Sendable
final class SafeConfig: @unchecked Sendable {
    let apiKey: String
    let timeout: TimeInterval

    init(apiKey: String, timeout: TimeInterval) {
        self.apiKey = apiKey
        self.timeout = timeout
    }
}

// ✅ Class with actor-protected state
actor SafeCounter: Sendable {
    private var count = 0 // Protected by actor isolation

    func increment() {
        count += 1 // Thread-safe automatically
    }

    func getValue() -> Int {
        return count
    }
}

// ✅ Class with manually protected state
final class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func store(_ data: Data, for key: String) {
        lock.lock()
        defer { lock.unlock() }
        storage[key] = data
    }

    func retrieve(for key: String) -> Data? {
        lock.lock()
        defer { lock.unlock() }
        return storage[key]
    }
}

// Usage: compiler checks Sendable
func processInBackground(data: UserData) { // ✅ UserData is Sendable
    Task.detached {
        // No warning: UserData is Sendable value type
        print("Processing user: \(data.name)")
    }
}

func processUnsafe(counter: UnsafeCounter) {
    Task.detached {
        // ⚠️ Warning: UnsafeCounter is not Sendable
        // counter.increment()
    }
}

Sendable-regels:

  • Struct/Enum met Sendable-properties: automatisch Sendable
  • Klassen: moeten final + onveranderlijk zijn, of @unchecked Sendable gebruiken met handmatige bescherming (locks, actors)
  • Closures: automatisch Sendable als ze alleen Sendable-types vastleggen
@unchecked Sendable

@unchecked Sendable schakelt de compiler-controles uit. Alleen gebruiken als thread safety handmatig is gegarandeerd (locks, serial queues). Het is de verantwoordelijkheid van de ontwikkelaar om data races te voorkomen.

Klaar om je iOS gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Conclusie

Het beheersen van Swift Structured Concurrency is essentieel geworden voor iOS-interviews in 2026. Recruiters toetsen drie niveaus: begrip van de concepten (async/await vs callbacks), beheersing van de patronen (TaskGroup, actor isolation) en debugging (cancellation, Sendable).

Voorbereidingschecklist:

  • ✅ async/await vs DispatchQueue uitleggen met concreet voorbeeld
  • ✅ Het gebruik van TaskGroup voor parallelle operaties demonstreren
  • ✅ Een thread-safe actor implementeren om muteerbare staat te beschermen
  • ✅ Fouten afhandelen in concurrent context (Result, throwing)
  • ✅ Task, Task.detached en async let onderscheiden met use cases
  • ✅ Een timeout implementeren op een asynchrone operatie
  • ✅ MainActor correct gebruiken in een SwiftUI-architectuur
  • ✅ Sendable begrijpen en data races vermijden

De beste kandidaten combineren theorie en praktijk: ze leggen het "waarom" uit (data races vermijden, leesbaarheid verbeteren) en het "hoe" (functionele code met foutafhandeling). Oefenen op echte projecten consolideert deze patronen.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#swift
#concurrency
#async-await
#actors
#interview

Delen

Gerelateerde artikelen