Trang chủ

Bật kiểm tra concurrency nghiêm ngặt trên một ứng dụng bốn năm tuổi

Chuyển một ứng dụng có sẵn sang kiểm tra concurrency nghiêm ngặt sinh ra 812 cảnh báo. Bản năng đầu tiên của tôi là tắt nó đi, và bản năng thứ hai là rắc @unchecked Sendable khắp nơi, thứ cũng y hệt vậy nhưng kèm thêm mấy bước.

Thứ thật sự hiệu quả là làm theo một thứ tự cụ thể, trong khoảng ba tuần, và chấp nhận rằng phần lớn các cảnh báo đang nói cho tôi một điều đúng.

Hãy bật dần dần

Thiết lập này có nhiều mức, và chúng tồn tại để bạn không phải đối mặt với tất cả cùng lúc:

// Package.swift
swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency")
]

Trong Xcode, Strict Concurrency Checking có ba giá trị:

  • Minimal — chỉ những gì được đánh dấu tường minh. Đây đại khái là hành vi cũ.
  • Targeted — kiểm tra đoạn code vốn đã tham gia vào concurrency. Điểm khởi đầu đúng.
  • Complete — kiểm tra tất cả. Đích đến.

Hãy bắt đầu ở Targeted, đưa về không, rồi mới chuyển sang Complete. Nhảy thẳng vào Complete trên một codebase lớn sinh ra một danh sách cảnh báo mà chẳng ai hành động nổi.

Làm theo từng module

Nếu ứng dụng đã chia module, hãy bật nó cho từng module một, bắt đầu từ đáy đồ thị phụ thuộc. Một package Models không có phụ thuộc thì là một trăm bản sửa nhỏ và không có câu hỏi kiến trúc nào; đưa nó về không nghĩa là module ngay trên nó bắt đầu từ một nền sạch thay vì thừa hưởng cảnh báo.

Làm theo thứ tự ngược lại — cái vỏ ứng dụng trước — nghĩa là mọi cảnh báo đều rối vào những kiểu bạn chưa sửa, và không tài nào biết cái nào là thật.

Hai thay đổi đã dẹp phần lớn cảnh báo

@MainActor trên các view model và các kiểu thuộc giao diện. Khoảng 400 trong số 812 cảnh báo của tôi là trạng thái khả biến vốn chỉ bao giờ bị chạm tới trên luồng chính mà chưa từng được đánh dấu như vậy.

@MainActor
final class ProfileViewModel {
    var name = ""
    var isLoading = false
}

Đúng một chú thích đó dẹp các cảnh báo và biến một quy tắc ngầm thành một quy tắc được thi hành. Nó cũng lan ra một cách hữu ích — gọi một phương thức @MainActor từ một ngữ cảnh không cô lập sẽ thành lỗi, đúng cái lỗi bạn muốn được biết.

Sendable trên các kiểu giá trị. Phần lớn số còn lại là các struct và enum vốn đã an toàn luồng nhờ cấu tạo và chỉ cần nói ra điều đó. Nhiều cái thậm chí không cần chú thích — một struct mà mọi thuộc tính đều Sendable sẽ được sinh phần tuân thủ tự động, nên cách chữa thường là đổi một thuộc tính thành let hoặc đổi một trường kiểu class thành struct.

Giữa hai thứ đó, khoảng 700 trong số 812 cảnh báo biến mất.

Cảnh báo

Hãy cưỡng lại @unchecked Sendable trong lúc làm việc này. Nó dẹp cảnh báo mà không làm cho thứ gì an toàn, và vì nó biên dịch sạch sẽ nên bạn sẽ chẳng bao giờ quay lại. Tôi đã đánh dấu mười lăm kiểu là @unchecked ngay buổi chiều đầu tiên rồi phải đi rà lại từng cái sau đó — làm cho tử tế ngay từ đầu còn tốn ít thời gian hơn cuộc rà soát ấy.

Những cái thật sự khó

Một trăm cái còn lại là vấn đề thật, và đáng bỏ thời gian.

Trạng thái khả biến toàn cục. static var shared trên một singleton là một cuộc đua dữ liệu vốn vẫn luôn ở đó. Cách chữa là một let nếu nó bất biến, @MainActor nếu nó gần với giao diện, hoặc một actor nếu nó thật sự là trạng thái khả biến dùng chung.

Các callback delegate đến từ hàng đợi tùy ý. Một delegate URLSession cũ hay một callback từ thư viện C đến trên một luồng mà trình biên dịch không lập luận được. Cách chữa là nhảy luồng tường minh ngay tại ranh giới:

nonisolated func didReceive(_ data: Data) {
    Task { @MainActor in
        self.buffer.append(data)
    }
}

Completion handler bắt lấy trạng thái khả biến. Đây phần lớn là những cuộc đua có thật vẫn đang chạy được nhờ may mắn, và vài cái trong số đó giải thích những báo cáo sự cố mà tôi chưa bao giờ tái hiện nổi.

Thư viện bên thứ ba không có chú thích Sendable. Nhóm gây bực nhất, vì cách chữa không nằm trong tay bạn. @preconcurrency import SomeLibrary dập các cảnh báo phát sinh từ module đó, và đó là công cụ đúng — nó thu hẹp đúng vào vấn đề thật thay vì tắt việc kiểm tra một cách chung chung.

Có đáng không

Có, vì một lý do cụ thể: ba trong số các cảnh báo là những con bug tôi đã biết và không tái hiện được. Hai cú sập lúc có lúc không và một báo cáo “thỉnh thoảng danh sách hiện dữ liệu cũ”, tất cả đều được giải thích bởi những cuộc đua mà trình biên dịch chỉ thẳng vào.

Phần giá trị còn lại mang tính phòng ngừa và khó đo hơn. Nhưng codebase giờ là một nơi mà thêm một thao tác chạy nền không thể lặng lẽ gieo một cuộc đua dữ liệu, và điều đó thay đổi mức độ cẩn trọng mà mọi thay đổi trong tương lai cần đến.

Điều tôi sẽ làm khác đi

Bắt đầu ở Targeted, đừng bắt đầu ở Complete. Tôi khởi động ở Complete, nhận 812 cảnh báo, và mất hai ngày vì choáng ngợp trước khi khởi động lại ở Targeted.

Sửa các chú thích @MainActor trước, tất cả chúng, trước mọi thứ khác. Đó là nhóm lớn nhất theo một khoảng cách xa, nó máy móc, và nó khiến các cảnh báo còn lại dễ đọc hơn hẳn vì tầng giao diện thôi tạo ra tiếng ồn.

Đừng gộp các commit. Một module một commit, hoặc một nhóm vấn đề một commit. Một commit “chuyển đổi concurrency” đụng 400 file thì không ai review nổi và không thể bisect khi có thứ gì đó hỏng.