2

I'm writing an ASP.net Core 6 application (but the question is more about C# in general) where I have a controller action like this:

[HttpGet]
public async Task<IActionResult> MyAction() {
  var result = await myService.LongOperationAsync();
  return Ok(result);
}

Basically, the action calls a service that needs to do some complex operation and can take a bit of time to respond, up to a minute. Obviously, if in the meantime another request arrives a second run of LongOperationAsync() starts, consuming even more resources.

What I would like to do is redesign this so that the calls to LongOperationAsync() don't run in parallel, but instead wait for the result of the first call and then all return the same result, so that LongOperationAsync() only runs once.

I thought of a few ways to implement this (for example by using a scheduler like Quartz to run the call and then check if a relevant Job is already running before enqueueing another one) but they all require quite a bit of relatively complicated plumbing.

So I guess my questions are:

  • Is there an established design pattern / best practice to implement this scenario? Is it even practical / a good idea?
  • Are there features in the C# language and/or the ASP.net Core framework that facilitate implementing something like this?

Clarification: basically I want to run the long-running operation only once, and "recycle" the result to any other call that was waiting without executing the long-running operation again.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Master_T
  • 7,232
  • 11
  • 72
  • 144
  • You can abstract your Task to a class with can keep trace of the execution status and eventually keep a list of the clients to answer to after the end of execution – Marco Beninca Aug 29 '22 at 14:42

2 Answers2

0

You could use an async version of Lazy<T> to do this.

Stephen Toub has posted a sample implementation of LazyAsync<T> here, which I reproduce below:

public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<T> valueFactory) :
        base(() => Task.Run(valueFactory))
    { }

    public AsyncLazy(Func<Task<T>> taskFactory) :
        base(() => Task.Run(taskFactory))
    { }

    public TaskAwaiter<T> GetAwaiter() { return Value.GetAwaiter(); }
}

You could use it like this:

public class Program
{
    public static async Task Main()
    {
        var test = new Test();

        var task1 = Task.Run(async () => await test.AsyncString());
        var task2 = Task.Run(async () => await test.AsyncString());
        var task3 = Task.Run(async () => await test.AsyncString());

        var results = await Task.WhenAll(task1, task2, task3);

        Console.WriteLine(string.Join(", ", results));
    }
}

public sealed class Test
{
    public async Task<string> AsyncString()
    {
        Console.WriteLine("Started awaiting lazy string.");
        var result = await _lazyString;
        Console.WriteLine("Finished awaiting lazy string.");

        return result;
    }

    static async Task<string> longRunningOperation()
    {
        Console.WriteLine("longRunningOperation() started.");
        await Task.Delay(4000);
        Console.WriteLine("longRunningOperation() finished.");
        return "finished";
    }

    readonly AsyncLazy<string> _lazyString = new (longRunningOperation);
}

If you run this console app, you'll see that longRunningOperation() is only called once, and when it's finished all the tasks waiting on it will complete.

Try it on DotNetFiddle

Matthew Watson
  • 104,400
  • 10
  • 158
  • 276
  • @TheodorZoulias OP says `wait for the result of the first call and then all return the same result, so that LongOperationAsync() only runs once.` – Matthew Watson Aug 29 '22 at 15:24
  • The "in the meantime" refers to the thing that the OP wants to prevent, I think. – Matthew Watson Aug 29 '22 at 15:27
  • 1
    @TheodorZoulias: Mathew is correct, basically I want to only run the long-running operation only once and "recycle" the result to any other call that was waiting without executing the long-running operation again. – Master_T Aug 29 '22 at 15:41
  • @Master_T in this case please check if your question is the same with [that](https://stackoverflow.com/questions/28340177/enforce-an-async-method-to-be-called-once) question, in which case you could consider closing this question as a duplicate! – Theodor Zoulias Aug 29 '22 at 15:45
  • @MatthewWatson: thank you, I think this is what I need, I will try to implement me in my projects. PS: I think there's a typo in your last instruction, I think you meant to write `new AsyncLazy(longRunningOperation);` at the end – Master_T Aug 29 '22 at 15:46
  • 1
    @TheodorZoulias: you're kinda right. Although I would like if there was an elegant way to detect when there are no more waiting calls and dispose of the AsyncLazy instance, since the implementation I see doesn't have this. I will think about this and maybe expande on Mathew's answer or mark as duplicate if appropriate. – Master_T Aug 29 '22 at 15:59
  • @Master_T It's not actually a typo - it's a [target-typed *new* expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/target-typed-new). Regarding disposing the returned value - is that really necessary, if there's only every going to be one? – Matthew Watson Aug 29 '22 at 16:10
  • 2
    Matthew you may want to replace the `Task.Factory.StartNew` with `Task.Run`. Stephen Toub's [article](https://devblogs.microsoft.com/pfxteam/asynclazyt/) was written in the pre-`Task.Run` era! – Theodor Zoulias Aug 29 '22 at 16:59
  • 1
    @TheodorZoulias I've done that - note that now we don't need the `Unwrap()` because the `Task.Run()` overload handles that for you. – Matthew Watson Aug 30 '22 at 09:10
-1

As Matthew's answer points out, what you're looking for is an "async lazy". There is no built-in type for this, but it's not that hard to create.

What you should be aware of, though, is that there are a few design tradeoffs in an async lazy type:

  • What context the factory function is run on (the first invoker's context or no context at all). In ASP.NET Core, there isn't a context. So the Task.Factory.StartNew in Stephen Toub's example code is unnecessary overhead.
  • Whether failures should be cached. In the simple AsyncLazy<T> approach, if the factory function fails, then a faulted task is cached indefinitely.
  • When to reset. Again, by default the simple AsyncLazy<T> code never resets; a successful response is also cached indefinitely.

I'm assuming you do want the code to run multiple times; you just want it not to run multiple times concurrently. In that case, you want the async lazy to be reset immediately upon completion, whether successful or failed.

The resetting can be tricky. You want to reset only when it's completed, and only once (i.e., you don't want your reset code to clear the next operation). My go-to for this kind of logic is a unique identifier; I like to use new object() for this.

So, I would start with the Lazy<Task<T>> idea, but wrap it instead of derive, which allows you to do a reset, as such:

public class AsyncLazy<T>
{
  private readonly Func<Task<T>> _factory;
  private readonly object _mutex = new();
  private Lazy<Task<T>> _lazy;
  private object _id;

  public AsyncLazy(Func<Task<T>> factory)
  {
    _factory = factory;
    _lazy = new(_factory);
    _id = new();
  }

  private (object LocalId, Task<T> Task) Start()
  {
    lock (_mutex)
    {
      return (_id, _lazy.Value);
    }
  }

  private void Reset(object localId)
  {
    lock (_mutex)
    {
      if (localId != _id)
        return;
      _lazy = new(_factory);
      _id = new();
    }
  }

  public async Task<T> InvokeAsync()
  {
    var (localId, task) = Start();
    try
    {
      return await task;
    }
    finally
    {
      Reset(localId);
    }
  }
}
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Is there something wrong with my answer? I've looked it over a few times and I don't see any obvious bugs... – Stephen Cleary Aug 30 '22 at 12:20
  • 1
    Stephen your answer is not wrong per se, but the OP [has clarified](https://stackoverflow.com/questions/73530467/73530990?noredirect=1#comment129849015_73530990) that they want to execute the factory only once, and never execute it again. Your answer assumes that the OP wants to prevent concurrent executions, and allow multiple executions, so it's off-topic here IMHO. You might want to repost it [there](https://stackoverflow.com/questions/73438513/how-to-make-multiple-concurrent-calls-to-async-function-collapse-into-1-task), where it will be more relevant. – Theodor Zoulias Aug 30 '22 at 13:23
  • 1
    @TheodorZoulias: Gotcha. I missed that comment; thanks! – Stephen Cleary Aug 30 '22 at 13:26