Cannot understand the nature of data race warnings in Swift 5.10

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