Swift's region based isolation log
In SE-414, Swift introduced ‘region based isolation’ analysis (RBI) to improve the sophistication and usability of its compile time data race safety checking. This mechanism performs data flow analysis on functions to ensure that program state cannot be shared in ways that may introduce data races. RBI has been a great improvement to Swift’s concurrency model as it eases the burden on developers to annotate many of their types as Sendable
, and generally allows the compiler to understand more code patterns that are concurrency-safe, but for which that fact is not expressed via the type system.
When RBI analysis determines code is not safe, the precise reason why can sometimes be difficult to discern. The error feedback provided by the compiler has been steadily improving over time, but sometimes one wants to go deeper to see what’s going on ‘under the hood’.
Luckily, there is a setting that can be used which will cause the RBI infrastructure to emit a lot of extra information about the decisions it makes as it’s running. In ‘assert’ compilers (i.e. development compilers built with assertions enabled), one can add the following option to a swiftc
invocation to see this output:
-Xllvm -sil-regionbasedisolation-log=<none|on|verbose>
Consider the following code:
final class NS {}
@MainActor
func useOnMain(_ ns: NS) {}
func send(_ ns: NS) async {
await useOnMain(ns)
|- error: sending 'ns' risks causing data races
`- note: sending task-isolated 'ns' to main actor-isolated global function 'useOnMain' risks causing data races between main actor-isolated and task-isolated uses
}
If you were to compile this with the verbose
RBI log output enabled, you’d get something like the following before the sendability error is emitted (raw output run through swift-demangle to make some of the symbols a bit easier to read):
╾──────────────────────────────╼
Performing Dataflow!
╾──────────────────────────────╼
Values!
%%0: TrackableValue. State: TrackableValueState[id: 0][is_no_alias: no][is_sendable: no][region_value_kind: task-isolated].
Rep Value: %0 = argument of bb0 : $NS // users: %9, %1
%%1: TrackableValue. State: TrackableValueState[id: 1][is_no_alias: no][is_sendable: yes][region_value_kind: disconnected].
Rep Value: %2 = enum $Optional<Builtin.Executor>, #Optional.none!enumelt // users: %11, %3
%%2: TrackableValue. State: TrackableValueState[id: 2][is_no_alias: no][is_sendable: no][region_value_kind: main actor-isolated].
Rep Value: // function_ref useOnMain(_:)
%4 = function_ref @output.useOnMain(output.NS) -> () : $@convention(thin) (@guaranteed NS) -> () // user: %9
// ...snip...
Block: bb0
Visiting Preds!
Applying: assign_fresh %%2: // function_ref useOnMain(_:)
%4 = function_ref @output.useOnMain(output.NS) -> () : $@convention(thin) (@guaranteed NS) -> () // user: %9
Before: [(0)]
After: [(0)(2)]
Applying: require %%0: %9 = apply %4(%0) : $@convention(thin) (@guaranteed NS) -> ()
Before: [(0)(2)]
After: [(0)(2)]
Applying: send %%0: %9 = apply %4(%0) : $@convention(thin) (@guaranteed NS) -> ()
Before: [(0)(2)]
After: [(0)(2)]
Working Partition: [(0)(2)]
Exit Partition: [(0)(2)]
Updated Partition: yes
===> PROCESSING: output.send(output.NS) async -> ()
Emitting diagnostics for function output.send(output.NS) async -> ()
Walking blocks for diagnostics.
|--> Block bb0
Entry Partition: [(0)]
Applying: assign_fresh %%2: // function_ref useOnMain(_:)
%4 = function_ref @output.useOnMain(output.NS) -> () : $@convention(thin) (@guaranteed NS) -> () // user: %9
Before: [(0)]
After: [(0)(2)]
Applying: require %%0: %9 = apply %4(%0) : $@convention(thin) (@guaranteed NS) -> ()
Before: [(0)(2)]
After: [(0)(2)]
Applying: send %%0: %9 = apply %4(%0) : $@convention(thin) (@guaranteed NS) -> ()
Before: [(0)(2)]
Emitting Error. Kind: Sent Non Sendable
ID: %%0
Rep: %0 = argument of bb0 : $NS // users: %9, %1
Dynamic Isolation Region: task-isolated
Isolated Value: %0 = argument of bb0 : $NS // users: %9, %1
Isolated Value Name: ns
After: [(0)(2)]
Exit Partition: [(0)(2)]
The analysis operates on SIL, so a bit of reverse engineering is sometimes necessary to map back to what’s going on in the corresponding source code. The ‘Values!’ section enumerates the state that is tracked and may matter in the analysis. My interpretation of what this is telling us is roughly:
final class NS {}
@MainActor
func useOnMain(_ ns: NS) {}
func send(_ ns: NS) async {
// Info from 'Values!' section:
//
// ns => id '0', task-isolated, non-sendable
// useOnMain() => id '2', main-actor-isolated, non-sendable
// Region partition info:
//
// [(0)] - ns starts in its own disconnected region
// [(0)(2)] - Before applying useOnMain to ns, the function ref
// is treated as a value in a disconnected region (it is main
// actor-isolated)
// The 'Applying: send %%0...' step attempts to merge (0) and (2)
// into the same region, but this cannot be done because (0) is
// non-sendable and task-isolated, and (2) is in the main-actor's
// region. Thus the compiler emits an error.
await useOnMain(ns)
|- error: sending 'ns' risks causing data races
`- note: sending task-isolated 'ns' to main actor-isolated global function 'useOnMain' risks causing data races between main actor-isolated and task-isolated uses
}
To check out the full output for this example yourself, you can inspect it more closely via godbolt.
I’ve found this log output to be quite useful when trying to build intuition around how RBI works, and why it produces the errors it does (or fails to do so). May it be of use to you too, dear reader!