CodeGym /Các khóa học /C# SELF /Giới thiệu về Span<T>...

Giới thiệu về Span<T>ReadOnlySpan<T>

C# SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Khi bạn làm việc với mảng, chuỗi và buffer kiểu byte, thường cần "nhìn" vào một phần của dữ liệu đó. Ví dụ: lấy một substring, slice một mảng, xử lý một phần của luồng vào. Trong các phiên bản .NET cũ, để làm việc này thường phải copy dữ liệu (tạo mảng/substr mới) hoặc viết vòng lặp bắt đầu từ chỉ số này đến chỉ số kia. Tất cả điều đó không vui cả về hiệu năng lẫn độ dễ đọc.

Ví dụ cách cũ: cần truyền vào method chỉ một phần của mảng lớn:

// Cách cũ — copy một phần mảng (không hiệu quả!)
int[] source = new int[] { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] subArray = source.Skip(2).Take(4).ToArray(); // tạo một mảng mới

Vì vậy, nếu bạn muốn truyền một "slice" của mảng (hoặc thậm chí một phần của chuỗi) hiệu quả mà không tạo đối tượng thừa, các công cụ cũ của C# thua hẳn, đặc biệt khi xử lý lượng dữ liệu lớn.

Đó chính là lúc nhân vật chính xuất hiện — Span<T>!

2. Span<T> là gì? Ý tưởng chính

Span<T> là một kiểu đại diện cho một vùng nhớ liên tục cùng loại T. Mục đích của nó là cung cấp cho bạn cách nhanh, an toàn và hiệu quả để làm việc với các mảnh mảng, chuỗi, struct, và cả bộ nhớ unmanaged (ví dụ bộ nhớ cấp phát ngoài runtime .NET).

Điểm nổi bật của Span<T>"slice mà không tạo mảng mới". Hãy tưởng tượng một cây thước, bạn có thể đo các đoạn của cùng một mảng mà không copy dữ liệu và giảm thiểu lỗi chỉ số.

Tóm tắt:

  • Span<T> — một "cửa sổ" hoặc "view" lên một mảnh bộ nhớ, dễ và an toàn để thao tác.
  • Không cấp phát bộ nhớ mới — tiết kiệm tài nguyên và giảm công việc cho GC.
  • Hoạt động không chỉ với mảng mà còn với các đoạn chuỗi, khối stackalloc và cả bộ nhớ unmanaged.
  • Không thể lưu trong trường của class bình thường: đây là kiểu chỉ trên stack (stack-only struct).

Tại sao quan trọng?

Trong các tác vụ hiệu năng cao (parse file, xử lý buffer lớn, crypto, serialization) tiết kiệm từng phép copy mảng có thể mang lại tốc độ lớn và giảm tải cho garbage collector (GC). Và hơn nữa, bạn sẽ chứng tỏ với đồng đội rằng bạn rành các tính năng C#/.NET hiện đại!

3. Sử dụng cơ bản của Span<T>: slice đầu tiên

int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

// Tạo Span trên một phần mảng (ví dụ: phần tử từ chỉ số 2 đến 5)
Span<int> middle = new Span<int>(numbers, 2, 4); // chỉ số: 2, 3, 4, 5

// Chúng ta nhìn thấy mảng con: 3, 4, 5, 6
Console.WriteLine(string.Join(", ", middle.ToArray())); // 3, 4, 5, 6

// Thay đổi trên Span thay đổi mảng gốc!
middle[1] = 999;
Console.WriteLine(numbers[3]); // 999

Quan trọng! Span<T> không copy dữ liệu, chỉ trỏ tới "mảnh" mảng. Mọi thay đổi thấy ở cả mảng gốc và Span.

4. Các cách tạo Span<T>

Từ mảng:

int[] arr = { 10, 20, 30, 40, 50 };
Span<int> span = arr; // toàn bộ
Span<int> slice = arr.AsSpan(1, 3); // phần tử 20, 30, 40

Từ một phần của mảng:

Span<int> part = new Span<int>(arr, 2, 2); // phần tử 30, 40

stackalloc: cấp phát trên stack (cực nhanh và không vào heap):

Span<byte> buffer = stackalloc byte[128];
buffer[0] = 42;

Dùng phương thức .Slice():

Span<int> subSpan = span.Slice(1, 2); // phần tử 20, 30

Sơ đồ trực quan của "slice"


Mảng ban đầu:  1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
                           <--- Span: 3, 4, 5, 6 --->

5. Giới hạn và đặc điểm của Span<T>

  • Chỉ trên stack! Không thể lưu trong trường của class "bình thường" hoặc làm thành phần của closure — đây là kiểu chạy trên stack.
  • Không thể dùng làm trường của class hoặc trả về từ các phương thức async (compiler sẽ báo lỗi).
  • Không thể capture trong lambda/anonymous methods — dùng "tại chỗ" thay vì giữ lâu dài.
  • Không thể trực tiếp serialize hoặc truyền giữa threads.

Lý do là Span có thể trỏ tới bất kỳ vùng nhớ nào, và nếu nó "đi vào" heap thì trạng thái có thể trở nên không an toàn.

6. Tính bất biến: ReadOnlySpan<T>

Đôi khi cần "nhìn" vào mảnh bộ nhớ nhưng không muốn sửa nó. Dành cho trường hợp này có phiên bản bất biến — ReadOnlySpan<T>.

string text = "Hello, Span!";
ReadOnlySpan<char> letters = text.AsSpan(7, 4); // 'S', 'p', 'a', 'n'
Console.WriteLine(string.Join(", ", letters.ToArray())); // S, p, a, n

// letters[0] = 'Z'; // Lỗi: indexer chỉ đọc!

Kịch bản kinh điển — truyền an toàn một "miếng" chuỗi hoặc mảng đến nơi mà không muốn (và không cho phép) sửa đổi.

7. Thực hành: cắt mảng và chuỗi

Giả sử đây là một trình phân tích dữ liệu, từ một chuỗi dài lấy ra substring, tìm các số trong đó và trả về tổng (không copy thừa khi lấy slice ban đầu):

using System;

class Program
{
    static void Main()
    {
        // Giả sử người dùng nhập một chuỗi dài các số, cách nhau bằng dấu cách
        string input = "12 34 56 78 90 123 456 789";
        // Chúng ta cần tính tổng các số chỉ trong "vùng giữa", ví dụ: 56 78 90

        // Lấy substring (nhưng không copy!)
        ReadOnlySpan<char> center = input.AsSpan(6, 8); // chỉ số có thể tính động

        // Parse các số bằng Split (tạo mảng tạm)
        string[] numbers = center.ToString().Split(' ');
        int sum = 0;
        foreach (var str in numbers)
        {
            if (int.TryParse(str, out int num))
                sum += num;
        }
        Console.WriteLine($"Tổng các số ở giữa: {sum}");
    }
}

Các thư viện parse CSV/JSON hiện đại dùng Span để đạt tốc độ cao khi làm việc với lượng lớn dữ liệu chuỗi — giờ bạn đã hiểu "ma thuật" phía sau rồi.

8. Những chi tiết hữu ích

Span so với copy mảng

// Cách cũ: copy một đoạn mảng
int[] arr = Enumerable.Range(0, 1000000).ToArray();
int[] firstThousand = arr.Take(1000).ToArray(); // tạo một mảng mới 1000 phần tử

// Cách mới: Span
Span<int> bestThousand = arr.AsSpan(0, 1000); // không copy chút nào!
bestThousand[0] = 42; // cũng thay đổi trong arr

Sự khác biệt rõ ràng khi parse file nặng, xử lý buffer mạng, hoặc làm việc với dữ liệu nhị phân.

Ứng dụng thực tế: tại sao cần biết về Span

  • Parse và xử lý dữ liệu dạng chuỗi/binary hiệu năng cao. Các thư viện serialization hiện đại (ví dụ, System.Text.Json, Span trong tài liệu Microsoft) dùng Span để tăng tốc.
  • Buffering và đọc file (cắt buffer lớn mà không copy).
  • Xử lý dữ liệu trong môi trường hạn chế bộ nhớ (embedded, IoT) — tài liệu chính thức về Memory/Spans.
  • Thuật toán xử lý ảnh và âm thanh, nơi tốc độ và ít allocation rất quan trọng.
  • Tăng tốc parse CSV, JSON, XML bằng Span — đặc biệt trong .NET 8/9.

Trong phỏng vấn, câu hỏi về Span xuất hiện kể từ khi nó ra mắt trên .NET Core 2.1+, và trong .NET 9 ngày càng thường gặp như kiến thức mong muốn.

Sơ đồ trực quan: đâu là Span, đâu là mảng


+--------------------+
|   int[]  mảng      |
|  1 2 3 4 5 6 7 8   |
+--------------------+
       ^       ^
       |       |
   [ 2, 3, 4, 5 ]  <-- Span<int> "cửa sổ bộ nhớ" (slice)

Span<T> — không phải mảng riêng, mà là "thấu kính trong suốt" nhìn vào phần dữ liệu.

Khác biệt với các collection khác: bảng so sánh

Loại Có lưu dữ liệu không? Có thể sửa phần tử? Có thay đổi kích thước được không? Có sao chép khi cắt? Nằm ở
int[]
Không Có (qua .Take) Heap
List<int>
Heap
Span<int>
Không Không Không Stack
ReadOnlySpan<int>
Không Không Không Không Stack

9. Lỗi phổ biến khi làm việc với Span/ReadOnlySpan

Lỗi #1: cố gắng lưu Span dưới dạng field của class. Compiler sẽ báo "Span type may not be used in this context". Điều này là có chủ đích: lưu Span trong field là không an toàn.

Lỗi #2: trả Span từ phương thức async. Không được làm vậy, vì phương thức async có thể "chạy lên heap". Thay vào đó dùng mảng hoặc kiểu khác.

Lỗi #3: quên rằng thay đổi qua Span sẽ phản ánh trên mảng gốc. Điều này có thể vô tình sửa dữ liệu "bên ngoài" và dẫn tới hành vi khó đoán.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION