SE-0410: Atomics

We may be able to do something here, but I would hold off until the clang importer understands non copyable types.

_Atomic works pretty differently than how our Atomic<T> works because given the following:

void increment(_Atomic(int) value) {
  value += 1;
}

you actually pass value by value here which is a big no no for our atomics (for anyone curious, this creates stack space, copies value into said space, and performs the atomic add op on that). We could however in theory support:

void increment(_Atomic(int) *value) {
  *value += 1;
}

// and in C++

void increment(_Atomic(int) &value) {
  value += 1;
}

C++'s std::atomic is actually much closer to the semantics of our Atomic<T>. Struct and class types who have a std::atomic as a member are treated as address only types and std::atomic itself is not copyable.

1 Like

There hasn't been any discussion on these names. These names come from the swift-atomics package where they were renamed from AtomicProtocol which was proposed and reviewed in SE-0282v1.

The swift-atomics package arrived at these protocol names based on prolonged discussions.

I don't recall issues filed over protocol names over the three years the package has been available. I'm considering this as proof that the names work well enough -- granted, it's a quite weak proof, but it's still based on more concrete data than most naming discussions on this forum.

The protocol names aren't typically mentioned in code that uses atomics, so renaming them would not be as disruptive a change as, say, renaming the core atomic operations. (Edit: especially since we decided to overhaul the protocols in this proposal, so they do not work the way they do in the package.)

But the package still serves as quite strong prior art; deviations need to be very deliberate, as they do not come for free. We aren't starting from scratch here.

1 Like

Another possibility might be to use additional protocols like the AtomicOptionalWrappable protocol used to let Optional be atomic. Types whose SIMD vectors fit within an atomic access could have AtomicSIMDNWrappable conformances with associated types for the SIMD vectors' atomic storage.

1 Like

Very happy with how the APIs ended up. I think these being in the standard library itself is the right solution (cf. prior discussion about layering issues) and the final names chosen are such that their appearing on fundamental types won't be hugely distracting.

Two minor comments on API design:

  • bitwiseAnd(with:ordering:) and friends, as well as logicalAnd(with:ordering:) and friends, don't really require an argument label for the first parameter, just as add and subtract don't. API design guidelines would suggest omitting vacuous labels where possible.

  • The terms "wrapping add" and "wrapping subtract" are somewhat unfortunate in that they diverge from The Swift Programming Language, which names these operations "overflow add" and "overflow subtract" (which aren't great, either), while simultaneously being a different usage of the term "wrap" than in AtomicOptionalWrappable. One compromise here would be something like: addWrappingOverflow (à la addReportingOverflow).

3 Likes

TSPL is wrong here. "Overflow add" is a terrible name for this operation, as by definition it does not ever overflow. We should update TSPL.

FWIW, we use "wrapping" or "wrapped sum" in the documentation and API for SIMD arithmetic.

10 Likes

Good reminder about SIMD precedent. Updating TSPL would be good, yes.

Nit: The second paragraph under Specialized Integer Operations as well as the following chapter on boolean operations still suggest there are two variants of each operation instead of just one returning the (oldValue, newValue) pair.

I think it's a good idea to mention that the operations traditionally come in two variants but that Swift chose otherwise here for better ergonomy.

1 Like

Overall, I'm very excited to see this.

I'm concerned about the usability of this feature given its interaction with exclusivity. Programmers will naturally think of atomic objects as mutable — a compare-exchange on an atomic is a mutating operation in the natural sense of the word — and so they will want to declare them as vars. Unfortunately, as discussed in the proposal, this is always wrong but not actually diagnosed. The exact same problem will also arise with non-allocating lock types.

I really think you should add an attribute to this proposal that either disallows declaring a var of a particular type or implicitly reinterprets var as let. (Maybe it should also ban declaring mutating methods on the type.) It would still be possible for users to accidentally get a mutable object of atomic type with generics, but it's reasonable to expect that it will be very uncommon to declare atomics that way, and covering the 99% case should be good enough. You can also give that attribute the (currently non-public) behavior of ensuring that borrowed values of the type are always passed by address, so that non-stdlib programmers have a complete tool they can use to safely wrap their own C-style concurrency primitives.

11 Likes

I'm thrilled to see this refined version of the swift-atomics package coming to the standard library.

The proposal covers what I'd expect and the API looks reasonable to me. I didn't get around to actually writing working code against it, but I did review some of the C++ atomics-handling code in the concurrency library to envision how it would look with this proposal. The proposal is mirroring the C++ atomics APIs very closely, which I think is okay: we're leveraging the C++ memory model, and there's a lot of benefits to cross-language consistency for something this complicated. I suspect that we'll want some slightly-higher-level APIs in the future, but that won't obviate the need for what's in this proposal.

I'm glad to see AtomicLazyReference here as one of these "higher-level APIs". I think it should be conditionally Sendable, i.e.,

extension AtomicLazyReference: Sendable where Instance: Sendable { }

The thread-safe lazy initialization pattern is listed as:

var _foo: AtomicLazyReference<Foo> = ...

// This is safe to call concurrently from multiple threads.
var atomicLazyFoo: Foo {
  if let foo = _foo.load() { return foo }
  // Note: the code here may run concurrently on multiple threads.
  // All but one of the resulting values will be discarded.
  let foo = Foo()
  return _foo.storeIfNil(foo)
}

but IIUC that _foo should be a let rather than a var, right? And it's fairly likely that one would then want atomicLazyFoo to also be nonisolated, so it can be referenced concurrently.

It doesn't need to be in this proposal, but this cries out for a macro so one could do:

@AtomicLazy 
nonisolated var atomicLazyFoo: Foo = Foo()

to apply the above pattern.

An additional note with regards to the Law of Exclusivity, atomic values should never be declared with a var binding, always prefer a let one.

As noted by others, this is a pretty big foot-gun, especially since we've been teaching folks for years that var means mutable and let means immutable, and atomics feel like value types even though identity is critical. At the very least, we'll want some compiler warnings here to help guide folks away from var for these atomic types. It would be better to generalize this notion with an attribute, because I suspect we're going to have other noncopyable types like this in the future.

Doug

12 Likes

Yeah, one kind of understated aspect of this proposal is that it generalizes the existing let-vs-var from merely immutable-vs-mutable to "shared access only"-vs-"allows exclusive access". For the vast majority of types, mutation is only safe under exclusive access, so that generalization is irrelevant, but atomics, locks, and other concurrency primitives make it important. I think that, within the boundaries of static exclusivity checking, that there could be uses for exclusive access to atomics. When you know you have exclusive access to an atomic, then it's safe to nonatomically access the storage, and so that could be a mechanism by which we provide statically safe "tearing" operations on atomics. This already happens somewhat implicitly because atomics are still movable types: you can have an Atomic<T> in a field of a noncopyable struct, and move the containing struct around, which is safe because consuming requires exclusive ownership of the value so we can safely memcpy the atomic storage from the old owner to the new owner. So I think the thing to provide a diagnostic for would be when an Atomic appears as a var that would be subject to dynamic exclusivity checking, such as a class property, escaped closure capture, or global/static variable.

On the subject of memcpy-ing when moving atomics, it's probably worth mentioning that the AtomicRepresentation associated type of an AtomicValue conformance is required to be bitwise-movable. This can be an informal stated requirement for now, but if we get a BitwiseMovable generic constraint in the future, we ought to formalize it in the protocol.

8 Likes

Right. I’ve been struggling with how we teach let-vs-var once we have this. It’s clear that “let” has the right language mechanics for atomics and in-place locks, and it’s always been the case that “let” is only shallowly immutable, since the variable can be of class type (or have a class type buried in it). This isn’t for the proposal to solve per se, but it’s interesting going forward.

I had somehow missed this link; thanks for the clarification.

Doug

2 Likes

Hi all,

I've pushed some updates to the proposal which resolves some of your minor nits that you all caught. These can be found here: [SE-0410] Atomics: Some clarifications (minor fixes), WordPair changes, and RawRepresentable OptionalWrappable by Azoy · Pull Request #2203 · apple/swift-evolution · GitHub

Thanks! I've removed these references in the update :+1:

Sure, I've gone ahead and removed these labels to make them consistent with the add/subtract functions.

Yep, the implementation made it Sendable, but the proposal didn't state it. Apologies, and thanks for the find! :+1:

Yep, you're right this should be a let :+1: I've also gone ahead and marked the atomicLazyFoo as nonisolated as well.

In addition to those changes, I've also reworded a paragraph in WordPair which implied that the type would be unable when the platform doesn't support double wide atomics, but that is not true the type will always be available. It's the conformance that won't be available.

One sort of major thing that I had forgotten to include in the proposal was the inclusion of the default implementations of AtomicOptionalWrappable for RawRepresentable where Self: AtomicOptionalWrappable, RawValue: AtomicOptionalWrappable. This works the same as the default implementations for AtomicValue already discussed for RawRepresentable in Custom Atomic Types, but had forgotten to mention the extra support for optionality. Here's what I wrote:

We also support the AtomicOptionalWrappable defaults for RawRepresentable as well:

extension RawRepresentable where Self: AtomicOptionalWrappable, RawValue: AtomicOptionalWrappable {
  ...
}

Similar to the enum example, we can model types whose raw value type is a pointer for example and use their optional value in atomic operations:

struct MyPointer: RawRepresentable, AtomicOptionalWrappable {
  var rawValue: UnsafeRawPointer

  init(rawValue: UnsafeRawPointer) {
    self.rawValue = rawValue
  }
}

let myAtomicPointer = Atomic<MyPointer?>(nil)
...
myAtomicPointer.compareExchange(
  expected: nil,
  desired: MyPointer(rawValue: somePointer),
  ordering: .relaxed
).exchanged {
...
}
...
myAtomicPointer.store(nil, ordering: .releasing)

(This get you an AtomicValue conformance for free as well because AtomicOptionalWrappable: AtomicValue , so non-optional Atomic<MyPointer> will work just as fine as well)

Apologies for all of the fixes I've had to do, it turns out reviving 3 year old proposals isn't easy :sweat_smile:

6 Likes

Are you suggesting the proposal add a new public attribute, something like @noVarOnlyLet? It seems to me that for the time being the proposal can retrofit the current attribute it uses to guarantee address only-ness, @_rawLayout, to include the no var only let semantics. So the proposal could then mention that it would actively disallow declaring vars with type Atomic so we can leave design space and iteration on the public attribute in a separate proposal. I think formalizing @_rawLayout itself would be a better future goal that would allow folks to make these kinds of types outside of the standard library. In fact, I think using this attribute on a new type, something akin to UnsafeCell in Rust, would be the general purpose tool folks could use.

Right, I think these cases are the ones where var is actively hurting these kinds of types. A local var that's not captured would be fine, but it would make things like reassignment possible for atomics which is 1. not an atomic operation and 2. quite weird maybe. (Similar for vars as struct stored properties)

func something() {
  // No dynamic exclusivity checking here
  var x = Atomic<Int>(0)

  // Not an atomic operation
  x = Atomic<Int>(123)
}

So I think it would beneficial to ban all uses of var for these kinds of types regardless if there is dynamic exclusivity checking included or not. We can still achieve the safe "tearing" operations when moving these things during consumption as lets.

2 Likes

It does not make sense to me to do this as a separate proposal. One way to describe the behavior of that attribute would be “make this type act like the atomic types do”, but we’ll already have decided how the atomic types work in this proposal, so that future proposal would be, what, just picking a name?

Or to put it another way, you’re already describing several behaviors for these new types that are unlike any other types, and this seems like the most appropriate time to have a discussion of those behaviors and how they’re exposed in the language.

2 Likes

Is there any interest in implementing the atomic wait/notify_one/notify_all primitives? Seems like they might be useful tools to build condition locks.

Those aren’t atomics, they are synchronization primitives that require kernel support.

On top of that, even when we do add synchronization primitives like locks, I hold out hope that we can avoid adding traditional condition variables, which have some nasty properties.

2 Likes

This would imply to me either we disable var somehow entirely for these declarations, or we declare atomic variables via atomic myVariableName rather than let myVariableName. The latter would be more obvious but also more destructive to the point of being overkill.

Could we explain that Atomic and similar types have "reference semantics"? This would follow the precedent of ReferenceWritableKeyPath, which uses "reference" to describe the ability to non-exclusively mutate something.

As a tangent, I think interior mutability for noncopyable types need not be particularly unusual or low-level in the future. Shared mutable state is quite common, like with classes. Noncopyable types would just guarantee that the shared mutable state has a bounded lifetime and doesn't require heap allocation. We could embrace opt-in "reference semantics" for noncopyable types in the future. Maybe we could add a noncopyable @ReferenceWritable property wrapper, or class A: ~Copyable syntax, or something.

I think if we explain that Atomic has "reference semantics", we should be good. We have a similar situation with classes, where we do allow vars, but disallow mutating methods. Some people have complained about the latter restriction and have used protocol extensions to work around it.

A var atomic could have some limited use cases, such as if we statically know that nothing is borrowing an atomic, and we want to change its value without incurring the potential performance cost of an atomic operation.

We already emit a compiler warning when a var can be turned into a let. Maybe we can also emit a compiler warning if a non-public mutating method never mutates the instance.

2 Likes