Catatan
Akses ke halaman ini memerlukan otorisasi. Anda dapat mencoba masuk atau mengubah direktori.
Akses ke halaman ini memerlukan otorisasi. Anda dapat mencoba mengubah direktori.
Artikel ini berisi rekomendasi mendetail untuk pola tertentu yang tidak aman, risiko yang mereka ambil, dan cara mengurangi risiko tersebut. Panduan ini menargetkan semua pengembang yang menulis atau meninjau kode yang tidak aman di C#. Bahasa .NET lainnya seperti F# dan Visual Basic berada di luar cakupan artikel ini, meskipun beberapa rekomendasi mungkin juga berlaku untuk bahasa tersebut.
Glossary
- AVE - Pengecualian pelanggaran akses.
- Byref - Pointer terkelola (
ref T t) yang mirip dengan pointer tidak terkelola tetapi dilacak oleh GC. Biasanya menunjuk ke bagian sembarang dari objek atau tumpukan. Referensi pada dasarnya adalah pointer terkelola dengan offset +0. - CVE - Vulnerabilitas keamanan siber yang diungkapkan secara publik.
- JIT - Kompilator just-in-time (RyuJIT di CoreCLR dan NativeAOT).
- PGO - Pengoptimalan yang dipandu profil.
- Penunjuk tidak terkelola (atau penunjuk mentah) - Penunjuk (
T* p) yang menunjuk ke lokasi memori sewenang-wenang dan tidak dikelola atau dilacak oleh GC.
Untuk istilah lain, lihat Glosarium Runtime .NET.
Pola umum yang tidak dapat diandalkan
C# menyediakan lingkungan yang aman di mana pengembang tidak perlu khawatir tentang pekerjaan internal runtime dan GC. Kode yang tidak aman memungkinkan Anda untuk melewati pemeriksaan keamanan ini, berpotensi memperkenalkan pola yang tidak dapat diandalkan yang dapat menyebabkan kerusakan memori. Meskipun pola tersebut mungkin berguna dalam skenario tertentu, Anda harus menggunakannya dengan hati-hati dan hanya jika benar-benar diperlukan. Tidak hanya C# dan .NET tidak menyediakan alat untuk memverifikasi kebisingan kode yang tidak aman (seperti yang mungkin disediakan oleh berbagai sanitizer C/C++), perilaku khusus GC mungkin menimbulkan risiko tambahan dalam C# yang tidak aman di luar yang mungkin diketahui pengembang C/C++ tradisional.
Kode tidak aman di sekitar referensi terkelola harus ditulis dengan anggapan konservatif berikut:
- GC dapat mengganggu eksekusi metode apa pun kapan saja pada instruksi apa pun.
- GC dapat memindahkan objek dalam memori dan memperbarui semua referensi yang dilacak .
- GC tahu dengan tepat kapan referensi tidak lagi diperlukan.
Contoh klasik kerusakan timbunan terjadi ketika GC kehilangan trek referensi objek atau memperlakukan pointer yang tidak valid sebagai referensi timbunan. Ini sering mengakibatkan crash non-deterministik atau kerusakan memori. Bug kerusakan tumpukan sangat menantang untuk mendiagnosis dan mereproduksi karena:
- Masalah ini dapat tetap tersembunyi untuk waktu yang lama dan hanya bermanifestasi setelah perubahan kode atau pembaruan runtime yang tidak terkait.
- Mereka sering memerlukan waktu yang tepat untuk diproduksi ulang, seperti ketika eksekusi terganggu oleh GC di lokasi tertentu dan memulai pemadatan heap, yang merupakan peristiwa langka dan tidak deterministik.
Bagian berikutnya menjelaskan pola umum yang tidak aman dengan ✔️ rekomendasi DO dan ❌ DON'T.
1. Pointer terkelola yang tidak terlacak (Unsafe.AsPointer dan sejenisnya)
Tidak dimungkinkan untuk mengonversi penunjuk terkelola (terlacak) ke penunjuk yang tidak dikelola (tidak terlacak) di C#yang aman. Ketika kebutuhan seperti itu muncul, mungkin menggoda untuk menggunakan Unsafe.AsPointer<T>(T) untuk menghindari overhead dari sebuah fixed statement. Meskipun ada kasus penggunaan yang valid untuk itu, itu menimbulkan risiko membuat pointer yang tidak terlacak ke objek yang dapat dipindahkan.
Contoh:
unsafe void UnreliableCode(ref int x)
{
int* nativePointer = (int*)Unsafe.AsPointer(ref x);
nativePointer[0] = 42;
}
Jika GC mengganggu eksekusi metode UnreliableCode segera setelah pointer dibaca (alamat direferensikan oleh x) dan merelokasi objek yang direferensikan, GC akan memperbarui lokasi yang disimpan di x dengan benar tetapi tidak akan mengetahui tentang nativePointer dan tidak akan memperbarui nilai yang dikandungnya. Pada saat itu, menulis ke nativePointer berarti menulis ke memori sembarang.
unsafe void UnreliableCode(ref int x)
{
int* nativePointer = (int*)Unsafe.AsPointer(ref x);
// <-- GC happens here between the two lines of code and updates `x` to point to a new location.
// However, `nativePointer` still points to the old location as it's not reported to the GC
nativePointer[0] = 42; // Potentially corrupting write, access violation, or other issue.
}
Setelah GC melanjutkan eksekusi metode, GC akan menulis 42 ke lokasi lama x, yang mungkin menyebabkan pengecualian tak terduga, kerusakan umum pada status global, atau penghentian proses melalui pelanggaran akses.
Solusi yang direkomendasikan adalah menggunakan fixed kata kunci dan & alamat operator untuk memastikan bahwa GC tidak dapat merelokasi referensi target selama durasi operasi.
unsafe void ReliableCode(ref int x)
{
fixed (int* nativePointer = &x) // `x` cannot be relocated for the duration of this block.
{
nativePointer[0] = 42;
}
}
Recommendations
-
❌ JANGAN gunakan
ref Xargumen dengan kontrak implisit yangXselalu dialokasikan di stack, dipakukan, atau tidak dapat dipindahkan oleh GC. Hal yang sama berlaku untuk objek biasa dan Rentang - jangan perkenalkan kontrak berbasis panggilan yang tidak jelas mengenai masa pakainya dalam parameter metode. Pertimbangkan untuk mengambil argumen struct ref atau mengubah argumen menjadi jenis pointer mentah (X*). - ❌ JANGAN gunakan pointer dari Unsafe.AsPointer<T>(T) jika pointer tersebut bisa bertahan lebih lama dari objek asli yang ditunjukkannya. Sesuai dokumentasi API, terserah pemanggil Unsafe.AsPointer<T>(T) untuk menjamin bahwa GC tidak dapat merelokasi referensi. Pastikan terlihat jelas oleh peninjau kode bahwa pemanggil telah memenuhi prasyarat ini.
- ✔️ Gunakan GCHandle atau
fixedcakupan alih-alih Unsafe.AsPointer<T>(T) untuk menentukan cakupan eksplisit bagi pointer yang tidak dikelola dan untuk memastikan bahwa objek selalu disematkan. - ✔️ GUNAKAN pointer unmanaged (dengan
fixed) alih-alih byref saat Anda perlu menyelaraskan array ke batas tertentu. Ini memastikan GC tidak akan merelokasi objek dan menggagalkan asumsi mengenai alignment apa pun yang mungkin diandalkan logika Anda.
2. Mengekspos pointer di luar cakupan fixed
Meskipun kata kunci tetap mendefinisikan cakupan untuk pointer yang diperoleh dari objek yang disematkan, pointer tersebut masih bisa keluar dari cakupan dan menyebabkan bug, karena C# tidak memberikan perlindungan kepemilikan/siklus hidup apa pun untuknya. Contoh umumnya adalah cuplikan berikut:
unsafe int* GetPointerToArray(int[] array)
{
fixed (int* pArray = array)
{
_ptrField = pArray; // Bug!
Method(pArray); // Bug if `Method` allows `pArray` to escape,
// perhaps by assigning it to a field.
return pArray; // Bug!
// And other ways to escape the scope.
}
}
Dalam contoh ini, array disematkan dengan benar menggunakan kata kunci fixed, memastikan GC tidak dapat merelokasinya di dalam blok fixed, tetapi kemudian pointer diekspos di luar blok fixed. Ini menciptakan pointer menjuntai yang dereferensinya akan mengakibatkan perilaku yang tidak terdefinisi.
Recommendations
- ✔️ PASTIKAN bahwa penunjuk pada blok
fixedtidak meninggalkan cakupan yang ditentukan. - ✔️ DO lebih memilih primitif tingkat rendah yang aman dengan analisis escape bawaan, seperti struktur ref C#. Untuk informasi selengkapnya, lihat Peningkatan struktur tingkat rendah.
3. Rincian implementasi internal runtime dan pustaka
Meskipun mengakses atau mengandalkan detail internal implementasi adalah praktik buruk secara umum (dan tidak didukung oleh .NET), ada baiknya menyoroti contoh kasus tertentu yang umum diamati. Ini tidak dimaksudkan untuk menjadi daftar lengkap dari semua kemungkinan hal yang bisa salah ketika kode secara tidak tepat bergantung pada detail implementasi internal.
Recommendations
❌ JANGAN mengubah atau membaca bagian mana pun dari header objek.
- Header objek mungkin berbeda di seluruh runtime.
- Di CoreCLR, header objek tidak dapat diakses dengan aman tanpa menyematkan (pin) objek terlebih dahulu.
- Jangan pernah mengubah jenis objek dengan memodifikasi penunjuk MethodTable.
❌ JANGAN menyimpan data apa pun dalam padding objek. Jangan berasumsi bahwa konten padding akan dipertahankan atau bahwa padding selalu dijadikan nol secara default.
❌ JANGAN membuat asumsi tentang ukuran dan offset apa pun selain primitif dan struktur dengan tata letak berurutan atau eksplisit. Bahkan dalam kasus-kasus tertentu, pengecualian ada, seperti ketika handle GC terlibat.
❌ JANGAN panggil metode nonpublik, akses bidang nonpublik, atau mutasi bidang read-only dalam jenis BCL dengan kode refleksi atau tidak aman.
❌ JANGAN asumsikan anggota nonpublik yang diberikan di BCL (Base Class Library) akan selalu ada atau akan memiliki struktur tertentu. Tim .NET kadang-kadang memodifikasi atau menghapus API nonpublik dalam rilis layanan.
❌ JANGAN ubah
static readonlybidang menggunakan refleksi atau kode tidak aman, karena diasumsikan konstan. Misalnya, RyuJIT biasanya menggarisnya sebagai konstanta eksplisit.❌ JANGAN hanya berasumsi bahwa referensi tidak dapat direlokasi. Panduan ini berlaku untuk string dan literal UTF-8 (
"..."u8), bidang statis, bidang RVA, objek LOH, dan sebagainya.- Ini adalah detail implementasi runtime yang mungkin berlaku untuk beberapa runtime tetapi tidak untuk yang lain.
- Penunjuk yang tidak dikelola ke objek tersebut mungkin tidak menghentikan rakitan agar tidak dibongkar, menyebabkan pointer menjadi menjuntai. Gunakan
fixedruang lingkup untuk memastikan kebenaran.
ReadOnlySpan<int> rva = [1, 2, 4, 4]; int* p = (int*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(rva)); // Bug! The assembly containing the RVA field might be unloaded at this point // and `p` becomes a dangling pointer. int value = p[0]; // Access violation or other issue.❌ JANGAN menulis kode yang bergantung pada detail implementasi runtime tertentu.
4. Pointer terkelola tidak valid (meskipun tidak pernah didereferensikan)
Kategori kode tertentu akhirnya bersandar pada manipulasi dan aritmatika pointer, dan kode tersebut sering memiliki pilihan antara menggunakan pointer yang tidak dikelola (T* p) dan pointer terkelola (ref T p).
Pointer ini dapat dimanipulasi secara semena-mena, misalnya, melalui operator pada pointer yang tidak dikelola (p++) dan melalui Unsafe metode pada pointer terkelola (p = ref Unsafe.Add(ref p, 1)). Keduanya dianggap "kode tidak aman" dan dimungkinkan untuk membuat pola yang tidak dapat diandalkan dengan keduanya. Namun, untuk algoritma tertentu, dapat lebih mudah untuk secara tidak sengaja membuat pola tidak aman GC saat memanipulasi pointer terkelola. Karena pointer yang tidak dikelola tidak dilacak oleh GC, nilai yang dikandungnya hanya relevan ketika didereferensikan oleh kode pengembang. Sebaliknya, nilai pointer terkelola tidak hanya relevan ketika didereferensikan oleh kode pengembang, tetapi juga ketika diperiksa oleh GC. Dengan demikian, pengembang dapat membuat pointer tidak terkelola yang tidak valid tanpa konsekuensi selama tidak didereferensikan, tetapi membuat pointer terkelola yang tidak valid adalah bug. Contoh:
unsafe void UnmanagedPointers(int[] array)
{
fixed (int* p = array)
{
int* invalidPtr = p - 1000;
// invalidPtr is pointing to an undefined location in memory
// it's ok as long as it's not dereferenced.
int* validPtr = invalidPtr + 1000; // Returning back to the original location
*validPtr = 42; // OK
}
}
Namun, kode serupa yang menggunakan byrefs (penunjuk terkelola) tidak valid.
void ManagedPointers_Incorrect(int[] array)
{
ref int invalidPtr = ref Unsafe.Add(ref array[0], -1000); // Already a bug!
ref int validPtr = ref Unsafe.Add(ref invalidPtr, 1000);
validPtr = 42; // possibly corrupting write
}
Sementara implementasi terkelola di sini menghindari overhead penyematan kecil, ini tidak bisa diandalkan karena invalidPtrdapat menjadi penunjuk eksterior ketika alamat array[0] aktual sedang diperbarui oleh GC.
Bug semacam itu sulit dideteksi, dan bahkan .NET pernah mengalami masalah ini selama pengembangan.
Recommendations
-
❌ JANGAN membuat pointer terkelola yang tidak valid, meskipun tidak didereferensikan atau berada di dalam jalur kode yang tidak pernah dijalankan.
- Untuk informasi selengkapnya tentang apa yang merupakan pointer terkelola yang valid, lihat ECMA-335, Sek. II.14.4.2 Pointer terkelola; dan Addendum Spesifikasi ECMA-335 CLI, Sek. II.14.4.2.
- ✔️ Gunakan pointer tidak terkelola disematkan jika algoritma memerlukan manipulasi tersebut.
5. Tipe casting mirip reinterpret
Meskipun semua jenis cast struct-to-class atau class-to-struct adalah perilaku yang tidak terdefinisi berdasarkan definisi, anda juga dapat menemukan pola yang tidak dapat diandalkan dengan konversi struct-to-struct atau class-to-class. Contoh umum pola yang tidak dapat diandalkan adalah kode berikut:
struct S1
{
string a;
nint b;
}
struct S2
{
string a;
string b;
}
S1 s1 = ...
S2 s2 = Unsafe.As<S1, S2>(ref s1); // Bug! A random nint value becomes a reference reported to the GC.
Dan bahkan jika tata letaknya mirip, Anda masih harus berhati-hati ketika referensi GC (kolom) ada.
Recommendations
- ❌ JANGAN melemparkan struktur ke kelas atau sebaliknya.
-
❌ JANGAN gunakan
Unsafe.Asuntuk konversi struct-to-struct atau class-to-class kecuali Anda benar-benar yakin bahwa cast tersebut legal. Untuk informasi selengkapnya, lihat bagian Keterangan dariUnsafe.Asdokumen API. - Sebisa mungkin pilih penyalinan bidang demi bidang yang lebih aman, menggunakan pustaka eksternal seperti AutoMapper atau generator kode sumber untuk konversi tersebut.
- ✔️ DO lebih suka
Unsafe.BitCastdaripadaUnsafe.As, karenaBitCastmemberikan beberapa pemeriksaan penggunaan dasar. Perhatikan bahwa pemeriksaan ini tidak memberikan jaminan kebenaran penuh, artinyaBitCastmasih dianggap sebagai API yang tidak aman.
6. Melewatkan Write Barrier dan operasi non-atomik pada referensi GC
Biasanya, semua jenis penulisan atau pembacaan referensi GC selalu bersifat atomis. Selain itu, semua upaya untuk menetapkan referensi GC (atau byref ke struct dengan bidang GC) ke lokasi timbunan potensial melalui Penghalang Penulisan yang memastikan GC menyadari adanya koneksi baru antar objek. Namun, kode yang tidak aman memungkinkan kita untuk melewati jaminan ini dan memperkenalkan pola yang tidak dapat diandalkan. Contoh:
unsafe void InvalidCode1(object[] arr1, object[] arr2)
{
fixed (object* p1 = arr1)
fixed (object* p2 = arr2)
{
nint* ptr1 = (nint*)p1;
nint* ptr2 = (nint*)p2;
// Bug! We're assigning a GC pointer to a heap location
// without going through the Write Barrier.
// Moreover, we also bypass array covariance checks.
*ptr1 = *ptr2;
}
}
Demikian pula, kode berikut dengan pointer terkelola juga tidak dapat diandalkan:
struct StructWithGcFields
{
object a;
int b;
}
void InvalidCode2(ref StructWithGcFields dst, ref StructWithGcFields src)
{
// It's already a bad idea to cast a struct with GC fields to `ref byte`, etc.
ref byte dstBytes = ref Unsafe.As<StructWithGcFields, byte>(ref dst);
ref byte srcBytes = ref Unsafe.As<StructWithGcFields, byte>(ref src);
// Bug! Bypasses the Write Barrier. Also, non-atomic writes/reads for GC references.
Unsafe.CopyBlockUnaligned(
ref dstBytes, ref srcBytes, (uint)Unsafe.SizeOf<StructWithGcFields>());
// Bug! Same as above.
Vector128.LoadUnsafe(ref srcBytes).StoreUnsafe(ref dstBytes);
}
Recommendations
- ❌ JANGAN gunakan operasi non-atomik pada referensi GC (misalnya, operasi SIMD sering tidak menyediakannya).
- ❌ JANGAN gunakan pointer yang tidak dikelola untuk menyimpan referensi GC ke lokasi tumpukan (menghilangkan Penghalang Tulis).
7. Asumsi tentang masa pakai objek (finalizer, GC.KeepAlive)
Hindari membuat asumsi tentang masa pakai objek dari perspektif GC. Secara khusus, jangan berasumsi bahwa objek masih hidup ketika mungkin tidak. Masa pakai objek dapat bervariasi di berbagai runtime atau bahkan di antara Tingkat yang berbeda dengan metode yang sama (Tier0 dan Tier1 di RyuJIT). Finalizer adalah skenario umum di mana asumsi seperti itu bisa saja salah.
public class MyClassWithBadCode
{
public IntPtr _handle;
public void DoWork() => DoSomeWork(_handle); // A use-after-free bug!
~MyClassWithBadCode() => DestroyHandle(_handle);
}
// Example usage:
var obj = new MyClassWithBadCode()
obj.DoWork();
Dalam contoh ini, DestroyHandle mungkin dipanggil sebelum DoWork selesai atau bahkan sebelum dimulai.
Oleh karena itu, sangat penting untuk tidak menganggap bahwa objek, seperti this, akan tetap hidup sampai akhir metode.
void DoWork()
{
// A pseudo-code of what might happen under the hood:
IntPtr reg = this._handle;
// 'this' object is no longer alive at this point.
// <-- GC interrupts here, collects the 'this' object, and triggers its finalizer.
// DestroyHandle(_handle) is called.
// Bug! 'reg' is now a dangling pointer.
DoSomeWork(reg);
// You can resolve the issue and force 'this' to be kept alive (thus ensuring the
// finalizer will not run) by uncommenting the line below:
// GC.KeepAlive(this);
}
Oleh karena itu, disarankan untuk secara eksplisit memperpanjang masa pakai objek menggunakan GC.KeepAlive(Object) atau SafeHandle.
Instans klasik lain dari masalah ini adalah Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) API:
var callback = new NativeCallback(OnCallback);
// Convert delegate to function pointer
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);
// Bug! The delegate might be collected by the GC here.
// It should be kept alive until the native code is done with it.
RegisterCallback(fnPtr);
Recommendations
-
❌ JANGAN membuat asumsi tentang masa pakai objek. Misalnya, jangan pernah berasumsi
thisselalu aktif sampai akhir metode. - ✔️ DO gunakan SafeHandle untuk mengelola sumber daya asli.
- ✔️ DO menggunakan GC.KeepAlive(Object) untuk memperpanjang masa pakai objek bila perlu.
8. Akses lintas alur ke variabel lokal
Mengakses variabel lokal dari utas yang berbeda umumnya dianggap sebagai praktik buruk. Namun, itu menjadi perilaku yang tidak ditentukan secara eksplisit ketika referensi terkelola terlibat, seperti yang diuraikan dalam Model Memori .NET.
Contoh: Sebuah struktur yang berisi referensi GC mungkin di-nol-kan atau ditimpa dengan cara yang tidak aman dalam kawasan tanpa GC saat utas lain membacanya, yang menyebabkan perilaku yang tidak terdefinisikan.
Recommendations
- ❌ JANGAN mengakses lokal di seluruh utas (terutama jika berisi referensi GC).
- ✔️ DO menggunakan tumpukan atau memori yang tidak dikelola (misalnya, NativeMemory.Alloc) sebagai gantinya.
9. Penghapusan pemeriksaan batas tidak aman
Di C#, semua akses memori idiomatik menyertakan pemeriksaan batas secara default. Pengkompilasi JIT dapat menghapus pemeriksaan ini jika dapat membuktikan bahwa mereka tidak perlu, seperti pada contoh di bawah ini.
int SumAllElements(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
// The JIT knows that within this loop body, i >= 0 and i < array.Length.
// The JIT can reason that its own bounds check would be duplicative and
// unnecessary, so it opts not to emit the bounds check into the final
// generated code.
sum += array[i];
}
}
Meskipun JIT terus membaik dalam mengenali pola tersebut, masih ada skenario di mana pemeriksaan tetap ada, berpotensi berdampak pada performa dalam kode kritis. Dalam kasus seperti itu, Anda mungkin tergoda untuk menggunakan kode yang tidak aman untuk menghapus pemeriksaan ini secara manual tanpa sepenuhnya memahami risiko atau menilai manfaat performa secara akurat.
Pertimbangkan misalnya metode berikut.
int FetchAnElement(int[] array, int index)
{
return array[index];
}
Jika JIT tidak dapat membuktikan bahwa index selalu secara hukum dalam batas array, JIT akan menulis ulang metode untuk terlihat seperti di bawah ini.
int FetchAnElement_AsJitted(int[] array, int index)
{
if (index < 0 || index >= array.Length)
throw new IndexOutOfBoundsException();
return array.GetElementAt(index);
}
Untuk mengurangi overhead dari kode panas cek masuk, Anda mungkin tergoda untuk menggunakan API yang tidak setara dengan aman (Unsafe dan MemoryMarshal):
int FetchAnElement_Unsafe1(int[] array, int index)
{
// DANGER: The access below is not bounds-checked and could cause an access violation.
return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}
Atau gunakan penyematan dan pointer mentah:
unsafe int FetchAnElement_Unsafe2(int[] array, int index)
{
fixed (int* pArray = array)
{
// DANGER: The access below is not bounds-checked and could cause an access violation.
return pArray[index];
}
}
Ini dapat menyebabkan crash acak atau kerusakan status jika index berada di luar batas array.
Transformasi yang tidak aman tersebut dapat memiliki manfaat performa pada jalur yang sangat panas, tetapi manfaat ini sering bersifat sementara, karena setiap rilis .NET meningkatkan kemampuan JIT untuk menghilangkan pemeriksaan batas yang tidak perlu ketika aman untuk melakukannya.
Recommendations
- ✔️ Pastikan apakah versi terbaru .NET masih tidak dapat menghilangkan pengecekan batas. Jika bisa, tulis ulang menggunakan kode aman. Jika tidak, ajukan masalah terhadap RyuJIT. Gunakan masalah pelacakan ini sebagai titik awal yang baik.
- ✔️ Pastikan untuk mengukur dampak kinerja di dunia nyata. Jika perolehan performa dapat diabaikan atau kode tidak terbukti panas di luar microbenchmark sepele, tulis ulang menggunakan kode aman.
- ✔️ Berikan petunjuk tambahan ke JIT, seperti pemeriksaan batas manual sebelum perulangan dan menyimpan bidang ke variabel lokal, karena Model Memori .NET mungkin secara konservatif mencegah JIT menghapus pemeriksaan batas dalam beberapa skenario.
- ✔️ Kode penjaga DO dengan
Debug.Assertbatas memeriksa apakah kode yang tidak aman masih diperlukan. Pertimbangkan contoh di bawah ini.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here
Anda bahkan dapat merefaktor pengecekan ini ke dalam fungsi pembantu yang dapat digunakan kembali.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static T UnsafeGetElementAt<T>(this T[] array, int index)
{
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}
Penyertaan Debug.Assert tidak memberikan pemeriksaan ketepatan apa pun untuk build Rilis, tetapi mungkin membantu mendeteksi potensi bug dalam build Debug.
10. Penggabungan akses memori
Anda mungkin tergoda untuk menggunakan kode yang tidak aman untuk menyaring akses memori untuk meningkatkan performa.
Contoh klasik adalah kode berikut untuk ditulis "False" ke dalam array karakter:
// Naive implementation
static void WriteToDestination_Safe(char[] dst)
{
if (dst.Length < 5) { throw new ArgumentException(); }
dst[0] = 'F';
dst[1] = 'a';
dst[2] = 'l';
dst[3] = 's';
dst[4] = 'e';
}
// Unsafe coalesced implementation
static void WriteToDestination_Unsafe(char[] destination)
{
Span<char> dstSpan = destination;
if (dstSpan.Length < 5) { throw new ArgumentException(); }
ulong fals_val = BitConverter.IsLittleEndian ? 0x0073006C00610046ul : 0x00460061006C0073ul;
MemoryMarshal.Write(MemoryMarshal.AsBytes(dstSpan.Slice(0, 4)), in fals_val); // Write "Fals" (4 chars)
dstSpan[4] = 'e'; // Write "e" (1 char)
}
Dalam versi .NET sebelumnya, versi yang tidak aman menggunakan MemoryMarshal terbukti lebih cepat daripada versi aman yang sederhana. Namun, versi modern .NET berisi JIT yang jauh lebih baik yang menghasilkan codegen yang setara untuk kedua kasus. Pada .NET 10, codegen x64 adalah:
; WriteToDestination_Safe
cmp eax, 5
jl THROW_NEW_ARGUMENTEXCEPTION
mov rax, 0x73006C00610046
mov qword ptr [rdi+0x10], rax
mov word ptr [rdi+0x18], 101
; WriteToDestination_Unsafe
cmp edi, 5
jl THROW_NEW_ARGUMENTEXCEPTION
mov rdi, 0x73006C00610046
mov qword ptr [rax], rdi
mov word ptr [rax+0x08], 101
Ada versi kode yang lebih sederhana dan lebih mudah dibaca:
"False".CopyTo(dst);
Pada .NET 10, panggilan ini menghasilkan codegen yang identik seperti di atas. Bahkan memiliki manfaat tambahan: ini mengisyaratkan kepada JIT bahwa penulisan elemen per elemen yang ketat tidak perlu dilakukan secara atomik. JIT mungkin menggabungkan petunjuk ini dengan pengetahuan kontekstual lainnya untuk memberikan lebih banyak pengoptimalan di luar apa yang dibahas di sini.
Recommendations
- ✔️ DO lebih memilih kode aman idiomatik daripada kode tidak aman untuk penggabungan akses memori.
- Lebih suka
Span<T>.CopyTodanSpan<T>.TryCopyTountuk menyalin data. - Lebih suka
String.EqualsdanSpan<T>.SequenceEqualuntuk membandingkan data (bahkan saat menggunakanStringComparer.OrdinalIgnoreCase). - Lebih suka
Span<T>.Fillmengisi data danSpan<T>.Clearuntuk menghapus data. - Ketahuilah bahwa tulis/bacaan per elemen atau per bidang mungkin digabungkan oleh JIT secara otomatis.
- Lebih suka
- ✔️ DO mengajukan masalah terhadap dotnet/runtime jika Anda menulis kode idiomatik dan mengamati bahwa itu tidak dioptimalkan seperti yang diharapkan.
- ❌ JANGAN menyatukan akses memori secara manual jika Anda tidak yakin tentang risiko akses memori yang salah, jaminan atomitas, atau manfaat performa terkait.
11. Akses memori yang tidak selaras
Proses penggabungan akses memori yang dijelaskan dalam Memory access coalescing sering kali mengakibatkan pembacaan/penulisan yang tidak sejajar, baik secara eksplisit maupun implisit. Meskipun ini biasanya tidak menyebabkan masalah serius (selain dari potensi penalti performa karena melewati cache dan batas halaman), itu masih menimbulkan beberapa risiko nyata.
Misalnya, pertimbangkan skenario di mana Anda menghapus dua elemen array sekaligus:
uint[] arr = _arr;
arr[i + 0] = 0;
arr[i + 1] = 0;
Katakanlah nilai-nilai sebelumnya di lokasi ini adalah keduanya uint.MaxValue (0xFFFFFFFF).
Model Memori .NET menjamin bahwa kedua penulisan bersifat atomik, sehingga semua utas lain dalam proses hanya akan mengamati nilai 0 baru atau nilai 0xFFFFFFFF lama, tanpa pernah mengamati nilai "torn" seperti 0xFFFF0000.
Namun, asumsikan kode tidak aman berikut digunakan untuk melewati pemeriksaan batas dan mengatur kedua elemen ke nol dengan satu kali penyimpanan 64-bit.
ref uint p = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(arr), i);
Unsafe.WriteUnaligned<ulong>(ref Unsafe.As<uint, byte>(ref p), 0UL);
Kode ini memiliki efek samping menghapus jaminan atomitas. Nilai robek mungkin diamati oleh utas lain, yang mengarah ke perilaku yang tidak terdefinisi. Agar operasi penulisan gabungan seperti itu menjadi atomis, memori harus selaras dengan ukuran operasi penulisan (8 byte dalam hal ini). Jika Anda mencoba menyelaraskan memori secara manual sebelum operasi, Anda harus mempertimbangkan bahwa GC dapat merelokasi (dan, secara efektif, mengubah perataan) array kapan saja jika tidak disematkan. Lihat dokumentasi Model Memori .NET untuk detail selengkapnya.
Risiko lain dari akses memori yang tidak selaras adalah potensi crash aplikasi dalam skenario tertentu. Meskipun beberapa runtime .NET mengandalkan sistem operasi untuk memperbaiki pengaksesan yang tidak tertata, masih ada beberapa skenario di beberapa platform di mana pengaksesan yang tidak tertata dapat menyebabkan DataMisalignedException (atau SEHException). Beberapa contohnya meliputi:
-
Interlockedoperasi pada memori yang tidak selaras pada beberapa platform (contoh). - Operasi poin mengambang yang tidak selaras pada ARM.
- Mengakses memori perangkat khusus dengan persyaratan penyelarasan tertentu (tidak benar-benar didukung oleh .NET).
Recommendations
- ❌ JANGAN gunakan akses memori yang tidak sejajar dalam algoritma bebas kunci dan skenario lain di mana atomitas penting.
- ✔️ LAKUKAN menyelaraskan data secara manual jika perlu, tetapi perlu diingat bahwa GC dapat merelokasi objek kapan saja, secara efektif mengubah perataan secara dinamis. Ini sangat penting untuk berbagai
StoreAligned/LoadAlignedAPI dalam SIMD. - ✔️ DO gunakan API Baca/Tulis yang tidak sejajar secara eksplisit seperti Unsafe.ReadUnaligned/Unsafe.WriteUnaligned daripada API yang selaras seperti Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) atau Unsafe.As<TFrom,TTo>(TFrom) jika data mungkin tidak sejajar.
- ✔️ PERLU diingat bahwa berbagai API manipulasi memori seperti Span<T>.CopyTo(Span<T>) juga tidak memberikan jaminan atomitas.
- ✔️ DO berkonsultasi dengan dokumentasi Model Memori .NET (lihat referensi) untuk rincian tentang jaminan sifat atomik.
- ✔️ Ukurlah performa di semua platform target Anda, karena beberapa platform memberikan penalti performa yang signifikan untuk akses memori yang tidak selaras. Anda mungkin menemukan bahwa pada platform ini, kode naif berkinerja lebih baik daripada kode pintar.
- ✔️ PERLU diingat bahwa ada skenario dan platform di mana akses memori yang tidak disejajarkan dapat menyebabkan pengecualian.
12. Biner (de)serialisasi struktur dengan padding atau anggota yang tidak dapat digandakan secara langsung
Berhati-hatilah saat Anda menggunakan berbagai API yang mirip serialisasi untuk menyalin atau membaca struct ke dan dari array byte.
Jika struct berisi padding atau anggota yang tidak dapat di-blitt secara langsung (misalnya, bool atau bidang GC), maka operasi memori klasik yang tidak aman seperti Fill, CopyTo, dan SequenceEqual mungkin secara tidak sengaja menyalin data sensitif dari tumpukan ke padding atau memperlakukan data sampah sebagai signifikan selama perbandingan, yang dapat menciptakan bug yang jarang direproduksi. Pola anti yang umum mungkin terlihat seperti ini:
T UnreliableDeserialization<TObject>(ReadOnlySpan<byte> data) where TObject : unmanaged
{
return MemoryMarshal.Read<TObject>(data); // or Unsafe.ReadUnaligned
// BUG! TObject : unmanaged doesn't guarantee that TObject is blittable and contains no paddings.
}
Satu-satunya pendekatan yang benar adalah menggunakan beban bidang demi bidang/penyimpanan khusus untuk setiap TObject input (atau digeneralisasi dengan Pustaka Pantulan, Generator Sumber, atau (de)serialisasi).
Recommendations
-
❌ JANGAN gunakan kode yang tidak aman untuk menyalin/memuat/membandingkan struktur dengan padding atau anggota yang tidak dapat di-blittable. Beban dari input yang tidak tepercaya bermasalah bahkan untuk jenis dasar seperti
boolataudecimal. Pada saat yang sama, penyimpan data mungkin secara tidak sengaja mengubah informasi sensitif dalam bentuk serial dari tumpukan pada celah/padding struct. -
❌ JANGAN mengandalkan
T : unmanagedbatasan,RuntimeHelpers.IsReferenceOrContainsReferences, atau API serupa untuk menjamin bahwa jenis generik aman untuk melakukan operasi bitwise. Pada saat menulis pedoman ini, tidak ada cara pemrograman yang andal untuk menentukan apakah diizinkan melakukan operasi bitwise arbitrer pada jenis tertentu.- Jika Anda harus melakukan manipulasi bitwise seperti itu, lakukan hanya pada daftar jenis yang telah dikodekan secara permanen ini, dan perhatikan endianness mesin saat ini:
- Jenis integral primitif
Byte,SByte,Int16,UInt16,Int32,UInt32,Int64, danUInt64; - Salah satu jenis integral primitif di atas yang mendukung
Enum; -
Char,Int128,UInt128,Half,Single,Double,IntPtr,UIntPtr.
- Jenis integral primitif
- Jika Anda harus melakukan manipulasi bitwise seperti itu, lakukan hanya pada daftar jenis yang telah dikodekan secara permanen ini, dan perhatikan endianness mesin saat ini:
- ✔️ DO gunakan deserialisasi/memuat-penyimpanan bidang demi bidang sebagai gantinya. Pertimbangkan untuk menggunakan pustaka populer dan aman untuk (de)serialisasi.
13. Penunjuk terkelola null
Umumnya, byrefs (pointer terkelola) jarang null dan satu-satunya cara aman untuk membuat byref null saat ini adalah dengan menginisialisasi ref struct dengan default. Kemudian semua bidangnya ref adalah penunjuk terkelola null:
RefStructWithRefField s = default;
ref byte nullRef = ref s.refFld;
Namun, ada beberapa cara yang tidak aman untuk membuat byref null. Beberapa contohnya meliputi:
// Null byref by calling Unsafe.NullRef directly:
ref object obj = ref Unsafe.NullRef<object>();
// Null byref by turning a null unmanaged pointer into a null managed pointer:
ref object obj = ref Unsafe.AsRef<object>((void*)0);
Risiko memunculkan masalah keamanan memori rendah, dan setiap upaya untuk melakukan dereferensi pada null byref akan menyebabkan NullReferenceException yang terdefinisi dengan baik. Namun, pengkompilasi C# mengasumsikan bahwa dereferensi byref selalu berhasil dan tidak menghasilkan efek samping yang dapat diamati. Oleh karena itu, ini adalah pengoptimalan yang sah untuk menghilangkan dereferensi apa pun yang hasil nilainya langsung dibuang. Lihat dotnet/runtime#98681 (dan komentar terkait ini) untuk contoh bug yang telah diperbaiki dalam .NET di mana kode pustaka secara keliru bergantung pada dereferensi yang memicu efek samping, tanpa menyadari bahwa pengompilasi C# secara efektif memutuskan logika yang dimaksudkan.
Recommendations
- ❌ JANGAN membuat byref null di C# jika tidak diperlukan. Pertimbangkan untuk menggunakan referensi terkelola normal, Pola Objek Null, atau rentang kosong sebagai gantinya.
- ❌ JANGAN buang hasil dereferensi byref, karena mungkin dioptimalkan dan menyebabkan potensi bug.
14. stackalloc
stackalloc secara historis telah digunakan untuk membuat array kecil yang tidak melarikan diri pada tumpukan, mengurangi tekanan GC. Di masa mendatang, Analisis Escape JIT mungkin mulai mengoptimalkan alokasi array GC yang tidak melarikan diri ke objek tumpukan, yang berpotensi membuat stackalloc redundan. Sampai saat itu, stackalloc tetap berguna untuk mengalokasikan buffer kecil pada tumpukan. Untuk buffer yang lebih besar atau ekspansi, sering dikombinasikan dengan ArrayPool<T>.
Recommendations
✔️ DO selalu menggunakan
stackallocdiReadOnlySpan<T>/Span<T>sisi kiri ekspresi untuk memberikan pemeriksaan batas:// Good: Span<int> s = stackalloc int[10]; s[2] = 0; // Bounds check is eliminated by JIT for this write. s[42] = 0; // IndexOutOfRangeException is thrown // Bad: int* s = stackalloc int[10]; s[2] = 0; s[42] = 0; // Out of bounds write, undefined behavior.❌ Jangan gunakan
stackallocdi dalam perulangan. Ruang tumpukan tidak diklaim kembali sampai metode kembali, jadi termasukstackallocdi dalam perulangan dapat mengakibatkan penghentian proses karena luapan tumpukan.❌ JANGAN gunakan panjang besar untuk
stackalloc. Misalnya, 1024 byte dapat dianggap sebagai batas atas yang wajar.✔️ CEK rentang variabel yang digunakan sebagai panjang
stackalloc.void ProblematicCode(int length) { Span<int> s = stackalloc int[length]; // Bad practice: check the range of `length`! Consume(s); }Versi tetap:
void BetterCode(int length) { // The "throw if length < 0" check below is important, as attempting to stackalloc a negative // length will result in process termination. ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length)); Span<int> s = length <= 256 ? stackalloc int[length] : new int[length]; // Or: // Span<int> s = length <= 256 ? stackalloc int[256] : new int[length]; // Which performs a faster zeroing of the stackalloc, but potentially consumes more stack space. Consume(s); }✔️ Harap gunakan fitur C# modern seperti literal koleksi (
Span<int> s = [1, 2, 3];),params Span<T>, dan array inline untuk menghindari manajemen memori manual sebisa mungkin.
15. Buffer ukuran tetap
Buffer berukuran tetap berguna untuk skenario interoperabilitas dengan sumber data dari bahasa atau platform lain. Mereka kemudian digantikan oleh array inline yang lebih aman dan lebih nyaman.
Contoh buffer ukuran tetap (memerlukan unsafe konteks) adalah cuplikan berikut:
public struct MyStruct
{
public unsafe fixed byte data[8];
// Some other fields
}
MyStruct m = new();
ms.data[10] = 0; // Out-of-bounds write, undefined behavior.
Alternatif modern dan lebih aman adalah array sebaris:
[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer
{
private int _element0; // can be generic
}
public struct MyStruct
{
public Buffer buffer;
// Some other fields
}
MyStruct ms = new();
ms.buffer[i] = 0; // Runtime performs a bounds check on index 'i'; could throw IndexOutOfRangeException.
ms.buffer[7] = 0; // Bounds check elided; index is known to be in range.
ms.buffer[10] = 0; // Compiler knows this is out of range and produces compiler error CS9166.
Alasan lain untuk menghindari buffer ukuran tetap yang mendukung array sebaris, yang selalu diinisialisasi nol secara default, adalah bahwa buffer ukuran tetap mungkin memiliki konten yang tidak di-nol dalam skenario tertentu.
Recommendations
- ✔️ DO lebih suka mengganti buffer ukuran tetap dengan array inline atau atribut marshalling IL jika memungkinkan.
16. Meneruskan data yang bersebelahan sebagai pointer + panjang (atau mengandalkan penghentian dengan nol)
Hindari menentukan API yang menerima pointer tidak terkelola atau pointer terkelola ke data kontigu. Sebagai gantinya, gunakan Span<T> atau ReadOnlySpan<T>:
// Poor API designs:
void Consume(ref byte data, int length);
void Consume(byte* data, int length);
void Consume(byte* data); // zero-terminated
void Consume(ref byte data); // zero-terminated
// Better API designs:
void Consume(Span<byte> data);
void Consume(Memory<byte> data);
void Consume(byte[] data);
void Consume(byte[] data, int offset, int length);
Terminasi nol sangat berisiko karena tidak semua buffer diterminasi nol, dan membaca melewati terminator nol dapat menyebabkan pengungkapan informasi, kerusakan data, atau penghentian proses melalui pelanggaran akses.
Recommendations
❌ JANGAN mengekspos metode yang argumennya adalah jenis penunjuk (penunjuk tidak dikelola
T*atau penunjuk terkelolaref T) ketika argumen tersebut dimaksudkan untuk mewakili buffer. Gunakan jenis buffer yang aman sepertiSpan<T>atauReadOnlySpan<T>sebagai gantinya.❌ JANGAN gunakan kontrak implisit untuk argumen byref, seperti mengharuskan semua penelepon mengalokasikan input pada tumpukan. Jika kontrak seperti itu diperlukan, pertimbangkan untuk menggunakan struktur ref sebagai gantinya.
❌ JANGAN berasumsi bahwa buffer berakhiran nol kecuali skenario secara eksplisit mendokumentasikan bahwa ini adalah asumsi yang valid. Misalnya, meskipun .NET menjamin bahwa
stringinstans dan"..."u8literal diakhiri dengan null, hal yang sama tidak berlaku untuk tipe buffer lain terhadapReadOnlySpan<char>atauchar[].unsafe void NullTerminationExamples(string str, ReadOnlySpan<char> span, char[] array) { Debug.Assert(str is not null); Debug.Assert(array is not null); fixed (char* pStr = str) { // OK: Strings are always guaranteed to have a null terminator. // This will assign the value '\0' to the variable 'ch'. char ch = pStr[str.Length]; } fixed (char* pSpan = span) { // INCORRECT: Spans aren't guaranteed to be null-terminated. // This could throw, assign garbage data to 'ch', or cause an AV and crash. char ch = pSpan[span.Length]; } fixed (char* pArray = array) { // INCORRECT: Arrays aren't guaranteed to be null-terminated. // This could throw, assign garbage data to 'ch', or cause an AV and crash. char ch = pArray[array.Length]; } }❌ JANGAN melewatkan
Span<char>atauReadOnlySpan<char>yang dipasang melalui batas p/invoke kecuali Anda juga telah melewatkan argumen panjang secara eksplisit. Jika tidak, kode di sisi lain dari batas p/invoke mungkin salah beranggapan bahwa buffer diakhiri dengan null.
unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe void IncorrectPInvokeExample(ReadOnlySpan<char> data)
{
fixed (char* pData = data)
{
// INCORRECT: Since 'data' is a span and is not guaranteed to be null-terminated,
// the receiver might attempt to keep reading beyond the end of the buffer,
// resulting in undefined behavior.
SomePInvokeMethod(pData);
}
}
Untuk mengatasinya, gunakan tanda tangan p/invoke alternatif yang menerima baik penunjuk data dan panjangnya jika memungkinkan. Jika tidak, jika penerima tidak memiliki cara untuk menerima argumen panjang terpisah, pastikan data asli dikonversi ke string sebelum menyematkannya dan meneruskannya di seluruh batas p/Invoke.
unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe static extern void SomePInvokeMethodWhichTakesLength(char* pwszData, uint cchData);
unsafe void CorrectPInvokeExample(ReadOnlySpan<char> data)
{
fixed (char* pData = data)
{
// OK: Since the receiver accepts an explicit length argument, they're signaling
// to us that they don't expect the pointer to point to a null-terminated buffer.
SomePInvokeMethodWhichTakesLength(pData, (uint)data.Length);
}
// Alternatively, if the receiver doesn't accept an explicit length argument, use
// ReadOnlySpan<T>.ToString to convert the data to a null-terminated string before
// pinning it and sending it across the p/invoke boundary.
fixed (char* pStr = data.ToString())
{
// OK: Strings are guaranteed to be null-terminated.
SomePInvokeMethod(pStr);
}
}
17. Mutasi string
String dalam C# tidak dapat diubah berdasarkan desain, dan setiap upaya untuk mengubahnya menggunakan kode yang tidak aman dapat menyebabkan perilaku yang tidak terdefinisi. Contoh:
string s = "Hello";
fixed (char* p = s)
{
p[0] = '_';
}
Console.WriteLine("Hello"); // prints "_ello" instead of "Hello"
Memodifikasi string yang di-intern (sebagian besar literal string) akan mengubah nilai untuk semua penggunaan lainnya. Bahkan tanpa interning string, penulisan ke dalam string baru yang dibuat harus diganti dengan API String.Create yang lebih aman.
// Bad:
string s = new string('\n', 4); // non-interned string
fixed (char* p = s)
{
// Copy data into the newly created string
}
// Good:
string s = string.Create(4, state, (chr, state) =>
{
// Copy data into the newly created string
});
Recommendations
-
❌ JANGAN mengubah string.
String.CreateGunakan API untuk membuat string baru jika logika penyalinan yang kompleks diperlukan. Jika tidak, gunakan.ToString(),StringBuilder,new string(...), atau sintaks interpolasi string.
18. Kode IL mentah (misalnya, System.Reflection.Emit dan Mono.Cecil)
Memancarkan IL mentah (baik melalui System.Reflection.Emit, pustaka pihak ketiga seperti Mono.Cecil, atau menulis kode IL secara langsung) secara otomatis mengabaikan semua jaminan keamanan memori yang diberikan C#.
Hindari menggunakan teknik tersebut kecuali benar-benar diperlukan.
Recommendations
- ❌ JANGAN memancarkan kode IL mentah karena datang tanpa pedoman dan memudahkan untuk memperkenalkan keamanan jenis dan masalah lainnya. Seperti teknik pembuatan kode dinamis lainnya, memancarkan IL mentah juga tidak ramah AOT jika tidak dilakukan pada waktu build.
- ✔️ Gunakan Source Generators sebaliknya, jika memungkinkan.
- ✔️ Sebaiknya gunakan [UnsafeAccessor] daripada memancarkan IL mentah untuk menulis kode serialisasi dengan overhead rendah pada anggota privat jika diperlukan.
- ✔️ DO ajukanlah proposal API terhadap dotnet/runtime jika suatu API tidak tersedia dan Anda harus menggunakan kode IL mentah sebagai gantinya.
- ✔️ Gunakanlah
ilverifyatau alat serupa untuk memvalidasi kode IL yang dipancarkan, jika Anda harus menggunakan IL mentah.
19. Lokal yang tidak diinisialisasi [SkipLocalsInit] dan Unsafe.SkipInit
[SkipLocalsInit] diperkenalkan dalam .NET 5.0 untuk memungkinkan JIT mengabaikan proses inisialisasi variabel lokal menjadi nol dalam metode, baik secara per metode maupun di seluruh modul. Fitur ini sering digunakan untuk membantu JIT menghilangkan inisialisasi nol redundan, seperti untuk stackalloc. Namun, hal ini dapat menyebabkan perilaku yang tidak terdefinisi jika lokal tidak diinisialisasi secara eksplisit sebelum digunakan. Dengan peningkatan terbaru dalam kemampuan JIT untuk menghilangkan inisialisasi nol dan melakukan vektorisasi, kebutuhan akan [SkipLocalsInit] dan Unsafe.SkipInit telah menurun secara signifikan.
Recommendations
-
❌ JANGAN gunakan
[SkipLocalsInit]danUnsafe.SkipInitjika tidak ada manfaat performa dalam kode yang sering digunakan yang diamati atau Anda tidak yakin tentang risiko yang mungkin timbul dari penggunaannya. - ✔️ Lakukan pengkodean secara defensif saat menggunakan API seperti
GC.AllocateUninitializedArraydanArrayPool<T>.Shared.Rent, yang juga dapat mengembalikan buffer yang tidak diinisialisasi.
20. ArrayPool<T>.Shared dan API pengelompokan serupa
ArrayPool<T>.Shared adalah kumpulan array bersama yang digunakan untuk mengurangi tekanan GC dalam kode panas. Ini sering digunakan untuk mengalokasikan buffer sementara untuk operasi I/O atau skenario berumur pendek lainnya. Meskipun API mudah dan tidak secara inheren berisi fitur yang tidak aman, API dapat menyebabkan bug use-after-free di C#. Contoh:
var buffer = ArrayPool<byte>.Shared.Rent(1024);
_buffer = buffer; // buffer object escapes the scope
Use(buffer);
ArrayPool<byte>.Shared.Return(buffer);
Setiap penggunaan _buffer setelah panggilan Return adalah bug penggunaan setelah pembebasan. Contoh minimal ini mudah ditemukan, tetapi bug menjadi lebih sulit dideteksi kapan Rent dan Return berada dalam cakupan atau metode yang berbeda.
Recommendations
- ✔️ DO terus mencocokkan panggilan ke
RentdanReturndalam metode yang sama jika memungkinkan untuk mempersempit cakupan potensi bug. -
❌ JANGAN gunakan
try-finallypola untuk memanggilReturndalamfinallyblok kecuali Anda yakin bahwa logika yang gagal sudah selesai menggunakan buffer. Lebih baik meninggalkan buffer daripada mempertaruhkan risiko bug penggunaan setelah pembebasan karena awalReturnyang tidak terduga. - ✔️ PERLU diketahui bahwa masalah serupa mungkin muncul dengan API atau pola pengumpulan lainnya, seperti ObjectPool<T>.
21. bool<->int konversi
Meskipun standar ECMA-335 mendefinisikan Boolean sebagai 0-255 di mana true adalah nilai non-nol, lebih baik untuk menghindari konversi eksplisit antara bilangan bulat dan Boolean untuk menghindari pengenalan nilai "denormalisasi" karena apa pun selain 0 atau 1 kemungkinan menyebabkan perilaku yang tidak dapat diandalkan.
// Bad:
bool b = Unsafe.As<int, bool>(ref someInteger);
int i = Unsafe.As<bool, int>(ref someBool);
// Good:
bool b = (byte)someInteger != 0;
int i = someBool ? 1 : 0;
JIT yang ada dalam runtime .NET sebelumnya tidak sepenuhnya mengoptimalkan versi aman logika ini, yang mengakibatkan pengembang menggunakan konstruksi yang tidak aman untuk mengonversi antara bool dan int di jalur kode sensitif performa. Ini tidak lagi terjadi, dan JIT .NET modern dapat mengoptimalkan versi aman secara efektif.
Recommendations
- ❌ JANGAN menulis konversi "tanpa cabang" antara bilangan bulat dan Boolean menggunakan kode yang tidak aman.
- ✔️ DO menggunakan operator terner (atau logika percabangan lainnya) sebagai gantinya. JIT .NET modern akan mengoptimalkannya secara efektif.
-
❌ JANGAN membaca
boolmenggunakan API yang tidak aman sepertiUnsafe.ReadUnalignedatauMemoryMarshal.Castjika Anda tidak mempercayai input. Pertimbangkan untuk menggunakan operator ternary atau perbandingan kesamaan sebagai gantinya:
// Bad:
bool b = Unsafe.ReadUnaligned<bool>(ref byteData);
// Good:
bool b = byteData[0] != 0;
// Bad:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = MemoryMarshal.Cast<byte, bool>(byteSpan).ToArray();
// Good:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = new bool[byteSpan];
for (int i = 0; i < byteSpan.Length; i++) { boolArray[i] = byteSpan[i] != 0; }
Untuk informasi selengkapnya, lihat Serialisasi biner dan deserialisasi struktur dengan padding atau anggota yang tidak dapat di-blit.
22. Interop
Meskipun sebagian besar saran dalam dokumen ini juga berlaku untuk skenario interoperabilitas, disarankan untuk mengikuti panduan praktik terbaik untuk interoperabilitas asli. Selain itu, pertimbangkan untuk menggunakan pembungkus interop yang dibuat secara otomatis seperti CsWin32 dan CsWinRT. Ini meminimalkan kebutuhan Anda untuk menulis kode interop manual dan mengurangi risiko memperkenalkan masalah keamanan memori.
23. Keamanan utas
Keamanan memori dan keamanan utas adalah konsep ortogonal. Kode dapat berupa memori yang aman namun masih berisi ras data, pembacaan robek, atau bug visibilitas; sebaliknya, kode dapat aman utas sambil tetap memanggil perilaku yang tidak terdefinisi melalui manipulasi memori yang tidak aman. Untuk panduan yang lebih luas, lihat Praktik terbaik utas terkelola dan Model Memori .NET.
24. Kode tidak aman di sekitar SIMD/Vektorisasi
Lihat Panduan vektorisasi untuk detail selengkapnya. Dalam konteks kode yang tidak aman, penting untuk diingat:
- Operasi SIMD memiliki persyaratan kompleks untuk memberikan jaminan atomitas (kadang-kadang, mereka tidak memberikannya sama sekali).
- Sebagian besar API Pemanggilan/Penyimpanan SIMD tidak memberikan pemeriksaan batas.
25. Pengujian fuzz
Pengujian fuzz (atau "fuzzing") adalah teknik pengujian perangkat lunak otomatis yang melibatkan penyediaan data yang tidak valid, tidak terduga, atau acak sebagai input ke program komputer. Ini menyediakan cara untuk mendeteksi masalah keamanan memori dalam kode yang mungkin memiliki celah dalam cakupan pengujian. Anda dapat menggunakan alat seperti SharpFuzz untuk menyiapkan pengujian fuzz untuk kode .NET.
26. Peringatan kompilator
Umumnya, pengkompilasi C# tidak memberikan dukungan ekstensif seperti peringatan dan penganalisis terkait penggunaan kode tidak aman yang tidak benar. Namun, ada beberapa peringatan yang ada yang dapat membantu mendeteksi potensi masalah dan tidak boleh diabaikan atau ditekan tanpa pertimbangan yang cermat. Beberapa contohnya meliputi:
nint ptr = 0;
unsafe
{
int local = 0;
ptr = (nint)(&local);
}
await Task.Delay(100);
// ptr is used here
Kode ini menghasilkan peringatan CS9123 ("Operator '&' tidak boleh digunakan pada parameter atau variabel lokal dalam metode asinkron"), yang menyiratkan kode kemungkinan salah.
Recommendations
- ✔️ PERHATIKAN peringatan dari kompiler dan perbaiki masalah mendasar, alih-alih mengabaikannya.
- ❌ JANGAN berasumsi bahwa tidak adanya peringatan kompilator menyiratkan kode sudah benar. Pengkompilasi C# memiliki dukungan yang terbatas atau bahkan tidak ada untuk mendeteksi penggunaan kode tidak aman yang salah.
Referensi
- Kode tidak aman, jenis penunjuk, dan penunjuk fungsi.
- Kode tidak aman, spesifikasi bahasa.
- Apa yang Harus Diketahui Setiap Pengembang CLR Sebelum Menulis Kode untuk topik lanjutan di sekitar coreCLR dan internal GC.
- Praktik terbaik interoperabilitas bawaan.
- Praktik Terbaik Pengelolaan Thread.
- Praktik terbaik untuk pengecualian.
- Pedoman vektorisasi
- Model Memori .NET
- ECMA-335
- Peningkatan ECMA-335