Một hệ thống kiểu dành cho thời gian
Cơ chế đồng thời của Swift không phải một thư viện luồng khoác cú pháp đẹp hơn. Nó là một hệ thống kiểu dành cho thời gian: trình biên dịch theo dõi những giá trị nào có thể đi qua lại giữa các ngữ cảnh chạy song song, và từ chối những giá trị sẽ gây tranh chấp. Hiểu được điều đó thì các thông báo lỗi thôi không còn vô lý nữa.
func loadProfile(id: String) async throws -> Profile {
let data = try await network.fetch("/profile/\(id)")
return try decoder.decode(Profile.self, from: data)
}
await là một điểm treo: hàm có thể dừng ở đây và trả luồng lại cho hệ thống, rồi chạy tiếp sau
đó — có thể trên một luồng khác. Nó không có nghĩa là “đi xuống nền”. Một hàm async chạy ở nơi mà
ngữ cảnh của bên gọi quy định, và hai dòng ở trên có thể chạy trên hai luồng khác nhau, cách nhau
khoảng thời gian bất kỳ.
Chính khoảng trống ấy là thứ phải luôn ghi nhớ. Mọi trạng thái bạn đọc trước một await đều có thể
đã đổi sau nó.
Tác vụ con thuộc về tác vụ cha, và cha không thể kết thúc trước con. Chính bảo đảm đó làm cho việc huỷ và lan truyền lỗi hoạt động được.
func loadDashboard() async throws -> Dashboard {
async let profile = loadProfile(id: currentUserID)
async let feed = loadFeed(page: 0)
return try await Dashboard(profile: profile, feed: feed)
}
Cả hai lệnh tải khởi động ngay lập tức và chạy song song; await thu kết quả về. Nếu một bên ném
lỗi, bên còn lại bị huỷ tự động. Với số lượng con thay đổi được, hãy dùng nhóm tác vụ:
func loadAll(ids: [String]) async throws -> [Profile] {
try await withThrowingTaskGroup(of: Profile.self) { group in
for id in ids {
group.addTask { try await loadProfile(id: id) }
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
Cảnh báo
Task { } tạo ra một tác vụ phi cấu trúc: nó không thừa hưởng việc huỷ từ phạm vi bao quanh
và sống lâu hơn hàm đã tạo ra nó. Đó là công cụ đúng ở ranh giới giữa code đồng bộ và code
async, và là công cụ sai ở mọi chỗ khác. Hãy nghĩ tới async let hoặc nhóm tác vụ trước.
Huỷ một tác vụ chỉ là bật một cờ. Không có gì bị ngắt ngang; code không bao giờ kiểm tra cờ đó sẽ chạy tiếp cho tới khi xong.
func process(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // ném CancellationError nếu đã bị huỷ
await handle(item)
}
}
Các hàm async của thư viện chuẩn có kiểm tra giúp bạn, nên một tác vụ đang chờ trong URLSession
hay Task.sleep thì có phản ứng. Vòng lặp của riêng bạn thì không, trừ khi bạn tự hỏi.
Actor bảo vệ trạng thái của nó bằng cách tuần tự hoá mọi truy cập:
actor ImageCache {
private var storage: [URL: Image] = [:]
func image(for url: URL) -> Image? { storage[url] }
func store(_ image: Image, for url: URL) {
storage[url] = image
}
}
Mọi lời gọi từ bên ngoài đều phải await, và mỗi lúc chỉ một lời gọi chạy. Không có khoá nào để
quên.
Điểm tinh tế mà ai cũng vấp là tính tái nhập của actor. Actor không giữ khoá xuyên qua một
await; nó cho lời gọi khác vào trong lúc lời gọi đầu đang treo:
actor Downloader {
private var cache: [URL: Data] = [:]
func data(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let downloaded = try await fetch(url) // ← một lời gọi khác chen vào được ở đây
cache[url] = downloaded
return downloaded
}
}
Hai lời gọi đồng thời cho cùng một URL đều trượt bộ đệm và đều tải xuống. Trạng thái không hỏng —
đó là thứ actor bảo đảm — nhưng logic thì vẫn sai. Cách sửa là lưu chính Task đang chạy thay vì chỉ
lưu kết quả:
actor Downloader {
private var tasks: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let existing = tasks[url] { return try await existing.value }
let task = Task { try await fetch(url) }
tasks[url] = task
return try await task.value
}
}
Sendable đánh dấu một kiểu là an toàn khi đưa qua lại giữa các miền đồng thời. Kiểu giá trị mà mọi
thành phần đều Sendable thì tự động tuân thủ; class thì không, trừ khi bất biến hoặc bạn tự nhận
trách nhiệm bằng @unchecked Sendable và tự khoá lấy.
struct Settings: Sendable { let theme: Theme } // tự động
final class Logger: @unchecked Sendable { // bạn hứa, bạn tự khoá
private let lock = NSLock()
private var lines: [String] = []
}
Ở chế độ ngôn ngữ Swift 6, đây là lỗi chứ không còn là cảnh báo. Việc di trú mệt một lần rồi thôi: trình biên dịch đã chứng minh sự vắng mặt của cả một lớp lỗi mà trước đây chỉ người dùng mới tìm ra.
Việc dựng giao diện thuộc về luồng chính, và @MainActor nói điều đó ra trong hệ thống kiểu:
@MainActor
final class ProfileModel {
private(set) var profile: Profile?
func load(id: String) async throws {
profile = try await loadProfile(id: id) // tự quay về luồng chính
}
}
await treo lại, phần việc mạng chạy ở nơi khác, rồi phép gán chạy tiếp trên main actor. Không cần
DispatchQueue.main.async, và cũng không có cách nào quên — gọi một phương thức @MainActor từ ngữ
cảnh khác mà không await là lỗi biên dịch.