Domande di colloquio Swift Structured Concurrency: async/await, TaskGroup, Actors

Domande tecniche di colloquio Swift Structured Concurrency: async/await, TaskGroup, actors e pattern di concorrenza per iOS 2026

Domande di colloquio Swift Structured Concurrency con async/await, TaskGroup e actors

La Structured Concurrency introdotta in Swift 5.5 ha rivoluzionato la programmazione asincrona su iOS. I recruiter ora valutano la padronanza di async/await, TaskGroup e actors durante i colloqui tecnici. Ecco le domande essenziali e le risposte attese per distinguersi nei colloqui.

Competenze chiave valutate nei colloqui

I recruiter valutano tre competenze: comprensione dei concetti fondamentali (async/await, Task), padronanza dei pattern di concorrenza (TaskGroup, isolamento degli actor) e capacità di diagnosticare errori comuni (data race, deadlock).

Qual è la differenza tra async/await e DispatchQueue?

Risposta attesa: async/await fornisce concorrenza strutturata con codice sequenziale leggibile, mentre DispatchQueue utilizza callback e può portare al cosiddetto "callback hell". Swift gestisce automaticamente i thread con 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)
}

Punti chiave: async/await elimina le piramidi di callback, riduce gli errori di thread (nessun bisogno di DispatchQueue.main.async) e permette al runtime di Swift di ottimizzare l'esecuzione tra i core CPU disponibili.

Vantaggio di performance

Il runtime di Swift utilizza un thread pool ottimizzato che evita la creazione eccessiva di thread. A differenza di DispatchQueue dove ogni .async può creare un nuovo thread, async/await riutilizza i thread esistenti in modo intelligente.

Come gestire più operazioni asincrone in parallelo?

Risposta attesa: Usare async let per 2-3 task semplici, o TaskGroup per un numero dinamico di task paralleli con raccolta dei risultati.

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 }
        }
    }
}

Errore comune: Usare await sequenzialmente invece di async let parallelizza le chiamate. let user = await fetchUser(); let posts = await fetchPosts() viene eseguito sequenzialmente (lento), mentre async let lancia entrambi simultaneamente.

Cos'è un actor e perché usarlo?

Risposta attesa: Un actor è un tipo che protegge il proprio stato mutabile dalle data race garantendo l'accesso sequenziale. Sostituisce i lock manuali (NSLock, DispatchQueue) per proteggere l'accesso concorrente.

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")

Punti chiave: L'actor garantisce che un solo thread acceda al suo stato alla volta. Il compilatore impone l'uso di await per le chiamate esterne, rendendo espliciti i potenziali punti di sospensione.

Trappola del MainActor

@MainActor è un actor globale per le operazioni UI. Marcare una classe come @MainActor forza tutti i suoi metodi a eseguire sul main thread. Attenzione alle chiamate bloccanti che possono congelare l'interfaccia.

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Come gestire gli errori in un TaskGroup?

Risposta attesa: withThrowingTaskGroup propaga il primo errore incontrato e cancella automaticamente i task rimanenti. Per raccogliere tutti gli errori, usare Result nel 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)
    }
}

Punto chiave: withThrowingTaskGroup si ferma al primo errore (utile per operazioni atomiche), mentre withTaskGroup + Result permette di continuare nonostante gli errori (utile per il batch processing).

Qual è la differenza tra Task, Task.detached e async let?

Risposta attesa: Task eredita il contesto padre (priorità, isolamento dell'actor), Task.detached crea un task indipendente senza ereditarietà, e async let crea un task figlio che attende automaticamente alla fine dello 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] { [] }
}

Casi d'uso:

  • Task: Operazioni legate al contesto corrente (es. aggiornamento UI da un ViewModel)
  • Task.detached: Task indipendenti in background (es. log, analytics)
  • async let: Operazioni parallele con risultati necessari nello scope corrente

Come implementare un timeout su un'operazione asincrona?

Risposta attesa: Usare Task.sleep in una corsa tra task principale e task di timeout con withThrowingTaskGroup, o creare un'utility withTimeout.

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)
    }
}

Alternativa moderna: Da iOS 16, usare URLSession con timeoutInterval configurato tramite URLSessionConfiguration specificamente per le chiamate HTTP.

Cancellazione esplicita

group.cancelAll() è cruciale per liberare le risorse. Senza di esso, il task perdente continuerebbe in background fino al completamento naturale, sprecando CPU e memoria.

Come condividere stato mutabile in modo sicuro tra più task?

Risposta attesa: Usare un actor per lo stato condiviso, o un AsyncStream per comunicare tra task tramite un flusso di valori.

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)
}

Scelta architetturale: Actor per stato centralizzato con logica di business, AsyncStream per comunicazione event-driven tra componenti disaccoppiati.

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Cos'è la cancellazione di Task e come gestirla?

Risposta attesa: La cancellazione di Task permette di annullare le operazioni asincrone in corso. I task devono verificare periodicamente Task.isCancelled o usare Task.checkCancellation() che lancia un errore.

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
    }
}

Punti chiave:

  • Task.isCancelled: verifica non bloccante (restituisce un bool)
  • Task.checkCancellation(): lancia CancellationError se cancellato
  • Modificatore SwiftUI .task { }: cancellazione automatica alla scomparsa della view
Cancellazione cooperativa

Swift utilizza un modello di cancellazione cooperativa: i task non vengono terminati forzatamente. Il codice deve verificare attivamente Task.isCancelled o checkCancellation() per reagire alla cancellazione. Senza questi controlli, il task continua indefinitamente.

Come usare correttamente MainActor in un'app SwiftUI?

Risposta attesa: Annotare i ViewModel con @MainActor per garantire che tutti gli aggiornamenti dello stato UI avvengano sul main thread. Usare @MainActor su funzioni individuali se solo certe operazioni toccano l'UI.

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()
    }
}

Errore comune: Marcare l'intera classe come @MainActor quando solo certi metodi toccano l'UI. Questo costringe tutto il codice sul main thread, comprese le operazioni pesanti che dovrebbero stare in background.

Come gestire le data race con Sendable?

Risposta attesa: Il protocollo Sendable garantisce che un tipo possa essere condiviso tra task senza rischio di data race. I tipi value (struct, enum) sono automaticamente Sendable, le classi devono essere final con proprietà immutabili o protette.

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()
    }
}

Regole Sendable:

  • Struct/Enum con proprietà Sendable: automaticamente Sendable
  • Classi: devono essere final + immutabili, o usare @unchecked Sendable con protezione manuale (lock, actor)
  • Closure: automaticamente Sendable se catturano solo tipi Sendable
@unchecked Sendable

@unchecked Sendable disabilita i controlli del compilatore. Usare solo se la thread-safety è garantita manualmente (lock, serial queue). È responsabilità dello sviluppatore evitare le data race.

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Conclusione

Padroneggiare Swift Structured Concurrency è diventato essenziale per i colloqui iOS nel 2026. I recruiter valutano tre livelli: comprensione dei concetti (async/await vs callback), padronanza dei pattern (TaskGroup, isolamento dell'actor) e debugging (cancellazione, Sendable).

Checklist di preparazione:

  • ✅ Spiegare async/await vs DispatchQueue con esempio concreto
  • ✅ Dimostrare l'uso di TaskGroup per operazioni parallele
  • ✅ Implementare un actor thread-safe per proteggere stato mutabile
  • ✅ Gestire errori in contesto concorrente (Result, throwing)
  • ✅ Differenziare Task, Task.detached e async let con casi d'uso
  • ✅ Implementare un timeout su un'operazione asincrona
  • ✅ Usare correttamente MainActor in un'architettura SwiftUI
  • ✅ Comprendere Sendable ed evitare le data race

I migliori candidati combinano teoria e pratica: spiegano il "perché" (evitare data race, migliorare leggibilità) e il "come" (codice funzionale con gestione degli errori). Esercitarsi su progetti reali per consolidare questi pattern.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

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

Condividi

Articoli correlati