Sendability when migrating from Dispatch to structured concurrency

My team is currently in the process of migrating existing code from stuff that uses a lot of DispatchQueue and DispatchGroup to using structured concurrency. Here's an example of some code that might exist in our repo.

func loadModelC(_ completion: @escaping (Result<ModelC, Error>) -> Void) {
  let group = DispatchGroup()
  var resultA: Result<ModelA, Error>?
  var resultB: Result<ModelB, Error>?
  
  group.enter()
  fetchModelA { result in 
    resultA = result
    group.leave()
  }
  
  group.enter()
  fetchModelB { result in 
    resultB = result
    group.leave()
  }

  group.notify(queue: .main) {
    let finalResult = Result {
      try ModelC(
        a: resultA.orThrow().get(), 
        b: resultB.orThrow().get()
      )
    }
    completion(finalResult)
  }
}

There doesn't seem to be an easy way to incrementally migrate to async/await, as Task blocks require passed in closure to be @Sendable, which is problematic with the mutable Results declared at the top. Is there any way around this to be able to, for example, replace one of these calls with one that uses async/await?

A related question: As we're migrating, is it at all likely that closure-taking methods on DispatchQueue and related types will require their closures to be @Sendable in the future? We're not entirely sure if legacy closure-taking code should be migrated to use @Sendable closures or if it's safe remaining non-Sendable.

func makeCallbackAsync<T>(_ operation: () -> (T) -> Void) async -> T {
  return await withCheckedContinuation { continuation in
    operation { val in
      continuation.resume(returning: val)
    }
  }
}

Should transform any callback-based API into an asynchronous one, if I got the syntax right. I don’t believe continuations need Sendable; places where it’s needed are such to enforce program correctness.

Edit for clarity: I mean that T should not need to be Sendable. operation may need to be @Sendable.

Right, I'm aware of the continuation APIs. To be clearer, the problem is, if I replace my fetchModelB method with an async version, and try to replace it in the code above...

group.enter()
Task {
  do {
    resultB = .success(try await fetchModelB())
  } catch {
    resultB = .failure(error)
  }
}

...this will not compile due to the mutable capture of resultB in a @Sendable closure. Therefore, we would have to migrate the entire method to use structured concurrency rather than migrate bit by bit. This is less than ideal when there's a large number of these sorts of methods with lots of group.enter/group.leave pairs involving more legacy asynchronous closure-taking method calls.

Do you need the mutable Results? Can you use an async let instead?

@available(macOS 10.15, *)
@MainActor
func loadModelC() async throws -> ModelC {
  async let resultA = withCheckedThrowingContinuation { continuation in
    fetchModelA { continuation.resume(with: $0) }
  }
  async let resultB = withCheckedThrowingContinuation { continuation in
    fetchModelB { continuation.resume(with: $0) }
  }
  return ModelC(a: try await resultA, b: try await resultB)
}

print(try await loadModelC())

// Scaffolding:

import Darwin  // for 'sleep'

struct ModelA {}
struct ModelB {}
struct ModelC { init(a: ModelA, b: ModelB) {} }

func fetchModelA(_ completion: @escaping (Result<ModelA, Error>) -> Void) {
  print("fetching A")
  sleep(2)
  print("completing A")
  completion(.success(ModelA()))
}
func fetchModelB(_ completion: @escaping (Result<ModelB, Error>) -> Void) {
  print("fetching B")
  print("completing B")
  completion(.success(ModelB()))
}



1 Like

Perhaps I misunderstood. You want to make fetchModelB async, but keep the wrapping function loadModelC callback-based? Using a callback-based API inside an async one is fairly easy with continuations, but the other way around is harder; I’m not sure how to do it without spawning unstructured Tasks:

func makeCallback<T>(operation: @escaping @Sendable () async -> T, completion: @escaping @Sendable (T) -> Void) {
  Task {
    let result = await operation()
    completion(result)
  }
}
1 Like

Right. The fundamental reason for this is that async functions can suspend, and non-async functions cannot. If fetchModelB can suspend (because it is async), it cannot be called from a context which cannot suspend (i.e. fetchModelC, which is not async).

The structure in structured concurrency inherently comes from these suspensions. You may need to approach your incremental migration from the other direction (top-down rather than bottom-up) in order to use structured concurrency, else you will be dealing with a lot of unstructured tasks because your calling code can't handle having to suspend.

1 Like

You want to make fetchModelB async, but keep the wrapping function loadModelC callback-based?

Correct. Migrating the entire loadModelC method to using structured concurrency, when there are many, many such methods in the codebase that might contain many nested calls like fetchModelA, is something we're hoping to approach incrementally within each method rather than all at once. Perhaps there is no way around this, though.

I would agree with @Karl then: The first method (of these three) that should be migrated from callbacks to async should be loadModelC, not fetchModelA or fetchModelB.

That's not so much a problem; the problem is that the use of those unstructured tasks mean that we're going to have to rewrite the whole loadModelC methods if we want to introduce any async/await code at all, which is a shame.

Seems there's no way around that though! In any case, many thanks to both of you.

Could this work for you?

func loadModelC(_ completion: @escaping (Result<ModelC, Error>) -> Void) {
    let group = DispatchGroup()
    var resultA: Result<ModelA, Error>?
    var resultB: Result<ModelB, Error>?
    let notification = Notification.Name("loadModelC.doneNotification")
    
    NotificationCenter.default.addObserver(forName: notification, object: nil, queue: nil or a queue) { note in
        let info = note.userInfo!
        switch info["name"] as! String {
        case "resultA": resultA = (info["resultA"] as! Result<ModelA, Error>)
        case "resultB": resultB = (info["resultB"] as! Result<ModelB, Error>)
        default: fatalError()
        }
    }
    
    @Sendable func notify(_ result: Result<ModelA, Error>) {
        NotificationCenter.default.post(name: notification, object: nil, userInfo: ["name" : "resultA", "resultA" : result])
    }

    Task {
        let resultA: Result<ModelA, Error>
        do {
            resultA = .success(try await fetchModelA())
        } catch {
            resultA = .failure(error)
        }
        notify(resultA)
    }
    ...

I suppose that might work, but I'm not a big fan of NotificationCenter and I don't think it's a good fit for this scenario.

Obviously as an interim solution, like the scaffolding that is eventually removed.

The upcoming Swift 6 language mode has a goal of enforcing data race safety, which will require the SDK to declare concurrently-executed callbacks like those on DispatchQueue to be @Sendable. You won't have to immediately change language modes, of course, and Swift will continue to provide source compatibility if you don't, but if you're asking where the language is going, it definitely includes things like that becoming @Sendable.

Continuations and explicit Tasks are usually the right way to bridge sync and async code when you need to migrate them gradually.

1 Like

Maybe this then?

class D {
    let group = DispatchGroup()
    var resultA: Result<ModelA, Error>?
    var resultB: Result<ModelB, Error>?
    
    func loadModelC(_ completion: @escaping (Result<ModelC, Error>) -> Void) {
        Task {
            do {
                resultA = .success(try await fetchModelA()) // πŸ”Ά Warning about `@Sendable`
            } catch {
                resultA = .failure(error)
            }
        }
        ...
    }
}