Mutation of captured var in concurrently-executing code
Asked Answered
P

3

24

I had an issue in Swift 5.5 and I don't really understand the solution.

import Foundation

func testAsync() async {

    var animal = "Dog"

    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        animal = "Cat"
        print(animal)
    }

    print(animal)
}

Task {
    await testAsync()
}

This piece of code results in an error

Mutation of captured var 'animal' in concurrently-executing code

However, if you move the animal variable away from the context of this async function,

import Foundation

var animal = "Dog"

func testAsync() async {
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        animal = "Cat"
        print(animal)
    }

    print(animal)
}

Task {
    await testAsync()
}

it will compile. I understand this error is to prevent data races but why does moving the variable make it safe?

Penance answered 9/11, 2022 at 9:50 Comment(1)
I see so probably this is just a feature in the compiler. How would you rewrite this piece of code to be concurrency safe?Penance
M
18

Regarding the behavior of the globals example, I might refer you to Rob Napier’s comment re bugs/limitations related to the sendability of globals:

The compiler has many limitations in how it can reason about global variables. The short answer is “don't make global mutable variables.” It‘s come up on the forums, but hasn‘t gotten any discussion. https://forums.swift.org/t/sendability-checking-for-global-variables/56515

FWIW, if you put this in an actual app and change the “Strict Concurrency Checking” build setting to “Complete” you do receive the appropriate warning in the global example:

Reference to var 'animal' is not concurrency-safe because it involves shared mutable state

This compile-time detection of thread-safety issues is evolving, with many new errors promised in Swift 6 (which is why they’ve given us this new “Strict Concurrency Checking” setting so we can start reviewing our code with varying levels of checks).

Anyway, you can use an actor to offer thread-safe interaction with this value:

actor AnimalActor {
    var animal = "Dog"
    
    func setAnimal(newAnimal: String) {
        animal = newAnimal
    }
}

func testAsync() async {
    let animalActor = AnimalActor()
    
    Task {
        try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
        await animalActor.setAnimal(newAnimal: "Cat")
        print(await animalActor.animal)
    }

    print(await animalActor.animal)
}

Task {
    await testAsync()
}

For more information, see WWDC 2021’s Protect mutable state with Swift actors and 2022’s Eliminate data races using Swift Concurrency.


Note, in the above, I have avoided using GCD API. The asyncAfter was the old, GCD, technique for deferring some work while not blocking the current thread. But the new Task.sleep (unlike the old Thread.sleep) achieves the same behavior within the concurrency system (and offers cancelation capabilities). Where possible, we should avoid GCD API in Swift concurrency codebases.

Magneton answered 10/11, 2022 at 17:53 Comment(3)
Interesting read, I always thought that global variables were operating on the main thread by default (not that I've ever used them).Sheena
Nope, there are no such assurances. The mutable global is not synchronized and is not safe.Magneton
The compiler will now warn us about mutable global states (at least with “Strict Concurrency Checking” compiler setting of “Complete”).Magneton
B
8

First of all, if you can, use structured concurrency, as the other answers suggest.

I hit a case where there is no clean structured concurrency API: A protocol that requires to return a value non-async.

protocol Proto {
    func notAsync() -> Value
}

To compute the Value, async method calls are needed. I settled for this solution:

func someAsyncFunc() async -> Value {
    ...
}

class Impl: Proto {
    func notAsync() -> Value {
        return UnsafeTask {
            await someAsyncFunc()
        }.get()
    }
} 

class UnsafeTask<T> {
    let semaphore = DispatchSemaphore(value: 0)
    private var result: T?
    init(block: @escaping () async -> T) {
        Task {
            result = await block()
            semaphore.signal()
        }
    }

    func get() -> T {
        if let result = result { return result }
        semaphore.wait()
        return result!
    }
}

You can copy past the UnsafeTask class and use it in your code if you hit the same case.

I consider this quite an ugly solution, eg: The type needs to be a class because structs get concurrency-checked, which means that the compiler errors on the concurrent access to both semaphore and result. As far as I know, semaphore should be thread safe and the result is only written to from one context and read from by the rest. In case T is pointer-sized or smaller, the write is atomic and thus 'safe'. In other cases it may not be safe. Though I might be overlooking some concurrency edge case. Open for suggestions.

Bambara answered 2/5, 2023 at 17:7 Comment(1)
follow up question - as I am running into something like this and implemented something similar. Why do you say : "In case T is pointer-sized or smaller, the write is atomic and thus 'safe'. In other cases it may not be safe." I am sure this is true but I want to understand why. My confusion stems because we have a semaphore lock here so writing to result should be safe, no?Ratal
S
0

When you declare the variable inside an async function, it becomes part of the structured concurrency. Hypothetically your testAsync function can be run from any context. The change to animal, however, is done on the main thread, which introduces a data race.

In the second example, the variable is declared globally and operates on the main thread*. The compiler doesn’t strictly check for concurrency on global variables.

*: Actually, it is not guaranteed to run on the main thread. Like @Rob said, avoid using global variables.

Sheena answered 11/11, 2022 at 21:40 Comment(1)
I’ve amended my answer, but please remember that the question was “why isn’t the compiler throwing an error”, not “should I use global variables (in concurrency)”.Sheena

© 2022 - 2025 — McMap. All rights reserved.