Com este artigo, continuo publicando uma série de artigos, cujo resultado será um livro sobre o trabalho do .NET CLR e .NET em geral (cerca de 200 páginas do livro já estão prontas, então seja bem-vindo ao final do artigo para obter links).
Tanto o idioma quanto a plataforma existem há muitos anos: e todo esse tempo existem muitas ferramentas para trabalhar com código não gerenciado. Então, por que agora é lançada a próxima API para trabalhar com código não gerenciado se, de fato, existe há muitos e muitos anos? Para responder a essa pergunta, basta entender o que estava faltando antes.
Os desenvolvedores da plataforma tentaram nos ajudar a melhorar a vida cotidiana do desenvolvimento usando recursos não gerenciados: são wrappers automáticos para métodos importados. E marshalling, que na maioria dos casos funciona automaticamente. Essa também é uma instrução stackallloc
, discutida no capítulo sobre a pilha de threads. No entanto, quanto a mim, se os primeiros desenvolvedores que usam C # vieram do mundo C ++ (como eu fiz), agora eles vêm de linguagens de nível superior (por exemplo, eu conheço um desenvolvedor que veio do JavaScript). O que isso significa? Isso significa que as pessoas suspeitam cada vez mais de recursos e construções não gerenciados que são similares em espírito ao C / C ++ e ainda mais ao Assembler.
Nota
O capítulo publicado em Habré não é atualizado e, provavelmente, já está um pouco desatualizado. E, portanto, consulte o original para obter textos mais recentes:

Como resultado dessa atitude, há cada vez menos conteúdo de código inseguro nos projetos e mais e mais confiança na API da própria plataforma. Isso é facilmente verificado observando o uso da construção stackalloc
repositórios abertos: é insignificante. Mas se você pegar qualquer código que o utilize:
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; }
Torna-se claro o motivo da impopularidade. Olhe sem ler o código e responda a uma pergunta: você confia nele? Eu posso assumir que a resposta é não. Então responda ao outro: por quê? A resposta será óbvia: além de ver a palavra Dangerous
, que de alguma forma sugere que algo pode dar errado, o segundo fator que afeta nossa atitude é a linha byte* buffer = stackalloc byte[s_readBufferSize];
e, mais especificamente, byte*
. Esse registro é um gatilho para qualquer pessoa, de modo que o pensamento surge na minha cabeça: "o que não poderia ser feito de maneira diferente ou o quê?" Então, vejamos um pouco mais a psicanálise: por que esse pensamento pode surgir? Por um lado, usamos construções de linguagem e a sintaxe proposta aqui está longe de, por exemplo, C ++ / CLI, que permite fazer qualquer coisa (incluindo inserções no Assembler puro) e, por outro lado, parece incomum.
Então qual é a questão? Como retornar os desenvolvedores de volta ao seio do código não gerenciado? É necessário dar-lhes uma sensação de calma de que não podem cometer um erro por acidente, por ignorância. Então, por que os Span<T>
e Memory<T>
introduzidos?
Extensão [T], ReadOnlySpan [T]
O tipo Span
representa uma parte de uma determinada matriz de dados, um sub-intervalo de seus valores. Ao mesmo tempo, permitindo, como no caso de uma matriz, trabalhar com elementos desse intervalo, tanto para escrever quanto para ler. No entanto, para overclock e entendimento geral, vamos comparar os tipos de dados para os quais é feita uma implementação do tipo Span
e examinar os possíveis propósitos de sua introdução.
O primeiro tipo de dados sobre o qual você deseja falar é uma matriz regular. Para matrizes, trabalhar com o Span ficará assim:
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
Como vemos neste exemplo, para começar, criamos uma certa matriz de dados. Depois disso, criamos um Span
(ou um subconjunto), que, referindo-se à própria matriz, permite que seu código use apenas o intervalo de valores que foi especificado durante a inicialização.
Aqui vemos a primeira propriedade desse tipo de dados: ele cria algum contexto. Vamos desenvolver nossa ideia com contextos:
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
Como podemos ver, o Span<T>
introduz uma abstração de acesso a um determinado pedaço de memória, tanto para leitura quanto para escrita. O que isso nos dá? Se recordarmos o que mais Span
pode ser feito com base, recordaremos recursos e linhas não gerenciados:
// 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
Ou seja, verifica-se que o Span<T>
é uma ferramenta de unificação para trabalhar com memória: gerenciada e não gerenciada, que garante segurança ao trabalhar com esse tipo de dados durante a coleta de lixo: se as áreas de memória com matrizes gerenciadas começarem a se mover, então para será seguro para nós.
No entanto, vale a pena se alegrar tanto? Tudo isso poderia ter sido alcançado antes? Por exemplo, se falamos de matrizes gerenciadas, não há dúvida: envolva a matriz em outra classe, fornecendo uma interface semelhante e pronto. Além disso, uma operação semelhante pode ser feita com strings: eles têm os métodos necessários. Novamente, basta agrupar a string exatamente do mesmo tipo e fornecer métodos para trabalhar com ela. Outra coisa é que, para armazenar uma string, um buffer ou uma matriz em um tipo, você precisará mexer muito armazenando links para cada uma das opções possíveis em uma única cópia (é claro, apenas uma estará ativa):
public readonly ref struct OurSpan<T> { private T[] _array; private string _str; private T * _buffer; // ... }
Ou, se você iniciar da arquitetura, execute três tipos que herdam uma única interface. Acontece que, para tornar a ferramenta uma interface unificada entre esses tipos de dados managed
, mantendo o desempenho máximo, não há outra maneira senão Span<T>
.
Além disso, para continuar a discussão, o que é uma ref struct
em termos de Span
? Essas são precisamente as próprias "estruturas, elas estão apenas na pilha", das quais ouvimos muitas vezes em entrevistas. E isso significa que esse tipo de dados só pode percorrer a pilha e não tem o direito de ir para a pilha. E, portanto, Span
, sendo uma estrutura ref, é um tipo de dados de contexto que fornece métodos, mas não objetos na memória. Disto, para sua compreensão, devemos prosseguir.
A partir daqui, podemos formular uma definição do tipo Span e do tipo readonly ReadOnlySpan associado a ele:
Span é um tipo de dados que fornece uma interface única para trabalhar com tipos heterogêneos de matrizes de dados, bem como a capacidade de transferir um subconjunto dessa matriz para outro método, para que, independentemente da profundidade do contexto, a velocidade de acesso à matriz original seja constante e a mais alta possível.
E realmente: se tivermos algo como este código:
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; }
a velocidade de acesso ao buffer de origem será a mais alta possível: você não está trabalhando com um objeto gerenciado, mas com um ponteiro gerenciado. I.e. não com um tipo gerenciado .NET, mas com um tipo inseguro envolvido em um shell gerenciado.
Span [T] por exemplos
Uma pessoa é tão organizada que, muitas vezes, até receber uma certa experiência, não chega uma compreensão final do motivo pelo qual uma ferramenta é necessária. E, portanto, como precisamos de alguma experiência, vejamos exemplos.
ValueStringBuilder
Um dos exemplos mais interessantes em termos de algoritmo é o tipo ValueStringBuilder
, que está enterrado em algum lugar nas entranhas do mscorlib
e, por algum motivo, como muitos outros tipos de dados interessantes, é marcado com o modificador internal
, o que significa que, se não fosse o estudo do código fonte do mscorlib, falaremos sobre um método de otimização maravilhoso nunca saberia.
Qual é o principal negativo do tipo de sistema StringBuilder? Isso, é claro, é sua essência: tanto ele quanto o que ele é baseado (e esse é um conjunto de char[]
) são tipos de referência. E isso significa pelo menos duas coisas: ainda (embora um pouco) carregamos um monte e a segunda - aumentamos a chance de erros nos caches do processador.
Outra pergunta que eu tinha para o StringBuilder era a formação de pequenas strings. I.e. quando a linha de resultado "dê dente" for curta: por exemplo, menos de 100 caracteres. Quando temos uma formatação bastante curta, surgem problemas de desempenho:
$"{x} is in range [{min};{max}]"
Quão pior é esse registro do que a geração manual via StringBuilder? A resposta está longe de ser sempre óbvia: tudo depende do local de formação: com que frequência esse método será chamado. Afinal, a primeira string.Format
aloca memória para o StringBuilder
interno, que criará uma matriz de caracteres (SourceString.Length + args.Length * 8) e se durante a formação da matriz ocorrer que o comprimento não foi adivinhado, outro StringBuilder
será criado para formar a continuação, formando assim uma lista simplesmente conectada. E, como resultado, será necessário retornar a linha gerada: e esta é outra cópia. Desperdiçar e desperdiçar. Agora, se pudéssemos nos livrar de colocar a primeira matriz da string sendo formada no heap, seria maravilhoso: definitivamente nos livraríamos de um problema.
Dê uma olhada no tipo das entranhas do 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); }
Essa classe possui funcionalidade semelhante ao seu irmão mais velho, StringBuilder
, mas possui um recurso interessante e muito importante: é um tipo significativo. I.e. armazenado e transmitido inteiramente por valor. E o mais recente modificador de tipo ref
, atribuído à assinatura da declaração de tipo, nos diz que esse tipo tem uma restrição adicional: ele tem o direito de estar apenas na pilha. I.e. a saída de suas instâncias para os campos da classe resultará em um erro. Por que todos esses agachamentos? Para responder a essa pergunta, basta olhar para a classe StringBuilder
, cuja essência acabamos de descrever:
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 é uma classe dentro da qual há um link para uma matriz de caracteres. I.e. quando você o cria, de fato, pelo menos dois objetos são criados: o próprio StringBuilder e uma matriz de caracteres de pelo menos 16 caracteres (a propósito, é por isso que é tão importante especificar o comprimento estimado da string: sua construção passará pela geração de uma lista de matrizes de 16 caracteres conectada individualmente. ) O que isso significa no contexto de nossa conversa sobre o tipo ValueStringBuilder: a capacidade está ausente por padrão, porque leva memória de fora, além de ser um tipo significativo e força o usuário a alocar um buffer para caracteres na pilha. Como resultado, toda a instância de tipo é colocada na pilha junto com seu conteúdo e o problema de otimização aqui é resolvido. Não há alocação de memória na pilha? Não há problema com a queda do desempenho na pilha. Mas você me diz: por que não usar ValueStringBuilder (ou sua versão auto-escrita: é interna e não é acessível para nós) sempre? A resposta é: você precisa analisar o problema que está solucionando. A cadeia resultante terá tamanho conhecido? Terá um certo comprimento máximo conhecido? Se a resposta for sim e se o tamanho da string não exceder alguns limites razoáveis, você poderá usar uma versão significativa do StringBuilder. Caso contrário, se esperamos longas filas, passamos a usar a versão regular.
ValueListBuilder
O segundo tipo de dados que quero observar especialmente é o tipo ValueListBuilder
. Foi criado para situações em que é necessário criar uma coleção de elementos por um curto período de tempo e atribuí-la imediatamente a algum algoritmo para processamento.
Concordo: a tarefa é muito semelhante à tarefa ValueStringBuilder
. Sim, e foi resolvido de uma maneira muito semelhante:
Arquivo ValueListBuilder.cs
Para ser franco, tais situações são bastante comuns. No entanto, antes de resolvermos essa questão de outra maneira: criamos uma List
, a preenchemos com dados e perdemos o link. Se o método é chamado com bastante frequência, surge uma situação triste: muitas instâncias da classe List
ficam penduradas no heap e, com elas, as matrizes associadas a eles ficam no heap. Agora esse problema foi resolvido: nenhum objeto adicional será criado. No entanto, como no caso do ValueStringBuilder
, ele foi resolvido apenas para programadores da Microsoft: a classe possui um modificador internal
.
Termos e condições de uso
Para finalmente entender a essência do novo tipo de dados, você precisa "brincar" com ele escrevendo algumas coisas, ou melhor, mais métodos para usá-lo. No entanto, as regras básicas podem ser aprendidas agora:
- Se o seu método processará alguns conjuntos de dados recebidos sem alterar seu tamanho, tente parar no tipo
Span
. Se não houver modificação nesse buffer, no tipo ReadOnlySpan
; - Se seu método funcionar com strings, calculando algumas estatísticas ou analisando uma string, seu método deverá aceitar
ReadOnlySpan<char>
. É obrigado: esta é uma nova regra. Afinal, se você aceitar uma string, forçará alguém a criar uma substring para você - Se você precisar criar uma matriz bastante curta com dados (digamos, 10 KB no máximo) como parte do trabalho do método, poderá organizá-la facilmente usando
Span<TType> buf = stackalloc TType[size]
. No entanto, é claro, o TType deve ser apenas um tipo significativo, pois stackalloc
funciona apenas com tipos significativos.
Em outros casos, vale a pena examinar mais de perto a Memory
ou usar tipos de dados clássicos.
Como o Span funciona
Além disso, gostaria de falar sobre como o Span funciona e o que há de mais notável nisso. E há algo a se falar: o tipo de dados em si é dividido em duas versões: para .NET Core 2.0+ e para todos os outros.
Span.Fast.cs, arquivo do .NET Core 2.0
public readonly ref partial struct Span<T> { /// .NET internal readonly ByReference<T> _pointer; /// private readonly int _length; // ... }
Arquivo ??? [descompilado]
public ref readonly struct Span<T> { private readonly System.Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; // ... }
O problema é que o .NET Framework grande e o .NET Core 1. * não possuem um coletor de lixo especialmente modificado (diferente da versão do .NET Core 2.0+) e, portanto, são forçados a arrastar um ponteiro adicional: para o início do buffer com o qual trabalho Ou seja, o Span
trabalha internamente com objetos gerenciados da plataforma .NET como não gerenciados. Dê uma olhada no interior da segunda versão da estrutura: existem três campos. O primeiro campo é uma referência ao objeto gerenciado. O segundo é o deslocamento do início deste objeto em bytes para obter o início do buffer de dados (em linhas, é um buffer com caracteres char
, em matrizes, é um buffer com dados do array). E, finalmente, o terceiro campo é o número de elementos desse buffer empilhados um após o outro.
Por exemplo, pegue o trabalho Span
para seqüências de caracteres:
Arquivo 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); }
Onde string.GetRawStringData()
o seguinte:
Arquivo de definição de campo coreclr :: src / System.Private.CoreLib / src / System / String.CoreCLR.cs
Arquivo de definição de 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; }
I.e. acontece que o método vai diretamente para dentro da linha, e a especificação ref char
permite rastrear o link não gerenciado do GC dentro da linha, movendo-o junto com a linha durante a operação do GC.
A mesma história acontece com matrizes: quando Span
é criado, algum código dentro do JIT calcula o deslocamento do início dos dados da matriz e inicializa o Span
esse deslocamento. E como calcular deslocamentos para seqüências de caracteres e matrizes, aprendemos no capítulo sobre a estrutura dos objetos na memória.
Span [T] como valor de retorno
, 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 .
Link para o livro inteiro
