[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種類のメソッドでベンチマークします。TaskReturn
と TaskAwait
のベンチマークの違いは、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
しない場合)
Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
---|---|---|---|---|---|---|---|---|
TaskFactory | 1.011 μs | 0.1256 μs | 0.0069 μs | 1.00 | 0.00 | 0.0076 | 64 B | 1.00 |
TaskRun | 1.055 μs | 0.1092 μs | 0.0060 μs | 1.04 | 0.01 | 0.0153 | 128 B | 2.00 |
TaskFactory_AsyncTask | 1.055 μs | 0.2281 μs | 0.0125 μs | 1.04 | 0.01 | 0.0153 | 138 B | 2.16 |
TaskRun_AsyncTask | 1.078 μs | 0.2108 μs | 0.0116 μs | 1.07 | 0.01 | 0.0248 | 208 B | 3.25 |
TaskFactory_AsyncTask_Result | 1.097 μs | 0.3063 μs | 0.0168 μs | 1.09 | 0.02 | 0.0248 | 218 B | 3.41 |
TaskRun_AsyncTask_Result | 1.096 μs | 0.0657 μs | 0.0036 μs | 1.08 | 0.00 | 0.0343 | 288 B | 4.50 |
TaskAwait
の結果 (await
する場合)
Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
---|---|---|---|---|---|---|---|---|
TaskFactory_Await | 1.097 μs | 0.1899 μs | 0.0104 μs | 1.00 | 0.00 | 0.0191 | 160 B | 1.00 |
TaskRun_Await | 1.122 μs | 0.2346 μs | 0.0129 μs | 1.02 | 0.01 | 0.0267 | 226 B | 1.41 |
TaskFactory_AsyncTask_Await | 1.100 μs | 0.3888 μs | 0.0213 μs | 1.00 | 0.01 | 0.0267 | 232 B | 1.45 |
TaskRun_AsyncTask_Await | 1.106 μs | 0.1886 μs | 0.0103 μs | 1.01 | 0.02 | 0.0343 | 302 B | 1.89 |
TaskFactory_AsyncTask_Result_Await | 1.171 μs | 0.4156 μs | 0.0228 μs | 1.07 | 0.01 | 0.0381 | 320 B | 2.00 |
TaskRun_AsyncTask_Result_Await | 1.111 μs | 0.3398 μs | 0.0186 μs | 1.01 | 0.03 | 0.0458 | 389 B | 2.43 |
Task.Factory.StartNew()
の方が、メモリアロケーションが少ないのが分かります。
感謝
関連記事
新着記事