17.
Schedulers
Written by Florent Pillet
As you’ve been progressing through this book, you’ve read about this or that operator taking a scheduler as a parameter. Most often you’d simply use DispatchQueue.main because it’s convenient, well understood and brings a reassuring feeling of safety. This is the comfort zone!
As a developer, you have at least a general idea of what a DispatchQueue is. Besides DispatchQueue.main, you most certainly already used either one of the global, concurrent queues, or created a serial dispatch queue to run actions serially on. Don’t worry if you haven’t or don’t remember the details. You’ll re-assess some important information about dispatch queues throughout this chapter.
But then, why does Combine need a new similar concept? It is now time for you to dive into the real nature, meaning and purpose of Combine schedulers!
In this chapter, you’ll learn why the concept of schedulers came about. You’ll explore how Combine makes asynchronous events and actions easy to work with and, of course, you’ll get to experiment with all the schedulers that Combine provides.
An introduction to schedulers
Per Apple’s documentation, a scheduler is a protocol that defines when and how to execute a closure. Although the definition is correct, it’s only part of the story.
A scheduler provides the context to execute a future action, either as soon as possible or at a future date. The action is a closure as defined in the protocol itself. But the term closure can also hide the delivery of some value by a Publisher, performed on a particular scheduler.
Did you notice that this definition purposely avoids any reference to threading? This is because the concrete implementation is the one that defines where the “context” provided by the scheduler protocol executes!
The exact details of which thread your code will execute on therefore depends on the scheduler you pick.
Remember this important concept: A scheduler is not equal to a thread. You’ll get into the details of what this means for each scheduler later in this chapter.
Let’s look at the concept of schedulers from an event flow standpoint:
What you see in the figure above:
- A user action (button press) occurs on the main (UI) thread.
- It triggers some work to process on a background scheduler.
- Final data to display is delivered to subscribers on the main thread, so subscribers can update the app‘s UI.
You can see how the notion of scheduler is deeply rooted in the notions of foreground/background execution. Moreover, depending on the implementation you pick, work can be serialized or parallelized.
Therefore, to fully understand schedulers, you need to look at which classes conform to the Scheduler protocol.
But first, you need to learn about two important operators related to schedulers!
Note: In the next section, you’ll primarily use
DispatchQueuewhich conforms to Combine‘sSchedulerprotocol.
Operators for scheduling
The Combine framework provides two fundamental operators to work with schedulers:
-
subscribe(on:)andsubscribe(on:options:)creates the subscription (start the work) on the specified scheduler. -
receive(on:)andreceive(on:options:)delivers values on the specified scheduler.
In addition, the following operators take a scheduler and scheduler options as parameters. You learned about them in Chapter 6, “Time Manipulation Operators:”
debounce(for:scheduler:options:)delay(for:tolerance:scheduler:options:)measureInterval(using:options:)throttle(for:scheduler:latest:)timeout(_:scheduler:options:customError:)
Don’t hesitate to take a look back at Chapter 6 if you need to refresh your memory on these operators. Then you can look into the two new ones.
Introducing subscribe(on:)
Remember — a publisher is an inanimate entity until you subscribe to it. But what happens when you subscribe to a publisher? Several steps take place:
-
Publisherreceives the subscriber and creates aSubscription. -
Subscriberreceives the subscription and requests values from the publisher (dotted lines). -
Publisherstarts work (via theSubscription). -
Publisheremits values (via theSubscription). - Operators transform values.
-
Subscriberreceives the final values.
Steps one, two and three usually happen on the thread that is current when your code subscribes to the publisher. But when you use the subscribe(on:) operator, all these operations run on the scheduler you specified.
Note: You’ll come back to this diagram when looking at the
receive(on:)operator. You’ll then understand the two boxes at the bottom with steps labeled five and six.
You may want a publisher to perform some expensive computation in the background to avoid blocking the main thread. The simple way to do this is to use subscribe(on:).
It’s time to look at an example!
Open Starter.playground in the projects folder and select the subscribeOn-receiveOn page. Make sure the Debug area is displayed, then start by adding the following code:
// 1
let computationPublisher = Publishers.ExpensiveComputation(duration: 3)
// 2
let queue = DispatchQueue(label: "serial queue")
// 3
let currentThread = Thread.current.number
print("Start computation publisher on thread \(currentThread)")
Here‘s a breakdown of the above code:
- This playground defines a special publisher in Sources/Computation.swift called
ExpensiveComputation, which simulates a long-running computation that emits a string after the specified duration. - A serial queue you’ll use to trigger the computation on a specific scheduler. As you learned above,
DispatchQueueconforms to theSchedulerprotocol. - You obtain the current execution thread number. In a playground, the main thread (thread number 1) is the default thread your code runs in. The
numberextension to theThreadclass is defined in Sources/Thread.swift.
Note: The details of how the
ExpensiveComputationpublisher is implemented do not matter for now. You will learn more about creating your own publishers in the next chapter, “Custom Publishers & Handling Backpressure.”
Back to the subscribeOn-receiveOn playground page, you’ll need to subscribe to computationPublisher and display the value it emits:
let subscription = computationPublisher
.sink { value in
let thread = Thread.current.number
print("Received computation result on thread \(thread): '\(value)'")
}
Execute the playground and look at the output:
Start computation publisher on thread 1
ExpensiveComputation subscriber received on thread 1
Beginning expensive computation on thread 1
Completed expensive computation on thread 1
Received computation result on thread 1 'Computation complete'
Let’s dig into the various steps to understand what happens:
- Your code is running on the main thread. From there, it subscribes to the computation publisher.
- The
ExpensiveComputationpublisher receives a subscriber. - It creates a subscription, then starts the work.
- When work completes, publisher delivers the result through the subscription and completes.
You can see that all of this happen on thread 1 which is the main thread.
Now, change the publisher subscription to insert a subscribe(on:) call:
let subscription = computationPublisher
.subscribe(on: queue)
.sink { value in...
Execute the playground again to see output similar to the following:
Start computation publisher on thread 1
ExpensiveComputation subscriber received on thread 5
Beginning expensive computation from thread 5
Completed expensive computation on thread 5
Received computation result on thread 5 'Computation complete'
Ah! This is different! Now you can see that you’re still subscribing from the main thread, but Combine delegates to the queue you provided to perform the subscription effectively. The queue runs the code on one of its threads. Since the computation starts and completes on thread 5 and then emits the resulting value from this thread, your sink receives the value on this thread as well.
Note: Due to the dynamic thread management nature of
DispatchQueue, you may see different thread numbers in this log and further logs in this chapter. What matters is consistency: The same thread number should be shown at the same steps.
But what if you wanted to update some on-screen info? You would need to do something like DispatchQueue.main.async { ... } in your sink closure, just to make sure you’re performing UI updates from the main thread.
There is a more effective way to do this with Combine!
Introducing receive(on:)
The second important operator you want to know about is receive(on:). It lets you specify which scheduler should be used to deliver values to subscribers. But what does this mean?
Insert a call to receive(on:) just before your sink in the subscription:
let subscription = computationPublisher
.subscribe(on: queue)
.receive(on: DispatchQueue.main)
.sink { value in
Then, execute the playground again. Now you see this output:
Start computation publisher on thread 1
ExpensiveComputation subscriber received on thread 4
Beginning expensive computation from thread 4
Completed expensive computation on thread 4
Received computation result on thread 1 'Computation complete'
Note: You may see the second message (“ExpensiveComputation subscriber received…”) on a different thread than the two next steps. Due to internal plumbing in Combine, this step and the next may execute asynchronously on the same queue. Since
Dispatchdynamically manages its own thread pool, you may see a different thread number for this line and the next, but you won’t seethread 1.
Success! Even though the computation works and emits results from a background thread, you are now guaranteed to always receive values on the main queue. This is what you need to perform your UI updates safely.
In this introduction to scheduling operators, you used DispatchQueue. Combine extends it to implement the Scheduler protocol, but it’s not the only one! It’s time to dive into schedulers!
Scheduler implementations
Apple provides several concrete implementations of the Scheduler protocol:
-
ImmediateScheduler: A simple scheduler that executes code immediately on the current thread, which is the default execution context unless modified usingsubscribe(on:),receive(on:)or any of the other operators which take a scheduler as parameter. -
RunLoop: Tied to Foundation’sThreadobject. -
DispatchQueue: Can either be serial or concurrent. -
OperationQueue: A queue that regulates the execution of work items.
In the rest of this chapter, you’ll go over all of these and their specific details.
Note: One glaring omission here is the lack of a
TestScheduler, an indispensable part of the testing portion of any reactive programming framework. Without such a virtual, simulated scheduler, it’s challenging to test your Combine code thoroughly. You’ll explore more details about this particular kind of scheduler in Chapter 19, “Testing.”
ImmediateScheduler
The easiest entry in the scheduler category is also the simplest one the Combine framework provides: ImmediateScheduler. The name already spoils the details, so have a look at what it does!
Open the ImmediateScheduler page of the playground. You won’t need the debug area for this one, but make sure you make the Live View visible. If you’re not sure how to do that, see the beginning of Chapter 6, “Time Manipulation Operators.”
You’re going to use some fancy new tools built into this playground to follow your publisher values across schedulers!
Start by creating a simple timer as you did in previous chapters:
let source = Timer
.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.scan(0) { counter, _ in counter + 1 }
Next, prepare a closure that creates a publisher. You’ll make use of a custom operator defined in the Sources/Record.swift: recordThread(using:). This operator records the thread that is current at the time the operator sees a value passing through, and can record multiple times from the publisher source to the final sink.
Note: This
recordThread(using:)operator is for testing purposes only, as the operator changes the type of data to an internal value type. The details of its implementation are beyond the scope of this chapter, but the adventurous reader may find it interesting to look into.
Add this code:
// 1
let setupPublisher = { recorder in
source
// 2
.recordThread(using: recorder)
// 3
.receive(on: ImmediateScheduler.shared)
// 4
.recordThread(using: recorder)
// 5
.eraseToAnyPublisher()
}
// 6
let view = ThreadRecorderView(title: "Using ImmediateScheduler", setup: setupPublisher)
PlaygroundPage.current.liveView = UIHostingController(rootView: view)
In the above code, you:
- Prepare a closure that returns a publisher, using the given
recorderobject to setup current thread recording viarecordThread(using:). - At this stage, the timer emitted a value, so you record the current thread. Can you already guess which one it is?
- Make sure the publisher delivers values on the shared
ImmediateScheduler. - Record which thread you’re now on.
- The closure must return an
AnyPublishertype. This is mainly for convenience in the internal implementation. - Prepare and instantiate a
ThreadRecorderViewwhich displays the migration of a published value across threads at various record points.
Execute the playground page and look at the output after a few seconds:
This representation shows each value the source publisher (the timer) emits. On each line, you see the threads the value is going through. Every time you add a recordThread(using:) operator, you see an additional thread number logged on the line.
Here you see that at the two recording points you added, the current thread was the main thread. This is because the ImmediateScheduler “schedules” immediately on the current thread.
To verify this, you can do a little experiment! Go back to your setupPublisher closure definition, and just before the first recordThread line, insert the following:
.receive(on: DispatchQueue.global())
This requests that values the source emits be further made available on the global concurrent queue. Is this going to yield interesting results? Execute the playground to find out:
This is completely different! Can you guess why the thread changes all the time? You’ll learn more about this in the coverage of DispatchQueue in this chapter!
ImmediateScheduler options
With most of the operators accepting a Scheduler in their arguments, you can also find an options argument which accepts a SchedulerOptions value. In the case of ImmediateScheduler, this type is defined as Never so when using ImmediateScheduler, you should never pass a value for the options parameter of the operator.
ImmediateScheduler pitfalls
One specific thing about ImmediateScheduler is that it is immediate. You won’t be able to use any of the schedule(after:) variants of the Scheduler protocol, because the SchedulerTimeType you need to specify a delay has no public initializer and is meaningless for immediate scheduling.
Similar but different pitfalls exist for the second type of Scheduler you’ll learn about in this chapter: RunLoop.
RunLoop scheduler
Long-time iOS and macOS developers are familiar with RunLoop. Predating DispatchQueue, it is a way to manage input sources at the thread level, including in the Main (UI) thread. Your application’s main thread still has an associated RunLoop. You can also obtain one for any Foundation Thread by calling RunLoop.current from the current thread.
Note: Nowadays
RunLoopis a less useful class, asDispatchQueueis a sensible choice in most situations. This said, there are still some specific cases where run loops are useful. For example,Timerschedules itself on aRunLoop. UIKit and AppKit rely onRunLoopand its execution modes for handling various user input situations. Describing everything aboutRunLoopis outside the scope of this book.
To have a look at RunLoop, open the RunLoop page in the playground. The Timer source you used earlier is the same, so it’s already written for you. Add this code after it:
let setupPublisher = { recorder in
source
// 1
.receive(on: DispatchQueue.global())
.recordThread(using: recorder)
// 2
.receive(on: RunLoop.current)
.recordThread(using: recorder)
.eraseToAnyPublisher()
}
let view = ThreadRecorderView(title: "Using RunLoop", setup: setupPublisher)
PlaygroundPage.current.liveView = UIHostingController(rootView: view)
- As you previously did, you first make values go through the global concurrent queue. Why? Because it’s fun!
- Then, you ask values to be received on
RunLoop.current.
But what is RunLoop.current? It is the RunLoop associated with the thread that was current when the call was made. The closure is being called by ThreadRecorderView, from the main thread to set up the publisher and the recorder. Therefore, RunLoop.current is the main thread’s RunLoop.
Execute the playground to see what happens:
As you requested it, the first recordThread shows that each value goes through one of the global concurrent queue’s threads, then continues on the main thread.
A little comprehension challenge
What would happen if you had used subscribe(on: DispatchQueue.global()) instead of receive(on:) the first time? Try it!
You see that everything is recorded on thread one. It may not be obvious at first, but it is entirely logical. Yes, the publisher was subscribed to on the concurrent queue. But remember that you are using a Timer which is emitting its values… on the main RunLoop! Therefore, regardless of the scheduler you pick to subscribe to this publisher on, values will always begin their journey on the main thread.
Scheduling code execution with RunLoop
The Scheduler lets you schedule code that executes as soon as possible, or after a future date. While it was not possible to use the latter form with ImmediateScheduler, RunLoop is perfectly capable of deferred execution.
Each scheduler implementation defines its own SchedulerTimeType. It makes things a little complicated to grasp until you figure out what type of data to use. In the case of RunLoop, the SchedulerTimeType value is a Date.
You’ll schedule an action that will cancel the ThreadRecorderView’s subscription after a few seconds. It turns out the ThreadRecorder class has an optional Cancellable that can be used to stop its subscription to the publisher.
First, you need a variable to hold a reference to the ThreadRecorder. At the beginning of the page, add this line:
var threadRecorder: ThreadRecorder? = nil
Now you need to capture the thread recorder instance. The best place to do this is on the setupPublisher closure. But how? You could:
- Add explicit types to the closure, assign the
threadRecordervariable and return the publisher. You’ll need to add explicit types because the poor Swift compiler will complain “may be unable to infer complex closure return type.“ - Use some operator to capture the recorder at subscription time.
Go wild and do the latter!
Add this line in your setupPublisher closure before eraseToAnyPublisher():
.handleEvents(receiveSubscription: { _ in threadRecorder = recorder })
Interesting choice to capture the recorder!
Note: You already learned about
handleEventsin Chapter 10, “Debugging.” It has a long signature and lets you execute code at various points in the lifecycle of a publisher (in the reactive programming terminology this is called injecting side effects) without actually interacting with the values it emits. In this case, you’re intercepting the moment when the recorder subscribes to the publisher, so as to capture the recorder in your global variable. Not pretty, but it does the job in a fun way!
Now you’re all set and can schedule some action after a few seconds. Add this code at the end of the page:
RunLoop.current.schedule(
after: .init(Date(timeIntervalSinceNow: 4.5)),
tolerance: .milliseconds(500)) {
threadRecorder?.subscription?.cancel()
}
This schedule(after:tolerance:) lets you schedule when the provided closure should execute along with the tolerable drift in case the system can’t precisely execute the code at the selected time. You add 4.5 seconds to the current date to allow four values to be sent before the execution.
Run the playground. You can see that the list stops updating after the fourth item. This is your cancellation mechanism working!
Note: If you only get three values, it might mean your Mac is running a little slow and cannot accommodate a half-second tolerance, so you can try bumping the dates out more, e.g., set
timeIntervaleSinceNowto5.0andtoleranceto1.0.
RunLoop options
Like ImmediateScheduler, RunLoop does not offer any suitable options for the calls which take a SchedulerOptions parameter.
RunLoop pitfalls
Usages of RunLoop should be restricted to the main thread’s run loop, and to the RunLoop available in Foundation threads that you control if needed. That is, anything you started yourself using a Thread object.
One particular pitfall to avoid is using RunLoop.current in code executing on a DispatchQueue. This is because DispatchQueue threads can be ephemeral, which makes them nearly impossible to rely on with RunLoop.
You are now ready to learn about the most versatile and useful scheduler: DispatchQueue!
DispatchQueue scheduler
Throughout this chapter and previous chapters, you’ve been using DispatchQueue in various situations. It comes as no surprise that DispatchQueue conforms to the Scheduler protocol and is fully usable with all operators that take a Scheduler as a parameter.
But first, a quick refresher on dispatch queues. The Dispatch framework is a powerful component of Foundation that allows you to execute code concurrently on multicore hardware by submitting work to dispatch queues managed by the system.
A DispatchQueue can be either serial (the default) or concurrent. A serial queue executes all the work items you feed it, in sequence. A concurrent queue will start multiple work items in parallel, to maximize CPU usage. Both queue types have different usages:
- A serial queue is typically used to guarantee that some operations do not overlap. So, they can use shared resources without locking if all operations occur in the same queue.
- A concurrent queue will execute as many operations concurrently as possible. So, it is better suited for pure computation.
Queues and threads
The most familiar queue you work with all the time is DispatchQueue.main. It directly maps to the main (UI) thread, and all operations executing on this queue can freely update the user interface. UI updates are only permitted from the main thread.
All other queues, serial or concurrent, execute their code in a pool of threads managed by the system. Meaning you should never make any assumption about the current thread in code that runs in a queue. In particular, you should not schedule work using RunLoop.current because of the way DispatchQueue manages its threads.
All dispatch queues share the same pool of threads. A serial queue you give work to perform will use any available thread in that pool. A direct consequence is that two successive work items from the same queue may use different threads while still executing sequentially.
This is an important distinction: When using subscribe(on:), receive(on:) or any of the other operators taking a Scheduler parameter, you should never assume that the thread backing the scheduler is the same every time.
Using DispatchQueue as a scheduler
It’s time for you to experiment! As usual, you’re going to use a timer to emit values and watch them migrate across schedulers. But this time around, you’re going to create the timer using a Dispatch Queue timer.
Open the playground page named DispatchQueue. First, you‘ll create a couple queues to work with. Add this code to your playground:
let serialQueue = DispatchQueue(label: "Serial queue")
let sourceQueue = DispatchQueue.main
You’ll use sourceQueue to publish values from a timer, and later use serialQueue to experiment with switching schedulers.
Now add this code:
// 1
let source = PassthroughSubject<Void, Never>()
// 2
let subscription = sourceQueue.schedule(after: sourceQueue.now,
interval: .seconds(1)) {
source.send()
}
- You’ll use a
Subjectto emit a value when the timer fires. You don’t care about the actual output type, so you just useVoid. - As you learned in Chapter 11, “Timers,” queues are perfectly capable of generating timers, but there is no
PublisherAPI for queue timers. It is a surprising omission from the API! You have to use the repeating variant of theschedule()method from theSchedulersprotocol. It starts immediately and returns aCancellable. Every time the timer fires, you‘ll send aVoidvalue through the source subject.
Note: Did you notice how you’re using the
nowproperty to specify the starting time of the timer? This is part of theSchedulerprotocol and returns the current time expressed using the scheduler’sSchedulerTimeType. Each class implementing theSchedulerprotocol defines its own type for this.
Now, you can start exercising scheduler hopping. Setup your Publisher by adding the following code:
let setupPublisher = { recorder in
source
.recordThread(using: recorder)
.receive(on: serialQueue)
.recordThread(using: recorder)
.eraseToAnyPublisher()
}
Nothing new here, you’ve coded similar patterns several times in this chapter.
Then, as in your the previous examples, set up the display:
let view = ThreadRecorderView(title: "Using DispatchQueue",
setup: setupPublisher)
PlaygroundPage.current.liveView = UIHostingController(rootView: view)
Execute the playground. Easy enough, you see what was intended:
- The timer fires on the main queue and sends
Voidvalues through the subject. - The publisher receive values on your serial queue.
Did you notice how the second recordThread(using:) records changes in the current thread after the receive(on:) operator? This is a perfect example of how DispatchQueue makes no guarantee over which thread each work item executes on. In the case of receive(on:), a work item is a value that hops from the current scheduler to another.
Now, what would happen if you emitted values from the serial queue and kept the same receive(on:) operator? Would values still change threads on the go?
Try it! Go back to the beginning of the code and change the sourceQueue definition to:
let sourceQueue = serialQueue
Now, execute the playground again:
Interesting! Again you see the no-thread-guarantee effect of DispatchQueue, but you also see that the receive(on:) operator never switches threads! It looks like some optimization is internally happening to avoid extra switching. You’ll explore this in this chapter’s challenge!
DispatchQueue options
DispatchQueue is the only scheduler providing a set of options you can pass when operators take a SchedulerOptions argument. These options mainly revolve around specifying QoS (Quality of Service) values independently of those already set on the DispatchQueue. There are some additional flags for work items, but you won’t need them in the vast majority of situations.
To see how you would specify the QoS though, modify the receive(on:options:) in your setupPublisher to the following:
.receive(
on: serialQueue,
options: DispatchQueue.SchedulerOptions(qos: .userInteractive)
)
You pass an instance of DispatchQueue.SchedulerOptions to options that specifies the highest quality of service: .userInteractive. It instructs the OS to make its best effort to prioritize delivery of values over less important tasks. This is something you can use when you want to update the user interface as fast as possible. To the contrary, if there is less pressure for speedy delivery, you could use the .background quality of service. In the context of this example you won’t see a real difference since it’s the only task running.
Using these options in real applications helps the OS deciding which task to schedule first in situations where you have many queues busy at the same time. It really is fine tuning your application performance!
You’re nearly done with schedulers! Hang on a little bit more. You have one last scheduler to learn about.
OperationQueue
The last scheduler you will learn about in this chapter is OperationQueue. The documentation describes it as a queue that regulates the execution of operations. It is a rich regulation mechanism that lets you create advanced operations with dependencies. But in the context of Combine, you will use none of these mechanisms.
Since OperationQueue uses Dispatch under the hood, there is little difference on the surface in using one of the other. Or is there?
Give it a go in a simple example. Open the OperationQueue playground page and start coding:
let queue = OperationQueue()
let subscription = (1...10).publisher
.receive(on: queue)
.sink { value in
print("Received \(value)")
}
You’re creating a simple publisher emitting numbers between 1 and 10, making sure values arrive on the OperationQueue you created. You then print the value in the sink.
Can you guess what happens? Expand the Debug area and execute the playground:
Received 4
Received 3
Received 2
Received 7
Received 5
Received 10
Received 6
Received 9
Received 1
Received 8
This is puzzling! Items are emitted in order but arrive out of order! How can this be? To find out, you can change the print line to display the current thread number:
print("Received \(value) on thread \(Thread.current.number)")
Execute the playground again:
Received 1 on thread 5
Received 2 on thread 4
Received 4 on thread 7
Received 7 on thread 8
Received 6 on thread 9
Received 10 on thread 10
Received 5 on thread 11
Received 9 on thread 12
Received 3 on thread 13
Received 8 on thread 14
Ah-ha! As you can see see, each value is received on a different thread! If you look up the documentation about OperationQueue, there is a note about threading which says that OperationQueue uses the Dispatch framework (hence DispatchQueue) to execute operations. It means it doesn‘t guarantee it’ll use the same underlying thread for each delivered value.
Moreover, there is one parameter in each OperationQueue that explains everything: It’s maxConcurrentOperationCount. It defaults to a system-defined number that allows an operation queue to execute a large number of operations concurrently. Since your publisher emits all its items at roughly the same time, they get dispatched to multiple threads by Dispatch’s concurrent queues!
Make a little modification to your code. After defining queue, add this line:
queue.maxConcurrentOperationCount = 1
Then run the page and look at the debug area:
Received 1 on thread 3
Received 2 on thread 3
Received 3 on thread 3
Received 4 on thread 3
Received 5 on thread 4
Received 6 on thread 3
Received 7 on thread 3
Received 8 on thread 3
Received 9 on thread 3
Received 10 on thread 3
This time, you get true sequential execution — setting maxConcurrentOperationCount to 1 is equivalent to using a serial queue — and your values arrive in order.
OperationQueue options
There is no usable SchedulerOptions for OperationQueue. It’s actually type aliased to RunLoop.SchedulerOptions, which itself provides no option.
OperationQueue pitfalls
You just saw that OperationQueue executes operations concurrently by default. You need to be very aware of this as it can cause you trouble: By default, an OperationQueue behaves like a concurrent DispatchQueue.
It can be a good tool, though, when you have significant work to perform every time a publisher emits a value. You can control the load by tuning the maxConcurrentOperationCount parameter.
Challenges
Phew, this was a long and complex chapter! Congratulations on making it so far! Have some brainpower left for a couple of challenges? Let’s do it!
Challenge 1: Stop the timer
This is an easy one. In this chapter’s section about DispatchQueue you created a cancellable timer to feed your source publisher with values.
Devise two different ways of stopping the timer after 4 seconds. Hint: You’ll need to use DispatchQueue.SchedulerTimeType.advanced(by:).
Found the solutions? Compare them to the ones in the projects/challenge/challenge1/ final playground:
- Use the serial queue’s scheduler protocol
schedule(after:_:)method to schedule the execution of a closure which cancels thesubscription. - Use serialQueue’s normal
asyncAfter(_:_:)method (pre-Combine) to do the same thing.
Challenge 2: Discover optimization
Earlier in this chapter, you read about an intriguing question: Is Combine optimizing when you’re using the same scheduler in successive receive(on:) calls, or is it a Dispatch framework optimization?
To find out, you’ll want to turn over to challenge 2. Your challenge is to devise a method that will bring an answer to this question. It’s not very complicated, but it’s not trivial either.
Could you find a solution? Read on to compare yours!
In the Dispatch framework, the initializer for DispatchQueue takes an optional target parameter. It lets you specify a queue on which to execute your code. In other words, the queue you create is just a shadow while the real queue on which your code executes is the target queue.
So the idea to try and guess whether Combine or Dispatch is performing the optimization is to use two different queues having one targeting the other. So at the Dispatch framework level, code all executes on the same queue, but (hopefully) Combine doesn’t notice.
Therefore, if you do this and see all values being received on the same thread, it is most likely that Dispatch is performing the optimizations for you. The steps you take to code the solution are:
- Create the second serial queue, targeting the first one.
- Add a
.receive(on:)for the second serial queue, as well as a.recordThreadstep.
The full solution is available in the projects/challenge/challenge2 final playground.
Key points
- A
Schedulerdefines the execution context for a piece of work. - Apple‘s operating systems offer a rich variety of tools to help you schedule code execution.
- Combine tops these schedulers with the
Schedulerprotocol to help you pick the best one for the job in any given situation. - Every time you use
receive(on:), further operators in your publisher execute on the specified scheduler. That is, unless they themselves take aSchedulerparameter!
Where to go from here?
You‘ve learned a lot, and your brain must be melting with all this information! The next chapter is even more involved as it teaches you about creating your own publishers and dealing with backpressure. Make sure you schedule a much-deserved break now, and come back refreshed for the next chapter!