Swift Concurrency: Feedback Wanted!

Question: What can one say about the performance when using actors instead of classes in the non-concurrent case? There then certainly is a performance penalty. Practical use case: prepare an API for concurrency but not "forcing" it. Has somebody done benchmarks? ("performance" is mentioned in SE-0306 but not really answering my question.)

Update: I figured a workaround, allowing me to narrow down the cause [SR-15245] [Concurrency] Compiler crashes on attached input - Swift

Let's just say it required an out-of-nowhere insight, and some trial-and-error.

Overall, the new concurrency features are very, very good.

There is only thing that is really missing: calling an async method of an actor without awaiting the completion. Creating a new Task just to be able to call an async method of an actor is not a solution:

actor Printer {
    
    func print(_ message: String) {
        print(message)
    }
    
}

let printer = Printer()

Task {
    await printer.print("foo")
}

Task {
    await printer.print("bar")
}

Since the Tasks do not have any relationship, the output could be

foo
bar

or

bar
foo

What we need is something like:

actor Printer {
    
    func print(_ message: String) {
        print(message)
    }
    
}

let printer = Printer()
send printer.print("foo")
send printer.print("bar")

The output would always be

foo
bar

I really hope that the core developers see this as a problem as well. Especially for app development this is a problem because user actions are always processed by the sync main thread and using a Task no longer guarantees that the order of user interaction is correct.

2 Likes

Executors are free to reorder tasks based on priority. You are asking for queue semantics. You can still use a dispatch queue for those.

1 Like

If you want UI events to generate a stream of things handled off the main actor in a reliable order, the current recommendation is to have a task that consumes an AsyncStream.

5 Likes

It would be nice if we could make use of the message queue that an actor already uses internally.

Actors doesn’t currently use queues. They use a cooperative thread pool.

In the future, actors will support custom executors, at which point it will be possible to adopt FIFO semantics natively.

Thank you for your recommendation - that is indeed the pattern I am currently using. But I really think it is a mistake that is too easy to make (using a one shot Task) and the solution is too cumbersome. Without send we basically need to implement another runloop to make sure that the order of the main runloop is preserved in the actor runloop.

1 Like

Ah interesting - I though they would use some internal message queue.

OK, boy do I have feedback for you. In case you're wondering why it comes only now, see [SR-15245] [Concurrency] Compiler crashes on attached input - Swift which has stumped me until recently.

The first item of feedback is: please provide a bidirectional and more practical alternative to SE-0314: Async(Throwing)Stream (which itself I initially missed since it wasn't listed in the initial post, but maybe it hadn't made it to that first beta).

I will say this for AsyncStream: it exists, and as such it can form the basis for more elaborate inter-task exchange primitives; without AsyncStream, those couldn't exist at all. But implementing those from AsyncStream is awkward due to the latter's limitations and idiosyncrasies.

Now, how limited are we talking? The answer is: very. The main limitation is that the iterator has value semantics and its next() method is mutating (on top of being async, of course). That prohibits it from being protected by an actor: an actor (currently?) can't protect across suspension points and so refuses to run such a method on one of its member fields. OK, I guess that's the intent: for the AsyncStream receiving end to have Task affinity to help with priority inheritance for instance, which is a Good Thing. But the continuation (the sending end) does not have the same restrictions, doesn't that prevent identifying the task that will have to call that end to unblock the receiving task if necessary and so needs to have its priority boosted? Unless the engine is able to perform that determination from past usage, but then why aren't those smarts applied to the receiving end to lift those Task affinity restrictions?

Then we have the fact the continuation is not provided directly, but as a parameter to a callback that is invoked asynchronously, by which I mean here from a different Task… but the callback itself isn't async, which means I need to spawn my own Task if I need to await something as part of using the continuation. All that makes the API more suited to the primary use case (adapting existing synchronous code) at the cost of making it less suited to all other use cases: for instance, I can't send the continuation through itself to be recovered through the iterator, in order to wrap that into a saner primitive, because even with a tagged enum the Swift type system won't allow that. And I don't have access to a handle to that internal Task, which would allow me to await on it for the same purpose…

The way it is, that sounds like the situation if the Mach microkernel only ever allowed a Mach port to be created by a call that returned a receive right that couldn't possibly be transmitted to another Mach task, and didn't return a send right but instead passed it as a parameter to a new Mach task that was spawned as part of that call, making it your responsibility to get that send right to the proper place from there. Between that and fork(2) + exec(2) + pipe(2), the latter sound unexpectedly appealing: at least pipe(2) returns both descriptors to the same place and makes no restriction as to where you can use them.

Now you may be wondering: how bad could it possibly be? I think the answer is best provided by one of the types I had to involve to create a bidirectional primitive: AsyncStream<PayloadOrOutOfBand<Response, AsyncStream<Request>.Continuation>> (Request and Response substituted in for legibility): I couldn't figure out any other way for the requesting task to get its continuation handle than by making it go through the AsyncStream that is otherwise used for it to receive responses.

In my humble opinion, it's better for primitives to be bidirectional by default; I believe that to be one of the main takeaways from the success of the sockets API. I recognise bidirectional exchange primitives have a number of implications, one of which is to mandate the ability to wait on multiple events simultaneously (the equivalent of select(2)) which appears to be incompatible with receiving calls that are simultaneously async and mutating/inout, but I also believe these complications to be worth it in the end.

Here's my experiments project so you can take a look as to how I've done it. I have yet to write an explainer for it, so the short version is:

Whenever the recursive system in (Async)Explorers.swift would delegate to iteratePossibleLeftNodes(), it instead calls an injected dispatch function that sees if that delegation could best be performed in a concurrent context at this point (otherwise, that function just delegates directly). When that happens, iteratePossibleLeftNodes() inside that context is injected with a dispatch function that short-circuits that: no point in spawning further tasks from there. The architecture is meant for different concurrency APIs to be able to be injected: the GCD version is probably easier to follow, so read that one first, in DispatchAPI.swift. But DispatchAPI.swift intentionally doesn't make use of async Swift, so then read AsyncAPI.swift, its async Swift counterpart. PullAPI.swift is not an alternative to that, rather it is an experiment in how to conceive a pull-based concurrency API, that AsyncAPI.swift now relies on.

And now for some more feedback:

  • The backtrace support is much appreciated: when sampling with time profile the call stack goes seamlessly from sync functions to async ones as we go up the call hierarchy, even though at runtime the structures to walk are completely different.
  • I observe a slight slowdown of about 5-10% when I use async functions for the bulk of the job (you can try that out if you invoke iteratePossibleLeftNodesAsync() from the child task, instead of iteratePossibleLeftNodesPseudoReasync()) when compared with the corresponding sync ones. This may be related to the important allocation activity that is also observed in that case, due to the need to extend the Task LIFO structure I bet.
  • Would it be possible to add an existing Task to a TaskGroup, or better yet, obtain a Task handle when calling addTask/addTaskUnlessCancelled? Here my architecture has me add the same tasks to two different groups (in one case so I can multiplex waiting on all the tasks to keep a running count, in the other in order to wait for all processing to be done) and so I need to make this silly wrap around an existing handle: {return await task.value;}
  • In keeping with Swift philosophy, the diagnostics do provide useful feedback that eventually gets me to a correct solution, even if they don't always have fix-its. So much so that whenever a refactor action is unavailable, I just perform the fundamental incompatible change, then fix the cascading feedback that results until I get to a new stable state. Though I have to say that async adds complications of its own, some of which seem gratuitous: why can't I just copy value types directly from var to var, instead of having to add that let intermediate as in iteratePossibleLeftNodesDispatch()?
  • of the 1 second spent in the "main" task (compared with the 28 seconds spent in the child tasks, split over my 4 cores so they end up being 7 wall clock seconds), 99% of it is spent in swift_taskGroup_attachChild(), even though I wasn't under the impression this operation would be particularly contended: I don't call addTask more often than 3000 times per second (on average), and only from two tasks that are in lockstep.
  • Please add reasync support. And when you do, please make sure the sync version of the function can be made available to async-unaware code so I only need to maintain one copy of the code.
  • Any AsyncStream and any TaskGroup I need to involve increases my indenting level, resulting in significant wasted editor space even for moderately complex structures

By the way, I would love to get feedback in turn on what kind of scalability my project can get up to on something other than my meager 13' MacBook Pro from 2016 (two cores, four logical processors); especially on Apple silicon.

As I understand it, TaskGroup specifically avoids this feature so it can guarantee the child tasks can't outlive their parent task.

This was a bug in Swift 5.5 that looks like it was fixed in 5.6 (Xcode 13.3).

Unfortunately it was just barely too late to make it onto the 5.6 branch :frowning: sorry about that!

Ah, you're right, I was testing my batched workaround. :sob:

(In the future it would be great if proposed release changes that weren't accepted were properly marked rather than just closed. I thought perhaps it had been integrated elsewhere.)

1 Like

Unfortunately, this bug still doesn't seem to be fixed in Swift 5.6:

https://bugs.swift.org/browse/SR-15745

As it stands, unless I am misunderstanding something about how actors are supposed to work, the compiler doesn't appear to be properly enforcing the access rules for actors, which makes it difficult for me to trust their safety. :confused:

So requesting a fix for that would be my rather strong piece of feedback.......

1 Like

While we're on the subject of actors, I will mention I used to use one so the results could be provided in a callback-like fashion, but I have now replaced that with an AsyncStream, a task for which the latter is ideally suited. As a result, I have no real feedback on actors.

Ah, right, that makes sense. So I guess my request would rather be: a way to group tasks (more specifically, their handles) that provides equivalent services as a TaskGroup (multiplex waiting, ability to wait for all) without the structuration constraints of a TaskGroup; an unstructured task group, if you will.

Regardless, the Zachit thank you for your service, Mr. Catfish_Man.

probably its already been stated elsewhere but it would be nice if we could more easily do pubsub using asyncstream

right now, the continuation has to be got from the initializer, like so

 self.stream = .init { self.continuation = $0 }

maybe asking for a new @Published is too much, but at least if we could start with a continuation and initialize the asyncstream from that it would be easier for someone who wants to implement that kind of thing...

private let continuation = AsyncStream<String>.Continuation()
let stream = AsyncStream(with: continuation)

func send(string: String) { self.continuation.yield(string) }

(also throttling/debounce would be a good addition too)

maybe i'm missing something obvious though, but just a thought...

Check out GitHub - apple/swift-async-algorithms: Async Algorithms for Swift.

2 Likes

wow cant believe i missed that :sweat_smile:, thanks for the pointer!

To be fair, they hadn't been mentioned in the thread thus far, and in fact hadn't been published by the time of my own feedback: Async Algorithms answers basically half of my feedback items.

2 Likes