Published on

Building a Serial Task Executor in Swift

Authors

Building a Serial Task Executor in Swift

Hey fellow developers! After wrestling with some tricky async execution patterns in one of my recent projects, I thought I'd share a neat solution I built: a serial executor for Swift's async/await tasks. If you've ever needed to ensure your async operations run in a predictable order (while still leveraging Swift's modern concurrency), you're going to love this.

The Promise of Swift Concurrency

Swift's concurrency model with async/await is beautiful. When we write code like this:

struct DataOperations {
    func fetch() async throws {
        try await Task.sleep(for: .seconds(1))
        print("Fetched data")
    }

    func create() async throws {
        try await Task.sleep(for: .seconds(1))
        print("Create data")
    }
}

let dataOperations = DataOperations()

Task {
    try? await dataOperations.fetch()
    try? await dataOperations.create()
}

We get a guarantee that create() won't execute until fetch() is complete. This sequential behavior is exactly what we want in many scenarios. But what happens when we need to initiate multiple async operations concurrently while still maintaining a specific order for their results?

When Concurrency Gets Messy

To demonstrate the problem, I've created a sample project that tracks async task execution through UI messages. Each operation adds a "start" message when it begins and a "finish" message when it completes (or an "error" message if things go wrong).

Let's compare two approaches to executing async tasks:

Approach 1: Sequential Execution

First, let's run tasks sequentially within a single Task group:

Task {
    await fetchNotes(taskNumber: 1)
    await fetchNote(taskNumber: 2)
    await addNote(taskNumber: 3)
    await fetchNotes(taskNumber: 4)
    await updateNote(taskNumber: 5)
    await fetchNotes(taskNumber: 6)
    await deleteNote(taskNumber: 7)
}

Running this code gives us perfectly ordered execution - each task completes before the next begins:

Sequential Execution

Great! But there's a problem - In reality these async functions can be initiated anytime by a user click or a UI on appear function.

Approach 2: Concurrent Execution

So let's try running each task in its own Task block:

.onAppear {
    Task { await fetchNotes(taskNumber: 1) }
    Task { await fetchNote(taskNumber: 2) }
    Task { await addNote(taskNumber: 3) }
    Task { await fetchNotes(taskNumber: 4) }
    Task { await updateNote(taskNumber: 5) }
    Task { await fetchNotes(taskNumber: 6) }
    Task { await deleteNote(taskNumber: 7) }
}

Now our tasks start in the order we specified, but they finish... whenever they finish! Check out this chaos:

Random Completion Order

This is problematic for many real-world scenarios. Imagine you're:

  • Maintaining a local cache that requires operations in a specific order
  • Implementing a wizard-like flow where steps depend on previous results
  • Supporting offline-first functionality with a strict write sequence

You need both concurrency AND ordered results. What's a developer to do?

Actors: A Promising Start (That Doesn't Quite Work)

My first thought was to use Swift actors. Since actors serialize access to their methods, they seemed like the perfect solution:

actor NotesRepository {
    let service: NotesService

    init(service: NotesService) {
        self.service = service
    }

    func notes() async throws(NotesError) -> [Note] {
        do {
            return try await service.fetchNotes().toNotes()
        } catch {
            throw .fetchNotesError
        }
    }

    // Other methods...
}

I made the repository an actor, expecting ordered execution. But when I ran it:

Incorrect Order with Actor

Wait, what? The tasks are still completing out of order! This is where I stumbled upon one of Swift's more interesting concurrency details: actor reentrancy.

The Actor Reentrancy Problem

Actors in Swift aren't just simple mutex locks around objects. They're smarter than that, but this creates some surprising behavior:

When an actor method hits an await point, the actor becomes available to handle other tasks. This helps prevent deadlocks, but it means that:

  1. Task A calls into the actor and suspends at an await
  2. While Task A is suspended, Task B can start executing in the actor
  3. If Task B completes before Task A resumes, the results arrive out of order

It's not that the actor is failing - it's actually working as designed! The actor guarantees that only one piece of code accesses its mutable state at once, but it doesn't guarantee that tasks complete in the order they were called.

Building a Proper Serial Executor

After hitting this wall with actors, I decided to build a custom solution. The goal: create a queue that processes async tasks one after another, making sure each task completely finishes before the next one starts.

Let's build it up step by step:

Version 1: Basic Serial Execution

Here's my first attempt:

actor SerialQueue1 {
    private var isExecuting = false
    private var taskQueue: [( () async -> Void, CheckedContinuation<Void, Never> )] = []

    func enqueue(_ task: @escaping () async -> Void) async {
        await withCheckedContinuation { continuation in
            taskQueue.append(({ await task() }, continuation))

            if !isExecuting {
                isExecuting = true
                Task {
                    await processQueue()
                }
            }
        }
    }

    private func processQueue() async {
        while !taskQueue.isEmpty {
            let (task, continuation) = taskQueue.removeFirst()
            await task()
            continuation.resume()
        }
        isExecuting = false
    }
}

The key insights here:

  1. We use an actor to protect our queue state
  2. Each call to enqueue suspends the caller with a continuation
  3. The processQueue method executes each task to completion before starting the next
  4. Each caller only resumes once their specific task is done

This works! Tasks execute serially, and the caller of enqueue waits until their specific task completes. But there's a problem - we can't handle errors.

Version 2: Adding Error Handling

Let's improve our queue to propagate errors back to callers:

actor SerialQueue2 {
    private var isExecuting = false
    private var taskQueue: [(() async throws -> Void, CheckedContinuation<Void, Error>)] = []

    func enqueue(_ task: @escaping () async throws -> Void) async throws {
        try await withCheckedThrowingContinuation { continuation in
            taskQueue.append(({ try await task() }, continuation))
            if !isExecuting {
                isExecuting = true
                Task { await processQueue() }
            }
        }
    }

    private func processQueue() async {
        while !taskQueue.isEmpty {
            let (task, continuation) = taskQueue.removeFirst()
            do {
                try await task()
                continuation.resume(returning: ())
            } catch {
                continuation.resume(throwing: error)
            }
        }
        isExecuting = false
    }
}

Now if a task throws an error, that error gets propagated back to the original caller. Much better! But we're still missing a critical feature - return values.

Version 3: The Complete Solution with Return Values

Our final evolution adds support for generic return types:

actor SerialQueue3 {
    private var isExecuting = false
    private var taskQueue: [(() async throws -> Any, CheckedContinuation<Any, Error>)] = []

    func enqueue<T>(_ task: @escaping () async throws -> T) async throws -> T {
        let anyResult = try await withCheckedThrowingContinuation { continuation in
            let wrapped: () async throws -> Any = { try await task() as Any }
            taskQueue.append((wrapped, continuation))
            if !isExecuting {
                isExecuting = true
                Task { await processQueue() }
            }
        }
        return anyResult as! T
    }

    private func processQueue() async {
        while !taskQueue.isEmpty {
            let (task, continuation) = taskQueue.removeFirst()
            do {
                let result = try await task()
                continuation.resume(returning: result)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        isExecuting = false
    }
}

The clever part here is using type erasure with Any to store heterogeneous tasks in the queue, then safely casting back to the expected type. This lets us store different task types with different return values in the same queue.

return anyResult as! T

This is safe because we know that the result will be of type T since we stored it in the queue with the correct type.

Using it is straightforward:

let serialQueue = SerialQueue3()

Task {
    do {
        let notes = try await serialQueue.enqueue {
            return try await fetchNotes()
        }
        print("Fetched \(notes.count) notes")
    } catch {
        print("Fetch failed: \(error)")
    }
}

Integration: Applying Our Serial Queue

Now let's update our NotesRepository to use our shiny new queue:

actor NotesRepository {
    let service: NotesService
    private let queue = SerialQueue3()

    init(service: NotesService) {
        self.service = service
    }

    func notes() async throws(NotesError) -> [Note] {
        do {
            return try await queue.enqueue { [weak self] in
                guard let self else { return [] }
                return try await service.fetchNotes().toNotes()
            }
        } catch {
            throw .fetchNotesError
        }
    }

    // Other methods similarly updated...
}

With this in place, let's run our parallel tasks again:

.onAppear {
    Task { await fetchNotes(taskNumber: 1) }
    Task { await fetchNote(taskNumber: 2) }
    Task { await addNote(taskNumber: 3) }
    Task { await fetchNotes(taskNumber: 4) }
    Task { await updateNote(taskNumber: 5) }
    Task { await deleteNote(taskNumber: 6) }
    Task { await fetchNotes(taskNumber: 7) }
}

The result?

Successful Task Handling

Perfect! Even though we're initiating tasks concurrently, they execute in order and return results in order. Best of all, errors are properly propagated back to callers, making error handling straightforward:

switch error {
    case .fetchNotesError:
        print("Failed to fetch notes")
    case .fetchNoteError:
        print("Failed to fetch specific note")
    case .createNoteError:
        print("Failed to create note")
    case .updateNoteError:
        print("Failed to update note")
    case .deleteNoteError:
        print("Failed to delete note")
}

Beware the Deadlock!

One word of caution: Since our SerialQueue actor isn't reentrant, trying to call enqueue within a task that's already queued will deadlock:

func notes() async throws(NotesError) -> [Note] {
    do {
        return try await queue.enqueue { [weak self] in
            guard let self else { return [] }

            // 🚨 DEADLOCK ALERT! 🚨
            let note: Note? = try await queue.enqueue { [weak self] in
                guard let self else { return nil }
                return try await service.fetchNote("id")?.toNote()
            }

            return try await service.fetchNotes().toNotes()
        }
    } catch {
        throw .fetchNotesError
    }
}

This code will hang indefinitely because the outer task blocks the queue while waiting for the inner task, which can't run until the outer task finishes. Classic deadlock!

Wrapping Up

We've successfully built a powerful serial executor for Swift async tasks that:

  1. Executes tasks in strict order, even across await points
  2. Propagates errors back to callers
  3. Supports typed return values
  4. Maintains the async nature of the tasks

This pattern has saved me countless headaches when dealing with ordered operations in Swift's concurrent world. I've since packaged this up as a reusable Swift Package that I use across projects.

🧵 SerialTaskExecutor Library: View on GitHub

Have you faced similar challenges with async execution order? What solutions have you found? Drop a comment below - I'd love to hear how you're tackling these problems in your own apps!

Until next time, happy coding!

SwiftUI and Accessibility
NEW RELEASE

SwiftUI and Accessibility

Master accessibility in SwiftUI and make your apps usable by everyone.

Subscribe to iOS Dev Library

Get the latest iOS development tips, tutorials, and resources delivered straight to your inbox.

We respect your privacy. Unsubscribe at any time.