The coroutine proposal as it stands essentially exposes raw delimited 
continuations. While this is a flexible and expressive feature in the abstract, 
for the concrete purpose of representing asynchronous coroutines, it provides 
weak user-level guarantees about where their code might be running after being 
resumed from suspension, and puts a lot of pressure on APIs to be well-behaved 
in this respect. And if we're building toward actors, where async actor methods 
should be guaranteed to run "in the actor", I think we'll *need* something more 
than the bare-bones delimited continuation approach to get there. I think the 
proposal's desire to keep coroutines independent of a specific runtime model is 
a good idea, but I also think there are a couple possible modifications we 
could add to the design to make it easier to reason about what context things 
run in for any runtime model that benefits from async/await:

# Coroutine context

Associating a context value with a coroutine would let us thread useful 
information through the execution of the coroutine. This is particularly useful 
for GCD, so you could attach a queue, QoS, and other attributes to the 
coroutine, since these aren't reliably available from the global environment. 
It could be a performance improvement even for things like per-pthread queues, 
since coroutine context should be cheaper to access than pthread_self. 

For example, a coroutine-aware `dispatch_async` could spawn a coroutine with 
the queue object and other interesting attributes as its context:

extension DispatchQueue {
 func `async`(_ body: () async -> ()) {
   dispatch_async(self, {
     beginAsync(context: self) { await body() }
   })
 }
}

and well-behaved dispatch-aware async APIs could use the context to decide how 
they should schedule completion:

func asyncOverlay() async -> T {
 // If the coroutine is associated with a queue, schedule completion on that 
queue
 if let currentQueue = getCoroutineContext() as? DispatchQueue {
   if #available(iOS 23, macOS 10.24, *) {
     // Maybe Apple frameworks add APIs that let you control completion 
dispatch up front…
     suspendAsync { continuation in
       originalAPI(on: currentQueue, completion: continuation)
     }
   } else {
     // …but we still need to queue-hop explicitly for backward deployment
     suspendAsync { continuation in
       originalAPI(completion: { dispatch_async(currentQueue, continuation) })
     }
   }
 } else {
   // If the coroutine isn't associated with a queue, leave it up to the API to 
schedule continuation
   suspendAsync { continuation in
     originalAPI(completion: continuation)
   }
 }
}

Similarly, if you've built your own framework on another platform with 
per-thread event loops and want to maintain thread affinity for coroutines 
through your APIs, you could similarly provide APIs that beginAsync with a 
thread ID as context and use that context to figure out where to schedule the 
continuation when you do something that suspends.

# `onResume` hooks

Relying on coroutine context alone still leaves responsibility wholly on 
suspending APIs to pay attention to the coroutine context and schedule the 
continuation correctly. You'd still have the expression problem when 
coroutine-spawning APIs from one framework interact with suspending APIs from 
another framework that doesn't understand the spawning framework's desired 
scheduling policy. We could provide some defense against this by letting the 
coroutine control its own resumption with an "onResume" hook, which would run 
when a suspended continuation is invoked instead of immediately resuming the 
coroutine. That would let the coroutine-aware dispatch_async example from above 
do something like this, to ensure the continuation always ends up back on the 
correct queue:

extension DispatchQueue {
 func `async`(_ body: () async -> ()) {
   dispatch_async(self, {
     beginAsync(
       context: self,
       body: { await body() },
       onResume: { continuation in
         // Defensively hop to the right queue
         dispatch_async(self, continuation)
       }
     )
   })
 }
}

This would let spawning APIs provide a stronger guarantee that the spawned 
coroutine is always executing as if scheduled by a specific queue/actor/event 
loop/HWND/etc., even if later suspended by an async API working in a different 
paradigm. This would also let you more strongly associate a coroutine with a 
future object representing its completion:

class CoroutineFuture<T> {
 enum State {
   case busy // currently running
   case suspended(() -> ()) // suspended
   case success(T) // completed with success
   case failure(Error) // completed with error
 }

 var state: State = .busy

 init(_ body: () async -> T) {

   beginAsync(
     body: {
       do {
         self.state = .success(await body())
       } catch {
         self.state = .failure(error)
       }
     },
     onResume: { continuation in
       assert(self.state == .busy, "already running?!")
       self.state = .suspended(continuation)
     }
   }
 }

 // Return the result of the future, or try to make progress computing it
 func poll() throws -> T? {
   switch state {
   case .busy:
     return nil
   case .suspended(let cont):
     cont()
     switch state {
     case .success(let value):
       return value
     case .failure(let error):
       throw error
     case .busy, .suspended:
       return nil
     }
   case .success(let value):
     return value
   case .error(let error):
     throw error
 }
}


A downside of this design is that it incurs some cost from defensive 
rescheduling on the continuation side, and also prevents writing APIs that 
intentionally change context across an `await`, like a theoretical 
"goToMainThread()" function (though you could do that by spawning a 
semantically-independent coroutine associated with the main thread, which might 
be a better design anyway).

-Joe
_______________________________________________
swift-evolution mailing list
[email protected]
https://lists.swift.org/mailman/listinfo/swift-evolution

Reply via email to