Trang chủ

Task.cancel() gần như chẳng làm gì

Tôi có một màn hình tìm kiếm bắn một request cho mỗi phím gõ, và tôi đang hủy task trước đó rồi mới bắt đầu task tiếp theo. Các request vẫn chồng đống, kết quả vẫn về sai thứ tự, và màn hình vẫn nhấp nháy giữa kết quả cũ và mới.

Task.cancel() vẫn được gọi. Nó chỉ không làm điều tôi tưởng.

Hủy là một lá cờ

cancel() đặt một giá trị boolean lên task. Đó là toàn bộ thao tác. Nó không dừng việc thực thi, không ném lỗi, không tháo ngăn xếp. Một task không bao giờ kiểm tra lá cờ đó sẽ chạy đến khi xong y hệt như thể bạn chưa từng hủy nó.

let task = Task {
    for item in hugeCollection {
        expensiveWork(item)          // không kiểm tra — chạy hết
    }
}
task.cancel()                        // đặt một lá cờ chẳng ai đọc

Điều này là cố ý. Giết một luồng giữa chừng thao tác là cách bạn làm hỏng trạng thái, và những người thiết kế Swift đã chọn kiểu hủy hợp tác thay vì thế. Cái giá là phần hợp tác là việc của bạn.

Hai cách kiểm tra

Task.checkCancellation() ném CancellationError nếu đã bị hủy:

for item in hugeCollection {
    try Task.checkCancellation()
    expensiveWork(item)
}

Task.isCancelled trả về một Bool và để bạn tự quyết:

for item in hugeCollection {
    if Task.isCancelled {
        await saveProgress()          // dọn dẹp, rồi dừng
        return
    }
    expensiveWork(item)
}

Hãy dùng checkCancellation() khi việc dừng chỉ là một cú throw đơn giản. Dùng isCancelled khi bạn cần hoàn tất một việc gì đó — xả một bộ đệm, lưu lại phần đã làm, trả về những gì đang có.

Thứ gì vốn đã hợp tác

Không phải thứ gì cũng cần kiểm tra bằng tay. Phần lớn các API async của thư viện chuẩn vốn đã nhận biết việc hủy:

  • Task.sleep ném lỗi ngay khi bị hủy
  • Các phương thức async của URLSession ném URLError.cancelled
  • Phần lớn các lượt duyệt AsyncSequence sẽ dừng
  • withTaskGroup hủy các con khi cả nhóm bị hủy

Nên một task chủ yếu đang chờ những thứ này sẽ tự dừng khá nhanh. Vấn đề nằm ở đoạn code giữa các lần await — và ở mọi vòng lặp chặt làm việc đồng bộ, nơi chẳng có gì kiểm tra trừ khi bạn tự làm.

Cảnh báo

Việc Task.sleep ném lỗi khi bị hủy rất dễ bị làm hỏng bởi try?. Viết try? await Task.sleep(for: .seconds(1)) bên trong một vòng lặp thăm dò sẽ nuốt mất lỗi hủy, và vòng lặp cứ thế chạy mãi. Hãy để nó lan ra, hoặc kiểm tra isCancelled một cách tường minh ngay sau đó.

Concurrency có cấu trúc hủy giúp bạn

Lý do .task trong SwiftUI chạy tốt đến vậy là nó buộc vòng đời của một task vào danh tính của view:

.task(id: query) {
    results = await search(query)
}

Khi query đổi, task trước bị hủy và một task mới bắt đầu. Khi view biến mất, task bị hủy. Không AnyCancellable, không sổ sách thủ công, và nó đúng ngay từ mặc định.

Điều tương tự áp dụng bên trong async let và các task group — hủy cha thì hủy các con, xuống tận đáy.

Task { } tạo bằng tay là ngoại lệ. Nó không có cấu trúc: nó không thừa hưởng việc hủy của một cha nào, và chẳng gì hủy nó khi phạm vi bao quanh kết thúc. Nếu bạn tạo một cái, bạn sở hữu vòng đời của nó.

final class SearchModel {
    private var searchTask: Task<Void, Never>?

    func search(_ query: String) {
        searchTask?.cancel()                 // hủy cái trước
        searchTask = Task {
            guard !Task.isCancelled else { return }
            let results = await api.search(query)
            guard !Task.isCancelled else { return }   // kiểm tra lại sau lần await
            self.results = results
        }
    }
}

Hai lần kiểm tra, không phải một. Lần đầu dừng công việc đã bị hủy trước cả khi nó bắt đầu; lần sau ngăn một kết quả cũ được ghi đè lên sau khi một lượt tìm kiếm mới hơn đã bắt đầu. Chính lần kiểm tra thứ hai là thứ màn hình tìm kiếm của tôi đang thiếu.

Hủy không phải một lỗi

Một task bị hủy không phải một thất bại, và đối xử với nó như thất bại sẽ sinh ra những thông báo lỗi vô cớ:

do {
    try await load()
} catch is CancellationError {
    return                                  // đúng như dự kiến: đừng nói gì
} catch {
    show(error)                             // chỉ những lỗi thật
}

Không có mệnh đề đầu tiên đó, việc rời khỏi một màn hình giữa lúc đang nạp sẽ hiện cho người dùng một hộp thoại lỗi về một thao tác mà chính họ đã hủy. Tôi cũng đã phát hành cái đó rồi.

Lưu ý rằng URLSession ném URLError.cancelled chứ không phải CancellationError, nên một tầng mạng cần xử lý cả hai — hoặc chuẩn hóa cái này thành cái kia ngay tại ranh giới của nó.

Danh sách kiểm tra tôi dùng

  • Mọi vòng lặp làm việc thật đều có try Task.checkCancellation() ở đầu.
  • Sau mỗi lần await trong một task mà kết quả của nó sửa trạng thái dùng chung, hãy kiểm tra lại isCancelled trước khi ghi.
  • CancellationError được bắt và bỏ qua ở ranh giới giao diện, tách riêng khỏi các lỗi thật.
  • Ưu tiên .task(id:) hơn Task { } ở bất cứ đâu công việc thuộc về một view.
  • Nếu bạn tạo một Task bằng tay, hãy lưu nó lại, rồi hủy nó trong deinit hoặc khi thay nó bằng cái mới.

Nguyên tắc chung: hủy là một lời đề nghị, không phải một mệnh lệnh. Hệ thống hỏi; code của bạn quyết định có dừng không và dừng lúc nào. Không có phần nào trong đó là tự động, và kiểu hỏng thì im lặng — công việc đơn giản là chạy tiếp, thứ trông y hệt như công việc đang chậm.