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).