1. Giới thiệu
Trong bài trước mình đã nói về cách C# và .NET Runtime xử lý hầu hết việc quản lý bộ nhớ cho bạn. Tuy nhiên đôi khi cần hoặc muốn có quyền kiểm soát bộ nhớ ở mức thấp hơn, đặc biệt để tối ưu hiệu năng hoặc tương tác với mã unmanaged (ví dụ các API hệ thống viết bằng C++). C# cung cấp một số tính năng cho mục đích này — chúng mạnh mẽ nhưng cần dùng cẩn thận.
Khi nói về "quản lý bộ nhớ cấp thấp" trong C#, thường ta ám chỉ:
- Làm việc trực tiếp với con trỏ: Truy cập bộ nhớ theo địa chỉ, giống như trong C/C++.
- Cấp phát bộ nhớ ngoài heap do GC quản lý: Dùng bộ nhớ mà garbage collector không theo dõi.
- Quản lý tài nguyên ở biên giới managed/unmanaged: Tương tác hiệu quả với thư viện native.
Trong C#, code bình thường không được phép thao tác trực tiếp với địa chỉ bộ nhớ (con trỏ) để đảm bảo an toàn và ổn định. Tuy nhiên cho các thao tác cấp thấp bạn có thể dùng con trỏ trong unsafe context.
Context unsafe là gì?
Một block hoặc phương thức được đánh dấu bằng từ khóa unsafe cho phép bạn dùng cú pháp con trỏ và thực hiện các thao tác mà CLR không kiểm tra về an toàn. Code trong unsafe không an toàn hơn mã native. Để dùng unsafe code project phải được compile với tùy chọn /unsafe.
Ví dụ: Khai báo phương thức và block unsafe
public unsafe class UnsafeExamples
{
// Phương thức unsafe
public static unsafe void ManipulatePointer(int* ptr)
{
Console.WriteLine($"Giá trị tại con trỏ: {*ptr}");
*ptr = 200; // Thay đổi giá trị tại địa chỉ
}
public static void DemoUnsafeBlock()
{
int value = 100;
unsafe // Block unsafe bên trong phương thức bình thường
{
int* ptr = &value; // Lấy địa chỉ của biến
ManipulatePointer(ptr); // Truyền con trỏ vào phương thức unsafe
Console.WriteLine($"Giá trị mới: {value}"); // Output: Giá trị mới: 200
}
}
}
Các loại con trỏ
Trong C# bạn có thể khai báo con trỏ tới các kiểu giá trị (int*, bool*, MyStruct*) và tới void (void* cho con trỏ tổng quát). Không thể khai báo con trỏ trực tiếp tới kiểu tham chiếu, nhưng có thể lấy con trỏ tới field của kiểu tham chiếu nếu field đó là kiểu giá trị.
Ví dụ: Các loại con trỏ khác nhau
unsafe
{
double d = 123.45;
double* dPtr = &d; // Con trỏ tới double
int[] numbers = { 1, 2, 3 };
fixed (int* arrPtr = numbers) // 'fixed' ghim object trong bộ nhớ để GC không di chuyển nó
{
Console.WriteLine($"Phần tử đầu tiên của mảng: {arrPtr[0]}");
Console.WriteLine($"Phần tử thứ hai của mảng: {*(arrPtr + 1)}");
}
}
Toán tử con trỏ
- & (địa chỉ): Lấy địa chỉ của biến.
- * (dereference): Lấy giá trị tại địa chỉ.
- -> (truy cập thành viên): Truy cập thành viên của struct/class qua con trỏ tới nó (chỉ cho kiểu giá trị).
- [] (index): Truy cập phần tử mảng qua con trỏ (như trong C/C++).
2. Các block bộ nhớ ghim và được cấp phát
Khi làm việc với con trỏ, quan trọng là đối tượng mà con trỏ trỏ tới không bị GC di chuyển trong lúc bạn thao tác. Để làm điều này dùng các từ khóa fixed và stackalloc.
Toán tử fixed
Toán tử fixed "ghim" biến kiểu tham chiếu trong bộ nhớ, ngăn không cho nó bị di chuyển bởi garbage collector trong thời gian thực thi block fixed. Điều này rất quan trọng khi bạn truyền con trỏ tới object managed vào code unmanaged hoặc thao tác trực tiếp với chúng.
Ví dụ: Dùng fixed với mảng
public unsafe class FixedExample
{
public static void ProcessFixedArray()
{
byte[] buffer = new byte[10];
// Điền bộ đệm
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i + 1);
}
unsafe
{
// Ghim mảng trong bộ nhớ, lấy con trỏ tới phần tử đầu tiên
fixed (byte* p = buffer)
{
// Giờ có thể làm việc với p, biết chắc mảng sẽ không bị GC di chuyển
Console.WriteLine($"Giá trị tại byte đầu tiên: {*p}");
Console.WriteLine($"Giá trị tại byte thứ hai: {*(p + 1)}");
// Có thể truyền 'p' vào hàm native chấp nhận con trỏ
} // Sau khi ra khỏi block 'fixed', mảng có thể bị GC di chuyển
}
}
}
fixed cũng có thể dùng với field của struct hoặc với string.
Cấp phát trên stack: stackalloc
stackalloc cho phép cấp phát một block bộ nhớ trên stack. Rất nhanh nhưng bộ nhớ chỉ tồn tại đến khi phương thức hiện tại kết thúc. Bộ nhớ cấp phát không được quản lý bởi GC.
- Lợi điểm: Cấp phát cực nhanh, không overhead của GC, giải phóng bộ nhớ xác định.
- Hạn chế: Kích thước giới hạn (stack nhỏ hơn heap), nguy cơ tràn stack (StackOverflowException) nếu cấp phát quá lớn.
- Sử dụng: Phù hợp cho các buffer nhỏ, sống ngắn.
Ví dụ: Dùng stackalloc
public unsafe class StackAllocExample
{
public static void ProcessStackAlloc()
{
unsafe
{
// Cấp phát 10 int trên stack
int* numbers = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
numbers[i] = i * 10;
}
Console.WriteLine($"Giá trị phần tử đầu tiên: {numbers[0]}");
Console.WriteLine($"Giá trị phần tử thứ năm: {numbers[4]}");
} // Bộ nhớ tự động được giải phóng khi thoát khỏi phương thức này.
}
}
Toán tử stackalloc có thể dùng cùng với Span<T>, giúp làm việc với bộ nhớ an toàn hơn vì Span<T> là kiểu quản lý (struct) cung cấp truy cập an toàn tới các block bộ nhớ liên tục. Mình sẽ nói chi tiết hơn về Span trong bài tiếp theo.
Ví dụ: stackalloc với Span<T>
using System;
public class StackAllocWithSpan
{
public static void DemoSpanStackAlloc()
{
// Cấp phát trên stack, nhưng làm việc qua Span
an toàn Span
buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Phần tử đầu tiên của Span: {buffer[0]}"); Console.WriteLine($"Phần tử cuối cùng của Span: {buffer[9]}"); // Có thể truyền Span
vào các phương thức nhận nó PrintSpan(buffer); } public static void PrintSpan(Span
s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } }
Đây là cách kết hợp: cấp phát trên stack (cấp thấp), nhưng truy cập an toàn qua Span<T> (cấp cao).
3. Tương tác với mã unmanaged (P/Invoke)
Platform Invoke (P/Invoke) là cơ chế cho phép code C# gọi các hàm trong thư viện unmanaged (ví dụ DLL của Windows API hoặc file .so trên Linux). Đây là phần quan trọng của quản lý bộ nhớ cấp thấp vì thường bạn sẽ truyền con trỏ dữ liệu giữa managed và unmanaged.
Khai báo hàm extern
Bạn dùng attribute DllImport để khai báo các phương thức extern tĩnh tương ứng với hàm native.
Ví dụ 3.1: Gọi hàm Windows API
using System.Runtime.InteropServices;
public class PInvokeExample
{
// Import hàm native MessageBox từ user32.dll
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
public static void ShowMessageBox()
{
// Gọi hàm native
MessageBox(IntPtr.Zero, "Xin chào từ C#!", "Tiêu đề cửa sổ", 0);
}
}
Marshaling dữ liệu
Khi gọi hàm native, .NET Runtime thực hiện marshaling — chuyển đổi kiểu dữ liệu giữa định dạng managed và unmanaged. Ví dụ, string trong C# có thể được marshal thành char* hoặc wchar_t* trong C++.
Với marshaling phức tạp hơn bạn có thể dùng attribute MarshalAs và lớp Marshal.
Ví dụ: Marshaling struct
using System.Runtime.InteropServices;
// Struct này sẽ được marshal thành struct native
[StructLayout(LayoutKind.Sequential)] // Chỉ định rằng các field phải sắp xếp tuần tự
public struct NativePoint
{
public int X;
public int Y;
}
public class StructMarshalExample
{
[DllImport("your_native_lib.dll")] // Ví dụ: hàm trong thư viện native
public static extern void ProcessPoint(NativePoint point);
[DllImport("your_native_lib.dll")]
public static extern void FillPoint(out NativePoint point); // Nhận con trỏ tới struct
public static void DemoStructMarshal()
{
NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
ProcessPoint(myPoint); // Struct sẽ được marshal theo giá trị (sao chép)
NativePoint resultPoint;
FillPoint(out resultPoint); // Struct sẽ được điền bởi hàm native
Console.WriteLine($"Point từ native: ({resultPoint.X}, {resultPoint.Y})");
}
}
4. GCHandle và ghim đối tượng
GCHandle là struct cho phép bạn có một "handle" tới object trong heap managed và, khi cần, ghim nó tạm thời để ngăn không cho bị di chuyển hoặc thu dọn bởi GC. Điều này hữu ích khi bạn cần truyền một con trỏ cố định tới object managed vào code unmanaged.
Ví dụ: Ghim object với GCHandle
using System.Runtime.InteropServices;
public class GCHandleExample
{
public static void PinObject()
{
byte[] data = new byte[100];
GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Ghim mảng trong bộ nhớ
try
{
IntPtr pointer = handle.AddrOfPinnedObject(); // Lấy con trỏ tới object đã ghim
Console.WriteLine($"Địa chỉ mảng đã ghim: {pointer:X}");
// Giờ 'pointer' có thể truyền an toàn vào hàm native.
// Hàm native có thể thao tác trực tiếp trên bộ nhớ này.
Marshal.WriteByte(pointer, 0, 255); // Thay đổi byte đầu tiên qua con trỏ
Console.WriteLine($"Byte đầu tiên của mảng: {data[0]}"); // Output: 255
}
finally
{
if (handle.IsAllocated)
{
handle.Free(); // Giải phóng handle, cho phép GC quản lý lại object
}
}
}
}
Phân tích chi tiết về P/Invoke và GCHandle vượt quá phạm vi khóa học này, nhưng chúng rất hữu dụng nếu bạn muốn gọi trực tiếp hàm từ thư viện Windows. Ít nhất bây giờ bạn đã biết nên bắt đầu từ đâu.
GO TO FULL VERSION