PerformanceCounter가 느린 이유?

아래 코드는 작업관리자의 "사용자"탭처럼 사용자별 프로세스의 메모리 용량을 체크하는 소스입니다.
회사의 개발PC에서는 아주 잘 작동하고 메모리 크기도 98% 정확도로 표시가됩니다.
그래서 다른 PC에 설치를 해서 테스트를 해보니 어떤 PC에서는 프로세스 한개의 메모리를 체크하는데 10초~30초정도 걸리고, 어떤 PC에서는 "카운터는 단일 인스턴스가 아니며 인스턴스 이름을 지정해야 합니다."라는 에러가 발생합니다.
아무리 검색해도 딱히 방법이 없던데 혹시나 경험해보신분이 있다면 조언 부탁드립니다.

아래는 컴파일 가능한 전체 소스입니다. (콘솔앱, 닷넷4.8.1에서 테스트)

static Dictionary<int, string> processId2MemoryProcessName = new Dictionary<int, string>();

[DllImport("psapi.dll", SetLastError = true)]
static extern bool GetProcessMemoryInfo(IntPtr hProcess, out PROCESS_MEMORY_COUNTERS_EX counters, uint size);

[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_MEMORY_COUNTERS_EX
{
    public uint cb;
    public uint PageFaultCount;
    public UIntPtr PeakWorkingSetSize;
    public UIntPtr WorkingSetSize;
    public UIntPtr QuotaPeakPagedPoolUsage;
    public UIntPtr QuotaPagedPoolUsage;
    public UIntPtr QuotaPeakNonPagedPoolUsage;
    public UIntPtr QuotaNonPagedPoolUsage;
    public UIntPtr PagefileUsage;
    public UIntPtr PeakPagefileUsage;
    public UIntPtr PrivateUsage;
}

[DllImport("Wtsapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool WTSQuerySessionInformation(
    IntPtr hServer,
    int sessionId,
    WTS_INFO_CLASS wtsInfoClass,
    out IntPtr ppBuffer,
    out uint pbytesReturned);

[DllImport("Wtsapi32.dll")]
static extern void WTSFreeMemory(IntPtr pointer);

enum WTS_INFO_CLASS
{
    WTSUserName = 5
}


static void Main(string[] args)
{
    CheckUserMemory();

    Console.ReadLine();
}

static void CheckUserMemory()
{
    Dictionary<string, long> userMemory = new Dictionary<string, long>();

    Process[] processes = Process.GetProcesses();
    foreach (Process p in processes)
    {
        int sessionId = p.SessionId;
        string user = GetUserName(sessionId);
        if (string.IsNullOrWhiteSpace(user)) continue;

        long privateWS = CurrentMemoryUsage(p);

        if (userMemory.ContainsKey(user))
            userMemory[user] += privateWS;
        else
            userMemory.Add(user, privateWS);
    }

    foreach (var entry in userMemory)
    {
        double memMB = entry.Value / (1024.0 * 1024.0);
        Console.WriteLine($"{entry.Key} : {memMB:F1} MB");
    }
}

static string GetUserName(int sessionId)
{
    IntPtr buffer;
    uint bytesReturned;
    bool result = WTSQuerySessionInformation(IntPtr.Zero, sessionId, WTS_INFO_CLASS.WTSUserName, out buffer, out bytesReturned);

    if (!result) return null;

    string userName = Marshal.PtrToStringUni(buffer);
    WTSFreeMemory(buffer);
    return userName;
}


static long CurrentMemoryUsage(Process proc)
{
    long currentMemoryUsage = 0L;
    var nameToUseForMemory = GetNameToUseForMemory(proc);
    using (var procPerfCounter = new PerformanceCounter("Process", "Working Set - Private", nameToUseForMemory))
    {
        //에러 발생 : "카운터는 단일 인스턴스가 아니며 인스턴스 이름을 지정해야 합니다."
        currentMemoryUsage = procPerfCounter.RawValue;
    }

    return currentMemoryUsage;
}

static string GetNameToUseForMemory(Process proc)
{
    if (processId2MemoryProcessName.ContainsKey(proc.Id))
        return processId2MemoryProcessName[proc.Id];

    var nameToUseForMemory = string.Empty;
    var category = new PerformanceCounterCategory("Process");
    var instanceNames = category.GetInstanceNames().Where(x => x.Contains(proc.ProcessName));

    foreach (var instancename in instanceNames)
    {
        // PerformanceCounter가 엄청 느린 부분
        using (var procPerfCounter = new PerformanceCounter("Process", "ID Process", instancename, true))
        {
            if (procPerfCounter.RawValue != proc.Id) continue;

            nameToUseForMemory = instancename;

            break;
        }
    }

    if (!processId2MemoryProcessName.ContainsKey(proc.Id))
    {
        processId2MemoryProcessName.Add(proc.Id, nameToUseForMemory);
    }

    return nameToUseForMemory;
}

좀 더 정확히는, category.GetInstanceNames() 호출에서 더 시간이 걸리는 것입니다. 제 경우에 테스트를 해보면 호출 한 번 당 70ms 정도 걸리는데, svchost.exe처럼 100개가 넘는 상황이라면 그거 하나에 7초 정도 소요될 테니 당연히 시간이 많이 걸리는 게 맞습니다.

따라서 cache를 하려면 오히려 instanceNames를 저장해 두는 것이 더 좋습니다. (물론, 프로세스의 생성/종료에 따라 instanceNames는 바뀔 수 있다는 것이 문제긴 합니다.)

참고로, 굳이 Working Set 메모리를 구하기 위해 그런 식으로 우회해서 구할 필요가 있을까 싶은데요… 그냥 Process.WorkingSet64 필드를 이용해 구하는 것이 더 권장됩니다.

(업데이트 #1: “ID Process” 매칭시키는 것도 프로세스 수가 많아짐에 따라 시간이 걸리는 것도 맞습니다.)

(업데이트 #2: Process.WorkingSet64는 성능 카운터는 "Working Set"과 같고, 코드에서 원했던 "Working Set - Private"은 아니긴 합니다. ^^;)

(업데이트 #3: 혹은 Windows 11 이상이라는 제약만 없다면 InstanceNames와 "ID Process"를 매칭시킬 필요가 없는 “Process V2” 범주의 정보를 가져오는 방법도 있습니다. 참고: Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 “Process V2”)

(업데이트 #4: Win32_PerfRawData_PerfProc_Process를 사용하면 빠르게 구할 수 있습니다. 자세한 것은 퇴근 후에. ^^;)

3개의 좋아요

이거면 메모리 사용량이 나오는데 PerformanceCounter 의 역활은 뭔가요?

아래의 글에 정리했으니 참고하세요.

C# - 프로세스 메모리 중 Private Working Set 크기를 구하는 방법(성능 카운터, WMI)

4개의 좋아요

처음에 말씀하신 API로도 구현해봤는데, 실행하면 작업관리자에서 보이는 메모리와 차이가 많이납니다. 그래서 여기저기 찾아서 최종적으로 발견한게 본문에 있는 소스입니다.

감사합니다.
한번 확인해보겠습니다.