Performing a delayed and/or repeating operation in a Swift Actor

Say you want to perform some operation after a delay, and/or at regular intervals, inside a Swift actor – maybe your actor represents a weather station and you want to periodically fetch the latest weather information, automatically. How do you do that?

You could punt the problem to some other code, outside the actor – i.e. make something else periodically call some method on the actor. Sometimes this is a suitable approach, but sometimes you just want your actor to be self-contained and not require external aid. And if the work takes time anyway (e.g. network activity) what’s the point of having some external entity calling in and having to wait on your actor – easier if your actor just notifies the external entity whenever new data is available.

Timer ❌

You might think to just use Timer (formerly NSTimer) – and most of the internet would strongly encourage you to do so, based on the search results you’ll find for this topic.

Except it doesn’t work.

Timer relies on RunLoop (NSRunLoop) to actually schedule itself and be executed. RunLoops are normally created automatically in most cases and kind of just work magically in the background.

Actors don’t have runloops. More accurately – the magical, mutually-exclusive context in which an actor runs is not a RunLoop and does not interact with RunLoops. Actors use a relatively new Executor class family, but at least for now (2022) those are basically useless – literally the only method they have is to add a task to be executed as soon as possible. They provide no way to schedule a task after a delay.

Confusingly, if you access RunLoop.current from an actor context, it will return some non-nil value, but it’s useless because nothing ever runs that particular RunLoop. And how would you run it yourself – calling its run method from within the actor’s context would block the actor indefinitely and cause deadlock.

You could use the main runloop that most Swift apps have by default, but you shouldn’t – that main runloop is intended for user interaction only. You should never put random background tasks on it, as they can interfere with your UI and make your app stutter.

You could spawn your own Thread to actually run a RunLoop, but it’s unsafe – you cannot interact with any runloop except the current runloop – i.e. you have to be in a runloop in order to touch it – and you can’t get into such a runloop in an easy way from an actor context. Seemingly obvious and innocuous methods like calling RunLoop.current from actor context are dangerous because they’re not guaranteed to return the same runloop each time. Instead, you have to explicitly bridge to the runloop via some bespoke message system that you have to pre-install into that runloop. Ick.

It’s also very inefficient, if you don’t actually need to use a whole CPU core most of the time – you’ll need a dedicated thread for every actor instance, and threads aren’t completely free to create & maintain – plus there’s a limit to how many you can have alive at one time.

Dispatch ✔️

A better approach is to use Dispatch (formerly Grand Central Dispatch) – specifically the convenient methods on DispatchQueue that allow you to set up delayed and/or scheduled tasks. There’s the various async… methods for doing something once after a certain time period, and also schedule… methods which also support recurring, cancellable timers. Granted they aren’t tied to the actor’s context – you still have to bridge back into the actor via Task or similar – but at least they actually work.

e.g.:

let timer = DispatchQueue
    .global(qos: .utility)
    .schedule(after: DispatchQueue.SchedulerTimeType(.now()),
              interval: .seconds(refreshInterval),
              tolerance: .seconds(refreshInterval / 5)) { [weak self] in
    guard let self else { return }
    Task { await self.getLatestMeasurement() } // Trampoline back into the actor context.
}

// Keep "timer" around as e.g. a member variable if you want to be able to cancel it.

If you use the schedule… methods you may need to import Combine, as the Cancellable type that they return is actually defined in Combine. But you don’t have to actually use Combine otherwise.

Structured Concurrency (Task) ✔️

An alternative approach is to just use Swift Concurrency primitives. This is arguably “purer” – no need to pull in the Dispatch & Combine libraries – but requires a bit more manual labour if you need to support cancellation.

Basically you can create a new Task which just sits in a loop running the desired operation endlessly (or until cancelled), manually sleeping between executions.

e.g.:

self.timer = Task(priority: .utility) { [weak self] in
    while !Task.isCancelled {
          guard let self else { return }
          await self.getLatestMeasurement()
          try? await Task.sleep(nanoseconds: UInt64(refreshInterval * 1_000_000_000))
    }
}

It doesn’t look too bad, but keep in mind this loses some important functionality vs the Dispatch approach – there’s no way to specify a tolerance to the timer, so this is not as good for energy efficiency (in cases where you don’t need exact timing, which is most cases).

There is a new variant of the Task.sleep method potentially coming in iOS 16, macOS 13, etc which does allow you to specify a tolerance, but that of course requires a minimum deployment target of essentially 2023 or later.

Leave a Comment