Sendable: Taking Swift 5.6 for a spin

Hello,

I have deeply enjoyed using actors and async/await in my codebase, and decided to take a look at Swift 5.6 stricter concurrency checks would do to it.

The good news is, the Sendable checks indirectly found a couple of mistakes in my code, but in other cases, I am not sure whether my solutions are correct, the compiler checking is correct, or if I generally misunderstand the philosophy of using actors.

I am using an actor to funnel all API calls to a native library that has certain threading requirements, and I am very happy with the results.

Here are the notes that I took with my struggles, hopefully these will be useful to the language designers, and maybe someone can steer me in the right direction:

Sendable Challenges

I am calling into an actor method with await, like this:

class Identity {
   var username: String
}

actor myActor {
    func method (name: String) {}
}

class Demo {
    var identity: Identity

    func stuff () async {
       await myActor.method (name: identity.username)
    }
}

The above call to method produces this error:
cannot use property 'identity' with a non-sendable type 'Identity' across actors

Ok, the issue is that I am accessing the identity from the async function, and it is not itself sendable, so I am guessing it could be poked by externally?

Weak Fields

The following is tricky:

class Demo: Sendable {
    weak var other: MyActor!
}

It complains that other is mutable. The challenge is that I do not want to introduce a strong cycle by keeping a reference to the actor. Does this mean that actors should not participate in these cycle prevention things?

Observation: this I sort of worked around in one case, I made it strong, but it makes me wonder about how I would use this idiom in practice.

Multi-stage initialization

I need to initialize my Actor twice, not because I want, but because I have not found any other way to solve this.

I am interoperating with a C library that takes a closure and passes this closure to callbacks for these callbacks to find their purpose in life. What I do is that I create an opaque objects that wraps self. But because I am referencing self, I have to first initialize my actor variable, so I initialize it with a fake actor first, then grab self, then reset it.

But this produces the following error:
Stored property 'actor' of 'Sendable'-conforming class 'Demo' is mutable

actor MyActor {
    init (fakeSetup: Bool) {}
    init (handle: OpaquePointer) { 
       call_c_library_with_handle (handle)
    }
}

class Demo: Sendable {
   var actor: MyActor

   init () {
      actor = MyActor (fakeSetup: true)
      let handle = opaqueHansle = UnsafeMutableRawPointer (mutating: Unmanaged.passUnretained (self).toOpaque ())
      actor = MyActor (handle: handle)
    }
}

Passing Data to an actor

It does not seem possible to pass Data to an actor, even if it is a let Data. It seems like either Data should be sendable, or the fact that the Data is a constant and not used later taken into consideration:

func cuteApi (data: Data) {
    // We know here that we can not poke at 'data' at all, so why is this a problem?
    myActor.doSomething (with: data)
}

DispatchQueue.main.async

In various places, I use async methods like this:

class MyView: UIView {
   var label: UIView

   func someCuteBackgroundMethod () async {
      workWork ()
      // Update the UI
      DispatchQueue.main.async {
         self.label.text = "Done"
      }
   }
}

But I get the following error:
Capture of 'self' with non-Sendable type 'MyView' in a @Sendable closure
No problem, I went and slapped a Sendable on my UIView, but I am wondering if this is the right thing to do. It did get rid of a ton of issues, so I am crossing fingers that this was the right move.

Callbacks are problematic

In a number of places I pass a callback that I need to invoke at a later point, like these:

class Demo: Sendable {
   let readCallback: (Demo demo)async ->()
   init (readCallback: (Demo) async->()) {
     self.readCallback = readCallback
   }
   func doStuff () async {
      await readCallback (self)
   }
}

But the compiler complains like this:
Stored property 'readCallback' of 'Sendable'-conforming class 'Demo' has non-sendable type '(Demo) async → ()'

I do not understand why this is a problem, the Demo class itself is sendable, as in, it is safe to access its properties concurrently, given than the readCallback is a let variable, initialized only once. I am puzzled by this error.

Quick hacks

To get over a bunch of errors, I added Sendable conformances to various built-in types, these are dubious decisions, but they were done based on my assumption that I knew how I was using them, and I believe their usage is correct (due to the fact that I do not mutate them), or their purpose is to help me implement Sendability, these are:

  • Data
  • SecKey
  • DispatchQueue
  • DispatchSemaphore
10 Likes

Yes. Specifically, there is nothing preventing me setting identity from another function that is executing concurrently with stuff. This code is unsafe.

I don't think this has anything to do with weak, and is instead entirely to do with the fact that var other is mutable from arbitrary contexts without synchronisation.

As written this is unsafe, for the same reasons as noted above (var fields can be mutated from arbitrary locations). You can make this safe in a number of ways, the easiest of which is to make it private(set) and then you can mark this class as @unchecked Sendable. The compiler cannot prove that your use of var actor in this way is safe, so you are going to have to assert that it is.

I believe the SDK has not been fully updated to be Sendable-conformant and so some types are not marked as sendable, though they should be. I believe Data is one of them.

Rather than use DispatchQueue.main.async, dispatch a task to the main actor: MainActor.run. This will correctly take account of the fact that your UIView subclass is @MainActor.

The Demo class is, but the callback may not be. For example, I could write this:

var foo = 1
let demo = Demo { _ in
    // Mutating shared global state! BAD!
    foo += 1
}

await withTaskGroup(of: Void.self) { group in
    for _ in 0..<100 {
        group.addTask { await demo.doStuff() }
    }
}

assert(foo == 100)

This is an important note: a closure's arguments and return value may be Sendable, but whether a closure is Sendable is also about it closes over.

See my above note about the SDK.

1 Like

I don’t actually see a problem with identity there; that looks like a bug in sendability checking.

Hmm. As I reread that I think I agree. I found the pattern there reasonably confusing.

Because this class is supposed to be inferred as @MainActor, it's also supposed to be inferred as Sendable, as @MainActor-constrained objects can always be safely shared between concurrent contexts (because we can directly enforce that their uses are appropriately restricted). So this annotation is fine, but the fact that you need it looks like a bug.

Oh, this interaction is unfortunate, since you can't define a weak let. I wonder if people would hate it if we lifted that restriction. I have never thought of the fact that reading a weak reference can start yielding nil as a mutation — it conceptually remains a weak reference to the same object even if the object is no longer accessible — and I've noticed that people with that mental model seem to find it inconsistent that we don't have stricter rules around e.g. structs that contain weak properties.

8 Likes

That's quite the pickle; sorry about that! It seems the primary reason you had to try turning this into a two-stage initialization is the heavy restrictions on self in the actor's initializer that exist in Swift 5.6.

I'm happy to report that with the implementation of SE-327 aka "flow isolation", you won't need to workaround that issue in the next release of Swift. Right now on main, this is correct and compiles without any warnings / errors:

func call_c_library_with_handle(_ handle: UnsafeMutableRawPointer) {}

actor MyActor {
  var whatever = 10
  init() {
    let handle = UnsafeMutableRawPointer (mutating: Unmanaged.passUnretained(self).toOpaque())
    call_c_library_with_handle(handle)
    // whatever = 11  // <- still would need to appear before `handle` with SE-327.
  }
}

Now, only if you uncomment the assignment of whatever, which happens after turning self into a raw pointer, there will be a warning that looks like this:

feedback.swift:X:14: warning: cannot access property 'whatever' here in non-isolated initializer; this is an error in Swift 6
    whatever = 11
    ~~~~~~~~~^~~~
feedback.swift:X:63: note: after calling static method 'passUnretained', only non-isolated properties of 'self' can be accessed from this init
    let handle = UnsafeMutableRawPointer (mutating: Unmanaged.passUnretained(self).toOpaque())
                                                    ~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~

which I hope is understandable for folks (open to suggestions).

4 Likes

If you’re looking for feedback, that warning is a little confusing. If I “cannot” do something, shouldn’t it be an error? How about “warning: accessing isolated property ‘whatever’ in non-isolated initializer; this will become an error in Swift 6”

4 Likes

Not to hijack Miguel's thread too much, but thanks for the feedback! For the release of Swift 5.5, we made the restrictions on self in actor inits warnings, since the race issues were discovered a bit too late. Since we plan to tighten up the concurrency model in Swift 6, I decided to keep the new, more flexible restrictions as a warning until Swift 6, where it becomes an error.

The concern I have with this wording is that it may not be precise enough. The 'whatever' property can be accessed in many places, just specifically not after escaping or copying self. Consider this slightly larger example:

func call_c_library_with_handle(_ handle: UnsafeMutableRawPointer) throws -> Int { 0 }

actor MyActor {
  var whatever: Int
  init(with val: Int?) {
    whatever = 0
    if let val = val {
      whatever = val
    } else {
      // note: after calling static method 'passUnretained', only non-isolated properties of 'self' can be accessed from this init
      let handle = UnsafeMutableRawPointer (mutating: Unmanaged.passUnretained(self).toOpaque())
      let ans = try? call_c_library_with_handle(handle)
      if let ans = ans {
        whatever = ans // warning: cannot access property 'whatever' here in non-isolated initializer
      }
    }
  }
}

Here we have a situation where whatever gets assigned in both branches of an if-else, but only one of them is incorrect. Since the whatever = ans would happen after escaping self, so we need to raise the warning / eventual error. But the whatever = val is totally OK. Perhaps my message is too wordy or precise?

2 Likes

It sounds to me like Kyle was just reacting to the use of "cannot" for a diagnostic that surfaces as a warning. I feel like we could keep almost the same text just without using "cannot," e.g., "illegal access to property 'whatever' here in non-isolated initializer; this is an error in Swift 6" (along with the note showing why it's illegal). Or maybe "unsafe" instead of "illegal"? Or "cannot safely access property 'whatever' here..."?

2 Likes

It sounds to me like Kyle was just reacting to the use of "cannot" for a diagnostic that surfaces as a warning. I feel like we could keep almost the same text just without using "cannot," e.g., "illegal access to property 'whatever' here in non-isolated initializer

Ahh I see and that makes sense. Thanks for clarifying :slight_smile:. I'll tweak the wording there.

1 Like