@isolated(any) Function Types
What is @isolated(any)
@isolated(any)
is a function attribute that preserves the isolation information of a function.
@MainActor
func updateUI() {
print("I’m main actor isolated")
}
let f: @isolated(any) () -> Void = updateUI
print(f.isolation) // Optional(Swift.MainActor)
When you set a function defined with @isolated(any)
the isolation information is no longer type-erased, and can be queried through the property isolation: (any Actor)?
. The type reflects the three possible isolations of Swift 6 functions:
- non-isolated (
nil
) - isolated to an actor
- or isolated to global actor
You can operate with this type as usual in ifs, switch, etc
if let isolation: any Actor = fn.isolation {
print(isolation)
}
switch fn.isolation {
case nil:
print("Function is non-isolated")
case let actor?:
print("Function is isolated to: \(actor)")
}
The primary motivation for @isolated(any)
is to make smart scheduling decisions. Before @isolated(any)
, Task creation APIs started on the global executor and then hopped on the appropriate actor:
Task {
// at this point we are on the global executor
// and about to hop to myActor
await myActor.work()
}
But with @isolated(any)
work is synchronously enqueued on the appropriate executor. Benefits:
- Better performance: no unnecessary executor hops
- Ordering guarantees: tasks enqueue in creation order on the actor
Always await
Functions that carry @isolated(any)
are always called with await
.
When you call an @isolated(any)
function, the compiler doesn’t know if:
- It’s non-isolated (would run immediately)
- It’s isolated to the current actor (would run immediately)
- It’s isolated to a different actor (needs to hop to that actor)
Since the compiler can’t know which case applies, it must assume the worst case - that the function needs to hop to a different actor. Because of this:
- The call requires await because switching actors is an async operation
- It’s treated as crossing an isolation boundary - with all the sendability requirements that entails.
Synchronous functions are no exception, if they carry @isolated(any)
they must be awaited:
func executeOperation(_ operation: @isolated(any) () -> String) async {
await operation()
}
@MainActor
func op() -> String {
""
}
await executeOperation(op)
Sendability rules
The rules for sendability are nuanced:
- The function must be Sendable if it’s async and the context isn’t known to be non-isolated
- Arguments and results follow standard sendability rules for cross-isolation calls
- Non-async
@isolated(any)
functions in non-isolated contexts don’t require sendability
Before @isolated(any)
, passing functions across isolation boundaries had three drawbacks:
- The function type had to be async to switch isolation internally
- Non-Sendable values couldn’t be passed even when safe
- The isolation was completely erased with no way to recover it
Examples
Each task starts on operation’s preferred executor:
func parallelMap<T, U>(
_ items: [T],
operation: @isolated(any) (T) async -> U
) async -> [U] {
await withTaskGroup(of: (Int, U).self) { group in
for (index, item) in items.enumerated() {
group.addTask {
(index, await operation(item))
}
}
// ... collect results
}
}
Handlers that complete on the preferred caller’s isolation:
struct NetworkEventHandler: EventHandler {
func handle(
event: Event,
completion: @isolated(any) () -> Void
) {
Task {
// Process on background
await processEvent(event)
// Complete on caller's isolation
completion()
}
}
}
Other examples:
- Library authors writing task-spawning APIs
- Framework developers building concurrency abstractions
- Performance-critical code avoiding unnecessary executor hops
- Debugging tools that need isolation awareness
Can’t schedule
While @isolated(any)
exposes isolation information, it doesn’t provide APIs to schedule work on that isolation from synchronous contexts. The Swift runtime has this capability internally but doesn’t expose it publicly.
This means you can inspect isolation and pass it to APIs like Task.init, but you can’t implement your own scheduling on dynamic isolation. For library authors needing this capability, the current workaround is to use @MainActor
or require explicit isolation.
Here’s an imaginary example of what we can’t do but would like to:
func withObservationTracking<T>(_ apply: () -> T, onChange: () -> Void) { ... }
func observeValue<T>(_ operation: @isolated(any) () -> T) -> AsyncStream<T> {
AsyncStream { continuation in
withObservationTracking {
_ = operation()
} onChange: {
let isolation = operation.isolation
// I would like to do this, but I can’t dynamically schedule
// on an isolation that is only known at runtime.
isolation.schedule { // 'schedule' does not exist
let newValue = operation()
continuation.yield(newValue)
}
// instead, I can always schedule in main
// whether my clients like it or not
Task { @MainActor in
let newValue = await operation()
continuation.yield(newValue)
}
}
}
}
// I wanted observations to run on this actor but can’t
actor DataModel {
var count = 0
func getCount() -> Int {
count
}
}
let model = DataModel()
let stream = observeValue(model.getCount)
// I can see you are calling with actor X,
// but I can’t dynamically schedule on it.
Links
- @isolated(any) I wrote mine after reading this to understand it better
- Observations26 also because I couldn’t schedule this on arbitrary actors
- SE-0431 @isolated(any) Function Types