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.sleepném lỗi ngay khi bị hủy- Các phương thức async của
URLSessionnémURLError.cancelled - Phần lớn các lượt duyệt
AsyncSequencesẽ dừng withTaskGrouphủ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
awaittrong 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ạiisCancelledtrướ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ơnTask { }ở bất cứ đâu công việc thuộc về một view. - Nếu bạn tạo một
Taskbằng tay, hãy lưu nó lại, rồi hủy nó trongdeinithoặ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.