Skip to content

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

Merged
merged 12 commits into from
Feb 12, 2023

Conversation

dfed
Copy link
Owner

@dfed dfed commented Jan 16, 2023

This PR updates the ActorQueue API to make tasks sent to the queue to execute within the target actor's context. This API change is a breaking one, and so we'll be bumping our version to 0.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:

  1. Multiple interactions with a single actor-conforming type can be executed without requiring multiple suspensions.
  2. Only interacting with async-decorated properties and methods on a given actor requires the await 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.

@dfed dfed force-pushed the dfed--isolated-actor-task branch 2 times, most recently from 9e2ed90 to 46fdca4 Compare January 16, 2023 01:03
@codecov
Copy link

codecov bot commented Jan 16, 2023

Codecov Report

Merging #9 (1c8cab5) into main (b073271) will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##              main        #9   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            7         7           
  Lines          703       752   +49     
=========================================
+ Hits           703       752   +49     
Impacted Files Coverage Δ
Sources/AsyncQueue/FIFOQueue.swift 100.00% <ø> (ø)
Sources/AsyncQueue/ActorQueue.swift 100.00% <100.00%> (ø)
Tests/AsyncQueueTests/ActorQueueTests.swift 100.00% <100.00%> (ø)

@dfed dfed force-pushed the dfed--isolated-actor-task branch from 46fdca4 to 505f846 Compare January 16, 2023 01:13
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))
Copy link
Owner Author

@dfed dfed Jan 16, 2023

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 its target
  • An ActorQueue's target should be set in the target actor's init

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.

@dfed dfed force-pushed the dfed--isolated-actor-task branch from 505f846 to adbef7a Compare January 16, 2023 01:25

private actor ActorExecutor {
Copy link
Owner Author

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.

Comment on lines -131 to -132
// This works around a bug when compiling with Xcode 14.1: https://github.com/apple/swift/issues/62503
_ = void
Copy link
Owner Author

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

@dfed dfed requested review from bachand and Gabardone January 16, 2023 01:50
@dfed dfed marked this pull request as ready for review January 16, 2023 01:50
@dfed dfed force-pushed the dfed--isolated-actor-task branch from 978502c to 5e91ff5 Compare January 16, 2023 07:43

```swift
Copy link
Owner Author

@dfed dfed Jan 16, 2023

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.

@dfed dfed force-pushed the dfed--isolated-actor-task branch 4 times, most recently from c67c361 to c343fc7 Compare January 16, 2023 07:56
Base automatically changed from dfed--readme to main February 12, 2023 20:46
@dfed dfed force-pushed the dfed--isolated-actor-task branch from 5c302b6 to a61f857 Compare February 12, 2023 20:46
Copy link
Collaborator

@bachand bachand left a 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
Copy link
Collaborator

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.

Copy link
Collaborator

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.

Copy link
Collaborator

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.

Copy link
Owner Author

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.
Copy link
Collaborator

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Wait all enqueued tasks to finish.
// Wait for all enqueued tasks to finish.

?

Copy link
Owner Author

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`.
Copy link
Collaborator

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.
Copy link
Collaborator

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.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh I missed all the methods added in #7. cdfdfe6

XCTAssertNil(weakCounter)
}

func test_async_retainsAdoptedActorUntilQueueFlushes() async {
Copy link
Collaborator

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?

Copy link
Owner Author

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 {
Copy link
Collaborator

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()
Copy link
Collaborator

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So much cleaner.

@dfed dfed force-pushed the dfed--isolated-actor-task branch from c4d455f to a4871ad Compare February 12, 2023 22:07
@dfed dfed enabled auto-merge (squash) February 12, 2023 23:22
@dfed dfed merged commit 8b6a898 into main Feb 12, 2023
@dfed dfed deleted the dfed--isolated-actor-task branch February 12, 2023 23:27
@@ -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`:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe "few" -> "couple"?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there are two.

Copy link
Owner Author

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.
Copy link
Collaborator

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

dfed added a commit that referenced this pull request Feb 21, 2023
dfed added a commit that referenced this pull request Feb 21, 2023
#13)

* Make FIFOQueue, ActorQueue, and tests pass strict concurrency checking

* Post-merge review feedback from #9 & #12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants