Swift Atomics, Playgrounds, Swift 5.9 and move-only types

Recently that there had been several commits to swift-atomics that seemed to indicate that the retain_n/release_n issues were being addressed in Swift 5.9. So today I checked whether using swift-atomics under Xcode 15 playgrounds was possible. I was pretty excited to note that (modulo a fairly minor Package.swift problem) using the tip of main in a playground actually seems to work! thanks for great work @lorentey!

But that got me thinking about this comment concerning atomics and move-only types.

Looking at SE-390, I read:

=========================
This means a noncopyable type cannot:

* conform to any protocols, except for `Sendable`.
* serve as a type witness for an `associatedtype` requirement.
* be used as a type argument when instantiating generic types or calling generic functions.
* be cast to (or from) `Any` or any other existential.
* be accessed through reflection.
* appear in a tuple.

==========================

ManagedAtomic would seem to meet all of the requirements for being move-only type, but I'm confused: isn't the whole point of swift-atomics that two or more threads are holding a reference to shared memory and can arbitrate access to that memory using the same atomic? I'm not seeing how I can obey the Law of Exclusivity with regards to ownership of the Atomic itself.

Enlightment would be appreciated.

1 Like

Operations that mutate atomics can be borrowing operations, because they're atomic.

2 Likes

So currently I have some code that looks like the following:

import Atomics

enum Either<Left, Right> {
    case left(Left)
    case right(Right)
}

final class Both<Left, Right>: AtomicReference {
    let left: Left?
    let right: Right?

    public init(left: Left? = .none, right: Right? = .none) {
        self.left = left
        self.right = right
    }
}

@Sendable func barrier<Left, Right>(
    _ left: Left?, _ right: Right?
) -> @Sendable (Either<Left, Right>) throws -> (Left, Right)? {
    let atomic: ManagedAtomic<Both<Left, Right>> = .init(.init(left: left, right: right))
    return { leftOrRight in
        switch leftOrRight {
            case let .left(lValue): try setLeft(atomic, lValue)
            case let .right(rValue): try setRight(atomic, rValue)
        }
    }
}

struct Barrier<Left, Right>: Sendable, ~Copyable {
    let run: @Sendable (_ value: Either<Left, Right>) throws -> (Left, Right)?

    @Sendable init(left: Left? = .none, right: Right? = .none) {
        run = barrier(left,right)
    }

    @Sendable func callAsFunction(_ value: Either<Left, Right>) throws -> (Left, Right)? {
        try run(value)
    }
}

(I'm leaving out the implementation of setLeft and setRight for brevity). When you say:

Operations that mutate atomics can be borrowing operations, because they're atomic.

Do you mean that I could:

  1. let Both be a non-copyable
  2. not use the ManagedAtomic wrapper there
  3. make the returned closure and setLeft, setRight be borrowing and
  4. have it all work because the mutation operation is atomic automatically?

What he means here, is that any operation that mutates the underlying atomic value atomically doesn't have to be mutating because we can perform atomic stores and such with just a borrow. Mutations in Swift are not atomic automatically. Because atomic store operations and the like can all be borrows, from the Law of Exclusivity's eyes we're just reading the value and we can perform multiple reads from different threads at the same time.

I’d suggest a semantic tweak here: from the Law of Exclusivity’s perspective the update is instantaneous, so it cannot overlap with another read or write. The enforcement of that happens by treating it as a borrow. But reads and writes and updates all have to be effectively instantaneous for this to be true; otherwise, Thread A could borrow the value for several statements, assuming it won’t change, and Thread B could write to it in the middle.

2 Likes

The Law of Exclusivity already contains an opt-out for atomic operations, so they are allowed to result in overlapping accesses just like reads can (although - importantly - they are only allowed to overlap with other atomic operations, just as nonatomic reads may only overlap with other nonatomic reads):

To resolve this problem, we propose to introduce the concept of atomic access, and to amend the Law of Exclusivity as follows:

Two accesses to the same variable aren't allowed to overlap unless both accesses are reads or both accesses are atomic.

SE0282 - Clarify the Swift memory consistency model

Ok, that makes sense to me.

But then I don't understand this statement from the non-copyable types section of the announcement thread about swift-atomics 1.1 though:

I expect Swift Atomics 1.2 will introduce Atomic<Value> soon after the language matures enough to support it, if and when that happens. (I also expect the Standard Library to start providing the same or (similar) construct at that point, eventually replacing the need for this package altogether.)

Assuming that Atomic<Value> is a non-copyable type, I don't see what the syntax would be for being able to reference instances of that type in mutliple threads. Assume it's a simple counter, how can I invoke the increment operation on that counter from two different threads without sharing the non-copyable Atomic<Value> in a non-borrowable way.

Interestingly under Xcode 15 RC1, the following playground compiles (unexpectedly to me, bc I would have thought that myNonCopyable would inherently not be accessible there):

struct MyNonCopyable: Sendable, ~Copyable {
    func someConcurrentOperation() -> Void { }
}

let myNonCopyable = MyNonCopyable()

let t1 = Task {
    myNonCopyable.someConcurrentOperation()
}

let t2 = Task {
    myNonCopyable.someConcurrentOperation()
}

But fails with the following log in the Playground log window:

error: couldn't IRGen expression. Please enable the expression log by running "log enable lldb expr", then run the failing expression again, and file a bug report with the log output.

You could only share the borrowed reference to the non-copyable atomic value. Gaining access to an inout or consuming binding of the non-copyable value means you have exclusive access to it.

I feel like I must be missing something everyone else is seeing.

Below is the standard simplest example of using an atomic as a counter. I just ran this in a playground using the tip of main on swift-atomics and verified it works as expected.

If the ManagedAtomic in this example becomes AtomicValue<T>: ~Copyable type (as clearly discussed in this thread), how can I access that Atomic<Int> from two different Tasks concurrently to increment the counter? If the answer is: "you can't" then when would I ever use a ~Copyable Atomic? And how would I modify this code using borrowing/consuming to achieve the concurrency this requires?

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

import Atomics

class IntRef { var i: Int = 0 }

let atomicValue: ManagedAtomic<Int> = .init(0)
let nonatomicValue: IntRef = .init()

Task {
    let t1 = Task<Void, Never> {
        for i in 0 ..< 100_000 {
            atomicValue.wrappingIncrementThenLoad(by: 1, ordering: .relaxed)
            nonatomicValue.i += 1
        }
    }

    let t2 = Task<Void, Never> {
        for i in 0 ..< 100_000 {
            atomicValue.wrappingIncrementThenLoad(by: 1, ordering: .relaxed)
            nonatomicValue.i += 1
        }
    }
    await t1.value
    await t2.value

    print("done. atomicValue = \(atomicValue.load(ordering: .relaxed)), nonatomicValue = \(nonatomicValue.i)")
    PlaygroundPage.current.finishExecution()
}
1 Like

fwiw, the above prints:

done. atomicValue = 200000, nonatomicValue = 199906

when run in a Swift 5.9 playground on Xcode 15 RC1 on a MacBook Pro M1 and clearly shows the race condition that using an atomic solves.

The Task initializer takes an escaping closure which means immutable non copyable captures are done via a borrow (there's more to read here: https://github.com/apple/swift-evolution/blob/main/proposals/0390-noncopyable-structs-and-enums.md#noncopyable-variables-captured-by-escaping-closures). So if there was a non copyable atomic type, we must ensure that we don't try to mutate (reassigning the variable or calling mutating/consuming methods) the value inside this closure to ensure that we only borrow the value instead. I think there's a bug right now where the compiler consumes the value even if you only borrow it, but @Joe_Groff did mention saying that we could box the value and pass this box to the closure. Assuming we do have a borrowed reference to a non copyable atomic value, we can perform atomic updates because those methods would just be marked borrowing and as pointed earlier, the Law of Exclusivity was amended to allow 2+ atomic ops to occur on the same value at the same time as long as they are atomic.

1 Like

Assuming we do have a borrowed reference to a non copyable atomic value, we can perform atomic updates because those methods would just be marked borrowing and as pointed earlier, the Law of Exclusivity was amended to allow 2+ atomic ops to occur on the same value at the same time as long as they are atomic.

Two observations follow directly from that.

  1. If I have a borrowed value, and if borrowing guarantees exclusivity, then why do I need the atomic wrapper? I should just mutate the borrowed value and be done, no need for a wrapper. I'm just not following what the ~Copyable Atomic value that @lorentey discusses is going to do.

  2. The example "counter" above shows exactly why I need the atomic. Unless the borrowing closures given to each Task are somehow locking and unlocking access to the borrowed resource for the duration of the borrowing call, then I'm not understanding how I can borrow a value between two asynchronously running tasks. I understand the emendation that @Karl points out above, and it seems to cover precisely this case. What I don't understand is what the syntax for marking the "yes-this-value-is-~Copyable-but-it-mutates-atomically-and-so-its actually-ok" type is going to be.

The Law of Exclusivity already contains an opt-out for atomic operations,

As I mention in another comment but less clearly: how does one access the "opt-out"? Has syntax for doing that been discussed that I missed?

And, ohhhh... I now understand @Joe_Groff's comment:

Operations that mutate atomics can be borrowing operations, because they're atomic.

But I don't understand how the compiler can distinguish "this kind of ~Copyable type has atomic mutation semantics" from the kind that don't have such semantics.

Borrowing guarantees that no one has exclusivity during the borrow, and only shared accesses are possible. An inout or consuming operation is by contrast a guarantee of exclusive access, and you're right that in principle that would allow nonatomic access to the contained value while you know you have exclusive access.

2 Likes

Ok, so I don't want borrowing in the counter above. But I can't have two consumings. Does this mean that there would additional syntax to cover this atomically mutating non-copyable case?

I have to admit that I reread this discussion several times when it was ongoing and just didn't follow what was intended. I think now I may be getting a clue if the idea is to specifically mark the atomically mutating case for the compiler.

@Alejandro has just posted a pitch for Atomic<T> on the evolution forum -- the text has some examples that illuminate the concept a little.

You can do atomic operations on borrowed Atomic values, despite the fact that they may change the stored value. (As Karl mentioned, this is exactly what the accepted revision of SE-0282 was mostly about -- it introduced the idea of "atomic" access to avoid having to do galaxy-brain mental gymnastics like categorizing certain mutating operations as "read-only".)

In fact, borrowing operations are the only way to perform concurrent atomic access in Swift. If we defined any consuming or mutating operations on Atomic<T>, then it would not be possible to call them concurrently -- by definition, any concurrent, overlapping calls would count as exclusivity violations. The consuming/mutating modifiers are explicitly incompatible with overlapping concurrent use, by design.

(This doesn't necessarily mean that such operations would be entirely useless, though. For example, we've been playing with the idea of adding a consuming func dispose() operation that returned the "final" value of the atomic cell before destroying it. However, it doesn't seem this would carry its weight, and the restrictions on its use would very likely be too confusing to consider.)

2 Likes