yield return

19. August 2015

Dieses Sprachfeature gibt es zwar schon etwas länger, jedoch hat es bei mir etwas gedauert damit warm zu werden. Auch die Beispiele, die ich bisher gesehen habe, waren mir nicht immer einleuchtend. Deshalb habe ich es mal für mich verständlich zerlegt.

Starten möchte ich mit for-Schleifen, hierbei werden x und y durch die Methode Add einfach aufsummiert. Die Add Methode simuliert dabei eine langlaufende Methode, um später die Performance der jeweiligen Implementierung besser aufzeigen zu können.

   1: private static IEnumerable<int> CallSimpleLoop()

   2: {

   3:     IList<int> items = new List<int>(); 

   4:     for (int x = 0; x < 3; x++)

   5:     {

   6:         for (int y = 0; y < 3; y++)

   7:         {

   8:             items.Add(Add(x, y));

   9:         }

  10:     } 

  11:     return items;

  12: }

  13:  

  14: private static int Add(int x, int y)

  15: {

  16:     Thread.Sleep(100); //simulate long running worker

  17:     return x + y;

  18: }

Im Beispiel werden dann immer die ersten beiden Items übersprungen und dann die nächsten beiden herausgesucht.

   1: CallSimpleLoop().Skip(2).Take(2).ToList()

Als yield könnte das ganze so aussehen. Obwohl es sich beim Ergebnis wieder um ein IEnumerable<int> handelt, kann das Verhalten beider Lösungen nicht unterschiedlicher sein. Während beim SimpleLoop oben die Add Methode immer gleich ausgeführt wird und nur das Ergebnis in der Liste abgelegt wird, wird hier der Aufruf selbst übernommen. Er wird nur beim Auslesen ausgeführt.

   1: private static IEnumerable<int> CallYieldLoop()

   2: {

   3:     for (int x = 0; x < 3; x++)

   4:     {

   5:         for (int y = 0; y < 3; y++)

   6:         {

   7:             yield return Add(x, y);

   8:         }

   9:     }

  10: }

Heißt hier werden nur die ersten 4 Adds ausgeführt. 2-mal für Skip und 2-mal für Take.

   1: CallYieldLoop().Skip(2).Take(2).ToList()

Aber was heißt er wird nur beim Zugriff ausgeführt? Was versteckt sich hinter jedem Item des yield Iterators?

Ich werde im Folgenden nur das Innere des “on demand” Verhalten von yield betrachten. Den äußeren Teil, wie also der Enumerator durchlaufen und das Current (Item) ausgeführt wird lasse ich außen vor. Somit kann man sich jedes Item sehr vereinfacht als Functionpointer (Delegate) vorstellen.

   1: private static IEnumerable<Func<int>> CallSimpleFuncLoop()

   2: {

   3:     IList<Func<int>> items = new List<Func<int>>();

   4:     for (int x = 0; x < 3; x++)

   5:     {

   6:         for (int y = 0; y < 3; y++)

   7:         {

   8:             items.Add(() => Add(x, y));

   9:         }

  10:     }

  11:     return items;

  12: }

Hier iteriert man jedoch durch die Delegates und muss am Ende den Wert über den Aufruf im Select erst noch berechnen lassen.

   1: CallSimpleFuncLoop().Skip(2).Take(2).Select(i => i()).ToList()

Während jedoch beim YieldLoop neben den Takes auch die Skips ausgeführt werden, werden beim FuncLoop nur die Selects ausgeführt. Dies schlägt sich in der Performance nieder. Am längsten benötigt der SimpleLoop, hier werden auch alle Add Aufrufe ausgeführt, dafür liegen dann aber auch alle Ergebnisse vor. Da der YieldLoop die Add Methode 4-mal aufruft benötigt er ca. doppelt so viel Zeit wie der FuncLoop. Er führt die Add Methode nur 2-mal aus!

SimpleLoop start

925,4136

SimpleLoop end

CallYieldLoop start

405,3873

CallYieldLoop end

CallSimpleFuncLoop start

206,5743

CallSimpleFuncLoop end

Wie bei allen LINQ Features sollte man auf Performance achten, meist liegen die Ergebnisse nicht vor sondern werden erst für den einzelne (jeden!!!) Zugriff aufgebaut. Genau dafür ist LINQ konzipiert!

Hier eine kleine Erweiterung, die diesen Mechanismus aushebelt. Dabei wird um das Delegate ein Wrapper gestrickt, der sich merkt ob das Delegate schon ausgeführt wurde.

   1: public class Wrapper

   2: {

   3:     private object m_lockObject = new object();

   4:     private bool m_loaded = false;

   5:     private int m_data;

   6:  

   7:     private Func<int> m_getData;

   8:  

   9:     public Wrapper(Func<int> getData)

  10:     {

  11:         this.m_getData = getData;

  12:     }

  13:  

  14:     public int Data

  15:     {

  16:         get

  17:         {

  18:             lock (m_lockObject)

  19:             {

  20:                 if(!m_loaded)

  21:                 {

  22:                     m_data = m_getData();

  23:                     m_loaded = true;

  24:                 }

  25:                 return m_data;

  26:             }

  27:         }

  28:     }        

  29: }

  30:  

  31: private static IEnumerable<Wrapper> CallWrapperFuncLoop()

  32: {

  33:     IList<Wrapper> items = new List<Wrapper>();

  34:  

  35:     for (int x = 0; x < 3; x++)

  36:     {

  37:         for (int y = 0; y < 3; y++)

  38:         {

  39:             items.Add(new Wrapper(() => Add(x, y)));

  40:         }

  41:     }

  42:  

  43:     return items;

  44: }

Auch hier wird das Delegate erst im Select ausgeführt, also 2-mal.

   1: CallWrapperFuncLoop().Skip(2).Take(2).Select(i => i.Data).ToList()

Der Unterschied zwischen dem WrapperLoop und dem FuncLoop wird vor allem dann klar wenn man das Skip und Take mehrfach ausführt. Hier habe ich es einfach 10-mal laufen lassen. Der SimpleLoop benötigt immer 9 Add Aufrufe und damit bleibt er immer gleich. Beim YieldLoop werden 4 x 10 nötig und beim FuncLoop 2 x 10. Lediglich der WrapperLoop führt nur 2 Aufrufe durch.

SimpleLoop start

927,8073

SimpleLoop end

CallYieldLoop start

4025,9645

CallYieldLoop end

CallSimpleFuncLoop start

2022,8212

CallSimpleFuncLoop end

CallWrapperFuncLoop start

205,7986

CallWrapperFuncLoop end

Vieles hat LINQ mit sich gebracht, so auch yield return. Eigentlich gar nichts Neues wenn man sich das Feature vereinfacht als Delegate vorstellt. Wie bei jeder Technologie sollte man sich vor Augen welche Restriktionen sie mit sich bringt. Im Falle von LINQ ist es sehr oft der mehrfache Zugriff auf vermeintlich dasselbe Objekt, nur durch ein Statement kann die Performance sehr schnell ganz schlecht werden. Neben der Zeit gibt es auch andere Performancecounter, die negative beeinflusst werden könnten. Wären x und y beispielsweise große Businessobjekte oder Buffer, so würde beim SimpleLoop nur das Ergebnis übernommen aber x und y durch die Garbagecollection abgeräumt werden. Beim YieldLoop müssen jedoch für jedes Item (Delegate) x und y vorgehalten werden und können erst abgeräumt werden wenn die IEnumerable<int> nicht mehr benötigt wird. Beim WrapperLoop könnte man dies beispielsweise darüber lösen m_getData nach dem Laden auf null zu setzen.