Preventing Data Races in the Swift Concurrency Model

Yes, I'm open to longer names to make things more clear here. In my experimental implementation of this idea I ended up going with ConcurrentValue, because the fundamental property here is that values of the type can be used concurrently. I think @Joe_Groff's formulation as being about reachability to unsynchronized shared mutable state is the thing to capture.

Yes.

Good point, thanks.

I disagree fairly strongly with the main direction of that paper, as I noted in my response. Implicitly turning global variables into per-actor-instance variables is a huge semantic change, and I don't think it's going in the right direction. Technically not "source breaking", because your code will still compile, but will behavior very strangely the moment anyone introduces an actor that touches a global---even for correctly-synchronized global variables, such as a singleton that handles synchronization internally (it would no longer be a singleton in your proposed). At least with a source break, you know that semantics have changed.

Do you want to debate that point further, or are there other models you want to discuss?

This is incorrect. You can evolve a let into an @actorIndependent var without breaking API or ABI.

In this model, "only certain kinds of async functions" can be called from another actor because of the ActorSendable/ConcurrentValue story. Either the whole model has this problem or it's not a real problem; there is no argument specific to let here.

I've been writing code against this model, and it is so very, very useful in practice. This current pitch on Preventing Data Races takes away the special case from actors and makes all let's with ActorSendable/ConcurrentValue/Shareable/wathever types accessible concurrently.

I truly don't understand where you're coming from with this line of reasoning. Immutability is perhaps the simplest and most pervasive tool we have for making concurrent code safe from data races. Swift has nudged programmers toward using let because immutability makes code simpler to reason about, and it also makes it safe to use concurrently when working with ActorSendable/ConcurrentValue types---something Swift has also nudged programmers worked with pervasive value semantics. Why would we not embrace this wholeheartedly in our concurrency design?

Nor are all nonescaping closures non-concurrent. Per your hill-climbing question:

I'm not opposed to having a @concurrent attribute on function types. I think we'll need them regardless.

The prototype and my pitch take the approach of having new code---code that is within an actor or async code---opt in to the checking based on the "escaping closures are assumed concurrent" notion. Having @concurrent types be completely opt-in leaves large holes in the checking model that lets us diagnose data races in code that uses actors with existing APIs. Most examples are more subtle, but let's be blatant:

actor class MyActor {
  var count: Int

  func f(array: [Int]) {
    runLater {
      self.count = self.count + 1
    } 
  }
}

Where we got runLater from some library:

func runLater(_ body: @escaping () -> Void) {
  // toss this on a timer somewhere to run
}

My proposed scheme and the prototype will complain about this because escaping implies concurrent by default. But it allows non-escaping closures to just work

extension MyActor {
  func silly(array: [Int]) {
    array.forEach { i in count += i } // okay because closure is non-escaping, so it's in the actor's domain
  }
}

With @concurrent being a purely opt-in feature, we'll either silently admit the data race in the first example, or have to reject the second example.

Having escaping imply @concurrent by default doesn't mean that escaping and concurrent aren't orthogonal; rather, the defaults are lined up and you have different spellings for the exceptional cases:

  • @escaping () -> Void: escaping, concurrent
  • @escaping @nonconcurrent () -> Void: escaping, non-concurrent (expected to be very, very rare)
  • () -> Void: non-escaping, non-concurrent
  • @concurrent () -> Void: non-escaping, concurrent (parallel/concurrent algorithms would use this)

The first and third are the most common cases, and with the approach I'm describing, they don't have to change at all (neither ABI nor API). But we can still address the uncommon cases to fill out the matrix.

Because it took me longer to figure out that this can be checked directly.

It would still be implicit, because most closures will become @concurrent implicitly based on context. That's probably okay in practice.

Doug

3 Likes