-1

I have a method LoopAsync that takes a lambda parameter, and invokes this lambda repeatedly a number of times with a delay. Both the method and the lambda are asynchronous:

static async Task LoopAsync(Func<int, Task> action,
    int start, int count, int delayMsec)
{
    for (int i = start; i < start + count; i++)
    {
        await Task.Delay(delayMsec).ConfigureAwait(false);
        await action(i).ConfigureAwait(false);
    }
}

Now I want to enhance the LoopAsync with an overload that takes a synchronous lambda parameter (an Action<int>). I want to avoid duplicating my code, so I am thinking to implement the overload using the existing implementation like this:

static Task LoopAsync(Action<int> action,
    int start, int count, int delayMsec)
{
    return LoopAsync(i =>
    {
        action(i); return Task.CompletedTask;
    }, start, count, delayMsec);
}

What I dislike with this approach is that it captures the action argument, resulting in an allocation of an object every time the LoopAsync is invoked. I want to prevent this capturing from happening. Is it possible?

Visual Studio screenshot

To summarize, I want to have two method overloads, the first with asynchronous lambda and the second with synchronous lambda, that are sharing the same implementation, without incurring the penalty of allocating closure objects on the heap. I am equally OK with the one overload being based on the other, or with both being based on the same private core implementation.

This question was inspired by a recent question by Avrohom Yisroel.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 3
    You realise with an async method you're always creating a new state machine anyway, right? Is there any reason you're bothered by the allocation for the capture, but not for the state machine? (The state machine will only end up on the heap if an await expression has to await something that hasn't completed, but given that you're awaiting `Task.Delay`, that seems pretty likely...) – Jon Skeet Oct 31 '22 at 22:08
  • @JonSkeet I am aware that the `LoopAsync` allocates memory. I just don't want it to allocate more than it's absolutely necessary. – Theodor Zoulias Oct 31 '22 at 22:11
  • 4
    Then you basically need to take the hit of duplicating a few lines of code. This is micro-optimization, which you should always *expect* to come at the cost of a bit of readability/maintainability. (I would wait until you have very concrete data telling you this is a real problem - you may have that already, but we can't tell at the moment.) – Jon Skeet Oct 31 '22 at 22:13
  • (Or if you really want to avoid the duplication in your source code, you could write a source generator to do it for you, but that really seems like overkill.) – Jon Skeet Oct 31 '22 at 22:14
  • _"What I dislike with this approach is that it captures the action argument, resulting in an allocation of an object every time the LoopAsync is invoked"_ - unless whatever is happening in your call-back plus the rest of your application uses **less** memory than the cost of the capture, such concerns are perhaps just a case of _premature optimisation._ Is your app targeting a device with memory limits such as an embedded system or a device where the GC will hurt performance (like back in the days of XNA on Xbox)? –  Oct 31 '22 at 23:47

1 Answers1

0

I think that I have found a solution to this problem. I made a private LoopCoreAsync method that takes an additional generic TArg argument:

private static async Task LoopCoreAsync<TArg>(Func<TArg, int, Task> action,
    TArg arg, int start, int count, int delayMsec)
{
    for (int i = start; i < start + count; i++)
    {
        await Task.Delay(delayMsec).ConfigureAwait(false);
        await action(arg, i).ConfigureAwait(false);
    }
}

...and then I used this method for implementing the two LoopAsync overloads:

static Task LoopAsync(Func<int, Task> action,
    int start, int count, int delayMsec)
{
    return LoopCoreAsync(static (arg, i) =>
    {
        return arg(i);
    }, action, start, count, delayMsec);
}

static Task LoopAsync(Action<int> action,
    int start, int count, int delayMsec)
{
    return LoopCoreAsync(static (arg, i) =>
    {
        arg(i); return Task.CompletedTask;
    }, action, start, count, delayMsec);
}

The TArg is resolved as Func<int, Task> for the first overload, and as Action<int> for the second overload. The static lambda modifier (C# 9 feature) ensures that no variables are captured by the two lambdas.

According to my measurements, this optimization prevents the allocation of 88 bytes on the heap for each LoopAsync invocation (.NET 6, Release built).

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    This is similar to [generic code generation](https://blog.stephencleary.com/2022/10/modern-csharp-techniques-3-generic-code-generation.html), which I believe could also be used as a solution. Stephen Toub wrote about that approach in a [recent blog post](https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/?WT.mc_id=DT-MVP-5000058#:~:text=One%20final%20change%20related%20to%20reading%20and%20writing%20performance). – Stephen Cleary Nov 02 '22 at 13:13