規劃系統時自己搞出一個冷門需求,打算比照 Windows Task Manager (工作管理員)列舉執行中程式名稱、CPU 使用率、記憶體用量,以便透過 API 方式偵錯遠端系統問題(例如:檢查 IIS 的 w3wp 是否 CPU 滿載或接近 2GB 記憶體上限)。要達到此一目標,我打算取得所有執行中應用程式的名稱、執行帳號(主要用於識別 IIS AppPool)、CPU 使用率以及記憶體用量(Private Working Set):(下圖黃色欄位)

動手前先做了功課。首先,發現一個好用的命令列工具:tasklist

呼叫外部程式截取其執行結果是很省事的解法,tasklist 的輸出結果乍看可滿足程式名稱、執行帳號及記憶體用量三項資訊,只缺 CPU 使用率。細究後卻發現,它的記憶體數字是 Working Set,與我們在工作管理員常用的 Private Working Set 不同,Working Set 是實體記憶體用量沒錯,但包含多程式共用部分。例如: 程序 A、B、C 的 Working Set 各為 100 MB、200 MB 及 300 MB,三者的 Working Set 均有 50MB 屬共用部分,故三支程式實際耗用的實體記憶體為 500 MB ,而非 600 MB,因此 Private Working Set 較能突顯因為此程式耗用的記憶體。延伸閱讀:CyberNotes: Windows Memory Usage Explained

評估後決定用 .NET 程式自幹。經爬文,Process.GetProcesses() 可取得系統所有執行中程序,而 C# 抓 CPU% 及記憶體用量多半是透過 .NET PerformanceCounter 類別取得(延伸閱讀:程式範例-使用 C# 查詢 CPU 與記憶體使用狀況),抓 Process 執行身分則可靠 WMI 的 GetOwner(),所有的資訊都有管道可取。

建立 PerformanceCounter 時有個眉角,new PerformanceCounter(類別, 計數器名稱, 執行個體名稱)中的執行個體名稱(Instance Name),遇同一程式執行多份時,PerformanceCounter 執行個體名稱會以類似 chrome、chrome#1、chrome#2... 的形式自動編號,只能經由列舉比對找出 Process 對應的執行個體名稱。關於比對技巧,可參考 MVP Rick Capturing Performance Counter Data for a Process by Process Id一文。

將查到的做法組裝起來,我寫出第一個版本:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Text;
using System.Threading.Tasks;

namespace MyTaskMgr
{
    public class ProcInfoModule
    {

        public static List<ProcInfoDto> QueryProcesses()
        {
            var sw = new Stopwatch();
            sw.Start();
            var list = Process.GetProcesses();
            var res = list.AsParallel().Select(o =>
            {
                var p = new ProcInfoDto()
                {
                    ProcessId = o.Id,
                    Name = o.ProcessName,
                };
                var pcs = GetProcCountersForProcessId(o.Id, "% Processor Time,Working Set - Private".Split(','));
                if (pcs != null)
                {
                    p.ProcTimeInPerc = $"{pcs[0].NextValue() / Environment.ProcessorCount}%";
                    p.PrivWorkSet = $"{pcs[1].NextValue() / 1024:n0}K";
                }

                return p;
            }).ToList();
            sw.Stop();
            Console.WriteLine($"Get CPU & Memory Usage {sw.ElapsedMilliseconds:n0}ms");
            sw.Restart();
            res.AsParallel().ForAll(o => { o.UserName = GetProcOwnerName(o.ProcessId); });
            sw.Stop();
            Console.WriteLine($"Get Proc Owner {sw.ElapsedMilliseconds:n0}ms");
            return res;
        }

        //REF: https://dotblogs.com.tw/larrynung/2013/03/11/96137
        static string GetProcOwnerName(int pid)
        {
            var query = "Select * From Win32_Process Where ProcessId = " + pid;
            var searcher = new ManagementObjectSearcher(query);
            var mgmtObj = searcher.Get().Cast<ManagementObject>().FirstOrDefault();
            if (mgmtObj != null)
            {
                var argList = new string[2];
                if (Convert.ToInt32(mgmtObj.InvokeMethod("GetOwner", argList)) == 0)
                    return string.Join(@"\", argList.Reverse().ToArray());

            }
            return null;
        }

        //REF: https://weblog.west-wind.com/posts/2014/Sep/27/Capturing-Performance-Counter-Data-for-a-Process-by-Process-Id
        public static PerformanceCounter[] GetProcCountersForProcessId(int processId, string[] processCounterNames)
        {
            string instance = GetInstanceNameForProcessId(processId);
            if (string.IsNullOrEmpty(instance))
                return null;
            return processCounterNames.Select(o => new PerformanceCounter("Process", o, instance)).ToArray();
        }

        public static string GetInstanceNameForProcessId(int processId)
        {
            try
            {
                var process = Process.GetProcessById(processId);
                string processName = Path.GetFileNameWithoutExtension(process.ProcessName);

                PerformanceCounterCategory cat = new PerformanceCounterCategory("Process");
                string[] instances = cat.GetInstanceNames()
                    .Where(inst => inst.StartsWith(processName))
                    .ToArray();

                foreach (string instance in instances)
                {
                    using (PerformanceCounter cnt = new PerformanceCounter("Process",
                        "ID Process", instance, true))
                    {
                        int val = (int) cnt.RawValue;
                        if (val == processId)
                        {
                            return instance;
                        }
                    }
                }
            }
            catch
            {
                //ignore
            }
            return null;
        }
    }
}

測試程式如下,先取得程序列表,印出個數,從中挑出 IIS (w3wp),印出其執行帳號、CPU% 及記憶體用量。(註:程式需以管理者權限執行,否則查 GetOwner() 可能出現存取被拒)

var res = ProcInfoModule.QueryProcesses();
Console.WriteLine(res.Count);
var iis = res.SingleOrDefault(o => o.Name == "w3wp");
Console.WriteLine($"{iis.UserName},{iis.ProcTimeInPerc},{iis.PrivWorkSet}");
Console.Read();

第一版拼裝車可行,但耗時快三分鐘才跑完,慢到令人抓狂。

Get CPU & Memory Usage 146,312ms
Get Proc Owner 25,890ms
356
IIS APPPOOL\\MyWeb,0%,163,268K

分析問題出在每次由 Process ID 重新掃瞄比對 Instance Name 的做法太沒效率,於是先做一次簡單重構,將列舉結果一次轉成 Dictionary<int, string>,後面改用查表,應能大幅改善效能。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Text;
using System.Threading.Tasks;

namespace MyTaskMgr
{
    public class ProcInfoModule2
    {

        public static List<ProcInfoDto> QueryProcesses()
        {
            var sw = new Stopwatch();
            sw.Start();
            BuildInstanceNameDictionary();
            sw.Stop();
            Console.WriteLine($"BuildInstanceNameDictionary {sw.ElapsedMilliseconds:n0}ms");
            sw.Restart();
            var list = Process.GetProcesses();
            var res = list.AsParallel().Select(o =>
            {
                var p = new ProcInfoDto()
                {
                    ProcessId = o.Id,
                    Name = o.ProcessName,
                };
                var pcs = GetProcCountersForProcessId(o.Id, "% Processor Time,Working Set - Private".Split(','));
                if (pcs != null)
                {
                    p.ProcTimeInPerc = $"{pcs[0].NextValue() / Environment.ProcessorCount}%";
                    p.PrivWorkSet = $"{pcs[1].NextValue() / 1024:n0}K";
                }

                return p;
            }).ToList();
            sw.Stop();
            Console.WriteLine($"Get CPU & Memory Usage {sw.ElapsedMilliseconds:n0}ms");
            sw.Restart();
            res.AsParallel().ForAll(o => { o.UserName = GetProcOwnerName(o.ProcessId); });
            sw.Stop();
            Console.WriteLine($"Get Proc Owner {sw.ElapsedMilliseconds:n0}ms");
            return res;
        }

        //REF: https://dotblogs.com.tw/larrynung/2013/03/11/96137
        static string GetProcOwnerName(int pid)
        {
            //...省略...
        }

        //REF: https://weblog.west-wind.com/posts/2014/Sep/27/Capturing-Performance-Counter-Data-for-a-Process-by-Process-Id
        public static PerformanceCounter[] GetProcCountersForProcessId(int processId, string[] processCounterNames)
        {
            //...省略...
        }

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

        static void BuildInstanceNameDictionary()
        {
            var proc = Process.GetProcesses().ToDictionary(o => o.Id, o => string.Empty);

            PerformanceCounterCategory cat = new PerformanceCounterCategory("Process");
            cat.GetInstanceNames()
                .AsParallel()
                .ForAll(n =>
                {
                    try
                    {
                        var pid = (int) new PerformanceCounter(
                            "Process", "ID Process", n, true).RawValue;
                        if (proc.ContainsKey(pid)) proc[pid] = n;
                    }
                    catch
                    {
                        //ignore
                    }
                });

            PidToInstanceNameDictionary = proc;
        }

        public static string GetInstanceNameForProcessId(int processId)
        {
            if (PidToInstanceNameDictionary.ContainsKey(processId))
                return PidToInstanceNameDictionary[processId];
            return null;
        }
    }
}

重構版本果然大有起色,花 10 秒建立對照表,花 15 秒測量 353 個 Process 的 CPU% 及記憶體,再花 25 秒走 WMI 查出 Process 執行身分。

BuildInstanceNameDictionary 10,345ms
Get CPU & Memory Usage 15,821ms
Get Proc Owner 25,967ms
353
IIS APPPOOL\\MyWeb,,0%,138,008K

雖然時間縮短到 1/3,但共耗時 50 秒還是讓我無法接受,WMI 及 PerformanceCounter 並不以效率著稱,屬於先天限制,很難再有突破,必須改換它法。Windows 內建的工作管理或 SysInternal Process Explorer 可以做到一秒一刷,就跟我的 C# 版本有天壤之別,爬文推測應是使用未公開的 Win32 API (延伸閱讀:NtQuerySystemInformation的使用),研究完技術文件,改用該 API 的複雜度超出我的能力範圍,但確立改用 Win32 API 取代 WMI/PeformanceCounter 加速的方向。

尋找資料過程,意外發現 SELECT * FROM Win32\_PerfFormattedData\_PerfProc\_Process 這個 WMI 查詢能一口氣取得 Process ID、Name、CPU %、Private Working Set 資訊,而且速度不慢。缺少的執行帳號,我也找到使用 Win32 API 由 Process Handle 取得執行帳號的做法,於是寫了第三版:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace MyTaskMgr
{
    public class ProcInfoModule3
    {

        public static List<ProcInfoDto> QueryProcesses()
        {
            var sw = new Stopwatch();
            sw.Start();
            var procs = QueryProcInfoByCIMV2();
            sw.Stop();
            Console.WriteLine($"QueryProcInfoByCIMV2 {sw.ElapsedMilliseconds:n0}ms");
            sw.Restart();
            Process.GetProcesses().AsParallel()
                .ForAll(o =>
                {
                    try
                    {
                        if (procs.ContainsKey(o.Id))
                        {
                            procs[o.Id].UserName = ExGetProcUserByHandle(o.Handle);
                        }
                    }
                    catch
                    {
                        //Ignore
                    }
                });
            sw.Stop();
            Console.WriteLine($"Get Proc Owner {sw.ElapsedMilliseconds:n0}ms");
            return procs.Values.OrderBy(o => o.Name).ToList();
        }

        //https://bytes.com/topic/c-sharp/answers/225065-how-call-win32-native-api-gettokeninformation-using-c
        public const int TOKEN_QUERY = 0X00000008;

        enum TOKEN_INFORMATION_CLASS { TokenUser = 1 }

        [StructLayout(LayoutKind.Sequential)]
        struct TOKEN_USER
        {
            public _SID_AND_ATTRIBUTES User;
        }
        [StructLayout(LayoutKind.Sequential)]
        public struct _SID_AND_ATTRIBUTES
        {
            public IntPtr Sid;
            public int Attributes;
        }

        [DllImport("advapi32")]
        static extern bool OpenProcessToken(
            IntPtr ProcessHandle, int DesiredAccess, ref IntPtr TokenHandle 
        );

        [DllImport("advapi32", CharSet = CharSet.Auto)]
        static extern bool GetTokenInformation(
            IntPtr hToken, TOKEN_INFORMATION_CLASS tokenInfoClass, IntPtr TokenInformation,
            int tokeInfoLength, ref int reqLength
        );

        [DllImport("kernel32")]
        static extern bool CloseHandle(IntPtr handle);

        public static bool DumpUserInfo(IntPtr pToken, out IntPtr SID)
        {
            int Access = TOKEN_QUERY;
            IntPtr procToken = IntPtr.Zero;
            bool ret = false;
            SID = IntPtr.Zero;
            try
            {
                if (OpenProcessToken(pToken, Access, ref procToken))
                {
                    ret = ProcessTokenToSid(procToken, out SID);
                    CloseHandle(procToken);
                }
                return ret;
            }
            catch (Exception err)
            {
                return false;
            }
        }

        private static bool ProcessTokenToSid(IntPtr token, out IntPtr SID)
        {
            TOKEN_USER tokUser;
            const int bufLength = 256;
            IntPtr tu = Marshal.AllocHGlobal(bufLength);
            bool ret = false;
            SID = IntPtr.Zero;
            try
            {
                int cb = bufLength;
                ret = GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenUser, tu, cb, ref cb);
                if (ret)
                {
                    tokUser = (TOKEN_USER)Marshal.PtrToStructure(tu, typeof(TOKEN_USER));
                    SID = tokUser.User.Sid;
                }
                return ret;
            }
            catch (Exception err)
            {
                return false;
            }
            finally
            {
                Marshal.FreeHGlobal(tu);
            }
        }

        public static string ExGetProcUserByHandle(IntPtr handle)
        {
            try
            {
                IntPtr _SID = IntPtr.Zero;
                if (DumpUserInfo(handle, out _SID))
                {
                    return new System.Security.Principal.SecurityIdentifier(_SID)
                        .Translate(typeof(System.Security.Principal.NTAccount)).Value;
                }
            }
            catch { }
            return "Unknown";
        }

        public static Dictionary<int, ProcInfoDto> QueryProcInfoByCIMV2()
        {
            //http://wutils.com/wmi/root/cimv2/win32_perfformatteddata_perfproc_process/
            ManagementObjectSearcher searcher =
                new ManagementObjectSearcher("root\\CIMV2",
                    "SELECT * FROM Win32_PerfFormattedData_PerfProc_Process");
            return searcher.Get().Cast<ManagementObject>().ToList().Select(queryObj =>
                {
                    var pid = Convert.ToInt32(queryObj["IDProcess"]);
                    if (pid == 0) return null;
                    return new ProcInfoDto()
                    {
                        ProcessId = pid,
                        Name = queryObj["Name"].ToString(),
                        PrivWorkSet = $"{Convert.ToUInt64(queryObj["WorkingSetPrivate"]) / 1024:n0}K",
                        ProcTimeInPerc = $"{Convert.ToInt64(queryObj["PercentProcessorTime"]):n0}%"
                    };
                })
                .Where(o => o != null)
                .ToDictionary(o => o.ProcessId, o => o);
        }
    }
}

執行速度由三分鐘一舉推進到 1 秒內,辛苦半天終於達到可接受的水準,特 PO 文留念。(灑花)

QueryProcInfoByCIMV2 534ms
Get Proc Owner 104ms
368
IIS APPPOOL\\MyWeb,5%,109,656K

2018-11-29 補充:網友提到 PowerShell 有個 Get-Process,試了一下,執行速度夠快且一次給足 Process Name、CPU、Memory 與 UserName (如下圖),可惜跟 .NET Process 類別一樣,記憶體數字只有 Working Set 沒有 Private Working Set,仍需事後加工,評估後決定維持現行做法。

It's not difficulte to provide a process list including process name, cpu%, working set private and username with .NET program, but the WMI and performance counter is way too slow. By replacing Win32\_PerfFormattedData\_PerfProc\_Process and Win32 API, the excution time is 170x faster.


Comments

# by Robert chu

請問ProcInfoDto這個類別定義在哪裡?

# by Jeffrey

to Robert chu, ProcInfoDto是我為示範隨意捏造的自訂類別,可換成你想用的資料呈現物件。 若要直接使用ProcInfoDto,可以定義如下 public class ProcInfoDto { public int PID; public string Name; public string PrivWorkSet; public string ProcTimeInPerc; }

# by Balance

可以教一下...為什麼跑出來的結果與工作管理員的處理程序內的值不同(與工作管理員的詳細資料內的值基本是相符的)

# by Jeffrey

to Balance,不知是哪個值不相同,如果擷圖範例大家可以幫想。

Post a comment