Những cập nhật mới về GC handles trong CoreCLR tại .NET 9 và .NET 10

23/08/2026

Những gì mới với GC handles trong CoreCLR tại .NET 9 và .NET 10

.NET 9 và .NET 10 là các bản phát hành quan trọng cho GC handles trong trình thu gom rác CoreCLR. Chúng ta có các API công khai mới để làm việc với GC handles. Lần đầu tiên trong lịch sử mã nguồn mở của CoreCLR1, các loại GC handle mới đã được giới thiệu vào CoreCLR GC. Các loại GC handle mới này là chi tiết triển khai của runtime CoreCLR mà bạn sẽ không sử dụng trực tiếp nhưng vô cùng hấp dẫn.

Background

Tôi đã đề cập đến GC handles trong CoreCLR trong bài viết năm 2024. Bài viết đó thảo luận về 10 loại GC handle khác nhau được triển khai trong trình thu gop rác CoreCLR tại thời điểm đó. Để tóm tắt, GC trong CoreCLR là một trình thu gop rác theo dõi, nghĩa là GC xác định哪些 đối tượng còn sống bằng cách duyệt đồ thị đối tượng, bắt đầu từ các gốc GC như biến tĩnh và biến cục bộ2. Không phải tất cả các mẫu mã đều phù hợp với mô hình này, đặc biệt trong các trường hợp liên quan đến tương tác với mã gốc. Khi không thể biểu diễn vòng đời đối tượng thông qua hệ thống bình thường, GC Handle abstraction cung cấp một cách để kiểm soát và theo dõi vòng đời đối tượng một cách rõ ràng.

Cách GC handle hoạt động là bạn có thể tạo một handle cho một đối tượng cụ thể. Handle này sau đó có thể được truyền qua mã gốc, vì nó chỉ là một giá trị kích thước con trỏ. Sau đó, mã managed của bạn có thể lấy lại đối tượng từ handle. Có một ví dụ về cách sử dụng GC handles trong phần tiếp theo.

Các API GC Handle Công Khai Mới

Kể từ khi .NET ra đời, cấu trúc GCHandle đã là cách chính để tương tác với GC handles. .NET 6 đã thêm cấu trúc DependentHandle.

.NET 10 đã thêm bốn kiểu mới để sử dụng GC handles. So với cấu trúc GCHandle cũ, các kiểu này có tính an toàn kiểu tốt hơn bằng cách sử dụng các kiểu riêng biệt cho các loại GC handle khác nhau. Ví dụ, với cấu trúc GCHandle cũ, việc gọi AddrOfPinnedObject() trên một handle không bị đóng băng sẽ gây ra ngoại lệ tại thời điểm chạy. GC handle mới ngăn chặn vấn đề này tại thời điểm biên dịch: chỉ PinnedGCHandle<T> mới cung cấp phương thức để lấy địa chỉ của đối tượng bị đóng băng. Các kiểu mới sử dụng kiểu tổng quát, vì vậy bạn không cần chuyển đổi kết quả của việc đọc mục tiêu handle. Các kiểu GC handle mới này cũng có hiệu suất tốt hơn một chút.

  • GCHandle<T> – một GC handle mạnh. Đây thay thế việc sử dụng GCHandleType.Normal với GCHandle cũ.
  • WeakGCHandle<T> – một GC handle yếu. Đây thay thế việc sử dụng GCHandleType.Weak hoặc GCHandleType.WeakTrackResurrection với GCHandle cũ.
  • PinnedGCHandle<T> – một GC handle đóng băng đối tượng của nó. Đây thay thế việc sử dụng GCHandleType.Pinned với GCHandle cũ.
  • GCHandleExtensions – thêm các phương thức mở rộng cho PinnedGCHandle<T> để lấy con trỏ指向 dữ liệu của các mảng và chuỗi bị đóng băng.

Hãy cùng xem ví dụ về cách bạn có thể sử dụng GC handle. Hàm glibc qsort_r sắp xếp một mảng. tham số cuối cùng, void *arg, là giá trị được truyền cho hàm callback so sánh các phần tử khác nhau của mảng. Chúng ta có thể sử dụng GC handle để truyền một đối tượng thông qua tham số đó.

using System.Runtime.InteropServices;

unsafe partial class Program
{
    static void Main()
    {
        //Tạo một số điểm ngẫu nhiên
        Point[] points = new Point[10];
        for (int i = 0; i < points.Length; i++)
        {
            Point p = new(Random.Shared.Next(10), Random.Shared.Next(10));
            points[i] = p;
            Console.WriteLine($"random point {i}: {p}");
        }

        // Sắp xếp các điểm
        GCHandle<TextWriter> hand = new(Console.Out);
        var functionPointer = (delegate* unmanaged<Point*, Point*, nint, int>)&CompareFunction;
        fixed (Point* pPoints = points)
        {
            qsort_r(pPoints, (nuint)points.Length, (nuint)sizeof(Point), (void*)functionPointer, GCHandle<TextWriter>.ToIntPtr(hand));
        }
        hand.Dispose();

        // In các điểm đã sắp xếp
        for (int i = 0; i < points.Length; i++)
        {
            Console.WriteLine($"sorted point {i}: {points[i]}");
        }

    }

    [StructLayout(LayoutKind.Sequential)]
    record struct Point(int X, int Y);

    [UnmanagedCallersOnly]
    private static int CompareFunction(Point* a, Point* b, nint arg)
    {
        GCHandle<TextWriter> hand = GCHandle<TextWriter>.FromIntPtr(arg);
        TextWriter writer = hand.Target;
        writer.WriteLine($"Comparing {*a} with {*b}");
        if (a->X == b->X)
        {
            return a->Y - b->Y;
        }
        return a->X - b->X;
    }

    [LibraryImport("libc")]
    private static partial void qsort_r(void* @base, nuint nmemb, nuint size, void* compar, nint arg);
}

Đó là một ví dụ đơn giản nhưng minh họa cách GC handles rất hữu ích khi làm việc với mã không được quản lý.

Cải thiện Hiệu suất của các Kiểu GC Handle Mới

Tôi đã viết một micro benchmark để minh họa sự khác biệt hiệu suất giữa kiểu GCHandle cũ và kiểu GCHandle<T> mới:

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<HandleBenchmarks>(args: args);

[StructLayout(LayoutKind.Sequential)]
public class IntBox
{
    public int SomeInt;
}

public class HandleBenchmarks
{
    private static readonly GCHandle s_standardHandle = GCHandle.Alloc(new IntBox());
    private static readonly GCHandle<IntBox> s_newHandle = new GCHandle<IntBox>(new IntBox());

    [Benchmark(Baseline = true)]
    public IntBox StandardGcHandle() => (IntBox)s_standardHandle.Target!;

    [Benchmark]
    public IntBox NewGcHandle() => s_newHandle.Target;
}

Khi tôi chạy benchmark này trên máy tính của mình sử dụng .NET 10.0.9, GC handle mới mất 74% thời gian để truy cập mục tiêu so với GC handle cũ:

Phương thức Trung bình Lỗi StdDev Tỷ lệ Tỷ lệSD
StandardGcHandle 1.0260 ns 0.0572 ns 0.0477 ns 1.00 0.06
NewGcHandle 0.7580 ns 0.0634 ns 0.0929 ns 0.74 0.09

Để giải thích sự khác biệt, hãy chạy đoạn mã sau qua Compiler Explorer:

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

static class Example
{
    static IntBox StandardGcHandle(GCHandle hand) => (IntBox)hand.Target!;

    static IntBox NewGcHandle(GCHandle<IntBox> hand) => hand.Target;
}

[StructLayout(LayoutKind.Sequential)]
public class IntBox
{
    public int SomeInt;
}

Chúng ta có thể thấy kiểu handle mới chỉ giải tham chiếu giá trị handle:

Example:NewGcHandle(System.Runtime.InteropServices.GCHandle`1[IntBox]):IntBox (FullOpts):
       mov      rax, gword ptr [rdi]
       ret

Handle cũ có nhiều kiểm tra bổ sung. Tôi đã thêm giải thích về chúng bên dưới.

Example:StandardGcHandle(System.Runtime.InteropServices.GCHandle):IntBox (FullOpts):
       push     rbp
       mov      rbp, rsp
       // Kiểm tra giá trị handle bằng không và ném `InvalidOperationException` nếu bằng không.
       test     rdi, rdi
       je       SHORT G_ThrowInvalidOperationException
       // Xóa bit IsPinned ở dưới cùng giá trị handle.
       and      rdi, -2
       // Giải tham chiếu giá trị handle.
       mov      rsi, gword ptr [rdi]
       // Kiểm tra null và trả về sớm nếu null.
       mov      rax, rsi
       test     rax, rax
       je       SHORT G_Return
       // Kiểm tra xem đối tượng có phải là thể hiện của IntBox không và chuyển sang logic chuyển đổi phức tạp hơn nếu không khớp chính xác.
       mov      rdi, 0x740999C5B800      ; IntBox
       cmp      qword ptr [rax], rdi
       je       SHORT G_Return
       call     CORINFO_HELP_CHKCASTCLASS_SPECIAL
G_Return:  ;; offset=0x002C
       // Bây giờ sau khi thực hiện tất cả kiểm tra, chúng ta có thể trả về tham chiếu đối tượng trong rax.
       nop      
       pop      rbp
       ret      
G_ThrowInvalidOperationException:  ;; offset=0x002F
       call     [System.ThrowHelper:ThrowInvalidOperationException_HandleIsNotInitialized()]
       int3

Các Kiểu GC Handle Nội Bộ Mới

Khi CoreCLR được mã nguồn mở vào năm 2015, GC của nó hỗ trợ 10 loại handle. Xem bài viết trước hoặc mã nguồn gốc để biết thêm chi tiết.

Trong .NET 9 và .NET 10, hai kiểu GC handle nội bộ bổ sung đã được thêm.

Weak Interior Pointer Handles

.NET 9 đã thêm các weak interior pointer handles (HNDTYPE_WEAK_INTERIOR_POINTER). Các handle này có một giá trị con trỏ bổ sung liên kết với chúng. Con trỏ bổ sung này trỏ đến một vị trí trong bộ nhớ không được quản lý chứa con trỏ đến đối tượng trong GC heap. Con trỏ đến đối tượng có thể trỏ đến đầu đối tượng hoặc đến một vị trí bên trong đối tượng. Bất cứ khi nào đối tượng được di chuyển, vị trí được trỏ đến bởi con trỏ liên kết sẽ được cập nhật để tiếp tục trỏ đến vị trí mới của đối tượng.

Đồ thị này cho thấy cách bố trí trong bộ nhớ.

GC Handle                         Object in GC heap
                                                                 
┌────────────────────────────┐       ┌──────────────┐                
│  Primary handle value      ├──────►│Method Table  │                
│                            │       │              │                
┼────────────────────────────┤       ├──────────────┤                
│                            │       │Field 1       │◄──────────────┐
│  Secondary pointer value   ├──┐    │              │               │
│                            │  │    ├──────────────┤               │
└────────────────────────────┘  │    │Field 2       │               │
                                │    └──────────────┘               │
                                │                                   │
                                │                                   │
                                │     Unmanaged or pinned memory    │
                                │                                   │
                                │    ┌───────────────────┐          │
                                │    │...                │          │
                                │    ├───────────────────│          │
                                └───►│Interior Pointer   ├──────────┘
                                     ├───────────────────┤           
                                     │...                │           
                                     └───────────────────┘

Loại handle này được thêm trong pull request #100446. Ban đầu nó được sử dụng để giữ con trỏ đến đối tượng managed System.Type cho các loại có thể thu gom được cập nhật trong các cấu trúc dữ liệu MethodTable và MethodDesc không được quản lý.

Pull request #99183 đã thay đổi cách cấp phát bộ nhớ cho biến tĩnh (và giải phóng cho các assembly có thể thu gop). Nó làm cho mã quản lý bộ nhớ này đơn giản hơn và truy cập bộ nhớ này có hiệu suất cao hơn. Weak interior pointer handle được sử dụng khi cấp phát bộ nhớ cho biến tĩnh trong các assembly có thể thu gop. Xem mô tả PR và phần Biến tĩnh trong Sách Runtime để biết thêm chi tiết.

Cross-reference Handles

.NET 10 đã thêm hỗ trợ thực nghiệm để sử dụng CoreCLR thay Mono khi xuất bản ứng dụng MAUI cho Android. Để hỗ trợ tính năng này, cross-reference handles (HNDTYPE_CROSSREFERENCE) đã được thêm trong pull request #116310. Điều này đã thêm các lớp trong namespace System.Runtime.InteropServices.Java.

Mô tả đầy đủ về hệ thống tương tác Java của .NET nằm ngoài phạm vi bài viết này3. Đặc điểm thú vị của hệ thống mà chúng tôi tập trung trong bài viết này là hệ thống có thể ghép nối một đối tượng .NET và Java. Các đối tượng này phải chia sẻ cùng vòng đời. Không phải GC Java nor CoreCLR GC có bức tranh toàn cảnh về việc một đối tượng có đang được sử dụng hay không. Do đó hệ thống tương tác sử dụng một .NET GC handle để giữ đối tượng .NET sống vô thời hạn. Nó cũng sử dụng phiên bản GC handle mạnh của Java, là tham chiếu toàn cục4, để giữ đối tượng Java sống vô thời hạn.

Đây là đồ thị về sự phụ thuộc vòng đời giữa các đối tượng Java và .NET.

Hình 1: một đối tượng .NET và đối tượng Java tham chiếu lẫn nhau

Vấn đề với việc tạo tham chiếu mạnh đến các đối tượng trong cả hai GC heap rõ ràng là: Các đối tượng sẽ không bao giờ được thu gop ngay cả khi chúng không thể truy cập được trong cả hai GC heap.

Giải pháp cho nút thắt Gordian của GC là cross-reference handle được giới thiệu trong .NET 10. Chúng được sử dụng để điều phối việc thu gop các đối tượng trên cả hai GC heap. Về mục đích chính, chúng hoạt động như GC handle mạnh. Nơi nó trở nên thú vị là khi quá trình thu gop xảy ra, những điều sau đây diễn ra:

  1. GC .NET tạo danh sách tất cả các cross-reference handle mà mục tiêu của chúng không thể truy cập được trong GC .NET. GC mở rộng vòng đời của các đối tượng để chúng tiếp tục tồn tại.
  2. Hệ thống tương tác Java của .NET lấy danh sách các handle này và phản ánh đồ thị tham chiếu đối tượng ở phía Java.
  3. Hệ thống tương tác hạ cấp các tham chiếu của nó đến các đối tượng Java thành tham chiếu yếu.
  4. Một quá trình thu gop Java được kích hoạt.
  5. Các cross-reference handle tương ứng với các đối tượng Java đã được thu gop sẽ được giải phóng, cho phép đối tượng .NET tương ứng được thu gop trong lần chạy GC .NET tiếp theo.

Phần tiếp theo mô tả thuật toán chi tiết hơn.

Chi tiết về cách GC .NET và Java GC được nối liền

Để giữ phía Java sống, một tham chiếu toàn cục được tạo cho đối tượng Java và được lưu trong cấu trúc HandleContext trên heap không được quản lý. Điều này giữ đối tượng Java sống vô thời hạn. Ở phía .NET, đối tượng .NET và HandleContext* được truyền đến JavaMarshal.CreateReferenceTrackingHandle, tạo handle kiểu HNDTYPE_CROSSREFERENCE. GC .NET sẽ giữ đối tượng .NET sống vô thời hạn.

Ở đây chúng ta có một số đối tượng trong heap .NET và Java. Cạnh đen giữa các đối tượng cho thấy đối tượng có tham chiếu đến đối tượng khác. Các đối tượng cùng số trên mỗi heap được ghép nối với nhau. Ví dụ, đối tượng .NET d0 được ghép nối với đối tượng Java j0. Trong đồ thị này tất cả các đối tượng đều là rác. Để tìm các đối tượng chết này, GC .NET quét tất cả các handle kiểu HNDTYPE_CROSSREFERENCE và tạo danh sách các đối tượng rác. Sau đó nó đẩy các đối tượng .NET chết lên để chúng tiếp tục tồn tại trong thời gian hiện tại.

Hình 2: đồ thị tham chiếu đối tượng .NET và Java

Trước khi chúng ta cố gắng thu gop các đối tượng ở phía Java, chúng ta cần sao chép đồ thị đối tượng .NET lên các đối tượng được ghép nối. Điều này cần thiết để đảm bảo rằng các đối tượng ở phía Java tồn tại chừng nào còn có cách để tiếp cận chúng thông qua đồ thị đối tượng .NET. Để giảm lượng thông tin về tham chiếu giữa các đối tượng .NET cần được sao chép sang phía Java, .NET sử dụng thuật toán các thành phần liên thông mạnh của Tarjan để tìm các thành phần liên thông mạnh (SCC) của đồ thị đối tượng .NET. Xử lý này xảy ra trong gcbridge.cpp. Trong đồ thị sau, các hộp bao quanh đối tượng trong heap .NET đại diện cho các thành phần liên thông mạnh và mũi tên xanh giữa các SCC đại diện cho tham chiếu chéo giữa các thành phần liên thông mạnh.

Hình 3: đồ thị tham chiếu đối tượng .NET và Java

GC .NET đóng gói thông tin về các thành phần liên thông mạnh và tham chiếu chéo vào cấu trúc MarkCrossReferencesArgs. Các đối tượng Java tương ứng được đại diện bởi HandleContext* đã truyền cho CreateReferenceTrackingHandle. GC .NET truyền con trỏ đến cấu trúc MarkCrossReferencesArgs cho callback được đăng ký với JavaMarshal.Initialize. Đây là hàm GCBridge::mark_cross_references. Điều này bắt đầu quá trình xử lý đồ thị đối tượng .NET trên một luồng riêng và trả về nhanh chóng để GC .NET có thể tiếp tục chạy và nối lại thực thi các luồng .NET.

BridgeProcessingShared::process là điểm vào cho logic GC bridge cốt lõi. Với mỗi thành phần liên thông mạnh có nhiều hơn một đối tượng trong đó5, nó kết nối các đối tượng Java tương ứng với nhau trong một vòng tròn bằng các tham chiếu đối tượng Java bình thường6. Tham chiếu chéo giữa các SCC cũng được đại diện bằng các tham chiếu đối tượng Java bình thường.

Trong đồ thị này, chúng ta có thể thấy cách các SCC đã được sao chép sang phía Java. Cạnh xanh dương là tham chiếu vòng tròn và cạnh xanh lá là tham chiếu chéo giữa các SCC.

Hình 4: đồ thị tham chiếu đối tượng .NET và Java

Tại thời điểm này, GC bridge chuyển đổi tất cả tham chiếu toàn cục đến các đối tượng Java thành tham chiếu yếu. GC bridge gọi java.lang.Runtime.gc() để kích hoạt GC. Sau đó nó kiểm tra tất cả các tham chiếu yếu đến các đối tượng Java. Nếu đối tượng đã được thu gop, tham chiếu yếu sẽ được giải phóng. Nếu đối tượng Java vẫn sống, tham chiếu yếu sẽ được nâng cấp thành tham chiếu toàn cục để đối tượng Java một lần nữa được giữ sống vô thời hạn.

Phía tương tác Java sẽ tạo danh sách tất cả các GC Handle không thể truy cập từ cả phía .NET lẫn Java. Nó sẽ truyền các handle này đến GC .NET thông qua hàm JavaMarshal.FinishCrossReferenceProcessing. Hàm này sẽ xóa bất kỳ GC handle yếu nào7 trỏ đến cùng đối tượng .NET như bất kỳ handle nào trong số đó. Sau đó nó sẽ giải phóng tất cả GC handle.

Kết luận

Các cấu trúc GC handle tổng quát mới là một cải tiến được chào đón cho trải nghiệm phát triển. Tôi hy vọng bạn thấy chi tiết về các kiểu GC handle mới này hấp dẫn như tôi. Thường thì với tư cách là kỹ sư phần mềm, chúng ta sống trong các ràng buộc của ngôn ngữ hoặc runtime ngôn ngữ để biểu diễn giải pháp cho các vấn đề. Các nhà phát triển ngôn ngữ và runtime xây dựng thế giới mà chúng ta sống. Các GC handle mới này là lời nhắc nhở rằng bằng cách thay đổi các trừu tượng có sẵn trong runtime, chúng ta có được từ vựng mới để biểu diễn giải pháp cho các vấn đề, biến điều không thể thành có thể. Đây là niềm vui của phần mềm hệ thống.

Ghi chú

1 Ban đầu khi mã nguồn mở CoreCLR vào năm 2015, có 10 loại GC handle. Loại cũ nhất lúc đó, HNDTYPE_WEAK_WINRT, có lẽ đã được thêm trong .NET Framework 4.5 vào năm 2012. .NET 9 vào năm 2024 là lần tiếp theo một kiểu GC handle được giới thiệu. Vì vậy đã khá lâu rồi kể từ khi các kiểu handle mới được thêm.

2 Xem mã CoreCLR tại đây và mã NativeAOT tại đây. Ngoài việc quét biến tĩnh và biến cục bộ, các runtime này sử dụng các callback IGCToCLR::GcScanRoots để mở rộng vòng đời của các loại đối tượng khác không phù hợp với mô hình GC handle.

3 Xem tài liệu chính thức để biết mô tả về cách hệ thống tương tác hoạt động, nhưng không có gì về GC bridge tại thời điểm viết bài. Tài liệu cũ cho Xamarin.Android chứa mô tả về chức năng của GC bridge được sử dụng với Mono runtime. Đề xuất API cho JavaMarshal cung cấp một số gợi ý. Cũng xem pull request sử dụng API mới này trong dotnet/android. Trước khi tính năng này được triển khai trong CoreCLR, Filip Navara đã mô tả chi tiết cách tính năng này được triển khai trong Mono và các tùy chọn để triển khai trong CoreCLR. Bài báo “Collecting Cyclic Garbage across Foreign Function Interfaces”8 đưa ra mô tả học thuật về thuật toán điều phối hai trình thu gop rác khác nhau. Việc hiểu cách JNI hoạt động sẽ hữu ích. Tôi không biết nhiều về JNI, nhưng tài liệu về tham chiếu cục bộ và toàn cục hữu ích để hiểu các thuật ngữ được sử dụng trong mã tương tác.

8 Tetsuro Yamazaki, Tomoki Nakamaru, Ryota Shioya, Tomoharu Ugawa, và Shigeru Chiba. 2023. Collecting Cyclic Garbage across Foreign Function Interfaces: Who Takes the Last Piece of Cake? Proc. ACM Program. Lang. 7, PLDI, Article 130 (June 2023), 24 pages. https://doi.org/10.1145/3591244

4 Xem tài liệu JNI để biết thêm chi tiết.

5 SCC với một đối tượng Java trong đó không cần xử lý đặc biệt ở đây. SCC với không đối tượng Java là có thể xảy ra. Những trường hợp này xảy ra khi các đối tượng .NET trong SCC không có peer Java. Chúng được đại diện trong đồ thị đối tượng Java bằng một thể hiện của lớp GCUserPeer.

6 Các tham chiếu giữa các đối tượng Java được tạo bằng cách gọi phương thức monodroidAddReference trên đối tượng Java. Phương thức này được thêm vào tất cả các wrapper có thể gọi .NET được tạo bởi quy trình build.

7 Việc cố gắng đọc đối tượng từ WeakReference hoặc WeakReference<T> sẽ chặn trong khi xử lý GC Bridge diễn ra. Điều này ngăn chặn việc hồi sinh một đối tượng .NET mà peer Java của nó có thể đã được thu gop.


© Austin Wise

xem mã nguồn