C# ValueTask: Ne Zaman Task Yerine, Hangi Kurallarla
Cache'ten senkron dönen bir async method Task<int> ile her çağrıda 72 byte ayırırken ValueTask<int> hiç ayırmaz. Debug ve Release ölçüm farkı, runtime'ın küçük sonuç cache'i, BenchmarkDotNet ile cache ve gerçekten asenkron yollar, ValueTask'ı iki kez await etmenin Channel üzerinde gerçek InvalidOperationException'ı, AsTask ve ne zaman ValueTask seçmeli.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Stok bilgisini cache'te tutan küçük bir class yazdık. İlk çağrı servise gidiyor, sonrakiler sonucu dictionary'den okuyup await'e hiç uğramadan dönüyor. Aynı method'un iki sürümü var: biri Task<int>, diğeri ValueTask<int> döndürüyor. Cache doluyken ikisini birer milyon kez çağırıp o thread'de ayrılan belleği ölçüyoruz. Çağrı başına kaç byte çıkar?
const string Isbn = "978-0441172719";
StockCache cache = new();
await cache.GetStockAsync(Isbn); // ilk çağrı servise gider ve cache'i doldurur
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000_000; i++)
{
await cache.GetStockAsync(Isbn);
}
long taskBytes = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000_000; i++)
{
await cache.GetStockValueTaskAsync(Isbn);
}
long valueTaskBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"Task<int>: {taskBytes / 1_000_000} bytes per call");
Console.WriteLine($"ValueTask<int>: {valueTaskBytes / 1_000_000} bytes per call");
class StockCache
{
private readonly Dictionary<string, int> _stock = new();
public async Task<int> GetStockAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock; // cache'te: await yok, senkron biter
}
stock = await LoadFromServiceAsync(isbn);
_stock[isbn] = stock;
return stock;
}
public async ValueTask<int> GetStockValueTaskAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
stock = await LoadFromServiceAsync(isbn);
_stock[isbn] = stock;
return stock;
}
private static async Task<int> LoadFromServiceAsync(string isbn)
{
await Task.Delay(100);
return 42;
}
}
Task<T> Bir Object'tir
Release modda çıktı:
Task<int>: 72 bytes per call
ValueTask<int>: 0 bytes per call
Cache'ten dönen her çağrı, Task<int> sürümünde 72 byte ayırdı; bir milyon çağrıda 72 MB, garbage collector'ın toplaması gereken bir milyon kısa ömürlü object. ValueTask<int> sürümünde ise hiç.
Sebebi tiplerin doğasında. Task<T> bir class. Method senkron bitse bile çağırana "işin sonucu" olarak bir Task object'i vermek zorundadır; sonuç hazır olduğu için tamamlanmış bir task üretilir ama yine de heap'te bir object'tir. ValueTask<T> ise bir struct ve iki şeyden birini taşır: sonuç hazırsa sonucun kendisini, hazır değilse arkadaki asenkron işi temsil eden bir object'i. Cache'ten dönen yolda içine int'i koyar ve hiçbir şey ayırmaz.
Bir restoranın gel-al tezgâhını düşün. Sipariş hazırsa görevli onu doğrudan eline verir. Hazır değilse sana bir çağrı cihazı verir ve cihaz titreyince gelip alırsın. Task<T> her müşteriye, siparişi hazır olsa bile önce bir çağrı cihazı veren bir tezgâh gibidir. ValueTask<T> ise hazır olanı elden verir, cihazı sadece gerçekten beklemen gerektiğinde verir.
Ölçerken iki ayrıntıya dikkat et. Birincisi, aynı programı Debug modda çalıştırdığımızda Task<int> 136, ValueTask<int> 72 byte çıktı. Debug derlemede compiler async state machine'i debugger'a kolaylık olsun diye class olarak üretir ve her çağrıda o da ayrılır. Allocation ölçümünü her zaman Release'te yap. İkincisi, servisin döndürdüğü değeri 42 yerine 4 yaptığımızda Task<int> sürümü de 0 byte ayırdı. Runtime bazı sık kullanılan sonuçlar için tamamlanmış task'ları cache'te tutuyor; bu makinedeki .NET 8 runtime'ında -1 ile 8 arasındaki int'ler için böyle. Bu bir uygulama ayrıntısı, belgelenmiş bir garanti değil; tasarımını ona dayandırma.
BenchmarkDotNet ile Ölçmek
Allocation tek başına hikâyenin yarısı. Süreye de bakmak için aynı yolları BenchmarkDotNet 0.15.8 ile ölçtük. İkinci çift, her çağrıda gerçekten asenkron biten bir method: Task.Yield() devamı thread pool'a gönderir, yani method asla senkron bitmez.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StockBenchmarks>();
[InProcess]
[MemoryDiagnoser]
public class StockBenchmarks
{
private const string Isbn = "978-0441172719";
private readonly Dictionary<string, int> _stock = new() { [Isbn] = 42 };
[Benchmark(Baseline = true)]
public Task<int> TaskCacheHit() => GetStockAsync(Isbn);
[Benchmark]
public ValueTask<int> ValueTaskCacheHit() => GetStockValueTaskAsync(Isbn);
[Benchmark]
public Task<int> TaskYield() => YieldTaskAsync();
[Benchmark]
public ValueTask<int> ValueTaskYield() => YieldValueTaskAsync();
private async Task<int> GetStockAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
await Task.Delay(100);
return 42;
}
private async ValueTask<int> GetStockValueTaskAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
await Task.Delay(100);
return 42;
}
// her çağrıda gerçekten asenkron biten yol
private static async Task<int> YieldTaskAsync()
{
await Task.Yield();
return 42;
}
private static async ValueTask<int> YieldValueTaskAsync()
{
await Task.Yield();
return 42;
}
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------ |------------:|----------:|----------:|------------:|-------:|--------:|-------:|----------:|------------:|
| TaskCacheHit | 11.01 ns | 0.236 ns | 0.242 ns | 11.04 ns | 1.00 | 0.03 | 0.0086 | 72 B | 1.00 |
| ValueTaskCacheHit | 10.34 ns | 0.186 ns | 0.174 ns | 10.35 ns | 0.94 | 0.03 | - | - | 0.00 |
| TaskYield | 1,178.49 ns | 35.050 ns | 92.336 ns | 1,150.93 ns | 107.13 | 8.66 | 0.0114 | 96 B | 1.33 |
| ValueTaskYield | 1,444.86 ns | 23.279 ns | 54.414 ns | 1,440.75 ns | 131.35 | 5.66 | 0.0114 | 105 B | 1.46 |
Proje net6.0 hedefli ve Release modda derlendi; makinede .NET 6 runtime'ı olmadığı için DOTNET_ROLL_FORWARD=Major ile .NET 8.0.25 üzerinde, in-process çalıştı. Mutlak sayılar makineden makineye değişir.
Cache'ten dönen yolda süre farkı küçük: 11,01 ns'ye karşı 10,34 ns, yaklaşık yüzde 6. Asıl fark son iki sütunda: Task<int> her çağrıda 72 byte ayırıyor, ValueTask<int> hiç ayırmıyor. Saniyede yüz binlerce kez çağrılan bir yolda bu, garbage collector'ın daha seyrek çalışması demektir. Tek bir çağrının hızı değil, sistemin toplam bellek baskısı değişir.
Asenkron Biten Yolda Kazanç Yok
Tablonun alt yarısı hikâyenin diğer yüzü. Method gerçekten beklediğinde iki sürüm de bellek ayırıyor: 96 byte'a karşı 105 byte. Asenkron biten bir async method'un state machine'i heap'e taşınmak zorundadır, dönüş tipi ne olursa olsun. ValueTask<int> bu durumda içinde bir object taşır ve bu koşuda daha da yavaş çıktı: 1,18 µs'ye karşı 1,44 µs. Bu satırlarda sapma yüksek, çünkü ölçülen şeyin büyük kısmı thread pool'a gidip gelmek; ama yön açık.
Çıkarılacak ders şu: ValueTask "daha hızlı Task" değildir. Sadece sonucun çoğu zaman senkron olarak hazır olduğu yollarda kazandırır. Çağrıların çoğu gerçekten bir ağ isteği ya da disk okuması bekliyorsa Task ile aynı yerdesin, üstüne bir de kurallarına uymak zorundasın.
ValueTask'ın Kuralları
Task'ı istediğin kadar await edebilir, bir field'a koyup sonra tekrar bekleyebilir, WhenAll'a verebilirsin. ValueTask'ta bunların hiçbiri güvenli değil. Sebebi, arkasındaki object'in yeniden kullanılabilmesi. Yüksek performanslı API'ler asenkron yol için bile allocation yapmamak üzere IValueTaskSource denen, pool'dan gelen ve tekrar tekrar kullanılan object'ler kullanır. Sen sonucu bir kez aldığında o object bir sonraki işe verilebilir. Bu davranışı BCL'nin kendi içinde görebiliriz. Tek okuyuculu bir Channel'ın ReadAsync'i tam olarak böyle bir object döndürür:
using System.Threading.Channels;
Channel<string> orders = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
ValueTask<string> read = orders.Reader.ReadAsync(); // channel boş, okuma bekliyor
orders.Writer.TryWrite("order-1");
Console.WriteLine($"First await: {await read}");
try
{
Console.WriteLine($"Second await: {await read}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Second await: {ex.Message}");
}
Task<string> readTask = orders.Reader.ReadAsync().AsTask();
orders.Writer.TryWrite("order-2");
Console.WriteLine($"Task, awaited twice: {await readTask}, {await readTask}");
First await: order-1
Second await: The result of the operation was already consumed and may not be used again.
Task, awaited twice: order-2, order-2
İlk await sonucu verdi. Aynı ValueTask'ı ikinci kez beklediğimizde ise InvalidOperationException geldi: sonuç zaten tüketilmişti. Bu sefer şanslıydık, exception açık bir mesajla geldi. Kendi yazdığın ya da başka bir kütüphanenin IValueTaskSource'unda aynı hata exception yerine yanlış bir değerle, yani bir sonraki işin sonucuyla da karşına çıkabilir.
Kurallar üç tane. Bir ValueTask'ı yalnızca bir kez await et. Aynı anda birden fazla yerden bekleme. Tamamlanmadığından emin olmadan .Result ya da .GetAwaiter().GetResult() ile sonucunu okuma. En güvenli kullanım, method'u çağırdığın satırda hemen await etmek: int stock = await cache.GetStockValueTaskAsync(isbn);.
Sonucu gerçekten birden fazla kez beklemen, saklaman ya da Task.WhenAll'a vermen gerekiyorsa AsTask() ile bir Task'a çevir. Örneğin son kısmında gördüğün gibi, Task'ı iki kez beklemek sorun değil. AsTask() asenkron yolda bir allocation getirir, ama doğru çalışan bir allocation yanlış çalışan bir optimizasyondan iyidir.
Ne Zaman ValueTask Seçmeli
Varsayılan cevap Task. Her yerde ValueTask kullanmak kodu kurallarla doldurur ve çoğu zaman ölçülebilir bir kazanç getirmez. ValueTask<T>'ın kendisi de bir referanstan büyük bir struct; her kopyalanışının küçük bir maliyeti var.
ValueTask'ı şu koşullar bir arada olduğunda seç: method sıcak bir yolda, yani çok sık çağrılıyor; çağrıların büyük kısmı senkron bitiyor (cache, buffer'da hazır veri, zaten açılmış bir bağlantı); ve allocation'ın gerçekten bir sorun olduğunu ölçtün. Bir diğer meşru yer interface tasarımı. ValueTask<int> GetStockAsync(string isbn, CancellationToken cancellationToken = default) gibi bir interface method'u, cache'li bir implementasyona allocation'sız dönme imkânı tanır; cache'siz implementasyon da arkada bir Task ile aynı signature'ı karşılayabilir.
BCL'nin kendisi de ValueTask'ı tam bu tür yerlerde kullanır: Stream.ReadAsync(Memory<byte>, ...), ChannelReader<T>.ReadAsync, IAsyncDisposable.DisposeAsync ve async iterator'ların MoveNextAsync'i. Async method'ların Task dışında bir tip döndürebilmesi C# 7.0 ile gelen generalized async return types özelliği sayesinde mümkün; generic olmayan ValueTask ise .NET Core 2.1 ile eklendi.
Sık Düşülen Tuzaklar
Bir ValueTask'ı iki kez await etmek ya da bir field'a koyup sonra beklemek. Arkadaki object yeniden kullanılmış olabilir; exception ya da yanlış sonuç alırsın.
ValueTask'ları doğrudan Task.WhenAll ya da Task.WhenAny'ye vermeye çalışmak. Bu API'ler Task ister; önce AsTask() ile çevir.
Ölçmeden ValueTask'a geçmek. Çoğu çağrı asenkron bitiyorsa kazanç yok, hatta biraz kayıp var.
Allocation'ı Debug modda ölçmek. Debug derlemede state machine'in kendisi de ayrılır ve sayılar yanıltıcı çıkar.
Runtime'ın küçük sonuçlar için kullandığı cache'e güvenmek. Bu bir uygulama ayrıntısı; sürümden sürüme değişebilir.
Küçük Bir Deneme
★☆☆ İlk örneği önce Debug, sonra Release modda çalıştır (dotnet run -c Release) ve çağrı başına byte değerlerini karşılaştır.
★★☆ Servisin döndürdüğü değeri 42'den 8'e, sonra 9'a değiştir ve Task<int> sürümünün allocation'ını her biri için ölç. Ne gözlemliyorsun? Aynı denemeyi Task<decimal> ile tekrarla.
★★★ Benchmark'a üçüncü bir senaryo ekle: çağrıların yüzde 90'ı cache'ten, yüzde 10'u Task.Yield() ile asenkron dönsün. Task ve ValueTask arasındaki fark bu karışık yükte nasıl çıkıyor?
ValueTask ile tek bir async sonucun maliyetini düşürdük. Peki sonuç tek bir değer değil de, zaman içinde parça parça gelen bir stream ise? Bir sonraki yazıda IAsyncEnumerable<T>'yi, await foreach'i ve async dispose'u görüyoruz; MoveNextAsync'in neden ValueTask<bool> döndürdüğü de orada yerine oturacak.