swiftasync-awaitconcurrencyactor

Swift Actor thread safety with continuations


I am reading about actors and as a teaching example I am rewriting an old piece of code which used delegation to use async await. I am using checked continuations to interface the sync to async.

I read this introduction: https://www.swiftbysundell.com/articles/swift-actors/ As I understand, since the actors are re-entrant we need to keep track of the work that's currently being performed - i.e. the active task.

What I am not sure about is how this marries together with continuations.

Here's my code. Thanks for the help!

public actor MyNewDelegateSub {
    
    
    private var activeTask: Task<BLEDevice, Error>?
    private var checkedThrowingContinuation: CheckedContinuation<BLEDevice, Error>?
    private var bleDelegate: BluetoothLEDelegate
    
    
    public func returnFunctionThatReplacesDelegateCallbacks() async throws -> BLEDevice? {
        if let existingTask = activeTask {
            return try await existingTask.value
        }
        
        let task = Task<BLEDevice?, Error> {
            return try await withCheckedThrowingContinuation({ [weak self] (continuation: CheckedContinuation<BLEDevice, Error>) in
                bleDelegate.checkedThrowingContinuation = continuation
                do{
                    bleDelegate.scan()
                    activeTask = nil
                } catch {
                   activeTask = nil
                   throw error
            })
        }
        activeTask = task
        return try await task.value
    }
}

private final class BluetoothLEDelegate: NSObject, CBCentralManagerDelegate {
    
    private let centralManager = CBCentralManager()
    var checkedThrowingContinuation: CheckedContinuation<BLEDevice, Error>?
    
    init() {
        centralManager.delegate = self
    }
    
    
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        checkedThrowingContinuation?.resume(returning: BLEDevice(peripheral) )
        checkedThrowingContinuation = nil
       }
    ....
}

Solution

  • Yes, it is perfectly acceptable to use withCheckedThrowingContinuation in a Task.


    That having been said, there are a bunch of issues with this code snippet, though:

    1. In your do-try-catch, there is no try. Thus there is nothing to ever catch. Also, you wouldn’t throw from inside a withCheckedThrowingContinuation, anyway, you would resume with the error.

    2. Whenever you use unstructured concurrency, such as Task {…}, you are responsible for writing your own cancelation handling code, e.g. withTaskCancellationHandler.

    3. In a more stylistic observation, but rather than passing the continuation to the bluetooth manager object (which entangles these two types together, introduces non-local reasoning about the continuation which practically invites its eventual misuse, etc.), I might advise that you decouple these two types:

      • give the bluetooth manager object closure properties associated with the relevant delegate methods, and have the delegate methods call those closures … now the bluetooth manager is no longer tightly-coupled with the continuation code; and

      • inside the withCheckedThrowingContinuation, spin up the bluetooth manager, set its closures, and inside those closures, resume and cleanup as needed.

    4. The right pattern for discover of bluetooth devices is probably an AsyncSequence, e.g., an AsyncStream, anyway, not a task. See WWDC 2021 video Meet AsyncSequence.