Calling a mutating async actor-isolated method (ActorIsolatedMutatingAsync)
Overview
Calling a mutating async method on actor-isolated state allows concurrent access to that state during mutation, risking data races. To resolve this error, copy the state to a local variable, call the mutating async method on the copy, then write it back. This will overwrite concurrent changes.
For example:
struct Backpack {
var snacks = 5
mutating func refill() async {
snacks += await purchaseSnacks(current: snacks)
}
}
@MainActor
class Hiker {
var backpack = Backpack()
func getReady() async {
await backpack.refill()
}
func eatSnack() {
backpack.snacks -= 1
}
}Building the above code produces an error about calling a mutating async function on an actor-isolated property:
| func getReady() async {
| await backpack.refill()
| | `- error: cannot call mutating async function 'refill()' on actor-isolated property 'backpack' [#ActorIsolatedMutatingAsync]
| `- note: 'backpack' can be concurrently accessed during mutation, risking data races
| }A mutating async function takes self as inout. This means that the value of backpack will be copied into refill, then copied out when the method returns. inout requires exclusive access for the duration of the call. While refill is suspended, the main actor is free to do other work. This is a data race, since refill is still mutating backpack. If eatSnack ran before refill finished, it would decrement snacks, but the eaten snack reappears when refill writes back the stale copy.
// Task 1: getReady() | // Task 2
await backpack.refill()
// copies backpack (snacks: 5) |
// suspends... |
eatSnack()
// | // backpack.snacks is now 4
// resumes |
// purchaseSnacks returns 0 |
// writes back backpack (snacks: 5) |
// backpack.snacks is 5 |
// eatSnack() was silently lost! |When possible, you can structure code to avoid the need for mutating async functions by separating async computation and mutation:
func getReady() async {
// await the asynchronous computation
let refill = await backpack.computeRefill()
// apply the mutation synchronously
backpack.apply(refill)
}Because the mutation is synchronous, no concurrent changes can occur between reading and writing backpack as part of apply. If eatSnack is called concurrently while computeRefill is suspended, the Hiker may purchase too few snacks, but the decrement from eating a snack won’t be lost.
If restructuring isn’t appropriate, copy backpack to a local variable to make the potential overwrite explicit at the call site. This way, the call site shows that backpack is overwritten after await, rather than happening implicitly due to inout. Because copy is a local variable, it is not shared with other tasks and can be safely mutated.
func getReady() async {
var copy = backpack
await copy.refill()
backpack = copy
}See also
See Also
@dynamicCallable implementation requirements (DynamicCallable)Add @preconcurrency import (AddPreconcurrencyImport)Always enabled availability domains (AlwaysAvailableDomain)Argument matching for trailing closures (TrailingClosureMatching)Calling an actor-isolated method from a synchronous nonisolated context (ActorIsolatedCall)Captures in a `@Sendable` closure (SendableClosureCaptures)Compilation caching (CompilationCaching)Conforming to `StringInterpolationProtocol` (StringInterpolationConformance)Conversion from `@isolated(any)` function type to synchronous function type (ConversionFromIsolatedAnyToSynchronous)Cross-isolation data race (RegionIsolationCrossIsolationDataRace)Deprecated declaration warnings (DeprecatedDeclaration)Deprecated implementation-only imports (ImplementationOnlyDeprecated)Dynamic exclusivity (DynamicExclusivity)Embedded Swift language restrictions (EmbeddedRestrictions)Existential Types and Performance (ExistentialType)