All posts
Swift 6 Strict Concurrency
iosswift

Swift 6 Strict Concurrency

Hao Nguyen K.'s avatarHao Nguyen K.
Table of Contents14 sections

Chào các bạn, những lập trình viên iOS quả cảm! Nếu bạn đang ở đây, có lẽ bạn vừa dũng cảm bật thiết lập Strict Concurrency Checking thành Complete trong một dự án hàng trăm ngàn dòng code, để rồi nhìn Xcode "tặng" lại một danh sách vài trăm (hoặc vài ngàn) cảnh báo đỏ chót và màu vàng rực rỡ.

Cảm giác đó giống như mở chiếc hộp Pandora vậy. Đừng hoảng loạn, và cũng đừng vội tắt nó đi để quay lại vùng an toàn. Swift 6 không cố tình làm khó bạn; nó chỉ đang cố gắng ngăn chặn những lỗi data-race (xung đột dữ liệu) bí ẩn mà bạn từng mất cả tuần để debug trên môi trường Production.

📌 Lưu ý về thuật ngữ: Trong bài này, "cảnh báo" (warning) là những gì bạn thấy khi bật Complete checking ở Swift 5 language mode. Khi bạn chuyển hẳn dự án sang Swift 6 language mode, phần lớn những cảnh báo này sẽ trở thành compile error — tức là không sửa thì không build được. Đây chính là lý do nên xử lý dần từ bây giờ.

Bài viết này không nói lại lý thuyết suông. Chúng ta sẽ cùng nhau phân tích sâu về Sendable, Actor, cách giải quyết các cảnh báo tốn thời gian nhất, và chiến lược refactor code thông minh để "sống sót" qua đợt nâng cấp này mà không làm sập kiến trúc hiện tại của dự án.


1. Bộ Ba Quyền Lực: Hiểu Đúng Để Không "Chống Lại" Swift 6

Trước khi lao vào sửa lỗi, chúng ta cần thống nhất lại cách tư duy. Swift 6 chuyển dịch từ mô hình "luồng ai nấy chạy" sang mô hình Isolation Boundaries (Ranh giới cô lập). Dữ liệu muốn đi qua ranh giới này phải được "kiểm duyệt".

Sendable: Hộ chiếu thông hành

Sendable không phải là một Class hay Struct, nó là một protocol đánh dấu (marker protocol). Nó nói với compiler rằng: "Dữ liệu này an toàn để truyền qua lại giữa các isolation domain (các luồng thực thi) khác nhau."

  • Giá trị mặc định an toàn (có điều kiện): Struct, Enum, Tuple sẽ được compiler tự động coi là Sendable khi và chỉ khi tất cả stored properties (hoặc associated values) bên trong chúng cũng là Sendable. Các Primitive types (Int, String, Bool...) đương nhiên thỏa điều kiện này. Lưu ý: một struct là value type nhưng chứa một reference đến class không-Sendable thì không tự động là Sendable — bản copy của struct vẫn trỏ về cùng một object.

  • Mối nguy hiểm: Class (Reference types). Nếu Class của bạn chứa mutable state (biến var), nó KHÔNG THỂ là Sendable một cách tự nhiên.

  • Mẹo cho code dạng module/framework: Với các public type dùng chung giữa nhiều module, implicit conformance sẽ không được "xuất khẩu" ra ngoài module. Hãy khai báo tường minh struct MyModel: Sendable để phía sử dụng cũng nhận được sự bảo đảm này.

Actor: Pháo đài bảo vệ State

Actor giống như một Class, nhưng nó có một đặc quyền: nó đảm bảo tại một thời điểm chỉ có duy nhất một task được thực thi code truy cập vào state bên trong nó (mutual exclusion).

Một điểm hay bị hiểu nhầm: actor không sở hữu một thread riêng nào cả. Các lần gọi vào actor có thể được thực thi trên những thread khác nhau trong cooperative thread pool của Swift Concurrency — điều được đảm bảo là chúng không bao giờ chạy đồng thời. Vì vậy đừng hình dung actor như một serial DispatchQueue gắn chặt với một thread.

Truy cập vào actor từ bên ngoài hầu hết phải thông qua await, trừ hai ngoại lệ:

  • Các thuộc tính let bất biến có kiểu Sendable (từ Swift 6.0 / SE-0434, truy cập trực tiếp không cần await).

  • Các member được đánh dấu nonisolated (sẽ nói ở phần 3).

⚠️ Bẫy "reentrancy": Actor trong Swift là reentrant. Khi một hàm bên trong actor gặp await, actor được "giải phóng" tạm thời và có thể xử lý một task khác chen ngang. Hệ quả: state của actor có thể đã thay đổi trước và sau điểm await. Đừng giả định rằng biến bạn đọc trước await vẫn giữ nguyên giá trị sau đó — hãy kiểm tra lại (re-check) các điều kiện quan trọng sau mỗi điểm await.

Global Actors (@MainActor): Vùng đất của UI

Mọi thứ liên quan đến giao diện (UIKit/SwiftUI) bắt buộc phải chạy trên Main Thread. @MainActor là một Global Actor giúp bạn đảm bảo điều đó ngay từ cấp độ Compiler chứ không cần phải gọi DispatchQueue.main.async một cách thủ công và cầu nguyện nữa. (Đây cũng là actor đặc biệt duy nhất thực sự gắn với một thread cụ thể: Main Thread.)


2. Diệt Trừ Những Cảnh Báo "Ám Ảnh" Nhất

Dưới đây là 3 kịch bản gây ức chế nhất trong các dự án lớn và cách giải quyết "êm đẹp" nhất.

Kịch bản 1: Cảnh báo "Passing non-sendable parameter in a concurrent execution" với Closure / Completion Handler

Đây là lỗi kinh điển khi bạn kết hợp code async/await mới với các hàm sử dụng Callback/Completion Handler cũ.

// Code cũ gây lỗi trong Swift 6
func fetchUserProfile(completion: @escaping (UserProfile) -> Void) {
    Task {
        let profile = await networkService.downloadProfile()
        completion(profile) // ⚠️ Warning: Capture of 'completion' with non-sendable type
    }
}

Tại sao bị? Compiler không thể biết chắc cái completion closure kia sẽ được thực thi ở đâu và liệu nó có an toàn khi bị capture vào trong một Task bất đồng bộ hay không.

Cách giải quyết: Hãy gắn thẻ @Sendable cho closure và đảm bảo UserProfile cũng là Sendable.

// Giải pháp an toàn
struct UserProfile: Sendable { // Đảm bảo Model là Sendable
    let id: String
    let name: String
}

func fetchUserProfile(completion: @escaping @Sendable (UserProfile) -> Void) {
    Task {
        let profile = await networkService.downloadProfile()
        completion(profile) // ✅ An toàn
    }
}

Lưu ý: gắn @Sendable là một "lời hứa" lan truyền — mọi nơi truyền closure vào hàm này giờ cũng phải đảm bảo closure của họ là Sendable (không capture state không an toàn).


Kịch bản 2: Cơn ác mộng mang tên "Legacy Singleton"

Hầu như dự án lớn nào cũng có những class dạng UserManager.shared hoặc APIManager.shared chứa đầy biến var toàn cục.

// Code cũ gây hoang mang
class UserManager {
    // ⚠️ Warning tại dòng này: Static property 'shared' is not concurrency-safe
    // because non-'Sendable' type 'UserManager' may have shared mutable state
    static let shared = UserManager()
    var currentUser: UserProfile? // Chính biến var này khiến UserManager không thể là Sendable
}

Cách giải quyết:

  • Cách 1 (Lý tưởng nhất): Chuyển class đó thành một actor.

  • Cách 2 (Thực tế, không muốn sửa code gọi hàm ở 100 nơi khác): Ép nó chạy trên @MainActor nếu nó chủ yếu phục vụ UI, hoặc biến currentUser thành thread-safe bằng cách cô lập truy cập.

// Giải pháp nhanh gọn cho UI-bound Singleton
@MainActor
class UserManager {
    static let shared = UserManager()
    var currentUser: UserProfile? // ✅ An toàn vì toàn bộ class được bảo vệ bởi MainActor
}

Lưu ý: Nếu dùng @MainActor, những nơi gọi UserManager.shared.currentUser từ các background context sẽ bắt buộc phải thêm await.


Kịch bản 3: Delegation Pattern (Protocols) Bị Gãy

Mô hình Delegate rất phổ biến trong các dự án sử dụng VIPER hoặc MVVM kiểu cũ. Khi một Controller gọi Delegate ở một background thread, Swift 6 sẽ chặn lại ngay.

protocol MyViewModelDelegate: AnyObject {
    func didUpdateData()
}

class MyViewModel {
    weak var delegate: MyViewModelDelegate? // ⚠️ Warning nếu ViewModel xử lý bất đồng bộ
}

Cách giải quyết: Hầu hết các Delegate trong iOS đều dùng để cập nhật UI. Hãy đánh dấu Protocol đó là @MainActor.

@MainActor
protocol MyViewModelDelegate: AnyObject {
    func didUpdateData()
}

// Bây giờ compiler biết chắc delegate này chỉ được tương tác trên Main Thread.

3. Chiến Lược Refactor "Từng Bước Một" Cho Dự Án Lớn

Nếu bạn bật cấu hình Swift 6 Complete Concurrency lên và cố gắng sửa hết lỗi trong một lần commit, bạn đang tự sát. Hãy áp dụng chiến lược "tằm ăn lá dâu" sau đây:

Bước 1: Hạ nhiệt với @preconcurrency

Khi dự án của bạn phụ thuộc vào các thư viện thứ ba (Swinject, Alamofire phiên bản cũ, v.v.) chưa kịp cập nhật Swift 6, hãy dùng @preconcurrency import.

@preconcurrency import SomeLegacyThirdPartyLibrary

Hiệu ứng của nó tùy vào language mode bạn đang dùng:

  • Swift 5 mode + Complete checking: các warning liên quan đến type từ thư viện đó sẽ được tắt.

  • Swift 6 mode: các lỗi (error) liên quan sẽ được hạ xuống thành warning — vẫn build được, nhưng compiler không im lặng hoàn toàn. Đừng bất ngờ khi migrate hẳn sang Swift 6 mode mà vẫn thấy chúng xuất hiện dưới dạng cảnh báo.

Bước 2: Sử dụng @unchecked Sendable như giải pháp "bình cứu hỏa"

Có những class quá phức tạp, được bọc bảo vệ bằng NSLock hoặc DispatchQueue thủ công rất an toàn nhưng Compiler không đủ thông minh để hiểu. Đừng tốn thời gian viết lại toàn bộ class đó ngay lập tức. Hãy dùng @unchecked Sendable.

// Báo với Compiler: "Tôi thề là tôi tự quản lý thread-safe rồi, đừng quét tôi nữa!"
final class ThreadSafeCache: @unchecked Sendable {
    private let queue = DispatchQueue(label: "cache.queue")
    private var data: [String: Any] = [:]

    func set(_ value: Any, forKey key: String) {
        queue.async { self.data[key] = value }
    }

    func value(forKey key: String) -> Any? {
        queue.sync { data[key] } // Đọc đồng bộ qua cùng một queue → không data-race
    }
}

⚠️ Cảnh báo từ chuyên gia: Hãy dùng @unchecked Sendable một cách cực kỳ tiết kiệm. Nếu bạn nói dối Compiler mà code bên trong không thực sự thread-safe, bạn sẽ phải trả giá bằng các lỗi crash mập mờ lúc runtime — đúng thứ mà Swift 6 sinh ra để tiêu diệt.

Bước 3: Tận dụng nonisolated

Khi bạn chuyển một Class thành Actor hoặc @MainActor, đôi khi có những hàm thuần túy (Pure functions) hoặc các biến chỉ đọc (let) không cần phải đồng bộ. Hãy dùng nonisolated để các luồng khác gọi nó mà không cần await.

@MainActor
final class DetailViewModel {
    let itemId: String // let + Sendable → tự động truy cập được từ mọi nơi không cần await

    init(itemId: String) {
        self.itemId = itemId
    }

    // Hàm này không chạm vào UI State, gọi từ đâu cũng được
    nonisolated func logAnalytics() {
        print("Viewing item: \(itemId)")
    }
}

Ghi chú 1: Ví dụ trên dùng ViewModel thay vì ViewController vì UIViewController (và toàn bộ UIKit view/controller) đã được Apple đánh dấu @MainActor sẵn trong SDK — subclass tự động kế thừa, bạn không cần (và không nên) tự thêm lại.

Ghi chú 2: Từ Swift 6.0 (SE-0434), các thuộc tính let có kiểu Sendable như itemId ở trên vốn đã truy cập được từ ngoài mà không cần awaitnonisolated chủ yếu cần cho các hàm hoặc computed property mà bạn muốn "mở cửa" ra ngoài isolation.


4. Bảng Tra Cứu Nhanh

Tình huống code

Giải pháp nhanh nhất

Mức độ rủi ro

Model (Struct) chứa một Model khác chưa Sendable

Thêm : Sendable cho cả 2 Struct

Thấp (Rất khuyến khích)

Singleton bị báo lỗi Concurrency

Thêm @MainActor vào đầu Class (nếu UI-bound) hoặc chuyển thành actor

Thấp

Thư viện Pod/SPM cũ bị vỡ cảnh báo

Thay bằng @preconcurrency import

Trung bình

Class kế thừa từ Framework cũ (Objective-C)

Dùng @unchecked Sendable và tự lock

Cao

Hàm pure/let trong actor bị bắt await không cần thiết

Đánh dấu nonisolated (hoặc để nguyên nếu là let Sendable)

Thấp


Lời Kết

Bước lên kỷ nguyên Swift 6 Strict Concurrency giống như việc bạn đi khám sức khỏe tổng quát vậy. Những cảnh báo đó không làm code của bạn tệ đi, chúng chỉ vạch trần những căn bệnh tiềm ẩn vốn dĩ đã nằm sẵn trong dự án từ lâu.

Lời khuyên của tôi dành cho các dự án lớn: Hãy đi từng bước. Hãy bật Strict Concurrency ở mức MinimalTargeted trước, giải quyết dần các module lõi (Core/Network), sau đó mới tiến lên Complete, và cuối cùng là chuyển hẳn sang Swift 6 language mode theo từng target/module một. Chúc các bạn "sống sót" thành công và tận hưởng một hệ thống iOS mượt mà, không còn bóng dáng của lỗi crash do data-race!