普通视图

发现新文章,点击刷新页面。
昨天以前首页

深入理解 Swift Concurrency:从 async/await 到隔离域

作者 皇上o_O
2026年1月12日 16:02

在 Swift 并发系统(Swift Concurrency)诞生之前,iOS 开发者的日常被回调(Callbacks)、代理(Delegates)和 Combine 填满。我们用这些工具来处理应用中大量的等待时间:网络请求、磁盘 I/O、数据库查询。它们虽然能解决问题,但代价是代码的可读性——嵌套的回调地狱(Callback Hell)和陡峭的 Combine 学习曲线让代码维护变得艰难。

Swift 的 async/await 引入了一种全新的范式。它允许开发者用看似同步的顺序代码来编写异步逻辑。底层运行时高效地管理着任务的暂停与恢复,而不再需要开发者手动在回调中穿梭。

但 async/await 只是冰山一角。Swift 并发模型的真正核心,在于它如何从根本上改变了我们对“线程安全”的理解——从管理线程(Threads)转向管理隔离(Isolation)。

本文将深入探讨这一体系,从基础语法到隔离域模型,再到实际开发中的最佳实践。

基础:暂停与恢复

异步函数(Async Function)  是这一模型的基础构建块。通过 async 标记,函数声明了它具有被“挂起”的能力。在调用时,await 关键字则是一个明确的标记,表示“在此处暂停,直到任务完成”。

func fetchUser(id: Intasync 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)
}

// 调用示例
let user = try await fetchUser(id: 123)
// fetchUser 完成后,代码才继续向下执行

这里的关键在于 挂起(Suspension)  而非 阻塞(Blocking) 。当代码在 await 处暂停时,当前线程并不会被锁死,Swift 运行时会利用这段空闲时间去处理其他工作。当异步操作完成,函数会从暂停的地方恢复执行。

并行:结构化并发

顺序执行 await 虽然直观,但在处理多个独立任务时效率低下。如果我们需要同时获取头像、横幅和简介,逐个等待会导致不必要的串行延迟。

async let 允许我们以声明式的方式并行启动任务:

func loadProfile() async throws -> Profile {
    // 三个任务立即同时启动
    async let avatar = fetchImage("avatar.jpg")
    async let banner = fetchImage("banner.jpg")
    async let bio = fetchBio()

    // 在需要结果时才进行 await
    return Profile(
        avatar: try await avatar,
        banner: try await banner,
        bio: try await bio
    )
}

这种方式既保留了代码的整洁,又实现了并行的高效。

如果任务数量是动态的(例如下载一个数组中的所有图片),则应使用 TaskGroup。它将任务组织成树状结构,父任务会等待组内所有子任务完成或抛出错误。这种层级关系被称为 结构化并发(Structured Concurrency) ,其最大优势在于生命周期管理:取消父任务会自动传播给所有子任务,且错误处理更加可预测。

任务管理:Task 的正确用法

编写了异步函数后,我们需要一个上下文来运行它们。Task 就是这个异步工作单元。它提供了从同步代码进入异步世界的桥梁。

视图层面的管理

在 SwiftUI 中,最推荐的方式是使用 .task 修饰符。它自动管理任务的生命周期:视图显示时启动,消失时自动取消。

struct ProfileViewView {
    var userIDString
    @State private var avatarImage?

    var body: some View {
        // 当 userID 变化时,旧任务取消,新任务启动
        Image(systemName"person")
            .task(id: userID) {
                avatar = await downloadAvatar(for: userID)
            }
    }
}

常见的反模式:不受管理的 Task

开发者常犯的一个错误是滥用 Task { ... } 或 Task.detached { ... }。这种手动创建的任务是“非托管”的。一旦创建,你就失去了对它的控制权:无法自动随视图销毁而取消,难以追踪执行状态,也难以捕获其中的错误。

这就像把漂流瓶扔进大海,你不知道它何时到达,也无法在发出去后撤回。

最佳实践

    1. 优先使用 .task 修饰符或 TaskGroup
    1. 仅在确实需要(如点击按钮触发)时使用 Task { },并意识到其生命周期的独立性。
    1. 极少使用 Task.detached,除非你明确知道该任务不需要继承当前的上下文(如优先级、Actor 隔离)。

核心范式转变:从线程到隔离

在 Swift 并发出现之前,不管是 GCD 还是 OperationQueue,我们关注的核心是 线程(Thread) :代码在哪个队列跑?是否在主线程更新 UI?

这种模型极其依赖开发者的自觉性。一旦忘记切换线程,或者两个线程同时访问同一块内存,就会导致 数据竞争(Data Race) 。这是未定义行为,可能导致崩溃或数据损坏。

Swift 并发模型不再询问“代码在哪里运行”,而是问:“谁有权访问这块数据?

这就是 隔离(Isolation)

Swift 通过编译器在构建阶段强制执行隔离规则,而不是依赖运行时的运气。底层依然是线程池在调度,但上层的安全由 Actor 模型保证。

1. MainActor:UI 的守护者

@MainActor 是一个全局 Actor,代表主线程的隔离域。它是 UI 框架(SwiftUI, UIKit)的领地。

@MainActor
class ViewModel {
    // 编译器强制要求:访问 items 必须在 MainActor 上
    var items: [Item] = [] 
}

标记了 @MainActor 的类,其属性和方法默认都在主线程隔离域中。这意味着你不需要手动 DispatchQueue.main.async,编译器会确保外部调用者必须通过 await 来跨越隔离边界。对于大多数应用,将 ViewModel 标记为 @MainActor 是默认且正确的选择。

2. Actor:数据孤岛

actor 是一种引用类型,它像类一样,但有一个关键区别:它保护其可变状态。Actor 保证同一时间只有一个任务能访问其内部状态,从而从根本上消除了数据竞争。

actor BankAccount {
    var balance: Double = 0
    
    func deposit(_ amountDouble) {
        balance += amount // 安全:Actor 内部串行访问
    }
}

// 外部调用必须等待,因为可能需要排队
await account.deposit(100)

可以将 Actor 想象成办公楼里的独立办公室,一次只能进一个人处理文件。

3. Nonisolated:公共走廊

标记为 nonisolated 的代码显式退出了 Actor 的隔离保护。它可以被任何地方调用,不需要 await,但也因此不能访问 Actor 的内部受保护状态。

数据的跨域传递:Sendable

隔离域保护了数据,但数据总需要在不同域之间传递。当一个对象从后台 Actor 传递到 MainActor 时,Swift 必须确保这一传递是安全的。

Sendable 协议就是这个通行证。它告诉编译器:“这个类型可以安全地跨越隔离边界”。

  • • 值类型(Struct, Enum) :通常是 Sendable,因为传递的是拷贝,互不影响。
  • • Actor:也是 Sendable,因为它们自带同步机制。
  • • 类(Class) :通常 不是 Sendable。除非它是 final 的且只有不可变属性。

如果试图在并发环境中传递一个普通的类实例,编译器会报错,因为它无法保证两个线程不会同时修改这个类。

隔离的继承与流转

理解 Swift 并发的关键在于理解 隔离继承

在启用了完整并发检查(Swift 6 / Approachable Concurrency)的项目中,代码执行的上下文通常遵循以下规则:

  1. 1. 函数调用:继承调用者的隔离。如果在 @MainActor 的函数中调用另一个普通函数,后者也在 MainActor 上运行。
  2. 2. Task { } :继承创建它的上下文。在 ViewModel(MainActor)中创建的 Task,其中的代码默认也在 MainActor 上运行。
  3. 3. Task.detached:斩断继承,在一个没有任何特定隔离的上下文中运行。

这也是为什么不要迷信 async 等于后台线程。

@MainActor
func slowFunction() async {
    // 错误:这虽然是 async 函数,但依然在 MainActor 运行
    // 这里的同步计算会卡死 UI
    let result = expensiveCalculation() 
    data = result
}

async 只意味着函数 可以 暂停,并不意味着它会自动切到后台。如果是 CPU 密集型任务,你需要显式地将其移出主线程(例如使用 Swift 6.2 的 @concurrent 标记或放入 detached task)。

常见误区与避坑指南

  1. 1. 过度设计 Actor:不要为每个数据源都创建一个 Actor。大多数时候,将状态隔离在 @MainActor 的 ViewModel 中已经足够。只有当确实存在跨线程共享的可变状态时,才引入自定义 Actor。
  2. 2. 滥用 @unchecked Sendable:不要为了消除编译器警告而随意使用 @unchecked Sendable。这相当于告诉编译器“闭嘴,由于我自己负责”,一旦出错就是难以调试的竞争问题。
  3. 3. 阻塞协作线程池:永远不要在 async 上下文中使用信号量(Semaphore)或 DispatchGroup.wait()。Swift 的底层线程池容量有限(通常等同于 CPU 核心数),阻塞其中一个线程可能导致死锁或饥饿。
  4. 4. 无脑 MainActor.run:很多开发者习惯在获取数据后写 await MainActor.run { ... }。更好的做法是直接将更新数据的函数标记为 @MainActor,让编译器自动处理上下文切换。

总结

Swift 的并发模型建立在三个支柱之上:

  1. 1. async/await:处理控制流,让异步代码线性化。
  2. 2. Task:结构化地管理异步工作的生命周期。
  3. 3. Actor & Isolation:通过隔离域在编译时消除数据竞争。

对于大多数应用开发,遵循简单的规则即可:默认使用 @MainActor 保护 UI 状态,使用 async/await 处理 I/O,利用 .task 管理生命周期。只有在遇到真正的性能瓶颈或复杂的共享状态时,才需要深入自定义 Actor 和细粒度的隔离控制。

编译器是你的向导,而非敌人。当它报出并发错误时,它实际上是在帮你规避那些曾在旧时代导致无数崩溃的隐形 Bug。

Swift 新并发框架之 async/await

作者 皇上o_O
2025年12月25日 10:26

1. 为什么需要 async/await

在移动开发里,“并发/异步”几乎无处不在:网络请求、图片下载、文件读写、数据库操作……它们都有一个共同特点:

  • 耗时(如果你在主线程里死等,会卡 UI)
  • 结果稍后才回来(你必须用某种方式“拿到结果”)

传统的并发模型大多是“回调式”的:用 completion、delegate、通知等在未来某个时间点把结果交还给你。 这套方案能跑,但会带来两个典型挑战(你参考资料里也点出来了):

  1. 代码维护性差:异步回调让代码阅读“跳来跳去”,不线性
  2. 容易出现数据竞争(Data Races):共享可变状态在并发下很容易出 Bug,而且难复现、难排查

Swift 从 5.5 开始引入新并发框架,async/await 先解决第一个核心痛点:让异步代码看起来像同步代码一样顺畅,并且把错误处理变得更自然。


2. 先复习 4 个基础概念

2.1 同步 vs 异步(描述“函数怎么返回”)

  • 同步(Synchronous):函数执行完才返回 例子:let x = add(1, 2),拿到结果才能往下走
  • 异步(Asynchronous):函数把任务“丢出去”,结果以后再给你 所以你必须用 completion / delegate / async/await 等方式拿结果

2.2 串行 vs 并行/并发(描述“一组任务怎么跑”)

  • 串行(Serial):一次只执行一个任务,完成一个再下一个
  • 并发/并行(Concurrent/Parallel):同一段时间内可能执行多个任务 (本文不严格区分并发与并行;你只要知道:会“同时处理多个任务”就行)

3. 回调式异步的典型痛点:回调地狱 + 错误处理难看

用“头像加载”来说明: 步骤:拿 URL → 下载数据(加密)→ 解密 → 解码成图片

3.1 回调地狱(Callback Hell)

class AvatarLoader {
    func loadAvatar(token: String, completion: @escaping (UIImage) -> Void) {
        fetchAvatarURL(token: token) { url in
            self.fetchAvatar(url: url) { data in
                self.decryptAvatar(data: data) { data in
                    self.decodeImage(data: data) { image in
                        completion(image)
                    }
                }
            }
        }
    }

    func fetchAvatarURL(token: String, completion: @escaping (String) -> Void) { /* ... */ }
    func fetchAvatar(url: String, completion: @escaping (Data) -> Void) { /* ... */ }
    func decryptAvatar(data: Data, completion: @escaping (Data) -> Void) { /* ... */ }
    func decodeImage(data: Data, completion: @escaping (UIImage) -> Void) { /* ... */ }
}

阅读体验像在“走迷宫”:你要不停地进回调、出回调,脑子要同时记住很多上下文。

3.2 错误处理会让代码更糟

异步回调常见的写法是 completion(value, error) 或者 Result。 但在多层嵌套里,错误传递会越来越啰嗦,漏掉某个分支的 completion 也很常见。


4. async/await 的核心:把“异步代码”写成“线性代码”

4.1 先看效果:一眼就能读懂

class AvatarLoader {
    func loadAvatar(token: String) async throws -> UIImage {
        let url = try await fetchAvatarURL(token: token)
        let encryptedData = try await fetchAvatar(url: url)
        let decryptedData = try await decryptAvatar(data: encryptedData)
        return try await decodeImage(data: decryptedData)
    }

    func fetchAvatarURL(token: String) async throws -> String { /* ... */ }
    func fetchAvatar(url: String) async throws -> Data { /* ... */ }
    func decryptAvatar(data: Data) async throws -> Data { /* ... */ }
    func decodeImage(data: Data) async throws -> UIImage { /* ... */ }
}

你会发现:

  • 代码像同步一样从上到下执行(“直线式”)
  • 错误处理回到了熟悉的 throws + try + do/catch
  • 逻辑清晰、可维护性大幅提升

5. 语法规则:你只需要掌握这 3 条

5.1 async:声明“这个函数可能会挂起”

func fetch() async -> Int { 42 }

含义:它是“异步函数”,执行过程中可能暂停(挂起),等待某些事情完成(比如网络返回、IO 完成)。

5.2 await:调用 async 函数时必须写

let value = await fetch()

await 表示:这里是一个潜在挂起点(potential suspension point)。 意思是:运行到这里,可能会“先停一下”,等结果准备好再继续往下走。

5.3 await 只能出现在“异步上下文”里

异步上下文主要有两类:

  1. async 函数体内部
  2. Task 闭包内部

6. 一个关键认知:挂起的是“函数”,不是“线程”

这是很多新手最容易误解的地方:

  • await 不是“把当前线程卡住等待”
  • 它是“把当前函数挂起”,让出执行权
  • 等条件满足后,再恢复执行(恢复时可能换了线程

你可以把它想象成:

你在排队取号(await),你人可以先离开去做别的(线程去执行别的任务),等叫到你号了你再回来继续办理(函数恢复执行)。

结论:在 async/await 的世界里,别强依赖“我现在一定在某个线程上”。


7. 为什么“锁 + await”容易出事:一个经典坑

一个很典型的示例:

let lock = NSLock()

func test() async {
    lock.lock()
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    lock.unlock()
}

for _ in 0..<10 {
    Task { await test() }
}

问题在哪里?

  • lock.lock() 后遇到了 await
  • 函数可能挂起并发生线程切换
  • 其他任务也想拿锁,但锁可能被“拿着不放”
  • 结果就是:很容易出现死锁/饥饿等难排查问题

经验法则:

不要在持有锁的期间跨过 await。 如果你需要保护共享可变状态,优先考虑 actor或让状态只在单一执行上下文里修改。


8. 真正能上手的代码:用 URLSession 写一个 async 网络请求

iOS 15+(Swift 5.5+)开始,URLSession 已经提供了 async API,例如:

let (data, response) = try await URLSession.shared.data(from: url)

我们做一个“获取用户信息”的示例(包含错误处理):

8.1 定义模型与错误

import Foundation

struct User: Decodable {
    let id: Int
    let name: String
}

enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case httpStatus(Int)
}

8.2 写一个 async 网络层

final class APIClient {
    func fetchUser(id: Int) async throws -> User {
        guard let url = URL(string: "https://example.com/users/\(id)") else {
            throw NetworkError.invalidURL
        }

        let (data, response) = try await URLSession.shared.data(from: url)

        guard let http = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        guard (200...299).contains(http.statusCode) else {
            throw NetworkError.httpStatus(http.statusCode)
        }

        return try JSONDecoder().decode(User.self, from: data)
    }
}

这一段代码的阅读体验就是“同步式”的:构建 URL → await 拿数据 → 校验 → decode。


9. 在 UIViewController 里怎么调用 async 并更新 UI?

这是 iOS 开发里最常见的落地问题:

async 方法不能直接在普通函数里 await,那我怎么从按钮点击里发请求?

答案:用 Task { } 把它放进异步上下文里。

9.1 示例:点击按钮加载用户并刷新 label

import UIKit

final class UserViewController: UIViewController {

    private let api = APIClient()
    private let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white

        label.numberOfLines = 0
        label.frame = CGRect(x: 20, y: 100, width: 320, height: 200)
        view.addSubview(label)

        loadUser()
    }

    private func loadUser() {
        // 进入异步上下文
        Task { [weak self] in
            guard let self else { return }

            do {
                let user = try await api.fetchUser(id: 1)

                // 更新 UI:回到主线程(MainActor)
                await MainActor.run {
                    self.label.text = "用户:\(user.name)\nID:\(user.id)"
                }

            } catch {
                await MainActor.run {
                    self.label.text = "加载失败:\(error)"
                }
            }
        }
    }
}

你只要记住一句话就够了:

  • 耗时工作放在 Taskawait
  • UI 更新放在 MainActor(主线程语义)里做

10. 从旧代码迁移:把 completion 回调包装成 async

很多项目里已经有大量回调式 API,你不可能一夜之间全改掉。Swift 提供了“续体(Continuation)”做桥接。

10.1 假设你有旧接口

final class LegacyService {
    func fetchText(completion: @escaping (Result<String, Error>) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            completion(.success("hello async/await"))
        }
    }
}

10.2 用 withCheckedThrowingContinuation 包装

extension LegacyService {
    func fetchText() async throws -> String {
        try await withCheckedThrowingContinuation { continuation in
            fetchText { result in
                switch result {
                case .success(let text):
                    continuation.resume(returning: text)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

10.3 使用方式

Task {
    do {
        let text = try await LegacyService().fetchText()
        print(text)
    } catch {
        print("error:", error)
    }
}

11. 入门阶段的最佳实践清单

  1. 不要在持锁期间跨 await(容易死锁/逻辑卡住)
  2. UI 更新统一回到 MainActor(避免主线程问题)
  3. 能用 throws 就别用 Optional+error 乱传:错误路径更清晰
  4. 从入口处就结构化async 函数调用 async 函数,别层层回调
  5. 迁移旧代码用 Continuation:逐步改,不要一次性重构到崩

12. 小结

到这里,你已经具备了 async/await 的“可用级理解”:

  • async:这个函数可能挂起
  • await:潜在挂起点,只能在异步上下文使用
  • async/await 让异步代码“线性可读”,错误处理回到 throws
  • 挂起的是函数,不是线程;await 前后可能换线程
  • iOS 里用 Task {} 进入异步上下文,用 MainActor 更新 UI
  • 旧回调接口可以用 withCheckedThrowingContinuation 平滑迁移

❌
❌