+6

[Non-alloc C#] #1 Struct với Class khác nhau thế nào? Optimize code với Value type và Span trong C#

Xin chào mọi người, mình là Summer, mình hiện đang là C# và Unity developer dưới 20 năm kinh nghiệm 😃 Gần đây có thời gian nên mình sẽ viết 1 series về tối ưu bộ nhớ trong C#
Bài đầu tiên sẽ trả lời cho câu hỏi kinh điển cho các buổi phỏng vấn "Em hãy phân biệt Class và Struct trong C#?"
Mọi người thường sẽ trả lời là Class có thế kế thừa còn Struct thì không, Struct thường dùng cho những data đơn giản hơn, Class là Reference Type còn Struct là Value Type... chúng ta sẽ tìm hiểu về cụm mà mình đã tô đậm, đó cũng là thứ mà người phỏng vấn muốn nghe

Stack với Heap

Nếu bạn nào từng học qua môn Kĩ thuật lập trình hay Hệ điều hành sẽ biết về 2 khái niệm này, mình sẽ tóm tắt ngắn gọn, bạn biết rồi có thể skip
Các chương trình khi hoạt động cần sử dụng bộ nhớ trên RAM. Trước tiên ta hãy nhớ về khái niệm cấp phát (allocate) và thu hồi (deallocate) vùng nhớ

  • Chương trình không thể tự tiện truy xuất vùng nhớ mà phải "xin phép" hệ điều hành, khi đó chương trình "sở hữu" vùng nhớ này và hệ điều hành đảm bảo sẽ không có 1 chương trình nào khác can thiệp vào, quá trình này gọi là cấp phát vùng nhớ (memory allocation)
  • Sau khi sử dụng xong vùng nhớ đã cấp phát, chương trình cần "trả" nó lại cho hệ điều hành (để chương trình khác có thể sử dụng), quá trình này gọi là thu hồi vùng nhớ (memory deallocation)


Về cơ bản, vùng nhớ của 1 chương trình có 2 loại chính là StackHeap (ngoài ra còn có Global segment và Code segment, 2 thằng fixed size nên không bàn nhé 😃). Stack và Heap khác nhau trong cách quản lý dữ liệu đã cấp phát

  • Stack memory: Quá trình cấp phát và thu hồi trong Stack sẽ hoạt động theo kiểu LIFO, nghĩa là dữ liệu nào được cấp phát trước sẽ bị thu hồi sau, tại sao nó lại thế?

    • Do Stack được sinh ra để phục vụ cho quá trình gọi hàm: ví dụ hàm A() gọi hàm B(), khi đó A() bắt đầu trước B() nhưng lại kết thúc sau, thì các dữ liệu trong hàm A() sẽ được cấp phát trước, sau đó mới tới B(); còn khi thu hồi thì ngược lại, dữ liệu trong B() bị thu hồi trước, sau đó tới A()
      // Allocate sau A()
      // Deallocate trước B()
      public void B() { 
          int b = 5;
      }
      // Allocate trước B()
      // Deallocate sau B()
      public void A() {
          int a = 3;
      }
    
    • Thế nên, Stack chỉ chứa dữ liệu phát sinh trong quá trình gọi hàm (aka local variable), các dữ liệu trong hàm sẽ bị thu hồi sau khi hàm kết thúc. Tuy nhiên, 1 chương trình phải phát sinh dữ liệu nằm ngoài scope của hàm aka global variable (không bị thu hồi sau khi hàm kết thúc), từ đó Heap ra đời
  • Heap memory: Dữ liệu được cấp phát sẽ không có thứ tự xác định, để truy cập dữ liệu trong Heap cần phải thông qua địa chỉ (khi cấp phát xong thì hệ điều hành sẽ trả về địa chỉ này)

    • Mục đích của Heap là tạo ra các global variable, nên dữ liệu trên Heap sẽ không tự động thu hồi sau khi kết thúc hàm (quá trình thu hồi cần phải được chương trình tự thực hiện, tuy nhiên Garbage collector đã làm hộ chúng ta)
      public class AllocatedClass { }
      public void A() {
        // Biến "a" sẽ không bị tự động thu hồi sau khi A() kết thúc
        // Quá trình thu hồi sẽ do Garbage collector đảm nhận
        var c = new AllocatedClass();
      }
    

Hiệu năng (performance) và kích thước (size) là thứ đáng chú ý giữa StackHeap!

  • Stack có kích thước nhỏ hơn Heap nhiều, do Stack chỉ cần chứa các local variables, trong khi đó Heap cần phải chứa tất cả dữ liệu "sống" trong chương trình
  • Hiệu năng của Stack (cấp phát/thu hồi/truy xuất) cao hơn Heap rất nhiều, điều này hiển nhiên do các hàm sẽ được gọi rất nhiều lần trong chương trình
  • Thêm vào đó, Garbage collector là thành phần ảnh hưởng lớn đến hiệu năng của chương trình, càng nhiều dữ liệu trên Heap thì nó sẽ cần phải xử lý nhiều hơn.

Vậy bài học rút ra là... Hãy tận dụng Stack, và hạn chế Heap nhiều nhất có thể đối với các giá trị tạm thời

Value type và Reference type

Trong C# có 2 loại dữ liệu là Value type (managed data types)Reference type (unmanaged data types), mỗi loại sẽ đại diện cho 1 số kiểu dữ liệu

  • Reference type: nó chính là class (dạo gần đây còn có thêm record), các kiểu dữ liệu khi khai báo dưới dạng Reference type thì sẽ luôn luôn nằm trên Heap
  • Value type: bao gồm các kiểu dữ liệu các bạn thường thấy int, float, bool, byte, char, enum, struct... (stringclass nhé), các dữ liệu này có thể nằm trên Heap hay Stack tùy vào cách sử dụng

Các ảnh chụp dưới đây mình sử dụng dotMemory của Rider để thống kê

Reference type

Như đã nói, classrecord sẽ luôn nằm trên Heap cho dù bạn có khai báo là nó là local variable hay global variable

public class MyClass { }
public record MyRecord { }
void HeapOnly()
{
    // Class/Record instance sẽ luôn nằm trên Heap dù có là local variable
    var onlyHeapClass = new MyClass();
    var onlyHeapRecord = new MyRecord();
}

Khi chạy hàm HeapOnly(); sẽ cho thấy có 2 dữ liệu được cấp phát trên Heap

Value type

Value type (int, bool, struct, enum) sẽ nằm trên Stack nếu nó là local variable của 1 hàm.

public struct StackOnlyStruct { }
void StackOnly() {
    int stackOnlyInt = 5;
    AnotherStackOnly(stackOnlyInt, ref stackOnlyInt, false);
}
void AnotherStackOnly(int valueParameterWillBeCopiedIntoTheFunction,
    ref int useRefModifierForPassingByReferenceToAvoidCopying, //Pass by reference
    in bool useInModifierForPassingByReferenceAndReadonly)  //Pass by reference and readonly
{
    var stackOnlyStruct = new StackOnlyStruct();
}


Ta có thể thấy khi gọi hàm StackOnly() sẽ không cấp phát trên Heap do đều sử dụng các Value type (hình dưới cho thấy không có instance nào của StackOnlyStruct trên Heap)
Cần phải chú ý các Value type khi được gửi vào các hàm sẽ bị copy vào hàm (tự động tạo 1 biến mới tương tự trong hàm), để tránh quá trình này cần sử dụng ref (Pass by reference) hay in (Tương tự ref nhưng readonly) (quá trình này gọi là Defensive copy, bài sau mình sẽ hướng dẫn cách hạn chế nó)


Nếu struct có chứa class thì cũng chỉ có class đó cấp phát trên Heap, còn struct vẫn trên Stack (và giữ địa chỉ của class instance nằm trong Heap)

public struct StackOnlyStruct
{
    public MyClass AReferenceDoesNotMakeTheStructStayOnHeap;
}
void StackOnly()
{
    var stackOnlyStruct = new StackOnlyStruct {
      AReferenceDoesNotMakeTheStructStayOnHeap = new MyClass()
    };
}



Có thể thấy chỉ có MyClass là trên Heap còn StackOnlyStruct vẫn trên Stack

Value type sẽ trên Heap nếu nó nằm trong 1 Reference type

Khi một Value type trở thành field của Reference type thì nó sẽ nằm trên Heap, điều này tất nhiên, do nó cần phải "sống" cùng với instance này (không thể bị thu hồi khi kết thúc hàm)

public class MyClass {
    public StackOrHeapStruct AStructFieldWillBeOnHeapToo;
    public int IntAlso;
}
public struct StackOrHeapStruct {
}
void HeapOrStackStruct() {
    // StackOrHeapStruct là Value type nên nằm trên Stack
    var onlyStack = new StackOrHeapStruct();
    
    // Lúc này AStructFieldWillBeOnHeapToo sẽ nằm trên Heap do là 1 field của class
    // Dù cho nó có là Value type đi chăng nữa
    var myClass = new MyClass {
        AStructFieldWillBeOnHeapToo = new StackOrHeapStruct(),
        IntAlso = 1
    };
}



Ta kiểm tra Heap của chương trình, sẽ thấy không chỉ MyClass mà còn có StackOrHeapStructint được cấp phát chung

ref struct sẽ đảm bảo 1 struct luôn nằm trên Stack

Thỉnh thoảng bạn sẽ thấy có kiểu khai báo public ref strut MyRefStruct, thật ra nó không có gì đặc biệt hết, đây chỉ là khai báo đảm bảo struct này không thể là field của 1 Reference type (để không thể nằm trên Heap)

public ref struct StackOnlyStruct {}
public class MyClass {
  public StackOnlyStruct AReferenceTypeCanNotEmbedARefStructField;
}

Đoạn code bên trên sẽ không thể compile, do StackOnlyStruct là 1 ref struct

Cấp phát mảng trên Stack với Span<T>

Trước khi kết thúc, mình muốn giới thiệu đến mọi người 1 ref structSpan<T>, thông thường nó được dùng để trỏ tới 1 mảng trong Heap (tương tự ArraySegment), chúng ta sẽ xem 1 cách dùng hay ho khác nữa
Với kiểu dữ liệu tuần tự (linear sequence), thì bạn sẽ sử dụng []T (array) hay List<T>, nhưng cả 2 thằng này đều là class (Reference type) và sẽ nằm trên Heap
Nếu muốn sử dụng mảng trên Stack, bạn cần kết hợp Span<T>stackalloc (chú ý T phải là Value type)

public struct Struct {}
public void StackAllocSpan() {
  // Cần phải khai báo kích thước của span
  // T phải là Value type
  Span<int> notPlayWithHeapIntSpan = stackalloc int[3];
  notPlayWithHeapIntSpan[0] = 0;
  Span<Struct> notPlayWithHeapStrutSpan = stackalloc Struct[3];
}

Khi gọi hàm StackAllocSpan(); sẽ không cần cấp phát trên Heap. Tuy nhiên cần phải chú ý! Stack có kích thước nhỏ, khi sử dụng Span thì các phần tử trong Span cũng nằm trên Stack (thay vì chỉ 1 con trỏ tới dữ liệu như mảng thông thường), do đó việc cấp phát Span quá lớn có thể dẫn đến StackOverflow
Sử dụng Span<T> sẽ đem lại hiệu năng tốt hơn rất nhiều, tuy nhiên phải lưu ý stackalloc Span chỉ được sử dụng trong scope của hàm, không đươc return hay out nó ra , ngoài ra các async method cũng không cho phép Span

Kết luận

Vậy bạn đã có câu trả lời cho câu hỏi ban đầu rồi đúng không? Class (Reference type) sẽ luôn trên Heap, trong khi đó struct (Value type) sẽ còn tùy vào tình huống sử dụng
Hãy thử làm 1 quiz nho nhỏ để kiểm tra kiến thức nãy giờ nhé!

public class MyStruct_1 {
  public int MyInt;
  public MyClass MyClass;
}
public class MyClass {
  public MyStruct_2 MyFieldStruct_2;
}
public class MyStruct_2 {
}
public void MyFunc() {
  var myStruct_1 = new MyStruct_1 {
    MyInt = 1,
    MyClass = new MyClass() {
      MyFieldStruct_2 = new MyStruct_2()
    }
  };
}

Nếu ta chạy hàm MyFunc(); thì MyInt, MyStruct_1, MyStruct_2, MyClass cái nào sẽ nằm trên Heap?

Vậy là kết thúc bài đầu tiên trong series này. Ở post sau, mình sẽ đề cập đến 1 vấn đề khi sử dụng struct là Defensive copy và cách giải quyết


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.