7
$\begingroup$

In some languages, async/await can be simply transformed into callbacks, as if it were syntactical sugar:

async function foo() {
  bar();
  const x = await baz();
  return qux(x.y);
}
// equivalent to:
function foo() {
  bar();
  return baz().then(x => qux(x.y));
}

However, this isn't necessarily the case. For example, Swift has no equivalent of the .then method, and asynchronous calls inherit the context they're called from (priority, executing actor if any, etc) unless explicitly run detached. It's not clear to me how this would be implemented, since task-local variables are not thread-local -- await may switch threads but does not switch tasks, and one task may spawn several parallel child tasks. The documentation on async/await is reminiscent of coroutines, using phrases like "yielding execution", but it doesn't get into the implementation details (as most developers don't need to know them).

Here's an example of task-local variables in Swift:

class Foo {
  @TaskLocal static var thing = 0
  static func printThing(_ label: String) { print(label, thing, separator: ": ") }
}

Task {
  Foo.$thing.withValue(1) {
    Foo.printThing("Start of first task")

    Task {
      Foo.printThing("Start of first task's child")
      try! await Task.sleep(nanoseconds: 10_000_000)
      Foo.printThing("End of first task's child")
    }

    Task.detached {
      Foo.printThing("Detached task spawned in first task")
    }
  }

  try! await Task.sleep(nanoseconds: 5_000_000)

  Foo.$thing.withValue(2) {
    Foo.printThing("End of first task")
  }
}

Task {
  Foo.$thing.withValue(3) {
    Foo.printThing("Second task")
  }
}

/* possible output:
Start of first task: 1
Second task: 3
Detached task spawned in first task: 0
Start of first task's child: 1
End of first task: 2
End of first task's child: 1
*/

The task-local variable is a static variable on class Foo, and that I never reference the task instances after spawning them. The values follow the assignment order in source, not the execution order.

How could thread-agnostic (and indeed thread-safe), task-local variables be implemented?

$\endgroup$
6
  • $\begingroup$ I don’t entirely understand your question. You say async/await can be desugared, which is true, but surely that answers your question as-stated. The fact that Swift doesn’t expose a then method in its API doesn’t fundamentally mean it couldn’t implement things that way internally. If your question is really about what Swift does specifically, I think it would be helpful to say that in your question, or at least elaborate on what features Swift offers that you think are incompatible with that desugaring. $\endgroup$
    – Alexis King
    Commented Jun 28, 2023 at 5:04
  • $\begingroup$ @AlexisKing I’ve seen it mentioned that certain features such as async let — which spawn a child task without the overhead of a full-fledged TaskGroup — require additional runtime machinery. Regardless, even if I can replace await with some runtime method that isn’t exposed to the end user, it’s still not clear to me how to implement task-local variables, since those can live anywhere (just with the @TaskLocal property wrapper). $\endgroup$
    – Bbrk24
    Commented Jun 28, 2023 at 11:52
  • $\begingroup$ I don’t understand what you mean by “can live anywhere”. I’m not intimately familiar with the way Swift implements async/await, which is what you really seem to be asking about, but every task necessarily has state associated with it, whether that means something like closures or captured stack frames. The variables are stored there. What do you think is the obstacle to storing them? $\endgroup$
    – Alexis King
    Commented Jun 28, 2023 at 14:54
  • $\begingroup$ Here is an example of what I mean. Notice that the @TaskLocal variable lives on class Foo, not the instance of the task. $\endgroup$
    – Bbrk24
    Commented Jun 28, 2023 at 16:20
  • $\begingroup$ Could you edit this example into your question? $\endgroup$
    – Alexis King
    Commented Jun 28, 2023 at 16:45

1 Answer 1

5
$\begingroup$

Task-local variables can be implemented in essentially the same way as thread-local variables, which have two standard implementation strategies:

  1. Every thread-local variable is really a weak map from threads to values. When the value is accessed, the current thread ID is used to index into the map and look up the current thread’s value.

  2. Every thread-local variable is really just a unique key, and it doesn’t “contain” anything at all. Every thread context then contains a map from these keys to values. Accessing a thread-local variable just looks up the value in the current thread’s map using the variable’s key.

This second strategy is more commonly chosen when the implementor has control over the language and runtime, as it has nicer characteristics:

  • Since the values are stored in the thread context itself, they are automatically deallocated when the thread is destroyed.

  • There is no need for any synchronization because each thread context has its own distinct map, and no state is actually stored in the thread-local variables themselves.

There are many optimizations to the basic technique that allow thread-local storage to be made more efficient. For example, this article discusses how thread-local storage is implemented on Linux; it is quite extensive. But the basic idea remains the same: thread-local variables are keys into a per-thread mapping.

This strategy can be easily adapted to tasks. All that is required is that tasks have some well-defined notion of task identity (so it’s clear what it means for code to execute within “the same task”) and that there is some way of obtaining a reference to the currently-executing task. Swift provides this via the withUnsafeCurrentTask(body:) function, which the proposal notes is used in the implementation of task-local storage.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .