Làm cho một kiểu Sendable mà không với tới @unchecked
Khi kiểm tra concurrency nghiêm ngặt tràn vào một codebase, cách nhanh nhất để dẹp các lỗi là
@unchecked Sendable. Tôi biết vì tôi đã làm thế với khoảng mười lăm kiểu trong một buổi chiều, và
mỗi cái đều là một lời nói dối mà tôi sẽ phải quay lại trả.
@unchecked nghĩa là “tôi cam đoan cái này an toàn luồng và tôi chịu trách nhiệm về nó”. Nếu bạn
không nói được vì sao nó an toàn luồng trong một câu thì bạn chưa làm cho thứ gì an toàn cả — bạn
vừa biến một lỗi biên dịch thành một cuộc đua dữ liệu.
Đây là những gì lẽ ra tôi nên làm.
Sendable thật ra đòi hỏi gì
Một kiểu Sendable là kiểu vượt qua được ranh giới cô lập một cách an toàn. Trình biên dịch tự kiểm
chứng được điều này trong ba trường hợp:
- Một kiểu giá trị mà mọi thuộc tính lưu trữ đều
Sendable. Struct và enum được sinh phần tuân thủ tự động. - Một actor. Actor là
Sendabletheo định nghĩa — trạng thái của chúng đã được bảo vệ. - Một class final bất biến mà mọi thuộc tính lưu trữ đều là
letvà đềuSendable.
Cái thứ ba là câu trả lời bị dùng ít nhất:
final class Configuration: Sendable {
let apiURL: URL
let timeout: TimeInterval
let retries: Int
}
Không @unchecked, không khóa, và trình biên dịch kiểm chứng nó. Một số lượng đáng ngạc nhiên các
kiểu tôi đã đánh dấu @unchecked vốn đã bất biến sẵn — tôi chỉ chưa khai báo chúng là final, hoặc
có một cái var mà thật ra chẳng bao giờ bị sửa sau khi khởi tạo.
Thứ đầu tiên nên thử là làm cho kiểu đó bất biến. Nó miễn phí, trình biên dịch kiểm tra hộ, và thường thì nó cũng cải thiện luôn thiết kế.
Lựa chọn hai: biến nó thành struct
Nếu một class tồn tại chỉ để được truyền đi truyền lại và không mang danh tính gì, có lẽ nó muốn làm một struct:
// trước: class, cần @unchecked
final class Coordinates {
var latitude: Double
var longitude: Double
}
// sau: struct, Sendable miễn phí
struct Coordinates: Sendable {
var latitude: Double
var longitude: Double
}
Ngữ nghĩa giá trị nghĩa là không có chuyện dùng chung, nên chẳng có gì để mà đua. Đây vẫn là lập luận mà kiểu giá trị luôn có, chỉ được bộ kiểm tra concurrency phát biểu lại.
Lựa chọn ba: biến nó thành actor
Nếu nó thật sự có trạng thái khả biến mà nhiều nơi cùng động tới, thì actor sinh ra để làm việc đó:
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 }
}
Cái giá là mọi lần truy cập đều thành await, thứ lan ra ngoài qua các bên gọi. Cơn lan đó là cái
giá thành thật của trạng thái khả biến dùng chung, và nó đáng trả khi trạng thái thật sự được dùng
chung — nhưng đó cũng là lý do đây không nên là thứ đầu tiên bạn với tới.
Cảnh báo
Đừng biến một kiểu thành actor chỉ để dẹp một cảnh báo. Một actor chỉ bao giờ bị chạm tới từ một
chỗ thì gánh toàn bộ cái giá await mà chẳng được lợi ích gì, và nó còn mang vào tính tái nhập
— trạng thái có thể đổi qua mỗi await, một lớp lỗi mới mà trước đó bạn không có.
Lựa chọn bốn: cô lập nó vào main actor
Với bất cứ thứ gì chỉ bao giờ chạy trên luồng chính — view model, trạng thái giao diện, phần lớn một
codebase UIKit — @MainActor vừa là chú thích đúng vừa là một phần tuân thủ Sendable miễn phí:
@MainActor
final class ProfileViewModel {
var name = ""
var isLoading = false
}
Giờ trình biên dịch bảo đảm rằng thứ này chỉ bị chạm tới từ main actor, đúng cái điều mà bạn vốn đã ngầm trông cậy vào. Cách này giải quyết được nhiều lỗi của tôi hơn cả ba lựa chọn kia cộng lại, vì một phần lớn “trạng thái khả biến dùng chung” trong một ứng dụng thật ra là trạng thái chỉ dùng ở luồng chính mà chưa bao giờ được ghi ra như vậy.
Khi nào @unchecked là chính đáng
Có đúng một cách dùng lương thiện: một kiểu mà tính an toàn luồng của nó là thật nhưng được diễn đạt theo cách trình biên dịch không nhìn thấy được. Thường thì nghĩa là có một cái khóa.
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int {
lock.withLock { _value }
}
func increment() {
lock.withLock { _value += 1 }
}
}
Đó là một @unchecked đúng đắn, và những thứ làm nên tính đúng đắn ấy đáng được gọi tên: mọi lần
truy cập _value đều đi qua cái khóa, _value là private nên không gì đi vòng qua được, và class
là final nên không lớp con nào thêm được trạng thái không được bảo vệ.
Nếu bạn viết @unchecked, hãy viết kèm một dòng chú thích nói bất biến nào đang giữ nó lại với nhau.
Người tiếp theo — kể cả chính bạn — không dựng lại được điều đó từ code, vì toàn bộ vấn đề nằm ở chỗ
trình biên dịch cũng đã không làm được.
Thứ tự tôi dùng bây giờ
- Nó bất biến được không? → thuộc tính
let,final class Foo: Sendable. - Nó có cần danh tính không? Nếu không → biến thành
struct. - Nó chỉ chạy ở luồng chính? →
@MainActor. - Nó thật sự là trạng thái khả biến dùng chung? →
actor. - Nó là trạng thái khả biến dùng chung mà không thể làm actor, vì một lý do có thật? →
@uncheckedkèm một cái khóa và một dòng chú thích.
Đi qua các bước đó theo thứ tự đã đưa tôi từ mười lăm chỗ tuân thủ @unchecked xuống còn hai, và cả
hai kẻ sống sót đều là lớp bọc dựa trên khóa quanh các thư viện C. Đó đúng là nhóm đối tượng mà
@unchecked được thiết kế cho.