Dengan artikel ini, saya terus menerbitkan serangkaian artikel, yang hasilnya akan menjadi buku tentang karya .NET CLR, dan .NET secara umum (sekitar 200 halaman buku sudah siap, jadi selamat datang di akhir artikel untuk tautan).
Baik bahasa dan platform telah ada selama bertahun-tahun: dan selama ini ada banyak alat untuk bekerja dengan kode yang tidak dikelola. Jadi mengapa sekarang API berikutnya untuk bekerja dengan kode yang tidak terkelola keluar jika sebenarnya sudah ada selama bertahun-tahun? Untuk menjawab pertanyaan ini, cukup memahami apa yang hilang sebelumnya.
Pengembang platform telah mencoba membantu kami mencerahkan kehidupan sehari-hari pembangunan dengan menggunakan sumber daya yang tidak dikelola: ini adalah pembungkus otomatis untuk metode yang diimpor. Dan marshalling, yang dalam banyak kasus berfungsi secara otomatis. Ini juga merupakan instruksi stackallloc
, yang dibahas dalam bab tentang tumpukan thread. Namun, bagi saya, jika pengembang awal yang menggunakan C # berasal dari dunia C ++ (seperti yang saya lakukan), sekarang mereka berasal dari bahasa tingkat yang lebih tinggi (misalnya, saya tahu pengembang yang berasal dari JavaScript). Apa artinya ini? Ini berarti bahwa orang semakin curiga terhadap sumber daya dan konstruksi yang tidak dikelola yang serupa semangatnya dengan C / C ++ dan bahkan lebih lagi bagi Assembler.
Catatan
Bab yang diterbitkan di Habré tidak diperbarui dan, mungkin, sudah agak ketinggalan zaman. Dan karena itu, silakan buka teks asli untuk teks yang lebih baru:

Sebagai hasil dari sikap seperti itu, semakin sedikit konten kode tidak aman dalam proyek dan semakin percaya pada API platform itu sendiri. Ini mudah diverifikasi dengan melihat penggunaan konstruksi stackalloc
di seluruh repositori terbuka: ia dapat diabaikan. Tetapi jika Anda mengambil kode apa pun yang menggunakannya:
Kelas 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; }
Ini menjadi alasan yang jelas untuk ketidakpopuleran. Lihatlah tanpa membaca kode dan jawab satu pertanyaan untuk diri sendiri: apakah Anda mempercayainya? Saya dapat berasumsi bahwa jawabannya adalah tidak. Lalu jawab yang lain: mengapa? Jawabannya akan jelas: selain melihat kata Dangerous
, yang entah bagaimana mengisyaratkan bahwa ada sesuatu yang salah, faktor kedua yang mempengaruhi sikap kita adalah baris byte* buffer = stackalloc byte[s_readBufferSize];
, dan lebih khusus lagi, byte*
. Catatan ini adalah pemicu bagi siapa saja sehingga pikiran itu muncul di kepala saya: "apa, tidak bisa dilakukan secara berbeda atau apa?". Kalau begitu mari kita lihat sedikit lebih jauh psikoanalisis: mengapa pemikiran seperti itu muncul? Di satu sisi, kami menggunakan konstruksi bahasa dan sintaks yang diusulkan di sini jauh dari, misalnya, C ++ / CLI, yang memungkinkan Anda untuk melakukan apa saja (termasuk menyisipkan pada Assembler murni), dan di sisi lain, itu terlihat tidak biasa.
Jadi apa pertanyaannya? Bagaimana cara mengembalikan pengembang ke pangkuan kode yang tidak dikelola? Penting untuk memberi mereka perasaan tenang bahwa mereka tidak dapat membuat kesalahan secara tidak sengaja, karena ketidaktahuan. Jadi, mengapa Span<T>
dan Memory<T>
diperkenalkan?
Rentang [T], BacaHanyapan [T]
Tipe Span
mewakili bagian dari array data tertentu, sub-rentang nilainya. Pada saat yang sama, memungkinkan, seperti halnya array, untuk bekerja dengan elemen-elemen dari rentang ini baik untuk menulis maupun membaca. Namun, untuk overclocking dan pemahaman umum, mari kita bandingkan tipe data yang menjadi implementasi dari tipe Span
dan lihat kemungkinan tujuan pengenalannya.
Jenis data pertama yang ingin Anda bicarakan adalah array reguler. Untuk array, bekerja dengan Span akan terlihat seperti ini:
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
Seperti yang kita lihat dalam contoh ini, untuk permulaan kita membuat array data tertentu. Setelah itu, kami membuat Span
(atau subset), yang, merujuk pada array itu sendiri, memungkinkan kodenya untuk hanya menggunakan rentang nilai yang ditentukan selama inisialisasi.
Di sini kita melihat properti pertama dari tipe data ini: ini menciptakan beberapa konteks. Mari kembangkan ide kita dengan konteks:
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
Seperti yang bisa kita lihat, Span<T>
memperkenalkan abstraksi akses ke bagian memori tertentu, baik untuk membaca maupun menulis. Apa yang ini berikan pada kita? Jika kita mengingat apa lagi yang dapat dibuat berdasarkan Span
, maka kita mengingat sumber daya dan jalur yang tidak dikelola:
// 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
Yaitu, ternyata Span<T>
adalah alat unifikasi untuk bekerja dengan memori: terkelola dan tidak terkelola, yang menjamin keamanan dalam bekerja dengan data semacam ini selama Pengumpulan Sampah: jika area memori dengan array yang dikelola mulai bergerak, maka untuk itu akan aman bagi kita.
Namun, apakah pantas untuk bersukacita begitu banyak? Mungkinkah semua ini telah dicapai sebelumnya? Sebagai contoh, jika kita berbicara tentang array yang dikelola, maka tidak ada keraguan sama sekali: cukup bungkus array di kelas lain, menyediakan antarmuka yang sama dan Anda selesai. Selain itu, operasi serupa dapat dilakukan dengan string: mereka memiliki metode yang diperlukan. Sekali lagi, cukup bungkus string dengan tipe yang persis sama dan berikan metode untuk bekerja dengannya. Hal lain adalah bahwa untuk menyimpan string, buffer atau array dalam satu jenis, Anda harus mengotak-atik banyak dengan menyimpan tautan ke masing-masing opsi yang mungkin dalam satu salinan (tentu saja, hanya satu yang akan aktif):
public readonly ref struct OurSpan<T> { private T[] _array; private string _str; private T * _buffer; // ... }
Atau, jika Anda memulai dari arsitektur, maka lakukan tiga jenis yang mewarisi satu antarmuka. Ternyata untuk membuat alat antarmuka terpadu antara tipe data ini managed
, sambil mempertahankan kinerja maksimum, tidak ada cara lain selain Span<T>
.
Selanjutnya, untuk melanjutkan diskusi, apa yang dimaksud dengan ref struct
dalam hal Span
? Ini justru "struktur, mereka hanya di tumpukan," yang sering kita dengar dalam wawancara. Dan ini berarti tipe data ini hanya bisa melalui tumpukan dan tidak memiliki hak untuk pergi ke tumpukan. Dan oleh karena itu, Span
, menjadi struktur referensi, adalah tipe data konteks yang menyediakan metode, tetapi bukan objek dalam memori. Dari sini, untuk pengertiannya, kita harus melanjutkan.
Dari sini kita dapat merumuskan definisi tipe Span dan tipe ReadonlySpan yang hanya baca yang terkait dengannya:
Span adalah tipe data yang menyediakan antarmuka tunggal untuk bekerja dengan tipe array data yang heterogen serta kemampuan untuk mentransfer subset dari array ini ke metode lain sehingga, terlepas dari kedalaman konteks, kecepatan akses ke array asli konstan dan setinggi mungkin.
Dan sungguh: jika kita memiliki sesuatu seperti kode ini:
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; }
maka kecepatan akses ke buffer sumber akan setinggi mungkin: Anda bekerja bukan dengan objek yang dikelola, tetapi dengan pointer yang dikelola. Yaitu bukan dengan tipe .NET yang dikelola, tetapi dengan tipe yang tidak aman yang dibungkus dengan shell yang dikelola.
Rentang [T] dengan contoh
Seseorang begitu teratur sehingga seringkali sampai ia menerima pengalaman tertentu, maka pemahaman akhir tentang mengapa suatu alat dibutuhkan sering tidak datang. Karena itu, karena kita perlu pengalaman, mari kita beralih ke contoh.
ValueStringBuilder
Salah satu contoh yang paling menarik secara algoritmik adalah tipe ValueStringBuilder
, yang dikubur di suatu tempat di perut mscorlib
dan untuk beberapa alasan, seperti banyak tipe data menarik lainnya, ditandai dengan pengubah internal
, yang berarti bahwa jika bukan untuk studi kode sumber mscorlib, kita akan berbicara tentang metode optimasi yang luar biasa, tidak akan pernah tahu.
Apa minus utama dari tipe sistem StringBuilder? Ini, tentu saja, adalah esensinya: dia sendiri dan berdasarkan apa dia (dan ini adalah array char[]
) adalah tipe referensi. Dan itu berarti setidaknya dua hal: kita masih (walaupun sedikit) memuat banyak dan yang kedua - kita meningkatkan kemungkinan kehilangan cache prosesor.
Pertanyaan lain yang saya miliki untuk StringBuilder adalah pembentukan string kecil. Yaitu ketika baris hasil "memberi gigi" akan pendek: misalnya, kurang dari 100 karakter. Ketika kami memiliki pemformatan yang cukup singkat, masalah kinerja muncul:
$"{x} is in range [{min};{max}]"
Seberapa parahkah catatan ini daripada pembuatan manual melalui StringBuilder? Jawabannya jauh dari selalu jelas: semuanya tergantung pada tempat pembentukan: seberapa sering metode ini akan dipanggil. Setelah semua, string.Format
pertama.Format mengalokasikan memori untuk StringBuilder
internal, yang akan membuat array karakter (SourceString.Length + args.Length * 8) dan jika selama pembentukan array ternyata panjangnya tidak ditebak, maka StringBuilder
lain akan dibuat untuk membentuk kelanjutan, dengan demikian membentuk daftar yang hanya terhubung. Dan sebagai hasilnya, akan diperlukan untuk mengembalikan baris yang dihasilkan: dan ini adalah salinan lain. Menyia-nyiakan dan menyia-nyiakan. Sekarang, jika kita dapat menyingkirkan penempatan array pertama dari string yang dibentuk pada heap, itu akan luar biasa: kita pasti akan menyingkirkan satu masalah.
Lihatlah jenis dari usus mscorlib
:
Kelas 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); }
Kelas ini memiliki fungsionalitas yang mirip dengan kakaknya, StringBuilder
, tetapi memiliki satu fitur yang menarik dan sangat penting: ini adalah tipe yang signifikan. Yaitu disimpan dan dikirim seluruhnya berdasarkan nilai. Dan pengubah tipe ref
terbaru, yang ditugaskan untuk tanda tangan dari deklarasi tipe, memberi tahu kita bahwa tipe ini memiliki batasan tambahan: ia memiliki hak untuk berada di stack saja. Yaitu output instansnya ke bidang kelas akan menghasilkan kesalahan. Kenapa semua squat ini? Untuk menjawab pertanyaan ini, lihat saja kelas StringBuilder
, esensi yang baru saja kami jelaskan:
Class 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 adalah kelas di dalamnya yang memiliki tautan ke berbagai karakter. Yaitu ketika Anda membuatnya, pada kenyataannya, setidaknya dua objek dibuat: StringBuilder itu sendiri dan sebuah array karakter setidaknya 16 karakter (omong-omong, itu sebabnya sangat penting untuk menentukan perkiraan panjang string: konstruksinya akan melalui pembuatan daftar array 16-karakter yang terhubung secara tunggal. ) Apa artinya ini dalam konteks percakapan kami tentang tipe ValueStringBuilder: kapasitas tidak ada secara default, karena dibutuhkan memori dari luar, ditambah itu sendiri adalah tipe yang signifikan dan memaksa pengguna untuk mengalokasikan buffer untuk karakter pada stack. Akibatnya, seluruh instance type didorong ke stack bersama dengan kontennya, dan masalah optimisasi di sini menjadi teratasi. Tidak ada alokasi memori di heap? Tidak ada masalah dengan kinerja kendur di heap. Tetapi Anda memberi tahu saya: mengapa tidak menggunakan ValueStringBuilder (atau versi yang ditulis sendiri: apakah itu internal dan tidak dapat diakses oleh kami) selalu? Jawabannya adalah: Anda perlu melihat masalah yang sedang Anda pecahkan. Akankah string yang dihasilkan berukuran diketahui? Apakah akan memiliki panjang maksimal tertentu yang diketahui? Jika jawabannya adalah ya dan jika ukuran string tidak melampaui batas yang masuk akal, maka Anda dapat menggunakan versi StringBuilder yang bermakna. Kalau tidak, jika kita mengharapkan antrean panjang, kita beralih menggunakan versi reguler.
ValueListBuilder
Tipe data kedua yang ingin saya perhatikan adalah tipe ValueListBuilder
. Itu dibuat untuk situasi ketika perlu untuk membuat kumpulan elemen untuk waktu yang singkat dan segera memberikannya kepada beberapa algoritma untuk diproses.
Setuju: tugas ini sangat mirip dengan tugas ValueStringBuilder
. Ya, dan itu diselesaikan dengan cara yang sangat mirip:
File ValueListBuilder.cs
Terus terang, situasi seperti itu cukup umum. Namun, sebelum kami menyelesaikan pertanyaan ini dengan cara lain: kami membuat List
, mengisinya dengan data dan kehilangan tautan. Jika metode ini cukup sering dipanggil, muncul situasi yang menyedihkan: banyak instance dari kelas List
digantung di heap, dan dengan mereka array yang terkait dengan mereka digantung di heap. Sekarang masalah ini telah dipecahkan: tidak ada objek tambahan yang akan dibuat. Namun, seperti dalam kasus ValueStringBuilder
, itu diselesaikan hanya untuk pemrogram Microsoft: kelas memiliki pengubah internal
.
Syarat dan ketentuan penggunaan
Untuk akhirnya memahami esensi dari tipe data baru, Anda perlu "bermain-main" dengannya dengan menulis beberapa hal, atau lebih baik, lebih banyak metode menggunakannya. Namun, aturan dasar dapat dipelajari sekarang:
- Jika metode Anda akan memproses beberapa set data yang masuk tanpa mengubah ukurannya, Anda dapat mencoba untuk berhenti pada tipe
Span
. Jika tidak ada modifikasi buffer ini, maka pada tipe ReadOnlySpan
; - Jika metode Anda akan bekerja dengan string, menghitung beberapa statistik atau mengurai string, maka metode Anda harus menerima
ReadOnlySpan<char>
. Ini wajib: ini adalah aturan baru. Lagi pula, jika Anda menerima string, maka Anda memaksa seseorang untuk membuat substring untuk Anda - Jika Anda perlu membuat array yang cukup pendek dengan data (katakanlah, maksimum 10Kb) sebagai bagian dari pekerjaan metode, maka Anda dapat dengan mudah mengatur array seperti itu menggunakan
Span<TType> buf = stackalloc TType[size]
. Namun, tentu saja, TType seharusnya hanya tipe yang bermakna, seperti stackalloc
hanya bekerja dengan tipe yang berarti.
Dalam kasus lain, ada baiknya melihat lebih dekat pada Memory
atau menggunakan tipe data klasik.
Bagaimana Span Bekerja
Selain itu, saya ingin berbicara tentang bagaimana Span bekerja dan apa yang sangat luar biasa tentangnya. Dan ada sesuatu untuk dibicarakan: tipe data itu sendiri dibagi menjadi dua versi: untuk .NET Core 2.0+ dan untuk semua orang.
Span.Fast.cs, .NET Core 2.0 File
public readonly ref partial struct Span<T> { /// .NET internal readonly ByReference<T> _pointer; /// private readonly int _length; // ... }
File ??? [didekompilasi]
public ref readonly struct Span<T> { private readonly System.Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; // ... }
Masalahnya adalah .NET Framework dan .NET Core 1. * yang besar tidak memiliki pengumpul sampah yang dimodifikasi secara khusus (berbeda dengan versi .NET Core 2.0+) dan karena itu dipaksa untuk menyeret penunjuk tambahan: ke awal buffer dengan mana bekerja. Yaitu, ternyata Span
internal bekerja dengan objek yang dikelola dari platform .NET sebagai tidak dikelola. Lihatlah bagian dalam versi kedua dari struktur: ada tiga bidang. Bidang pertama adalah referensi ke objek yang dikelola. Yang kedua adalah offset dari awal objek ini dalam byte untuk mendapatkan awal buffer data (dalam garis itu adalah buffer dengan karakter char
, dalam array itu adalah buffer dengan data array). Dan akhirnya, bidang ketiga adalah jumlah elemen buffer ini ditumpuk satu demi satu.
Misalnya, ambil pekerjaan Span
untuk string:
File 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); }
Di mana string.GetRawStringData()
sebagai berikut:
File definisi lapangan coreclr :: src / System.Private.CoreLib / src / System / String.CoreCLR.cs
File definisi file 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; }
Yaitu ternyata metode tersebut berjalan langsung di dalam garis, dan spesifikasi referensi memungkinkan Anda untuk melacak tautan tidak terkelola GC di dalam garis, menggerakkannya bersama dengan garis selama operasi GC.
Kisah yang sama terjadi dengan array: ketika Span
dibuat, beberapa kode di dalam JIT menghitung offset awal data array dan menginisialisasi Span
offset ini. Dan bagaimana cara menghitung offset untuk string dan array, kami belajar di bab tentang struktur objek dalam memori.
Rentang [T] sebagai nilai kembali
, 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 .
Tautan ke seluruh buku
