[C#] Task.Run() のラムダ式でクロージャを回避して、ヒープメモリを軽減する

Task.Run() とラムダ式を使用すると、簡単にタスクを実行できます。しかし、ラムダ式で外部変数をキャプチャすると、クロージャによりメモリアロケーションが発生します。
外部変数を引数に取る静的メソッドを用意することで、ラムダ式のメモリアロケーションを削減できます。
Task.Run() では実現できないため、Task.Factory.StartNew() を使用して実現する方法です。

環境

  • .NET 8.0.100
  • C# 12.0
  • Visual Studio 2022 Version 17.8.1
  • Windows 11 Pro 22H2 22621.2715

結果

Task.Factory.StartNew() を使用して、実装します。

public static Task StartNew(Action<object> action, object state) => Task.Factory.StartNew(
    action, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

public static Task StartNew(Func<object, Task> function, object state) => Task.Factory.StartNew(
    function, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();

public static Task<TResult> StartNew<TResult>(Func<object, Task<TResult>> function, object state) => Task.Factory.StartNew(
    function, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();

使用例

  • 引数 action, function には、静的メソッドか静的ラムダを指定します。
    • 💡 インスタンスメソッドを使用する場合は、インスタンスを使い回せる場面が良いでしょう。
  • 引数 state に、静的メソッドに渡したい引数を指定します。
  • 引数 state には、参照型を指定します。
    • ⚠️ 値型を指定すると、ボックス化されてパフォーマンスが低下します。
static async Task Main()
{
    var state = new State();

    await StartNew(static x =>
    {
        var state = (State)x;
        state.Value++;
    }, state);
}

sealed class State
{
    public int Value { get; set; }
}

制限事項

  • 引数 action, function に渡せる引数(state)は、1つだけです。
  • 引数 action, function に渡せる引数(state)は、参照型にしてください。
  • 引数 action, function の引数に、CancellationToken は渡せません。
  • StartNew() するごとに、わざわざ state クラスを new しないでください。

stete となるクラスが上記の要件を満たすような場合は、StartNew() を使用すると効果的だと思います。

多くのケースでは、Task.Run() を使用することになると思います。
呼び出し回数が多い場合は、Task.Run() のラムダ式でヒープメモリの使用量が増えます。
その場合は、StartNew() が使用できる形を検討すると良いと思います。

説明

Task.Run() の内部実装です。
https://github.com/dotnet/runtime/blob/v8.0.0/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L5406-L5410

public static Task Run(Action action)
{
    return InternalStartNew(null, action, null, default, TaskScheduler.Default,
        TaskCreationOptions.DenyChildAttach, InternalTaskOptions.None);
}

Task.Factory.StartNew() の内部実装です。
https://github.com/dotnet/runtime/blob/v8.0.0/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskFactory.cs#L493-L499

public Task StartNew(Action<object?> action, object? state, CancellationToken cancellationToken,
                    TaskCreationOptions creationOptions, TaskScheduler scheduler)
{
    return Task.InternalStartNew(
        Task.InternalCurrentIfAttached(creationOptions), action, state, cancellationToken, scheduler,
        creationOptions, InternalTaskOptions.None);
}

Task.Factory.StartNew() と、Task.Run() は、同じく Task.InternalStartNew() を呼び出しています。
本記事の実装では state を渡せるようにしたこと以外は同じになります。

ベンチマーク

このコードで良いか自信ありませんが、ベンチマークしてみます。

TaskUtil の3種類のメソッドでベンチマークします。
TaskReturnTaskAwait のベンチマークの違いは、Task をそのまま return するか、await するかの違いです。

public static class TaskUtil
{
    public static Task StartNew(Action<object> action, object state) => Task.Factory.StartNew(
        action, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

    public static Task StartNew(Func<object, Task> function, object state) => Task.Factory.StartNew(
        function, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();

    public static Task<TResult> StartNew<TResult>(Func<object, Task<TResult>> function, object state) => Task.Factory.StartNew(
        function, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();
}

public sealed class State1 { }

[MemoryDiagnoser]
[ShortRunJob]
public class TaskReturn
{
    private readonly State1 _state1 = new();

    // Action<object>

    [Benchmark(Baseline = true)]
    public Task TaskFactory()
    {
        return TaskUtil.StartNew(static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);
        }, _state1);
    }

    [Benchmark]
    public Task TaskRun()
    {
        return Task.Run(() =>
        {
            GC.KeepAlive(_state1);
        });
    }

    // Func<object, Task>

    [Benchmark]
    public Task TaskFactory_AsyncTask()
    {
        return TaskUtil.StartNew(async static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);

            await Task.CompletedTask;
        }, _state1);
    }

    [Benchmark]
    public Task TaskRun_AsyncTask()
    {
        return Task.Run(async () =>
        {
            GC.KeepAlive(_state1);

            await Task.CompletedTask;
        });
    }

    // Func<object, Task<TResult>>

    [Benchmark]
    public Task<State1> TaskFactory_AsyncTask_Result()
    {
        return TaskUtil.StartNew(async static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);

            await Task.CompletedTask;
            return externalState;
        }, _state1);
    }

    [Benchmark]
    public Task<State1> TaskRun_AsyncTask_Result()
    {
        return Task.Run(async () =>
        {
            GC.KeepAlive(_state1);

            await Task.CompletedTask;
            return _state1;
        });
    }
}

[MemoryDiagnoser]
[ShortRunJob]
public class TaskAwait
{
    private readonly State1 _state1 = new();

    // await Action<object>

    [Benchmark(Baseline = true)]
    public async Task TaskFactory_Await()
    {
        await TaskUtil.StartNew(static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);
        }, _state1);
    }

    [Benchmark]
    public async Task TaskRun_Await()
    {
        await Task.Run(() =>
        {
            GC.KeepAlive(_state1);
        });
    }

    // await Func<object, Task>

    [Benchmark]
    public async Task TaskFactory_AsyncTask_Await()
    {
        await TaskUtil.StartNew(async static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);

            await Task.CompletedTask;
        }, _state1);
    }

    [Benchmark]
    public async Task TaskRun_AsyncTask_Await()
    {
        await Task.Run(async () =>
        {
            GC.KeepAlive(_state1);

            await Task.CompletedTask;
        });
    }

    // await Func<object, Task<TResult>>

    [Benchmark]
    public async Task<State1> TaskFactory_AsyncTask_Result_Await()
    {
        return await TaskUtil.StartNew(async static state =>
        {
            var externalState = (State1)state;
            GC.KeepAlive(externalState);

            await Task.CompletedTask;
            return externalState;
        }, _state1);
    }

    [Benchmark]
    public async Task<State1> TaskRun_AsyncTask_Result_Await()
    {
        return await Task.Run(async () =>
        {
            GC.KeepAlive(_state1);

            await Task.CompletedTask;
            return _state1;
        });
    }
}
BenchmarkDotNet v0.13.10, Windows 11 (10.0.22621.2715/22H2/2022Update/SunValley2)
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK 8.0.100
  [Host]   : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  ShortRun : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2

Job=ShortRun  IterationCount=3  LaunchCount=1
WarmupCount=3

TaskReturn の結果 (await しない場合)

MethodMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
TaskFactory1.011 μs0.1256 μs0.0069 μs1.000.000.007664 B1.00
TaskRun1.055 μs0.1092 μs0.0060 μs1.040.010.0153128 B2.00
TaskFactory_AsyncTask1.055 μs0.2281 μs0.0125 μs1.040.010.0153138 B2.16
TaskRun_AsyncTask1.078 μs0.2108 μs0.0116 μs1.070.010.0248208 B3.25
TaskFactory_AsyncTask_Result1.097 μs0.3063 μs0.0168 μs1.090.020.0248218 B3.41
TaskRun_AsyncTask_Result1.096 μs0.0657 μs0.0036 μs1.080.000.0343288 B4.50

TaskAwait の結果 (await する場合)

MethodMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
TaskFactory_Await1.097 μs0.1899 μs0.0104 μs1.000.000.0191160 B1.00
TaskRun_Await1.122 μs0.2346 μs0.0129 μs1.020.010.0267226 B1.41
TaskFactory_AsyncTask_Await1.100 μs0.3888 μs0.0213 μs1.000.010.0267232 B1.45
TaskRun_AsyncTask_Await1.106 μs0.1886 μs0.0103 μs1.010.020.0343302 B1.89
TaskFactory_AsyncTask_Result_Await1.171 μs0.4156 μs0.0228 μs1.070.010.0381320 B2.00
TaskRun_AsyncTask_Result_Await1.111 μs0.3398 μs0.0186 μs1.010.030.0458389 B2.43

Task.Factory.StartNew() の方が、メモリアロケーションが少ないのが分かります。

感謝

2023-11-24 (金)