Chi Phí Ẩn Của Boxing Trong C#: Cách Phát Hiện Và Tránh Chúng

Boxing và Unboxing trong C# là những yếu tố quan trọng ảnh hưởng đến hiệu suất ứng dụng. Tuy nhiên, chúng thường bị bỏ qua. Chúng liên quan đến cấp phát bộ nhớ heap mang theo hình phạt do cơ chế truy cập của chúng. Trong bài viết hôm nay, chúng ta sẽ khám phá chi tiết về Boxing và Unboxing, nghiên cứu cách chúng gây tốn kém cho ứng dụng của bạn và làm thế nào để tránh những vấn đề như vậy.

Boxing và Unboxing trong C# là gì?

Boxing đề cập đến việc chuyển đổi một kiểu giá trị, chẳng hạn như int, double, long hoặc struct thành một kiểu tham chiếu (ví dụ: object hoặc interface). Khi thời gian chạy ngôn ngữ chung (CLR) thực hiện boxing một kiểu giá trị, nó bọc giá trị bên trong một thể hiện System.Object và lưu trữ nó trên managed heap. Heap lưu trữ dữ liệu trong thời gian dài và Garbage Collector thực hiện cấp phát và giải phóng. Trong trường hợp không boxing, các biến có vòng đời ngắn như biến cục bộ và tham số phương thức sử dụng stack hoạt động theo nguyên tắc Last In, First Out (LIFO). Boxing chuyển đổi ngầm các biến kiểu giá trị thành đối tượng, vì mọi kiểu đều là con của lớp Object.

Unboxing là quá trình ngược lại với Boxing, trong đó các kiểu giá trị được trích xuất từ các kiểu đối tượng. Unboxing yêu cầu ép kiểu rõ ràng sang kiểu đích.

Ví dụ Boxing


int count = 200;
object obj = count;

Chúng ta đã thực hiện boxing biến count vào thể hiện obj thuộc kiểu object.

Ví dụ Unboxing


object obj = 204;
int count = (int) obj;

Bây giờ chúng ta đã hiểu Boxing là gì, hãy chuyển sang phần thứ hai, đó là cách nó ảnh hưởng đến ứng dụng của bạn.

Dự án Boxing để đánh giá sự khác biệt hiệu suất với BenchmarkDotNet

Tôi đang tạo một dự án benchmark để xem tác động của Boxing so với các tình huống không boxing. Nếu bạn muốn tìm hiểu về benchmarking và thư viện BenchmarkDotnet, hãy đảm bảo đọc qua bài viết này: Cách giám sát hiệu suất ứng dụng của bạn với .NET Benchmarking.

Bước 1: Tạo dự án


dotnet new console -n BoxingBenchmark
cd BoxingBenchmark

Bước 2: Cài đặt BenchmarkDotNet


dotnet add package BenchmarkDotNet

Bước 3: Thiết lập trình chạy Benchmark


using BenchmarkDotNet.Attributes;

namespace BoxingBenchmark;

[MemoryDiagnoser]
public class BoxingBenchmarksRunner
{

private int value = 222;

[Benchmark]
public object WithBoxing()
{
// Boxing: converting value type (int) into object
return value;
}

[Benchmark]
public int WithoutBoxing()
{
// No boxing: stays as int
return value;
}

[Benchmark]
public int WithUnboxing()
{
// Boxing + Unboxing: store as object and cast back
object boxed = value;
return (int)boxed;
}
}

Tôi đã định nghĩa 3 phương thức: Boxing, WithoutBoxingWithUnboxing cho một biến int đơn giản. Với thuộc tính Benchmark, chúng sẽ được đưa vào quá trình benchmarking. Theo mặc định, BenchmarkDotNet chỉ đánh giá thời gian. Do đó, ở trên cùng, tôi đã chỉ định MemoryDiagnoser để bao gồm cả bộ nhớ trong quá trình benchmarking.

Bước 4: Chạy trình benchmark trong Program.cs


using BenchmarkDotNet.Running;
using BoxingBenchmark;

BenchmarkRunner.Run<BoxingBenchmarksRunner>();

Bước 5: Build solution

Benchmark yêu cầu môi trường release. Để release, chúng ta cần build dự án trước.


dotnet build

Bước 6: Chạy release

Bây giờ chạy ở chế độ release


dotnet run -c Release

Kết quả

Chúng ta có thể nhận thấy rõ ràng rằng Boxing không chỉ làm chậm quá trình thực thi mà còn tiêu thụ bộ nhớ ngay cả cho một biến int duy nhất. Không có Boxing, một stack nhanh được sử dụng theo nguyên tắc LIFO. Trong khi Boxing sử dụng bộ nhớ heap để lưu trữ giá trị đó. Việc cấp phát và giải phóng liên quan đến garbage collector loại bỏ giá trị khỏi heap khi nó không còn cần thiết. Tất cả điều này mất nhiều thời gian hơn so với push và pop stack. Tuy nhiên, bản thân garbage collector là một chương trình gây ra chi phí CPU, cùng với hình phạt về bộ nhớ và thời gian. Stack sử dụng bộ nhớ cache CPU được tối ưu hóa cho lưu trữ giá trị nhỏ. Tuy nhiên, heap yêu cầu truy cập bộ nhớ chậm hơn, do đó nó bỏ qua các tối ưu hóa caching.

Benchmark Boxing để đánh giá ArrayList vs List

Một trong những ví dụ nổi bật nhất về boxing mà bạn sử dụng mà không biết là ArrayList. Đúng vậy, ArrayList hoạt động dựa trên boxing đằng sau hậu trường. Hãy tiếp tục với cùng một dự án, cho thấy việc sử dụng ArrayListList có thể khác nhau như thế nào.

Bước 1: Tạo trình chạy Benchmark


using System.Collections;
using BenchmarkDotNet.Attributes;

namespace BoxingBenchmark;

[MemoryDiagnoser]
public class AppointmentBenchmarksRunner
{
private const int Count = 1000;

[Benchmark]
public void UsingArrayList()
{
var list = new ArrayList();
for (int i = 0; i < Count; i++)
{
// Boxing happens here because ArrayList stores as object
list.Add(i);

// Unboxing when retrieving
var a = (int)list[i];
}
}

[Benchmark]
public void UsingGenericList()
{
var list = new List<int>();
for (int i = 0; i < Count; i++)
{
list.Add(i);

// No boxing/unboxing
var a = list[i];
}
}
}

Chúng tôi đã định nghĩa hai phương thức ở đây. Phương thức đầu tiên sử dụng ArrayList và phương thức thứ hai sử dụng List generic để lưu dữ liệu số nguyên. Để duy trì kích thước mẫu đáng kể, tôi đã thực hiện 1000 lần lặp.

Bước 2: Chạy trình benchmark trong Program.cs


using BenchmarkDotNet.Running;
using BoxingBenchmark;

BenchmarkRunner.Run<AppointmentBenchmarksRunner>();

Bước 3: Build dự án


dotnet build

Bước 4: Chạy dự án ở chế độ release


dotnet run -c Release

Kết quả

Bước 5: Tăng kích thước lặp

Để quan sát hiệu ứng của việc tăng dữ liệu, tôi đang tăng kích thước lên 10000.


private const int Count = 10_000;

Chúng ta có thể quan sát thấy rằng khi kích thước dữ liệu tăng lên 10 lần, tốc độ của danh sách generic giảm theo cùng tỷ lệ. Tuy nhiên, việc sử dụng bộ nhớ tăng nhẹ. Nhân tiện, sự thay đổi trong việc sử dụng tài nguyên đã có thể dự đoán được. Mặt khác, ArrayList mất nhiều thời gian hơn trong quá trình thực thi và việc cấp phát bộ nhớ của nó cao gấp khoảng bốn lần so với List generic. List không có cấp phát ẩn, do đó nó cung cấp khả năng mở rộng ổn định. Trong khi ArrayList sử dụng các hoạt động Garbage Collector cho từng mục, điều này rất tốn kém.

Boxing đã hoạt động ở đâu trong dự án của bạn?

Boxing xuất hiện ở nhiều nơi mà bạn cần biết và tránh. Tôi đã liệt kê một vài tình huống phổ biến nơi bạn sử dụng boxing một cách có ý thức hoặc vô ý.

Sử dụng các collection không generic (ArrayList, Hashtable)


var list = new ArrayList();
list.Add(26); // boxing
int firstValue = (int)list[0]; // unboxing

Các collection generic với ràng buộc interface


var list = new List<IComparable>();
list.Add(42); // boxing, because int stored as IComparable

Ngay cả khi bạn đang sử dụng một collection generic nhưng với ràng buộc interface, nó vẫn sử dụng boxing.

Làm thế nào để tránh Boxing trong C#?

Vì nó tốn kém về nhiều mặt, chúng ta nên tránh rơi vào các tình huống sử dụng boxing một cách ngầm định hoặc rõ ràng.

Sử dụng các collection Generics

Như chúng ta đã thấy trong các ví dụ của mình, việc sử dụng các collection generic cho các kiểu giá trị giúp chúng ta tránh khỏi những cạm bẫy của boxing. Sử dụng List<T> hoặc Dictionary<TKey, TValue> thay vì một collection dựa trên boxing như ArrayList.

Tránh ép kiểu object không cần thiết

Bất cứ khi nào có thể, hãy tránh sử dụng các biến System.Object trong dự án của bạn. System.Object yêu cầu ép kiểu thành các kiểu giá trị sử dụng boxing.

Sử dụng Object pooling

Thay vì tạo các đối tượng mới mỗi khi bạn cần chúng, hãy tạo một object pool cho các đối tượng được cấp phát heap. Nó cho phép bạn tái sử dụng các đối tượng từ các pool và giải phóng garbage collector khỏi các công việc cấp phát và giải phóng thường xuyên. Kỹ thuật object pooling rất cần thiết trong các hệ thống thời gian thực, máy chủ thông lượng cao và các ứng dụng quan trọng về hiệu suất khác.

Kết luận

Boxing là một hình phạt hiệu suất bị ẩn trong một dự án nếu bạn không chọn đúng thứ cho đúng nơi. Hầu hết, nó không dẫn bạn đến các vấn đề hiệu suất đáng kể. Tuy nhiên, nếu ứng dụng của bạn rất quan trọng về hiệu suất và boxing tham gia vào các hoạt động với dữ liệu rộng lớn, bạn có thể gặp rắc rối. Trong bài viết này, tôi đã chia sẻ những hiểu biết về mức độ ảnh hưởng của nó nếu bạn chọn sai collection hoặc cách xử lý các kiểu giá trị. Chúng tôi đã benchmark hiệu suất cho các phương thức boxing và unboxing. Chúng tôi đã thảo luận về nơi bạn có thể tiềm ẩn dựa vào boxing mà không biết. Bằng cách làm theo hướng dẫn của bài viết này, bạn có thể bảo vệ dự án của mình khỏi việc sử dụng tài nguyên không cần thiết và độ trễ.

Chỉ mục