Trang chủ

`lazy var` không an toàn luồng, còn `static let` thì có

Cả hai thứ này đều hoãn công việc lại tới lần dùng đầu tiên:

class Service {
    lazy var client = HTTPClient()          // KHÔNG an toàn luồng
}

enum Constants {
    static let client = HTTPClient()        // an toàn luồng
}

Chúng trông như cùng một tính năng ở hai phạm vi khác nhau. Chúng được cài đặt hoàn toàn khác nhau, và chỉ một trong hai sống sót qua truy cập đồng thời.

lazy var biên dịch ra cái gì

Đại khái thế này:

private var _client: HTTPClient?

var client: HTTPClient {
    get {
        if _client == nil {
            _client = HTTPClient()
        }
        return _client!
    }
}

Không có cái khóa nào. Hai luồng cùng đọc client một lúc có thể cùng thấy nil, cùng dựng một HTTPClient, và cùng ghi vào _client. Trường hợp tốt nhất là tạo ra hai đối tượng và rò rỉ một cái; trường hợp tệ nhất là một lần ghi rách vào cái optional và một cú sập.

Đó cũng là lý do lazy var không thể là let — cái getter sửa trạng thái, và đó cũng là lý do truy cập một lazy var trên một struct đòi hỏi var và không dùng được từ một ngữ cảnh không mutating.

static let biên dịch ra cái gì

Các thuộc tính toàn cục và static trong Swift cũng lười, nhưng chúng dùng swift_once — đúng cơ chế của dispatch_once. Bộ khởi tạo được bảo đảm chạy đúng một lần, và mọi luồng khác chặn lại cho tới khi nó xong.

Đó là một lời bảo đảm thật từ runtime, không phải một đặc tính tình cờ. static let là cách đúng để viết một singleton trong Swift, và nó không cần đoạn khuôn mẫu dispatch_once nào cũng chẳng cần khóa.

Cảnh báo

static var cũng lười và cũng được khởi tạo một lần theo cách đó, nhưng chẳng có gì bảo vệ những lần ghi về sau. Một biến toàn cục khả biến là một cuộc đua dữ liệu theo định nghĩa, và nó đúng là thứ mà kiểm tra concurrency nghiêm ngặt gắn cờ đầu tiên. static let thì an toàn; static var cần một actor, một cái khóa, hoặc @MainActor.

Khi nào lazy var là ổn

Nó không phải một tính năng hỏng — nó là một tính năng đơn luồng. Nó đúng khi đối tượng sở hữu bị giam trong một luồng, và điều đó bao phủ rất nhiều code giao diện:

@MainActor
final class ProfileViewController: UIViewController {
    lazy var formatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter
    }()
}

@MainActor bảo đảm truy cập đơn luồng, nên phần khởi tạo lười không thể chạy đua. Cái chú thích đó đang làm việc thật ở đây, và đó là lý do bộ kiểm tra concurrency chấp nhận đoạn này.

Cách dùng chính đáng còn lại là phần thiết lập thật sự tốn kém mà nhiều thực thể sẽ chẳng bao giờ cần đến — một bộ phân tích cú pháp nặng, một bảng tra cứu lớn, một kết nối mà phần lớn các đường code không động tới.

Ba lựa chọn thay thế

Nếu nó dùng chung và bất biến — static let.

enum Formatters {
    static let medium: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter
    }()
}

An toàn luồng, khởi tạo một lần, không cần chú thích nào.

Nếu kiểu đó chỉ chạy ở luồng chính — @MainActor và giữ nguyên lazy var.

Nếu nó là trạng thái khả biến dùng chung — một actor.

actor ImageCache {
    private var storage: [URL: Image] = [:]
}

Thứ khiến chuyện này vỡ ra với tôi

Tôi đã cho rằng lazy là một tối ưu bộ nhớ. Không phải — nó là một cách kiểm soát thời điểm. Nó nói “hãy chạy cái này ở lần đầu có ai hỏi tới”, và lý do nó tồn tại là bộ khởi tạo cần một thứ chưa có sẵn lúc init, hoặc đủ tốn kém để không phải ai cũng nên trả giá.

Một khi tôi đọc nó theo cách đó thì câu hỏi về an toàn luồng trở nên hiển nhiên: một tính năng nói về khi nào code chạy thì chẳng nói gì về bao nhiêu luồng chạy nó, và việc tôi mong nó an toàn là do tôi lẫn lộn hai bài toán khác nhau.

Đề xuất Swift Evolution cho lazy nói rõ điều này. Giới hạn đơn luồng là hành vi đã được ghi trong tài liệu chứ không phải một sơ suất — làm cho nó an toàn luồng sẽ đồng nghĩa với một cái khóa ở mọi lần truy cập mọi thuộc tính lười, và ngôn ngữ chọn cách bắt bạn nói ra điều đó thay vì vậy.