1

I have a very large, synchronous web application which would benefit significantly from asynchronous calls, but refactoring it to do so would be a significant amount of work.

There's one specific point in the application where 2 expensive remote calls are made, and being able to fire both off at the same time would greatly improve performance. Obviously the flow is synchronous, so I use Task.Run to achieve the asynchronous call:

private void RemoteCalls()
{
    var task1 = Task.Run(async() => await DoExpensiveThing());
    var task2 = Task.Run(async () => await DoAnotherExpensiveThing());
    
    Task.WhenAll(task1, task2);
}

private async Task DoExpensiveThing()
{
    await //some expensive call
}

private async Task DoAnotherExpensiveThing()
{
    await //another expensive call
}

This successfully achieves what I'm after, the work for both expensive calls is performed at the same time and waits for both to complete.

My question is, are there any significant disadvantages to doing this? I have the (vague) feeling async is really designed to be used in applications which are async all the way from top to bottom. Will I suffer as a result of using the above until the application can be upgraded, as a consequence of using Task.Run?

FBryant87
  • 4,273
  • 2
  • 44
  • 72
  • `var task1 = Task.Run(async() => await DoExpensiveThing());` can be simplified to just `var task1 = Task.Run( () => DoExpensiveThing() );` or even `var task1 = Task.Run( DoExpensiveThing );` - any parameterless function that returns a `Task` can be passed by function-reference directly into `Task.Run`, there's no need for an inner `await` - this also avoids the GC heap allocation caused by the lambda capture. – Dai Jan 18 '21 at 09:01
  • 6
    Because you aren't using `await` with `Task.WhenAll(task1, task2);` your program will have issues. If you need to run `async` code from within a synchronous context you still need to `await` the final result - which you can do with the thread pool and classic synchronization primitives (like `ManualResetEvent`) and a "non-blocking" poll loop for safety. – Dai Jan 18 '21 at 09:03
  • What's the point of calling WhenAll if you aren't awaiting it ? Additionally what's the point of offloading this to Task.Run, these tasks would already be started and you are just wasting 2 thread pool threads – TheGeneral Jan 18 '21 at 09:05
  • @Dai That is a good point. Suppose I did change this and awaited Task.WhenAll, does using async at all here offer any advantages? (since it's Task.Run which is allowing this to run in parallel) – FBryant87 Jan 18 '21 at 09:05
  • 5
    Firstly, async and concurrency are *different things*; it sounds like you're really after concurrency here. Note that `Task.WhenAll` probably isn't what you actually want here, since that *returns a task* that must then be awaited - but you can't await unless you're in an async context, meaning that you'd actually need to block (`Wait()`), which is a very bad idea. – Marc Gravell Jan 18 '21 at 09:06
  • You might as well just call `_ = DoExpensiveThing(); _ = DoAnotherExpensiveThing();` – TheGeneral Jan 18 '21 at 09:06
  • 2
    https://stackoverflow.com/questions/6123406/waitall-vs-whenall – mjwills Jan 18 '21 at 09:09
  • Good comments all. Would anyone be able to suggest a solution (answer) which resolves the concurrency problem? (if async offers no benefit here) – FBryant87 Jan 18 '21 at 09:09
  • @FBryant87 What version of .NET are you running? And where is this code running? e.g. ASP.NET WebForms, ASP.NET MVC, .NET Core? – Dai Jan 18 '21 at 09:10
  • It's actually an ASP.NET WebForms app (which does carry some awkwardness), but hopefully the misunderstanding is generic enough that an answer can be useful to all cases – FBryant87 Jan 18 '21 at 09:11
  • 1
    @FBryant87 Unfortunately it's never that simple. Fortunately ASP.NET WebForms has its own "background worker" feature (`hostingenvironment.queuebackgroundworkitem`) which you should be using. – Dai Jan 18 '21 at 09:11
  • Why don't you simply: `await Task.WhenAll(DoExpensiveThing, DoAnotherExpensiveThing);`? It will call the two async method concurrently and it will wait until both of them finish their work. – Peter Csala Jan 18 '21 at 09:12
  • 2
    @PeterCsala Because that requires the parent method to also be `async` which the OP said they can't do. – Dai Jan 18 '21 at 09:12
  • important: legacy webforms has a "sync context" - which means that any attempt to do "sync over async" (which is what would be required here to do the waiting) is likely to cause a deadlock, *plus even if it works*, it is likely that the async operations would end up back on the same shared thread the first time that they `await` - making concurrency pointless in the first place – Marc Gravell Jan 18 '21 at 09:20
  • @MarcGravell ASP.NET WebForms can be very `async`-friendly if configured correctly. Though the OP hasn't shared their `web.config` though so we don't know for certain. That said, I'd appreciate your feedback on my answer :) The only person we're missing now is @StephenCleary ;) – Dai Jan 18 '21 at 09:22
  • Are you doing anything with the `task1` and `task2` tasks, except from passing them as arguments to the `Task.WhenAll` method? They look like [fire-and-forget](https://blog.stephencleary.com/2014/06/fire-and-forget-on-asp-net.html) tasks to me. – Theodor Zoulias Jan 18 '21 at 09:31

1 Answers1

2

Because you aren't using await with Task.WhenAll(task1, task2); your program will have issues. If you need to run asynchronous code from within a synchronous context you still need to await the final result - which you can do with the thread pool and classic synchronization primitives (like ManualResetEvent) and a "non-blocking" poll loop for safety.

It looks like you want to run two methods concurrently - those methods may or may not be "true" async (i.e. IO-bound, not CPU-bound). In which case use the thread-pool via Task.Run but that "outer" task itself needs to be run in the pool so that the Task scheduler.

Rather than simply blocking on Task.Result, I prefer polling it in a loop with Task.Wait(Int32) (instead of Task.Wait()!) which alleviates some deadlocking problems - note that this shares .Result's problem of making very unproductive use of a program thread. An approach like this should not be used in high-performance, high-demand, or high-throughput applications, especially in ASP.NET WebForms where the (now antiquated and terrible) "one thread per request" model is still being used:


// NASAL DEMON WARNING:
// This method is only intended as a stopgap as this approach does not scale to handling many concurrent entrypoint calls: so it should not be used in *serious business* applications. 
private void EntrypointThatWillBlock()
{
    Task threadPoolTask = Task.Run( DoConcurrentRequestsAsync );
    
    Boolean didComplete = threadPoolTask.Wait( millisecondsTimeout: 30 * 1000 );
    if( !didComplete ) throw new TimeoutException( "Didn't complete in 30 seconds." );
}

private async Task DoConcurrentRequestsAsync()
{
    try
    {
        Task task1 = this.DoExpensieThingAsync();
        Task task2 = this.DoAnotherExpensiveThing();

        await Task.WhenAll( task1, task2 ).ConfigureAwait(false);
    }
    catch( Exception ex )
    {
        // You should log any errors here (and then re-throw) as dealing with unhandled exceptions in async contexts can be a mess. At least by logging here you're guaranteed to get _something_ logged.
        this.log.LogError( ex );
        throw; // <-- Don't use `throw ex;` as that resets the stack-trace)
    }
}

Disclaimer: There likely is a much better way of doing this by using a context-specific synchronization context. As you're using ASP.NET WebForms you can use async correctly with some configuration tweaks (check your web.config file, adding Async="true" to your @Page directive if applicable, using IHttpAsyncHandler and/or HostingEnvironment.QueueBackgroundWorkItem and so on).

Also read What's the meaning of "UseTaskFriendlySynchronizationContext"?

Dai
  • 141,631
  • 28
  • 261
  • 374
  • I may be missing something, I fail to see the benefits of this approach over `threadPoolTask.Wait(30)` – Kevin Gosse Jan 18 '21 at 09:27
  • @KevinGosse WOW, ugh - okay this is embarrassing - I completely forgot that was a thing! I'll update my answer now. **UPDATE**: Fixed! – Dai Jan 18 '21 at 09:28
  • I appear to have been downvoted (currently +1 and -1 for a score of 0). If whoever downvoted me is reading this, please post a comment reply with feedback for improving my answer. – Dai Jan 18 '21 at 09:37
  • Thanks for this. In the case of a high performance app, what would be the recommendation for simply processing the 2 tasks concurrently? (assuming we can't refactor the whole thing to be async from top down) – FBryant87 Jan 18 '21 at 09:37
  • @FBryant87 For a high-performance application the **only** solution is to do `async` top-to-bottom, but it really isn't *that* much work to get working, so I'm perplexed why you're using that as an excuse. You don't need to refactor an entire project - just a single code-path, and it's acceptable to copy+paste+convert-to-async for stuff like this. – Dai Jan 18 '21 at 09:40
  • @FBryant87 That said, "high-performance" (or rather, "high-throughput") is subjective. How many requests-per-second are we talking about? (that's requests for this particular code-path, not overall). If the total time to process the request is under 2-3 seconds and you've got less than 100 requests/second then the code I've posted will be fine. I consider "high-throughput" to be on the order of 1,000 requests/second with a response time under 200ms. – Dai Jan 18 '21 at 09:41
  • Problem is that code path is enormous and inter-twined with countless other (un-tested) code paths, which would then need to be updated also without mistakes. Thanks for clarifying the performance bit - that's useful – FBryant87 Jan 18 '21 at 09:42
  • You don't have integration tests set-up? Eeep - **adding automated tests should be your top priority right now**, not implementing performance hacks. – Dai Jan 18 '21 at 09:43
  • 1
    I'm afraid without the technical and commercial context of a system, it's impossible to make statements like that – FBryant87 Jan 18 '21 at 09:44
  • 1
    @FBryant87 This is true! But I don't let _facts_ and _reasoned and informed discussion_ detract me from telling people what to do over the Internet :) – Dai Jan 18 '21 at 09:45
  • Haha, very wise :) – FBryant87 Jan 18 '21 at 09:46
  • 1
    `If whoever downvoted me is reading this, please post a comment reply with feedback for improving my answer` I'm afraid there are a lot of people who stop at "task.Wait = bad" and don't try to think about whether it could be a legitimate use-case. I'll just note that if your app is processing a lot of requests (hundreds of requests per second), you can run into threadpool starvation: https://medium.com/criteo-engineering/net-threadpool-starvation-and-how-queuing-makes-it-worse-512c8d570527 Unfortunately I don't think there is any silver bullet to avoid that – Kevin Gosse Jan 18 '21 at 09:54
  • 1
    Oh, one more thing @FBryant87. `Task.Run` does not flow the synchronization context. That's a good thing to avoid deadlocks **but** you can't use the `HttpContext` outside of the synchronization context. I doubt you need it in that scenario but that's something to keep in mind. – Kevin Gosse Jan 18 '21 at 09:59
  • 1
    Hi Dai. I am the downvoter, and the reason is that I don't see how your answer addresses the OP's question. The OP asked: *"My question is, are there any significant disadvantages to doing this? [...] Will I suffer as a result of using the above [...]?"* In short the OP asks for an evaluation of their own approach, not for recommendations about better solutions. Personally I am not able to tell whether your recommendation is better than OP's approach, mainly because I am amazed that OP's approach works at all (they claim that it successfully achieves what they're after). – Theodor Zoulias Jan 18 '21 at 10:55
  • @TheodorZoulias I explained in my comment that their approach won't work reliably (due to the lack of a final `await`) and so it's only right of me to suggest a better alternative. The fact the OP accepted my answer suggests it solved their problem. – Dai Jan 18 '21 at 11:03
  • 1
    Dai if your answer to the OP's question exists in a comment, then IMHO you should embed this comment into your answer. AFAIK the comments are intended for asking missing information, not for answering questions. As for the fact that the OP accepted this answer, it doesn't prove anything IMHO. Honestly it looks to me more like an admission that they posted an unclear/too-broad question, and they are willing to accept anything that resembles an answer. Besides that, my vote reflects my own opinion about this answer, not the OP's opinion. – Theodor Zoulias Jan 18 '21 at 11:29