CodeGym /コース /C# SELF /C#における低レベルなメモリ管理

C#における低レベルなメモリ管理

C# SELF
レベル 65, レッスン 1
使用可能

1. はじめに

前回の講義では C# と .NET Runtime がメモリ管理の多くを担っていることを話しました。しかし、パフォーマンス最適化やアンマネージドコード(例えば C++ で書かれたシステムAPI)と連携する必要がある場合、より低レベルなメモリ制御が求められることがあります。C# はそのためのいくつかの機能を提供しており、強力ですが慎重に使う必要があります。

C#で「低レベルなメモリ管理」と言うとき、通常次のことを指します:

  • ポインタを直接扱うこと:C/C++ のようにアドレスでメモリにアクセスする。
  • GC以外のメモリ割り当て:ガベージコレクタが追跡しないメモリの使用。
  • マネージド/アンマネージド境界でのリソース管理:ネイティブライブラリと効率的に連携すること。

通常の C# コードは安全性と安定性を確保するためにメモリアドレス(ポインタ)を直接扱えません。しかし、低レベル操作のために unsafe コンテキスト内でポインタを使用できます。

どんなものが unsafe コンテキストなのか?

unsafe キーワードでマークされたブロックやメソッドは、ポインタ構文を使ったり CLR の安全チェックを回避する操作を実行できます。unsafe コンテキストのコードはネイティブコードと同等に危険になり得ます。unsafe コードを使うには、プロジェクトを /unsafe オプションでコンパイルする必要があります。

例:unsafe メソッドとブロックの宣言


public unsafe class UnsafeExamples
{
    // Unsafe メソッド
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"ポインタの指す値: {*ptr}");
        *ptr = 200; // アドレスの値を変更
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // 通常メソッド内の unsafe ブロック
        {
            int* ptr = &value; // 変数のアドレスを取得
            ManipulatePointer(ptr); // ポインタを unsafe メソッドに渡す
            Console.WriteLine($"新しい値: {value}"); // 出力: 新しい値: 200
        }
    }
}

ポインタの種類

C# では値型のポインタ(int*, bool*, MyStruct*)や void のポインタ(void*、汎用ポインタ)を宣言できます。参照型のポインタを直接宣言することはできませんが、参照型のフィールドが値型であればそのフィールドのポインタを取得できます。

例:さまざまなポインタ型


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // double のポインタ

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' はオブジェクトをメモリに固定し、GC の移動を防ぐ
    {
        Console.WriteLine($"配列の最初の要素: {arrPtr[0]}");
        Console.WriteLine($"配列の2番目の要素: {*(arrPtr + 1)}");
    }
}

ポインタ演算子

  • & (アドレス):変数のアドレスを取得します。
  • * (デリファレンス):アドレスの指す値を取得します。
  • -> (メンバーアクセス):構造体/クラスのポインタ経由でメンバーにアクセスします(値型のみ)。
  • [] (インデクシング):ポインタ経由で配列要素にアクセスします(C/C++ と同様)。

2. 固定(ピン)と割り当てられたメモリブロック

ポインタを扱うとき、ポインタが指すオブジェクトが操作中に GC によって移動されないことが重要です。これを保証するために fixed と stackalloc を使用します。

fixed 演算子

fixed 演算子は参照型の変数をメモリ上で「固定」し、fixed ブロックの実行中に GC がそれを移動しないようにします。これは、マネージドオブジェクトへのポインタをアンマネージドコードに渡すときや、直接操作する場合に重要です。

例:配列での fixed の使用


public unsafe class FixedExample
{
    public static void ProcessFixedArray()
    {
        byte[] buffer = new byte[10];
        // バッファを埋める
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = (byte)(i + 1);
        }

        unsafe
        {
            // 配列をメモリに固定し、最初の要素へのポインタを取得
            fixed (byte* p = buffer)
            {
                // これで配列は移動されないことが分かっているので p を安全に扱える
                Console.WriteLine($"最初のバイトの値: {*p}");
                Console.WriteLine($"2番目のバイトの値: {*(p + 1)}");
                // 'p' をポインタを期待するネイティブ関数に渡すこともできる
            } // 'fixed' ブロックを抜けると配列は GC によって移動され得る
        }
    }
}

fixed は構造体のフィールドや文字列にも使えます。

スタック上のメモリ割り当て:stackalloc

stackalloc はスタック上にメモリブロックを割り当てます。非常に高速ですが、そのメモリは現在のメソッドが終わるまでしか有効ではありません。割り当てられたメモリは GC によって管理されません。

  • 利点:非常に速い割り当て、GC のオーバーヘッドがない、決定論的な解放。
  • 欠点:サイズが限定される(スタックは比較的小さい)、大きな割り当てでスタックオーバーフロー(StackOverflowException)のリスクがある。
  • 用途:小さく短命なバッファに最適。

例:stackalloc の使用


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // スタック上に 10 個の int を割り当てる
            int* numbers = stackalloc int[10];

            for (int i = 0; i < 10; i++)
            {
                numbers[i] = i * 10;
            }

            Console.WriteLine($"最初の要素の値: {numbers[0]}");
            Console.WriteLine($"5番目の要素の値: {numbers[4]}");
        } // メソリはこのメソッドを抜けると自動的に解放される
    }
}

stackalloc は Span<T> と組み合わせて使うことができ、Span<T> はそのメモリへの安全なアクセスを提供する管理型(struct)です。Span の詳細は次回の講義で扱います。

例:stackalloc と Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // スタックに割り当て、しかし安全な Span
  
    経由で扱う Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Span の最初の要素: {buffer[0]}"); Console.WriteLine($"Span の最後の要素: {buffer[9]}"); // Span
    
      を受け取るメソッドに渡すことができる PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

これはハイブリッドなアプローチです:スタック上に割り当てる(低レベル)けれど、Span<T> を通じて安全にアクセスする(高レベル)。

3. アンマネージドコードとの相互作用(P/Invoke)

Platform Invoke (P/Invoke) は、C# コードがアンマネージドライブラリ(たとえば Windows API の DLL ファイル や Linux の .so ファイル)内の関数を呼び出す仕組みです。これは低レベルなメモリ管理の基本的な側面で、マネージドとアンマネージドの間でデータのポインタを渡すことがよくあります。

外部関数の宣言

ネイティブ関数に対応する静的な extern メソッドを宣言するには、DllImport 属性を使います。

例 3.1: Windows API 関数の呼び出し


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // user32.dll からネイティブ関数 MessageBox をインポート
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void ShowMessageBox()
    {
        // ネイティブ関数を呼び出す
        MessageBox(IntPtr.Zero, "C# からの挨拶!", "ウィンドウのタイトル", 0);
    }
}

データのマーシャリング

ネイティブ関数を呼ぶとき、.NET Runtime はデータ型を管理対象と非管理対象のフォーマットの間で変換する、つまりマーシャリングを行います。例えば C# の string は C++ 側で char* や wchar_t* としてマーシャリングされることがあります。

より複雑なマーシャリングには、MarshalAs 属性や Marshal クラスを使えます。

例:構造体のマーシャリング


using System.Runtime.InteropServices;

// この構造体はネイティブ構造体にマーシャリングされる
[StructLayout(LayoutKind.Sequential)] // フィールドを連続して配置することを指定
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // 例: ネイティブライブラリ内の関数
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // 構造体へのポインタを受け取る

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // 構造体は値でマーシャリングされる(コピー)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // ネイティブ関数が構造体を埋める
        Console.WriteLine($"ネイティブからのポイント: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle とオブジェクトのピン留め

GCHandle は、マネージドヒープ上のオブジェクトに対して「ハンドル」を取得し、必要に応じて一時的にそれをピン留めして移動や回収を防ぐための構造体です。これはマネージドオブジェクトへの恒久的なポインタをアンマネージドコードに渡す必要があるときに便利です。

例:GCHandle でオブジェクトをピン留めする


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // 配列をメモリにピン留め
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // ピン留めされたオブジェクトのポインタを取得
            Console.WriteLine($"ピン留めされた配列のアドレス: {pointer:X}");

            // これで 'pointer' をネイティブ関数に安全に渡せる
            // ネイティブ関数はこのメモリを直接操作できる

            Marshal.WriteByte(pointer, 0, 255); // ポインタ経由で最初のバイトを変更
            Console.WriteLine($"配列の最初のバイト: {data[0]}"); // 出力: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // ハンドルを解放して GC が再びオブジェクトを管理できるようにする
            }
        }
    }
}

P/Invoke と GCHandle の詳細な解説はこのコースの範囲を超えますが、Windows のライブラリ関数を直接呼び出したい場合には非常に役立ちます。少なくとも、どこを調べればいいかは分かったはずです。

2
タスク
C# SELF, レベル 65, レッスン 1
ロック未解除
stackallocを使ったスタック上のメモリ確保
stackallocを使ったスタック上のメモリ確保
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION