Avec cet article, je continue de publier une série d'articles, dont le résultat sera un livre sur le travail du .NET CLR, et .NET en général (environ 200 pages du livre sont déjà prêtes, alors bienvenue à la fin de l'article pour les liens).
Le langage et la plate-forme existent depuis de nombreuses années: et pendant tout ce temps, il y a eu de nombreux outils pour travailler avec du code non managé. Alors, pourquoi la prochaine API pour travailler avec du code non managé sort-elle si elle existe depuis de très nombreuses années? Pour répondre à cette question, il suffit de comprendre ce qui manquait auparavant.
Les développeurs de la plateforme ont essayé de nous aider à égayer la vie quotidienne du développement en utilisant des ressources non managées: ce sont des wrappers automatiques pour les méthodes importées. Et le triage, qui dans la plupart des cas fonctionne automatiquement. Il s'agit également d'une instruction stackallloc
, qui est abordée dans le chapitre sur la pile de threads. Cependant, pour moi, si les premiers développeurs utilisant C # venaient du monde C ++ (comme je l'ai fait), ils viennent maintenant de langages de niveau supérieur (par exemple, je connais un développeur venu de JavaScript). Qu'est-ce que cela signifie? Cela signifie que les gens se méfient de plus en plus des ressources non gérées et des constructions qui sont similaires dans l'esprit à C / C ++ et encore plus à Assembler.
Remarque
Le chapitre publié sur Habré n'est pas mis à jour et, probablement, est un peu dépassé. Et par conséquent, veuillez vous tourner vers l'original pour un texte plus récent:

Du fait d'une telle attitude, il y a de moins en moins de contenu non sécurisé dans les projets et de plus en plus de confiance dans l'API de la plateforme elle-même. Ceci est facilement vérifié en examinant l'utilisation de la construction stackalloc
dans les référentiels ouverts: elle est négligeable. Mais si vous prenez n'importe quel code qui l'utilise:
Classe Interop.ReadDir
/src/mscorlib/shared/Interop/Unix/System.Native/Interop.ReadDir.cs
unsafe { // s_readBufferSize is zero when the native implementation does not support reading into a buffer. byte* buffer = stackalloc byte[s_readBufferSize]; InternalDirectoryEntry temp; int ret = ReadDirR(dir.DangerousGetHandle(), buffer, s_readBufferSize, out temp); // We copy data into DirectoryEntry to ensure there are no dangling references. outputEntry = ret == 0 ? new DirectoryEntry() { InodeName = GetDirectoryEntryName(temp), InodeType = temp.InodeType } : default(DirectoryEntry); return ret; }
Il devient clair la raison de l'impopularité. Regardez sans lire le code et répondez à une question par vous-même: lui faites-vous confiance? Je peux supposer que la réponse est non. Réponds ensuite à l'autre: pourquoi? La réponse sera évidente: en plus de voir le mot Dangerous
, qui suggère en quelque sorte que quelque chose pourrait mal tourner, le deuxième facteur affectant notre attitude est la ligne byte* buffer = stackalloc byte[s_readBufferSize];
, et plus précisément, byte*
. Ce record est un déclencheur pour n'importe qui, de sorte que la pensée me vient à l’esprit: "quoi, ne pourrait-on pas faire différemment ou quoi?". Alors regardons un peu plus la psychanalyse: pourquoi une telle pensée pourrait-elle surgir? D'une part, nous utilisons des constructions de langage et la syntaxe proposée ici est loin, par exemple, de C ++ / CLI, qui vous permet de faire quoi que ce soit (y compris des insertions sur Assembler pur), et d'autre part, cela semble inhabituel.
Quelle est donc la question? Comment ramener les développeurs au sein du code non managé? Il faut leur donner un sentiment de calme afin qu'ils ne puissent pas se tromper par accident, par ignorance. Alors, pourquoi les Span<T>
et Memory<T>
introduits?
Span [T], ReadOnlySpan [T]
Le type Span
représente une partie d'un certain tableau de données, une sous-plage de ses valeurs. En même temps, permettant, comme dans le cas d'un tableau, de travailler avec des éléments de cette plage à la fois pour l'écriture et pour la lecture. Cependant, pour l'overclocking et la compréhension générale, comparons les types de données pour lesquels une implémentation du type Span
est effectuée et examinons les objectifs possibles de son introduction.
Le premier type de données dont vous souhaitez parler est un tableau standard. Pour les tableaux, travailler avec Span ressemblera à ceci:
var array = new [] {1,2,3,4,5,6}; var span = new Span<int>(array, 1, 3); var position = span.BinarySearch(3); Console.WriteLine(span[position]); // -> 3
Comme nous le voyons dans cet exemple, pour commencer, nous créons un certain tableau de données. Après cela, nous créons un Span
(ou un sous-ensemble), qui, se référant au tableau lui-même, permet à son code d'utiliser uniquement la plage de valeurs spécifiée lors de l'initialisation.
Nous voyons ici la première propriété de ce type de données: elle crée du contexte. Développons notre idée avec des contextes:
void Main() { var array = new [] {'1','2','3','4','5','6'}; var span = new Span<char>(array, 1, 3); if(TryParseInt32(span, out var res)) { Console.WriteLine(res); } else { Console.WriteLine("Failed to parse"); } } public bool TryParseInt32(Span<char> input, out int result) { result = 0; for (int i = 0; i < input.Length; i++) { if(input[i] < '0' || input[i] > '9') return false; result = result * 10 + ((int)input[i] - '0'); } return true; } ----- 234
Comme nous pouvons le voir, Span<T>
introduit une abstraction d'accès à un certain morceau de mémoire, à la fois pour la lecture et l'écriture. Qu'est-ce que cela nous donne? Si nous nous rappelons de quoi d'autre Span
peut être créé sur la base de, nous rappelons à la fois les ressources et les lignes non gérées:
// Managed array var array = new[] { '1', '2', '3', '4', '5', '6' }; var arrSpan = new Span<char>(array, 1, 3); if (TryParseInt32(arrSpan, out var res1)) { Console.WriteLine(res1); } // String var srcString = "123456"; var strSpan = srcString.AsSpan().Slice(1, 3); if (TryParseInt32(strSpan, out var res2)) { Console.WriteLine(res2); } // void * Span<char> buf = stackalloc char[6]; buf[0] = '1'; buf[1] = '2'; buf[2] = '3'; buf[3] = '4'; buf[4] = '5'; buf[5] = '6'; if (TryParseInt32(buf.Slice(1, 3), out var res3)) { Console.WriteLine(res3); } ----- 234 234 234
Autrement dit, il s'avère que Span<T>
est un outil d'unification pour travailler avec la mémoire: géré et non géré, qui garantit la sécurité dans l'utilisation de ce type de données pendant le Garbage Collection: si les zones de mémoire avec des tableaux gérés commencent à se déplacer, alors pour ce sera sans danger pour nous.
Mais vaut-il la peine de se réjouir autant? Tout cela aurait-il pu être réalisé auparavant? Par exemple, si nous parlons de tableaux gérés, il n'y a aucun doute: enveloppez simplement le tableau dans une autre classe, fournissant une interface similaire et vous avez terminé. De plus, une opération similaire peut être effectuée avec des chaînes: elles ont les méthodes nécessaires. Encore une fois, enveloppez simplement la chaîne dans le même type et fournissez des méthodes pour travailler avec. Une autre chose est que pour stocker une chaîne, un tampon ou un tableau dans un type, vous devrez bricoler beaucoup en stockant des liens vers chacune des options possibles dans une seule instance (bien sûr, une seule sera active):
public readonly ref struct OurSpan<T> { private T[] _array; private string _str; private T * _buffer; // ... }
Ou, si vous partez de l'architecture, faites trois types qui héritent d'une seule interface. Il s'avère que pour faire de l'outil une interface unifiée entre ces types de données managed
, tout en maintenant des performances maximales, il n'y a pas d'autre moyen que Span<T>
.
De plus, pour poursuivre la discussion, qu'est-ce qu'une ref struct
en termes de Span
? Ce sont précisément ces «structures, elles ne sont que sur la pile», dont nous entendons si souvent parler dans les interviews. Et cela signifie que ce type de données ne peut passer que par la pile et n'a pas le droit d'aller au tas. Et par conséquent, Span
, étant une structure de référence, est un type de données de contexte qui fournit des méthodes, mais pas des objets en mémoire. De cela, pour sa compréhension, nous devons procéder.
À partir d'ici, nous pouvons formuler une définition du type Span et du type en lecture seule ReadOnlySpan qui lui sont associés:
L'étendue est un type de données qui fournit une interface unique pour travailler avec des types hétérogènes de tableaux de données ainsi que la possibilité de transférer un sous-ensemble de ce tableau vers une autre méthode de sorte que, quelle que soit la profondeur du contexte, la vitesse d'accès au tableau d'origine est constante et aussi élevée que possible.
Et vraiment: si nous avons quelque chose comme ce code:
public void Method1(Span<byte> buffer) { buffer[0] = 0; Method2(buffer.Slice(1,2)); } Method2(Span<byte> buffer) { buffer[0] = 0; Method3(buffer.Slice(1,1)); } Method3(Span<byte> buffer) { buffer[0] = 0; }
alors la vitesse d'accès au tampon source sera la plus élevée possible: vous ne travaillez pas avec un objet géré, mais avec un pointeur géré. C'est-à -dire non pas avec un type géré .NET, mais avec un type non sécurisé enveloppé dans un shell géré.
Span [T] par des exemples
Une personne est ainsi disposée que souvent jusqu'à ce qu'elle reçoive une certaine expérience, alors une compréhension finale des raisons pour lesquelles un outil est nécessaire ne vient souvent pas. Et donc, puisque nous avons besoin d'expérience, passons à des exemples.
ValueStringBuilder
L'un des exemples les plus intéressants du point de vue algorithmique est le type ValueStringBuilder
, qui est enterré quelque part dans les entrailles de mscorlib
et pour une raison quelconque, comme de nombreux autres types de données intéressants, est marqué avec le modificateur internal
, ce qui signifie que si ce n'était pas pour l'étude du code source mscorlib, nous parlerons d'une telle méthode d'optimisation merveilleuse ne saurait jamais.
Quel est le principal inconvénient du type de système StringBuilder? Ceci, bien sûr, est son essence: à la fois lui-même et ce sur quoi il est basé (et ceci est un tableau de caractères char[]
) sont des types de référence. Et cela signifie au moins deux choses: nous continuons (quoique un peu) à charger un tas et la seconde - nous augmentons les chances de manquer des caches de processeur.
Une autre question que j'avais pour StringBuilder était la formation de petites cordes. C'est-à -dire lorsque la ligne de résultat "donner dent" sera courte: par exemple, moins de 100 caractères. Lorsque le formatage est assez court, des problèmes de performances se posent:
$"{x} is in range [{min};{max}]"
À quel point cet enregistrement est-il pire que la génération manuelle via StringBuilder? La réponse est loin d'être toujours évidente: tout dépend du lieu de formation: à quelle fréquence cette méthode sera appelée. Après tout, la première string.Format
alloue de la mémoire au StringBuilder
interne, ce qui créera un tableau de caractères (SourceString.Length + args.Length * 8) et si pendant la formation du tableau, il s'avère que la longueur n'a pas été devinée, alors un autre StringBuilder
sera créé pour former la suite, formant ainsi une liste simplement connectée. Et par conséquent, il sera nécessaire de renvoyer la ligne générée: et ceci est une autre copie. Le gaspillage et le gaspillage. Maintenant, si nous pouvions nous débarrasser de placer le premier tableau de la chaîne en cours de formation sur le tas, ce serait merveilleux: nous nous débarrasserions certainement d'un problème.
Jetez un oeil au type des entrailles de mscorlib
:
Classe ValueStringBuilder
/ src / mscorlib / shared / System / Text / ValueStringBuilder
internal ref struct ValueStringBuilder { // private char[] _arrayToReturnToPool; // private Span<char> _chars; private int _pos; // , public ValueStringBuilder(Span<char> initialBuffer) { _arrayToReturnToPool = null; _chars = initialBuffer; _pos = 0; } public int Length { get => _pos; set { int delta = value - _pos; if (delta > 0) { Append('\0', delta); } else { _pos = value; } } } // - public override string ToString() { var s = new string(_chars.Slice(0, _pos)); Clear(); return s; } // // : public void Insert(int index, char value, int count) { if (_pos > _chars.Length - count) { Grow(count); } int remaining = _pos - index; _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); _chars.Slice(index, count).Fill(value); _pos += count; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Append(char c) { int pos = _pos; if (pos < _chars.Length) { _chars[pos] = c; _pos = pos + 1; } else { GrowAndAppend(c); } } [MethodImpl(MethodImplOptions.NoInlining)] private void GrowAndAppend(char c) { Grow(1); Append(c); } // , // // // [MethodImpl(MethodImplOptions.NoInlining)] private void Grow(int requiredAdditionalCapacity) { Debug.Assert(requiredAdditionalCapacity > _chars.Length - _pos); char[] poolArray = ArrayPool<char>.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); _chars.CopyTo(poolArray); char[] toReturn = _arrayToReturnToPool; _chars = _arrayToReturnToPool = poolArray; if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Clear() { char[] toReturn = _arrayToReturnToPool; this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } // : private void AppendSlow(string s); public bool TryCopyTo(Span<char> destination, out int charsWritten); public void Append(string s); public void Append(char c, int count); public unsafe void Append(char* value, int length); public Span<char> AppendSpan(int length); }
Cette classe est similaire en fonctionnalités à son frère aîné StringBuilder
, mais elle a une caractéristique intéressante et très importante: c'est un type significatif. C'est-à -dire stocké et transmis entièrement par valeur. Et le dernier modificateur de type ref
, qui est affecté à la signature de la déclaration de type, nous dit que ce type a une restriction supplémentaire: il a le droit d'être sur la pile uniquement. C'est-à -dire la sortie de ses instances dans les champs de classe entraînera une erreur. Pourquoi tous ces squats? Pour répondre à cette question, regardez simplement la classe StringBuilder
, dont nous venons de décrire l'essence:
Classe StringBuilder /src/mscorlib/src/System/Text/StringBuilder.cs
public sealed class StringBuilder : ISerializable { // A StringBuilder is internally represented as a linked list of blocks each of which holds // a chunk of the string. It turns out string as a whole can also be represented as just a chunk, // so that is what we do. internal char[] m_ChunkChars; // The characters in this block internal StringBuilder m_ChunkPrevious; // Link to the block logically before this block internal int m_ChunkLength; // The index in m_ChunkChars that represent the end of the block internal int m_ChunkOffset; // The logical offset (sum of all characters in previous blocks) internal int m_MaxCapacity = 0; // ... internal const int DefaultCapacity = 16;
StringBuilder est une classe à l'intérieur de laquelle se trouve un lien vers un tableau de caractères. C'est-à -dire lorsque vous le créez, en fait, au moins deux objets sont créés: le StringBuilder lui-même et un tableau de caractères d'au moins 16 caractères (d'ailleurs, c'est pourquoi il est si important de spécifier la longueur estimée de la chaîne: sa construction passera par la génération d'une liste de tableaux à 16 caractères connectés individuellement. ) Qu'est-ce que cela signifie dans le contexte de notre conversation sur le type ValueStringBuilder: la capacité est absente par défaut, car il prend de la mémoire de l'extérieur, plus il est lui-même un type significatif et oblige l'utilisateur à allouer un tampon pour les caractères de la pile. Par conséquent, l'instance de type entière est poussée sur la pile avec son contenu, et le problème d'optimisation ici est résolu. Pas d'allocation de mémoire sur le tas? Pas de problème avec l'affaissement des performances sur le tas. Mais vous me dites: pourquoi ne pas toujours utiliser ValueStringBuilder (ou sa version auto-écrite: est-ce interne et qui ne nous est pas accessible)? La réponse est: vous devez regarder le problème que vous résolvez. La chaîne résultante aura-t-elle une taille connue? Aura-t-il un certain maximum connu en longueur? Si la réponse est oui et si la taille de la chaîne ne dépasse pas certaines limites raisonnables, vous pouvez utiliser une version significative de StringBuilder. Sinon, si nous nous attendons à de longues lignes, nous passons à l'utilisation de la version régulière.
ValueListBuilder
Le deuxième type de données que je veux particulièrement noter est le type ValueListBuilder
. Il a été créé pour les situations où il est nécessaire de créer une collection d'éléments pendant une courte période et de le donner immédiatement à un algorithme de traitement.
D'accord: la tâche est très similaire à la tâche ValueStringBuilder
. Oui, et cela a été résolu de manière très similaire:
Fichier ValueListBuilder.cs
Pour le dire franchement, de telles situations sont assez courantes. Cependant, avant de résoudre cette question d'une autre manière: nous avons créé une List
, l'avons remplie de données et perdu le lien. Si la méthode est appelée assez souvent, une triste situation se produit: de nombreuses instances de la classe List
suspendues au tas et, avec elles, les tableaux qui leur sont associés sont suspendus au tas. Ce problème est maintenant résolu: aucun objet supplémentaire ne sera créé. Cependant, comme dans le cas de ValueStringBuilder
, cela n'a été résolu que pour les programmeurs Microsoft: la classe a un modificateur internal
.
Termes et conditions d'utilisation
Afin de comprendre enfin l'essence du nouveau type de données, vous devez «jouer avec» en écrivant quelques choses, ou mieux, plus de méthodes en l'utilisant. Cependant, les règles de base peuvent être apprises maintenant:
- Si votre méthode traite un ensemble de données entrantes sans modifier sa taille, vous pouvez essayer de vous arrêter au type
Span
. S'il n'y a pas de modification de ce tampon, alors sur le type ReadOnlySpan
; - Si votre méthode fonctionne avec des chaînes, en calculant des statistiques ou en analysant une chaîne, votre méthode doit accepter
ReadOnlySpan<char>
. C'est obligatoire: c'est une nouvelle règle. Après tout, si vous acceptez une chaîne, vous forcez ainsi quelqu'un à créer une sous-chaîne pour vous - Si vous devez créer un tableau de données assez court (par exemple, 10 Ko maximum) dans le cadre du travail de la méthode, vous pouvez facilement organiser un tel tableau à l'aide de
Span<TType> buf = stackalloc TType[size]
. Cependant, bien sûr, TType ne devrait être qu'un type significatif, car stackalloc
ne fonctionne qu'avec des types significatifs.
Dans d'autres cas, il vaut la peine d'examiner de plus près la Memory
ou d'utiliser des types de données classiques.
Fonctionnement de Span
De plus, je voudrais parler du fonctionnement de Span et de ce qui est si remarquable à ce sujet. Et il y a quelque chose à dire: le type de données lui-même est divisé en deux versions: pour .NET Core 2.0+ et pour tout le monde.
Span.Fast.cs, fichier .NET Core 2.0
public readonly ref partial struct Span<T> { /// .NET internal readonly ByReference<T> _pointer; /// private readonly int _length; // ... }
Fichier ??? [décompilé]
public ref readonly struct Span<T> { private readonly System.Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; // ... }
Le fait est que le grand .NET Framework et .NET Core 1. * n'ont pas de garbage collector spécialement modifié (contrairement à la version de .NET Core 2.0+) et sont donc obligés de faire glisser un pointeur supplémentaire: au début du tampon avec lequel travailler. Autrement dit, il s'avère que Span
fonctionne en interne avec les objets gérés de la plateforme .NET comme non gérés. Jetez un œil à l'intérieur de la deuxième version de la structure: il y a trois champs. Le premier champ est une référence à l'objet géré. Le second est le décalage par rapport au début de cet objet en octets pour obtenir le début du tampon de données (en lignes c'est un tampon avec des caractères char
, en tableaux c'est un tampon avec des données de tableau). Et enfin, le troisième champ est le nombre d'éléments de ce tampon empilés les uns après les autres.
Par exemple, prenez le travail Span
pour les chaînes:
Fichier coreclr :: src / System.Private.CoreLib / shared / System / MemoryExtensions.Fast.cs
public static ReadOnlySpan<char> AsSpan(this string text) { if (text == null) return default; return new ReadOnlySpan<char>(ref text.GetRawStringData(), text.Length); }
OĂą string.GetRawStringData()
le suivant:
Fichier de définition de champ coreclr :: src / System.Private.CoreLib / src / System / String.CoreCLR.cs
Fichier de définition GetRawStringData coreclr :: src / System.Private.CoreLib / shared / System / String.cs
public sealed partial class String : IComparable, IEnumerable, IConvertible, IEnumerable<char>, IComparable<string>, IEquatable<string>, ICloneable { // // These fields map directly onto the fields in an EE StringObject. See object.h for the layout. // [NonSerialized] private int _stringLength; // For empty strings, this will be '\0' since // strings are both null-terminated and length prefixed [NonSerialized] private char _firstChar; internal ref char GetRawStringData() => ref _firstChar; }
C'est-à -dire il s'avère que la méthode va directement à l'intérieur de la ligne, et la spécification ref char
vous permet de suivre le lien GC non géré à l'intérieur de la ligne, en le déplaçant avec la ligne pendant l'opération GC.
La mĂŞme histoire se produit avec les tableaux: lorsque Span
est créé, du code à l'intérieur du JIT calcule le décalage du début des données du tableau et initialise le Span
ce décalage. Et comment calculer les décalages pour les chaînes et les tableaux, nous avons appris dans le chapitre sur la structure des objets en mémoire.
Span [T] comme valeur de retour
, Span
, , . :
unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = new byte[100]; return reff; }
. , :
unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = stackalloc byte[100]; return reff; }
. , , , .
, , , , . , . , , , x[0.99] .
, , , , : CS8352 Cannot use local 'reff' in this context because it may expose referenced variables outside of their declaration scope
: , , , .
Span<T>
, . , use cases .
Lien vers le livre entier
