Implementing a HeartbeatActor. Should I consider executorPreference

I've been reading up on the relevant proposal and my take on this is that I should definitely reach for this for my use case but I would like the opinions of the professionals here.

I'd like to implement a HeartbeatActor that will perform some async work at some regular interval (maybe every 60 seconds). We can assume the following:

  • Will be active for the entire lifetime of the app
  • There'll be a fair amount of other async work happening when a tick occurs
  • This actor doesn't care to return anything. It's not fetching data only sending out data (This point makes me think that executor preference wouldn't matter as much)

I've never used the executor preference before so I may not understand its implications. What are the pros and cons here? Is there anything else that I should consider?

Could you elaborate on why you think you need a custom TaskExecutor here? At first glance, I don’t see why a default actor wouldn’t suffice. Also, why a TaskExecutor instead of a SerialExecutor?

1 Like

Also, why a TaskExecutor instead of a SerialExecutor ?

Granted, I may not understand the difference between the two well enough here to make a meaningful selection. Is one more suitable than the other here?

My reason for even reaching for this is me thinking this is a performance optimization. That is, if I define a specified executor I can avoid competing with UI work, animation work, database work, networking, etc.

I guess this introduces a philosophical question too. Should work be grouped by themes to a given executor i.e. database work on an executor, networking and so on. Or is this overkill.

The proposal itself puts it quite succinctly:

As an intuitive way to think about TaskExecutor and SerialExecutor, one can think of the prior as being a "source of threads" to execute work on, and the latter being something that "provides serial isolation" and is a crucial part of Swift actors. The two share similarities, however the task executor has a more varied application space.

To give some additional context, think of the TaskExecutor as a service that provides execution resources. It can either run jobs (partial tasks) directly or serve as the backing execution resource for a SerialExecutor.

For example, Swift’s default TaskExecutor, the Global Concurrent Executor (GCE), is the execution service for all non-MainActor tasks and for default actors (actors that do not implement their own executor). That default TaskExecutor owns a fixed-size pool of threads roughly equivalent to the number of CPU cores (simplified explanation).

In general, you should almost always start with this setup: default actors, and simply rely on the GCE. Custom executors are mainly useful when you need to guarantee that certain work runs on a dedicated thread, or when you perform heavy synchronous work and have determined that it causes enough congestion on the GCE that it is better to offload that work to a separate execution resource.

However, be aware that creating your own execution resource can degrade performance, since it may introduce frequent context switches (thread switches).

3 Likes

Ultimately you’re not competing for executors, you’re competing for time on CPU cores. The existing default executor will already use all available cores, so introducing new executors will not reduce contention.

5 Likes

Anything wrong with a straightforward implementation:

        Task {
            let clock = ContinuousClock()
            var time = clock.now
            while true {
                await heartbeat() // assuming this won't take more than a few seconds.
                time += .seconds(60) // otherwise modify this logic.
                try await Task.sleep(until: time)
            }
        }
2 Likes

@tera Ultimately, that's about what I landed on. I guess I'm really just shocked it's this simple.

That sounds like you just want to use a lower priority for this Task?

@nikolai.ruhe Agreed. Or higher. Anything to distinguish priority of task execution. I see now that I was trying to solve a problem for which the entire Swift concurrency architecture has been designed to solve. That being said... Now I would like to see appropriate use cases where it's desirable to fire tasks on a given executor. cc: @NotTheNHK in case you have thoughts.

1 Like

I would say the two most common, and not necessarily distinct, use cases for a custom TaskExecutor are:

  • Reducing context switches when interacting with a high-performance system that has its own execution resources.
  • Offloading work from the GCE, for example, to avoid contention when running heavy, long-running synchronous jobs.

Just to reemphasize my earlier post, keep this quote from the proposal in mind:

Applying task executors to solve a performance problem should be done after thoroughly understanding the problem an application is facing, and only then determining the right "sticky"-ness behavior and specific pieces of code which might benefit from it.

Edit: I just realized that the proposal already has a whole section covering these exact points, so consider this just a quick summary and check out that section of the proposal: Execution semantics discussion.

2 Likes