There are many similarities between dispatch queues and run loops, and many situations where both options can be considered. In practice, dispatch queues are more compelling: they are easier to set up and maintain, while offering better performance. That said, there is still one thing that run loops make easier to manage: timers. With a bit of work, and with the help of PARDispatchQueue, one can have the best of both worlds.
With run loops, you can easily fire a timer, or cancel it if you change your mind:
1 2 3 4 5 6 7 8 9 10 11
Timers can also be used indirectly with the
1 2 3 4 5 6 7 8 9 10 11
However, timers and delayed execution only work as expected if you have a run loop set up on the current thread. In practice, I have found that using a run loop on anything other than the main thread is a painful experience.
Fortunately, timers and delayed execution are also possible in the world of Grand Central Dispatch. For delayed execution, one can use the
dispatch_after function. Timers are also part of the APIs, as a particular type of
dispatch_source_t, which can be created using the
dispatch_source_set_timer function. The APIs are a bit intimidating, but thanks to Mike Ash, I did not have to fight too hard to decipher how to use them: the source code for MABGTimer is a great starting point, and it offers a familiar Objective C interface for managing a timer in a dispatch queue.
Coalesce and Delay
With MABGTimer, Mike introduces a simple concept, which I have found to be very powerful in many common situations. It’s timer behavior:
1 2 3 4 5
MABGTimerCoalesce behavior means that subsequent calls to a charged timer can only reduce the time until it fires. In other words, once the firing date is set, it cannot be delayed further (but it can be shortened). Conversely, the
MABGTimerDelay behavior means that subsequent calls will potentially replace the scheduled time previously set, and thus extend it further.
To illustrate where those behaviors can be useful, let’s consider the indexing of documents with SearchKit. When new documents are added to an index, the data is first stored in memory, and only saved to disk when the index is “flushed”. A naive approach is then to flush after every addition. However, flushing too often results in high I/O activity and has a very significant impact on performance when a large number of documents need to be indexed. Here is a visual representation of that solution:
It would be equally dangerous to wait until all the documents are indexed before flushing, as memory usage will increase, the final flush will take forever, and there is a risk we will lose all the data if for some reason, the flush is not done. A good compromise for batch indexing is thus to flush every minute. The problem with this approach is that it can leave a large delay after adding just one document. This means the index will not be saved to disk for a while, data loss could happen, and the user may perceive a delay when searching (since a flush has to be performed before any search anyway):
A good strategy is thus to flush after 5 seconds of inactivity following the last document addition, or else, flush every minute as long as documents are being added to the index:
This approach fits well with
MABGTimer behaviors. We can first use the
MABGTimerDelay behavior to have a flush scheduled 5 seconds after a document is added to the index: the flush time will keep being pushed as more documents are added to the queue. We then also use the
MABGTimerCoalesce behavior to have a flush scheduled 1 minute after a document is added to the index: the flush will happen after 1 minute even if more document additions are scheduled. Whichever timer fires first, it cancels the other one. The resulting behavior looks like this:
Alas, despite all the cleverness of Mike Ash’s design, the above scheme quickly hit the limits of
MABGTimer and its APIs. The
MABGTimer class is really a thin wrapper for a dispatch queue, and it can only be used with one timer on one dispatch queue.
Since I had a dispatch queue wrapper of my own, that I was using for other purposes as well, I decided to simply steal most of
MABGTimer code and integrate it in
PARDispatchQueue (it is BSD-licensed and I am thus only using the word “steal” for dramatic effects). The result is available on GitHub.
The APIs for timers in
PARDispatchQueue are very simple:
1 2 3 4 5 6
The queue can have multiple timers, which can be safely scheduled and canceled at any point. The timers are simply tracked by their name as an
NSString object. I find that scheduling and canceling timers with this design is in fact even easier than on run loops.
In the case of the SearchKit example, we would create a queue to serialize access to an instance of
SKIndexRef (the SearchKit APIs are mostly thread-safe, but there are still some aspects that are better handled via a serial queue, in addition to the use of timers here):
Here is what we do when adding a document:
1 2 3 4 5 6 7 8
The implementation of
flushWhenNeeded is where the timers appear:
1 2 3 4 5 6 7 8 9 10 11 12
_flush method actually does the job. Note that it is called from within the queue itself. With this design, only one of the 2 timers will actually be fired, whichever comes first:
1 2 3 4 5 6
PARDispatchQueue Timers in the UI
Such timers are not just useful when dealing with worker queues like in the SearchKit example. They are also useful in UI-related code.
Let’s consider another example related to search, but that now affects the user interface. When a user types in a search field, we need to start a new search and display the results as they come in. For a better user experience, we would like the following behavior:
- perform the search in a background thread
- do not initiate the search immediately, in case the user is still typing; instead, wait 0.2 seconds after the last keystroke before starting the actual search
- if the search is still running after 0.5 seconds, display a spinning cursor, and keep it spinning until the search is finished (if the query is fast and takes less than 0.5 seconds, no need to add extra UI clutter with a short-lived spinning indicator)
Using the classic run loop timers, it is possible to make the above work, but it is a bit tricky and requires multiple methods and callbacks.
Here is what the delegate callback for the search field looks like with
PARDispatchQueue. In this code, I also use a
searchIndex object that can return search results using a block-based method call
query:resultBlock: (the implementation of which is not shown):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
Blocks make it easy to have all the code in one place, and the timer APIs of
PARDispatchQueue make it easy to add subtle behavior to the UI with a minimal amount of fuss. The end result is less code, easier to read and easier to maintain.