Runtime: Deprecate the async API and replace it with CLR abstractions for threading

Created on 21 Nov 2020  Â·  14Comments  Â·  Source: dotnet/runtime

Overview

The current asynchronous programming model is unnecessary because __all__ code written in .NET is asynchronous by nature of running on a multi-threaded operating system. If we don't require explicit language support for executing on a system-managed thread, we shouldn't require explicit language support for executing in a CLR-managed context. It should be the job of the CLR, not the developer, to determine the context in which a method is executing and which context control method is required (e.g. Thread.Sleep vs Task.Delay) _when_ it is required.

public static int Program()
{
  IFoo foo = Foo.Create();
  return foo.Bar();
}

public class Sync : IFoo
{
  int IFoo.Bar() => 100;
}

public class Async : IFoo
{
  int IFoo.Bar()
  {
    wait 1000;  //Thread.Sleep(1000) if the context is a thread, Task.Delay(1000) if the context is a Task.
    return 50;
  }
}

Problem Statement

I understand the implications of breaking changes, and I understand the initial resistance to deprecating major functionality. In this case, I believe there is more harm in retaining support for the async model than in removing it. The async model is a minefield of 'gotchas' that make the language less stable and less accessible.

  1. The current model violates fundamental OO design principles.
    The TAP pattern requires that the interface describe the implementation. The async model requires that interfaces not only describe what members an object has, but also how those members behave (whether or not they block / yield). The result is that a truly agnostic interface must have both a sync and and async method variant for __every method__ (See example at bottom). This can further complicate interface design because properties, despite being methods "under-the-hood", cannot be async. Thus, properties cannot be used in truly-agnostic interfaces, and must be replaced with Get/Set methods which each have sync and async variants. An interface, by definition, should only need to describe what an object does, not how. Whether or not the member blocks / yields is a matter for the documentation, not the method signature. That's the way it's always been for multi-threading, and I see no benefit to the new model.

  2. The async model makes C# less accessible to less-experienced programmers
    The intuitive way to retrieve the result of an async operation from a synchronous context is Task.Result. (But, wait, do you use Task.Result or Task.GetAwaiter().Result?) This can often lead to deadlocks, and internet searches are either fantastically technical or ambiguously simple, because with async there appears to be no in-between. The fact is that the current model is simply not easy to use, and is easy to do 'wrong'.

  3. The async model makes C# makes threading more difficult for experienced programmers.
    In complex systems that utilize the async model, thread synchronization can become an issue when accessing critical sections. C# provides a plethora of low-level thread synchronization primitives that have evolved since the early days of programming. Many effective, well-established patterns exist around these primitives. Using these primitives in an async context can result in undefined behavior as existing primitives can block the thread instead of yielding the task.

Proposed Solution

The design objectives for the async model are already possible with operating system's thread scheduler (traditional threading). Context switches are expensive, however, and presumably this gave rise to the TaskScheduler. The concept of a TaskScheduler in lieu of a managed ThreadScheduler makes sense, because a task may not run on a thread (It could be Overlapped I/O, a message pump, etc.).

So if the objective is to allow code to run in an asynchronous context, why not use the API that has successfully enabled multi-threading out of the OS? Why create a new one? If .NET seeks to create a higher-level abstraction of the execution context, that should be a function of the CLR, __not__ the application logic. We have a virtual machine - we should use it, and this is a great use-case.

Proposal # 1: Refactor the existing Threading API

The existing thread API (Thread.Sleep, Thread.Yield, etc) maps directly to operating-system threads. Instead, these can be made context-aware. If the current context is a Task, calling Thread.Sleep would call Task.Delay. If the current context is an OS Thread, sleep would be called on the OS thread. Thread.Yield would call Task.Yield or pass sleep(0) to the OS, so-on and so-forth. The same refactoring would need to take place for all threading "primitives," including mutexes, semaphores, wait handles, etc.

Benefits:

  • Fewer changes to the C# API and language spec
  • Familiar API for existing
  • Backwards compatibility with complex threading in pre-async applications.

Drawbacks:

  • Lose direct access to OS threads

Proposal # 2: Create a new API that abstracts context control and synchronization

Create a new object model and / or C# language features that provide context control to the application, regardless of what the context may be (A thread, Task, etc). Thread / Task control would all be performed through the single API.

Benefits:

  • Purpose-Specific API would allow C# to define optimal behaviors for context control and synchronization, setting a new standard for languages to abstract context control on a VM

Drawbacks:

  • Significant design effort

C# language support

public void Synchronous()  //Current 
{
  Thread.Yield();
}

public async Task Asycnchronous()  //Current 
{
  await Task.Yield();
}

public void Agnostic()  //Proposed
{
  yield;
}

Example

This is my program. This is what I _need_ to do. The logic doesn't need to (and shouldn't) change depending on whether the file is on a local drive (and the calls synchronous) or the file is on Azure (and the calls are asynchronous). The interface defines what my program cares about. A file needs a name, and I need to open it. This is the case with traditional threading. The implementation should not change depending on whether or not this is running on a Thread or in a Task (async context).

public void Process(IEnumerable<IFile> files)
{
  foreach (var textFile in files.Where(x => x.Name.EndsWith(".txt")))
    using (var stream = textFile.Open())
      ReadTextFile(stream);
}

This is what my interface would like, normally.

public interface IFile
{
  string Name { get; set; }
  Stream Open();
}

public class AzureBlob : IFile
{
  public string Name
  {
    get => GetFileName();  //Async call
    set => SetFileName(value);  //Async call
  }

  public Stream Open() => OpenFile();  //Async call
}

public class OsFile : IFile
{
  FileInfo _file;
  public string Name
  {
    get => _file.Name;
    set => File.Copy(_file.Name, value);
  }

  public Stream Open() => _file.OpenRead();
}

This is the abomination my code becomes when the TAP pattern is applied to the interface. Each method in each concrete class implements either the async or sync method, and simply creates a redirection from the alternate method. This creates a lot of duplicate code and is certainly not the most efficient design paradigm, both from the perspective of performance _and_ maintainability.

  public interface IFile
  {
    string GetName();
    Task<string> GetNameAsync();

    void SetName(string name);
    Task SetNameAsync(string name);

    Stream Open();
    Task<Stream> OpenAsync();
  }

  public class AzureBlob : IFile
  {
    public void SetName(string name) => Task.Run(() => SetNameAsync(name));
    public async Task SetNameAsync(string name) => await SetFileName(name);

    public string GetName() => Task.Run(GetNameAsync).Result;
    public async Task<string> GetNameAsync() => await GetFileName();

    public Stream Open() => Task.Run(OpenFile).Result;
    public async Task<Stream> OpenAsync() => await OpenFile();

  }
  public class OsFile : IFile
  {
    FileInfo _file;
    public string GetName() => _file.Name;
    public Task<string> GetNameAsync() => Task.FromResult(_file.Name);

    public Stream Open() => _file.OpenRead();
    public Task<Stream> OpenAsync() => Task.FromResult((Stream)_file.OpenRead());

    public void SetName(string name) => _file.CopyTo(name);
    public Task SetNameAsync(string name) => await SetName(name);
  }
area-System.Threading untriaged

Most helpful comment

I feel like you're looking at async-await as something that's new, and assuming that there's a lot of investment (both personal and corporate) in regular threading infrastructure, but almost none in async-await. That might have been true in 2012, but it's 2020 now and the world is full of async-await code.

I think there is pretty much zero chance that such a massive breaking change would be introduced to .Net, even if what you're proposing had incredible advantages and low cost of implementation. (But it doesn't: I think the implementation cost would be really high and the advantages are quite limited.)

All 14 comments

I feel like you're looking at async-await as something that's new, and assuming that there's a lot of investment (both personal and corporate) in regular threading infrastructure, but almost none in async-await. That might have been true in 2012, but it's 2020 now and the world is full of async-await code.

I think there is pretty much zero chance that such a massive breaking change would be introduced to .Net, even if what you're proposing had incredible advantages and low cost of implementation. (But it doesn't: I think the implementation cost would be really high and the advantages are quite limited.)

Perhaps I've marketed this change incorrectly. A VM abstraction of context switching could co-exist with async/await: it would simply make it obsolete. I don't want to get hung up on debating sunk cost or breaking changes before discussing the feasibility of the feature.

My point is that thread context management should be a function of the VM/CLR, and context switches should be made transparent to the developer. A Thread.Sleep requires no special language support. A Task.Delay requires async/await. Why? Why must we force developers to write the same procedure in two different ways depending on the context? The OS has always been able to suspend execution - the VM/CLR should be able to do the same. I'd argue that async/await is appropriate for languages without a VM - C# has the CLR: we should use it. C# would be better for it.

I guess the first question I'd like help answering is this - can the CLR do it? Can the CLR abstract the thread context? Is there something I'm missing about async/await, or perhaps the CLR, that makes what I'm suggesting impossible?

I guess the first question I'd like help answering is this - can the CLR do it? Can the CLR abstract the thread context? Is there something I'm missing about async/await, or perhaps the CLR, that makes what I'm suggesting impossible?

If we ignored the existence of async/await (and probably a few other things in native interop code), you're looking at doing something like Java's Project Loom. So the basic answer is "the engineering is possible in the general sense".

This is my program. This is what I need to do. The logic doesn't need to (and shouldn't) change depending on whether the file is on a local drive (and the calls synchronous) or the file is on Azure (and the calls are asynchronous).

Generally _all_ IO, including reading from local drives, should be asynchronous, just because of how slow touching a drive is (this used to matter far more before the advent of modern SSDs). This is before you consider things like network drives, where you might think the file is local, but the OS is just helpfully presenting a file somewhere out on the internet. Or silly situations where you have a really slow local drive and a really fast internet connection, and it's faster to read from the internet than the local drive.

Why must we force developers to write the same procedure in two different ways depending on the context?

... generally you don't. Outside of a few specific things that are best done asynchronously (like IO), you write a synchronous implementation. Which you can wrap into a Task if the situation warrants it. There are very few things that should be explicitly using Thread in modern C#.

Generally all IO, including reading from local drives, should be asynchronous

One implementation of an abstract File API I have represents a virtual file system built on the browser's local storage and accessed via Blazor. It is synchronous because it is marshalled in-memory. Unit tests running on .NET 5 provide the file as a local system file, which is asynchronous, while the runtime application running on MONO (Blazor) in the browser provides a file from the browser, which is synchronous. Since a File is really just a named blob, I would not want to impose a synchronicity on the API, which is abstract. Instead, I'm forced to require that concrete classes of my abstract class implement both synchronous and asynchronous variants.

/edit: The business logic that implements the File does not care how long it takes - whether opening the file is synchronous or asynchronous. The threading model is the concern of the application, not the library.

This highlights my first objection to async/await - we require that the method signature describe the implementation. This is a constraint imposed by the implementation, not the requirements. This is backwards and is a reason to deprecate the async/await API. If we were to look at the OO design of C# in a vacuum, and were to ask the question, "What should be defined in a method signature?", would we really say that we should specify the synchronicity? I would posit "no" as all methods should be presumed asynchronous: any code could be suspended at any time. The code should be indifferent to whether it yields execution to the OS or the CLR. Yet async methods have distinct signatures from their synchronous counterparts, which has a profound impact on API design.

I think you are confusing the "asynchronous" with "multithreading", in C#, Task is not a sort of "replicate" of Future, and "asynchronous" is also working in a single thread environment, every async-marked-method which contains await modifier will be transformed into a state machine(you can check it through Sharplab.io), where the original method is split into several "stages". If you remove async-await, there's no way a compiler knows if any method should be transformed into a state machine or not, the only way is to transform every method which...definitely would come with a high cost. So if you want to use async/await and TAP, I suggest you to learn some coroutine concepts instead of keep using the multithreading way, the SynchronizationContext is actually a Dispatcher which dispatch some task to run on a specified thread, and the so-called "context switching" here, might not be the thread context switching.
I've also noticed those two links you've mentioned above, Don't block on async code and Awaits and UI and deadlocks oh my, the scenarios mentioned in these two posts are also a matter of SynchronizationContext, because the default behaviors of SynchronizationContext in UI applications are dispatched its continuations to captured context(you can consider as the thread by which invokes await operation), so you cannot block to wait for the result on UI thread while the SynchronizationContext trying to dispatch its result back to its captured context(at here, it's UI thread), which apparently will causing deadlocks, and that's why ConfigureAwait(false) appears — it tells the awaiting Task to not use the default behavior(dispatch to captured context), but dispatch the continuations to another thread, then deadlock will definitely disappear. If you understand what does "Dispatch" means here, those bugs will be very clear for you
Also, I cannot find any requirement to write two methods in your examples, one SetNameAsync or GetNameAsync or OpenAsync would be enough because you can just left the Task there and do not apply the await modifier and it will not suspended, if you really need those internal logics to be pure synchronous, you can just use Task.FromResult, it's born for this—And first of all, like what @Clockwork-Muse said: All I/O operations should be asynchronous:

static async Task Main(string[] args)
{
    var azure = new AzureFile();
    var local = new LocalFile();
    var stopWatch = new Stopwatch();
    Console.WriteLine("Start");
    stopWatch.Start();
    azure.OpenFileAsync();
    Console.WriteLine("azure.OpenFileAsync(): This invocation neither suspended nor blocked, it will just run like a background task, because you are not using await, and its internal logic is asynchronous: " + stopWatch.Elapsed.Seconds);
    stopWatch.Restart();
    await azure.OpenFileAsync();
    Console.WriteLine("await azure.OpenFileAsync(): This invocation actually gets suspended, but not blocked, because it is modified by await operator: " + stopWatch.Elapsed.Seconds);
    stopWatch.Restart();
    _ = azure.OpenFileAsync().Result;
    Console.WriteLine("azure.OpenFileAsync().Result: This invocation actually gets blocked, because you are accessing Task.Result, warning, this operation may cause deadlock in some specified SynchronizationContext: " + stopWatch.Elapsed.Seconds);
    stopWatch.Restart();
    await local.OpenFileAsync();
    Console.WriteLine("await local.OpenFileAsync(), This invocation actually gets blocked despite of await operator, because it's internal logic is blocked and the Task is just a wrapper of the result: " + stopWatch.Elapsed.Seconds);
    stopWatch.Restart(); 
    local.OpenFileAsync();
    Console.WriteLine("local.OpenFileAsync(): This invocation actually gets blocked despite of await operator, because it's internal logic is blocked and the Task is just a wrapper of the result: " + stopWatch.Elapsed.Seconds);
    stopWatch.Restart();
    _ = local.OpenFileAsync().Result;
    Console.WriteLine("local.OpenFileAsync().Result: This invocation actually gets blocked, and it's safe because the Task is already completed when return: " + stopWatch.Elapsed.Seconds);
}

interface IFile
{
    Task<Stream> OpenFileAsync();
}

class AzureFile : IFile
{
    // You want it to run asynchronously? not problem
    public async Task<Stream> OpenFileAsync()
    {
        await using var memo = new MemoryStream();
        await new FileInfo("some file") /* we are simulating here so I just use FileInfo */ .OpenRead().CopyToAsync(memo);
        await Task.Delay(3000);
        return memo;
    }
}

class LocalFile : IFile
{
    // You want the internal logic to be completely synchronous? it's OK
    public Task<Stream> OpenFileAsync()
    { 
        using Stream memo = new MemoryStream();
        new FileInfo("some file") /* we are simulating here so I just use FileInfo */ .OpenRead().CopyTo(memo);
        Thread.Sleep(3000);
        return Task.FromResult(memo);
    }
}

// outputs:
// Start
// azure.OpenFileAsync(): This invocation neither suspended nor blocked, it will just run like a background task, because you are not using await, and its internal logic is asynchronous: 0
// await azure.OpenFileAsync(): This invocation actually gets suspended, but not blocked, because it is modified by await operator: 3
// azure.OpenFileAsync().Result: This invocation actually gets blocked, because you are accessing Task.Result, warning, this operation may cause deadlock in some specified SynchronizationContext: 3
// await local.OpenFileAsync(), This invocation actually gets blocked despite of await operator, because it's internal logic is blocked and the Task is just a wrapper of the result: 3
// local.OpenFileAsync(): This invocation actually gets blocked despite of await operator, because it's internal logic is blocked and the Task is just a wrapper of the result: 3
// local.OpenFileAsync().Result: This invocation actually gets blocked, and it's safe because the Task is already completed when return: 3

update: One more thing need to be reminded: Allow users to control the dispatching(you call it the execution context) is a key feature of this paradigm so... if you want to give it back to CLR, you are just rolling it back...

Well, I do not see comparisons with Go have much value because the lack of async-await model in Go. So you can't just make an async method mimic sync method in Go. But you can mimic Goroutines in .Net, you have Channels, RX, Data flow, MailboxAgent (F#). I think what you really might be proposing is first class language support for something like Goroutines maybe, something to simplify creating reactive non direct-awaiting API's? RX comes to mind again but it has a learning curve and it's not out of the box.
I think it's true that in C# we always have to operate with 'runtime types' like Task instead of some language-specific abstraction. Same with Actions and Funcs. So maybe this is really a language suggestion and not a runtime one?

method which contains await modifier will be transformed into a state machine

Correct - what I'm suggesting is that this be a function of the CLR / VM, not the compiler / language. In the example you provide, async/await seems not only benign, but arguably superfluous. This is because you're async "all-the-way-down." That is, you're starting in an async context with static async Task Main. In my experience, most async code looks identical to sync code, except Task/Async methods are decorated with awaits. The problems arise when you mix sync / async code, and this can happen a lot for a lot of reasons, but especially with shared libraries, where the best approach is to provide "overloads" for both calling conventions.

So if async works best if it's _everywhere_ or _nowhere_, why do we need it at all? What's the difference between "legacy" code that blocks and awaited code, if they function the same (giving the impression of serial execution)? The only difference is how the program is yielding control while waiting for data: with sync code, yielding is a context switch, and with async code, it's a state machine an execution remains in the process. The only difference, and the reason for the existence of async/await, is performance - avoiding the context switch.

We need to pull this back to the root cause, the fundamental problem that async/await tries to solve. All code is a series of instructions executed sequentially. At some point, usually some form of I/O, program execution cannot continue because it does not have the data required. So what does the CPU _DO_ then? Traditionally, the OS suspends the thread by storing its context: registers, stack, etc, so the CPU can start processing work from another program. The thread will be resumed when the data is available. This context switch is expensive. The state machine attempts to do the exact same thing more efficiently without ceding the quantum and incurring a context switch. Ergo the only purpose async/await serves is to avoid a context switch: like killing a fly with a bazooka.

The default mode-of-operation should be sequential / synchronous. This is, after all, what await tries to emulate. Any parallelization should be explicit. To my third point in the OP, the async/await model falls apart with complex code that includes critical sections. It's fine if all you're doing is await with a couple Task.WaitAny and Task.WaitAll for parallelization. If you want something like a ReaderWriterLock, you're asking for trouble because this will block the execution, at worst deadlocking, and at best you're back to where you started with context switches.

All methods should be blocking, by default. Again, this is what await emulates. What I'm suggesting is that when a method "blocks", the CLR can perform an optimized switch to another Task or cede the quantum to the OS if it has no work to do. The solution we need is more efficient context switching, and that should be a function of the CLR.

It is true that if CLR can automatically switch the execution context when the CPU is idle, then async/await will become meaningless, but as we all know, async/await' always know where to return when await ends thanks to the state machine, in your proposal, CLR should be able to switch to another context automatically, then consider this scenario: CLR post an I/O task to another thread, and that I/O job also post another I/O task to the third thread, now we have three threads running three tasks concurrently, and the problem is: how does CLR cooperate these related tasks? (actually, coroutine is born for this as its name means "subroutines for co-operative multitasking" according to this page) Since there are no longer any state machines (obviously you cannot generate them at runtime or it will become a profoundly complicated task because you need to modify not only the callee but also every callers' code and turn them into state machines, this will bring a huge time complexity let alone the difficulty of its implementation, I don't think JIT can afford this), you might need an extra call stack that goes across all the threads to store the context on each time it switches which seems still cannot achieve what you said "more efficient context switching" because pop and push operations might be very fast but an extra stack also brings extra space costs

All methods should be blocking, by default. Again, this is what await emulates. What I'm suggesting is that when a method "blocks", the CLR can perform an optimized switch to another Task or cede the quantum to the OS if it has no work to do. The solution we need is more efficient context switching, and that should be a function of the CLR.

Again, what you seem to be asking for is Java's Project Loom.

The threading model is the concern of the application, not the library.

For ease-of-use reasons, most applications (and their programmers) would gladly cede concerns about threading to the library for things that actually require concurrent execution, not just asynchronous execution.


Speaking specifically about your file api example, if it was rooted in "traditional" IO and just happened to have a Blazor frontend I would just do the implementation async anyways. Tasks are allowed to complete synchronously, and immediately, and a number of libraries perform just this optimization (and the runtime can perform a bunch of its own optimizations for some other cases). If you wanted to really push the envelope, return ValueTask instead of Task.
If it's primarily intended as some sort of api just over the browser storage, using a real file backing store doesn't make much sense for unit tests; you should be using some sort of in-memory store even there, especially since hitting against a real filesystem has concurrency issues for tests run in parallel.

Again, what you seem to be asking for is Java's Project Loom.

I appreciate the reference. I wasn't aware of this project before and I agree that it looks very similar to what I'm proposing. Do you have any thoughts on the applicability of Project Loom to .NET? Are you aware of any previous discussions on the topic? Do you have any insight you can provide on that project?

most [programmers] would gladly cede concerns about threading to the library

I liken this to how few libraries are "thread-safe". It's generally accepted that threading and context management is handled by the application (with a few exceptions, of course).

Speaking specifically about your file api

The solution you propose follows the exact train of thought that spawned this post: it seems the best way to avoid problems with async is to make everything async, even stuff that might complete synchronously. That being the case, why mark any interface as synchronous? As a thought experiment, assume we don't. Okay now if everything's async, why do we need any special language features? Why are we passing Task instead of void everywhere? Well we don't need the language features: the compiler/CLR can handle this. But wait, we're right back where we started! C# without async/await! So what does the language feature do if it's not needed? It spawns the generation of a state machine. Well, if the OS can switch without needing a generated state machine, why should we? Why not just have the VM do what the OS is doing? We already have "managed" threads and Tasks. And here we are.

consider this scenario [...] how does CLR cooperate these related tasks?

That's getting too detailed for where we're at right now, I think. I'm more concerned with approval of the concept than the "how." I believe it is possible, but I'm hoping to leverage the collective experience of the community and SME's here to vet whether or not we "should" at this point. If you believe what I'm asking is technically impossible or inefficient in-practice, could you please elaborate?

I appreciate the reference. I wasn't aware of this project before and I agree that it looks very similar to what I'm proposing. Do you have any thoughts on the applicability of Project Loom to .NET? Are you aware of any previous discussions on the topic? Do you have any insight you can provide on that project?

It was decided (At the time async/await was first designed, and referenced in some more recent discussions) that the explicit use of a syntax element to call attention to the continuation/coroutine/etc was preferable. That is, it was explicitly decided to not go the route that Loom is currently exploring/attempting (although Loom itself is more recent, the basic syntax/runtime ideas are not). It's extremely unlikely that this decision will be revisited or changed.

I appreciate the feedback - it's very helpful.

I think the difference between when the decision was made and now is that we have the benefit of experience - we now have a full view of the benefits and drawbacks of async/await. The deeper I dig into async/await, the more it occurs to me that the only tangible benefit to async/await is performance - specifically avoiding an OS context switch. Everything that can be done with async/await can be done without it, assuming we permit liberal use of context switching. There is a significant amount of change in the API and patterns for the sake of avoiding context switching. IMO, there are far more drawbacks to async/await then benefits. When a solution creates more problems than it solves (the cure is worse than the disease), it should be revisited.

So I feel compelled to succinctly restate the top 3 drawbacks to async/await:

  1. The execution context (sync/async) is now part of the method signature, effectively dividing all code into two categories.
  2. High learning curve for novice engineers (easy to deadlock, ambiguous API members, lots of patterns to learn)
  3. Limitations to advanced threading - unable to use threading primitives in async contexts with no alternatives.

This seems like a lot to take on for a bump in performance. Is there not a better way to avoid context switching? Am I missing some other tangible benefits? Is my assessment of the limitations incorrect?

  1. The execution context (sync/async) is now part of the method signature, effectively dividing all code into two categories.

AKA what color is your function. However most code _does_ generally only fall into one bucket or the other. Like I said before, though, there was an explicit decision to make this bucketing clear.

  1. High learning curve for novice engineers (easy to deadlock, ambiguous API members, lots of patterns to learn)

While it's true that it might be easy to _misuse_ the API and deadlock, correctly using the API is simple, and for basic use there's only one pattern to learn.

  1. Limitations to advanced threading - unable to use threading primitives in async contexts with no alternatives.

I'm not sure what you mean by "advanced threading". It's true that you can't use lock, Monitor, and a few other things in an async context, but there are some alternatives depending on what effect you were actually trying to do

I believe we see the severity of the drawbacks differently. Can we agree then that the aforementioned caveats, at best, introduce complexity, and at worst impact usability and stability? That is, if we could accomplish the same benefit that async/await provides (fast context switching) without any of the caveats, wouldn't that be superior?

Was this page helpful?
0 / 5 - 0 ratings