İlkay İlknur

Foreach Döngülerinde Oluşabilecek Allocationlar

Nisan 11, 2021

Herkese Selamlar,

Bu yazıda konumuz foreach döngülerinin oluşturabileceği allocationlar. Biraz ilginç bir konu. :) Konuya hemen basitçe giriş yapalım.

Bir foreach döngüsü yazdığımızda yazdığımız kod compiler tarafından yaklaşık olarak aşağıdaki gibi dönüştürülür.

Foreach Döngüsü

List<int> Collection = new List<int>();
foreach (var item in Collection)
{
    
}

 Çevrilmiş hali

List<int> Collection = new List<int>();
List<int>.Enumerator enumerator = Collection.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
    }
}
finally
{
    ((IDisposable)enumerator).Dispose();
}

Şimdi burada allocationa sebep olacak noktalara tek tek bakarsak karşımıza ilk olarak Collection.GetEnumerator(); metot çağrısı geliyor. Bu metot içerisinde çağırılan kod aşağıdaki gibi. Kaynak https://source.dot.net/#System.Private.CoreLib/List.cs,597

 public Enumerator GetEnumerator()
            => new Enumerator(this);

List<T>.Enumerator tipine baktığımızda da bu tipin bir struct olduğunu görüyoruz. System.Collections.Generic namespace'i içerisinde bulunan diğer collectionlar için de bu durum geçerli. Yani tüm collectionların enumeratorları birer struct. Dolayısıyla burada heap allocation oluşturacak bir durum şu an için yok. Peki aşağıdaki gibi bir durumda değişen ne olacak bir de ona bakalım.

IEnumerable<int> Collection = new List<int>();
foreach(var i in Collection)
{
  
}

Buradaki kod compiler tarafından aşağıdaki gibi çevriliyor.

IEnumerable<int> Collection = new List<int>();
IEnumerator<int> enumerator = ((IEnumerable<int>)Collection).GetEnumerator();
try
{
  while (enumerator.MoveNext())
  {
    Console.WriteLine(enumerator.Current);
  }
}
finally
{
  if (enumerator != null)
  {
    enumerator.Dispose();
  }
}

List<T> tipi içerisinde IEnumerable<T>.GetEnumerator metodunun tanımlaması ise şu şekilde. Kaynak https://source.dot.net/#System.Private.CoreLib/List.cs,600

IEnumerator<T> IEnumerable<T>.GetEnumerator()
            => new Enumerator(this);

Burada da aslında yeni bir Enumerator instance'ı yaratılıp dönülüyor. Sorun gözükmüyor gibi duruyor ama arada şöyle bir fark var. Enumerator structı IEnumerator<T>'ye cast ediliyor. Dolayısıyla burada bir boxing, heap allocation bulunmakta.

Şu ana kadar incelediğimiz kısmı özetlersek, eğer foreach döngüsünü tiplerin kendisi üzerinden çalıştırırsak compiler bu durumda doğru Enumerator tipini buluyor ve heap allocationa neden olmadan Enumerator 'ı elde edebiliyor. Ancak belirli bir interface üzerinden aynı foreach döngüsü çalıştırıldığında compiler enumeratorı IEnumerator<T>'ye cast etmesi gerektiği için bir heap allocation oluşuyor.

Şimdi gelelim finally bloğuna.

finally
{
    ((IDisposable)enumerator).Dispose();
}

Burada da baktığımızda bir IDisposable castingi var. Bu da görünürde bir allocationa neden olmakta. Ancak buradan detaylarını okuyabileceğiniz üzere C# compilerı bu noktada özel bir optimizasyon yapmakta ve value type üzerinde expose edilen bir Dispose metodu varsa bu metodu casting yapmadan doğrudan çağırmakta. Bu nedenle de bu noktada bir heap allocation oluşmamakta.

Kısaca tüm senaryoyu özetlersek eğer bir collection tipi üzerinden foreach döngüsü yazarsanız bu döngü bir allocationa neden olmamakta. Ancak interface üzerinden yazarsanız bir allocation oluşmakta.

Şimdi bu noktaya kadar çıkarımlarımızı hep kod okuyarak yaptık. Ancak performans ve memory kullanımı ile ilgili bir şey iddia ediyorsanız bunu benchmark yazarak kanıtlamanız gerekiyor. Şimdi gelelim benchmarklara.

[MemoryDiagnoser]
[MarkdownExporter]
public class ForeachBenchmark
{
    private IEnumerable<int> IEnumerable;
    private List<int> List;

    [GlobalSetup]
    public void Setup()
    {
        List = new List<int>()
        {
            1, 2, 3, 4, 5, 6, 7, 8, 9, 10
        };
        
        IEnumerable = new List<int>()
        {
            1, 2, 3, 4, 5, 6, 7, 8, 9, 10
        }; 
    }

    [Benchmark]
    public int ForeachOverList()
    {
        var sum = 0;
        foreach (var item in List)
        {
            sum += item;
        }

        return sum;
    }
    
    [Benchmark]
    public int ForeachOverIEnumerable()
    {
        var sum = 0;
        foreach (var item in IEnumerable)
        {
            sum += item;
        }

        return sum;
    }
}

Çok basit olarak iki tane metot yazdık. Bu metotlar liste içerisindeki elemanların toplamını dönmekte. Şimdi sonuçlara bakalım.

BenchmarkDotNet=v0.12.1, OS=macOS 11.2.3 (20D91) [Darwin 20.3.0]

Intel Core i9-8950HK CPU 2.90GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores

.NET Core SDK=5.0.201

  [Host]     : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT

  DefaultJob : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT
MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
ForeachOverList29.18 ns0.761 ns2.231 ns----
ForeachOverIEnumerable101.04 ns2.704 ns7.845 ns0.0063--40 B

Görüldüğü üzere IEnumerable üzerinden çalıştırdığımız foreach döngüsünde bir heap allocation oluşmakta. Bu allocation tabi ki çok büyük bir allocation değil. Yani toptan tüm foreach kullanımlarımızı değiştirelim gibi bir sonuç çıkarmak doğru değil. Ancak hot path dediğimiz noktalarda allocationlardan kaçınmak istediğimiz durumlarda bu noktalar göz önünde bulundurulabilir.

Bir sonraki yazıda görüşmek üzere.