The Guinea Pig in the Cocoa Mine

Underground Cocoa Experiments

Timers and Dispatch Queues

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
// if a timer is already scheduled, cancel it
[saveTimer invalidate];

// save in 5 seconds
// maybe this timer will also be canceled before it fires,
// amd the 'save' will be delayed further
saveTimer = [NSTimer scheduledTimerWithTimeInterval:5.0
                                             target:self
                                           selector:@selector(save)
                                           userInfo:nil
                                            repeats:NO];

Timers can also be used indirectly with the performSelector:... APIs:

1
2
3
4
5
6
7
8
9
10
11
// if a 'save' call is already scheduled, cancel it
[NSObject cancelPreviousPerformRequestsWithTarget:self
                                         selector:@selector(save:)
                                           object:nil];

// save in 5 seconds
// maybe this call will also be canceled before it fires,
// and the 'save' will be delayed further
[self performSelector:@selector(save:)
           withObject:nil
           afterDelay:5.0];

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
typedef NS_ENUM(NSInteger, MABGTimerBehavior)
{
    MABGTimerCoalesce,
    MABGTimerDelay
};

The 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.

Coalesce and delay

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:

Flush every time

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):

Flush every minute

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:

Flush with delay and coalesce

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:

Flush with timers

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.

Hence PARDispatchQueue.

PARDispatchQueue

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
- (BOOL)scheduleTimerWithName:(NSString *)name
                 timeInterval:(NSTimeInterval)delay
                     behavior:(PARTimerBehavior)behavior
                        block:(PARDispatchBlock)block;
- (void)cancelTimerWithName:(NSString *)name;
- (void)cancelAllTimers;

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):

1
 indexQueue = [PARDispatchQueue dispatchQueueWithLabel:@"skindex"];

Here is what we do when adding a document:

1
2
3
4
5
6
7
8
- (void)addDocument:(NSString *)identifier content:(NSString *)content
{
    // ... prepare `document` and `string` ...
    SKIndexAddDocumentWithText(indexRef, document, string, 1);
    CFRelease(string);
    CFRelease(document);
    [self flushWhenNeeded];
}

The implementation of flushWhenNeeded is where the timers appear:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)flushWhenNeeded
{
    [self.indexQueue scheduleTimerWithName:@"flush_coalesce"
                              timeInterval:60.0
                                  behavior:FDTimerBehaviorCoalesce
                                     block:^{ [self _flush]; }];

    [self.indexQueue scheduleTimerWithName:@"flush_delay"
                              timeInterval:5.0
                                  behavior:FDTimerBehaviorDelay
                                     block:^{ [self _flush]; }];
}

Finally, the _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
- (void)_flush
{
    SKIndexFlush(self.indexQueue);
    [self.indexQueue cancelTimerWithName:@"flush_coalesce"];
    [self.indexQueue cancelTimerWithName:@"flush_delay"];
}

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
- (IBAction)searchFieldDidChange:(id)sender
{
    NSString *query = self.searchField.stringValue;

    // Ignore empty queries
    if ([query length] == 0)
    {
        [self showAll];
        return;
    }

    // Use a serial queue, so only one query will run at a time
    // Start searching when the user seems to be done typing (after 0.2 seconds)
    [self.searchQueue scheduleTimerWithName:@"query"
                               timeInterval:0.2
                                   behavior:FDTimerBehaviorDelay
                                      block:^
      {
          // Spinning indicator will start if the query is still running after 0.5 secs
          NSString *sessionUUID = [[NSUUID UUID] UUIDString];
          FDDispatchQueue *mainQueue = [FDDispatchQueue mainDispatchQueue]
          [mainQueue scheduleTimerWithName:sessionUUID
                              timeInterval:0.5
                                  behavior:FDTimerBehaviorDelay
                                     block:^{ [self showSearchIndicator]; }];

          // Run query
          // Results are returned in batches every 0.1 seconds
          [self.searchIndex query:query batchTimeInterval:0.1 resultBlock:^BOOL(NSSet *results, BOOL hasMore)
           {
               __block BOOL shouldContinue = YES;
               [mainQueue dispatchSynchronously:^
                {
                    NSString *currentQuery = self.searchField.stringValue;
                    // We will bail out if the query has changed in the meantime
                    shouldContinue = [query isEqualToString:currentQuery];
                    [self showResults:results];
                }];
               return shouldContinue;
           }];

          // Stop spinning indicator (and maybe it was never started in the first place)
          [mainQueue cancelTimerWithName:sessionUUID];
          [mainQueue dispatchAsynchronously:^{ [self hideSearchIndicator]; }];
      }];
}

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.

Comments