top of page

01 · INTRODUCTION

What Is Cooperative Scheduling in Swift?

The short answer

Cooperative scheduling is the execution model in which Swift tasks make room for other eligible work by suspending or completing their current synchronous job.

The runtime can choose among jobs that are ready to execute. It does not normally interrupt arbitrary synchronous Swift code merely because another task is waiting for the same executor.

EXECUTION DIAGRAM

RUNNING JOB
     │
     ├── reaches a suspension point
     │
     └── or completes
             │
             ▼
EXECUTOR CAN RUN ANOTHER ELIGIBLE JOB

The task cooperates by returning the execution opportunity when its current work no longer needs it.

💡 Important Terminology

Job = a synchronous portion of task work that an executor can run.

Eligible = ready to execute now rather than suspended or waiting on a dependency.

Executor = a service that accepts eligible jobs and arranges their execution.

Cooperation = running code reaches a point where the executor can make progress on another job.

02 · TUTORIAL

From Tasks to Runnable Jobs

A task represents the complete lifetime of an asynchronous operation.

CODE EXAMPLE

Task {
    status = "Loading"
    let launches = try await api.loadLaunches()
    status = "Finished"
}

The task may not execute as one uninterrupted block.

EXECUTION DIAGRAM

ONE TASK

JOB 1
status = "Loading"
begin loadLaunches()
        │
        ▼
     SUSPEND
        │
        ▼
JOB 2
receive launches
status = "Finished"

Each job contains synchronous instructions that are currently eligible to run.

The suspension between them remains part of the task's lifetime, but there is no runnable task work during that wait.

03 · TUTORIAL

The Executor Chooses Among Eligible Jobs

Imagine three tasks associated with the main actor:

CODE EXAMPLE

Task { @MainActor in
    await refreshLaunches()
}

Task { @MainActor in
    updateCountdown()
}

Task { @MainActor in
    showConnectionStatus()
}

Their main-actor jobs must take turns.

EXECUTION DIAGRAM

MAIN-ACTOR EXECUTOR — CONCEPTUAL MODEL

Eligible jobs

[Refresh job] [Countdown job] [Status job]
       │
       ▼
Executor selects one eligible job
       │
       ▼
Main execution domain runs its instructions

The executor provides serial execution for main-actor-isolated work.

It decides which eligible job is arranged next. Developers should not treat the diagram as a guaranteed first-in, first-out queue or rely on one exact scheduling order.

04 · TUTORIAL

Why Is the Scheduling Cooperative?

Once a synchronous job begins, its instructions run until the job reaches a suitable boundary.

CODE EXAMPLE

@MainActor
func refreshLaunches() async throws {
    status = "Loading"
    requestCount += 1

    launches = try await api.loadLaunches()

    status = "Finished"
}

The first two state changes execute synchronously.

When the task reaches the asynchronous call and genuinely suspends, the current job ends. The main-actor executor can arrange another eligible job.

EXECUTION DIAGRAM

REFRESH TASK

JOB 1 RUNS
status = "Loading"
requestCount += 1
        │
        ▼
      await
        │
        ▼
TASK SUSPENDS
        │
        ▼
ANOTHER ELIGIBLE JOB CAN RUN

The runtime did not pre-empt the code between those statements.

The task reached a point where it could no longer make immediate progress and cooperatively stopped occupying the executor.

05 · TUTORIAL

A Long Synchronous Job Does Not Cooperate Promptly

Consider this deliberately bad main-actor function:

CODE EXAMPLE

@MainActor
func calculateLaunchWindows() {
    var result = 0

    for value in 1...500_000_000 {
        result &+= value
    }

    launchWindow = result
}

The loop contains one long synchronous sequence of instructions.

EXECUTION DIAGRAM

MAIN-ACTOR EXECUTOR

Calculation job
████████████████████████████████████████████

Countdown job
                                            ████

Touch-handling work
                                                ████

The countdown and touch-related work may already be ready.

However, the running job does not return the main execution opportunity until the calculation completes.

The executor can schedule eligible jobs. It cannot make the synchronous loop inexpensive or silently split it at arbitrary source-code lines.

💡 The Central Rule

A scheduler can choose among work that is eligible to run.

It cannot run a second serial-executor job until the current job returns control.

06 · TUTORIAL

async Does Not Make Every Function Cooperative

Adding async does not insert scheduling opportunities into synchronous code.

CODE EXAMPLE

@MainActor
func calculateLaunchWindows() async {
    for value in 1...500_000_000 {
        performStep(value)
    }
}

The declaration permits asynchronous calls, but this implementation contains none.

The loop still runs as one synchronous job.

EXECUTION DIAGRAM

ASYNC DECLARATION
        │
        ▼
No await reached
        │
        ▼
No suspension occurs
        │
        ▼
Current job continues until return

Cooperation depends on execution behaviour, not the visual presence of concurrency keywords around the function.

07 · TUTORIAL

Task {} Does Not Force Fairness

Creating several tasks does not guarantee that each task receives an equal turn immediately.

CODE EXAMPLE

Task { @MainActor in
    calculateLaunchWindows()
}

Task { @MainActor in
    status = "Second task ran"
}

If the calculation job runs first, the second task cannot update the same actor until that job finishes.

Task creation expresses multiple operation lifetimes. It does not promise round-robin scheduling between every line or loop iteration.

CODE EXAMPLE

NOT THIS

Task A: one loop iteration
Task B: one statement
Task A: one loop iteration
Task B: next statement


POSSIBLE REALITY ON ONE SERIAL EXECUTOR

Task A: complete synchronous job
Task B: complete next eligible job

The boundaries of runnable jobs matter.

08 · TUTORIAL

Suspension Creates a Scheduling Opportunity

An asynchronous wait naturally gives the executor another opportunity:

CODE EXAMPLE

Task { @MainActor in
    print("A: begin")
    try await Task.sleep(for: .seconds(2))
    print("A: finish")
}

Task { @MainActor in
    print("B: update interface")
}

A possible output is:

CODE EXAMPLE

A: begin
B: update interface
A: finish

EXECUTION DIAGRAM

MAIN ACTOR

Task A job 1
██████
      │
      ▼ A suspends

Task B job
      ██████████████

Task A job 2
                    ███████

Task A and Task B are concurrent because their lifetimes overlap.

Their main-actor jobs remain serial. Suspension allows their work to interleave without requiring simultaneous execution.

09 · TUTORIAL

What Does Task.yield() Do?

Task.yield() suspends the current task and offers the scheduler an opportunity to run other work.

CODE EXAMPLE

func processLaunches(_ launches: [Launch]) async {
    for launch in launches {
        process(launch)
        await Task.yield()
    }
}

This example offers a scheduling opportunity after every processed launch.

It does not guarantee that a different task will run next. If no higher-priority or equally eligible work is selected, the same task may continue.

💡 Reality Check

Task.yield() is a scheduling hint and suspension opportunity.

It is not a fairness guarantee, a delay, or a general solution for expensive CPU work.

10 · TUTORIAL

Should Long Calculations Repeatedly Yield?

Occasional yielding can help specialised algorithms cooperate when their work must remain in one asynchronous context.

But sprinkling Task.yield() through a large main-actor calculation is usually not the architectural goal.

CODE EXAMPLE

@MainActor
func calculateLaunchWindows() async {
    for value in 1...500_000_000 {
        performStep(value)

        if value.isMultiple(of: 10_000) {
            await Task.yield()
        }
    }
}

This may create opportunities for other main-actor jobs, but the expensive calculation still competes for the latency-sensitive main execution domain.

CPU-intensive model work normally needs an execution design appropriate for concurrent computation, with safe values crossing back to the UI's isolation domain.

The later articles on executors, isolation and Sendable will build that design properly.

11 · TUTORIAL

Cooperative Scheduling Is Not Processor Scheduling

Swift and the operating system schedule different things.

EXECUTION DIAGRAM

SWIFT CONCURRENCY

Executors arrange eligible task jobs
                │
                ▼
SYSTEM THREADS EXECUTE THOSE JOBS
                │
                ▼
OPERATING SYSTEM

Schedules runnable threads onto processor cores

The operating system may pre-empt a thread and run another thread.

That does not mean Swift has divided the current task job at a safe language-level boundary or allowed another job on the same serial executor to enter actor-isolated state.

OS thread pre-emption and Swift task cooperation are separate scheduling layers.

12 · TUTORIAL

Cooperation Does Not Mean One Global Queue

Swift does not place every task in the application onto one serial line.

Different executors can arrange different eligible jobs, and the runtime ultimately uses system execution resources underneath.

CODE EXAMPLE

MAIN-ACTOR EXECUTOR

Serial UI-isolated jobs


OTHER EXECUTION CONTEXTS

Other eligible task jobs


SYSTEM THREADS AND CPU CORES

Provide the underlying execution resources

Some work can progress concurrently, and suitable independent work may execute in parallel when resources and isolation allow it.

Cooperative scheduling does not mean the entire program runs one task at a time. It describes how task jobs expose boundaries at which runtime scheduling can make progress.

13 · TUTORIAL

Why Cooperation Matters to the Interface

A responsive application repeatedly needs short opportunities to process events and coordinate visible updates.

A main-actor task that suspends during a network wait gives those jobs a chance to run:

CODE EXAMPLE

@MainActor
func refreshLaunches() async throws {
    status = "Loading"

    launches = try await api.loadLaunches()

    status = "Finished"
}

EXECUTION DIAGRAM

MAIN EXECUTION DOMAIN

Refresh job 1
████ status = "Loading" ████
               │
               ▼ refresh task suspends

Event and UI-related jobs
               █████████████████

Refresh job 2
                                ████ update launches ████

Suspension does not command a render or guarantee which job executes next.

It returns the execution opportunity so other eligible main-actor and framework work can progress.

14 · TUTORIAL

A Cooperative Launch Feature

The API expresses waiting through suspension:

CODE EXAMPLE

struct LaunchAPI {
    func loadLaunches() async throws -> [Launch] {
        let (data, _) = try await URLSession.shared.data(
            from: launchesURL
        )

        return try JSONDecoder().decode(
            [Launch].self,
            from: data
        )
    }
}

The feature keeps its synchronous main-actor periods short:

CODE EXAMPLE

@MainActor
final class LaunchListFeature: ObservableObject {
    @Published private(set) var launches: [Launch] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let api: LaunchAPI

    init(api: LaunchAPI) {
        self.api = api
    }

    func refresh() async {
        isLoading = true
        errorMessage = nil

        defer { isLoading = false }

        do {
            let result = try await api.loadLaunches()
            launches = result
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

The first main-actor job establishes loading state and begins the asynchronous request.

The task suspends while no response is available. Other eligible jobs can use the released execution opportunity.

The continuation becomes eligible when the request finishes. A later main-actor job publishes the result.

The feature cooperates because its actor-isolated synchronous periods are short and its genuine wait suspends.


15 · TUTORIAL

The Complete Mental Model

EXECUTION DIAGRAM

TASK
  │
  ├── runnable job
  │       │
  │       ▼
  │   executor arranges execution
  │       │
  │       ▼
  │   synchronous instructions run
  │       │
  │       ├── job completes
  │       │
  │       └── task suspends
  │               │
  │               ▼
  │       execution opportunity returns
  │               │
  │               ▼
  │       another eligible job can run
  │
  └── continuation becomes runnable later

Swift tasks are cooperative because their runnable jobs execute synchronously until they complete or reach suspension.

Executors choose among eligible jobs, but they cannot silently break a long synchronous job into fair turns.

Well-designed asynchronous code keeps synchronous jobs appropriately bounded and represents genuine waiting with suspension.

That is cooperative scheduling in Swift.


16 · TUTORIAL

What to Remember

💡 What to Remember

1. A task can contain several runnable jobs separated by suspension.

2. An executor arranges the execution of eligible jobs.

3. A running synchronous job normally continues until it completes or the task suspends.

4. The scheduler cannot make long synchronous work inexpensive.

5. Declaring a function async does not insert automatic scheduling opportunities.

6. Creating several tasks does not guarantee equal or round-robin turns.

7. A genuine suspension returns the execution opportunity for other eligible work.

8. Task.yield() offers a scheduling opportunity but does not guarantee another task runs next.

9. Swift task scheduling and operating-system thread scheduling are different layers.

10. Responsive code keeps latency-sensitive synchronous jobs short.


17 · TUTORIAL

Frequently Asked Questions

What is cooperative scheduling in Swift?

It is the model in which task jobs run synchronously until they complete or reach suspension, returning an execution opportunity that allows an executor to arrange another eligible job.

Can Swift interrupt a running Task?

The operating system can pre-empt the underlying thread, but Swift does not normally split arbitrary synchronous task code into separately scheduled jobs. At the Swift level, another job on the same serial executor needs the current job to return control.

Does await always let another Task run?

No. await marks a potential suspension point. If suspension is unnecessary, the task may continue. Even when suspension occurs, the scheduler decides which eligible work runs next.

Does Task.yield guarantee fairness?

No. It suspends the current task and offers a scheduling opportunity. It does not promise which task will be selected next or that another task must run first.

Why can async code still freeze the UI?

An asynchronous function can execute a long synchronous job on the main actor before reaching suspension or returning. During that job, other main-actor-dependent work remains delayed.

Is cooperative scheduling the same as serial execution?

No. A particular executor such as the main actor runs its isolated jobs serially, while other executors and system threads may execute other work concurrently or in parallel.


18 · TUTORIAL

Continue Learning Swift Concurrency

Read Suspension vs Blocking in Swift to revisit the execution boundary that enables cooperation.

Continue with What Is an Executor in Swift? to examine the service that accepts eligible task jobs and arranges where they execute.

Swift's official Concurrency documentation describes tasks, suspension and the language's concurrency model.


19 · TUTORIAL

Download Xcode Playground

The accompanying What Is Cooperative Scheduling in Swift? Xcode playground can make executor cooperation visible in the console.

It should run two main-actor tasks, first with one long synchronous job and then with a genuine suspension. The output will show why the second task cannot progress until the first returns control.

A separate experiment should use Task.yield() and explicitly demonstrate that the resulting order is an observation rather than a fairness guarantee.

The final page should run the complete launch feature while a countdown task continues updating, connecting short jobs and suspension to visible responsiveness.

The article explains the cooperation. The playground will reveal exactly where the execution opportunity changes hands.

bottom of page