Skip to content

Commit 9b7f11c

Browse files
ktosoDougGregor
andauthored
[SE-0417] Revise Task(executorPreference:) ownership semantics (#2504)
Co-authored-by: Doug Gregor <[email protected]>
1 parent 048f0b6 commit 9b7f11c

File tree

1 file changed

+26
-31
lines changed

1 file changed

+26
-31
lines changed

proposals/0417-task-executor-preference.md

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -422,37 +422,23 @@ and wrapping the code that is required to run on a specific executor in an `with
422422

423423
Nevertheless, because we understand there may be situations where synchronous code may want to compare task executors, this capability is exposed for advanced use cases.
424424

425-
Another use case may be carrying the same task executor into an un-structured Task -- although this should only be done with **extreme caution**,
426-
because it breaks structured concurrency lifetime expectations of executors. For example, the following code is correct under structured concurrency's
427-
default and automatic behavior surrounding task executors:
425+
### TaskExecutor ownership
428426

429-
```swift
430-
func computeThings() async {
431-
let eventLoop = MyCoolEventLoop()
432-
defer { eventLoop.shutdown() }
427+
Task executors, unlike serial executors, are explicitly owned by tasks as long as they are running on the given task executor.
433428

434-
let computed = await withTaskExecutorPreference(eventLoop) {
435-
async let first = computation(1)
436-
async let second = computation(2)
437-
return await first + second
438-
}
429+
This is achieved in two ways. The `withTaskExecutorPreference` APIs by their construction as `with...`-style APIs,
430+
naturally retain and keep alive the task executor for as long as the `with... { ... }` body is executing.
431+
This also naturally extends to other structured concurrency constructs like `async let` and task groups, which can
432+
rely on the task executor to remain alive while these constructs are running within such `withTaskExecutorPreference(...) { ... }` closure body.
439433

440-
return computed // event loop will be shutdown and the executor destroyed(!)
441-
}
434+
Unstructured tasks which are started with a task executor preference (e.g. `Task(executorPreference: someTaskExecutor)`),
435+
take ownership of the executor for as long as the task is running.
442436

443-
func computation(_ int: Int) -> Int { return int * 2 }
444-
```
445-
446-
The above code is structurally correct and we guarantee the lifetime of `MyCoolEventLoop` throughout all of its uses
447-
by structured concurrency tasks in this snippet.
448-
449-
The following snippet is **not safe**, which is why task executors are not inherited to un-structured tasks:
437+
In other words, it is safe to rely on a task, structured or not, to keep alive the task executor it may be running on.
438+
This makes it possible to write code like the following snippet, without having to worry about manually keeping the
439+
executor alive until "all tasks which may be executing on it have finished":
450440

451441
```swift
452-
// !!! POTENTIALLY UNSAFE !!!
453-
// Do not do this, unless you can guarantee the lifetime of TaskExecutor
454-
// exceeds all potential for any task to be running on it (!)
455-
456442
func computeThings() async {
457443
let eventLoop: any TaskExecutor = MyCoolEventLoop()
458444
defer { eventLoop.shutdown() }
@@ -466,19 +452,27 @@ func computeThings() async {
466452
return computed // event loop will be shutdown and the executor destroyed(!)
467453
}
468454

469-
// DANGEROUS; MUST ENSURE THE EXECUTOR REMAINS ALIVE FOR AS LONG AS ANY TASK MAY BE RUNNING ON IT
470455
func computation(_ int: Int) -> Int {
471456
withUnsafeCurrentTask { task in
472457
let unownedExecutor: UnownedTaskExecutor? = task?.unownedTaskExecutor
473-
let eventLoop: MyCoolEventLoop? = EventLoops.find(unownedExecutor)
474-
475-
// Dangerous because there is no structured guarantee that eventLoop will be kept alive
476-
// for as long as there are any of its child tasks and functions running on it
477-
Task(executorPreference: eventLoop) { ... }
458+
let eventLoop: MyCoolEventLoop = EventLoops.find(unownedExecutor)
459+
// we need to start an unstructured task for some reason (try to avoid this if possible)
460+
// and we have located the `MyCoolEventLoop` in our "cache".
461+
//
462+
// Since we have a real MyCoolEventLoop reference, this is safe to forward
463+
// to the unstructured task which will retain it.
464+
Task(executorPreference: eventLoop) {
465+
async let something = ... // inherits the executor preference
466+
}
478467
}
479468
}
480469
```
481470

471+
Same as with `SerialExecutor`'s `UnownedSerialExecutor` type, the `UnownedTaskExecutor` does _not_ retain the executor,
472+
so you have to be extra careful when relying on unowned task executor references for any kind of operations. If you
473+
were to write some form of "lookup" function, which takes an unowned executor and returns an `any TaskExecutor`,
474+
please make sure that the returned references are alive (i.e. by keeping them alive in the "cache" using strong references).
475+
482476
## Combining `SerialExecutor` and `TaskExecutor`
483477

484478
It is possible to declare a single executor type and have it conform to *both* the `SerialExecutor` (introduced in the custom actor executors proposal),
@@ -786,6 +780,7 @@ We considered if not introducing this feature could be beneficial and forcing de
786780

787781

788782
## Revisions
783+
789784
- 1.6
790785
- introduce the global `var defaultConcurrentExecutor: any TaskExecutor` we we can express a task specifically wanting to run on the default global concurrency pool.
791786
- 1.5

0 commit comments

Comments
 (0)