Swift Concurrency 2026: async/await, Actors và @concurrent trong Swift 6.2
Hướng dẫn Swift Concurrency trong Swift 6.2: từ async/await cơ bản, actors, đến @concurrent mới và chiến lược migrate dự án legacy thực tế kèm code mẫu.
Swift Concurrency là mô hình lập trình bất đồng bộ chính thức của Swift, sử dụng async/await cho các tác vụ tạm dừng, actor để bảo vệ dữ liệu chia sẻ khỏi data race, và Task để tạo ra đơn vị thực thi đồng thời. Tất cả được biên dịch kiểm tra ngay tại compile time kể từ Swift 6. Với Swift 6.2, Apple giới thiệu chế độ Approachable Concurrency, thay đổi hành vi mặc định của nonisolated async để hàm chạy trên actor của người gọi, đồng thời bổ sung thuộc tính @concurrent cho việc opt-in song song có chủ đích. Bài viết này hệ thống lại toàn bộ mô hình từ những ngày Objective-C đến hôm nay.
async/await thay thế hoàn toàn completion handler và DispatchQueue cho luồng dữ liệu bất đồng bộ. Mã đọc tuần tự, biên dịch viên hiểu được điểm tạm dừng.
Actor là kiểu tham chiếu cô lập trạng thái: chỉ một task truy cập mutable state tại một thời điểm, loại bỏ race condition ở compile time.
Trong Swift 6.2, hàm nonisolated async mặc định kế thừa actor của caller, không còn nhảy thread ngầm sang global executor như trước.
Thuộc tính mới @concurrent dùng để ép hàm chạy trên cooperative thread pool khi bạn cần song song thực sự cho công việc CPU-bound.
Sendable là hợp đồng compile-time đảm bảo giá trị truyền qua ranh giới isolation là an toàn để chia sẻ. Bỏ qua nó là nguồn lỗi phổ biến nhất khi migrate.
Strict Concurrency Checking ở Swift 6 là bắt buộc cho dự án mới. Với dự án cũ, hãy migrate từng module bằng tooling thay vì bật toàn cục một lần.
async/await: từ completion handler đến mã tuần tự
Trước Swift 5.5, mọi tác vụ bất đồng bộ trong iOS đều phải đi qua completion handler. Bạn viết callback lồng callback, mỗi block bắt giữ self bằng [weak self], rồi unwrap optional trước khi tiếp tục. Honestly, tôi đã viết loại mã đó từ thời Objective-C với ^(NSError *error) và thú thực là không nhớ nổi mình bao nhiêu lần quên gọi completion ở nhánh lỗi. async/await xoá sạch lớp ceremony đó.
Một hàm async là hàm có thể tạm dừng. Khi gặp await, runtime giải phóng thread, lưu lại trạng thái của task, và quay lại tiếp tục khi giá trị đã sẵn sàng. Cú pháp đọc giống mã đồng bộ nhưng dưới mui xe là một state machine do compiler tự sinh:
func loadProfile(userID: String) async throws -> Profile {
let user = try await api.fetchUser(id: userID)
let avatar = try await images.download(url: user.avatarURL)
return Profile(user: user, avatar: avatar)
}
Hai điểm await ở đây là hai suspension point. Giữa chúng, thread có thể được dùng cho task khác. Bạn không cần biết task nào sẽ tiếp tục mình. Runtime lo phần đó. Điều quan trọng cần ghi nhớ: await không có nghĩa là "chạy nền". Nó chỉ có nghĩa "có thể tạm dừng". Việc hàm có chạy trên thread khác hay không phụ thuộc vào isolation context, mà từ Swift 6.2 đã thay đổi đáng kể (xem phần sau).
Bridge từ completion handler cũ
Khi làm việc với API cũ chưa có phiên bản async, hãy bọc bằng withCheckedThrowingContinuation:
func fetchLegacy() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetch { data, error in
if let error { continuation.resume(throwing: error) }
else if let data { continuation.resume(returning: data) }
}
}
}
Hai quy tắc bất di bất dịch: gọi resume đúng một lần, và không bao giờ bỏ sót continuation. Quên một nhánh, task của bạn sẽ treo vĩnh viễn. Tôi đã ship bug này trong một project năm ngoái và phải mất nửa ngày để dò ra vì task suspend im lặng, không có log gì.
Task, TaskGroup và structured concurrency
Task là đơn vị thực thi cơ bản, tương tự thread nhưng nhẹ hơn nhiều lần vì được lập lịch bởi cooperative thread pool thay vì hệ điều hành. Bạn tạo task khi cần khởi động công việc bất đồng bộ từ một context đồng bộ (ví dụ trong onAppear của SwiftUI), hoặc khi muốn chạy nhiều việc song song:
Đó là unstructured task: tuổi đời độc lập với context tạo ra nó, bạn phải tự quản lý huỷ. Trong hầu hết trường hợp, hãy ưu tiên structured concurrency bằng async let hoặc TaskGroup để child task tự huỷ khi parent kết thúc.
Ba lời gọi network này chạy song song. Nếu một trong ba ném lỗi, hai task còn lại được huỷ tự động. Đây chính là điểm tôi thích nhất ở structured concurrency: cancellation lan toả tự động, không cần token thủ công như NSOperation ngày xưa.
Khi nào dùng TaskGroup
TaskGroup thích hợp khi số lượng task chỉ biết tại runtime, ví dụ tải song song một danh sách URL có độ dài thay đổi:
func downloadAll(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await URLSession.shared.data(from: url).0 }
}
var results: [Data] = []
for try await data in group { results.append(data) }
return results
}
}
Lưu ý: thứ tự kết quả trong group là thứ tự hoàn thành, không phải thứ tự thêm vào. Nếu cần giữ thứ tự gốc, hãy dùng tuple (index, data) rồi sort sau cùng.
Actor và mô hình isolation
actor là kiểu tham chiếu giống class nhưng có một tính chất khác biệt: compiler đảm bảo chỉ một task truy cập mutable state của nó tại một thời điểm. Bạn không cần lock thủ công, không cần dispatch queue serial. Runtime tự xếp hàng các lời gọi.
actor RequestCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? { storage[url] }
func store(_ data: Data, for url: URL) {
storage[url] = data
}
}
Từ bên ngoài, mọi lời gọi phương thức của actor đều là async, kể cả khi phương thức không có async ở khai báo:
let cache = RequestCache()
let cached = await cache.data(for: url) // async, vì phải "vào" actor
Trong nội bộ actor, bạn truy cập state đồng bộ bình thường. Compiler hiểu rằng method của actor luôn chạy trên isolation của chính actor đó. Tham số có annotation nonisolated được phép truy cập từ bất kỳ context nào nhưng đổi lại không được đụng vào mutable state.
Khi cần bảo vệ trạng thái dùng chung trong toàn app (ví dụ singleton logger, in-memory cache, hay analytics tracker), actor là lựa chọn mặc định, không phải class với DispatchQueue. Mã ngắn hơn, an toàn hơn, và compiler bắt lỗi cho bạn. Để hiểu sâu hơn cơ chế Sendable mà actor dựa vào, hãy đọc thêm bài Swift 6.2: Approachable Concurrency, InlineArray và tất cả tính năng mới.
@MainActor và lý do SwiftUI cần nó
UIKit và SwiftUI đều ràng buộc một quy tắc cứng: mọi cập nhật UI phải xảy ra trên main thread. Trước Swift Concurrency, vi phạm quy tắc này dẫn đến crash ngẫu nhiên hoặc warning ở runtime. Giờ đây, @MainActor nâng quy tắc đó lên thành ràng buộc compile-time.
@MainActor
final class FeedViewModel: ObservableObject {
@Published var items: [Post] = []
func refresh() async {
let fresh = await api.loadPosts()
items = fresh // an toàn, vẫn ở MainActor sau await
}
}
Toàn bộ class được đánh dấu @MainActor, nghĩa là mọi phương thức và property của nó chỉ truy cập được từ main thread. Khi bạn gọi từ context khác, compiler buộc thêm await. Trong SwiftUI, View.body đã được isolation tới @MainActor ngầm định kể từ iOS 17, nên view model gắn @MainActor kết nối mượt mà.
MainActor.run cho block đơn lẻ
Nếu lớp của bạn không gắn @MainActor tổng thể nhưng cần cập nhật UI ở một chỗ, dùng:
await MainActor.run {
self.label.text = "Done"
}
Cú pháp này tương đương DispatchQueue.main.async nhưng tích hợp vào hệ thống isolation, nên compiler có thể kiểm tra Sendable cho các giá trị bạn capture.
Sendable và cách dập tắt lỗi data race
Sendable là marker protocol đánh dấu một kiểu là an toàn để truyền qua ranh giới isolation. Compiler tự suy ra Sendable cho:
Value type (struct, enum) mà mọi stored property đều Sendable
Actor (mặc định luôn Sendable)
final class không có mutable state, hoặc có lock nội bộ và được khai báo @unchecked Sendable
Class thông thường không phải Sendable, vì nó là kiểu tham chiếu có thể bị chia sẻ và mutate đồng thời. Khi compiler báo lỗi "Type X does not conform to the 'Sendable' protocol", đừng vội thêm @unchecked Sendable để tắt warning. Đó là tự lừa mình. Hãy kiểm tra:
Có thể chuyển sang struct không? (Lựa chọn tốt nhất)
Có thể đánh dấu final và làm read-only không?
Nếu phải giữ là class có mutable state, bọc bằng actor.
Chỉ dùng @unchecked Sendable khi bạn đã có lock/queue và tự chịu trách nhiệm đảm bảo an toàn, kèm comment giải thích cơ chế đó.
Swift 6.2: nonisolated(nonsending) và @concurrent
So, đây là phần có thể làm bạn ngạc nhiên nhất khi cập nhật lên Xcode 26 và Swift 6.2. Trước đây, một hàm nonisolated async không gắn isolation nào sẽ nhảy sang global executor khi được gọi, tức là chạy trên một thread nền bất kỳ. Hệ quả: mỗi await từ @MainActor đều băng qua ranh giới isolation, kéo theo warning Sendable cho mọi giá trị truyền vào.
Đề xuất SE-0461 đã đảo ngược mặc định này. Trong Swift 6.2 với chế độ Approachable Concurrency:
nonisolated func decode(_ json: Data) async throws -> Profile {
let decoder = JSONDecoder()
return try decoder.decode(Profile.self, from: json)
}
@MainActor
func showProfile() async throws {
let profile = try await decode(jsonData) // vẫn ở MainActor!
}
Trước Swift 6.2, lời gọi decode sẽ nhảy sang background thread, và profile phải Sendable. Sau Swift 6.2, decode kế thừa MainActor của caller, không nhảy thread, không cần Sendable. Cú pháp đầy đủ là nonisolated(nonsending) nhưng đó cũng là mặc định mới nên bạn ít khi phải viết.
Khi bạn thực sự muốn công việc chạy song song trên thread khác (vì nó là CPU-bound và đáng để parallel hoá), dùng thuộc tính mới @concurrent:
@concurrent
func resizeImage(_ image: UIImage) async -> UIImage {
// chạy trên cooperative thread pool, không trên caller's actor
image.resized(to: CGSize(width: 300, height: 300))
}
@concurrent không thể kết hợp với @MainActor, custom global actor, hay nonisolated(nonsending). Bạn phải chọn một trong hai mô hình. Quy tắc đơn giản tôi tự đặt cho mình: mặc định để Swift 6.2 giữ hàm trên actor của caller; chỉ dùng @concurrent khi hàm có vòng lặp tốn CPU giữa các điểm await. Bài viết về Typed Throws trong Swift 6.2 bàn sâu hơn về error handling kết hợp với mô hình concurrency mới này.
Actor reentrancy: cái bẫy ai cũng dính một lần
Actor đảm bảo chỉ một task thực thi tại một thời điểm, nhưng không đảm bảo trạng thái không thay đổi qua điểm await. Lý do: khi method của actor suspend tại await, actor được trả về cho các task khác chạy. Khi task gốc quay lại, state có thể đã khác.
actor Downloader {
private var cache: [URL: Data] = [:]
func download(_ url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let data = try await URLSession.shared.data(from: url).0
cache[url] = data // task khác có thể đã thêm vào cache trong lúc await!
return data
}
}
Đoạn code trên không sai về kiểu, nhưng có thể download trùng cùng một URL nếu hai task gọi đồng thời. Cách khắc phục: kiểm tra lại cache sau await, hoặc dùng pattern dedup bằng dictionary chứa Task đang chạy:
actor Downloader {
private var inflight: [URL: Task<Data, Error>] = [:]
private var cache: [URL: Data] = [:]
func download(_ url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
if let task = inflight[url] { return try await task.value }
let task = Task { try await URLSession.shared.data(from: url).0 }
inflight[url] = task
defer { inflight[url] = nil }
let data = try await task.value
cache[url] = data
return data
}
}
Đây là pattern quan trọng đến mức tôi viết hẳn snippet trong Swift Testing để verify deduplication hoạt động đúng dưới tải đồng thời. Reentrancy là tính năng tốt (nó giúp tránh deadlock) nhưng bạn phải coi mỗi await là điểm mà thế giới có thể thay đổi.
Chiến lược migrate dự án legacy
Nếu bạn maintain một codebase đã có từ thời iOS 13 với hàng nghìn completion handler, đừng bật strict-concurrency=complete trên target chính rồi cố vá hàng đêm. Apple khuyến nghị quy trình bốn bước, và tôi đã chạy nó qua hai dự án production:
Bật Swift 6 language mode trên một module tách biệt, thường là package nội bộ hoặc target tiện ích. Đo lường thời gian và số warning trước khi nhân rộng.
Convert API completion handler sang async bằng Xcode refactor (Editor → Refactor → Convert Function to Async). Giữ phiên bản completion làm wrapper gọi async cho call site cũ.
Thêm @MainActor cho view model và view controller trước khi bắt đầu sửa Sendable. Cách này loại bỏ ngay nhiều cảnh báo "isolation may change".
Bật Approachable Concurrency build setting theo từng feature. Setting này bật cùng lúc nhiều default mới (NonisolatedNonsendingByDefault, inferred Sendable cho closure ở context MainActor) nên hành vi runtime sẽ thay đổi. Có hướng dẫn chi tiết tại Swift 6 Concurrency Migration Guide.
Hiệu năng, profiling và thread explosion
Cooperative thread pool của Swift Concurrency có số thread bằng số CPU core, không hơn. Đây là tính năng, không phải hạn chế. Nó tránh chi phí context switching mà GCD thường gây ra khi bạn tạo hàng trăm queue. Nhưng nó cũng có nghĩa là blocking call (như Thread.sleep, file I/O đồng bộ, hoặc lock chờ) trong async function sẽ ăn hết một thread của pool. Đủ nhiều blocking call và app của bạn chết đơ.
Quy tắc: trong hàm async, không bao giờ gọi API chặn thread. Thay vào đó dùng phiên bản async của API (URLSession có data(from:) async, FileHandle có bytes async sequence). Nếu buộc phải gọi API đồng bộ chặn thread, gói nó trong DispatchQueue.global().async hoặc dùng @concurrent với Task.detached.
Xcode 26 nâng cấp Swift Concurrency Instrument với hai view mới đáng dùng:
Task Tree: hiển thị cây quan hệ cha-con giữa các task, giúp tìm orphan task chạy ngầm sau khi view biến mất.
Executor Hops: đếm số lần task nhảy giữa các executor. Nhiều hop trên đường nóng nghĩa là nhiều overhead, thường có thể giảm bằng cách thêm @MainActor đồng nhất.
Tham khảo Swift Concurrency documentation chính thức cho mô hình memory ordering và đảm bảo của runtime. Phần "Asynchronous Sequences" thường bị bỏ qua nhưng rất hữu ích khi xử lý stream sự kiện.
Checklist tối ưu hiệu năng
Đo trước, sửa sau. Đừng đoán mò bằng cách rắc Task.detached khắp nơi.
Một task lớn thường rẻ hơn nhiều task nhỏ. Gộp công việc khi có thể.
Dùng TaskPriority hợp lý: .userInitiated cho UI-triggered, .background cho prefetch, mặc định cho phần còn lại.
Task { ... } trong onAppear nên huỷ trong onDisappear, hoặc dùng modifier .task để SwiftUI tự huỷ.
Câu hỏi thường gặp
async/await khác gì với DispatchQueue?
DispatchQueue đặt closure vào hàng đợi và thực thi trên một thread của pool, nhưng compiler không hiểu luồng dữ liệu, không kiểm tra Sendable, và không tự huỷ. async/await được compiler hỗ trợ toàn diện: kiểm tra isolation, lan truyền cancellation, tích hợp với actor. Bạn vẫn có thể dùng DispatchQueue cho công việc đặc thù như serial queue ràng buộc với C API, nhưng cho luồng dữ liệu mới thì async/await là lựa chọn mặc định.
Khi nào nên dùng actor thay vì class?
Dùng actor khi bạn cần kiểu tham chiếu có mutable state được truy cập từ nhiều task. Dùng class final read-only khi state là bất biến sau khởi tạo (thường nên là struct luôn). Dùng class có lock thủ công chỉ khi cần tương thích với code C/Objective-C cũ, và phải đánh dấu @unchecked Sendable kèm comment giải thích cơ chế lock.
@concurrent trong Swift 6.2 dùng để làm gì?
@concurrent đánh dấu một hàm async nên chạy trên cooperative thread pool thay vì kế thừa actor của caller. Bạn dùng nó cho công việc CPU-bound (resize ảnh, parse dữ liệu lớn, tính toán hash) khi muốn rời khỏi MainActor để không block UI. Mặc định mới của Swift 6.2 là không chuyển thread, nên @concurrent là cách opt-in tường minh.
Làm sao để huỷ một Task đang chạy?
Gọi task.cancel(). Việc huỷ không dừng task ngay lập tức. Task phải tự kiểm tra Task.isCancelled hoặc gọi try Task.checkCancellation() tại các điểm hợp lý. Hầu hết API trong stdlib (URLSession, AsyncStream, Task.sleep) đã honor cancellation tự động và ném CancellationError. Trong structured concurrency, parent task bị huỷ sẽ huỷ luôn child task, vì vậy bạn hiếm khi phải gọi cancel() thủ công.
Tại sao tôi nhận lỗi "Sendable" khi truyền closure vào Task?
Closure truyền vào Task { } được capture bởi một context bất đồng bộ, nên mọi giá trị nó capture phải Sendable. Lỗi thường gặp: capture self của một class không Sendable, hoặc capture một mảng tham chiếu tới object class. Giải pháp: chuyển sang struct, đánh dấu class là final với mutable state được bảo vệ, hoặc bọc state trong actor và capture actor reference.
Học cách dùng KeyframeAnimator và PhaseAnimator trong SwiftUI iOS 26 để dàn dựng animation nhiều track song song, xử lý Reduce Motion đúng cách và tối ưu hiệu năng render pipeline.
Hướng dẫn đầy đủ Swift Charts trong iOS 26 và SwiftUI: từ BarMark, LineMark, AreaMark đến Chart3D, scrolling axes, chartGesture và VectorizedChartContent với code Xcode 26.
Swift Macros (Swift 5.9+) cho phép metaprogramming tại compile time. Tìm hiểu @Observable, @Model, #Preview, #Predicate và cách tự tạo custom macro với SwiftSyntax cho iOS 2026.