[Second Review] SE-0410: Atomics

I can’t do that, I need to be able to mutate the atomic from places where I do have exclusive access.

I am pointing out that this proposal throws away the fundamental guarantees of let and the response is “you can just hide that wart from your clients.” I feel like Mugatu in Zoolander. Swift has a keyword for values whose storage can change. It’s var!

1 Like

I think this logic still extends to atomics; you can't change which atomic container a let refers to, but through its interface you can still atomically update the value inside of it. The fact that atomics are noncopyable, unlike class references, means they only have a single owner and lifetime-constrained borrowers, which allows for the indirection to be "optimized away" so that the atomic is stored inline in its owner instead of being allocated separately.

3 Likes

That sounds like an argument for a new atomic kind that is a peer of class and actor.

But running with that idea for a second, Swift does have a lazy modifier that imposes its own unique rules:

lazy let foo: String // error: 'lazy' cannot be used on a let

lazy var bar: String // error: lazy properties must have an initializer

struct S {
  lazy var baz: String = "hello"
}
let s = S()
s.baz = "goodbye" // error: cannot use mutating getter on immutable value: 's' is a 'let' constant

var s2 = S()
s2.baz = "goodbye" // ok

In fact, the only strange thing about lazy is that `private(set) seems to have no effect:

struct Q {
  lazy private(set) var quux = "Hello"
}

var q = Q()
q.quux = "Goodbye" // ok?!?

Since atomic types require implementation support from the compiler, I think it would make a lot of sense to have an atomic keyword that’s similar to lazy, imparting its own additional semantics.

But I don’t think rewriting the proposal to adopt that approach is necessary. It would be enough to simply choose var instead of let as the only keyword which atomics can be used with, along with the ability to use private(set) to prohibit external modification.

2 Likes

If we were to design lazy today, it would almost certainly be a property wrapper or macro, since it doesn't have any outward behavior that can't be expressed as a handwritted computed property (and the peculiarities of its behavior like the private(set) thing are usually bugs arising from it being a special case rather than an instance of a general feature). Similarly, atomics don't outwardly interact with the language model differently from other noncopyable structs, and the implementation detail it uses to contain raw storage within its owner has applications beyond atomics; we could use it for things including locks and fixed-capacity collections as well. So I don't think it makes sense to make it a new kind of thing because it fits within the existing rules of things we already have.

4 Likes

But we will want these semantics for potential things like mutexes, concurrent data structures, etc. Do we want an atomic var lock: Mutex<Int>? Or an atomic var cache: ConcurrentDictionary<String, Int>?

2 Likes

Yep, that’s another reason I’m not actually suggesting to redesign the proposal around a lazy-like modifier. Just wanted to complete the thought.

Are you saying that any non-copyable struct stored in a let will actually be mutable? That would be rather surprising to me!

Yes, I think so. You shouldn’t be able to mutate a lock if you only have a let or borrow view of it. What is the advantage of forcing you to spell it let lock: Mutex<Int> if it turns out that such a value behaves as a publicly mutable var for all intents and purposes?

(edit: Oh, in those examples atomic doesn’t make sense as a modifier. Those types might be borrow-only but they aren’t implemented with atomic backing storage. In any event, I am not advocating that you rewrite the proposal to use atomic var.)

1 Like

Hm. @ksluder, does it help to think of an atomic as "equivalent" to the following struct?

public struct OwnedHeapAtomicInt: ~Copyable {
  private var buffer: UnsafeMutablePointer<Int>

  public init(capacity: Int) {
    self.buffer = .allocate(capacity: 1)
  }

  deinit {
    self.buffer.deallocate()
  }

  public borrowing func load(ordering: AtomicLoadOrdering) -> Int {
    UnsafeAtomic(at: buffer).load(ordering: ordering)
  }

  public borrowing func store(
    _ newValue: Int,
    ordering: AtomicStoreOrdering
  ) {
    UnsafeAtomic(at: buffer).store(newValue, ordering: ordering)
  }

  // ...
}

This is a struct, it's non-copyable, it has no mutating operations, you can manually reassign the whole thing, and yet store is a useful API even when someone makes one of these using let. It's a container whose "value" is the identity of the memory inside it, even though it doesn't expose that identity in any observable way, and doesn't let you share it with anyone.

The atomics in this proposal have the exact same semantics except that they dip into some compiler magic to not actually use a heap allocation. If they were copyable, you'd be able to observe the ways in which such an implementation does not have pure reference semantics, but that's not part of this proposal.

9 Likes

I see this discussed in Interaction with Existing Language Features. What remains unclear to me is whether

To prevent users from accidentally falling into this trap, Atomic (and AtomicLazyReference) will not support var bindings. It is a compile-time error to have a varthat has an explicit or inferred type of Atomic.

is just band-aid to prevent accidentally mutating Atomic variables directly, or if the compiler safeguard also extends to data structures containing (nested) let properties of Atomic (or AtomicLazyReference) types?

If one uses a let for the immediate Atomic property, will it also be an error to make vars of struct values containing one? Surely it's a programmer error trying to do so, in any case?

struct Foo {
  let atomic: Atomic<Int>
  var whatever: String
}
struct Bar {
  var foo: Foo  // Is this `var` already an error?
}
var bar: Bar  // Is this `var` an error as well?

And—more importantly—if so, what's the way (similarly to ~Copyable) to interact with types like Foo and Bar in a generic context? (Is that the Cell type mentioned above?)

1 Like

This makes your example entirely different from this proposal. Its storage is out-of-line, like a class. And by invoking Unsafe types, it explicitly opts out of the memory safety guarantees that let is supposed to provide.

let has two interpretations. To a type’s client, it means “you may not call the setter.” In fact, the ABI for resilient lets is equivalent to var { get }, and protocols require you to spell it as var { get } instead of let to allow for the option of private(set). This is the mechanism that allows for caching within an “immutable” type, the usefulness of which I already acknowledged above.

To the type’s author, let means “this property’s storage is immutable”. It is a much stronger memory safety guarantee. It is a promise that the storage can be safely shared because it is not mutable. This interpretation of let—a way to prohibit accidentally sharing mutable state—has been explicit since the earliest days of Swift, and is a key part of how Swift’s exclusivity checker does its job without being as in-your-face as Rust’s borrow checker. By forcing atomic values to be declared let, this proposal violates this guarantee and introduces doubt into every memory safety question for apparently arbitrary reasons.

3 Likes

The restriction only applies to property declarations that are concretely of type Atomic<T>, to prevent what is almost definitely a mistake. We can't completely prevent it from happening transitively through generic abstraction or composition within structs. Nor do I think we want to; although Atomic (currently) doesn't have any useful mutating API, it is useful to put atomics inside of other noncopyable structs that do have mutating API but also take advantage of atomics to maintain caches or other associated data in a way that's still referentially transparent and safe to borrow. Atomics themselves could grow mutating API in the future, which would allow for safe nonatomic access to the storage in cases where we statically know the code has exclusive access to the atomic; if that happens, I would say that the restriction should be loosened so that the use of Atomic as a var with dynamic exclusivity checking is what gets banned.

6 Likes

Is it possible to implement such a restriction? If so, why not just adopt that right now and avoid the whole issue of let vs. var?

Relatedly, C++ allows you to declare a reference to const atomic. If this imports to Swift as let, but let atomics allow mutation, won’t this break internal invariants in the C++ implementation?

// C++
class C {
public:
  const std::atomic<int>& atomic;
  C(std::atomic<int>& a) : atomic(a) { }
};

int main(int argc, char **argv) {
  std::atomic<int> atomic(42);
  C c = C(atomic);
  c.atomic.fetch_add(1); // error: no matching member function for call to 'fetch_add'
  std::cout << c.atomic.load() << std::endl;
  return 0;
}


// Swift
struct C: ~Copyable {
  public let atomic: Atomic<Int>
  init(_ atomic: borrowing Atomic<Int>)
}

func main() {
  let atomic = Atomic<Int>(42)
  let c = C(atomic)
  c.atomic.add(1) // no error?!?
  print("\(c.load())")
}
3 Likes

That's far from the only issue that C++ interop with atomics would have, to be fair. The way that mutable & maps to inout and const & maps to borrowing would be even more of a lie for atomic types than it is for C++ types in general, so would probably need special case handling. A read-only reference to an atomic is however more or less isomorphic to a get-only computed property of the wrapped type that does the atomic load in its implementation, so that might be a reasonable way of importing the API into Swift.

I think @ksluder has an important point with all this. Though I understand the pragmatist arguments for mutable atomic lets, they seem to be teetering on the edge of a slippery slope into 'madness', for the lay-programmer.

And as Kyle mentioned, the justifications for them have thus far been presented in very esoteric language terms, which I struggle to translate (or conceive being translated) to lay-programmer explanations.

I'm what you might call a definite Enthusiast of Swift, with a notably deeper knowledge of the language than average, but even I have to read and re-read a lot of these debate threads very slowly and carefully to grok the rationales and language behaviours.

The problem - that was unfortunately set up long before this proposal - is twofold:

  1. The Swift language defines var vs let as variables vs constants, right from the very start. It's literally the first concept introduced in the official Language Guide.

    Of course, as we all know, it then goes on to violate that deeply, once reference types are introduced and constants become suddenly not constant. Worse, you can't even tell from the use-site whether a let is actually a constant or not (even if you're just considering apparent behaviour, let-alone literal behaviour).

    But, just because you're in a hole already isn't a great reason to dig a deeper one.

  2. Swift doesn't distinguish between constant references and constant values. C's const int* const etc. So it's fundamentally incapable of expressing this basic nuance of mutability control.

I've never liked the behaviour of let with reference types, and it seems very obviously a hacky workaround to problem #2 (above). It confused me right at the outset - and still to this day - that I can declare a constant and then modify its value. It makes it practically impossible to use the language to control mutability - you have to do things Objective-C style with NSString vs NSMutableString patterns - which were more elegant in Objective-C because of the consistency with which they were used and how it interplayed with other aspects of the language, none of which is the case with Swift, but have always had big pitfalls even in Objective-C (e.g. the need to aggressively -copy all object inputs all the time, which almost nobody ever did, so nominally immutable values mutate underneath you all the time).

Emphasising that point, any time I read the words "you just need to make a wrapper" I die a little inside. Swift code is so full of boilerplate and ceremonial wrappers already, the last thing it needs is even more of them.

(also, Swift makes it unduly difficult to 'just' create wrapper types, because of things like the inability to forward protocol conformances, so while it always looks passable in trivial examples in these Forum threads, when you get real world, non-trivial types, making wrappers becomes an ordeal)

I don't know what this proposal should do, as it's just a victim of the existing language here. But there was mention of a potential way to make var work for atomics, through compiler improvements, and that does seem promising. If we can avoid let atomics, that will at least make this one part of the language / stdlib less confusing.

Or, put another way: an Atomic<T> for a value type T should itself behave like a value type, at least in this respect. I think that's part of what Kyle's alluding to with the atomic keyword hypothetical (which, to be honest, I find enticing - 'atomicness' is to me an attribute of variable, not a type).

17 Likes

First off, I would like to thank Karoy et al for the swift-atomics package which finally got me into concurrent-safe structures (I have been, for most of my career, on team-Mutex)

I think that Kyle's argument is sound and Pyry's question regarding composition is also valid.

The more I think about it, the stronger I believe that Atomic<T> should be scrapped in favor of extending UnsafePointer<T: AtomicValue> with load(at: offset: Int) and UnsafeMutablePointer<T> with the kitchen sink. This would:

  1. prevent having to break already established language rules
  2. ward off users that are not confident in their actions
  3. make storage explicit, without having to resort to some sort of implicit, Swift version of volatile
  4. since the language wouldn't support it, I guess C++ atomic imports would show up as Unsafe[Mutable]Pointer<EquivalentType>, complete with the "if you break it, you get to keep all the pieces" guarantee

While this may be shortsighted on my part, I don't see manual memory management as a barrier to anyone who is is even remotely likely to use them correctly.

1 Like

Experts make mistakes too, and it's just as important to provide sound abstractions to them wherever possible. The Atomic design here associates the atomic semantics with the storage in a safe and composable way, that fits within the language model, and also straightforwardly allows for inline storage of the atomic in its owner, and that last point was otherwise impossible up to this point. While atomics will always be an advanced feature, this is strictly better than the existing package in every way.

7 Likes

Interestingly, Joe pointed out to me offline that Rust atomics work the same way as this proposal:

// Rust
use std::sync::atomic::{AtomicI64, Ordering};

struct S {
    atomic: AtomicI64,
}

pub fn main() {
    let s = S {
        atomic: AtomicI64::new(42),
    };
    s.atomic.fetch_add(1, Ordering::AcqRel); 
}

If you were to add a normal i64 property to that struct, the compiler would prevent you from mutating it as you’d expect. This is why AtomicI64 is capitalized, to signify it is an “owned” type rather than a “borrowed” one. As a non-Rust programmer, I am quite surprised at this state of affairs.

But is that a convention in Rust (I'm genuinely unfamiliar)? If so, then that makes it a substantially different situation than for Swift, where there is no equivalent convention.

All user-defined typenames in Rust are capitalized as a matter of style, and the compiler complains if you don't capitalize them. It has nothing to do with the behavior of the type.

4 Likes

Rust struggled in their design with a similar problem here: the fundamental property handled by the borrow model is whether access is shared or exclusive, but for 99.9% of types developers see in normal code, the only practical distinction that arises from that is whether the access is immutable or mutable. They chose to focus the surface syntax on that mutability question, and leave the few people who have to deal with concurrency primitives to know that & really means "shared" and &mut really means "exclusive", and from a "progressive disclosure" standpoint that seems like a reasonable tradeoff to me. Adding syntactic layers between the underlying model and the surface syntax in an attempt to reduce surprise seems to me like it would ultimately obscure the underlying model, and increase the cognitive load in more abstract cases, since you would now have to understand that foo Atomic<T> really means bar U as a special case when U is an Atomic<T>.

4 Likes

Sorry, I don’t know where I picked that one up. Like I said, I am not a Rust programmer. :smiley: