SE-0302: ConcurrentValue and @concurrent closures

I'm not sure how much the big picture has changed as these proposals have been iterated on, but at one point the answer to your question was that long-term (i.e., Swift 6), we'd require mutable globals like that to be annotated with a global actor, so it could not be directly mutated from arbitrary code like Foo.f.

That's super scary. It's like having handrails that crumble when you lean on them too hard - worse than having no handrails at all.

I think it would be better to have only the version with Unsafe in the name, until we can guarantee safety.

So, how does @concurrent interact with async? Are they orthogonal, one imply/reject another?

Agreed. static variables should not participate in ConcurrentValue. I propose that the above code should be give an error.

var global = 0
public struct Foo: ConcurrentValue {
  public func f() {
    global += 1 // Error: mutable static variables are not safe from concurrent code; Please use UnsafeConcurrentValue
  }
}

Hi @Philippe_Hausler,

If I understand correctly, you are concerned about annotation burden for putting : ConcurrentValue on things. One of the discussions that came up in the pitch threads (but which got dropped along the way to reduce scope) is that the vastly most common case in Swift is to have types that provide thread-safe value semantics, and those types all are implicitly ConcurrentValue. The discussion about that made it apparent that defining this was complicated, so we ended up pushing that out to future work.

That said, the design of this attribute being a marker protocol gives us a lot of flexibility to adapt and improve this over time - e.g. make it implicit when first class support for value semantics comes in, make it implicit for non-public/open types, or other potential refinements.

I think that Swift concurrency in general will take some amount of annealing after all of the big ticket items land and we get experience. It is important that we get the mechanics required to enforce safety checks, even if the "UI" for those gets refined over time.

I don't really understand how those are related to this proposal. The goal of this proposal is to provide some mechanics that allows the compiler to diagnose unsafe transfers of values across concurrency domains. atomic properties and ref counting isn't comparable to this in any way that is obvious to me.

This proposal is orthogonal to making global variable access safe. There are at least two different proposals to how to handle this (e.g. mine), and both compose correctly with this proposal.

They are orthogonal. async means suspendable. @concurrent means the closure can be passed across concurrency domains.

-Chris

3 Likes

Even in my short time using the new concurrency features I’ve realized that passing values between concurrent contexts will be orders of magnitude more common than using Hashable or Codable types. I wouldn’t be surprised if every type in every Apple framework needs to be annotated. And given that friction it seems extremely likely that users will simply mark whatever types they need as UnsafeConcurrentValue and continue on like before.

The sheer scale of the audit and markup that must be performed for this feature, as proposed, should make it clear it’s simply untenable. I don’t see any way that Apple, framework authors, or users can reasonably be expected to make informed design decisions about every single type, much less manually mark every single type they create so it’s useful in an async context. Some sort of implicit conformance must be possible for this feature.

3 Likes

A @concurrent function type is safe to transfer across concurrency domains (and thus, it implicitly conforms to the ConcurrentValue protocol)
.....
The @concurrent attribute to function types is orthogonal to the existing @escaping attribute, but it works the same way.

If @concurrent works the same way as the @escaping attribute then the type of the closure doesn't change. How can we enforce that a closure is concurrent like the example below? if not, will there be facilities to check if a closure is concurrent?

// Swift 5
typealias EscapingClosure = @escaping () -> () // Error: @escaping attrbute may only be used in function parameter position

// Swift 6+
typealias ConcurrentClosure = @concurrent () -> () // Allowed?
let myArray = [ConcurrentClosure]() // ?

I started from this perspective, too, and previously argued that all conformances to ConcurrentValue should be explicit for similar reasons to what you cite above. Additionally, I regret the implicit conformance we have for raw-valued enums because they come across as surprising.

However, as Philippe notes, the annotation burden is very high:

Jon talks about the problem in terms of an audit of Apple frameworks:

We shouldn't worry about Apple here, specifically, but about the wealth of existing Swift code which has followed all of the best practices to date, but will now need a significant amount of manual intervention to work with concurrency. The per-type overhead of adding : ConcurrentValue is small, yes, but the effect on the ecosystem is large.

A struct or enum composed of ConcurrentValue types is a ConcurrentValue, by definition. The only reason not to mark it as a ConcurrentValue is to preserve the ability for it to become non-concurrent-safe in the future, say, because one adds a new stored property with a non-ConcurrentValue type. Yes, this can happen, but we have tools to check for unintentional API changes like this. For me, the risk of this happening doesn't justify the annotation burden of adding ConcurrentValue everywhere.

I think Philippe is making another point here that's been misunderstood:

By requiring users to explicitly annotate types with ConcurrentValue, we force them to understand what ConcurrentValue means---even though their simple struct or enum already follows all of the rules and they don't care at all about resilience over tim. It's adding additional mental overhead for every Swift user, while providing benefits only to a small number of users that have longer-term stable APIs and ABIs to maintain.

My earlier intuition about ConcurrentValue needing to be explicit turned out to be wrong. Here are some reasons why I believe ConcurrentValue conformance should be implicit for enum and struct instances comprised of ConcurrentValue instance data while Equatable, Hashable, and Codable must remain explicit:

  • Equatable and Hashable are providing new functionality (==, hash(into:)) that otherwise wouldn't be present, and the synthesized implementations can be wrong. ConcurrentValue is ascribing semantics to an operation that's always there (the native copy).

  • Equatable and Hashable synthesize a non-trivial amount of code, and in aggregate making conformances to Equatable and Hashable implicit would significantly bloat code size. In contrast, ConcurrentValue has no runtime impact, so there's no cost to implicitly adding ConcurrentValue conformances.

  • Removing an accidentally-shipped Equatable or Hashable conformance would be extremely hard: it would break any existing binaries completely (they'll fail to link or launch due to missing symbols), and clients would be forced to do a significant amount of work just to get back to a working program. ConcurrentValue offers easier on-ramps, with (e.g.) @concurrent(unsafe) or UnsafeConcurrentValue.

    Doug

11 Likes

This code is allowed. I think the comparison to @escaping is causing more confusion than insight. Swift actually has both "non-escaping" and "escaping" function types, but they're very contextual: a function type written as the type of a parameter is non-escaping unless explicitly marked @escaping. A function type written anywhere else is implicitly @escaping.

@concurrent doesn't have this behavior. A function is @concurrent if it has @concurrent explicitly written on it. A closure is @concurrent if it has @concurrent explicitly written on it or it is used in a context where only a @concurrent function is permitted (i.e., type inference).

Doug

2 Likes

There are two reasons I disagree. The first is that this proposal is part of a larger arc for Swift Concurrency: it solves a necessary part of the problem of data races, but is not sufficient to solve all data races. Trying to solve all problems at once leads to proposals that are too large to meaningful digest or discuss.

The second is that you're conflating a type-level statement (values of the type can be shared across concurrency domains) with a more global statement (every operation on the type is free of data races), and they need not be conflated. The global statement applies equally to functions like your Foo.f as well as other global functions:

func bumpGlobal() {
  global += 1
}

We clearly want to know that bumpGlobal introduces a data race, but there's no ConcurrentValue check we can do to help here. Indeed, Foo.f could be refactored into:

public struct Foo: ConcurrentValue {
  public func f() {
    bumpGlobal()
  }
}

and there's no point where a ConcurrentValue check can help. That's why we look to doing something with global variables specifically to address the issue.

Doug

2 Likes

These are good points. A very reasonable middle ground that helps many app developers but not apple is to to make ConcurrentValue implicit for internal types. It may not be worth the complexity though,

-Chris

4 Likes

Thanks! Didn't realize that there will be other proposals that will make it safe.

I'm happy with problems being broken up into multiple proposals! I just noticed that the checks in the proposal are not sufficient to ensure safety, and wanted to know if there's something I'm missing. The first reply sounded like the safety wouldn't be introduced until long time into the future (swift 6 is often used as a hypothetical release when there are breaking changes, which I don't see happening any time soon). Now I know that the handrails will be reinforced before concurrency is out :)

In the pitch thread I asked what are the requirements for conforming to the ConcurrentValue protocol, and the answer was that all public API should be usable across concurrency domains

If that's not what "can be shared across concurrency domains" means, then could you tell me what does it mean? As far as I know values of all types can be shared across threads, as long as you don't perform any operations on them.

I'm looking forward to it!

This is well put, and it's an important point. Simply copying well-defined bits between threads never itself creates a data race; we always have to reason about operations on values, and especially on mutable memory locations that store values. When we start reasoning about specific operations, we find that some common patterns emerge:

  1. Some operations don't touch mutable memory at all.
  2. Some operations only touch a restricted set of memory — memory that's somehow "local" — and will be okay as long as no reference to that memory escapes the thread.
  3. Some operations have to be performed in a very specific dynamic context, perhaps on a specific thread, or perhaps while holding some specific lock.

By definition, there's no way that a language can make operations in the third category safe without in some way limiting how they can be used. Those operations are just going to be unsafe, or they have to be specially identified and restricted. Allowing those restrictions to be expressed (for at least a few important categories of restrictions) is part of the purpose of the proposals around actors and global actors.

Swift enforces certain exclusivity rules that collectively mean that local contexts are known to temporarily "own" certain values and mutable locations. These rules allow us to know that certain locations will not be accessed by other threads, or even recursively by things like opaque closures that have been passed in as arguments. One of the most important properties of the exclusivity rules is that components of the so-called primitive value types — structs, enums, and tuples — have the same properties as their containing value. We can take advantage of these rules to carve out operations in the second category: if an operation relies only on memory which is guaranteed exclusive to it by the normal exclusivity rules, it doesn't need any further restrictions. This is in some sense a special case, but because of the impact of the exclusivity rules and value semantics, it's a very important special case that deserves special consideration.

And of course operations in the first category don't need any restrictions at all.

ConcurrentValue is a tool that you as a programmer can use to restrict values from being passed between arbitrary threads. So it should be used, or not used, when that property is necessary and useful to make a type thread-safe:

  • If the type has operations in the third category, then those operations will always be unsafe unless they can be statically called out as special, somehow forcing Swift to enforce their preconditions. Preventing a value of the type from being shared between arbitrary threads in itself does nothing to make the type thread-safe. (If you could force the value to only be valid in a context where the preconditions hold, that would be good enough, but that would take a lot of extra restrictions.) Crucially, you can therefore completely ignore these operations when deciding whether to make the type conform to ConcurrentValue: it's totally fine to share values of the type, but callers will always have to meet an extra burden to use any of the operations in this category.

  • Given that, if ordinary Swift exclusivity isn't sufficient to make the unrestricted operations on the type thread-safe, but preventing the value from being shared between threads would be, then the type shouldn't conform to ConcurrentValue.

  • Otherwise, the type should probably conform to ConcurrentValue.

5 Likes

I may be over simplifying things a bit here, but I look at it this way:

A type should conform to ConcurrentValue when it (and all future versions of itself, ala resilience) satisfy the property that all public APIs may be used across concurrency domains without requiring external synchronization to prevent memory safety problems (e.g. races).

-Chris

5 Likes

I don't think your argument is really about the implicit ConcurrentValue. Your stated concern is that, on encountering a compiler error where a given type does not conform to ConcurrentValue, a user will use @concurrent(unsafe) or UnsafeConcurrentValue or whatever hammer we provide to silence the compiler error and move on. That problem is independent of whether the ConcurrentValue was written somewhere explicitly or not--and it's something we have to contend with whenever we provide an unsafe opt-out to a safety feature.

At some point, every Swift programmer is going to encounter ConcurrentValue and learn about it, and that's fine. However, Swift tries to follow the principle of progressive disclosure, and not inundate the developer with concerns up front when not necessary. There is a very large subset of Swift types for which the implicit ConcurrentValue conformance will always work, and we already nudge users toward modeling their data in terms of structs and enums of value types. Swift has always rewarded the use of value-semantic types like these with easier-to-reason-about code with localized mutation. The scenario you are discussing---adding a non-ConcurrentValue type such as a class instance to a struct or enum---will also break value semantics, so when doing so you're already in for some surprises from the basic Swift model.

Doug

3 Likes

It seems like we’re circling around POD types and pure functions, which are also concepts that people have wanted better expressed in the language.

Implicit conformance probably means we don’t want the UnsafePointer types conforming as well. Even though it is safe to transfer the bits and only accessing the memory needs to be synchronised, it would feel wrong for a struct full of UnsafePointers to be implicitly ConcurrentValue.

Thanks!

SE-0302 has been accepted in general, but will be revised according to the feedback we've received, and we will run a second review for those changes. I'll link the new review thread here when it's ready.

3 Likes

The second review is now open.