Async function doesn’t see external changes to an inout Bool in Release build

Title Why doesn’t this async function see external changes to an inout Bool in Release builds (but works in Debug)?

Body I have a small helper function that waits for a Bool flag to become true with a timeout:

public func test(binding value: inout Bool, timeout maximum: Int) async throws {
    var count = 0
    
    while value == false {
        count += 1
        
        try await Task.sleep(nanoseconds: 0_100_000_000)
        
        if value == true {
            return
        }
        
        if count > (maximum * 10) {
            return
        }
    }
}

I call like this:

  var isVPNConnected = false
    adapter.start(tunnelConfiguration: tunnelConfiguration) { [weak self] adapterError in
      guard let self = self else { return }
      if let adapterError = adapterError {
      } else {
        isVPNConnected = true
      }
      completionHandler(adapterError) 
    }
    
    try await waitUntilTrue(binding: &isVPNConnected, timeout: 10)

What I expect:

test should keep looping until flag becomes true (or the timeout is hit).

When the second task sets flag = true, the first task should see that change and return.

What actually happens:

In Debug builds this behaves as expected: when the second task sets flag = true, the loop inside test eventually exits.

In Release builds the function often never sees the change and gets stuck until the timeout (or forever, depending on the code). It looks like the while value == false condition is using some cached value and never observes the external write.

So my questions are:

Is the compiler allowed to assume that value (the inout Bool) does not change inside the loop, even though there are await suspension points and another task is mutating the same variable?

Is this behavior officially “undefined” because I’m sharing a plain Bool across tasks without any synchronization (actors / locks / atomics), so the debug build just happens to work?

What is the correct / idiomatic way in Swift concurrency to implement this kind of “wait until flag becomes true with timeout” pattern?

Should I avoid inout here completely and use some other primitive (e.g. AsyncStream, CheckedContinuation, Actor, ManagedAtomic, etc.)?

Is there any way to force the compiler to re-read the Bool from memory each iteration, or is that the wrong way to think about it?

Environment (if it matters):

Swift: [fill in your Swift version]

Xcode: [fill in your Xcode version]

Target: iOS / macOS [fill in as needed]

Optimization: default Debug vs. Release settings

I’d like to understand why Debug vs Release behaves differently here, and what the recommended design is for this kind of async waiting logic in Swift.

Answered by DTS Engineer in 866136022
Body I have a small helper function that waits for a Bool flag to become true

That code is fundamentally unsound. You’re assuming that Swift’s inout parameters actually pass a pointer under the covers. The language doesn’t guarantee that. In fact, it’s the opposite. As the name inout implies, it copies the value in on call and copies it back out on return. That’s the expected behaviour, and it only passes around pointers when the optimiser can prove that it’s not necessary.

Oh, and there are situations where it must copy in and copy out. I discuss that concept in more detail in The Peril of the Ampersand.

Beyond that, Swift relies on the Law of Exclusivity™ [1]. For more about the gory details, search swift.org with Law of Exclusivity and you’ll find lots of stuff [2]. However, as it applies to your example, if Swift did pass your inout parameter as a pointer under the covers, that would, if you’re lucky, trigger an exclusivity trap.

I’d like to understand why Debug vs Release behaves differently here

It’s because you’re doing unsupported things, and when you do unsupported things you’ll find that the actual behaviour can vary based on all sorts of factors. I have some relevant links in this post.

If you were using the Swift 6 language mode, it’s likely that this would either have failed to compile, or possibly trapped at runtime.

What is the correct / idiomatic way in Swift concurrency to implement this … ?

Well, there are two things in play here:

  • How to deal with older APIs based on completion handlers.
  • How to wait with a timeout in general.

These are related because, to implement the second, you must implement the first in a way that supports proper cancellation.


Your second code snippet shows you calling a completion handler API. In many cases you don’t actually have to do that, because Swift imports Objective-C methods with completion handlers as async function. For example, with NEVPNManager the Objective-C -saveToPreferencesWithCompletionHandler: method is imported as both a normal method with a completion handler and as an async method:

let manager: NEVPNManager = …
manager.saveToPreferences { error in
    … check error for `nil` …
}
try await manager.saveToPreferences()

The compiler implements the latter using a continuation (either UnsafeContinuation or CheckedContinuation).

This is very convenient, but there’s one critical drawback: There’s no automatic cancellation. If you call the async method and then your task gets cancelled, that cancellation request is not routed through to NEVPNManager. That’s because there’s no standard way to implementat cancellation in the Objective-C completion handler world.

There are two ways to implement cancellation:

  • Polling
  • With a cancellation handler

Polling makes sense when you’re doing something CPU bound. You add a periodic check for cancellation like so:

try Task.checkCancellation()

For example, a function doing movie transcoding might run this check between each frame.

A cancellation handler makes sense when you’re bridging to some other async mechanism. The basic structure looks like this:

await withTaskCancellationHandler {
    … the code to run the operation …
} onCancel: {
    … the code to cancel it …
}

You often combined this with a (hopefully checked) continuation.

Of course, that assumes that the underlying API has a cancellation mechanism. If it doesn’t, your options are limited. In some cases you can get away with the abandon-the-result cancellation strategy. That is, arrange for the operation to complete immediately and then, when the actual completion handler is called, just ignore the result. I’ve included an example of how you might do that at the end of this post.

But that’s not appropriate for all situations. For example, it’d be a very bad idea to apply this to the NEVPNManager snippet I showed earlier, because it’d leave the NEVPNManager in the saving state. If you then started some other operation on that same object, it’s hard to say what’ll happen.


With regards implementing timeouts, the basic strategy here is to start an async task and cancel it if it takes too long. This requires you to wait for it to complete after that cancellation, which is why cancellation is so important.

This isn’t something I’ve implemented myself, but there’s a long-running thread on Swift Forums with code snippets, links to open source implementations, and lots of interesting discussion.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Just kidding about the ™ thing (-:

[2] Make sure to include the forums. I typically use a scoped search, like this:

https://duckduckgo.com/?q=site%3Aswift.org+law+of+exclusivity

Body I have a small helper function that waits for a Bool flag to become true

That code is fundamentally unsound. You’re assuming that Swift’s inout parameters actually pass a pointer under the covers. The language doesn’t guarantee that. In fact, it’s the opposite. As the name inout implies, it copies the value in on call and copies it back out on return. That’s the expected behaviour, and it only passes around pointers when the optimiser can prove that it’s not necessary.

Oh, and there are situations where it must copy in and copy out. I discuss that concept in more detail in The Peril of the Ampersand.

Beyond that, Swift relies on the Law of Exclusivity™ [1]. For more about the gory details, search swift.org with Law of Exclusivity and you’ll find lots of stuff [2]. However, as it applies to your example, if Swift did pass your inout parameter as a pointer under the covers, that would, if you’re lucky, trigger an exclusivity trap.

I’d like to understand why Debug vs Release behaves differently here

It’s because you’re doing unsupported things, and when you do unsupported things you’ll find that the actual behaviour can vary based on all sorts of factors. I have some relevant links in this post.

If you were using the Swift 6 language mode, it’s likely that this would either have failed to compile, or possibly trapped at runtime.

What is the correct / idiomatic way in Swift concurrency to implement this … ?

Well, there are two things in play here:

  • How to deal with older APIs based on completion handlers.
  • How to wait with a timeout in general.

These are related because, to implement the second, you must implement the first in a way that supports proper cancellation.


Your second code snippet shows you calling a completion handler API. In many cases you don’t actually have to do that, because Swift imports Objective-C methods with completion handlers as async function. For example, with NEVPNManager the Objective-C -saveToPreferencesWithCompletionHandler: method is imported as both a normal method with a completion handler and as an async method:

let manager: NEVPNManager = …
manager.saveToPreferences { error in
    … check error for `nil` …
}
try await manager.saveToPreferences()

The compiler implements the latter using a continuation (either UnsafeContinuation or CheckedContinuation).

This is very convenient, but there’s one critical drawback: There’s no automatic cancellation. If you call the async method and then your task gets cancelled, that cancellation request is not routed through to NEVPNManager. That’s because there’s no standard way to implementat cancellation in the Objective-C completion handler world.

There are two ways to implement cancellation:

  • Polling
  • With a cancellation handler

Polling makes sense when you’re doing something CPU bound. You add a periodic check for cancellation like so:

try Task.checkCancellation()

For example, a function doing movie transcoding might run this check between each frame.

A cancellation handler makes sense when you’re bridging to some other async mechanism. The basic structure looks like this:

await withTaskCancellationHandler {
    … the code to run the operation …
} onCancel: {
    … the code to cancel it …
}

You often combined this with a (hopefully checked) continuation.

Of course, that assumes that the underlying API has a cancellation mechanism. If it doesn’t, your options are limited. In some cases you can get away with the abandon-the-result cancellation strategy. That is, arrange for the operation to complete immediately and then, when the actual completion handler is called, just ignore the result. I’ve included an example of how you might do that at the end of this post.

But that’s not appropriate for all situations. For example, it’d be a very bad idea to apply this to the NEVPNManager snippet I showed earlier, because it’d leave the NEVPNManager in the saving state. If you then started some other operation on that same object, it’s hard to say what’ll happen.


With regards implementing timeouts, the basic strategy here is to start an async task and cancel it if it takes too long. This requires you to wait for it to complete after that cancellation, which is why cancellation is so important.

This isn’t something I’ve implemented myself, but there’s a long-running thread on Swift Forums with code snippets, links to open source implementations, and lots of interesting discussion.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Just kidding about the ™ thing (-:

[2] Make sure to include the forums. I typically use a scoped search, like this:

https://duckduckgo.com/?q=site%3Aswift.org+law+of+exclusivity

This is the code that I mentioned in the previous. I split it out because the post exceed the character limit O-:

IMPORTANT The following code compiles and I’ve done some limited testing. However, you should not assume that it’s production ready.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


Imagine you have a multiply function that passes the operation to external hardware. This was written back in the day, so it uses a completion handler:

func multiply(l: Int, r: Int, completionHandler: @Sendable @escaping (_ result: Result<Int, Error>) -> Void) {
    … actual code here …
}

To turn this into an async function, use a continuation:

func multiplyAsync(l: Int, r: Int) async throws -> Int {
    try await withCheckedThrowingContinuation { continuation in
        multiply(l: l, r: r) { result in
            continuation.resume(with: result)
        }
    }
}

However, this doesn’t support cancellation. To do that, using the abandon-the-result cancellation strategy, add a cancellation handler:

func multiplyAsyncWithAbandoningCancellation(l: Int, r: Int) async throws -> Int {
    let continuationQ = Mutex<CheckedContinuation<Int, Error>?>(nil)

    @Sendable func complete(with result: Result<Int, Error>) {
        let yielderQ = continuationQ.withLock { lockedValue in
            defer { lockedValue = nil }
            return lockedValue
        }
        guard let yielder = yielderQ else {
            // Someone has already completed this operation.  Do nothing.
            return
        }
        // We won the completed/cancelled race; yield `result`.
        yielder.resume(with: result)
    }

    let result = try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Int, Error>) in
            continuationQ.withLock { lockedValue in
                lockedValue = continuation
            }
            // It’s possible that the cancellation handler run /before/ we
            // installed our continuation into `continuationQ`, so check for
            // cancellation now, before we kick off the async work.
            guard !Task.isCancelled else {
                complete(with: .failure(CancellationError()))
                return
            }
            multiply(l: l, r: r) { result in
                complete(with: result)
            }
        }
    } onCancel: {
        complete(with: .failure(CancellationError()))
    }

    return result
}

Isn’t that fun!?! (-:

It’s reasonably easy to make this generic, so that you can use it to adapt any completion-handler routine to one that’s cancellable via the abandon-the-result cancellation strategy:

func callWithAbandoningCancellation<R: Sendable>(_ start: (_ completionHandler: @Sendable @escaping (_ result: Result<R, Error>) -> Void) -> Void) async throws -> R {
    let continuationQ = Mutex<CheckedContinuation<R, Error>?>(nil)

    @Sendable func complete(with result: Result<R, Error>) {
        let yielderQ = continuationQ.withLock { lockedValue in
            defer { lockedValue = nil }
            return lockedValue
        }
        guard let yielder = yielderQ else {
            // Someone has already completed this operation.  Do nothing.
            return
        }
        // We won the completed/cancelled race; yield `result`.
        yielder.resume(with: result)
    }

    let result = try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<R, Error>) in
            continuationQ.withLock { lockedValue in
                lockedValue = continuation
            }
            // It’s possible that the cancellation handler run /before/ we
            // installed our continuation into `continuationQ`, so check for
            // cancellation now, before we kick off the async work.
            guard !Task.isCancelled else {
                complete(with: .failure(CancellationError()))
                return
            }
            start() { result in
                complete(with: result)
            }
        }
    } onCancel: {
        complete(with: .failure(CancellationError()))
    }

    return result
}
Async function doesn’t see external changes to an inout Bool in Release build
 
 
Q