Mục lục
Tại sao đa luồng lại quan trọng?
Chào mừng bạn trở lại với chuỗi bài viết “iOS Developer Roadmap”! Nếu bạn đã theo dõi các bài trước như Những Kiến Thức Swift Cơ Bản Mọi Lập Trình Viên iOS Phải Biết hay Hiểu rõ Vòng đời của ViewController trong UIKit, bạn sẽ nhận ra rằng một ứng dụng iOS thực tế không chỉ đơn thuần là hiển thị giao diện và phản hồi các thao tác người dùng đơn giản.
Ứng dụng của chúng ta cần thực hiện nhiều tác vụ phức tạp: tải dữ liệu từ mạng, xử lý hình ảnh lớn, truy vấn cơ sở dữ liệu, hoặc thực hiện các phép tính tốn thời gian. Nếu tất cả các tác vụ này đều chạy trên luồng chính (main thread), luồng chịu trách nhiệm cập nhật giao diện người dùng (UI), ứng dụng của bạn sẽ bị “đơ”. Người dùng sẽ không thể cuộn, nhấn nút, hoặc thậm chí thấy các animation hoạt động mượt mà.
Đa luồng (Concurrency) là giải pháp cho vấn đề này. Nó cho phép ứng dụng của bạn thực hiện nhiều tác vụ cùng lúc hoặc xen kẽ nhau, sử dụng nhiều luồng xử lý khác nhau. Bằng cách di chuyển các tác vụ nặng nhọc ra khỏi luồng chính, chúng ta giữ cho UI luôn phản hồi, mang lại trải nghiệm người dùng tốt hơn. Đồng thời, đa luồng còn giúp tận dụng tối đa sức mạnh của các thiết bị hiện đại với nhiều nhân xử lý (multi-core processors), cho phép các tác vụ thực sự chạy song song.
Trong hệ sinh thái Apple, chúng ta có hai phương pháp chính để quản lý đa luồng hiệu quả: Grand Central Dispatch (GCD) và Swift Concurrency (sử dụng async/await). Cả hai đều giúp bạn viết mã bất đồng bộ (asynchronous code), nhưng với các triết lý và cách tiếp cận khác nhau. Chúng ta hãy cùng tìm hiểu sâu hơn về từng phương pháp và xem khi nào thì nên sử dụng cái nào nhé.
Giới thiệu Grand Central Dispatch (GCD)
GCD là gì?
Grand Central Dispatch (GCD) là một thư viện cấp thấp được Apple giới thiệu từ macOS 10.6 và iOS 4. Nó được xây dựng dựa trên khái niệm hàng đợi (queues) và tác vụ (tasks). Thay vì làm việc trực tiếp với các luồng (threads), bạn làm việc với các tác vụ và yêu cầu GCD thực hiện chúng trên các hàng đợi. GCD sẽ tự động quản lý một pool các luồng và phân phối các tác vụ trong hàng đợi vào các luồng này để thực thi một cách hiệu quả nhất.
Mục tiêu chính của GCD là đơn giản hóa việc viết mã đa luồng bằng cách trừu tượng hóa sự phức tạp của quản lý luồng cấp thấp.
Hàng đợi (Dispatch Queues)
Trái tim của GCD là các Dispatch Queues. Đây là các cấu trúc dữ liệu FIFO (First-In, First-Out) chứa các khối mã (closures) mà bạn muốn thực thi. Có hai loại hàng đợi chính:
- Hàng đợi Nối tiếp (Serial Queue): Các tác vụ trong hàng đợi này được thực thi theo thứ tự bạn thêm chúng vào, chỉ một tác vụ tại một thời điểm. Hàng đợi nối tiếp rất hữu ích khi bạn cần bảo vệ tài nguyên dùng chung khỏi bị truy cập đồng thời (racing condition). Luồng chính (main thread) của ứng dụng chính là một hàng đợi nối tiếp đặc biệt: `DispatchQueue.main`.
- Hàng đợi Đồng thời (Concurrent Queue): Các tác vụ trong hàng đợi này có thể được thực thi đồng thời (hoặc gần đồng thời) trên nhiều luồng khác nhau. Thứ tự bắt đầu và kết thúc của các tác vụ không được đảm bảo. GCD cung cấp các hàng đợi đồng thời toàn cục (global concurrent queues) với các mức độ ưu tiên (Quality of Service – QoS) khác nhau: `userInteractive`, `userInitiated`, `default`, `utility`, `background`.
Bạn cũng có thể tạo các hàng đợi tùy chỉnh của riêng mình, cả nối tiếp và đồng thời.
Đồng bộ (Synchronous) vs Bất đồng bộ (Asynchronous)
Khi thêm một tác vụ vào hàng đợi, bạn có thể chọn thực hiện nó theo hai cách:
sync
: Phương thứcsync
chờ cho đến khi tác vụ trong hàng đợi hoàn thành trước khi trả về. Điều này có nghĩa là luồng gọisync
bị chặn (blocked) trong khi tác vụ đang chạy. Sử dụngsync
trên luồng hiện tại để gửi đến *cùng* một hàng đợi nối tiếp (ví dụ: gọi `DispatchQueue.main.sync` từ main thread) sẽ gây ra bế tắc (deadlock).async
: Phương thứcasync
trả về ngay lập tức sau khi gửi tác vụ vào hàng đợi. Luồng gọiasync
không bị chặn và có thể tiếp tục thực hiện công việc của nó. Tác vụ sẽ được thực thi trên một luồng khác do GCD quản lý khi đến lượt nó trong hàng đợi. Đây là cách phổ biến nhất để thực hiện công việc nền mà không làm đơ UI.
Các Mẫu Sử Dụng GCD Phổ Biến
- Thực hiện công việc nền và cập nhật UI: Gửi tác vụ nặng nhọc đến hàng đợi toàn cục bất đồng bộ, sau khi hoàn thành, gửi tác vụ cập nhật UI trở lại hàng đợi chính (serial).
- Thực hiện nhiều tác vụ đồng thời và chờ tất cả hoàn thành: Sử dụng `DispatchGroup`.
- Hoãn thực thi: Sử dụng `DispatchQueue.main.asyncAfter`.
DispatchQueue.global(qos: .userInitiated).async {
// Thực hiện công việc nền tốn thời gian
let result = doSomeHeavyWork()
// Quay lại luồng chính để cập nhật UI
DispatchQueue.main.async {
updateUI(with: result)
}
}
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
// Tác vụ 1
print("Task 1 finished")
group.leave()
}
group.enter()
DispatchQueue.global().async {
// Tác vụ 2
print("Task 2 finished")
group.leave()
}
group.notify(queue: .main) {
// Tất cả tác vụ trong group đã hoàn thành, cập nhật UI
print("All tasks finished, updating UI")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
// Code này sẽ chạy sau 2 giây trên luồng chính
print("Delayed execution")
}
Những hạn chế của GCD
Mặc dù GCD là một công cụ mạnh mẽ và đã phục vụ chúng ta rất tốt trong nhiều năm, nó vẫn có những nhược điểm đáng kể, đặc biệt khi xử lý các luồng công việc bất đồng bộ phức tạp:
- Callback Hell (Thác gọi lại): Khi bạn có nhiều thao tác bất đồng bộ phụ thuộc lẫn nhau, mã của bạn nhanh chóng trở thành một chuỗi lồng nhau của các closure (callback), gây khó khăn cho việc đọc, hiểu và bảo trì.
- Xử lý lỗi phức tạp: Truyền lỗi qua các callback có thể rườm rà. Bạn thường phải tự mình quản lý trạng thái lỗi và truyền nó đến closure cuối cùng. Hãy xem lại bài Xử lý Lỗi Một Cách Duyên dáng trong Swift: Xây dựng Ứng dụng iOS Vững vàng để hiểu tầm quan trọng của việc xử lý lỗi, và bạn sẽ thấy nó khó hơn như thế nào trong các callback lồng nhau của GCD.
- Hủy bỏ (Cancellation) khó khăn: Việc hủy bỏ một tác vụ đang chạy hoặc một chuỗi các tác vụ trong GCD không phải lúc nào cũng đơn giản và thường yêu cầu quản lý trạng thái thủ công.
- Đọc và debug khó khăn: Luồng thực thi có thể nhảy từ closure này sang closure khác, làm cho việc theo dõi logic và debug trở nên khó khăn hơn.
- Quản lý luồng ngầm định: Mặc dù GCD trừu tượng hóa việc quản lý luồng, nhưng vẫn có thể xảy ra các vấn đề liên quan đến bế tắc (deadlock) hoặc tình trạng tranh chấp (race condition) nếu không cẩn thận với việc sử dụng `sync` hoặc truy cập tài nguyên dùng chung mà không có biện pháp bảo vệ phù hợp (như sử dụng hàng đợi nối tiếp).
Giới thiệu Swift Concurrency (async/await)
async/await là gì?
Với sự ra đời của Swift 5.5, Apple đã giới thiệu một mô hình đa luồng mới, được tích hợp sâu vào ngôn ngữ Swift: Swift Concurrency, nổi bật với cú pháp `async/await`. Mục tiêu của Swift Concurrency là cung cấp một cách tiếp cận đa luồng có cấu trúc (structured concurrency), an toàn hơn, dễ đọc hơn và ít gây lỗi hơn so với các phương pháp dựa trên callback truyền thống.
Thay vì sử dụng callback, `async/await` cho phép bạn viết mã bất đồng bộ theo một luồng thực thi gần như tuyến tính, giống như mã đồng bộ thông thường.
Hàm async và Từ khóa await
async
: Một hàm hoặc phương thức được đánh dấu bằng từ khóa `async` trong chữ ký của nó báo hiệu rằng hàm này có thể tạm dừng (suspend) trong quá trình thực thi để chờ một thao tác bất đồng bộ hoàn thành. Nó không nhất thiết *phải* tạm dừng, nhưng nó *có thể*.
func fetchData() async -> Data {
// Tác vụ mạng giả định
print("Starting data fetch...")
try? await Task.sleep(nanoseconds: 2_000_000_000) // Tạm dừng 2 giây
print("Data fetch finished.")
return Data() // Trả về dữ liệu giả
}
await
: Khi gọi một hàm `async` từ bên trong một hàm `async` khác hoặc từ một ngữ cảnh bất đồng bộ (async context), bạn cần sử dụng từ khóa `await`. Từ khóa này đánh dấu một điểm tiềm năng tạm dừng. Luồng hiện tại có thể tạm dừng tại điểm `await` này và cho phép các công việc khác chạy cho đến khi thao tác bất đồng bộ mà `await` đang chờ hoàn thành. Khi thao tác hoàn thành, luồng sẽ tiếp tục thực thi từ ngay sau điểm `await`.func processData() async {
print("Processing data...")
let data = await fetchData() // Dừng ở đây cho đến khi fetchData() hoàn thành
print("Data received: \(data.count) bytes")
// Xử lý dữ liệu...
}
Lưu ý quan trọng: Bạn chỉ có thể sử dụng `await` bên trong một hàm `async` hoặc một ngữ cảnh bất đồng bộ khác (như `Task`).
Tasks
`Task` là đơn vị làm việc trong Swift Concurrency. Nó tương tự như việc gửi một khối mã đến hàng đợi trong GCD, nhưng được tích hợp chặt chẽ hơn với mô hình `async/await`.
- Bạn tạo một `Task` để chạy một khối mã `async` một cách độc lập.
Task {
print("Task started")
await processData() // Gọi hàm async bên trong Task
print("Task finished")
}
Structured Concurrency (Đa luồng có cấu trúc)
Một trong những cải tiến lớn nhất của Swift Concurrency là Structured Concurrency. Nó đảm bảo rằng tất cả các `Task` con được tạo ra trong một `Task` cha sẽ hoàn thành hoặc bị hủy bỏ trước khi `Task` cha kết thúc. Điều này giúp ngăn chặn các tác vụ “mồ côi” chạy ngầm và làm cho luồng kiểm soát trong mã bất đồng bộ trở nên rõ ràng hơn nhiều.
Ví dụ, sử dụng `async let` để chạy nhiều tác vụ đồng thời và chờ kết quả của tất cả:
func fetchMultipleData() async throws -> (Data, Data) {
async let data1 = fetchData(from: url1) // Bắt đầu fetch data1 ngay lập tức
async let data2 = fetchData(from: url2) // Bắt đầu fetch data2 ngay lập tức
// await ở đây sẽ đợi cả data1 và data2 hoàn thành
let result1 = try await data1
let result2 = try await data2
return (result1, result2)
}
// Để chạy hàm này từ ngữ cảnh đồng bộ (ví dụ: trong viewDidLoad)
Task {
do {
let (d1, d2) = try await fetchMultipleData()
print("Fetched both data streams.")
} catch {
print("Error fetching data: \(error)")
}
}
GCD vs async/await: So sánh trực tiếp
Bây giờ, hãy đặt hai mô hình này cạnh nhau để thấy rõ sự khác biệt.
Khả năng đọc và bảo trì
- GCD: Dựa trên callback và closure lồng nhau, đặc biệt với các chuỗi thao tác phụ thuộc, dẫn đến “callback hell”. Khó theo dõi luồng thực thi.
- async/await: Cho phép viết mã bất đồng bộ gần như tuyến tính. Luồng thực thi rõ ràng hơn nhiều. Giảm thiểu “callback hell” đáng kể. Dễ đọc và bảo trì hơn.
Xử lý lỗi
- GCD: Phải tự quản lý và truyền lỗi qua các callback. Rất dễ mắc lỗi và khó debug. Liên quan đến bài viết Xử lý Lỗi Một Cách Duyên dáng trong Swift, việc áp dụng try/catch trong các callback GCD không trực quan bằng.
- async/await: Tích hợp liền mạch với cơ chế xử lý lỗi `try/catch` của Swift. Hàm `async` có thể là `throwing`, và bạn sử dụng `try await` để gọi chúng trong một khối `do/catch` thông thường. Điều này làm cho việc xử lý lỗi nhất quán và dễ dàng hơn nhiều.
Hủy bỏ (Cancellation)
- GCD: Hủy bỏ là một thách thức lớn. `DispatchWorkItem` có thể bị cancel, nhưng việc lan truyền yêu cầu hủy bỏ qua một chuỗi các tác vụ phức tạp cần quản lý thủ công.
- async/await: Tích hợp cơ chế hủy bỏ hợp tác (cooperative cancellation). `Task` có thể bị cancel, và mã trong hàm `async` có thể kiểm tra trạng thái hủy bỏ (`Task.isCancelled` hoặc `Task.checkCancellation()`) để dừng công việc một cách gọn gàng. Structured concurrency giúp lan truyền yêu cầu hủy bỏ xuống các `Task` con.
Quản lý Tài nguyên
- GCD: Cần cẩn thận để tránh retain cycle trong các closure (như đã thảo luận trong bài Quản Lý Bộ Nhớ trong Swift: ARC, Tham Chiếu và Các Thực Hành Tốt Nhất). Việc quản lý luồng công việc phức tạp dễ dẫn đến các tác vụ “mồ côi”.
- async/await: Structured concurrency đảm bảo các `Task` con không chạy quá `Task` cha, giúp quản lý vòng đời tác vụ tốt hơn. Việc sử dụng `async/await` đúng cách cũng giúp giảm thiểu nguy cơ retain cycle so với các callback lồng nhau.
Debugging
- GCD: Rất khó debug vì luồng thực thi nhảy qua lại giữa các closure. Stack trace thường không hữu ích lắm.
- async/await: Luồng thực thi tuyến tính hơn giúp việc đặt breakpoint và theo dõi trở nên dễ dàng hơn nhiều. Debugger trong Xcode hỗ trợ tốt async/await.
Bảng Tóm Tắt
Đặc điểm | Grand Central Dispatch (GCD) | Swift Concurrency (async/await) |
---|---|---|
Giới thiệu | iOS 4 / macOS 10.6 | Swift 5.5 (iOS 15 / macOS 12) |
Mô hình | Dựa trên Hàng đợi (Queues) và Tác vụ (Blocks/Work Items). Quản lý luồng ở cấp thấp hơn (mặc dù trừu tượng hóa so với thread trực tiếp). | Dựa trên Tác vụ (Tasks) và Tạm dừng/Tiếp tục (Suspend/Resume). Mô hình đa luồng có cấu trúc (Structured Concurrency). |
Cú pháp | Phương thức `async`, `sync` trên `DispatchQueue`. Sử dụng closure/callback. `DispatchGroup`, `DispatchWorkItem`. | Từ khóa `async`, `await`. Kiểu `Task`. `async let`. |
Khả năng đọc | Có thể dẫn đến “Callback Hell” với các luồng công việc phức tạp. Khó theo dõi. | Luồng thực thi gần như tuyến tính. Dễ đọc và hiểu hơn đáng kể. |
Xử lý lỗi | Phải xử lý thủ công trong callback. Rườm rà, dễ sai. | Tích hợp với `try/catch` của Swift. Rõ ràng và nhất quán. |
Hủy bỏ | Khó khăn, cần quản lý thủ công (ví dụ: `DispatchWorkItem.cancel()`). | Tích hợp sẵn, dễ dàng kiểm tra và phản hồi yêu cầu hủy bỏ (`Task.isCancelled`, `Task.checkCancellation()`). Structured concurrency hỗ trợ lan truyền hủy bỏ. |
Debugging | Khó theo dõi luồng thực thi. Stack trace ít hữu ích. | Dễ debug hơn nhiều nhờ luồng thực thi tuyến tính và hỗ trợ từ Xcode. |
An toàn luồng (Thread Safety) | Cần cẩn thận với race condition, sử dụng hàng đợi nối tiếp hoặc lock thủ công. | Hỗ trợ Actors để đảm bảo an toàn dữ liệu khi truy cập từ nhiều `Task`. |
Khi nào sử dụng cái nào?
Mặc dù Swift Concurrency là tương lai của đa luồng trong Swift và được khuyến khích cho mã mới, GCD vẫn có chỗ đứng của nó.
- Sử dụng async/await khi:
- Viết mã mới cho các tác vụ bất đồng bộ.
- Bạn có các luồng công việc bất đồng bộ phức tạp, phụ thuộc lẫn nhau.
- Cần xử lý lỗi hoặc hủy bỏ một cách rõ ràng và dễ dàng.
- Muốn tận dụng Structured Concurrency.
- Làm việc với các API mới của Apple đã được thiết kế với async/await.
- Sử dụng GCD khi:
- Làm việc với các API cũ chỉ cung cấp giao diện dựa trên GCD.
- Thực hiện các tác vụ bất đồng bộ rất đơn giản, độc lập (ví dụ: gửi một khối mã nhỏ đến hàng đợi nền).
- Cần kiểm soát chi tiết hơn các thuộc tính của hàng đợi (ví dụ: tạo hàng đợi nối tiếp tùy chỉnh cho một tài nguyên cụ thể).
- Tích hợp với mã hiện có đã sử dụng GCD.
Trong nhiều trường hợp, bạn sẽ thấy mình sử dụng cả hai trong cùng một ứng dụng. Swift cung cấp các cách để “cầu nối” (bridge) giữa GCD và async/await, ví dụ: sử dụng `Task.detached` hoặc các initializer của `Task` để chạy mã async trong ngữ cảnh không phải async, hoặc sử dụng `DispatchQueue.async` bên trong một hàm `async` để tương tác với mã dựa trên GCD.
Kết luận
Đa luồng là một kỹ năng thiết yếu trên con đường trở thành một lập trình viên iOS giỏi, như đã đề cập trong Lộ trình học Lập trình viên iOS 2025 của chúng ta. Nắm vững GCD đã là một bước tiến lớn, nhưng làm quen và sử dụng thành thạo Swift Concurrency với async/await sẽ giúp bạn viết mã đa luồng hiện đại, an toàn, dễ đọc và bảo trì hơn rất nhiều.
Hãy bắt đầu thực hành viết các hàm `async`, sử dụng `await`, và tạo `Task`. Dần dần, bạn sẽ thấy cách nó đơn giản hóa việc xử lý bất đồng bộ so với các callback truyền thống.
Swift Concurrency không chỉ giới hạn ở async/await mà còn có các tính năng mạnh mẽ khác như Actors (để quản lý trạng thái dùng chung an toàn) và AsyncSequence. Chúng ta có thể khám phá những chủ đề này trong các bài viết sau. Tạm thời, hãy tập trung vào việc làm quen với async/await và Task để xây dựng nền tảng vững chắc về đa luồng có cấu trúc trong Swift.
Chúc bạn học tốt!