Scheduler và luồng thực thi
Công việc diễn ra ở đâu, giá trị đến ở đâu, và vì sao đó là hai câu hỏi khác nhau
Một Scheduler trong Combine trả lời hai câu hỏi: khi nào một thứ chạy, và ở đâu. Protocol
này nhỏ, và hiểu nó thì dẹp được phần lớn sự lẫn lộn về luồng trong Combine.
protocol Scheduler {
associatedtype SchedulerTimeType: Strideable
var now: SchedulerTimeType { get }
var minimumTolerance: SchedulerTimeType.Stride { get }
func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void)
func schedule(after date: SchedulerTimeType, tolerance: …, _ action: @escaping () -> Void)
}
Hãy để ý now. Một scheduler không chỉ là bộ thực thi — nó còn là một cái đồng hồ, và đó là thứ
khiến các toán tử dựa trên thời gian của Combine kiểm thử được. debounce không đọc giờ hệ thống; nó
hỏi scheduler của mình. Thay scheduler bằng một cái mà bạn điều khiển được đồng hồ, và một bài test
debounce ba giây chạy xong tức thì. Đó là toàn bộ mẹo của chương kiểm thử.
Publisher chạy ở nơi nó được đăng ký
Mặc định, công việc của một toán tử diễn ra trên bất cứ luồng nào đã giao giá trị tới nó. Không có cú
nhảy luồng ngầm nào ở bất cứ đâu trong Combine — một closure map chạy trên luồng mà nguồn trên đã
phát ra từ đó, và luồng ấy có thể là hàng đợi delegate của URLSession, run loop của một Timer,
hoặc bất cứ thứ gì đã gọi send(_:) lên một subject.
Điều này có hai hệ quả đáng khắc vào đầu:
Một pipeline không có toán tử scheduler nào sẽ giao giá trị trên một luồng gần như tùy ý. Với
URLSession.dataTaskPublisher, đó là một hàng đợi nền, và đó là lý do cập nhật giao diện thẳng từ
sink của nó sinh ra những cảnh báo tím của bộ kiểm tra luồng chính.
Đặt receive(on:) ở đâu quyết định thứ gì chạy ở đâu. Mọi thứ bên dưới nó chuyển đi; mọi thứ
bên trên nó thì không.
api.fetchItems() // hàng đợi của URLSession
.map { $0.filter(\.isVisible) } // vẫn hàng đợi của URLSession — tốt
.receive(on: DispatchQueue.main) // nhảy luồng
.sink { self.items = $0 } // main — tốt
Chuyển cái receive(on:) đó lên đầu thì việc lọc diễn ra trên hàng đợi chính mà chẳng vì lý do gì.
Giam luồng trong thực tế
Combine không cho bạn sự an toàn luồng nào miễn phí. Một publisher có thể giao giá trị trên bất kỳ luồng nào, một subject có thể bị gửi vào từ bất kỳ luồng nào, và cả hai đều không được đồng bộ hóa.
Ba quy tắc giữ cho chuyện này trong tầm kiểm soát:
- Một
receive(on: DispatchQueue.main)đặt ngay trước sink, trong mọi pipeline kết thúc ở giao diện. Không sớm hơn. - Subject chỉ được nạp từ một hàng đợi duy nhất. Nếu có nhiều hơn một chỗ gọi
send(_:), hãy đặt mộtreceive(on:)giữa subject và mọi thứ phía dưới rồi giữ kỷ luật ở phía gửi, hoặc chuyển trạng thái đó vào một actor. @Publishedphải được sửa trên luồng chính khi có một view SwiftUI quan sát nó. SwiftUI không nhảy luồng hộ bạn, và kiểu hỏng là một cú sập trong lúc cập nhật view chứ không phải một lỗi luồng rõ ràng.
Cảnh báo
assign(to: \.property, on: object) không nhảy luồng gì cả. Gán từ một publisher chạy nền thẳng
vào một thuộc tính giao diện là một trong những cách dễ nhất để vi phạm luồng chính, và nó trông
hoàn toàn vô hại. Hãy đặt receive(on: DispatchQueue.main) trước mọi assign có động đến giao
diện.
Chọn scheduler
| Scheduler | Dùng cho |
|---|---|
DispatchQueue.main |
Giao cho giao diện. Lựa chọn mặc định ở cuối chuỗi |
DispatchQueue.global(qos:) |
Phân tích cú pháp, giải mã, việc trên đĩa |
Một DispatchQueue tuần tự riêng |
Tuần tự hóa truy cập vào một mẩu trạng thái |
RunLoop.main |
Liên thông với code cũ. Coi chừng lúc theo dõi cuộn |
OperationQueue |
Khi bạn cần maxConcurrentOperationCount |
ImmediateScheduler.shared |
Kiểm thử, khi không muốn có độ trễ nào |
Một hàng đợi tuần tự riêng bị dùng ít hơn mức đáng và thường là câu trả lời đúng. Nếu một subject
được nạp từ nhiều chỗ, receive(on: myQueue) đặt ngay sau nó khiến mọi toán tử phía dưới chạy trên
cùng một luồng, thứ biến một class đầy khóa thành một class không có cái khóa nào.
private let queue = DispatchQueue(label: "com.example.sync")
var events: AnyPublisher<Event, Never> {
subject.receive(on: queue).eraseToAnyPublisher()
}
Tham số qos quan trọng hơn vẻ ngoài của nó
DispatchQueue.global() mặc định là .default, thứ không phải lựa chọn nhanh nhất mà cũng không
phải tiết kiệm điện nhất. Nói rõ ra thì rẻ:
.userInitiated— người dùng đang chờ và đang nhìn. Giải mã màn hình họ vừa mở..utility— tiến độ nhìn thấy được nhưng người dùng đang làm việc khác. Một lượt đồng bộ, một lượt tải về..background— chẳng ai chờ cả. Tải trước, dọn dẹp, gửi dữ liệu phân tích.
Sai theo hướng lãng phí — để mọi thứ ở .userInitiated — gây bùng nổ luồng và hao pin, và trên một
thiết bị có nhân tiết kiệm điện thì nó chủ động ngăn bộ lập lịch làm đúng việc của mình.
Đồng hồ, và vì sao Timer.publish cần connect
Timer.publish trả về một ConnectablePublisher, thứ chẳng làm gì cho tới khi được bảo bắt đầu:
let timer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect() // bắt đầu ở lần đăng ký đầu tiên
.sink { print($0) }
Không có .autoconnect() — hoặc một .connect() tường minh — thì đồng hồ không bao giờ nổ, và đây
là một báo cáo “đồng hồ của tôi không chạy” rất phổ biến. Cũng hãy để ý in: .common chứ không phải
.default: chế độ run loop mặc định tạm dừng trong lúc theo dõi cuộn, và đó vẫn là cái bẫy
RunLoop.main ở chương trước, chỉ khoác bộ đồ khác.