Enforce serial access (i.e. non-rentrant) calling of swift actor functions?

Rather than use GCD serial queues as a locking mechanism, structured concurrency promotes the idea of using actors to avoid data races. Consider an async function in an actor saveToDisk() which saves data to disk, but always writes the same file location. To avoid corruption, not only do I want to protect access to calling this function from multiple threads by using an actor, I don't even want to allow reentrancy to the function. Each call of this function needs to be essentially atomic.

Using GCD, it would be simple to use a serial queue to ensure that, either with sync() or async() (depending on whether the caller cares to wait for the function to finish or not) that only one call at a time to this function is allowed, and each call must finish before the next starts.

But if saveToDisk() can suspend itself in by calling await within its body (let's assume that it is in fact talking to a slow server to write its data), I can't allow it to be re-entered.

What paradigm do I use in our brave new world of structured concurrency to enforce this?

1 Like

Maybe add an NSLock to the actor and make saveToDisk() obtain the lock first thing?

Acquiring a lock in an async function can lead to some really gnarly bugs; It is better to use with style helpers.

extension NSLock {
  func withLock<T>(_ apply: () throws -> T) rethrows -> T {
    lock()
    defer { unlock() }
    return try apply()
  }
}

That way you are safe by avoiding the resume and then unlock being on a different thread from the acquisition of the lock.

2 Likes

Excellent point. Honestly, my main experience with locks is in other languages and even then, reentrancy was one of the situations that always required lots of hard thinking before deploying to production.

Usually I found that rewriting the code so that I didn't have to worry if it were reentrant was easier to think about and easier to test.

Bonus points for a solution that does not block the thread nor lead to deadlock.
Ideally I would like to await the call. And I would like to do it using the already existed structured concurrency tools. No semaphores or locks or anything of that nature. Because if we can’t do this with the existing tool kit then I would say that something is missing

One option is to dispatch the saveToDisk call in a Task, and then await any current task before proceeding. For example:

actor SaveManager {
    private var saveTask: Task<Void, Never>?

    private func saveToDiskInternal() async {
        ...
        self.saveTask = nil
    }

    func saveToDisk() async {
        while let saveTask = self.saveTask {
            _ = await saveTask.value
        }
        self.saveTask = Task { await self.saveToDiskInternal() }
    }

}

We're still waiting on non-reentrant primitives, which were listed as a future direction in the concurrency proposals, but that's the best way I've found to enforce non-reentrant access.

3 Likes

Your question is too abstract. Can you provide more details? Such as: Who owns the buffer that is going to be saved? Do you want to support the buffer being modified while a save is in progress? Are you modifying an existing application to use Swift concurrency or designing a new application?

1 Like

The previous poster outlined what I suspected was the right course, but didn’t want to suggest: an actor with a task variable. This can be packaged up to allow any code to be run sequentially in the actor. Maybe slightly surprised this construct does not already exist though…

1 Like

FYI, while this is what I was imagining one solution could be, I think that simply assigning to saveTask after the await may be wrong. If there are multiple callers waiting, you can only let through one at a time? So some sort of loop is required to keep checking (with an await each time) and the task itself needs to reset saveTask back to nil when savetoDiskInternal() returns.

1 Like

Right; I've edited the example in the earlier post to do that in case anyone tries to use it, but thanks for the correction.

"We're still waiting on non-reentrant primitives, which were listed as a future direction in the concurrency proposals"

Somehow I missed the above sentence. OK, good to know someone has indeed thought about this and it might be coming. In the meantime, what you wrote is easily abstracted to be the moral equivalent of (yes, mixing metaphors...):

    await mySerialDispatchQueue.sync { doSomething() }
1 Like

I wrote a helper actor which enforces limits on how many operations can be suspended at once. Let me know if this works for your use case!

Source here: playgrounds/TaskQueue.swift at main · gshahbazian/playgrounds · GitHub

This is cool. Now, do we have a race condition on the while/await loop that's protecting the task? So,

Alice -> save to disk
Bob -> queues up to wait for Alice's save to finish and then save
Carol -> queues up to wait for Alice's save, and winds up cutting in front of Bob

can that happen? This is part of reentrancy that requires low-level knowledge of the compiler's behavior to make sure you get it right. In another language I'd solve this with a queue, sticking Bob and Carol into a queue and then when the await completes, pulling the next request off the queue. I'm not really sure how to implement that in Swift, though.

Yes; this guarantees only non-reentrancy, not a FIFO order – the callers may execute in any order, but only one may be executing at any given time. Multi-actor execution scenario - #6 by John_McCall might be useful if you're wanting queue-like behaviour.

It was clear to me that the proposed solution (which is what I was thinking of), which involves looping while waiting to gain access to the "protected" resource obviously allows starvation.