Codable, SwiftConcurrency, Swift 6 - Fundamentally incompatible?

If I understand the original post correctly, the only reason there’s main actor annotated members is because they’re intended to be accessed by the main actor isolated view.

So, if you remove these annotations, initialise the class in the background and then transfer the resulting object using RBI and a transferring closure, you can then assign the class to a property within the main actor isolated view and use synchronously.

1 Like

Oh, I keep forgetting on that one. It definitely helps in certain cases (and in library code). Yet I wouldn’t go with it as main solution for model types as if you have to put isolation on most of the methods in such type, you probably be better with it isolated as whole.

2 Likes

Honestly - I think you are all over-thinking this stuff.

Now, perhaps Swift is forcing you to work in these (imho) over-engineered ways. And that's my problem with the language as it stands.

Here are the reasons for me wanting to use @MainActor and Codable:

  1. Using codable
    I want to save my state. Codable is fantastic for this.
    Apple describes it as "Swift's native serialization method" for good reason.

  2. Using @MainActor
    My class interacts with (is updated by and read from) background operations - and also stores state which drives the UI.

Marking UI properties @MainActor is a fantastic way to (mostly) get the compiler to check that I meet the requirement to only update UI properties on the Main thread.

This stuff isn't complex. It shouldn't be controversial. These are entirely vanilla issues faced by swift users all the time. I'd go as far as to suggest that the main purpose of concurrency for most people is to move expensive work off the main thread - so it can then update the UI when it is done.

My problem is that "Swift's native serialization method" doesn't work with concurrency - and I want both. I don't think that's unreasonable.

When you suggest that I could do something complex with transferring closures then that sounds like pain.

This is only one step away from - just ignore concurrency. Go back to the old ways, and make sure you (manually!) remember only to make changes on the main thread!

When you suggest that I could redesign my app so that I don't need to mark my properties with @MainActor because I ensure by design that I only act on @MainActor - then that seems risible. We all know how good coders (certainly me) are at guaranteeing never to access data from the wrong context manually. The entire premise of modern concurrency is that we're bad at it and the compiler is better.

Swift has given us @MainActor. I'm going to use it. Marking UI properties is about the most obvious use-case I can think of.
I expect other Swift stuff to deal with that!

2 Likes

There is nothing dangerous about initialising on one thread, then passing the object to another thread.

No danger of race conditions or other concurrency fails providing the sender doesn't use (or implicitly change) the object after passing it off.

Indeed - swift is starting to reason about these cases in the compiler.

But that's not the issue I'm hitting anyway. Sendable wouldn't help.

Except that's not the semantic promise you're making to the compiler. By not marking your type as Sendable, you're telling the compiler that there is a danger of race conditions were a value of this type being transferred between isolation regions (/threads/whatever), and that you promise not to do it or else you invoke undefined behavior.

Making this promise to the compiler is spelled Type: Sendable.

That's because you're still marking your type as @MainActor, which means "the properties on this type are only safe to touch on the main actor; you can pass this object around, but only read from it on the main actor" (which means that encode(to:) could only ever be called on the main actor, which you can't promise).

If this object really is a bog-standard data object, you can just write

class Foo: @unchecked Sendable, Codable {
    var bar: Int?
}

and get everything you want.

2 Likes

Imaginary Apple announcement

Swift brought native serialization with Codable, and compiler safe concurrency.

With Swift 6 you can get the ergonomics of Codable as well as the safety of modern concurrency.
AsyncCodable now provides all the convenience of Codable - but will operate and generate conformances on the appropriate isolation region for your class, struct or actor.

Most of the time, the compiler can figure out what isolation region it should generate conformance on - and the encoder or decoder will access that asynchronously.

In cases where you have mixed isolation, then you can manually define async encode(to: or decode(from:

This is EXACTLY my point.

If you want to use "Swift's native serialization system", then you can't also use SwiftConcurrency.

To me, that's an epic fail.

I understand why the language as it stands fails so hard here - but it seems absurd to me that Apple is pushing this language mode with such a crazy gap in functionality.

  • Ergonomic Serialization
  • Safe Concurency

Pick One :rofl: :man_facepalming: :exploding_head:

The reason I initially posted this thread was because I kinda assumed I had to be missing something. Surely Apple wouldn't be pushing us towards something so half-assed?

I have learned a few handy modifiers in this thread to 'YOLO' my way around the warnings - but clearly 'pick one' is the current state of play.

This isn't true.

It's totally possible to initialise any non isolated type in any isolation context. That type can conform to any non isolated protocol (such as Codable, Hashable, Equatable, Sequence and many 100s of others). With a tiny bit of boilerplate (actor isolated wrapper) you can transfer that instance across an isolation context today. And in the near future that will be made simpler.

The fact is, a non isolated class with mutable members isn't thread safe. Marking a couple of members with @MainActor doesn't make it so. In fact, it just fragments its members across isolation domains and forces your asynchronous work back to the main thread – which is surely the situation you were trying to avoid by initialising it asynchronously in the first place.

EDIT: Turns out the near future is closer than I thought. It's available in Swift 6.

4 Likes

That’s not what @tcldr suggesting. The suggestion is to create instance (non-isolated) in the background, then transfer it to the main actor context. All ensured by compiler, not loosing of any benefits. Using MainActor everywhere, without considerations on isolation boundaries design is not what Swift aims for. Solutions here are aimed to keep benefits of this compiler checks, not to suppress them.

4 Likes

I want to stress this, because I think it is the core of why people are struggling. It definitely is for me.

Swift concurrency does not support this arrangement without unsafe opt-outs. A Swift 6 compiler can sometimes accomidate it via region-based isolation. And further, I think all of the situations were this could work would remain valid if the MainActor isolation were removed.

1 Like

I may be missing your point - but to me, this is the core of what modern concurrency supports.

from some code that isn't guaranteed to be Mainactor, use async methods and the following:

read with
let foo = await instance.mainActorProperty

write with

await MainActor.run {
instance.mainActorProperty = newValue
}

am I misunderstanding your point?

As a point of interest, rather than a recommendation, I believe if you really want to avoid the unsafe opt-out, you can use a wrapping type that will permit the transfer.

class NonSendable {}

@MainActor struct Wrapper {
  
  let ns: NonSendable
  
  init(_ ns: NonSendable) {
    self.ns = ns
  }
}

func createInBackground(_ completion: @escaping @MainActor (Wrapper) -> Void) async {
  let ns = NonSendable()
  let wrapper = await Wrapper(ns)
  await completion(wrapper)
}

But in the meantime I'm happy with the unsafe opt-outs:

func createInBackground(_ completion: @escaping @MainActor (NonSendable) -> Void) async {
  nonisolated(unsafe) let ns = NonSendable()
  await completion(ns)
}

Until finally we get transferring:

// note additional closure annotation
func createInBackground(_ completion: @transferring @escaping @MainActor (NonSendable) -> Void) async {
  let ns = NonSendable()
  await completion(ns)
}
1 Like

The sending keyword is available in Swift 6 and it is great!

2 Likes

So it is! Thanks!

So really, background initialisation of a non sendable type isn't harder than marking a parameter as sendable:

nonisolated func createInBackground(
  _ completion: @escaping @MainActor (sending NonSendable) -> Void
) async {
  let ns = NonSendable()
  await completion(ns)
}
3 Likes

I think so.

My point is that your non-Sendable instance value is by definition only accessible in one isolation domain. The compiler will not allow you to access it from both background operations and the MainActor.

await MainActor.run {
    instance.mainActorProperty = newValue
}

For this to be valid, instance must either be already on the MainActor or possible to transfer to it (a Swift 6 feature). And in both cases, it is not possible to access from a non-MainActor context. It is non-Sendable.

So, if you really are creating this object in the background and then exclusively accessing it from the MainActor, the isolation is both unnecessary and problematic. But if you are later on accessing this class from a background thread, you have a race condition*. And the compiler should be finding that unless there are some opt-outs being used.

* as far as the compiler is concerned. I'm not implying you have a real bug!

5 Likes

The way I know I'm exclusively accessing it from MainActor is by marking the relevant properties as @MainActor.
That lets the compiler tell me if I inadvertently do wrong. So, technically - I guess I don't need the annotation as the program runs, but I certainly need it as I develop.
(and of course - the compiled code isn't annotated - it's just generated so that it actually accesses on the right thread)

Agreed.

Considering the initial example:

class Doc: Codable {
    var foo: String
    @MainActor
    var title: String
}

Once you are trying to access title in the way you suggest,

nonisolated func offMain(doc: Doc) async {
    let title = await doc.title // error here
}

You'll get the error: non-sendable type 'Doc' passed in implicitly asynchronous call to main actor-isolated property 'title' cannot cross actor boundary at the line you are awaiting property, because Doc isn't Sendable and can't cross boundary. The way you want read/write this main actor isolated property on non-isolated non-Sendable type is the source of data races.

Sure. I get that. And as I get pushed along the swift 6 treadmill, increasingly the compiler will insist that I do this stuff in a provably correct way.

But it feels like we're going down rabbit holes here. I don't currently have any issues with this kind concurrency design.

The thing I do have an issue with is:

Except! There is no such thing as "inadvertently doing it wrong" with Swift's concurency model. The compiler will not allow you to make the mistakes you are worried about. You are not giving up any safety by removing these annotations! This is kind of the magic of non-Sendable types.

And I understand why you are struggling with protocols here. A type like this is incompatible with all non-isolated protocols that use synchronous methods, not just Codable.

3 Likes

Right.

It seems insane to me that the key plumbing of the language hasn't been updated to play nicely with isolation.

1 Like