How to avoid cascading async functions?

When adopting structured concurrency I'm running into the issue of the entire call stack needing to be updated to async functions, even when they are void returning. I'd like to understand if there's a way to make a "fire and forget" type async call from a synchronous method and break this chain, e.g.:

public func log() {
  printOnMain()
}

private func printOnMain() {
  await MainActor.run {  // error: 'async' call in a function that does not support concurrency
    print("on main thread")
  }
}

So if I mark printOnMain() with async, then log() needs to marked async as well. Then all of log's callers need to be marked async, and so forth. This makes it really challenging to adopt, and is not so intuitive why there is no fire-and-forget type dispatch here.

That's two contradicting parts: with fire-and-forget you loose structured part, and structured eliminates such ways.

Swift provides unstructured Task to get fire-and-forget when it needed, but in general you want to use structured capabilites.

To your issue, that might be two cases:

  1. You actually need unstructured task. Sometimes it is just right tool for the job. So you wrap call inside a Task {} or Task.detached {}.
  2. You have wrong code isolation. If you have lot of awaits or required to have async through the long chain just because some method has different isolation, it might be the case that you better to re-arrange code so that more things lives in the same isolation.
4 Likes

It may be interesting to refer back to one of the original posts on structured concurrency before it arrived in Swift: Notes on structured concurrency, or: Go statement considered harmful — njs blog

In particular, this quote in it from Knuth:

Probably the worst mistake any one can make with respect to the subject of go to statements is to assume that "structured programming" is achieved by writing programs as we always have and then eliminating the go to 's. Most go to 's shouldn't be there in the first place! What we really want is to conceive of our program in such a way that we rarely even think about go to statements, because the real need for them hardly ever arises.

It's always possible that your specific case may be the case that you need unstructured concurrency, but the fact that you might instead need to rethink your architecture instead is a feature, not a bug.

4 Likes

I'm not sure that's a really fair comparison. With sync/async there is the coloring problem and async contamination, which are not a thing with go to statements. The OP's very first sentence: I'm running into the issue of the entire call stack needing to be updated to async functions, so unless you are suggesting to make everything async (which in my opinion would be terrible), Task seems to be what he's looking for.

1 Like

Sometimes the point is not whether your function returns something or not, but whether you want to wait for its completion. There is a way to fire-and-forget (Task { ... } or its detached variant) and thus avoid "async contamination" if you don't need guarantees of completion.

Another thing to keep in mind, if you do find yourself needing to wait for completion too often, it might be a sign you are overdoing concurrency, i.e. you might need to reconsider your overall architecture.

1 Like

I tried it and it seems that I also encountered the same problem.

This is exactly the fair comparison. Calling this out:

If you are doing structured concurrency (which the OP says they are), then "everything must be async" is the right spelling. Structured concurrency requires that if func a calls func b, a must not complete before b does. That is what it is for the code to be structured.

If the follow-up opinion is "structured concurrency is bad", then that's a conversation that can be had. But an unavoidable corollary of structured concurrency is that if you call async functions, you must be async.

5 Likes

It [article and excerpt] is not about coloring, but the effect unstructured concurrency makes — similarly to go to statement it breaks flow and makes it harder to reason. Elimination of the latter has already forced to rethink program structure. In the same way elimination of unstructured concurrency forces to rethink structure.

Function coloring can be the problem — I totally get it — but I think the better questions is it a problem most of the times? If there is a long chain of functions that have to become async because of one change, more likely design decisions needs to be revisited.

Just something I've noticed. It is very common for people to use the term "structured concurrency" when they mean "swift concurrency". And, that's extra confusing because of the important distinction between "structured" and "unstructured".

I have a feeling, but could definitely be wrong, that the OP was referring to "swift concurrency".

10 Likes

I had the same feeling. Terminology can easily be confused.

1 Like

Would something like this work?

func sync_meets_async () {
    ...
    let s = DispatchSemaphore (value: 0)
    Task {
        await async_world ()
        s.signal()
    }
    ...
    print ("waiting for async_world to finish...")
    s.wait()
    print ("finished")
}

func async_world () async {
    try! await Task.sleep(until: .now + .seconds(7))
}

Thanks everyone, I will go with the Task approach. I got confused following tutorials that said MainActor.run is how to dispatch back to the main thread, but it missed the nuance of structured vs unstructured concurrency.

1 Like

That’s a risky antipattern. If your sync_meets_async function is ever called from Swift’s global concurrent pool (for example, from an actor or async function, even indirectly), you will be blocking one of the finite number of threads in that pool on the expectation that another one of the threads will perform some computation later. While you might not notice any negative effects of this when using the pattern only occasionally, it can deadlock your app or server. Unlike GCD, which expects that people may do this kind of thing and allows a concurrent queue to have more threads than there are logical cores on the system, Swift concurrency requires that you never block one task on the expectation of future work occurring on another task (but offers await as a way for you to do so safely without blocking a thread). So if you fill all of the threads in the global concurrent pool with tasks that are blocking on future async work using a semaphore, none of that async work can ever be scheduled.

5 Likes

While these tutorials are not technically incorrect, I disagree with their advice :)

In my experience, 75% of the time people reach for MainActor.run or Task { @MainActor in …, they're trying to replicate the libdispatch design of "each call site has to specify where it wants things to run", which is the opposite of what Swift encourages. The reason why Swift encourages callee-decides is because it eliminates an entire class of programming mistake: calling functions in an incorrect way.

There are still situations where it's appropriate to do it this way of course, my objection is just to people propagating the idea that it's the default or even only way.

3 Likes

Thank you, @j-f1, for explaining why my tinkering is a risky anti-pattern.

But, is there really no practical solution for this that can be used when the sync world meets the async world in real life?

I have also asked the same question here.

this gets really annoying when you are dealing with closure APIs, especially DSLs.

this is okay:

let html:HTML = .init 
{
    $0[.ol]
    {
        for item in list 
        {
            $0[.li] = item
        }
    }
}

but this is not!

let html:HTML = .init 
{
    $0[.ol]
    {
        for await batch in cursor 
        {
            for item in batch 
            {
                $0[.li] = item
            }
        }
    }
}

and quite frequently you want to go from the former to the latter, and cannot, because of function coloring.

1 Like

That would be an indication that you're "in the wrong world", I believe, so it's really, really important to rethink your design. If you cannot move the offending code to the async world, I'd go with a callback (which in essence kind of mimics what an await is under the hood, see below).

What I have found in practice when people believe to be in this situation is, however, slightly different: They often want to fire off long(ish) running work in tasks, but need them to be performed in order (which is the only reason why they do not simply use Task { ... } in the first place). I think an AsyncSequence is the rescue then: Write yourself some wrapper type that internally manages a task that awaits on a stream, then use a continuation from a synchronous method to put work in that stream:

final class SerialWorker {
    private let continuation: AsyncStream<() async -> Void>.Continuation

    init() {
        let (stream, cont) = AsyncStream<() async -> Void>.makeStream()
        continuation = cont
        Task.detached {
            for await workItem in stream {
                await workItem()
            }
        }
    }

    deinit {
        continuation.finish()
    }

    func queue(_ workItem: @escaping () async -> Void) {
        continuation.yield(workItem)
    }
}

That's just a POC, I hope I did not make any glaring mistakes here...
I think it does not run the danger of blocking that you get with a semaphore as everything is "hidden" behind that for await loop.
If you require some form of return value, you have to use a callback, as said above. This can be tricky as you have to think about sendability and so on (or in other words: "getting back into sync land is hard"), but that's unavoidable with any callbacks (DispatchQueue.main.async has been a good friend to get back doing UI work after loading stuff in all those years).

3 Likes

Yeah, I feel your pain here. I don't think this is a function colouring issue though: async is no more of a function colour than throws[1]. This feels more like an issue with intersection of language features, where there is some work missing to get things to glue together.

What it seems like you want is a way to make your DSL operate nicely when using both sync and async code. This is basically reasync, to go with rethrows. That's a reasonable ask! Having a way to essentially be "generic" across the async effect is a totally natural feature, and I don't think anyone on the Swift team has ruled it out. In my view, that's what's really missing here.


  1. What is frequently meant when people say "function colour" is really very close to the idea of a function effect. Swift has an effect system, but I'd argue it hasn't generalized it yet, so each new effect needs bespoke handling in several places. ↩︎

6 Likes

Thank you. :+1: