Januar 2018

Band 33, Nummer 1

C# – Informationen zu Span: Eine neue tragende Säule in .NET

Von Stephen Toub | Januar 2018

Stellen Sie sich vor, Sie setzen eine spezielle Sortierroutine ein, um direkt mit Daten im Speicher zu arbeiten. Sie würden wahrscheinlich eine Methode zur Verfügung stellen, die ein Array annimmt und eine Implementierung bereitstellt, die T[] verarbeitet. Das ist großartig, wenn der Aufrufer Ihrer Methode über ein Array verfügt und das gesamte Array sortiert werden soll, aber wie würden Sie vorgehen, wenn der Aufrufer nur einen Teil davon sortiert haben möchte? Wahrscheinlich würden Sie dann auch eine Überladung bereitstellen, die ein Offset und eine Anzahl annimmt. Was aber, wenn Sie Daten im Speicher unterstützen möchten, die sich nicht in einem Array befinden, sondern z.B. aus nativem Code stammen oder auf dem Stapel gespeichert sind und Sie nur über einen Zeiger und eine Länge verfügen? Wie könnten Sie Ihre Sortiermethode schreiben, die mit einer so beliebigen Speicherregion operiert und dennoch mit vollständigen Arrays oder Teilmengen von Arrays gleichermaßen gut funktioniert und für verwaltete Arrays und nicht verwaltete Zeiger gleichermaßen gut geeignet ist?

Oder nehmen Sie ein anderes Beispiel. Sie implementieren einen Vorgang über System.String, z.B. eine spezielle Analysemethode. Sie würden wahrscheinlich eine Methode zur Verfügung stellen, die eine Zeichenfolge annimmt und eine Implementierung bereitstellt, die Zeichenfolgen verarbeitet. Aber wie gehen Sie vor, wenn Sie die Verarbeitung einer Teilmenge dieser Zeichenfolge unterstützen möchten? String.Substring könnte verwendet werden, um nur das Teilstück auszuschneiden, das für Sie interessant ist, aber das ist ein relativ teurer Vorgang, der eine Zeichenfolgenzuordnung und eine Speicherkopie beinhaltet. Sie könnten (wie im Array-Beispiel erwähnt), ein Offset und eine Anzahl verwenden, aber was ist, wenn der Aufrufer nicht über eine Zeichenfolge verfügt, sondern stattdessen über char[]? Oder wenn der Aufrufer über char* verfügt, z.B. mit stackalloc erstellt, um etwas Platz auf dem Stapel zu nutzen, oder als Ergebnis eines Aufrufs von nativem Code? Wie könnten Sie Ihre Analysemethode in einer Weise schreiben, die den Aufrufer nicht zwingt, irgendwelche Zuordnungen oder Kopien zu erstellen, und dennoch genauso gut mit Eingaben vom Typ string, char[] und char* funktioniert?

In beiden Fällen können Sie möglicherweise unsicheren Code und Zeiger verwenden, wodurch eine Implementierung bereitgestellt wird, die einen Zeiger und eine Länge akzeptiert. Dies eliminiert jedoch die Sicherheitsgarantien, die den Kern von .NET ausmachen, und eröffnet Ihnen Probleme wie Pufferüberläufe und Zugriffsverletzungen, die für die meisten .NET-Entwickler der Vergangenheit angehören. Darüber hinaus werden zusätzliche Leistungseinbußen verhängt, z.B. die Notwendigkeit, verwaltete Objekte für die Dauer des Vorgangs anzuheften, damit der abgerufene Zeiger gültig bleibt. Und je nach Art der betroffenen Daten kann es nicht zweckmäßig sein, überhaupt einen Zeiger abzurufen.

Es gibt eine Antwort auf diese rätselhafte Frage, und ihr Name ist Span<T>.

Was ist Span<T>?

System.Span<T> ist ein neuer Werttyp im Herzen von .NET. Er ermöglicht die Darstellung von zusammenhängenden Bereichen beliebigen Speichers, unabhängig davon, ob dieser Speicher einem verwalteten Objekt zugeordnet ist, ob er durch nativen Code über Interop bereitgestellt wird oder sich auf dem Stapel befindet. Und das alles bei gleichzeitigem sicheren Zugriff mit Leistungsmerkmalen wie bei Arrays.

Sie können z.B. aus einem Array ein Span<T>-Element erstellen:

var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>

Von dort aus können Sie einfach und effizient ein Span-Element erstellen, um nur eine Teilmenge dieses Arrays darzustellen/auf es zu zeigen, indem Sie eine Überladung der Slice-Methode des Span-Elements verwenden. Von dort aus können Sie das sich ergebende Span-Element indizieren, um Daten in den entsprechenden Teil des ursprünglichen Arrays zu schreiben und aus ihm zu lesen:

Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
Assert.Equal(42, slicedBytes[0]);
Assert.Equal(43, slicedBytes[1]);
Assert.Equal(arr[5], slicedBytes[0]);
Assert.Equal(arr[6], slicedBytes[1]);
slicedBytes[2] = 44; // Throws IndexOutOfRangeException
bytes[2] = 45; // OK
Assert.Equal(arr[2], bytes[2]);
Assert.Equal(45, arr[2]);

Wie bereits erwähnt, sind Span-Elemente mehr als nur ein Verfahren, um auf Arrays und Teilmengenarrays zuzugreifen. Sie können auch verwendet werden, um auf Daten auf dem Stapel zu verweisen. Beispiele:

Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans
bytes[0] = 42;
bytes[1] = 43;
Assert.Equal(42, bytes[0]);
Assert.Equal(43, bytes[1]);
bytes[2] = 44; // throws IndexOutOfRangeException

Generell können sie verwendet werden, um auf beliebige Zeiger und Längen zu verweisen, z.B. auf Speicher, der von einem nativen Heap zugewiesen wurde:

IntPtr ptr = Marshal.AllocHGlobal(1);
try
{
  Span<byte> bytes;
  unsafe { bytes = new Span<byte>((byte*)ptr, 1); }
  bytes[0] = 42;
  Assert.Equal(42, bytes[0]);
  Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);
  bytes[1] = 43; // Throws IndexOutOfRangeException
}
finally { Marshal.FreeHGlobal(ptr); }

Der Span<T>-Indexer nutzt die Vorteile einer in C# 7.0 eingeführten C#-Sprachfunktion, die als ref-Rückgaben bezeichnet wird. Der Indexer wird mit einem Rückgabetyp „ref T“ deklariert, der Semantik wie die des Indizierens in Arrays bereitstellt und einen Verweis auf den tatsächlichen Speicherort zurückgibt, anstatt eine Kopie dessen, was an diesem Speicherort gespeichert ist:

public ref T this[int index] { get { ... } }

Die Auswirkung dieses ref zurückgebenden Indexers ist am deutlichsten anhand eines Beispiels zu erkennen, z.B. durch einen Vergleich mit dem List<T>-Indexer, der nicht ref zurückgibt. Im Folgenden finden Sie ein Beispiel:

struct MutableStruct { public int Value; }
...
Span<MutableStruct> spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);
var listOfStructs = new List<MutableStruct> { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable

Eine zweite Variante von Span<T> (System.ReadOnlySpan<T>) ermöglicht schreibgeschützten Zugriff. Dieser Typ ist mit Span<T> identisch, nur nutzt sein Indexer eine neue C# 7.2-Funktion, um „ref readonly T“ anstelle von „ref T“ zurückzugeben, wodurch er mit unveränderlichen Datentypen wie System.String arbeiten kann. ReadOnlySpan<T> macht es sehr effizient, Zeichenfolgen ohne Zuordnung oder Kopieren zu segmentieren, wie hier gezeigt:

string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates ReadOnlySpan<char> worldSpan =
  str.AsReadOnlySpan().Slice(start: 7, length: 5); // No allocation
Assert.Equal('w', worldSpan[0]);
worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to

Span-Elemente bieten eine Vielzahl von Vorteilen, die über die bereits erwähnten hinausgehen. Span-Elemente unterstützen z.B. die Neuinterpretation von Umwandlungen, d.h. Sie können Span<Byte> in Span<int> umwandeln (wobei der 0. Index in Span<int> den ersten vier Bytes von Span<Byte> zugeordnet ist). Wenn Sie einen Puffer von Bytes lesen, können Sie ihn auf diese Weise an Methoden übergeben, die gruppierte Bytes als int-Werte sicher und effizient verarbeiten.

Wie wird Span<T> implementiert?

Entwickler müssen im Allgemeinen nicht verstehen, wie eine Bibliothek implementiert wird, die sie verwenden. Im Falle von Span<T> lohnt es sich jedoch, zumindest über ein grundlegendes Verständnis der Details im Hintergrund zu verfügen, da diese Details etwas über die Leistung und die Nutzungseinschränkungen aussagen.

Zunächst ist Span<T> ein Werttyp, der einen Verweis und eine Länge enthält, die ungefähr wie folgt definiert sind:

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

Das Konzept eines ref T-Felds mag zunächst seltsam erscheinen: Ein ref T-Feld kann nicht in C# und auch nicht in MSIL deklariert werden. Aber Span<T> wurde tatsächlich so programmiert, dass ein spezieller interner Typ in der Laufzeit verwendet wird, der als intrinsischer JIT-Wert (Just-in-time) behandelt wird. Dabei generiert der JIT-Wert für ihn das Äquivalent eines ref T-Felds. Sehen Sie sich eine ref-Verwendung an, die Ihnen wahrscheinlich viel vertrauter ist:

public static void AddOne(ref int value) => value += 1;
...
var values = new int[] { 42, 84, 126 };
AddOne(ref values[2]);
Assert.Equal(127, values[2]);

Dieser Code übergibt einen Slot im Array anhand eines Verweises, sodass (von Optimierungen abgesehen) ein ref T auf dem Stapel vorhanden ist. Hinter ref T in Span<T> verbirgt sich die gleiche Idee, einfach gekapselt in einer Struktur. Typen, die solche refs direkt oder indirekt enthalten, werden ref-ähnliche Typen genannt, und der C# 7.2-Compiler erlaubt die Deklaration solcher ref-ähnlichen Typen durch die Verwendung von „ref struct“ in der Signatur.

Aus dieser kurzen Beschreibung sollten zwei Dinge deutlich werden:

  1. Span<T> ist so definiert, dass Vorgänge genauso effizient sein können wie für Arrays: Die Indizierung in ein Span-Element erfordert keine Berechnung, um den Anfang von einem Zeiger und dessen Anfangsoffset zu bestimmen, da das ref-Feld selbst bereits beides kapselt. (Im Gegensatz dazu weist ArraySegment<T> ein separates Offsetfeld auf, wodurch die Indizierung und die Übergabe teurer werden.)
  2. Die Natur von Span<T> als ref-ähnlicher Typ bringt aufgrund des ref T-Felds einige Einschränkungen mit sich.

Dieses zweite Element besitzt einige interessante Auswirkungen, die dazu führen, dass .NET einen zweiten und verwandten Satz von Typen enthält, an deren Spitze Memory<T> steht.

Was ist Memory<T>, und wozu wird dieses Element verwendet?

Span<T> ist ein ref-ähnlicher Typ, da ein ref-Feld vorhanden ist, und ref-Felder können sich nicht nur auf den Anfang von Objekten wie Arrays beziehen, sondern auch auf deren Mitte:

var arr = new byte[100];
Span<byte> interiorRef1 = arr.AsSpan().Slice(start: 20);
Span<byte> interiorRef2 = new Span<byte>(arr, 20, arr.Length – 20);
Span<byte> interiorRef3 =
  Span<byte>.DangerousCreate(arr, ref arr[20], arr.Length – 20);

Diese Verweise werden als innere Zeiger bezeichnet, und ihre Nachverfolgung ist ein relativ teurer Vorgang für den Garbage Collector der .NET-Laufzeit. Die Laufzeit schränkt diese refs so ein, dass sie nur auf dem Stapel gespeichert werden, da sie eine implizite Untergrenze für die Anzahl der ggf. vorhandenen inneren Zeiger bereitstellt.

Außerdem ist Span<T> (wie oben gezeigt) größer als die Wortgröße des Computers, was bedeutet, dass das Lesen und Schreiben eines Span-Elements kein atomischer Vorgang ist. Wenn mehrere Threads gleichzeitig die Felder eines Span-Elements auf dem Heap lesen und schreiben, besteht die Gefahr eines „Zerreißens“. Stellen Sie sich ein bereits initialisiertes Span-Element vor, das einen gültigen Verweis und eine entsprechende _length von 50 aufweist. Ein Thread fängt über ihm an, ein neues Span-Element zu schreiben und kommt bis zum Schreiben des neuen _pointer-Werts. Bevor er die entsprechende _length auf 20 festlegen kann, liest ein zweiter Thread das Span-Element (einschließlich des neuen _pointer), aber mit der alten (und längeren) _length.

Infolgedessen können Span<T> Instanzen nur auf dem Stapel vorhanden sein, nicht auf dem Heap. Das bedeutet, dass Sie keine Boxing für Span-Elemente verwenden können (und somit Span<T> z.B. auch nicht mit vorhandenen Reflektionsaufruf-APIs verwendet werden kann, da diese Boxing erfordern). Das bedeutet, dass Sie keine Span<T>-Felder in Klassen und auch nicht in nicht ref-ähnlichen Strukturen verwenden können. Dies bedeutet, dass Sie keine Span-Elemente an Stellen im Code verwenden können, an denen sie implizit zu Feldern für Klassen werden könnten, indem sie beispielsweise in Lambdas erfasst werden oder als lokale Variablen in asynchronen Methoden oder Iteratoren (da diese „lokalen Variablen“ als Felder auf den vom Compiler generierten Zustandsautomaten enden können). Es bedeutet auch, dass Sie Span<T> nicht als generisches Argument verwenden können, da auf Instanzen dieses Argumenttyps Boxing angewendet werden kann oder sie anderweitig auf dem Heap gespeichert werden könnten (und es ist zurzeit keine Einschränkung „where T : ref struct“ verfügbar).

Diese Einschränkungen sind für viele Szenarien unerheblich, insbesondere für rechenintensive und synchrone Verarbeitungsfunktionen. Aber asynchrone Funktionalität steht auf einem anderen Blatt. Die meisten der am Anfang dieses Artikels aufgeführten Probleme im Zusammenhang mit Arrays, Arrayslices, nativem Speicher usw. bestehen unabhängig davon, ob es sich um synchrone oder asynchrone Vorgänge handelt. Wenn Span<T> nicht auf dem Heap gespeichert werden kann und somit nicht über asynchrone Vorgänge hinweg persistiert gespeichert werden kann, was ist dann die Antwort? Sie lautet Memory<T>.

Memory<T> looks very much like an ArraySegment<T>:
public readonly struct Memory<T>
{
  private readonly object _object;
  private readonly int _index;
  private readonly int _length;
  ...
}

Sie können Memory<T> aus einem Array erstellen und genau so wie ein Span-Element segmentieren, aber handelt sich um eine (nicht ref-ähnliche) Struktur, und diese kann auf dem Heap vorhanden sein. Wenn Sie dann synchrone Verarbeitung ausführen möchten, können Sie z.B. Span<T> daraus ableiten:

static async Task<int> ChecksumReadAsync(Memory<byte> buffer, Stream stream)
{
  int bytesRead = await stream.ReadAsync(buffer);
  return Checksum(buffer.Span.Slice(0, bytesRead));
  // Or buffer.Slice(0, bytesRead).Span
}
static int Checksum(Span<byte> buffer) { ... }

Wie bei Span<T> und ReadOnlySpan<T> weist auch Memory<T> ein schreibgeschütztes Äquivalent auf: ReadOnlyMemory<T>. Und wie Sie erwarten würden, gibt seine Span-Eigenschaft ein ReadOnlySpan<T>-Element zurück. Eine kurze Zusammenfassung der integrierten Mechanismen zur Konvertierung dieser Typen finden Sie in Abbildung 1.

Abbildung 1: Nicht zuordnende/nicht kopierende Konvertierungen zwischen spanbezogenen Typen

Aus In Mechanismus
ArraySegment<T> Memory<T> Implizite Umwandlung, AsMemory-Methode
ArraySegment<T> ReadOnlyMemory<T> Implizite Umwandlung, AsReadOnlyMemory-Methode
ArraySegment<T> ReadOnlySpan<T> Implizite Umwandlung, AsReadOnlySpan-Methode
ArraySegment<T> Span<T> Implizite Umwandlung, AsSpan-Methode
ArraySegment<T> T[] Array-Eigenschaft
Memory<T> ArraySegment<T> TryGetArray-Methode
Memory<T> ReadOnlyMemory<T> Implizite Umwandlung, AsReadOnlyMemory-Methode
Memory<T> Span<T> Span-Eigenschaft
ReadOnlyMemory<T> ArraySegment<T> DangerousTryGetArray-Methode
ReadOnlyMemory<T> ReadOnlySpan<T> Span-Eigenschaft
ReadOnlySpan<T> ref readonly T get-Accessor des Indexers, Marshallingmethoden
Span<T> ReadOnlySpan<T> Implizite Umwandlung, AsReadOnlySpan-Methode
Span<T> ref T get-Accessor des Indexers, Marshallingmethoden
String ReadOnlyMemory<char> AsReadOnlyMemory-Methode
String ReadOnlySpan<char> Implizite Umwandlung, AsReadOnlySpan-Methode
T[] ArraySegment<T> Ctor, implizite Umwandlung
T[] Memory<T> Ctor, implizite Umwandlung, AsMemory-Methode
T[] ReadOnlyMemory<T> Ctor, implizite Umwandlung, AsReadOnlyMemory-Methode
T[] ReadOnlySpan<T> Ctor, implizite Umwandlung, AsReadOnlySpan-Methode
T[] Span<T> Ctor, implizite Umwandlung, AsSpan-Methode
void* ReadOnlySpan<T> Ctor
void* Span<T> Ctor

Ihnen wird auffallen, dass das _object-Feld von Memory<T> nicht wie T[] stark typisiert ist, sondern als Objekt gespeichert wird. Dies hebt hervor, dass Memory<T> als Wrapper für andere Elemente als Arrays (z.B. System.Buffers.OwnedMemory<T>) verwendet werden kann. OwnedMemory<T> ist eine abstrakte Klasse, die als Wrapper für Daten verwendet werden kann, deren Lebensdauer streng verwaltet werden muss, z.B. Speicher, der aus einem Pool abgerufen wird. Das ist ein fortgeschritteneres Thema, das über den Rahmen dieses Artikels hinausgeht, aber auf diese Weise kann Memory<T> z.B. als Wrapper für Zeiger im nativen Speicher verwendet werden. ReadOnlyMemory<char> kann auch mit Zeichenfolgen verwendet werden, ebenso wie ReadOnlySpan<char>.

Wie werden Span<T> und Memory<T> in .NET-Bibliotheken integriert?

Im vorherigen Memory<T>-Codeausschnitt sehen Sie einen Aufruf von Stream.ReadAsync, der ein Memory<Byte> übergibt. Aber Stream.ReadAsync in .NET ist heute so definiert, dass ein byte[] akzeptiert wird. Wie funktioniert das?

Zur Unterstützung von Span<T> und verwandten Elementen werden Hunderte von neuen Membern und Typen in .NET hinzugefügt. Viele davon sind Überladungen vorhandener arraybasierter und zeichenfolgenbasierter Methoden, während andere völlig neue Typen sind, die sich auf bestimmte Verarbeitungsbereiche konzentrieren. Beispielsweise weisen alle primitiven Typen wie Int32 jetzt Parse-Überladungen auf, die ein ReadOnlySpan<char> zusätzlich zu den vorhandenen Überladungen akzeptieren, die Zeichenfolgen annehmen. Stellen Sie sich eine Situation vor, in der Sie eine Zeichenfolge erwarten, die zwei Zahlen enthält, die durch ein Komma getrennt sind (z.B. „123,456“), und Sie möchten diese beiden Zahlen analysieren. Heute könnten Sie Code wie diesen schreiben:

string input = ...;
int commaPos = input.IndexOf(',');
int first = int.Parse(input.Substring(0, commaPos));
int second = int.Parse(input.Substring(commaPos + 1));

Dies führt jedoch zu zwei Zeichenfolgenzuordnungen. Wenn Sie leistungsabhängigen Code schreiben, können dies zwei Zeichenfolgenzuordnungen zu viel sein. Stattdessen können Sie jetzt dies schreiben:

string input = ...;
ReadOnlySpan<char> inputSpan = input.AsReadOnlySpan();
int commaPos = input.IndexOf(',');
int first = int.Parse(inputSpan.Slice(0, commaPos));
int second = int.Parse(inputSpan.Slice(commaPos + 1));

Durch die Verwendung der neuen spanbasierten Parse-Überladungen kommt der gesamte Vorgang ohne Zuordnngen aus. Ähnliche Analyse- und Formatierungsmethoden sind für primitive Typen wie Int32 bis hin zu Kerntypen wie DateTime, TimeSpan und Guid und sogar bis hin zu Typen auf höherer Ebene wie BigInteger und IPAddress vorhanden.

Tatsächlich wurden viele solcher Methoden in das Framework integriert. Von System.Random über System.Text.StringBuilder bis hin zu System.Net.Sockets wurden Überladungen hinzugefügt, um das Arbeiten mit {ReadOnly}Span<T> und {ReadOnly}Memory<T> einfach und effizient zu gestalten. Einige von ihnen bringen sogar zusätzliche Vorteile mit sich. Beispielsweise verfügt Stream jetzt über diese Methode:

public virtual ValueTask<int> ReadAsync(
  Memory<byte> destination,
  CancellationToken cancellationToken = default) { ... }

Ihnen wird auffallen, dass im Gegensatz zur vorhandenen ReadAsync-Methode, die ein Byte[] akzeptiert und ein Task<int>-Element zurückgibt, diese Überladung nicht nur ein Memory<byte> anstelle eines Byte[] akzeptiert, sondern auch ein ValueTask<int>- anstelle eines Task<int>-Elements zurückgibt. ValueTask<T> ist eine Struktur, die in den Fällen dabei hilft, Zuordnungen zu vermeiden, wenn häufig erwartet wird, dass die Rückgabe einer asynchronen Methode synchron erfolgt, und wenn es unwahrscheinlich ist, dass ein abgeschlossener Task für alle allgemeinen Rückgabewerte zwischengespeichert werden kann. Beispielsweise kann die Laufzeit ein abgeschlossenes Task<bool>-Element für ein Ergebnis TRUE und ein Element für ein Ergebnis FALSE zwischenspeichern, aber sie kann nicht vier Milliarden Taskobjekte für alle möglichen Ergebniswerte eines Task<int>-Elements zwischenspeichern.

Da es durchaus üblich ist, dass Stream-Implementierungen auf eine Art und Weise puffern, die dazu führt, dass ReadAsync-Aufrufe synchron abgeschlossen werden, gibt diese neue ReadAsync-Überladung ein ValueTask<int>-Element zurück. Das bedeutet, dass asynchrone Stream-Lesevorgänge, die synchron abgeschlossen werden, völlig ohne Zuordnungen auskommen können. ValueTask<T> wird auch in anderen neuen Überladungen verwendet, z.B. in Überladungen von Socket.ReceiveAsync, Socket.SendAsync, WebSocket.ReceiveAsync und TextReader.ReadAsync.

Darüber hinaus gibt es Umstände, unter denen Span<T> erlaubt, dass das Framework Methoden einbezieht, die in der Vergangenheit zu Bedenken hinsichtlich der Speichersicherheit geführt haben. Stellen Sie sich eine Situation vor, in der Sie eine Zeichenfolge erstellen möchten, die einen zufällig generierten Wert enthält, z.B. für irgendeine ID. Heute schreiben Sie vielleicht Code, der die Zuweisung eines char-Arrays erfordert, so wie in diesem Beispiel:

int length = ...;
Random rand = ...;
var chars = new char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

Sie könnten stattdessen Stapelzuordnung verwenden und sogar Span<char> nutzen, um unsicheren Code zu vermeiden. Dieser Ansatz nutzt auch die Vorteile des neuen Zeichenfolgenkonstruktors, der wie im folgenden Beispiel gezeigt ein ReadOnlySpan<char>-Element akzeptiert:

int length = ...;
Random rand = ...;
Span<char> chars = stackalloc char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

Dies ist besser, da Sie die Heapzuordnung vermieden haben, aber Sie sind immer noch gezwungen, die Daten, die auf dem Stapel generiert wurden, in die Zeichenfolge zu kopieren. Dieser Ansatz funktioniert außerdem nur, wenn der erforderliche Speicherplatz für den Stapel klein genug ist. Eine kurze Länge (etwa 32 Bytes) ist unproblematisch, aber wenn es Tausende von Bytes sind, kann es leicht zu einem Stapelüberlauf kommen. Wäre es nicht hilfreich, wenn Sie stattdessen direkt in den Speicher der Zeichenfolge schreiben könnten? Span<T> ermöglicht genau das. Zusätzlich zum neuen Konstruktor verfügt string nun auch über eine Create-Methode:

public static string Create<TState>(
  int length, TState state, SpanAction<char, TState> action);
...
public delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

Diese Methode wird implementiert, um die Zeichenfolge zuzuweisen und dann ein schreibbares Span-Element auszugeben, in das Sie schreiben können, um den Inhalt der Zeichenfolge während ihrer Erstellung anzugeben. Beachten Sie, dass die ausschließlich stapelbezogene Natur von Span<T> in diesem Fall von Vorteil ist, da sie garantiert, dass das Span-Element (das sich auf den internen Speicher der Zeichenfolge bezieht) nicht mehr vorhanden ist, wenn der Konstruktor der Zeichenfolge abgeschlossen ist. Auf diese Weise ist es nicht möglich, das Span-Element zum Mutieren der Zeichenfolge nach dem Abschluss ihrer Erstellung zu verwenden:

int length = ...;
Random rand = ...;
string id = string.Create(length, rand, (Span<char> chars, Random r) =>
{
  for (int i = 0; chars.Length; i++)
  {
    chars[i] = (char)(r.Next(0, 10) + '0');
  }
});

Jetzt haben Sie nicht nur die Zuordnung vermieden, sondern Sie schreiben auch direkt in den Speicher der Zeichenfolge auf dem Heap, was bedeutet, dass Sie auch die Kopie vermeiden und nicht durch Größeneinschränkungen des Stapels eingeschränkt sind.

Neben den Kernframeworktypen, die neue Member gewinnen, werden viele neue .NET-Typen entwickelt, um mit Span-Elementen eine effiziente Verarbeitung in bestimmten Szenarien zu erzielen. Beispielsweise können Entwickler, die leistungsstarke Mikroservices und Websites mit hohem Textverarbeitungsaufwand schreiben möchten, einen signifikanten Leistungsgewinn erzielen, wenn sie beim Arbeiten in UTF-8 nicht in Zeichenfolgen codieren und decodieren müssen. Um dies zu ermöglichen, werden neue Typen wie System.Buffers.Text.Base64, System.Buffers.Text.Utf8Parser und System.Buffers.Text.Utf8Formatter hinzugefügt. Diese arbeiten mit Byte-Span-Elementen, was nicht nur die Unicode-Codierung und -Decodierung vermeidet, sondern auch die Arbeit mit nativen Puffern ermöglicht, die in den niedrigsten Ebenen verschiedener Netzwerkstapel üblich sind:

ReadOnlySpan<byte> utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
  out int bytesConsumed, standardFormat = 'P'))
  throw new InvalidDataException();

All diese Funktionen sind nicht nur für die öffentliche Nutzung gedacht, sondern das Framework selbst ist in der Lage, diese neuen, auf Span<T> und Memory<T> basierenden Methoden für eine bessere Leistung zu nutzen. Aufrufsites in .NET Core sind auf die Verwendung der neuen ReadAsync-Überladungen umgestiegen, um unnötige Zuordnungen zu vermeiden. Analysen, die durch die Zuordnung von Teilzeichenfolgen ausgeführt wurden, nutzen nun zuordnungsfreie Analysen. Sogar Nischentypen wie Rfc2898DeriveBytes sind auf diesen Ansatz umgestiegen und haben die Vorteile der neuen auf Span<byte> basierenden TryComputeHash-Methode für System.Security.Cryptography.HashAlgorithmus genutzt, um ungeheure Einsparungen bei der Zuordnung (ein Bytearray pro Iteration des Algorithmus, das Tausende von Iterationen durchlaufen kann) sowie eine Durchsatzverbesserung zu erzielen.

Dies beschränkt sich nicht auf der Ebene der .NET-Kernbibliotheken, sondern setzt sich bis hin zum Stapel fort. ASP.NET Core weist nun eine starke Abhängigkeit von Span-Elementen auf, z.B. mit dem HTTP-Parser des Kestrel-Servers, der basierend auf diesen geschrieben wurde. In der Zukunft ist es wahrscheinlich, dass die Span-Elemente aus öffentlichen APIs in den unteren Ebenen von ASP.NET Core bereitgestellt werden, z.B. in der Middlewarepipeline.

Und die .NET-Laufzeit?

Die .NET-Laufzeit bietet z.B. Sicherheit, indem sie sicherstellt, dass die Indizierung eines Arrays nicht erlaubt, die Länge des Arrays zu überschreiten. Diese Praxis wird als Grenzüberprüfung bezeichnet. Sehen Sie sich zum Beispiel die folgende Methode an:

[MethodImpl(MethodImplOptions.NoInlining)]
static int Return4th(int[] data) => data[3];

Auf dem x64-Computer, auf dem ich diesen Artikel schreibe, sieht die generierte Assembly für diese Methode wie folgt aus:

sub      rsp, 40
       cmp      dword ptr [rcx+8], 3
       jbe      SHORT G_M22714_IG04
       mov      eax, dword ptr [rcx+28]
       add      rsp, 40
       ret
G_M22714_IG04:
       call     CORINFO_HELP_RNGCHKFAIL
       int3

Diese cmp-Anweisung vergleicht die Länge des Datenarrays mit dem Index 3, und die nachfolgende jbe-Anweisung springt dann zur Bereichsüberprüfungs-Fehlerroutine, wenn 3 außerhalb des Bereichs liegt (damit eine Ausnahme ausgelöst wird). Der JIT-Compiler muss Code generieren, der sicherstellt, dass solche Zugriffe nicht über die Grenzen des Arrays hinausgehen, aber das bedeutet nicht, dass für jeden einzelnen Arrayzugriff eine Überprüfung der Grenzen erforderlich ist. Sehen Sie sich diese Sum-Methode an:

static int Sum(int[] data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

Der JIT-Compiler muss hier Code generieren, der sicherstellt, dass die Zugriffe auf data[i] die Grenzen des Arrays nicht überschreiten, aber weil der JIT-Compiler an der Struktur der Schleife erkennen kann, dass ich immer im Bereich sein werde (die Schleife durchläuft jedes Element von Anfang bis Ende), kann der JIT-Compiler die Überprüfung der Grenzen des Arrays optimieren. Der für die Schleife generierte Assemblycode sieht daher wie folgt aus:

G_M33811_IG03:
       movsxd   r9, edx
       add      eax, dword ptr [rcx+4*r9+16]
       inc      edx
       cmp      r8d, edx
       jg       SHORT G_M33811_IG03

Eine cmp-Anweisung ist immer noch in der Schleife vorhanden, die aber einfach zum Vergleichen des Werts von i (wie im edx-Register gespeichert) mit der Länge des Arrays (wie im r8d-Register gespeichert) verwendet wird. Es erfolgt keine zusätzliche Überprüfung der Grenzen.

Die Laufzeit wendet ähnliche Optimierungen für Span an (sowohl Span<T> als auch ReadOnlySpan<T>). Vergleichen Sie das vorherige Beispiel mit dem folgenden Code, bei dem die einzige Änderung für den Parametertyp vorgenommen wird:

static int Sum(Span<int> data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

Die generierte Assembly für diesen Code ist nahezu identisch:

G_M33812_IG03:
       movsxd   r9, r8d
       add      ecx, dword ptr [rax+4*r9]
       inc      r8d
       cmp      r8d, edx
       jl       SHORT G_M33812_IG03

Der Assemblercode ist zum Teil so ähnlich, weil die Überprüfungen der Grenzen entfallen. Ebenfalls relevant ist die Tatsache, dass der JIT-Compiler den Span-Indexer als intrinsisch erkennt, was bedeutet, dass der JIT-Compiler speziellen Code für den Indexer generiert, anstatt seinen eigentlichen IL-Code in eine Assembly zu übersetzen.

All dies soll verdeutlichen, dass die Laufzeit für Span-Elemente dieselben Optimierungen wie für Arrays anwenden kann, was Span-Elemente zu einem effizienten Mechanismus für den Zugriff auf Daten macht. Weitere Details finden Sie im Blogbeitrag unter bit.ly/2zywvyI.

C#-Sprache und -Compiler

Ich habe bereits auf Features angespielt, die der C#-Sprache und dem -Compiler hinzugefügt wurden, um Span<T> zu einem erstklassigen Element in .NET zu machen. Einige Features von C# 7.2 beziehen sich auf Span-Elemente (und tatsächlich wird der C# 7.2-Compiler benötigt, um Span<T> zu verwenden). Schauen wir uns drei dieser Features an.

Ref-Strukturen. Wie bereits erwähnt, ist Span<T> ein ref-ähnlicher Typ, der in C# ab Version 7.2 als ref-Struktur bereitgestellt wird. Indem Sie das ref-Schlüsselwort vor struct platzieren, teilen Sie dem C#-Compiler mit, dass andere ref struct-Typen wie Span<T> als Felder verwendet werden können, und dabei melden Sie sich auch für die zugehörigen Einschränkungen an, die Ihrem Typ zugewiesen werden sollen. Wenn Sie z.B. einen struct-Enumerator für ein Span<T>-Element schreiben möchten, dann müsste dieser Enumerator das Span<T>-Element speichern und somit selbst ein ref struct sein, so wie im folgenden Beispiel gezeigt:

public ref struct Enumerator
{
  private readonly Span<char> _span;
  private int _index;
  ...
}

Stackalloc-Initialisierungen von Span-Elementen. In früheren Versionen von C# konnte das Ergebnis von stackalloc nur in einer lokalen Zeigervariablen gespeichert werden. Ab C# 7.2 kann stackalloc nun als Teil eines Ausdrucks verwendet werden und ein Span-Element als Ziel angeben. Dies kann ohne Verwendung des unsicheren Schlüsselworts geschehen. Anstatt Folgendes zu schreiben:

Span<byte> bytes;
unsafe
{
  byte* tmp = stackalloc byte[length];
  bytes = new Span<byte>(tmp, length);
}

Können Sie einfach diesen Code verwenden:

Span<byte> bytes = stackalloc byte[length];

Dies ist auch sehr hilfreich in Situationen, in denen Sie für die Ausführung eines Vorgangs etwas temporären Speicher benötigen, aber die Zuweisung von Heapspeicher für relativ kleine Größen vermeiden möchten. Zuvor hatten Sie zwei Möglichkeiten:

  • Schreiben von zwei völlig unterschiedlichen Codepfaden, wobei die Zuordnung und die Verarbeitung über stapelbasierten Speicher und heapbasierten Speicher erfolgen.
  • Anheften des Speichers, der mit der verwalteten Zuweisung verbunden ist, und anschließendes Delegieren an eine Implementierung, die auch für den stapelbasierten Speicher verwendet und mit Zeigermanipulation in unsicherem Code geschrieben wird.

Jetzt kann das gleiche Ziel ohne Codeduplizierung, mit sicherem Code und mit minimalem Aufwand erreicht werden:

Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span<byte>

Verwendungsüberprüfung des Span-Elements. Da sich Span-Elemente auf Daten beziehen können, die ggf. einem bestimmten Stapelrahmen zugeordnet sind, kann es gefährlich sein, Span-Elemente so zu übergeben, dass es möglich ist, auf Speicher zu verweisen, der nicht mehr gültig ist. Stellen Sie sich zum Beispiel eine Methode vor, die versucht hat, die folgende Aktion auszuführen:

static Span<char> FormatGuid(Guid guid)
{
  Span<char> chars = stackalloc char[100];
  bool formatted = guid.TryFormat(chars, out int charsWritten, "d");
  Debug.Assert(formatted);
  return chars.Slice(0, charsWritten); // Uh oh
}

Hier wird Speicherplatz vom Stapel zugewiesen und dann versucht, einen Verweis auf diesen Speicherplatz zurückzugeben, aber in dem Moment, in dem die Rückgabe erfolgt, ist dieser Speicherplatz nicht mehr für die Verwendung gültig. Glücklicherweise erkennt der C#-Compiler eine solche ungültige Verwendung mit ref-Strukturen und bricht die Kompilierung mit einem Fehler ab:

Fehler CS8352: In diesem Kontext können keine lokalen "chars" verwendet werden, da sie referenzierte Variablen außerhalb ihres Deklarationsbereichs bereitstellen können.

Ausblick

Die hier besprochenen Typen, Methoden, Laufzeitoptimierungen und anderen Elemente sind auf dem besten Weg, in .NET Core 2.1 integriert zu werden. Danach erwarte ich, dass sie ihren Weg in .NET Framework finden. Die Kerntypen wie Span<T> sowie die neuen Typen wie Utf8Parser sind ebenfalls auf dem besten Weg, in einem System.Memory.dll-Paket zur Verfügung gestellt zu werden, das mit .NET Standard 1.1 kompatibel ist. Dadurch wird die Funktionalität für vorhandene Releases von .NET Framework und .NET Core verfügbar, allerdings ohne einige der Optimierungen, die bei der Integration in die Plattform implementiert wurden. Eine Vorschau dieses Pakets steht Ihnen zum Testen zur Verfügung. Fügen Sie einfach einen Verweis auf das Paket „System.Memory.dll“ von NuGet hinzu.

Sie sollten sich natürlich bewusst wein, dass es zwischen der aktuellen Vorschauversion und dem, was in einer stabilen Version tatsächlich ausgeliefert wird, Änderungen geben kann und wird. Solche Änderungen werden zum großen Teil auf das Feedback von Entwicklern wie Ihnen zurückzuführen sein, wenn Sie mit den Features experimentieren. Also probieren Sie es aus, und behalten Sie die Repositorys github.com/dotnet/coreclr und github.com/dotnet/corefx für die laufende Arbeit im Auge. Dokumentation finden Sie auch unter aka.ms/ref72.

Letztendlich hängt der Erfolg dieser Featuresammlung davon ab, dass Entwickler sie testen, Feedback bereitstellen und ihre eigenen Bibliotheken erstellen, die diese Typen verwenden. All dies mit dem Ziel, effizienten und sicheren Zugriff auf Speicher in modernen .NET-Programmen zu ermöglichen. Wir freuen uns darauf, von Ihren Erfahrungen zu hören, und noch besser, mit Ihnen an GitHub zu arbeiten, um .NET weiter zu verbessern.


Stephen Toub arbeitet bei Microsoft an .NET. Sie finden ihn auf GitHub unter github.com/stephentoub.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Krzysztof Cwalina, Eric Erhardt, Ahson Khan, Jan Kotas, Jared Parsons, Marek Safar, Vladimir Sadov, Joseph Tremoulet, Bill Wagner, Jan Vorlicek, Karel Zikmund


Diesen Artikel im MSDN Magazine-Forum diskutieren