Questions entretien Swift Structured Concurrency : async/await, TaskGroup, actors

Questions techniques sur Swift Structured Concurrency en entretien : async/await, TaskGroup, actors et patterns de concurrence pour iOS 2026

Questions d'entretien Swift Structured Concurrency avec async/await, TaskGroup et actors

La concurrence structurée introduite dans Swift 5.5 a révolutionné la programmation asynchrone sur iOS. Les recruteurs testent désormais la maîtrise d'async/await, TaskGroup et des actors lors des entretiens techniques. Voici les questions essentielles et les réponses attendues pour briller en entretien.

Points clés testés en entretien

Les recruteurs évaluent trois compétences : la compréhension des concepts fondamentaux (async/await, Task), la maîtrise des patterns de concurrence (TaskGroup, actor isolation) et la capacité à diagnostiquer les erreurs courantes (data races, deadlocks).

Quelle est la différence entre async/await et DispatchQueue ?

Réponse attendue : async/await offre une concurrence structurée avec un code séquentiel lisible, tandis que DispatchQueue utilise des callbacks et peut mener à du "callback hell". Swift gère automatiquement les threads avec async/await.

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

// ❌ Ancien style : DispatchQueue avec callbacks
func fetchUserOld(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        // Appel réseau simulé
        let result = self.performNetworkRequest(id: id)
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

// ✅ Nouveau style : async/await plus lisible
func fetchUser(id: Int) async throws -> User {
    // Le runtime Swift gère les threads automatiquement
    // Pas besoin de basculer manuellement entre queues
    return try await performNetworkRequest(id: id)
}

Points clés : async/await élimine les pyramides de callbacks, réduit les erreurs de thread (pas besoin de DispatchQueue.main.async) et permet au runtime Swift d'optimiser l'exécution sur les CPU cores disponibles.

Avantage performance

Le runtime Swift utilise un thread pool optimisé qui évite la création excessive de threads. Contrairement à DispatchQueue où chaque .async peut créer un nouveau thread, async/await réutilise intelligemment les threads existants.

Comment gérer plusieurs opérations asynchrones en parallèle ?

Réponse attendue : Utiliser async let pour 2-3 tâches simples, ou TaskGroup pour un nombre dynamique de tâches parallèles avec collecte des résultats.

DataFetcher.swiftswift
// Parallel async operations strategies

struct DataFetcher {
    // Stratégie 1: async let pour tâches fixes (2-4 opérations)
    func loadDashboard() async throws -> Dashboard {
        // Lancement en parallèle de 3 requêtes
        async let user = fetchUser()
        async let posts = fetchPosts()
        async let notifications = fetchNotifications()

        // Attente des résultats (en parallèle, pas séquentiel)
        let (userData, postsData, notificationsData) = try await (user, posts, notifications)

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

    // Stratégie 2: TaskGroup pour nombre dynamique de tâches
    func downloadImages(urls: [URL]) async throws -> [UIImage] {
        // TaskGroup permet de gérer N tâches avec collecte des résultats
        try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
            // Lancement d'une tâche par 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) // Retourne index pour conserver l'ordre
                }
            }

            // Collecte des résultats dans l'ordre
            var images = [UIImage?](repeating: nil, count: urls.count)
            for try await (index, image) in group {
                images[index] = image
            }

            return images.compactMap { $0 }
        }
    }
}

Erreur courante : Utiliser await séquentiellement au lieu de async let parallélise les appels. let user = await fetchUser(); let posts = await fetchPosts() exécute séquentiellement (lent), tandis que async let lance les deux en même temps.

Qu'est-ce qu'un actor et pourquoi l'utiliser ?

Réponse attendue : Un actor est un type qui protège son état mutable contre les data races en garantissant un accès séquentiel. Il remplace les locks manuels (NSLock, DispatchQueue) pour sécuriser les accès concurrents.

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

// ❌ Classe classique : risque de data race
class UnsafeCache {
    private var cache: [String: Data] = [:] // Pas thread-safe !

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

// ✅ Actor : protection automatique contre les races
actor SafeCache {
    private var cache: [String: Data] = [:]

    // Accès séquentiels garantis par l'actor isolation
    func store(_ data: Data, for key: String) {
        cache[key] = data // ✅ Thread-safe automatiquement
    }

    func retrieve(for key: String) -> Data? {
        return cache[key] // ✅ Lecture protégée
    }

    // Méthode synchrone interne (pas d'await nécessaire)
    nonisolated func clearAll() async {
        // nonisolated permet d'appeler depuis n'importe quel contexte
        await self.clear()
    }

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

// Utilisation : await requis pour accéder à l'actor
let cache = SafeCache()
await cache.store(data, for: "user_123") // Await obligatoire
let cachedData = await cache.retrieve(for: "user_123")

Points clés : L'actor garantit qu'un seul thread accède à son état à la fois. Le compilateur force l'utilisation d'await pour les appels externes, ce qui rend les potentiels suspension points explicites.

Piège MainActor

@MainActor est un actor global pour les opérations UI. Marquer une classe @MainActor force toutes ses méthodes à s'exécuter sur le main thread. Attention aux appels bloquants qui peuvent figer l'interface !

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Comment gérer les erreurs dans une TaskGroup ?

Réponse attendue : withThrowingTaskGroup propage la première erreur rencontrée et annule automatiquement les tâches restantes. Pour collecter toutes les erreurs, utiliser Result dans le TaskGroup.

BatchProcessor.swiftswift
// Error handling strategies in TaskGroup

struct BatchProcessor {
    // Stratégie 1: Propagation de la première erreur (échec rapide)
    func processItemsFastFail(items: [Item]) async throws -> [ProcessedItem] {
        try await withThrowingTaskGroup(of: ProcessedItem.self) { group in
            for item in items {
                group.addTask {
                    // Si une tâche throw, le group annule les autres
                    try await self.process(item)
                }
            }

            // Collecte des résultats jusqu'à la première erreur
            var results: [ProcessedItem] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
        // ⚠️ Si une tâche échoue, les autres sont annulées
    }

    // Stratégie 2: Collecte de toutes les erreurs (résilience)
    func processItemsResilient(items: [Item]) async -> ([ProcessedItem], [Error]) {
        await withTaskGroup(of: Result<ProcessedItem, Error>.self) { group in
            for item in items {
                group.addTask {
                    // Encapsulation dans Result pour capturer les erreurs
                    do {
                        let result = try await self.process(item)
                        return .success(result)
                    } catch {
                        return .failure(error)
                    }
                }
            }

            // Séparation succès/échecs
            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 {
        // Traitement avec possibilité d'erreur
        try await Task.sleep(nanoseconds: 100_000_000)
        return ProcessedItem(from: item)
    }
}

Point clé : withThrowingTaskGroup stoppe dès la première erreur (utile pour les opérations atomiques), tandis que withTaskGroup + Result permet de continuer malgré les erreurs (utile pour les traitements par batch).

Quelle est la différence entre Task, Task.detached et async let ?

Réponse attendue : Task hérite du contexte parent (priorité, actor isolation), Task.detached crée une tâche indépendante sans héritage, et async let crée une tâche enfant qui attend automatiquement à la fin du scope.

TaskLifecycle.swiftswift
// Understanding Task creation patterns

@MainActor
class ViewModel {
    var isLoading = false

    // Scénario 1: Task hérite du contexte (@MainActor ici)
    func loadDataWithTask() {
        Task {
            // ✅ Hérite @MainActor du parent
            // Pas besoin de await MainActor.run
            self.isLoading = true
            let data = try await fetchData()
            self.isLoading = false // ✅ Toujours sur MainActor
        }
    }

    // Scénario 2: Task.detached crée une tâche indépendante
    func loadDataDetached() {
        Task.detached {
            // ⚠️ N'hérite PAS de @MainActor
            let data = try await self.fetchData()

            // ❌ Erreur : isLoading n'est pas accessible directement
            // await MainActor.run {
            //     self.isLoading = false
            // }
        }
    }

    // Scénario 3: async let crée une tâche enfant structurée
    func loadMultipleData() async throws {
        // Les tâches async let sont liées au scope actuel
        async let users = fetchUsers()
        async let posts = fetchPosts()

        // ⚠️ Si on quitte la fonction avant await, compilation error
        let (usersData, postsData) = try await (users, posts)

        // Les tâches async let sont automatiquement annulées
        // si on sort du scope (ex: throw avant le 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] { [] }
}

Cas d'usage :

  • Task : Opérations liées au contexte actuel (ex: mise à jour UI depuis un ViewModel)
  • Task.detached : Tâches en arrière-plan indépendantes (ex: logs, analytics)
  • async let : Opérations parallèles avec résultats nécessaires dans le scope actuel

Comment implémenter un timeout sur une opération async ?

Réponse attendue : Utiliser Task.sleep dans une race entre la tâche principale et une tâche timeout avec withThrowingTaskGroup, ou créer un utilitaire withTimeout.

AsyncTimeout.swiftswift
// Timeout implementation for async operations

enum TimeoutError: Error {
    case timedOut
}

// Utilitaire générique pour ajouter un timeout
func withTimeout<T>(
    seconds: TimeInterval,
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        // Tâche 1: l'opération principale
        group.addTask {
            try await operation()
        }

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

        // Première tâche qui termine gagne
        guard let result = try await group.next() else {
            throw TimeoutError.timedOut
        }

        // Annule la tâche perdante (importante pour cleanup)
        group.cancelAll()

        return result
    }
}

// Exemple d'utilisation
struct NetworkService {
    func fetchUserWithTimeout(id: Int) async throws -> User {
        // Timeout de 5 secondes sur l'appel réseau
        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)
    }
}

Alternative moderne : À partir d'iOS 16, utiliser URLSession avec timeoutInterval configuré via URLSessionConfiguration pour les appels HTTP spécifiquement.

Annulation explicite

group.cancelAll() est crucial pour libérer les ressources. Sans cela, la tâche perdante continuerait en arrière-plan jusqu'à sa complétion naturelle, gaspillant CPU et mémoire.

Comment partager un état mutable entre plusieurs tasks de manière sécurisée ?

Réponse attendue : Utiliser un actor pour l'état partagé, ou AsyncStream pour communiquer entre tasks via un flux de valeurs.

SharedStateManager.swiftswift
// Safe state sharing between concurrent tasks

// Approche 1: Actor pour état partagé avec accès séquentiel
actor DownloadManager {
    private var activeDownloads: [String: Task<Data, Error>] = [:]
    private var cache: [String: Data] = [:]

    // Démarre un téléchargement ou retourne la tâche existante
    func download(url: String) async throws -> Data {
        // Vérifie le cache d'abord
        if let cachedData = cache[url] {
            return cachedData
        }

        // Vérifie si un téléchargement est déjà en cours
        if let existingTask = activeDownloads[url] {
            return try await existingTask.value
        }

        // Crée une nouvelle tâche de téléchargement
        let task = Task<Data, Error> {
            let data = try await self.performDownload(url: url)

            // Mise à jour du 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)
    }
}

// Approche 2: AsyncStream pour communication inter-tasks
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()
    }
}

// Exemple: monitoring de progression partagée
func processItemsWithProgress(items: [Item]) async {
    let eventStream = EventStream()

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

    // Task 2: Mise à jour UI avec la progression
    Task { @MainActor in
        for await event in eventStream.stream {
            switch event {
            case .itemProcessed(let id):
                print("Item \(id) processed")
            }
        }
    }
}

enum Event {
    case itemProcessed(String)
}

Choix architecture : Actor pour état centralisé avec logique métier, AsyncStream pour communication événementielle entre composants découplés.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Qu'est-ce que le Task cancellation et comment le gérer ?

Réponse attendue : Le Task cancellation permet d'annuler des opérations asynchrones en cours. Les tasks doivent vérifier périodiquement Task.isCancelled ou utiliser Task.checkCancellation() qui throw une erreur.

CancellableOperations.swiftswift
// Implementing proper task cancellation

struct ImageProcessor {
    // Traitement annulable avec vérifications explicites
    func processImages(_ images: [UIImage]) async throws -> [ProcessedImage] {
        var results: [ProcessedImage] = []

        for (index, image) in images.enumerated() {
            // Vérification 1: Check booléen (continue ou skip)
            if Task.isCancelled {
                print("Cancelled after \(index) images")
                break // Arrêt graceful
            }

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

            // Vérification 2: Throw automatique si annulé
            try Task.checkCancellation()
        }

        return results
    }

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

            // ✅ Vérifie l'annulation dans les boucles longues
            try Task.checkCancellation()
        }

        return ProcessedImage(from: image)
    }
}

// SwiftUI: Annulation automatique lors de la disparition de la vue
struct ImageGalleryView: View {
    @State private var images: [ProcessedImage] = []

    var body: some View {
        ScrollView {
            // Affichage des images
        }
        .task {
            // ✅ Task annulée automatiquement quand la vue disparaît
            let processor = ImageProcessor()
            do {
                images = try await processor.processImages(sourceImages)
            } catch is CancellationError {
                print("Processing cancelled")
            }
        }
    }
}

// Annulation manuelle d'une task stockée
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() {
        // Annulation explicite de la task stockée
        downloadTask?.cancel()
        downloadTask = nil
    }

    private func performLongDownload() async throws {
        try Task.checkCancellation()
        // Logique de téléchargement
    }
}

Points clés :

  • Task.isCancelled : vérification non-bloquante (retourne bool)
  • Task.checkCancellation() : throw CancellationError si annulé
  • .task { } modifier SwiftUI : annulation automatique à la disparition de la vue
Annulation coopérative

Swift utilise un modèle d'annulation coopératif : les tasks ne sont pas tuées de force. Le code doit vérifier activement Task.isCancelled ou checkCancellation() pour réagir à l'annulation. Sans ces vérifications, la task continue indéfiniment.

Comment utiliser MainActor correctement dans une app SwiftUI ?

Réponse attendue : Annoter les ViewModels @MainActor pour garantir que toutes les mises à jour d'état UI se font sur le main thread. Utiliser @MainActor sur les fonctions individuelles si seulement certaines opérations touchent l'UI.

MainActorPatterns.swiftswift
// Proper MainActor usage in SwiftUI architecture

// Pattern 1: ViewModel entièrement @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
    }

    // ✅ Toutes les méthodes sont implicitement @MainActor
    func loadUser(id: Int) async {
        isLoading = true // Pas besoin de await ou MainActor.run
        errorMessage = nil

        do {
            // L'appel réseau est fait sur un background thread par le runtime
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false // Toujours sur MainActor
    }

    // Méthode synchrone aussi sur MainActor
    func clearUser() {
        user = nil
        errorMessage = nil
    }
}

// Pattern 2: Méthodes sélectives avec @MainActor
class DataSyncService {
    // ❌ Pas @MainActor sur la classe (pas d'UI ici)

    func syncData() async throws {
        // Traitement en background
        let data = try await fetchRemoteData()
        let processed = processData(data)

        // ✅ Bascule sur MainActor uniquement pour l'UI
        await updateUI(with: processed)
    }

    @MainActor
    private func updateUI(with data: ProcessedData) {
        // Mise à jour d'une property observable
        NotificationCenter.default.post(
            name: .dataDidSync,
            object: data
        )
    }

    // Background work (pas @MainActor)
    private func fetchRemoteData() async throws -> Data {
        // Appel réseau
        Data()
    }

    private func processData(_ data: Data) -> ProcessedData {
        // Traitement CPU-intensif en background
        ProcessedData()
    }
}

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

        // ✅ Completion est garantie sur 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()
    }
}

Erreur courante : Marquer toute la classe @MainActor alors que seules certaines méthodes touchent l'UI. Cela force tout le code sur le main thread, même les opérations lourdes qui devraient être en background.

Comment gérer les data races avec Sendable ?

Réponse attendue : Le protocole Sendable garantit qu'un type peut être partagé entre tasks sans risque de data race. Les types valeurs (struct, enum) sont automatiquement Sendable, les classes doivent être final avec des propriétés immuables ou protégées.

SendableCompliance.swiftswift
// Making types safe for concurrent access

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

// ✅ Enum : automatiquement Sendable
enum LoadingState: Sendable {
    case idle
    case loading
    case loaded(UserData)
    case failed(Error) // ⚠️ Error doit aussi être Sendable
}

// ❌ Classe avec état mutable : pas Sendable par défaut
class UnsafeCounter {
    var count = 0 // Mutable, non protégé

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

// ✅ Classe immutable : Sendable explicite
final class SafeConfig: @unchecked Sendable {
    let apiKey: String
    let timeout: TimeInterval

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

// ✅ Classe avec état protégé par actor
actor SafeCounter: Sendable {
    private var count = 0 // Protégé par actor isolation

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

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

// ✅ Classe avec état protégé manuellement
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]
    }
}

// Utilisation : le compilateur vérifie Sendable
func processInBackground(data: UserData) { // ✅ UserData est Sendable
    Task.detached {
        // Pas de warning : UserData est un type valeur Sendable
        print("Processing user: \(data.name)")
    }
}

func processUnsafe(counter: UnsafeCounter) {
    Task.detached {
        // ⚠️ Warning : UnsafeCounter n'est pas Sendable
        // counter.increment()
    }
}

Règles Sendable :

  • Struct/Enum avec propriétés Sendable : automatiquement Sendable
  • Classes : doivent être final + immutables, ou utiliser @unchecked Sendable avec protection manuelle (locks, actors)
  • Closures : automatiquement Sendable si capturent uniquement des types Sendable
@unchecked Sendable

@unchecked Sendable désactive les vérifications du compilateur. À utiliser uniquement si la thread-safety est garantie manuellement (locks, serial queues). Responsabilité du développeur d'éviter les data races.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Conclusion

La maîtrise de Swift Structured Concurrency est devenue incontournable pour les entretiens iOS en 2026. Les recruteurs testent trois niveaux : compréhension des concepts (async/await vs callbacks), maîtrise des patterns (TaskGroup, actor isolation) et debugging (cancellation, Sendable).

Checklist de préparation :

  • ✅ Expliquer async/await vs DispatchQueue avec un exemple concret
  • ✅ Démontrer l'utilisation de TaskGroup pour des opérations parallèles
  • ✅ Implémenter un actor thread-safe pour protéger un état mutable
  • ✅ Gérer les erreurs dans un contexte concurrent (Result, throwing)
  • ✅ Différencier Task, Task.detached et async let avec leurs cas d'usage
  • ✅ Implémenter un timeout sur une opération asynchrone
  • ✅ Utiliser MainActor correctement dans une architecture SwiftUI
  • ✅ Comprendre Sendable et éviter les data races

Les meilleurs candidats combinent théorie et pratique : expliquer le "pourquoi" (éviter les data races, améliorer la lisibilité) et le "comment" (code fonctionnel avec gestion d'erreurs). Entraînez-vous sur des projets réels pour consolider ces patterns.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

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

Partager

Articles similaires