Trang chủ

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:

  1. 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.
  2. 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ột receive(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.
  3. @Published phả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.