Trang chủ

Con bug tái nhập actor tôi đã phát hành

Tôi chuyển một cái cache thành actor, trình biên dịch im lặng, và tôi cho rằng các vấn đề concurrency đã xong. Hai tuần sau, chúng tôi đang gửi cùng một request mạng bốn lần cho cùng một URL.

Actor ngăn được đua dữ liệu. Chúng không ngăn được tính tái nhập, và tái nhập là con bug mà tôi đã không biết để mà đi tìm.

Đoạn code

actor ImageCache {
    private var cache: [URL: Image] = [:]

    func image(for url: URL) async throws -> Image {
        if let cached = cache[url] {
            return cached
        }

        let image = try await downloader.download(url)   // điểm treo
        cache[url] = image
        return image
    }
}

Đoạn này trông kín kẽ. Trạng thái được actor bảo vệ, phép kiểm tra diễn ra trước lượt tải, kết quả được lưu sau đó. Không có đua dữ liệu nào và trình biên dịch đồng ý.

Thứ thật sự xảy ra

Bốn view xuất hiện cùng lúc, tất cả cùng xin một tấm ảnh.

  1. Lời gọi A kiểm tra cache[url] — rỗng. Bắt đầu tải, treo lại ở chỗ await.
  2. Trong lúc A đang treo, actor rảnh. Lời gọi B đi vào, kiểm tra cache[url] — vẫn rỗng, vì A chưa lưu gì cả. Bắt đầu lượt tải của riêng nó. Treo lại.
  3. Tương tự cho C và D.
  4. Cả bốn lượt tải hoàn tất và mỗi cái ghi vào cache[url].

Bốn request mạng, bốn tấm ảnh được giải mã, ba trong số đó bị vứt đi. Trên một màn hình có lưới ảnh thu nhỏ, chuyện này nhân lên rất tệ.

Sự thật then chốt: một actor chỉ bảo đảm truy cập độc quyền giữa các điểm treo. Ở mỗi await, actor được nhả ra và các lời gọi khác vào được. Hàm của bạn tiếp tục chạy sau đó trong một thế giới mà mọi thứ nó đã kiểm tra trước đó đều có thể đã đổi.

Cảnh báo

Mô hình tư duy khiến người ta lầm — kể cả tôi — là “một actor là một cái khóa”. Không phải. Một cái khóa giữ xuyên qua một await sẽ chặn tất cả; một actor thì cố tình không, vì chặn đúng là thứ mà actor sinh ra để tránh. Cái giá của hành vi không chặn ấy là vùng tới hạn của bạn chỉ gồm đoạn code nằm giữa hai điểm treo.

Cách chữa: lưu cái task, đừng lưu kết quả

Cái nhìn cốt lõi là cache cần ghi lại “một lượt tải đang bay”, chứ không chỉ “một lượt tải đã xong”. Lưu cái Task làm được cả hai:

actor ImageCache {
    private enum Entry {
        case inFlight(Task<Image, Error>)
        case ready(Image)
    }

    private var cache: [URL: Entry] = [:]

    func image(for url: URL) async throws -> Image {
        if let entry = cache[url] {
            switch entry {
            case .ready(let image):
                return image
            case .inFlight(let task):
                return try await task.value       // nhập vào lượt tải đang có
            }
        }

        let task = Task {
            try await downloader.download(url)
        }
        cache[url] = .inFlight(task)              // lưu TRƯỚC mọi lần await

        do {
            let image = try await task.value
            cache[url] = .ready(image)
            return image
        } catch {
            cache[url] = nil                      // để lời gọi sau thử lại
            throw error
        }
    }
}

Dòng quan trọng là cache[url] = .inFlight(task), đặt trước lần await đầu tiên. Giữa lúc vào hàm và phép gán đó không có điểm treo nào, nên không lời gọi nào khác đan xen vào được. Các bên gọi B, C và D tìm thấy cái task đang bay rồi cùng chờ đúng cái đó.

Một request. Ba bên gọi dùng chung.

Quy tắc tôi dùng bây giờ

Hãy kiểm tra lại các giả định của bạn sau mỗi await. Bất cứ thứ gì bạn đọc trước một điểm treo đều có thể đã cũ khi bạn chạy tiếp.

Cụ thể là:

func update(_ id: Item.ID) async {
    guard let item = items[id] else { return }
    let result = await process(item)

    // SAI: `item` có thể đã bị xóa hoặc thay thế trong lúc ta đang treo
    items[id]!.result = result

    // ĐÚNG: kiểm tra lại
    guard items[id] != nil else { return }
    items[id]?.result = result
}

Và tổng quát hơn: hãy sửa trạng thái trước khi treo, đừng sửa sau, ở bất cứ đâu thứ tự cho phép. Cái entry đang-bay ở trên chính là nguyên tắc đó được áp dụng.

Cần tìm gì trong code có sẵn

Ba hình dạng đáng rà lại trong mọi actor:

  1. Kiểm tra rồi hành động, cách nhau một await. if cache[x] == nil { … await … ; cache[x] = y }. Kinh điển, và là cái tôi đã phát hành.
  2. Một bộ đếm hay một cờ được đặt trước một await rồi đọc sau đó. isLoading = true; await load(); isLoading = false — hai lời gọi chồng nhau và cái cờ sai.
  3. Một chỉ số hay một tham chiếu được giữ lại trước một await. Một chỉ số mảng lấy trước khi treo có thể trỏ vào một phần tử khác, hoặc trỏ ra ngoài mảng, khi bạn chạy tiếp.

Actor vẫn cho bạn thứ gì

Tôi không muốn bài này đọc thành “actor vô dụng”. Chúng dẹp sạch cả một nhóm lỗi đua dữ liệu ở tầng thấp — những lần đọc rách, những dictionary hỏng, những cú sập vẫn xảy ra khi hai hàng đợi cùng chạm vào một mảng. Điều đó là thật và được trình biên dịch thi hành.

Thứ chúng không làm là khiến logic của bạn trở nên nguyên tử. Một dãy thao tác buộc phải xảy ra cùng nhau thì phải được viết thành một mạch không đứt đoạn giữa các điểm treo, và trình biên dịch sẽ không nói cho bạn biết khi nào thì không phải vậy.

Sự phân biệt đó là toàn bộ bài học, và tôi đã trả hai tuần cùng gấp bốn lần hóa đơn băng thông để học nó.