Mutation of captured a var in concurrently-executing code, racing, how?

Hello everyone,

I got an error/warning of 'Mutation of captured a var in concurrently-executing code.' How does the compiler see the race condition happen? Maybe there's a fundamental misunderstanding of how Task works in swift, on my end.

Here's are some code snippets

actor

actor BackgroundBatchActor {
    func act(input: String, action: @Sendable (String) -> String) {
        let value = input + UUID().uuidString
        let result = action(value)
        print(result)
    }
}

callsite:

Task {
    var message: String? = nil
    await actor.act(input: "very important message", action: {
        message = $0   //<< warning/error
        return $0
    })
    // more code...
}

My current understanding:

  • When a task is created, it will have its own stack
  • once an await happens, it will suspend the task. Since task's stack frame is not publicly available, there should not been two threads modifying the stack frame.

What's a good alternative for copying data out of a @Sendable closure?

The compiler must treat act opaquely.
It is possible in the implementation of act, you store action to a member variable, and that can be called at any time in the future. Now, if message is used later in the caller side (in your // more code part), or your act implementation decides to invoke action multiple times concurrently, than there would easily be a race (note that message is captured by reference).

3 Likes

A var captured by an escaping closure is stored in a box on the heap, so in your case, the two concurrently-executing functions could then both simultaneously access the value stored inside the box.

3 Likes

Technically action is non-escaping, so you cannot actually do that. However, you could instead imagine that actor.act() runs the closure in two different tasks simultaneously, waits for them to finish, and then returns, which will race.

3 Likes

As Task requires an escaping closure, I think that's impossible either?

actor BackgroundBatchActor {
    func act2(input: String, action: @Sendable (String) -> String) async {
        let value = input + UUID().uuidString
        let result = action(value)
        print(result)
        
        let t1 = Task {
            _ = action(value)  // error: escaping closure captures non-escaping parameter 'action'
        }
        await t1.result
    }
}
1 Like

in your given example, perhaps the actor's method could return the data you want. that way you could just assign the value to your message variable:

actor BackgroundBatchActor {
    func act(input: String, action: @Sendable (String) -> String) -> String {
        let value = input + UUID().uuidString
        let result = action(value)
        print(result)
        return result
    }
}

Task {
    let message: String? = await actor.act(input: "very important message", action: {
        return $0
    })
    // more code...
}

if you need the value to update, and that may happen across isolation boundaries, you could store it in a threadsafe 'box' by using Mutex or something equivalent:

import Synchronization

Task {
    let message = Mutex<String?>(nil)
    await actor.act(input: "very important message", action: { 
        message.withLock { [msg = $0] in $0 = msg }
        return $0
    })
    // more code...
}

or if you know that there is no actual risk of racing on the variable, then you can unsafely opt out of the compiler's concurrency checking in this particular case by marking the variable as nonisolated(unsafe), which will suppress the error:

Task {
    nonisolated(unsafe) var message: String? = nil
    await actor.act(input: "very important message", action: {
        message = $0   // âś…
        return $0
    })
    // more code...
}

as the unsafe parameter in the annotation indicates however, that is a potentially memory-unsafe thing to do, so you may wish to opt for another approach.

interestingly, async let seems to allow the thing Slava was describing (though it also requires modifying the actor's method to be async), which will induce a race:

actor BackgroundBatchActor {
    func act(input: String, action: @Sendable (String) -> String) async {
        let value = input + UUID().uuidString
        let result = action(value)
        print(result)

        async let v1 = { action(value) }()
        async let v2 = { action(value) }()
        _ = await (v1, v2)
    }
}
4 Likes

How about this :-)

func f(_ fn: @Sendable () -> Int) async -> (Int, Int) {
  async let x = fn()
  async let y = fn()

  return await (x, y)
}

@hborla also pointed out offline that Dispatch.sync is another option:

import Foundation

let q = DispatchQueue(label: "a")

func f(_ fn: @Sendable () -> ()) {
  q.sync { fn() }
}
3 Likes

Besides the ways mentioned above, we always have the async form of withoutActuallyEscaping:

// a mimic to Slava's example
func f(_ fn: @Sendable () -> Int) async -> (Int, Int) {
  await withoutActuallyEscaping(fn) { fn in
    let t1 = Task { fn() }
    let t2 = Task { fn() }
    return await(t1.value, t2.value)
  }
}

This use is semantically safe, as sendability and escapability are orthogonal concepts.

3 Likes

I'm no swift compiler hacker. Is this "box" a real type, internal or external to swift? like how lisp handles it? Are you talking about a metaphorical box?

I love the solution by adding a return type for act. This seems to me to be the cleanest

1 Like

Thanks. I find a bug by combining this approach and inout. The code compiles but the output in task closure is modified concurrently.

actor MyActor {
    func foo(output: inout Int, fn: @Sendable (Int, inout Int) -> Void) async {
        async let _ = fn(1, &output)
        async let _ = fn(2, &output) // output is accessed concurrently
    }
}

let actor = MyActor()

Task {
    var output: Int = 0
    await actor.foo(output: &output) {
        $1 = $0
    }
}

I filed #83100.

2 Likes

It’s “real” but internal. A “box” is our jargon for a heap-allocated buffer large enough to store a value.

Yes, Lisp systems implement mutable lexical bindings (like Swift’s var) in a similar way, by allocating a box large enough to hold a single value, and passing a pointer to the box. The key difference is that Lisp implementations usually use a uniform representation of values, so any value that is too large to fit in a pointer has to be boxed.

2 Likes