Cannot understand the nature of data race warnings in Swift 5.10

With update to Swift 5.10 I have got many warnings, all saying the same:

warning: passing argument of non-sendable type 'B' outside of actor-isolated context may introduce data races

I took out a few of them out of Apple SDKs to understand it better without any luck. So will appreciate any help on why there is a warning, and maybe where to read more on why it is a warning here? And how to correctly resolve it.

First example is simplified part of SwiftUI view, where a task group is created.

@MainActor
struct Wrapper {
    func test() async {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0..<100 {
                group.addTask {
                    print("Hello, world!")
                }
            }
            await group.waitForAll() // warning: passing argument of non-sendable type 'inout TaskGroup<Void>' outside of main actor-isolated context may introduce data races
        }
    }
}

I have managed to go await this warning by making this method nonisolated, yet I do not understand why?

The second example is an actor that uses other non-sendable class:

actor A {
    private let b = B()

    func prepare() async {
        await b.prepare() // warning: passing argument of non-sendable type 'B' outside of actor-isolated context may introduce data races
    }
}

final class B {
    func prepare() async {
    }
}

If I pass isolated A as parameter to B, the issue is gone since now prepare is isolated on an actor, but is that correct way? Or something should be done completely differently here?

Finally, I have top-level execution code where async calls being wrapped in a task, and it also gives data race warning, while I assumed it should be OK since everything is happening in main actor isolation context.

@MainActor
protocol Commands {
    func load()
}

final class List {
    func load() async {
    }
}

@MainActor
struct ErrorHandler {
    func task(_ execute: @escaping () async throws -> Void) {
        Task {
            try! await execute()
        }
    }
}

struct CommandsImpl: Commands {
    let list = List()
    let handler = ErrorHandler()

    func load() {
        handler.task {
            await list.load() // warning: passing argument of non-sendable type 'List' outside of main actor-isolated context may introduce data races
        }
    }
}
2 Likes

At least the second and third, if I understand correctly, have to do with switching from actor-isolation to nonisolated involving a non-sendable type.

I have just bee thinking about this (coming in from a different question) here:

In all of your code examples, there is an instance of a non-Sendable type being passed outside of an actor-isolated context when calling an async function that is not isolated to any actor. When you have a function that is async and nonisolated, it is always evaluated on the global concurrent thread pool, which is also called the "generic executor". The generic executor manages the global concurrent thread pool. This behavior of nonisolated async functions is detailed in SE-0338: Clarify the Execution of Non-Actor-Isolated Async Functions. So, this means that while your function on your actor (or the main actor) is suspended waiting for the nonisolated async function to return, the calling actor is freed up to run other work, which could end up accessing the non-Sendable state that was passed off to the function, leading to concurrent access of non-Sendable state! That's the reason for the warnings.

It's because withTaskGroup accepts a non-Sendable closure, which means the closure has to be isolated to whatever context it was formed in. If your test() function is nonisolated, it means the closure is nonisolated, so calling group.waitForAll() doesn't cross an isolation boundary.

I think your workaround is the right one for now, but this specific waitForAll() API should be fixed in the Concurrency library itself.

Yes, using an isolated parameter to prepare() will resolve the issue because prepare() will run on the actor. Whether or not that's the correct thing to do depends on what you're trying to do in prepare(), but if your intention is to always isolate access to B in that actor, adding the isolated parameter is the right way to accomplish that.

If you'd like everything in this code to happen on the main actor, you can mark List as @MainActor to resolve the warning:

@MainActor
protocol Commands {
  func load()
}

@MainActor
final class List {
  func load() async {
  }
}

@MainActor
struct ErrorHandler {
  func task(_ execute: @escaping () async throws -> Void) {
    Task {
      try! await execute()
    }
  }
}

struct CommandsImpl: Commands {
  let list = List()
  let handler = ErrorHandler()
  
  func load() {
    handler.task {
      await list.load()
    }
  }
}

Note that adding global actor isolation like @MainActor to a class makes that class Sendable. You can pass around an instance of the class all you want, but to access its mutable state, you need to get back on the main actor!

6 Likes

Seems like I've been missing on important piece and understood async functions behaviour incorrectly. Thanks, this proposal has clarified some details for me!

So in the way I am using it in this particular case, this isolation of B on A is what I need. And if I do not want to make it isolated on the actor, I need it to be sendable, since it is possible to create two tasks that call A.prepare and therefore access B which is not safe to access in that way, is that correct?

With that so far I have the least understanding on an options to approach... I would prefer List to be not isolated on a certain actor by design, but rather specify this isolation later. So far it has been possible to achieve this by this implementation I provided, not in obvious way, but it worked. Now I understand why it is not working that way anymore after reading the proposal, yet have no idea how to change it without binding List to the (main or any other global) actor?

1 Like

To be precise, I can specify an actor isolation parameter here as well, but this will require:

  1. Make all such methods on the List class(es) to accept actor as parameter.
  2. Until Swift 6, ensure that all calls receive correct actor when being called.

The second point is reasonable, since concurrency is not complete so far. But for the first I do not see a solution (maybe I am missing something, of course).

Being more explicit on execution context is a good thing, so I would like to specify it explicitly per use case, like...

struct CommandsImpl: Commands {
    @isolated(MainActor.shared)
    private let list = List()
    
    func load() {
        handler.task {
            await list.load() // now this will be isolated on main actor
        }
    }
}

...to make all the calls to the list isolated on the main actor. This attribute on a property, probably, has little sense matching to the effect, but so far I have no better expression to that.

2 Likes

Yes, that's right. the calls to A().prepare() on the same actor value can't be run in parallel, but the code in your second call to A().prepare() can run concurrently with the first call to b.prepare() if b is not isolated to the actor that stores it. The behavior of actors where they are freed up to do other work while waiting on an async function to finish is called actor re-entrancy, and it's what allows Swift concurrency to always make forward progress.

Right, isolated parameters are the only way to accomplish the effect you're describing right now. There are some other ideas for isolating an entire non-Sendable type to an actor value being discussed over in Isolation Assumptions - #56 by Nickolas_Pohilets

1 Like

I felt that this thread is related :slight_smile: Big thanks on clarification on all the questions!

1 Like