Skip to content
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

Show in-order processing patterns, equivalent to queue.async {} #69

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Guide.docc/IncrementalAdoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,4 @@ NS_SWIFT_ASYNC_NAME
NS_SWIFT_ASYNC_NOTHROW
NS_SWIFT_UNAVAILABLE_FROM_ASYNC(msg)
```

1 change: 1 addition & 0 deletions Guide.docc/MigrationGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ full code examples, and learn about how to contribute in the [repository][].
- <doc:CompleteChecking>
- <doc:IncrementalAdoption>
- <doc:CommonProblems>
- <doc:RuntimeBehavior>
207 changes: 207 additions & 0 deletions Guide.docc/RuntimeBehavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Runtime Behavior


Learn how Swift concurrency runtime semantics differ from other runtimes you may
be familiar with, and familiarize yourself with common patterns to achieve
similar end results in terms of execution semantics.

Swift's concurrency model with a strong focus on async/await, actors and tasks,
means that some patterns from other libraries or concurrency runtimes don't
translate directly into this new model. In this section, we'll explore common
patterns and differences in runtime behavior to be aware of, and how to address
them while you migrate your code to Swift concurrency.
Comment on lines +8 to +12
Copy link
Member

Choose a reason for hiding this comment

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

I think runtime behaviour is a great article name but IMO we should talk about executors and executor jobs here first and how they relate to Tasks before we dive into implementing a specific pattern of ordered processing.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, I'm not writing those in "final document order";

we'll do #59 which will explain executors, and it will be here; no worries.


## Ordered Processing

### From Synchronous Contexts

Swift concurrency naturally enforces program order for asynchronous code as long
as the execution remains in a single Task - this is equivalent to using "a single
thread" to execute some work, but is more resource efficient because the task may
suspend while waiting for some work, for example:
Copy link
Collaborator

Choose a reason for hiding this comment

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

This sentence is quite long, can it be broken down?


```swift
// ✅ Guaranteed order, since caller is a single task
let printer: Printer = ...
await printer.print(1)
await printer.print(2)
await printer.print(3)
```

This code is structurally guaranteed to execute the prints in the expected "1, 2, 3"
order, because the caller is a single task.

Dispatch queues offered the common `queue.async { ... }` way to kick off some
asynchronous work without waiting for its result. In dispatch, if one were to
Copy link
Collaborator

Choose a reason for hiding this comment

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

The past-tense here almost makes it sound like they cannot do this anymore haha.

Copy link
Member Author

Choose a reason for hiding this comment

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

hah :) need to change you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it is worth changing, just to keep the FUD to a minimum

write the following code:

```swift
let queue = DispatchSerialQueue(label: "queue")

queue.async { print(1) }
queue.async { print(2) }
queue.async { print(3) }
```

The order of the elements printed is guaranteed to be `1`, `2` and finally `3`.

At first, it may seem like `Task { ... }` is exactly the same, because it also
kicks off some asynchronous computation without waiting for it to complete.
A naively port of the same code might look like this:

```swift
// ⚠️ any order of prints is expected
Task { print(1) }
Task { print(2) }
Task { print(3) }
```
mattmassicotte marked this conversation as resolved.
Show resolved Hide resolved

This example **does not** guarantee anything about the order of the printed values,
because Tasks are enqueued on a global (concurrent) threadpool which uses multiple
Copy link
Member

Choose a reason for hiding this comment

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

It would help if we spoke about executors here first IMO.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I've been thinking a lot about this. Many readers are going to be seeing these concepts for the first time. I find it easier to see concepts I work with in day-to-day code first, and then have access to lower-level details should I need them. Structuring these kinds of things is tricky. But, I think starting high-level and then moving lower could help offer more progressive disclosure. But, it also could make for a less organized document. So, I'm not sure what the correct trade-off is here, especally given that this is specifically about runtime behavior.

Copy link
Member Author

Choose a reason for hiding this comment

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

There's sections to be written about executors, that'd address this concern imho; it would not be in this section anyway

threads to schedule the tasks. Because of this, any of the tasks may be executed first.

Another attempt at recovering serial execution may be to use an actor, like this:

```swift
// ⚠️ still any order of prints is possible
actor Printer {}
func go() {
// Notice the tasks don't capture `self`!
Task { print(1) }
Task { print(2) }
Task { print(3) }
}
}
```

This specific example still does not even guarantee enqueue order (!) of the tasks,
and much less actual execution order. The tasks this is because lack of capturing
`self` of the actor, those tasks are effectively going to run on the global concurrent
pool, and not on the actor. This behavior may be unexpected, but it is the current semantics.

We can correct this a bit more in order to ensure the enqueue order, by isolating
the tasks to the actor, this is done as soon as we capture the `self` of the actor:

```swift
// ✅ enqueue order of tasks is guaranteed
//
// ⚠️ however. due to priority escalation, still any order of prints is possible (!)
actor Printer {}
func go() { // assume this method must be synchronous
// Notice the tasks do capture self
Task { self.log(1) }
Task { self.log(2) }
Task { self.log(3) }
}

func log(_ int: Int) { print(int) }
}
```

This improves the situation because the tasks are now isolated to the printer
instance actor (by means of using `Task{}` which inherits isolation, and refering
to the actor's `self`), however their specific execution order is _still_ not deterministic.

Actors in Swift are **not** strictly FIFO ordered, and tasks
processed by an actor may be reordered by the runtime for example because
of _priority escalation_.

**Priority escalation** takes place when a low-priority task suddenly becomes
await-ed on by a high priority task. The Swift runtime is able to move such
task "in front of the queue" and effectively will process the now priority-escalated
task, before any other low-priority tasks. This effectively leads to FIFO order
violations, because such task "jumped ahead" of other tasks which may have been
enqueue on the actor well ahead of it. This does does help make actors very
responsive to high priority work which is a valuable property to have!

> Note: Priority escalation is not supported on actors with custom executors.

The safest and correct way to enqueue a number of items to be processed by an actor,
in a specific order is to use an `AsyncStream` to form a single, well-ordered
sequence of items, which can be emitted to even from synchronous code.
And then consume it using a _single_ task running on the actor, like this:
Comment on lines +119 to +122
Copy link
Member

Choose a reason for hiding this comment

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

We have to call out here that this pattern is good to get order but it is potentially dangerous since it can queue up an unbounded amount of work. We should call this out here that AsyncStream has no external backpressure.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, I can do a small note and direct to docs; there's a risk in too much information here, so I'll do just a short note on it


```swift
// ✅ Guaranteed order in which log() are invoked,
// regardless of priority escalations, because the disconnect
// between the producing and consuming task
actor Printer {
let stream: AsyncStream<Int>
let streamContinuation: AsyncStream<Int>.Continuation
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't this have to be nonisolated for the example to work?

Copy link
Member Author

Choose a reason for hiding this comment

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

I compiled this with Swift 6 mode, seems fine; I can double check again

var streamConsumer: Task<Void, Never>!

init() async {
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
self.stream = stream
self.streamContinuation = continuation

// Consuming Option A)
// Start consuming immediately,
// or better have a caller of Printer call `startStreamConsumer()`
// which would make it run on the caller's task, allowing for better use of structured concurrency.
self.streamConsumer = Task { await self.consumeStream() }
Copy link
Member

Choose a reason for hiding this comment

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

Can we please not put spawning an unstructured task in this example here. Instead let's add a func run() async that consumes the stream. The users of this actor can then decide on what task to call that run method on.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's explained below. I can inverse the order I guess

}

deinit {
self.streamContinuation.finish()
}

nonisolated func enqueue(_ item: Int) {
self.streamContinuation.yield(item)
}

nonisolated func cancel() {
self.streamConsumer?.cancel()
}

func consumeStream() async {
for await item in self.stream {
if Task.isCancelled { break }

log(item)
}
}

func log(_ int: Int) { print(int) }
}
```

and invoke it like:

```
let printer: Printer = ...
printer.enqueue(1)
printer.enqueue(2)
printer.enqueue(3)
```

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think including a real init to make this syntatically valid, and then adding a swift will make this stand out less than it currently does.

Copy link
Member Author

Choose a reason for hiding this comment

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

ok will do

We're assuming that the caller has to be in synchronous code, and this is why we make the `enqueue`
method `nonisolated` but we use it to funnel work items into the actor's stream.

The actor uses a single task to consume the async sequence of items, and this way guarantees

Choose a reason for hiding this comment

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

Suggested change
The actor uses a single task to consume the async sequence of items, and this way guarantees
The actor uses a single task to consume the async sequence of items, and this guarantees

the specific order of items being handled.

This approach has both up and down-sides, because now the item processing cannot be affected by
priority escalation. The items will always be processed in their strict enqueue order,
and we cannot easily await for their results -- since the caller is in synchronous code,
so we might need to resort to callbacks if we needed to report completion of an item
getting processed.

Notice that we kick off an unstructured task in the actor's initializer, to handle the
Copy link
Member

Choose a reason for hiding this comment

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

Ah we mentioned this down here. Can we just not show the unstructured task to begin with?

consuming of the stream. This also may be sub-optimal, because as cancellation must
now be handled manually. You may instead prefer to _not_ create the consumer task
at all in this `Printer` type, but require that some existing task invokes `await consumeStream()`, like this:

```
let printer: Printer = ...
Task { // or some structured task
await printer.consumeStream()
}

printer.enqueue(1)
printer.enqueue(2)
printer.enqueue(3)
```

Copy link
Collaborator

Choose a reason for hiding this comment

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

same thing here - the lack of syntax highlighting really makes these stand out.

Copy link
Member Author

Choose a reason for hiding this comment

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

ok, will fix

In this case, you'd should make sure to only have at-most one task consuming the stream,

Choose a reason for hiding this comment

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

Suggested change
In this case, you'd should make sure to only have at-most one task consuming the stream,
In this case, you should make sure to only have at-most one task consuming the stream,

e.g. by using a boolean flag inside the printer actor.