现在位置: 首页 > Swift 教程 > 正文

Swift 并发编程:async 与 await

下载图片、读取文件、请求接口,这些操作都要等待外部设备或网络,耗时远高于 CPU 计算。

如果让主线程一直等,界面就会卡住。

Swift 用 async / await(SE-0296,Swift 5.5 引入)把「等待」变成语言级别的语法,让异步代码读起来和同步代码一样线性。


并发要解决什么问题

传统的并发写法是回调嵌套:请求 A 的回调里发起请求 B,B 的回调里再处理结果。

这种代码层级深、错误处理分散,还容易漏掉某条分支。

Swift 的并发模型做三件事:用 async 标记异步函数,用 await 在调用点等待,用 Task 管理任务的生命周期。

概念写法作用
异步函数func f() async -> T声明这个函数可能挂起等待
等待let x = await f()挂起当前任务,结果就绪后继续
任务Task { ... }创建一个独立的并发执行单元
任务组withTaskGroup动态并发执行一批同类型的任务
执行器隔离@MainActor保证代码在指定线程上执行,如主线程

await 不是「阻塞」,而是「挂起」。当前任务让出执行权,线程可以去跑别的工作,等结果回来再恢复。这是它和 sleep 阻塞线程的本质区别。


async 函数与 await

把一个函数标记为 async,就表示它的执行过程中可能暂停。

async/await 的挂起与恢复时序图:主线程调用 await 后协程挂起并标注 SUSPENDED,主线程被释放,数据就绪后协程恢复,主线程继续执行

调用它的地方必须写 await,这是在语法上明确标出「这里会等待」。

定义一个异步函数

下面的例子模拟两次网络请求,用 Task.sleep 代表等待耗时。

实例

import Foundation

// async 标记异步函数,函数体内可以挂起等待
func fetchPage(_ name: String) async -> String {
    try? await Task.sleep(for: .milliseconds(20))
    return "\(name) 内容"
}

func loadAll() async {
    // await 表示在这里挂起,等结果回来再继续
    let home = await fetchPage("RUNOOB 首页")
    let doc = await fetchPage("Swift 教程")
    print(home)
    print(doc)
}

await loadAll()

运行结果:

RUNOOB 首页 内容
Swift 教程 内容

两次请求是串行的:第二次要等第一次返回后才开始。

如果它们之间没有依赖关系,可以并发执行,后面的 async let 会讲到。

在脚本、Playground 和 main.swift 的顶层代码里可以直接写 await。在普通函数里用 await,该函数本身必须标记为 async

await 是挂起点

执行到 await 时,当前任务会挂起,把线程让给其他任务。

结果就绪后,任务从这一行继续往下执行。

因为中间可能穿插了其他任务,所以不要在 await 前后假设「状态没有变过」,这一点在 actor 那篇会进一步展开。

async throws:既能等待也能失败

异步函数同样可以抛出错误,写法是把两个关键字并排放在参数列表之后。

实例

import Foundation

enum FetchError: Error {
    case badStatus(Int)
}

// async throws:既能挂起,也能抛错
func fetchScore(_ id: Int) async throws -> Int {
    try await Task.sleep(for: .milliseconds(10))
    guard id > 0 else { throw FetchError.badStatus(id) }
    return id * 10
}

do {
    let score = try await fetchScore(7)
    print("分数:\(score)")
    let bad = try await fetchScore(-1)
    print(bad)
} catch {
    print("出错:\(error)")
}

运行结果:

分数:70
出错:badStatus(-1)

注意 tryawait 的书写顺序是 try await

do-catch 里,try await 抛出的错误和同步函数一样,由 catch 处理。


Task 与结构化并发

只有 async 函数还不够,总得有人「发起」第一次异步调用。

Swift 提供两种方式:Task 创建独立任务,async letTaskGroup 组织结构化并发。

Task:独立任务

Task { } 创建一个新的并发任务,创建后立即开始执行。

它的返回值通过 task.value 取,取的时候要 await

实例

import Foundation

func fetchPage(_ name: String) async -> String {
    try? await Task.sleep(for: .milliseconds(20))
    return "\(name) 内容"
}

// Task 创建一个独立的并发任务,立即开始执行
let t1 = Task { await fetchPage("任务甲") }
let t2 = Task { await fetchPage("任务乙") }

// 按固定顺序等待结果,输出顺序就是确定的
print(await t1.value)
print(await t2.value)

运行结果:

任务甲 内容
任务乙 内容

两个任务确实是并发跑的,但这里按 t1、t2 的顺序取值,所以输出顺序稳定。

Task 创建的任务不受当前作用域约束,即使外层函数已经返回,任务仍可能继续运行,这类任务被称为「非结构化任务」。能用结构化并发时就优先用它。

async let:并发执行固定数量的任务

当几个异步调用的数量固定、彼此独立时,async let(SE-0317,Swift 5.5 引入)最简洁。

它让表达式在后台立即开始求值,直到你真正需要结果时才等待。

实例

import Foundation

func fetchPage(_ name: String) async -> String {
    try? await Task.sleep(for: .milliseconds(20))
    return "\(name) 内容"
}

func loadPair() async {
    // async let 让两个请求同时开始,不阻塞当前任务
    async let first = fetchPage("async let 甲")
    async let second = fetchPage("async let 乙")

    // 在这里统一等待两个结果
    let results = await (first, second)
    print(results.0)
    print(results.1)
}

await loadPair()

运行结果:

async let 甲 内容
async let 乙 内容

两个请求同时发起,总耗时接近一个请求而不是两个。

TaskGroup:并发数量在运行时决定

如果任务数量取决于运行时的数据,比如要并发下载一个数组里的所有地址,就用任务组。

结构化并发(SE-0304,Swift 5.5 引入)保证所有子任务在离开作用域前都会被等待,不会泄漏。

实例

import Foundation

// 任务组用于「数量在运行时才知道」的并发
func loadNumbers() async -> [Int] {
    await withTaskGroup(of: Int.self) { group in
        for i in 1...4 {
            group.addTask { i * 100 }
        }

        var results: [Int] = []
        for await value in group {
            results.append(value)
        }
        // 任务完成顺序不保证,排序后输出才稳定
        return results.sorted()
    }
}

print(await loadNumbers())

运行结果:

[100, 200, 300, 400]

任务组返回结果的顺序是「完成顺序」,不是「加入顺序」。需要固定顺序时,要么像示例一样排序,要么在任务里带上索引再重新排列。


取消与超时

异步任务一旦发起,就需要一种机制告诉它「不用继续了」,例如用户离开页面、请求已经超时。

Swift 的取消是「协作式」的:调用 cancel() 只是把任务标记为已取消,具体怎么响应由任务自己决定。

任务内可以用 Task.isCancelled 查询状态,或者用 Task.checkCancellation() 在已取消时直接抛错。

实例

import Foundation

func step(_ n: Int) async throws -> Int {
    // 主动检查取消状态,已取消则抛出 CancellationError
    try Task.checkCancellation()
    try await Task.sleep(for: .seconds(1))
    return n * n
}

let job = Task { try await step(4) }
job.cancel()   // 立刻请求取消

do {
    print(try await job.value)
} catch is CancellationError {
    print("任务被取消")
}

运行结果:

任务被取消

很多标准库 API 自带取消检查,例如 Task.sleep 被取消时会抛出 CancellationError,不用你手动判断。

如果自己的循环里没有 await,就要手动检查,否则取消信号永远不会被响应。

实例

// 循环中主动检查取消,及时退出
func countUp() async -> Int {
    var total = 0
    for i in 1...1_000_000 {
        if Task.isCancelled { break }   // 收到取消信号就停止
        total += i
    }
    return total
}

超时本身没有语言内置的写法,常见做法是让业务任务和一个计时任务赛跑,谁先完成就用谁的结果。

实例

import Foundation

enum FetchError: Error {
    case timedOut
}

// 超时控制:让业务任务和计时任务赛跑,谁先完成用谁的结果
func withTimeout<T: Sendable>(seconds: Double,
                              operation: @escaping @Sendable () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: .seconds(seconds))
            throw FetchError.timedOut
        }
        let result = try await group.next()!
        group.cancelAll()   // 取消还没结束的任务
        return result
    }
}

do {
    let fast = try await withTimeout(seconds: 1) {
        try await Task.sleep(for: .milliseconds(10))
        return "RUNOOB 快速返回"
    }
    print(fast)

    let slow = try await withTimeout(seconds: 0.1) {
        try await Task.sleep(for: .seconds(2))
        return "永远不会返回"
    }
    print(slow)
} catch {
    print("超时:\(error)")
}

运行结果:

RUNOOB 快速返回
超时:timedOut
API作用典型场景
task.cancel()请求取消任务,不会强制中断用户离开页面、页面关闭
Task.isCancelled查询当前任务是否已取消长循环里手动判断
Task.checkCancellation()已取消则抛出 CancellationError步骤开始前检查
group.cancelAll()取消任务组中所有子任务超时、拿到足够结果后提前结束

@MainActor 与 UI 更新

UIKit 和 SwiftUI 要求界面更新必须发生在主线程。

Swift 用全局 actor @MainActor(SE-0316,Swift 5.5 引入)来表达「这段代码属于主线程」。

把函数或类型标记为 @MainActor 后,从其他上下文调用它必须 await,编译器会自动完成线程切换。

实例

import Foundation

@MainActor
func updateUI(text: String) {
    print("主线程更新界面:\(text)")
}

@MainActor
final class ViewModel {
    var title: String = "加载中"

    func refresh() {
        title = "RUNOOB 已加载"
        print(title)
    }
}

func demoMainActor() async {
    await updateUI(text: "RUNOOB 首页加载完成")
    let model = await ViewModel()
    await model.refresh()
}

await demoMainActor()

运行结果:

主线程更新界面:RUNOOB 首页加载完成
RUNOOB 已加载

把整个 ViewModel 标成 @MainActor,它的属性和方法就都归主线程管理,不会出现后台线程改界面导致的崩溃。

不要在 @MainActor 类型里做耗时计算。标记成主线程不等于「可以随便卡住主线程」,耗时的部分应该放到 Task 里执行,再回到主线程更新状态。


常见问题

下面整理异步编程入门阶段最常遇到的几个问题。

await 会阻塞线程吗

不会。它只是挂起当前任务,线程会被释放去执行其他任务。

真正阻塞线程的是 Thread.sleep 或同步的耗时计算,那些写法在异步上下文里应当避免。

什么代码会真正跑在别的线程上

标记 async 本身不保证切换线程,它只是允许挂起。

具体在哪个线程执行由执行器决定;@MainActor 明确要求主线程,其余情况通常由并发线程池承担。

Task 和 async let 怎么选

需要脱离当前作用域、生命周期独立,用 Task

只是想在当前函数里并发几个固定调用,用 async let;数量不确定时用 TaskGroup

为什么并发任务里的输出顺序每次都不一样

因为任务的完成顺序由调度器决定,不受代码书写顺序约束。

需要稳定顺序时,要么串行 await,要么收集结果后排序,或者在任务中携带序号再重排。

TaskGroup 里的任务能直接改外部变量吗

不能。任务闭包是可发送的,捕获外部可变变量会被编译器拒绝。

正确做法是让每个任务返回结果,在主任务里收集。

async 函数可以直接当普通函数调用吗

不能。必须用 await,而且调用点本身要处在异步上下文里。

同步代码想发起异步调用,只能通过 Task 这类入口。