-
-
Notifications
You must be signed in to change notification settings - Fork 12
Make ActorQueue tasks execute on the target actor's execution context #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
9e2ed90
to
46fdca4
Compare
Codecov Report
@@ Coverage Diff @@
## main #9 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 7 7
Lines 703 752 +49
=========================================
+ Hits 703 752 +49
|
46fdca4
to
505f846
Compare
Sources/AsyncQueue/ActorQueue.swift
Outdated
public func async(_ task: @escaping @Sendable () async -> Void) { | ||
taskStreamContinuation.yield(task) | ||
public func async(_ task: @escaping @Sendable (isolated ActorType) async -> Void) { | ||
taskStreamContinuation.yield(ActorTask(target: target!, task: task)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using force unwraps is fine here given the strict instructions for how an ActorQueue
should be utilized:
- An
ActorQueue
's lifecycle should not exceed the lifecycle of itstarget
- An
ActorQueue
'starget
should be set in thetarget
actor'sinit
Therefore, the target
will always be available if the API is properly utilized.
The other reason I opted to force unwrap is there is no good way to surface a "target is nil
" error to the call-site without complicating the API. We could return a @discaradableResult
Bool
and return false
if guard let target
fails (likely the cleanest of our options), or we could make these APIs throw
an surface an error when the target
is nil
... but, given the expected API contract the target
should never be nil, at which point surfacing these kinds of errors in a non-crashing way to consumers isn't really helpful imo.
505f846
to
adbef7a
Compare
|
||
private actor ActorExecutor { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that we're explicitly targeting a specific actor, we no longer need another actor
type! The code previously on ActorExecutor
moved down into an extension on Actor
below.
// This works around a bug when compiling with Xcode 14.1: https://github.com/apple/swift/issues/62503 | ||
_ = void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We no longer need this since we're sending self
as a parameter of the task
978502c
to
5e91ff5
Compare
|
||
```swift |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code block didn't add as much as I wanted it to, and with the new API rev on ActorQueue
it was difficult to showcase the ActorQueue
API in a similar manner without breaking the ActorQueue
's guidelines (i.e. we couldn't show a concise, free-form actor queue without adopting the queue execution context outside of an actor's init
).
I don't think we lose much by deleting this code sample, since our in-code documentation explains everything explained here.
c67c361
to
c343fc7
Compare
5c302b6
to
a61f857
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Love this @dfed . Great work iterating and coming to such an elegant solution 🏆
count += 1 | ||
return count | ||
let incrementedCount = count | ||
XCTAssertEqual(incrementedCount, expectedCount) // often fails |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I always think it's good to pass through file
and line
to helper functions that call XCTAssert*
functions but I also see value in a simple README.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious why we need/want to change to this version of the code that no longer returns the new count.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar comment for other code examples.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious why we need/want to change to this version of the code that no longer returns the new count.
In this example it doesn't matter much, but in later examples where we have code like the following it becomes necessary and I didn't want to change examples too much:
for iteration in 1...100 {
counter.incrementAndAssertCountEquals(iteration)
}
If we used an increment
that would return, we'd end up with:
for iteration in 1...100 {
let count = await counter.increment()
XCTAssertEqual(count, iteration) // this test will succeed since we `await` the return value of `increment` each time
}
README.md
Outdated
}) | ||
} | ||
// Wait all enqueued tasks to finish. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
README.md
Outdated
}) | ||
} | ||
// Wait all enqueued tasks to finish. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Wait all enqueued tasks to finish. | |
// Wait for all enqueued tasks to finish. |
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦♂️ yes. Fixed up in a4871ad
} | ||
``` | ||
|
||
### Sending ordered asynchronous tasks to Actors | ||
FIFO execution has a key downside: the queue must wait for all previously enqueued work – including suspended work – to complete before new work can begin. If you desire new work to start when a prior task suspends, utilize an `ActorQueue`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice this is helpful.
@@ -94,7 +94,7 @@ public final class FIFOQueue: Sendable { | |||
} | |||
|
|||
/// Schedules an asynchronous throwing task and returns after the task is complete. | |||
/// The scheduled task will not execute until all prior tasks have completed. | |||
/// The scheduled task will not execute until all prior tasks – including suspended tasks – have completed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm pretty sure we missed one
/// The scheduled task will not execute until all prior tasks have completed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
XCTAssertNil(weakCounter) | ||
} | ||
|
||
func test_async_retainsAdoptedActorUntilQueueFlushes() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't flush the queue. Can we rename it to something about how the actor is retained while there is in-progress tasks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically we are flushing the queue by waiting for the async
task to complete, but that's not super intuitive! Updated in 1c8cab5
await semaphore.signal() | ||
} | ||
|
||
func test_async_taskParameterIsAdoptedActor() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
// Signal the semaphore from the actor queue. | ||
// If the actor queue were FIFO, this test would hang since this code would never execute: | ||
// we'd still be waiting for the prior `wait()` tasks to finish. | ||
await semaphore.signal() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉
let count = await counter.count | ||
XCTAssertEqual(count, iteration) | ||
await systemUnderTest.await { counter in | ||
XCTAssertEqual(counter.count, iteration) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So much cleaner.
c4d455f
to
a4871ad
Compare
Co-authored-by: Michael Bachand <[email protected]>
@@ -89,13 +89,16 @@ FIFO execution has a key downside: the queue must wait for all previously enqueu | |||
|
|||
Use an `ActorQueue` to send ordered asynchronous tasks to an `actor`’s isolated context from nonisolated or synchronous contexts. Tasks sent to an actor queue are guaranteed to begin executing in the order in which they are enqueued. However, unlike a `FIFOQueue`, execution order is guaranteed only until the first [suspension point](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID639) within the enqueued task. An `ActorQueue` executes tasks within the its adopted actor’s isolated context, resulting in `ActorQueue` task execution having the same properties as `actor` code execution: code between suspension points is executed atomically, and tasks sent to a single `ActorQueue` can await results from the queue without deadlocking. | |||
|
|||
An instance of an `ActorQueue` is designed to be utilized by a single `actor` instance: tasks sent to an `ActorQueue` utilize the isolated context of the queue‘s adopted `actor` to serialize tasks. As such, the lifecycle of any `ActorQueue` should not exceed the lifecycle of its `actor`. It is strongly recommended that an `ActorQueue` be a `let` constant on the adopted `actor`. Additionally, an `actor` utilizing an `ActorQueue` should set the adopted execution context of the queue to `self` within the `actor`’s `init`. Failing to set an adopted execution context prior to enqueuing work on an `ActorQueue` will result in a crash. | |||
An instance of an `ActorQueue` is designed to be utilized by a single `actor` instance: tasks sent to an `ActorQueue` utilize the isolated context of the queue‘s adopted `actor` to serialize tasks. As such, there are a few requirements that must be met when dealing with an `ActorQueue`: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe "few" -> "couple"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since there are two.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😅 was leaving room for me to add more later, but that's certainly a silly approach. Will fix this up in #13
@@ -89,13 +89,16 @@ FIFO execution has a key downside: the queue must wait for all previously enqueu | |||
|
|||
Use an `ActorQueue` to send ordered asynchronous tasks to an `actor`’s isolated context from nonisolated or synchronous contexts. Tasks sent to an actor queue are guaranteed to begin executing in the order in which they are enqueued. However, unlike a `FIFOQueue`, execution order is guaranteed only until the first [suspension point](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID639) within the enqueued task. An `ActorQueue` executes tasks within the its adopted actor’s isolated context, resulting in `ActorQueue` task execution having the same properties as `actor` code execution: code between suspension points is executed atomically, and tasks sent to a single `ActorQueue` can await results from the queue without deadlocking. | |||
|
|||
An instance of an `ActorQueue` is designed to be utilized by a single `actor` instance: tasks sent to an `ActorQueue` utilize the isolated context of the queue‘s adopted `actor` to serialize tasks. As such, the lifecycle of any `ActorQueue` should not exceed the lifecycle of its `actor`. It is strongly recommended that an `ActorQueue` be a `let` constant on the adopted `actor`. Additionally, an `actor` utilizing an `ActorQueue` should set the adopted execution context of the queue to `self` within the `actor`’s `init`. Failing to set an adopted execution context prior to enqueuing work on an `ActorQueue` will result in a crash. | |||
An instance of an `ActorQueue` is designed to be utilized by a single `actor` instance: tasks sent to an `ActorQueue` utilize the isolated context of the queue‘s adopted `actor` to serialize tasks. As such, there are a few requirements that must be met when dealing with an `ActorQueue`: | |||
1. The lifecycle of any `ActorQueue` should not exceed the lifecycle of its `actor`. It is strongly recommended that an `ActorQueue` be a `let` constant on the adopted `actor`. Enqueuing a task to an `ActorQueue` isntance after its adopted `actor` has been deallocated will result in a crash. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"isntance" -> "instance"
|
||
An `ActorQueue` can easily enqueue tasks that execute on an actor’s isolated context from a nonisolated context in order: | ||
```swift | ||
func testActorQueueOrdering() async { | ||
actor Counter { | ||
init() { | ||
// Adopting the execution context in `init` satisfies requirement #2 above. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
This PR updates the
ActorQueue
API to maketask
s sent to the queue to execute within the targetactor
's context. This API change is a breaking one, and so we'll be bumping our version to0.2.0
as part of this change.Similarly to the benefits discussed in #6, the benefits of running tasks on the target
actor
's context are:actor
-conforming type can be executed without requiring multiple suspensions.async
-decorated properties and methods on a givenactor
requires theawait
keyword, enabling the casual reader to distinguish which tasks will execute synchronously and which may suspend.These benefits are particularly important on an
ActorQueue
, where suspension can lead to interleaving of enqueued work.The downside of this API is that avoiding a retain cycle between an actor queue and the target actor requires the use of an unowned reference and some force unwrapping. See comments below.
Note that this PR is built on top of #8 to avoid conflicts.