0

Suppose a class that's arranged something like this:

class A {
    func a() {
        doStuff()
    }
    func b() {
        Task {
            something()
            await someLongRunningThing()
            somethingElse()
        }
    }
    func c() {
        doOtherStuff()
    }
}

How can I make sure that only one function is ever active at a time and that the execution of each function is in a FIFO manner? This would include the body of the Task in b(). If that Task is executing, none of the other functions of the class should be able to be entered and they would queue up and operate FIFO with no interleaving.

Since the member functions will probably be called on the main thread, there should be no blocking of a thread involved.

This is a common scenario I face, and I can't think of a reasonable solution. At first I was excited about actor, but the actor allows reentry when there's an await in a function, allowing race conditions. The only thing I can find about this is here. But here, the author really just pushes the problem further up the call hierarchy.

Steve M
  • 9,296
  • 11
  • 49
  • 98

3 Answers3

2

How about: class A has its own serial queue and your functions submit all their work to it, each as one block. That gets you FIFO processing. If Task isn't inline, then b()'s block can use some synchronization (enter/leave?) to hold the private queue until task completion.

Since all three functions only dispatch and return, their caller's thread isn't blocked.

Phillip Mills
  • 30,888
  • 4
  • 42
  • 57
  • That sounds doable but I was hoping there was a way to avoid the GCD stuff and use the new concurrency tools. Something with `AsyncSequence` maybe? – Steve M Jan 03 '22 at 19:30
  • Maybe. I'll leave that to someone with more experience of the new stuff. – Phillip Mills Jan 03 '22 at 19:35
1

Here is what I came up with. I don't think about terminating the stream as in my case I didn't need to.

class ActionQueue {
    typealias Action = () async -> Void
    
    func enqueue(action: @escaping Action) {
        DispatchQueue.main.async { [weak self] in
            guard let sself = self else { return }
            if let enqueueAction = sself.enqueueAction {
                enqueueAction(action)
            } else {
                sself.initialQueue.append(action)
            }
        }
    }
    
    private var enqueueAction: ((@escaping Action) -> Void)? = nil
    
    //necessary because the AsyncStream build block doesn't necessary happen
    //on main thread. Otherwise we could lose Actions.
    private var initialQueue = [Action]()
    
    private var stream: AsyncStream<Action> {
        AsyncStream { continuation in
            DispatchQueue.main.sync {
                enqueueAction = { action in
                    continuation.yield(action)
                }
                initialQueue.forEach { action in
                    enqueueAction!(action)
                }
            }
        }
    }
    
    init() {
        Task.init {
            for await action in stream {
                await action()
            }
        }  
    }
}

Usage:

class A {
    
    func a() {
        actionQueue.enqueue {
            doOtherStuff()
        }
    }
    
    func b() {
        actionQueue.enqueue {
            something()
            await someLongRunningThing()
            somethingElse()
            await MainActor.run {
                mustBeOnMainThread()
            }
        }
    }
  
    private let actionQueue = ActionQueue()
}
Steve M
  • 9,296
  • 11
  • 49
  • 98
  • I like it. A question. How would you modify your ActionQueue class in order to pass on the throws from the enqueued calls? – Aecasorg Apr 20 '22 at 15:16
0

You can’t under those conditions. It’s a contradiction to spawn work (Task {}) on a function that runs on the main thread, to execute the function fully before another is called, and have no blocking.

If you make it an actor, only one function will be active at a time, but given that you added a suspension point (await) there can be actor reentrancy (scheduling more work on an actor that already has suspended work items). This is also a feature to avoid priority inversion.

While it doesn’t have compiler support under the new concurrency model, it is OK to use NSLock or os_unfair_lock to implement your own synchronization. This would give you a FIFO class but you still would have to schedule its results on Main.

Maybe someone can suggest a better solution if you post a more realistic example of what you are trying to accomplish.

Jano
  • 62,815
  • 21
  • 164
  • 192
  • What do you think of my answer? – Steve M Jan 05 '22 at 17:11
  • It seems you are hoping to enqueue later, but it will just finish after iterating the current sequence. AsyncStream is not like an observable. If you mix AsyncStream with DispatchQueue.main.sync you are sending to a different thread with a blocking call, which negates the benefits of structured concurrency and may produce unexpected results. The advice from Apple is to never mix GCD and structured concurrency. The problem has contradictory requirements (main, FIFO, background tasks, no blocking). – Jano Jan 06 '22 at 20:56
  • I think you're misunderstanding something here, the `AsyncStream` build block just executes once shortly after the class is initialized, just assigning the value of `enqueueAction`. After that, the `continuation.yield()` just gets called every time an Action is enqueued and the async for loop just awaits in between calls. Actually it works fine with any of the GCD stuff too, but there's technically a data integrity issue I suppose since it's assigning `enqueueAction` on a different thread than it's being read from without the GCD. I don't think using GCD here could have much pitfall. – Steve M Jan 07 '22 at 18:43
  • 1
    Oh I see, you are producing values from within the stream closure, I didn’t interpret the code correctly. About GCD being discouraged, main two advantages of SC is that the compiler can analyze your code and no thread gets ever blocked –functions are stored in the heap instead of the stack and the thread remains active. If you mix with old concurrency (GCD) the compiler can no longer detect problems and there will be expensive context switch between threads. That being said, actions will execute sequentially in the order they are called in a(), b(). – Jano Jan 08 '22 at 14:20