Preguntas de entrevista Swift Structured Concurrency: async/await, TaskGroup, Actors

Preguntas técnicas de entrevista Swift Structured Concurrency: async/await, TaskGroup, actors y patrones de concurrencia para iOS 2026

Preguntas de entrevista Swift Structured Concurrency con async/await, TaskGroup y actors

Structured Concurrency introducida en Swift 5.5 revolucionó la programación asíncrona en iOS. Los reclutadores ahora evalúan el dominio de async/await, TaskGroup y actors durante las entrevistas técnicas. A continuación se presentan las preguntas esenciales y las respuestas esperadas para destacar en las entrevistas.

Habilidades clave evaluadas en entrevistas

Los reclutadores evalúan tres competencias: comprensión de los conceptos fundamentales (async/await, Task), dominio de los patrones de concurrencia (TaskGroup, aislamiento de actor) y capacidad para diagnosticar errores comunes (data races, deadlocks).

¿Cuál es la diferencia entre async/await y DispatchQueue?

Respuesta esperada: async/await ofrece concurrencia estructurada con código secuencial legible, mientras que DispatchQueue utiliza callbacks y puede provocar el llamado "callback hell". Swift gestiona los hilos automáticamente 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)
}

Puntos clave: async/await elimina las pirámides de callbacks, reduce los errores de threads (no se necesita DispatchQueue.main.async) y permite al runtime de Swift optimizar la ejecución entre los núcleos de CPU disponibles.

Ventaja de rendimiento

El runtime de Swift utiliza un thread pool optimizado que evita la creación excesiva de hilos. A diferencia de DispatchQueue donde cada .async puede crear un nuevo hilo, async/await reutiliza los hilos existentes de forma inteligente.

¿Cómo gestionar varias operaciones asíncronas en paralelo?

Respuesta esperada: Usar async let para 2-3 tareas simples, o TaskGroup para un número dinámico de tareas paralelas con recopilación de resultados.

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

Error común: Usar await secuencialmente en lugar de async let paraleliza las llamadas. let user = await fetchUser(); let posts = await fetchPosts() se ejecuta de forma secuencial (lento), mientras que async let lanza ambas simultáneamente.

¿Qué es un actor y por qué utilizarlo?

Respuesta esperada: Un actor es un tipo que protege su estado mutable contra los data races garantizando un acceso secuencial. Reemplaza los locks manuales (NSLock, DispatchQueue) para asegurar el acceso concurrente.

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

Puntos clave: El actor garantiza que solo un hilo acceda a su estado a la vez. El compilador impone el uso de await para las llamadas externas, haciendo explícitos los puntos de suspensión potenciales.

Trampa del MainActor

@MainActor es un actor global para las operaciones de UI. Marcar una clase @MainActor fuerza a todos sus métodos a ejecutarse en el hilo principal. Cuidado con las llamadas bloqueantes que pueden congelar la interfaz.

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

¿Cómo gestionar los errores en un TaskGroup?

Respuesta esperada: withThrowingTaskGroup propaga el primer error encontrado y cancela automáticamente las tareas restantes. Para recopilar todos los errores, usar Result en el 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 clave: withThrowingTaskGroup se detiene en el primer error (útil para operaciones atómicas), mientras que withTaskGroup + Result permite continuar a pesar de los errores (útil para procesamiento por lotes).

¿Cuál es la diferencia entre Task, Task.detached y async let?

Respuesta esperada: Task hereda el contexto padre (prioridad, aislamiento de actor), Task.detached crea una tarea independiente sin herencia, y async let crea una tarea hija que espera automáticamente al final del 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] { [] }
}

Casos de uso:

  • Task: Operaciones vinculadas al contexto actual (por ejemplo, actualización de UI desde un ViewModel)
  • Task.detached: Tareas independientes en background (por ejemplo, logs, analytics)
  • async let: Operaciones paralelas con resultados necesarios en el scope actual

¿Cómo implementar un timeout en una operación asíncrona?

Respuesta esperada: Usar Task.sleep en una carrera entre la tarea principal y una tarea de timeout con withThrowingTaskGroup, o crear una utilidad 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: Desde iOS 16, usar URLSession con timeoutInterval configurado vía URLSessionConfiguration específicamente para llamadas HTTP.

Cancelación explícita

group.cancelAll() es crucial para liberar recursos. Sin él, la tarea perdedora continuaría en background hasta su finalización natural, desperdiciando CPU y memoria.

¿Cómo compartir un estado mutable de forma segura entre varias tareas?

Respuesta esperada: Usar un actor para el estado compartido, o un AsyncStream para comunicarse entre tareas a través de un flujo de valores.

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

Elección arquitectónica: Actor para estado centralizado con lógica de negocio, AsyncStream para comunicación basada en eventos entre componentes desacoplados.

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

¿Qué es la cancelación de Task y cómo gestionarla?

Respuesta esperada: La cancelación de Task permite cancelar operaciones asíncronas en curso. Las tareas deben verificar periódicamente Task.isCancelled o usar Task.checkCancellation() que lanza un error.

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

Puntos clave:

  • Task.isCancelled: verificación no bloqueante (devuelve un bool)
  • Task.checkCancellation(): lanza CancellationError si está cancelada
  • Modificador SwiftUI .task { }: cancelación automática al desaparecer la vista
Cancelación cooperativa

Swift utiliza un modelo de cancelación cooperativa: las tareas no se matan a la fuerza. El código debe verificar activamente Task.isCancelled o checkCancellation() para reaccionar a la cancelación. Sin estas verificaciones, la tarea continúa indefinidamente.

¿Cómo usar MainActor correctamente en una app SwiftUI?

Respuesta esperada: Anotar los ViewModels con @MainActor para garantizar que todas las actualizaciones de estado UI ocurran en el hilo principal. Usar @MainActor en funciones individuales si solo ciertas operaciones tocan la 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()
    }
}

Error común: Marcar la clase entera como @MainActor cuando solo ciertos métodos tocan la UI. Esto fuerza todo el código en el hilo principal, incluyendo operaciones pesadas que deberían estar en background.

¿Cómo gestionar los data races con Sendable?

Respuesta esperada: El protocolo Sendable garantiza que un tipo puede compartirse entre tareas sin riesgo de data race. Los tipos por valor (struct, enum) son automáticamente Sendable, las clases deben ser final con propiedades inmutables o protegidas.

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

Reglas Sendable:

  • Struct/Enum con propiedades Sendable: automáticamente Sendable
  • Clases: deben ser final + inmutables, o usar @unchecked Sendable con protección manual (locks, actors)
  • Closures: automáticamente Sendable si solo capturan tipos Sendable
@unchecked Sendable

@unchecked Sendable desactiva las verificaciones del compilador. Usar solo si la thread-safety está garantizada manualmente (locks, serial queues). Es responsabilidad del desarrollador evitar los data races.

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Conclusión

Dominar Swift Structured Concurrency se ha vuelto esencial para las entrevistas iOS en 2026. Los reclutadores evalúan tres niveles: comprensión de los conceptos (async/await vs callbacks), dominio de los patrones (TaskGroup, aislamiento de actor) y debugging (cancelación, Sendable).

Checklist de preparación:

  • ✅ Explicar async/await vs DispatchQueue con un ejemplo concreto
  • ✅ Demostrar el uso de TaskGroup para operaciones paralelas
  • ✅ Implementar un actor thread-safe para proteger un estado mutable
  • ✅ Gestionar errores en contexto concurrente (Result, throwing)
  • ✅ Diferenciar Task, Task.detached y async let con casos de uso
  • ✅ Implementar un timeout en una operación asíncrona
  • ✅ Usar MainActor correctamente en una arquitectura SwiftUI
  • ✅ Comprender Sendable y evitar los data races

Los mejores candidatos combinan teoría y práctica: explican el "por qué" (evitar data races, mejorar legibilidad) y el "cómo" (código funcional con manejo de errores). Practicar en proyectos reales para consolidar estos patrones.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

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

Compartir

Artículos relacionados