We're working on an Endpoint Security extension and using Swift 6 with Concurrency. I've heard there are some subtleties to getting the threading right across those two domains and am hoping that someone can help shed light on it. In particular, ES events can be delivered on a high priority thread and I'd like to be sure that any work done in the Concurrency domain retains that priority to minimize latency between event delivery and response.
ES event thread playing nicely with Swift Concurrency
The guaranteed way to avoid problems here is to do your work using synchronous functions. That ensures that you never leave the thread that called the ES client’s message handler.
If you need to do IPC, you can do that using synchronous XPC [1], which ensures that the priority of that thread gets donated to the receiver.
If you have to use Swift concurrency then things get a lot more complex. It’s hard to offer good answers without knowing what you’re doing in your asynchronous functions. There are two common reasons to go async:
- Parallelism
- I/O [2]
I suspect you’re interested in the latter, and that’s by far the most complicated. Lemme use an example to illustrate that.
Let’s say your ES client needs to issue an HTTP request to respond to a message. The natural API for that is URLSession, which exposes a bunch of Swift async methods. But under the covers those method simply call through to the core URLSession implementation, which effectively bounces to a networking thread (currently that thread is named com.apple.NSURLSession-work). There’s no priority donation there. That thread runs at its standard priority. So, regardless of what you do on the Swift concurrency side, your response to the ES message is gonna end up waiting behind that networking thread.
As to what you can do on the Swift concurrency side, you have a bunch of options:
- Task priority (SE-0304)
- The fun’n’games associated with SE-0338 and the update in SE-0461
- Custom actor executors (SE-0392)
- Task executors (SE-0417)
- Synchronous task startup (SE-0472)
- Priority escalation (SE-0462)
However, you need to be really careful here. The wealth of these facilities can be distracting, making you think your making progress when really you’re shuffling the problem between layers without actually resolving it. So, it’s important to understand these features, so that you have them handy when you need them, but before you start coding anything you need to really grok the problem that you’re actually trying to solve.
I maintain a ‘cheat sheet’ for Swift concurrency features that you might find helpful. It’s the only way I can keep this stuff straight in my head (-:
There’s also the Concurrency Resources pinned post, which has a lot of links to useful stuff. I specifically recommend WWDC 2017 Session 706 Modernizing Grand Central Dispatch Usage, which is very enlightening even though it far predates Swift concurrency.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
[1] So, using xpc_connection_send_message_with_reply_sync, and things layered on top of that.
[2] I’m using this in the most general sense, so it covers anything that involves waiting for an external event, including timers and IPC.
Bridging a synchronous ES-style callback into an actor
The trick is to give the actor a serial dispatch queue as its executor, so the same isolation domain is reachable two ways: normal await from Swift concurrency, and a synchronous entry from the ES handler thread. The two "magic" declarations are a stored DispatchSerialQueue and a nonisolated var unownedExecutor that hands it back — same instance every call, and it must be serial:
public actor SensorMonitor {
private let executor = DispatchSerialQueue(label: "com.example.sensor.monitor")
public nonisolated var unownedExecutor: UnownedSerialExecutor {
self.executor.asUnownedSerialExecutor() // binds actor isolation to this queue
}
private nonisolated let client: ESClient // internally thread-safe, reached from both worlds
private var state = … // ordinary actor-isolated state
}
From Swift, nothing special is needed — it's a real actor, so regular idioms just work and priority escalates across await for free (await monitor.stats()). The custom executor only matters at the ES boundary: ES calls your handler serially on its own thread, and returning from the block just dequeues the next message — responding is a separate async call you should make inline, in the handler's priority context, to avoid the priority inversion you'd get by retaining the message and answering later from a lower-priority queue. You bridge in synchronously with executor.sync, which donates the handler thread's QoS to the actor's queue (boosting any in-flight actor work so it drains), and assumeIsolated, which is sound because executor.sync proved you're on that executor:
public nonisolated func handleMsg(_ msg: ESMessage) {
self.executor.sync { // join the isolation domain, inline
self.assumeIsolated { isolatedSelf in // no await needed — provably isolated
if case let .respond(verdict) = isolatedSelf.decide(msg) {
isolatedSelf.client.respond(to: msg, result: verdict) // respond inline
}
}
}
}
The one hard rule: you can never await on the handler thread. Since the closure is synchronous suspending would drop you out of the donated-priority. Any real IO (a reputation lookup, a network round-trip) happens in the async world, and only its result re-enters the actor by mutating state; the handler then reads that state synchronously and responds. When a decision genuinely needs enrichment, launch it and let the handler return:
// slow path inside decide(...): launch async work, respond when it lands
let task = Task { [weak self] in
let verdict = await enrich(msg) // IO happens here, off the handler thread
await self?.record(verdict) // result re-enters the actor as state
self?.client.respond(to: msg, result: verdict) // out-of-order respond (retained msg)
}
return .deferred(task)