最近「非同期ストリーム」というのを知りました。 今回はそんな非同期ストリームについて API で利用した場合のパフォーマンスの検証結果をご紹介します。
非同期ストリームについて
通常データを取得するメソッドはこんな感じです。
public Item[] GetItems()
{
}
非同期にするとこんな感じ。
public Task<Item[]> GetItemsAsync()
{
}
非同期ではあるけど、必要なデータが集まるまで待たされる。集まったデータは一括で呼び出し元に返されます。 これらの方法は必要なデータを集め終わるまではメモリを圧迫し続けます。デスクトップアプリケーションでは気にするほどではないと思いますが、大勢の人がアクセスする API サーバーではどうでしょうか?それが結構な重荷になってくるかもしれません。
そこで C# には昔からある yield return
を使った記述により返す値が準備できた順に返す処理にします。
public IEnumerable<Item> GetEnumerableAsync() { foreach (var item in Load()) yield return item; }
これなら処理中のキャッシュデータが無いのでメモリの消費を削減できます。
これを更に非同期にしたのが今回の「非同期ストリーム」となります。
public async IAsyncEnumerable<Item> GetEnumerableAsync( { foreach (var item in await LoadAsync()) yield return item; }
「非同期で余計なキャッシュによるメモリ消費も抑えることができて準備ができ次第返すので、従来のものより高速なはずだ。」と思いましたが、逆に遅くなる場合もあると小耳にはさんだので実際に検証してみることにしました。
検証
環境やコードは以下の通り
サーバー
App Service(Linux .NET8) ASP .NET Core を使った簡単な API を East US にデプロイします。
public class TestController : ControllerBase { public class Item { public string Value { get; set; } = null!; } [HttpGet] [Route("/task")] public async Task<IEnumerable<Item>> GetTaskEnumerable(int count, int textLength) { var items = new List<Item>(); for (var i = 0; i < count; i++) items.Add(await CreateItemAsync(textLength)); return items; } [HttpGet] [Route("/async")] public async IAsyncEnumerable<Item> GetEnumerableAsync(int count, int textLength) { for (var i = 0; i < count; i++) yield return await CreateItemAsync(textLength); } private async Task<Item> CreateItemAsync(int textLength) => await Task.FromResult(new Item { Value = string.Concat(Enumerable.Range(0, textLength).Select(q => "a")) }); }
クライアント
コンソールアプリ(Windows .NET8)
var httpClient = new HttpClient(); var domain = "localhost:7175"; // デプロイ先に変える var count = 1000; var textLength = 1000; // 初回接続用に一度呼んでおく await RequestTaskEnumerable(); await RequestAsyncEnumerable(); var taskTimes = new List<long>(); var asyncTimes = new List<long>(); for (var i = 0; i < 10; i++) { taskTimes.Add(await RequestAsync(RequestTaskEnumerable)); asyncTimes.Add(await RequestAsync(RequestAsyncEnumerable)); } // 結果を出力 Console.WriteLine("--- 従来の方法 ---"); foreach (var time in taskTimes) Console.WriteLine($"{time}ms"); Console.WriteLine($"平均 : {taskTimes.Average()}ms"); Console.WriteLine("--- 非同期ストリーム ---"); foreach (var time in asyncTimes) Console.WriteLine($"{time}ms"); Console.WriteLine($"平均 : {asyncTimes.Average()}ms"); Console.ReadKey(); async Task<long> RequestAsync(Func<Task> request) { var stopwatch = Stopwatch.StartNew(); await request(); stopwatch.Stop(); return stopwatch.ElapsedMilliseconds; } async Task RequestTaskEnumerable() { var uri = new Uri($"https://{domain}/task?count={count}&textLength={textLength}"); var response = await httpClient.GetAsync(uri); var items = await response.Content.ReadFromJsonAsync<Item[]>(); if (items!.Length != count) throw new Exception(); } async Task RequestAsyncEnumerable() { var uri = new Uri($"https://{domain}/async?count={count}&textLength={textLength}"); var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); var items = new List<Item>(); await foreach (var item in response.Content.ReadFromJsonAsAsyncEnumerable<Item>()) { if (item is not null) items.Add(item); } if (items.Count != count) throw new Exception(); } public class Item { public string Value { get; set; } = null!; }
このコードは、引数(textLength)で指定した文字数のデータを引数(count)の数だけ返すAPIです。
textLength
と count
を変えて速度を計測してみたいと思います。
計測結果は以下の通り。
textLength
と count
を変えてみましたがどちらもあまり変わりませんでした。
まとめ
非同期ストリームを使ってみて
- メリット
- ストレージの中継を行う API ではサーバーのメモリを食いつぶすことを抑えることができるので有効。
- デメリット
- 記述が面倒。
await foreach
の形でしか受け取れないので Linq を使いたい場合が多い昨今の C# では自力でリストに格納するか、パッケージを別途入れる必要があって手間が増える。 - 戻り値が必ずコレクションになってしまうため、API の戻り値を拡張したりできない。Azure AI Search Response の様に実際の値以外にもクライアントに返したい情報がある場合は従来の方法のほうが向いている。
- 記述が面倒。
この様にメリット・デメリットがあるので全てを非同期ストリームにするのではなく、うまく使い分けていくことが重要だと感じました。
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
hrmos.co
hrmos.co
hrmos.co
hrmos.co