0

I'm trying to make something like python's asyncio.Semaphore in Swift.

An object like this is used to limit the number of async tasks that that are concurrently accessing a particular resource.

Here's something I got that works, but I'm not happy with it:

class AsyncSemaphore {
    var value : Int

    class Waiter {
        var continuation: CheckedContinuation<Void, Never>
        var next : Waiter?
        init(continuation: CheckedContinuation<Void, Never>, next: Waiter?) {
            self.continuation = continuation
            self.next = next
        }
    }
    var waitList : Waiter?

    init(value: Int) {
        self.value = value
    }

    func wait() async {
        if self.value > 0 {
            self.value -= 1
            return
        }
        let _ : Void = await withCheckedContinuation({ k in
            let w = Waiter(continuation: k, next:self.waitList)
            self.waitList = w
        })
        self.value -= 1
    }

    func signal() {
        self.value += 1
        if let w = self.waitList {
            self.waitList = w.next
            w.continuation.resume()
        }
    }
}

actor Actor {
    var limit = AsyncSemaphore(value: 2)
    func act(_ s : String) async {
        await limit.wait()
        defer { limit.signal() }
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        print(s)
    }
}



@main
struct Main {
    static func main() async {
        let a = Actor()
        await withTaskGroup(of: Void.self) { g in
            for s in ["foo", "bar", "baz", "quux", "fizz", "buzz", "boof", "baaf"] {
                g.addTask {
                    await a.act(s)
                }
            }
        }

    }
}

But I wanted it to be a struct:

struct AsyncSemaphore {
    var value : Int

    class Waiter {
        var continuation: CheckedContinuation<Void, Never>
        var next : Waiter?
        init(continuation: CheckedContinuation<Void, Never>, next: Waiter?) {
            self.continuation = continuation
            self.next = next
        }
    }
    var waitList : Waiter?

    init(value: Int) {
        self.value = value
    }

    mutating func wait() async {
        if self.value > 0 {
            self.value -= 1
            return
        }
        let _ : Void = await withCheckedContinuation({ k in
            let w = Waiter(continuation: k, next:self.waitList)
            self.waitList = w
        })
        self.value -= 1
    }

    mutating func signal() {
        self.value += 1
        if let w = self.waitList {
            self.waitList = w.next
            w.continuation.resume()
        }
    }
}

actor Actor {
    var limit = AsyncSemaphore(value: 2)
    func act(_ s : String) async {
        await limit.wait() // ERROR on this line
        defer { limit.signal() }
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        print(s)
    }
}

@main
struct Main {
    static func main() async {
        let a = Actor()
        await withTaskGroup(of: Void.self) { g in
            for s in ["foo", "bar", "baz", "quux", "fizz", "buzz", "boof", "baaf"] {
                g.addTask {
                    await a.act(s)
                }
            }
        }

    }
}

But that gives me the error "Cannot call mutating async function 'wait()' on actor-isolated property 'limit'".

So I guess I have a few questions.

  • Why is this an error? Yes, limit is of a value type, but it belongs to Actor, why shouldn't Actor be allowed to mutate it -- in an async method or not?

  • Is there some way to override this error and force swift to let me do it anyway?

  • Is there a more idiomatic way to to this whole thing?

edit:

This question was based on some faulty premises. Making a semaphore be a struct doesn't really make sense. see comments below

Lawrence D'Anna
  • 2,998
  • 2
  • 22
  • 25
  • Why wouldn't you just use the language built in Semaphore? https://developer.apple.com/documentation/dispatch/dispatchsemaphore What does it even mean to be an AsyncSemaphore compared to just a textbook Semaphore? – Sam Hoffmann Aug 08 '22 at 16:00
  • DispatchSemaphore blocks threads, I want one that suspends async tasks. python also has a separate semaphore type for threads vs. async tasks see: https://docs.python.org/3/library/threading.html#semaphore-objects – Lawrence D'Anna Aug 08 '22 at 16:02
  • 1
    "But I wanted it to be a struct." That doesn't make sense. A struct is a value, not an object with identity. A Semaphore is useless without identity; you need multiple consumers to be using the same semaphore; they can't all have their own copy of a "semaphore value." I would expect that the correct type for a Semaphore would be an actor. Also, it absolutely would have to be Sendable. – Rob Napier Aug 08 '22 at 17:00
  • 1
    (I'm guessing about whether a semaphore should be an actor. Given how you've implemented it, it might be fine to be just a class. But it can't be a struct. There's a reason that DispatchSemaphore is a class, and it's not just because it's shared w/ ObjC) – Rob Napier Aug 08 '22 at 17:08
  • If your goal is "limit the number of async tasks that that are concurrently accessing a particular resource", then you can use `OperationQueue` (with `maxConcurrentOperationCount` set to number you want) instead of semaphores. See; https://developer.apple.com/documentation/foundation/operationqueue – timbre timbre Aug 08 '22 at 17:45
  • @sfgblackwarkrts I'm not sure that would work with async/await. You can't block waiting for an operation to complete, so you'd have to create exactly the same kind of continuation system as this, while introducing extra threading. Did you have a particular implementation in mind that is simpler than this one but works with async/await? – Rob Napier Aug 08 '22 at 19:33
  • @RobNapier. Yea, you're right. It shouldn't be a struct. I guess what I really wanted was to have it *allocated* inline in the thing I was composing it into, but I certainly don't want it to have value semantics. As you say, that would not make sense. – Lawrence D'Anna Aug 08 '22 at 20:29
  • Also, I don't believe this code is safe (even if you made AsyncSemaphore an actor). In `wait()`, while awaiting `withCheckedContinuation` (which suspends the current task), a second call to `wait` could be made. It would also schedule itself as the waiter, but this would replace the previous waiter, which would never be awakened, I believe. See https://forums.swift.org/t/semaphore-alternatives-for-structured-concurrency/59353/3 for a more-likely correct solution (I haven't evaluated it carefully) – Rob Napier Aug 08 '22 at 21:35
  • 1
    A key point is that any time you see "await," that is a suspension point and you can no longer assume that preconditions from earlier in the function still hold. While you were awaiting, this function may have been re-entered, or other functions may have run and modified the context/properties/etc. (This is even true with actors; actors promise methods will not run *simultaneously*, but it does not promise they won't run *concurrently* or re-enter.) – Rob Napier Aug 08 '22 at 21:38
  • @RobNapier. I guess the issue then is that from Actors's perspective, if it allows AsyncSemaphore to be passed as an inout to an async function, that function could suspend itself with the AsyncSemphore in an inconsistent state, which could then be accessed by Actor's methods. Is that right? So the problem I'm having here is that I can't directly tell the type system "yes this function is async and it's mutating, but it will mutate synchronously and leave the object consistent when it suspends". I suppose I could split the functions into sync and async parts. – Lawrence D'Anna Aug 09 '22 at 13:12
  • @RobNapier "a second call to wait could be made. It would also schedule itself as the waiter, but this would replace the previous waiter, which would never be awakened" I think this part is OK. The waiters are are a linked list. It won't lose the previous waiter, it'll just jump in front of it in line. – Lawrence D'Anna Aug 09 '22 at 13:14
  • One more thought... I did try making it an actor as well. The problem with this is that signal() then becomes async when called outside the actor, and signal should be synchronous. – Lawrence D'Anna Aug 09 '22 at 13:21
  • How do you ensure that wait and signal are don't race then? They could be called from different queues, and the updating of `value` is not atomic. Compare to the linked implementation above, which does use an actor. (Also, why does signal need to be synchronous?) – Rob Napier Aug 09 '22 at 13:42
  • You're right again. in python, unless you're using a custom runloop, async code all runs on the main thread, so that sort of thing can't race. But in swift it can. I think this version might actually be correct: https://gist.github.com/smoofra/86a9f50ee3734abbd317e90b441eea89 – Lawrence D'Anna Aug 09 '22 at 14:38
  • signal should be synchronous so you can use it in a defer statement, or from a synchronous callback – Lawrence D'Anna Aug 09 '22 at 14:54

0 Answers0