System.Diagnostics.Process.GetProcessesByNameのアロケーションに絶望した僕は、異世界で復讐することにした

# はじめに
毎度おなじみ？タイトル詐欺です。
プロセス名からプロセスIDを引く際に、`System.Diagnostics`名前空間の`Process.GetProcessesByName`を使用したら絶望したので滅ぼすことにしました。

# まずは通常通り`Process.GetProcessesByName`でプロセスIDを取得する

まずは、以下のコードを実行してみます。

``` csharp
public int TestProcess_GetProcessesByName()
{
    var ps = Process.GetProcessesByName("explorer");
    if (ps.Length > 0)
    {
        return ps[0].Id;
    }
    return 0;
}
```

上記のコードをBenchmarkDotNetでベンチマークしてみます。

```
BenchmarkDotNet=v0.13.5, OS=Windows 10 (10.0.19044.3086/21H2/November2021Update)
Intel Core i7-10700K CPU 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.300-preview.23179.2
  [Host]   : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
  ShortRun : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2

Job=ShortRun  IterationCount=3  LaunchCount=1
WarmupCount=3

|                         Method |     Mean |     Error |    StdDev | Allocated |
|------------------------------- |---------:|----------:|----------:|----------:|
| TestProcess_GetProcessesByName | 2.266 ms | 0.4786 ms | 0.0262 ms |  10.71 KB |
```

**は？アロケーション多すぎでしょ！こんなやつ絶対に復讐してやる！**

# `EnumProcesses`+`GetProcessImageFileName`の場合
`EnumProcesses`と`GetProcessImageFileName`を使ってプロセス名からプロセスIDを取得してみます。

1. `EnumProcesses`で、すべてのプロセスIDを取得
2. `OpenProcess`で、プロセスを開く
3. `GetProcessImageFileName`で、プロセス名を取得
4. プロセス名をチェックして対象プロセスだったらプロセスIDを返す

<details><summary>テストコード</summary>

``` csharp
[LibraryImport("psapi.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool EnumProcesses(ref uint processIds, uint arraySizeBytes, out uint bytesCopied);

[LibraryImport("kernel32.dll")]
private static partial IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId);

[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool CloseHandle(IntPtr handle);

[LibraryImport("psapi.dll", EntryPoint = "GetProcessImageFileNameW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint GetProcessImageFileName(IntPtr hProcess, ref char lpImageFileName, uint nSize);

public int TestEnumProcessAndGetProcessImageFileName()
{
    int pid = 0;

    var size = 2048;
    var pids = ArrayPool<uint>.Shared.Rent(size);
    Span<uint> pidSpan = pids;

    while (true)
    {
        if (!EnumProcesses(ref MemoryMarshal.GetReference(pidSpan), (uint)pidSpan.Length, out var b))
        {
            return pid;
        }

        if(pidSpan.Length > b)
        {
            pidSpan = pidSpan[..(int)(b / sizeof(uint))];
            break;
        }
        else
        {
            ArrayPool<uint>.Shared.Return(pids);

            size *= 2;
            pids = ArrayPool<uint>.Shared.Rent(size);
            pidSpan = pids;
        }
    }

    var processName = ArrayPool<char>.Shared.Rent(512);

    for (var i = 0; i < pidSpan.Length; i++)
    {
        var handle = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE, false, pidSpan[i]);
        if (handle == IntPtr.Zero) continue;

        Span<char> processNameSpan = processName;

        var processNameLength = GetProcessImageFileName(handle, ref MemoryMarshal.GetReference(processNameSpan), (uint)processNameSpan.Length);
        if (processNameLength == 0)
        {
            CloseHandle(handle);
            continue;
        }

        processNameSpan = processNameSpan[..(int)processNameLength];

        if (processNameSpan.EndsWith("explorer.exe"))
        {
            CloseHandle(handle);
            pid = (int)pidSpan[i];
            break;
        }

        CloseHandle(handle);
    }

    ArrayPool<char>.Shared.Return(processName);
    ArrayPool<uint>.Shared.Return(pids);

    return pid;
}
```

</details>

```
|                                    Method |     Mean |    Error |    StdDev | Allocated |
|------------------------------------------ |---------:|---------:|----------:|----------:|
| TestEnumProcessAndGetProcessImageFileName | 3.436 ms | 1.361 ms | 0.0746 ms |       4 B |
```

流行りの`ArrayPool`でアロケーションは消せましたが、速度がいまいちです。

# `CreateToolhelp32Snapshot`の場合
1. `CreateToolhelp32Snapshot`で、スナップショットを取る
2. `Process32First`で、最初の`PROCESSENTRY32`を取得
3. `PROCESSENTRY32.szExeFile`をチェックして対象プロセスなら`PROCESSENTRY32.th32ProcessID`を返す
4. `Process32Next`で、ほかのプロセスも調べる

<details><summary>テストコード</summary>

```csharp
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint th32ProcessID);

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto, Size = 568)]
private struct PROCESSENTRY32
{
    internal uint dwSize;
    internal uint cntUsage;
    internal uint th32ProcessID;
    internal IntPtr th32DefaultHeapID;
    internal uint th32ModuleID;
    internal uint cntThreads;
    internal uint th32ParentProcessID;
    internal int pcPriClassBase;
    internal uint dwFlags;
    internal char szExeFile;
}

[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool Process32First([In] IntPtr hSnapshot, ref PROCESSENTRY32 lppe);

[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool Process32Next([In] IntPtr hSnapshot, ref PROCESSENTRY32 lppe);

public int TestCreateToolhelp32Snapshot()
{
    var h = CreateToolhelp32Snapshot(0x2, 0);
    if(h == IntPtr.Zero) return 0;

    var pe = new PROCESSENTRY32();
    pe.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32));

    if(!Process32First(h, ref pe)) return 0;

    var targ = "explorer.exe".AsSpan();

    do
    {
        var sp = MemoryMarshal.CreateSpan(ref pe.szExeFile, 260);

        if(sp.StartsWith(targ))
        {
            return (int)pe.th32ProcessID;
        }

    } while (Process32Next(h, ref pe));

    return 0;
}
```

</details>

```
|                       Method |     Mean |     Error |    StdDev | Allocated |
|----------------------------- |---------:|----------:|----------:|----------:|
| TestCreateToolhelp32Snapshot | 4.100 ms | 0.7543 ms | 0.0413 ms |       5 B |
```

アロケーションは消せてるけど、**遅い！**

# そもそも、`Process.GetProcessesByName`って内部的にどのAPIを使用してるの？
C#のソースコードを読んでいくと、`GetProcessInfos`という関数があります。

https://github.com/microsoft/referencesource/blob/master/System/services/monitoring/system/diagnosticts/ProcessManager.cs#L1063

ここで`NtQuerySystemInformation`を呼んで、情報を取得しているようです。これが最速の取得方法みたいなので、実装してみます。

# `NtQuerySystemInformation`の場合
この関数は、低レベルなAPIのため使い方が少し難しいです。以下の記事が参考になりました。

https://potisan-programming-memo.hatenablog.jp/entry/2020/04/05/000000

上記コードを参考に、黒魔術を使ってマーシャリング周りを改善してみます。

<details><summary>テストコード</summary>

```csharp
[DllImport("ntdll.dll", SetLastError = false, ExactSpelling = true)]
public static extern int NtQuerySystemInformation(byte SystemInformationClass, ref byte SystemInformation, uint SystemInformationLength, out uint ReturnLength);

[StructLayout(LayoutKind.Sequential)]
private struct UNICODE_STRING
{
    public ushort Length;
    public ushort MaximumLength;
    public IntPtr Buffer;
}

[StructLayout(LayoutKind.Sequential)]
private struct SYSTEM_PROCESS_INFORMATION
{
    public uint NextEntryOffset;
    public uint NumberOfThreads;
    public long Reserved1;
    private long Reserved1_1;
    private long Reserved1_2;
    private long Reserved1_3;
    private long Reserved1_4;
    private long Reserved1_5;
    public UNICODE_STRING ImageName;
    public int BasePriority;
    public IntPtr UniqueProcessId;
    public IntPtr Reserved2;
    public uint HandleCount;
    public uint SessionId;
    public IntPtr Reserved3;
    public UIntPtr PeakVirtualSize;
    public UIntPtr VirtualSize;
    public uint Reserved4;
    public UIntPtr PeakWorkingSetSize;
    public UIntPtr WorkingSetSize;
    public IntPtr Reserved5;
    public UIntPtr QuotaPagedPoolUsage;
    public IntPtr Reserved6;
    public UIntPtr QuotaNonPagedPoolUsage;
    public UIntPtr PagefileUsage;
    public UIntPtr PeakPagefileUsage;
    public UIntPtr PrivatePageCount;
    public long Reserved7;
    private long Reserved7_1;
    private long Reserved7_2;
    private long Reserved7_3;
    private long Reserved7_4;
    private long Reserved7_5;
}

public unsafe int TestNtQuerySystemInformation()
{   
    const int SYSTEM_PROCESS_INFORMATION = 5;
    const int STATUS_INFO_LENGTH_MISMATCH = unchecked((int)0xC0000004);

    var buffer = ArrayPool<byte>.Shared.Rent(0x100000);
    Span<byte> span = buffer;

    while(true)
    {
        var status = NtQuerySystemInformation(SYSTEM_PROCESS_INFORMATION, ref MemoryMarshal.GetReference(span), (uint)span.Length, out var bufferLength);

        if (status == 0) break;

        if (status != STATUS_INFO_LENGTH_MISMATCH)
            throw new Exception();

        // Buffer resize
        ArrayPool<byte>.Shared.Return(buffer);
        buffer = ArrayPool<byte>.Shared.Rent((int)bufferLength);
        span = buffer;
    }

    int pid = 0;
    uint offset = 0;

    while (true)
    {
        ref var rb = ref MemoryMarshal.GetReference(span);
        ref var r = Unsafe.As<byte, SYSTEM_PROCESS_INFORMATION>(ref Unsafe.Add(ref rb, offset));

        if (r.NextEntryOffset == 0) break;

        var s = new ReadOnlySpan<char>(r.ImageName.Buffer.ToPointer(), r.ImageName.Length / 2);

        if(s.SequenceEqual("explorer.exe"))
        {
            pid = (int)r.UniqueProcessId;
            break;
        }

        offset += r.NextEntryOffset;
    }

    ArrayPool<byte>.Shared.Return(buffer);

    return pid;
}
```

</details>

```
|                       Method |     Mean |     Error |    StdDev | Allocated |
|----------------------------- |---------:|----------:|----------:|----------:|
| TestNtQuerySystemInformation | 2.401 ms | 0.7427 ms | 0.0407 ms |       4 B |
```

**速い！** そしてアロケーションも消えて、いい感じです。
こういう時に、どんなメモリに対しても同じAPIで操作できる`Span`はすごく便利です。
本来だったら、`GCHandle.Alloc`を使用するところも、`ArrayPool`+`Span`+`ref`で乗り切れます。

# まとめ
最後に、ベンチマークをまとめて実行した結果です。

```
BenchmarkDotNet=v0.13.5, OS=Windows 10 (10.0.19044.3086/21H2/November2021Update)
Intel Core i7-10700K CPU 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.300-preview.23179.2
  [Host]   : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2
  ShortRun : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX2

Job=ShortRun  IterationCount=3  LaunchCount=1
WarmupCount=3

|                                    Method |     Mean |     Error |    StdDev | Allocated |
|------------------------------------------ |---------:|----------:|----------:|----------:|
|            TestProcess_GetProcessesByName | 2.545 ms | 1.5778 ms | 0.0865 ms |   10970 B |
| TestEnumProcessAndGetProcessImageFileName | 3.234 ms | 2.2597 ms | 0.1239 ms |       4 B |
|              TestCreateToolhelp32Snapshot | 3.930 ms | 1.5387 ms | 0.0843 ms |       5 B |
|              TestNtQuerySystemInformation | 2.139 ms | 0.6918 ms | 0.0379 ms |       4 B |
```

最後の`NtQuerySystemInformation`を使用した方法が、実行速度、ヒープアロケーション、どちらを見ても最速となりました。

**やっぱり復讐は楽しいなぁ！！**

# ソースコード全体
今回使用したコードは以下の通りです。

https://gist.github.com/gazf/bf8af2e4aadb114488dd909e344c22c0