11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

この記事誰得? 私しか得しないニッチな技術で記事投稿!

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

Last updated at Posted at 2023-06-27

はじめに

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

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

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

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の場合

EnumProcessesGetProcessImageFileNameを使ってプロセス名からプロセスIDを取得してみます。

  1. EnumProcessesで、すべてのプロセスIDを取得
  2. OpenProcessで、プロセスを開く
  3. GetProcessImageFileNameで、プロセス名を取得
  4. プロセス名をチェックして対象プロセスだったらプロセスIDを返す
テストコード
[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;
}
|                                    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で、ほかのプロセスも調べる
テストコード
[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;
}
|                       Method |     Mean |     Error |    StdDev | Allocated |
|----------------------------- |---------:|----------:|----------:|----------:|
| TestCreateToolhelp32Snapshot | 4.100 ms | 0.7543 ms | 0.0413 ms |       5 B |

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

そもそも、Process.GetProcessesByNameって内部的にどのAPIを使用してるの?

C#のソースコードを読んでいくと、GetProcessInfosという関数があります。

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

NtQuerySystemInformationの場合

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

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

テストコード
[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;
}
|                       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を使用した方法が、実行速度、ヒープアロケーション、どちらを見ても最速となりました。

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

ソースコード全体

今回使用したコードは以下の通りです。

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?