Pitch #4: ConcurrentValue and @concurrent closures Evolution Pitches

I don't really see any acceptable option except that Error implies ConcurrentValue. Anything else is going to create a massive static/dynamic problem for people whenever they need to move a computation result between threads.

4 Likes

Perhaps in Swift 5 we could make Error implicitly conform to ConcurrentValue, but emit a warning instead of an error if one of the members is not itself a ConcurrentValue. You could silence the warning by conforming it to UnsafeConcurrentValue (or by fixing the members).

I guess that also means NSError would permanently need to be an UnsafeConcurrentValue due to its userInfo member of type [String: Any].

1 Like

(This regards @concurrent closures not needing to check that their parameter and result types are ConcurrentValue)

I agree, and thanks to Jordan and John for explaining it thoroughly.

I think DynamicMemberLookup was a different case; we did it because we were worried about abuse, but it didn't really have other benefits. Your first two points are interesting, because I think we're starting from the same examples and coming to different conclusions. Let's take two structs:

class MyClass { }

public struct A {
  public var x: Int
  private var y: Int
}

public struct B {
  public var x: Int
  private var y: MyClass
}

There are a couple of things that should be facts we can agree on:

  • If we make A and B conform to ConcurrentValue within the same source file they are declared, A: ConcurrentValue will be well-formed and B: ConcurrentValue will be an error because MyClass does not conform to ConcurrentValue.
  • If we make A and B conform to ConcurrentValue outside of the source file, we cannot see the private stored properties named y so the structural rule that the stored properties of a ConcurrentValue struct are themselves ConcurrentValue cannot be fully checked. If we do the check, it would have to conclude that both A and B conform to ConcurrentValue correctly.
  • With resilience, A can evolve to have the stored properties of B but the conformance to ConcurrentValue will be ill-formed if it was in the source file.
  • With resilience, B can evolve to make its property y public. That would make an out-of-source-file conformance B: ConcurrentValue ill-formed.

My take on this is that allowing a ConcurrentValue conformance outside of the source file means we're admitting a hole into the basic model (because B: ConcurrentValue is accepted despite the stored property of type MyClass) without a corresponding "unsafe" tag on it.

I suppose that means I do feel a bit strongly about this.

This is a great point. We can do this because C doesn't have resilience, inferring ConcurrentValue conformance for all C enums and C structs whose stored properties are ConcurrentValue.

I'd be okay with limiting ConcurrentValue to final classes. We can open it up later if it matters.

The full list would be tedious. I think taking the list roughly as I wrote it in my post is the right level of detail.

It can certainly be loosened in the future, so yeah, I'm fine with requiring an explicit capture list for mutable state.

You're right, it does fall out of the capture rules. Yay simplicity.

Future work is fine by me.

Yes, forbidding capture of non-concurrent values in key paths seems like the best approach here.

I think this is the only sound thing to do, but for the pesky problem that we'll be breaking source code to do it. We could perhaps stage it in via warning: if a type gets its ConcurrentValue conformance through Error, we warn about stored properties or associated values that aren't ConcurrentValue rather than error. Or perhaps we do the check only when there is a need for that particular conformance, so one only sees the diagnostic if you're writing concurrent code, which leaves a hole but won't be noisy in existing code.

Either way, we'd turn it into an error in Swift 6 to tighten things up.

Yeah, there's nothing we can do to make NSError non-unsafe. I think that's acceptable.

Doug

5 Likes

Giving more thought to this, I think this is also a problem for async let, runDetached, and task groups from structured concurrency. An error thrown crosses the bridge between two executors in those cases. Of course, forcing Error to conform to ConcurrentValue would work there too.

And the return type should be constrained to ConcurrentValue too for the cases above. runDetached could for instance add a generic constraint to ensure the returned value conforms to ConcurrentValue. (That wasn't necessary when @concurrent implied ConcurrentValue for parameter and return types, but now it will be.)

Yes, that seems reasonable. Really, this should be the rule for any standard-library protocol that gains a ConcurrentValue conformance; Error may be the first, but it might not be the only one.

This is unfortunately not really possible, since we'll use the fact that Error implies ConcurrentValue pervasively, and the Error conformance of the specific type will be used if it's ever thrown, which I think we can safely assume will happen somewhere.

Right, we can do this for CodingKey as well, which lets a bunch of Codable-related stuff conform to ConcurrentValue.

It would never work well, no. This is something where we'll need a source break in "Swift 6" to tighten up the model.

Doug

2 Likes