Schedulers and threads
Where work happens, where values arrive, and why they are different questions
A Scheduler in Combine answers two questions: when something runs, and where. The protocol
is small, and understanding it removes most Combine threading confusion.
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)
}
Note now. A scheduler is not only an executor — it is also a clock, and that is what makes
Combine’s time-based operators testable. debounce does not read the system time; it asks its
scheduler. Swap the scheduler for one whose clock you control, and a three-second debounce test runs
instantly. That is the whole trick of the testing chapter.
The publisher runs where it was subscribed
By default, an operator’s work happens on whatever thread delivered the value to it. There is no
implicit thread hop anywhere in Combine — a map closure runs on the thread the upstream published
from, and that thread might be a URLSession delegate queue, a Timer’s run loop, or whatever
called send(_:) on a subject.
This has two consequences worth internalising:
A pipeline with no scheduler operators delivers on an essentially arbitrary thread. For
URLSession.dataTaskPublisher, that is a background queue, which is why updating UI directly from
its sink produces the main-thread-checker purple warnings.
Where you put receive(on:) decides what runs where. Everything below it moves; everything
above it does not.
api.fetchItems() // URLSession's queue
.map { $0.filter(\.isVisible) } // still URLSession's queue — good
.receive(on: DispatchQueue.main) // hop
.sink { self.items = $0 } // main — good
Move that receive(on:) to the top and the filtering happens on the main queue for no reason.
Thread confinement in practice
Combine gives you no thread safety for free. A publisher may deliver on any thread, and a subject may be sent to from any thread, and neither is synchronised.
Three rules that keep it manageable:
- One
receive(on: DispatchQueue.main)immediately before the sink, in any pipeline that ends in UI. Not earlier. - Subjects are fed from one queue only. If more than one place calls
send(_:), put areceive(on:)between the subject and everything downstream and be disciplined about the sending side, or move the state into an actor. @Publishedmust be mutated on the main thread when a SwiftUI view observes it. SwiftUI does not do this hop for you and the failure is a crash in view update rather than an obvious threading error.
Warning
assign(to: \.property, on: object) performs no hop. Assigning from a background publisher
straight to a UI property is one of the easiest ways to get a main-thread violation, and it looks
completely innocent. Put receive(on: DispatchQueue.main) before every assign that touches UI.
Choosing a scheduler
| Scheduler | Use for |
|---|---|
DispatchQueue.main |
UI delivery. The default choice at the end of a chain |
DispatchQueue.global(qos:) |
Parsing, decoding, disk work |
A private serial DispatchQueue |
Serialising access to one piece of state |
RunLoop.main |
Legacy interop. Beware scroll tracking |
OperationQueue |
When you need maxConcurrentOperationCount |
ImmediateScheduler.shared |
Tests, where no delay is wanted at all |
A private serial queue is under-used and often the right answer. If a subject is fed from several
places, receive(on: myQueue) immediately after it makes every downstream operator run on one
thread, which turns a class full of locks into one with none.
private let queue = DispatchQueue(label: "com.example.sync")
var events: AnyPublisher<Event, Never> {
subject.receive(on: queue).eraseToAnyPublisher()
}
The qos argument matters more than it looks
DispatchQueue.global() defaults to .default, which is neither the fastest nor the most
power-efficient choice. Being specific is cheap:
.userInitiated— the user is waiting and watching. Decoding the screen they just opened..utility— progress is visible but the user is doing something else. A sync, a download..background— nobody is waiting. Prefetching, cleanup, analytics upload.
Getting this wrong in the wasteful direction — everything at .userInitiated — causes thread
explosion and battery drain, and on a device with efficiency cores it actively prevents the scheduler
from doing its job.
Timers, and why Timer.publish needs connect
Timer.publish returns a ConnectablePublisher, which does nothing until told to start:
let timer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect() // start on first subscription
.sink { print($0) }
Without .autoconnect() — or an explicit .connect() — the timer never fires, and this is a
common “my timer does not work” report. Note also in: .common rather than .default: the default
run loop mode pauses during scroll tracking, which is the same trap as RunLoop.main in the previous
chapter, in a different costume.