Is this concurrency warning correct?

I raised an issue the other day about this code:

        while launchOptions?.isEmpty != true {
            var s = "value"
            
            s += "?" // Invalid warning reported here "'s' mutated after capture by sendable closure"
            
            DispatchQueue.global().async {
                print(s)
            }
        }

The warning seems invalid to me and only occurs inside a loop. Surely the value should be captured by copy so subsequent modifications wouldn't matter and besides it's inside a loop so it's a new variable declared each iteration right? Am I missing something?

3 Likes

I reduced it to this for clarity (I'm on Swift 5.10, complete concurrency checks):

while true {
	var s = "value" // Warning: 's' mutated after capture by sendable closure
	DispatchQueue.global().async {
		print(s) // Warning: Reference to captured var 's' in concurrently-executing code; this is an error in Swift 6
	}
}

It becomes even clearer if you remove the loop:

var s = "value"
DispatchQueue.global().async {
	print(s) // Warning: Reference to captured var 's' in concurrently-executing code; this is an error in Swift 6
}

In Swift's concurrency philosophy, things that cross thread or actor boundaries can not be mutable because their mutation in a concurrent thread can break data integrity. Swift is just trying to help you with formal guarantees of correctness by enforcing this rule: threads and actors in Swift can only communicate with each other using immutable data (or anything Sendable).


P.S.

You are right, but I think Swift is trying to warn you that you may not know exactly which copy will be used since the variable is mutable. It is why let s fixes it.

3 Likes

I'm no expert but this sure feels like a bug to me.

In Xcode 16.1b1 you don't get the warning unless it is in a loop. My current theory looking at the code around where the warning is generated is this is a SILOptimiser stage run amok that thinks it can/must capture by reference which is never the case for an escaping closure surely - leaving concurrency to one side for now. The new concurrency warning is just picking this up. The evidence for this is if you force the value copy using the [s] in syntax all the warnings go away. Why is it suddenly talking about "Reference to captured var"? Value types used-to capture by copy particularly for @escaping closures.

1 Like

You pass a reference to a local variable. Warning is correct and explicit capture with [s] as suggested is a valid solution: in that case if inside async block you’ll modify it, it won’t affect outside scoped variable that can be used after.

Your example here can be considered as valid since you don’t mutate or access s after passing to async block, maybe compiler can diagnose this with region-based isolation. Yet in general that’s not safe, and your code on GitHub you reference within the issue doesn’t seem to be valid: IIUC you mutate property there, which can cause concurrency issues.

I'm no expert either and not entirely sure, but in case someone can explain this, here is even more simplified code without any real concurrency that triggers an error (!) instead of a warning in Swift 5.10:

func exec(closure: @Sendable () -> Void) {
	closure()
}

while true {
	var i = 0
	exec {
		print(i) // Error: Reference to captured var 'i' in concurrently-executing code
	}
}

Notice how the closure is not even escaping.

My first reaction to it was that the compiler's demand is legitimate, you need to pass a let value to guarantee correctness of your code, but I'm not sure anymore.

Since when were escaping closures passing a reference to a "local" (stack allocated) variable? How can that make sense?

Since always, because you access local variable and can mutate it inside:

var x = 0
DispatchQueue.global().async { 
    x = 10
}
x *= 5
print(x) // can be either 0 or 10, or even 50 (rarely, I guess)

You mutate a local variable here, no matter is it value or reference type.

1 Like

I think compiler acts conservatively here since it knows that closure is @Sendable, so it can be called from any isolation. Not sure if there is a possibility to address this, since that’s requires a compiler to relate call site and function body.

This prints 0 for me. You can modify the captured var copy of x but never the original "local" var x surely if it is a value type because it is captured by copy in the same way the following prints 0:

var x = 0
var c = x
c = 10
print(x)

There is no guarantee it will print 0 in all cases. Add sleep before printing and you’ll see the issue more prominently.

1 Like

This is the nub of the misunderstanding. My whole model for how escaping closures work will collapse if it were ever possible the closure could modify x. If x was a top level var maybe it could and you could expect a warning but this is a stack allocated local variable. Its a bug.

In a case like this where a closure captures a mutable local variable that variable is specifically not allocated on the stack and instead lives in a heap-allocated box for storing the capture context. E.g.:

func makeCounter() -> () -> Int {
    var x = 0
    return {
        x += 1
        return x
    }
}

let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2
10 Likes

Why then the compiler says it mutates:

29 |         DispatchQueue.global().async {
30 |             x = 10
   |             `- error: mutation of captured var 'x' in concurrently-executing code
31 |         }

And if I add sleep before printing:

var x = 0
DispatchQueue.global().async { 
    x = 10
}
x *= 5
Thread.sleep(forTimeInterval: 3)
print(x)

I'd get:

10

:exploding_head: Thanks for these replies. I'm one step closer to knowing Swift after 10 years. The warning still seems a bit fishy to me as this gives a warning (Xcode 16.1 beta1):

        var i = 5
        while i > 0 {
            var x = "value"
            x += "?" // Warning: 'x' mutated after capture by sendable closure
            DispatchQueue.global().async {
                print(x)
            }
            Thread.sleep(forTimeInterval: 3)
            print(x)
            i -= 1
        }

But this does not:

        var i = 5
        if i > 0 {
            var x = "value"
            x += "?"
            DispatchQueue.global().async {
                print(x)
            }
            Thread.sleep(forTimeInterval: 3)
            print(x)
            i -= 1
        }
1 Like

With Xcode 15 beta 6 toolchain both examples give me a warning in .v5/error in .v6. Plus with .v6 warning from the first snippet has gone.