AsyncStream and Actors

Oh dear, yes this is another post from a seasoned developer struggling with the strict checking in Xcode 5.10 highlighting how everything they thought they knew and understood about swift concurrency was wrong (or should I say (unsafe)).

Here is one example. I have been using an AsyncStream to serialize reads coming from a classic Thread API, into a Task that awaits new frames of data. Switching on strict checking lights up my code in golden warnings, so I tried to use an actor to take control of things...

actor ActorSource {

    private lazy var frames: AsyncStream<Data> = {
            AsyncStream { (continuation: AsyncStream<Data>.Continuation) -> Void in
                self.frameContinuation = continuation
            }
        }()

    private var frameContinuation: AsyncStream<Data>.Continuation?

    lazy private var frameIterator = frames.makeAsyncIterator()

    func nextFrame() async -> Data? {
        // Error: Cannot call mutating async function 'next()' on actor-isolated property 'frameIterator'   
        await self.frameIterator.next()
    }

    func receiveData(newData: Data) {
        frameContinuation?.yield(newData)
    }
}

I don't see why the compiler is unhappy with this. The only way I was able to compile this functionality into an actor was by declaring frames, frameContinuation and frameIterator as nonisolated(unsafe), which makes me rather uneasy. Even though that code compiles without complaint or golden warnings, I don't believe this is actually thread safe, as it seems to me the actor isolation is not enforced here.

If I revert the actor to a class, where I restrict the functions interacting with the AsyncStream to a global actor, it appears to me to be thread safe.

class ClassSource {

    private lazy var frames: AsyncStream<Data> = {
            AsyncStream { (continuation: AsyncStream<Data>.Continuation) -> Void in
                self.frameContinuation = continuation
            }
        }()

    private var frameContinuation: AsyncStream<Data>.Continuation?

    lazy private var frameIterator = frames.makeAsyncIterator()

    @MainActor
    func nextFrame() async -> Data? {
        await self.frameIterator.next()
    }

    func receiveData(newData: Data) {

        Task { @MainActor in
            frameContinuation?.yield(newData)
        }
    }
}

However reverting to a class lights up a series of other warnings, in other functions in the class where Tasks access self, about the class not being Sendable. If I tell the compiler I have dealt with the thread safety by declaring it Sendable, I get a warning about the AsyncStream frames being mutable. I can get rid of that warning by making it conform instead to @unchecked Sendable, but again I feel uneasy about what other compiler checks I may be losing out on.

So my questions are:

  1. Why can I not use an AsyncStreamIterator as a property of an actor ?
  2. What is the recommended pattern for using an AsyncStream safely between a classic GCD API and swift concurrency ?

As a side note, I have to say some of the terminology of swift concurrency really makes things difficult for me to reason about. Sendable is perhaps the worst example. I finally got it recently when I saw a post from HollyBora explain that Sendable actually means ThreadSafe. I wish it had been called that from the outset, it would have simplified my understanding tremendously. I am tempted to use a type alias, to help me think about my code that way.

I am having to rethink logic and structure throughout my apps as a result of the Swift 5.10 strict checking, which is immensely frustrating. I hope that the changes I am making are really actually needed to improve concurrent safety, but in some cases I wonder if I am being forced to jump through hoops to make changes that are not actually necessary...

7 Likes

That seems to be covered with this proposal - swift-evolution/proposals/0420-inheritance-of-actor-isolation.md at main ¡ apple/swift-evolution ¡ GitHub

Yet it is not going to be available until Swift 6, so right now I suppose the only way to handle this is to make function nonisolated.

1 Like

TL;DR: because it's both a sendability and exclusivity violation waiting to happen.

As @vns rightly posts above, the referred pitch uses AsyncStreamIterator as exactly the motivating case for the new behaviour.

Note also, however, that actor isolation isn't giving you what you want with this spelling. In particular, while you are suspended in AsyncStreamIterator.next, another call can come in to nextFrame and call that function again. Because the function is mutating, this is an immediate law of exclusivity violation.

Inheriting the isolation domain solves both problems, and that's what you need here.

3 Likes

Would it kill the compiler to elaborate in its error message, here? Your explanation (@lukasa) makes sense, but there's really no way someone could be expected to deduce that from the compiler's output.

Cannot call mutating async function 'next()' on actor-isolated property 'frameIterator'

Why not?

  • Is "mutating async function" relevant or merely describing the type of next?
    • Is the crux of the problem that the method is mutating?
    • Is the crux of the problem that the method is async?
    • Is the crux of the problem that the method has some special isolation requirement?
  • Which actor is it referring to? ActorSource or the async iterator type itself?
  • Is the frameIterator stored property somehow not properly isolated to the ActorSource actor?
  • Is it something to do with the stored property being lazy?

I really wish the compiler would explain itself, rather than just make pithy statements.

11 Likes

Thanks for the explanation, and pointer to the evolution proposal.

I'm struggling with interpreting the esoteric compiler messages and paucity of the information on concurrency in the Swift Programming Language book. GlobalActors are not even mentioned for instance. It is a pity this book is no longer updated on iBooks. I don't like the web based version, which is difficult to search and is not available offline. I find it particularly disappointing that the new concurrency chapter has multiple links out to the apple developer documentation rather than completing a full self contained explanation of the topic in the book itself.

One other warning I am seeing in the code above with strict checking turned on is that Data is not Sendable. I know the data I pass through the AsyncStream is not at risk of being changed elsewhere by my code, but are there any particular issues with Data and swift concurrency that need particular attention ?

4 Likes

Data itself conforms to Sendable, so I suppose it is not the issue there. If to think about initial goal, it seems that simply wrapping your existing API into a stream should be enough, eliminating the need to create a type with mutations in it. Because even without fighting error with next there are still so much going on, including the case @lukasa mentioned about potential races on nextFrame method.

Maybe it is a wrong take from me, but I see several distinct complications with Swift Concurrency right now. The first one, probably the major one, is that it is not finished yet. We are still using concept that is evolving and completing, like with the latest Swift release we just got completed sendable checks. So it is somewhat expected that some things might break, which does not make it less painful in any way. The second, is that concurrency itself is a hard thing to get, it is complex, and no amount of compiler checks will make it easy. Take Rust, claiming "fearless concurrency"', so far it is not even near fearless, with runtime not even being part of the language. Swift so far looks much more promising, but still complex. Finally, on the book side, its aim is to give a first perspective into language with just right amount of tools, and global actor happened to be not one of them. And global actors are advanced concept, that rather be avoided when you are a novice to the whole idea of concurrency or language itself. Proposals cover a lot of missing pieces, but the downside of them being fragmental, which makes hard to build the full picture out of it. There were notion in one of the topics, that there is work going on related to concurrency coverage in more details, but I assume it won't be published until Swift 6, since that is the point where the concurrency will be completed.

As a side note to that, with latest release I find hard to understand many points on program structure. And state on the main branch with -swift-version 6 scares me a lot with so many things throwing errors, even on projects that has strict concurrency enabled and no warnings.

2 Likes

Even if your assertion is true in principle, it's unfortunately not in practice - most developers encounter @MainActor (and other Swift Concurrency mechanisms) very early in their development (it's heavily used in SwiftUI, for example). So those concepts really shouldn't be advanced (nor complicated). Ideally, at least.

Concurrency isn't fundamentally as hard as it's often made out to be. It's usually just a language and/or tooling problem. e.g. Google does a few things concurrently, fair to say, and yet most of their code is C++. Some surprisingly simple mechanisms make that possible (with reliability and at scale), like language extensionsš to declare which locks protect which variables, and in what order locks must be taken (if held concurrently), etc.

I agree that concurrency in Swift is in the midst of its awkward teenage years. It's unclear what it's going to be when it finally grows up. Here's hoping it's good. :crossed_fingers:


¹ I don't recall if they required compiler changes, or to what extent… I think a lot of it was just macros that various tools could reason about. Like type hints in Python.

4 Likes

Well, with Apple's current policy "put @MainActor in every unclear situation", I still think it is OK. Just because of understanding global actors it won't make sense to everything else. I find it more bearable for devs with little to no experience left this topic for later and blindly use it for a while, because otherwise it is too much to process, and they end up not understanding it at all - but now they also think that they understood.

If they should not be advanced or not hard to judge for me. I lean towards idea that complexity have to live somewhere. Which doesn't mean we shouldn't try to fight it anyway. But when it comes to the language design and implementation, I have no experience to say definitely, so this more general evaluation anyway. As I wrote, some takes might be wrong :slight_smile:

Cannot agree to that, the complexity in the nature, it is hard to seize with the mind what's happening, and definitely there are people capable of that, yet most have to handle it in a various ways. We learn to manage it, avoid having situations with events flying around across many concurrency domain, but it is a skill, not given. If your program has - and ideally it should - just few concurrency domains, it is indeed not hard thing to do, because most of the code is synchronous and communication between these domains limited. It is easy to mess things up otherwise. It is hard to build it in that way correctly.

I agree that ideally, however, it should not be complex, there should be few advanced topic, like distributed actors, and rest is simple. For now I'm just trying to remember that it is still not ready in Swift, so it is too early too make final thoughts.

1 Like

Can you confirm how you're building where you see the Data warning? What's the target platform? What Swift and (if relevant) Xcode version?

I also find myself struggling with applying Swift concurrency. Whenever I foray into using actors, I feel that the language forces me to add a lot of structure to even simple problems, making the resulting code harder to understand and get right.

I just recently reverted from using actors in a tiny little plugin framework I wrote, changing it back to synchronous code, because at every boundary between the platform (UIKit/AppKit) and my code, I had to wrap things in Task { } or await MainActor.run { }, or change the overall design of my code to silence compiler errors related to actor initialization.

I have been teaching Swift in the years from Swift 2 to Swift 4 roughly, but I no longer really understand the problems and proposed solutions in proposals like this here.

I know this is not helpful, sorry for the rant.

5 Likes

I'm using Xcode 15.3, swift 5.10, under macOS 14.4, with strict concurrency checking.

However I realize now I was mistaken, the problem is not to do with Data at all. The errors are:

    @MainActor
    func nextFrame() async -> Data? {
// WARNING: Passing argument of non-sendable type 'inout AsyncStream<Data>.Iterator' outside of main actor-isolated context may introduce data races
        await self.frameIterator.next()
    }

    func receiveData(newData: Data) {
// WARNING: Type 'AsyncStream<Data>.Continuation.YieldResult' does not conform to the 'Sendable' protocol
        Task { @MainActor in
            frameContinuation?.yield(newData)
        }
    }

Which I mistakenly concluded were referring to the data type I was passing through the AsyncStream. I subsequently changed it to an Int to test that hypothesis but the errors remained, so even though Type 'AsyncStream<Data>.Continuation.YieldResult' == Data, it turns out that the issue is to do with the implementation of AsyncStream rather than the data I was passing through it.

1 Like

Yes, in particular it is common for async iterators not to be Sendable. This allows the AsyncSequence to manage the number of consumers it has, which can avoid some really frustrating bugs where the same AsyncSequence is iterated simultaneously from multiple tasks without intending to support that use-case.

1 Like

Thanks Cory, so how can an AsyncStream be used to share data safely from one thread (in my case a network call on a GCD queue) to a MainActor ?

1 Like

The best answer to that question is to use an explicit for loop instead of storing the iterator. That will ensure that the iterator itself cannot be re-entrantly reached, avoiding the exclusivity violation. You may well still get a Sendability warning until SE-0420 lands, but at that point your warning will mostly be vestigial (as publishing the iterator in this case will in fact be safe).

1 Like

Official documentation gives an example of wrapping closure API within a stream:
AsyncStream | Apple Developer Documentation. In that way you also flip dependencies (your API would not call receiveData method, instead stream itself handles this)

So if I understand correctly, that means the class implementing the network code, running on a background thread, should expose the AsyncStream, and post to it on an agreed actor, e.g MainActor. The data consumer should create its own AsyncStreamIterator and loop over it on that pre-agreed actor. Is that correct ?

class Consumer {
    @MainActor
    func readFrames(source: ClassSource) async {
        for await frame in source.frames {
            self.process(frame)
        }
    }
}

Is that correct ?

These steps are unnecessary. It is not possible to bind AsyncStream.Iterator.next to an actor you don't control today: that's what SE-0420 is trying to achieve. However, only the last step is required to actually get the safety you want. AsyncStream is designed to post work across actors, so it is safe to do this.

Have you tried using withCheckedContinuation(function:_:) | Apple Developer Documentation

Anecdotally I can’t say I’ve used it for doing much more than calling functions that should be (but aren’t) marked async. It should allow a function to exist in the form

func receivedData() async -> Data {
     return withCheckedContinuation({ continuation in
            continuation.resume(returning: await frameIterator.next())
      })
}

then calling with await actor.receivedData()

It may require abandoning async iteration and just calling the whatever async function is used to produce the equivalent next(). It’d need to be called inside of the continuation so that all output of the async function is effectively ‘serialized’

I'm curious how that was done. Say the thread is already holding locks ACD (assuming alphabetical order is the proper order of locking), then it wants to lock B, what did that infrastructure do? Unlock CD and then lock BCD (which would be improper), or how was it done? :thinking: