Thao tác không sao chép (Zero-Copy) an toàn trong C#

Nỗ lực của tôi khi nói về một trong những tính năng bị đánh giá thấp nhất của C#.

C# là một ngôn ngữ đa năng. Bạn có thể viết ứng dụng di động, ứng dụng desktop, game, website, dịch vụ và API với nó. Bạn có thể viết nó như Java với tất cả các phần trừu tượng và AbstractionFactoryClassProvider. Nhưng khác với Java, bạn cũng có thể viết mã cấp thấp và không an toàn. Khi tôi nói cấp thấp, tôi muốn nói đến việc không có GC, với con trỏ thô.

Mã cấp thấp thường cần thiết cho hiệu suất hoặc khả năng tương tác với thư viện C hoặc hệ điều hành. Lý do mã cấp thấp giúp cải thiện hiệu suất là nó có thể được sử dụng để loại bỏ các kiểm tra runtime trên truy cập bộ nhớ.

Vấn đề kiểm tra biên giới

Truy cập phần tử mảng được kiểm tra biên giới trong C# để đảm bảo an toàn. Nhưng điều đó có nghĩa là có tác động đến hiệu suất trừ khi trình biên dịch có thể loại bỏ thao tác kiểm tra biên giới. Logic loại bỏ kiểm tra biên giới cần đảm bảo rằng chỉ số mảng đã được kiểm tra biên giới trước đó, hoặc có thể đảm bảo nằm trong biên giới trong thời gian biên dịch. Ví dụ, hãy xem xét hàm đơn giản này:


int sum(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}
return sum;
}

Đó là tình huống lý tưởng để loại bỏ kiểm tra biên giới vì biến chỉ số i được tạo với biên giới đã biết và nó phụ thuộc vào độ dài của mảng. Vòng đời của biến chỉ số ngắn hơn vòng đời của mảng và nó được đảm bảo chứa các giá trị hợp lệ trong suốt hàm. Mã native được tạo cho sum không có kiểm tra biên giới.

Nhưng, điều gì sẽ xảy ra nếu chữ ký hàm hơi khác một chút?


int sum(int[] array, int startIndex, int endIndex)
{
int sum = 0;
for (int i = startIndex; i <= endIndex; i++)
{
sum += array[i];
}
return sum;
}

Bây giờ, trình biên dịch C# không có cách nào để biết liệu các giá trị startIndexendIndex được truyền có nằm trong biên giới của array hay không vì vòng đời của chúng là riêng biệt. Vì vậy, assembly native được tạo ra trở nên phức tạp hơn với các thao tác kiểm tra biên giới.

Mã không an toàn và con trỏ

Chúng ta có thể sử dụng các hàm không an toàn cấp thấp và con trỏ trong C# (đúng vậy, C# hỗ trợ con trỏ thô!) để tránh hoàn toàn việc kiểm tra biên giới, như thế này:


unsafe int sum(int* ptr, int length)
{
int* end = ptr + length;
int sum = 0;
for (; ptr < end; ptr++)
{
sum += *ptr;
}
return sum;
}

Điều đó cũng tạo ra mã được tối ưu hóa rất cao hỗ trợ truyền một phần nhỏ của một mảng.

Mã không an toàn và số học con trỏ có thể rất hiệu suất như bạn có thể thấy. Vấn đề là nó quá nguy hiểm. Với các giá trị độ dài không chính xác, bạn không chỉ đơn giản nhận được IndexOutOfRangeException, mà thay vào đó, ứng dụng của bạn hoặc là bị crash, hoặc trả về kết quả không chính xác. Nếu mã của bạn xảy ra sửa đổi vùng bộ nhớ thay vì chỉ đọc nó, thì bạn có thể có một điểm vào tốt cho lỗ hổng bảo mật tràn bộ đệm trong ứng dụng của mình. Chưa kể rằng tất cả các hàm gọi hàm đó cũng sẽ phải có các khối không an toàn.

Giải pháp an toàn với Span

Nhưng có thể xử lý điều này một cách an toàn và nhanh chóng trong C# mà không cần phải sử dụng các nghi thức huyền bí như vậy. Đầu tiên, làm thế nào để bạn giải quyết vấn đề về chỉ số để mô tả một phần của mảng và ranh giới thực tế của mảng bị tách rời khỏi nhau? Bạn tạo một kiểu bất biến mới giữ các giá trị này cùng nhau. Và kiểu đó được gọi là span trong C#. Các ngôn ngữ lập trình khác có thể gọi nó là slice. Khai báo kiểu Span giống như thế này. Chà, nó không chính xác như thế này, nhưng tôi muốn bạn hiểu khái niệm trước:


readonly struct Span<T>
{
readonly T* _ptr;
readonly int _len;
}

Về cơ bản nó là một con trỏ bất biến với độ dài. Điều tuyệt vời về một kiểu như thế này là trình biên dịch có thể đảm bảo rằng một khi một Span bất biến được khởi tạo với ranh giới chính xác, nó sẽ luôn an toàn để truy cập mà không cần bất kỳ kiểm tra biên giới nào. Điều đó có nghĩa là, bạn có thể truyền các khung nhìn con của mảng hoặc thậm chí các span khác một cách an toàn và nhanh chóng mà không có chi phí hiệu suất.

Nhưng, làm thế nào nó có thể an toàn? Điều gì sẽ xảy ra nếu GC quyết định vứt bỏ cấu trúc mà ptr trỏ đến? Chà, đó là nơi mà “ref types” xuất hiện trong C#.

Một ref type là một kiểu không thể rời khỏi stack và thoát ra heap, vì vậy nó luôn được đảm bảo rằng một thể hiện T sẽ tồn tại lâu hơn một thể hiện Span<T> được tạo ra từ nó. Đó là lý do tại sao khai báo Span<T> thực tế trông như thế này:


readonly ref struct Span<T> // chú ý "ref"
{
readonly ref T _ptr; // chú ý "ref"
readonly int _len;
}

Vì một ref type chỉ có thể sống trong stack, nó không thể là thành viên của một class, cũng không thể được gán cho một biến non-ref, ví dụ, nó cũng không thể được box. Một ref type chỉ có thể được chứa bên trong một ref type khác. Đó là ref types xuyên suốt đến tận cùng.

Phiên bản Span của hàm sum

Phiên bản Span của hàm sum của chúng ta có thể loại bỏ kiểm tra biên giới, và nó cũng có thể có các siêu năng lực khác. Điều đầu tiên là nó có thể nhận một khung nhìn con của một mảng:


int sum(Span<int> span)
{
int sum = 0;
for (int i = 0; i < span.Length; i++)
{
sum += span[i];
}
return sum;
}

Ví dụ, bạn có thể gọi hàm này với sum(array) hoặc bạn có thể gọi nó với một khung nhìn con của mảng như sum(array[startIndex..endIndex]). Điều đó sẽ không gây ra các thao tác kiểm tra biên giới mới ngoại trừ khi bạn đang cố gắng cắt mảng bằng toán tử phạm vi.

Siêu năng lực khác: ReadOnlySpan

Một siêu năng lực khác bạn nhận được là khả năng khai báo cấu trúc dữ liệu mà bạn nhận được là “bất biến” trong chữ ký hàm của bạn, để hàm bị cấm thay đổi nó, và bạn có thể tìm thấy các lỗi liên quan ngay lập tức. Tất cả những gì bạn cần làm là thay thế Span<T> bằng ReadOnlySpan<T>. Sau đó, các nỗ lực của bạn để sửa đổi nội dung span sẽ ngay lập tức gây ra lỗi trình biên dịch. Điều gì đó không thể thực hiện được với các mảng thông thường, ngay cả khi bạn khai báo chúng là readonly. Chỉ thị readonly chỉ bảo vệ tham chiếu khỏi sự thay đổi chứ không phải nội dung của cấu trúc dữ liệu.

Làm thế nào nó liên quan đến zero-copy?

Truyền một phần nhỏ hơn của một cấu trúc dữ liệu lớn hơn đến các API có liên quan từng liên quan đến việc sao chép hoặc truyền các giá trị offset và độ dài của phần liên quan cùng với cấu trúc dữ liệu. Nó yêu cầu API phải có các overload với phạm vi. Không thể đảm bảo tính an toàn của các API như vậy vì mối quan hệ giữa các tham số không thể được thiết lập bởi trình biên dịch hoặc runtime.

Bây giờ, việc triển khai các thao tác zero-copy an toàn bằng cách sử dụng spans trở nên dễ dàng và biểu cảm. Hãy xem xét một triển khai Quicksort chẳng hạn; nó thường có một hàm như thế này hoạt động với các phần của một mảng đã cho:


int partition(int[] array, int low, int high)
{
int midpoint = (high + low) / 2;
int mid = array[midpoint];

(array[midpoint], array[^1]) = (array[^1], array[midpoint]);
int pivotIndex = 0;
for (int i = low; i < high - 1; i++)
{
if (array[i] < mid)
{
(array[i], array[pivotIndex]) = (array[pivotIndex], array[i]);
pivotIndex += 1;
}
}
(array[midpoint], array[^1]) = (array[^1], array[midpoint]);
return pivotIndex;
}

Hàm này nhận một mảng, các offset bắt đầu và kết thúc vào mảng, và sắp xếp lại các mục dựa trên một giá trị được chọn trong đó. Các giá trị nhỏ hơn giá trị được chọn di chuyển sang trái, các giá trị lớn hơn di chuyển sang phải.

Với spans, cùng hàm Quicksort trông như thế này:


void Quicksort(Span<int> span)
{
if (span.Length <= 1)
{
return;
}

int pivot = partition(span);
Quicksort(span[..pivot]);
Quicksort(span[(pivot + 1)..]);
}

Xem cách sử dụng spans biểu cảm như thế nào đặc biệt là với cú pháp phạm vi? Nó cho phép bạn lấy một span mới ra khỏi một span hoặc mảng hiện có bằng cách sử dụng dấu chấm kép (..). Ngay cả hàm partition cũng trông tốt hơn nhiều.

Các thao tác zero-copy mới trong .NET runtime

Tôi đã sử dụng các ví dụ sử dụng lệnh gọi đệ quy để cho thấy cách các phần con của một cấu trúc dữ liệu lớn hơn có thể được chuyển đến một hàm khác mà không cần sao chép, nhưng spans có thể được sử dụng hầu như ở mọi nơi, và bây giờ .NET runtime cũng hỗ trợ các giải pháp thay thế zero-copy của các hàm phổ biến.

Ví dụ, hãy xem String.Split. Bây giờ bạn có thể tách một chuỗi mà không cần tạo các bản sao mới của mỗi phần tách của chuỗi. Thay vì:


string csvLine = // .. đọc dòng CSV
string[] parts = csvLine.Split(',');

foreach (string part in parts)
{
Console.WriteLine(part);
}

Bạn có thể thay thế bằng:


string csvLine = // .. đọc dòng CSV

var span = csvLine.AsSpan();
var parts = span.Split(',');
foreach (var range in parts)
{
Console.Out.WriteLine(span[range]);
}

Lợi ích tuyệt vời của thử nghiệm đó là không phân bổ bộ nhớ nào sau khi đọc dòng CSV vào bộ nhớ. Một logic tương tự cải thiện hiệu suất và hiệu quả bộ nhớ có thể được áp dụng ở mọi nơi có thể nhận Span<T>/ReadOnlySpan<T> thay vì một mảng.

Đón nhận tương lai

Spans và các cấu trúc slice-like là tương lai của các thao tác bộ nhớ an toàn trong các ngôn ngữ lập trình hiện đại. Hãy đón nhận chúng. Các điểm nhanh cần nhớ là:

  • Sử dụng spans thay vì mảng trong khai báo hàm của bạn trừ khi bạn yêu cầu một mảng độc lập trong hàm của mình vì một lý do nào đó. Một thay đổi như vậy mở ra API của bạn vào các kịch bản tối ưu hóa zero-copy và mã gọi sẽ biểu cảm hơn.
  • Đừng bận tâm với unsafe/pointers nếu bạn có thể mã hóa cùng logic với spans. Bạn vẫn có thể thực hiện các thao tác bộ nhớ cấp thấp mà không cần đi lang thang vào các phần nguy hiểm của khu rừng. Mã của bạn vẫn sẽ nhanh, và an toàn hơn.

Sử dụng spans, bất cứ khi nào có thể, chủ yếu là readonly.

Chỉ mục