The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何透過 PowerShell 優雅的關閉 Chrome、Edge 或任何視窗應用程式

我們最近有個專案使用 Blazor WebAssembly 技術打造一個以 Web 介面為主的 POS 系統,過程中有個功能需要重啟 Microsoft Edge 瀏覽器,我發現直接停用 Process (處理序) 的作法,可能會導致 Microsoft Edge 重啟時發生異常通知,這才發現原來還有更安全的關閉方法。這篇文章我打算來分享幾種不同的方法與適用的情境。

image

使用 PowerShell 取得正在運行的處理序

基本上有兩種方法,各有優缺點:

  1. 使用 Get-Process cmdlet

    取得所有處理序 (不含處理序擁有者資訊,你看不出啟動該處理序的使用者是誰)

    Get-Process
    

    取得名為 msedge 的處理序 (若用 msedge.exe 是搜尋不到的喔)

    Get-Process -Name 'msedge'
    

    取得名為 msedgechrome 的處理序

    Get-Process -Name 'msedge', 'chrome'
    

    篩選出有視窗標題的處理序清單 (這些都是你目前可以肉眼看見的視窗應用程式清單)

    Get-Process | Where-Object {$_.MainWindowTitle} | Format-Table Id, Name, MainWindowTitle -AutoSize
    

    取得名為 notepad2 的處理序物件,並取得該物件所有屬性

    Get-Process -Name 'notepad2' | Format-List *
    

    請注意: 使用 Format-List 只會顯示 Id, Handles, CPU, SI, Name 這 5 個摘要欄位,你必須使用 Format-List * 才能取得完整 Process 物件屬性清單!

    Name                       : Notepad2
    Id                         : 19680
    PriorityClass              : Normal
    FileVersion                : 4.2.25
    HandleCount                : 219
    WorkingSet                 : 16220160
    PagedMemorySize            : 2654208
    PrivateMemorySize          : 2654208
    VirtualMemorySize          : 171982848
    TotalProcessorTime         : 00:00:00.3593750
    SI                         : 1
    Handles                    : 219
    VM                         : 4466950144
    WS                         : 16220160
    PM                         : 2654208
    NPM                        : 13048
    Path                       : C:\Program Files\Notepad2\Notepad2.exe
    CommandLine                : "C:\Program Files\Notepad2\Notepad2.exe" /z "C:\WINDOWS\system32\notepad.exe"
    Parent                     : System.Diagnostics.Process (explorer)
    Company                    :
    CPU                        : 0.359375
    ProductVersion             :
    Description                : Notepad2 x64
    Product                    :
    __NounName                 : Process
    SafeHandle                 : Microsoft.Win32.SafeHandles.SafeProcessHandle
    Handle                     : 596
    BasePriority               : 8
    ExitCode                   :
    HasExited                  : False
    StartTime                  : 2022/11/17 下午 12:11:47
    ExitTime                   :
    MachineName                : .
    MaxWorkingSet              : 1413120
    MinWorkingSet              : 204800
    Modules                    : {System.Diagnostics.ProcessModule (Notepad2.exe), System.Diagnostics.ProcessModule (ntdll.
                                dll), System.Diagnostics.ProcessModule (KERNEL32.DLL), System.Diagnostics.ProcessModule (K
                                ERNELBASE.dll)…}
    NonpagedSystemMemorySize64 : 13048
    NonpagedSystemMemorySize   : 13048
    PagedMemorySize64          : 2654208
    PagedSystemMemorySize64    : 247008
    PagedSystemMemorySize      : 247008
    PeakPagedMemorySize64      : 3317760
    PeakPagedMemorySize        : 3317760
    PeakWorkingSet64           : 16269312
    PeakWorkingSet             : 16269312
    PeakVirtualMemorySize64    : 4502978560
    PeakVirtualMemorySize      : 208011264
    PriorityBoostEnabled       : True
    PrivateMemorySize64        : 2654208
    ProcessorAffinity          : 65535
    SessionId                  : 1
    StartInfo                  :
    Threads                    : {33320}
    VirtualMemorySize64        : 4466950144
    EnableRaisingEvents        : False
    StandardInput              :
    StandardOutput             :
    StandardError              :
    WorkingSet64               : 16220160
    SynchronizingObject        :
    MainModule                 : System.Diagnostics.ProcessModule (Notepad2.exe)
    PrivilegedProcessorTime    : 00:00:00.2968750
    UserProcessorTime          : 00:00:00.0625000
    ProcessName                : Notepad2
    MainWindowHandle           : 4653976
    MainWindowTitle            : Untitled - Notepad2
    Responding                 : True
    Site                       :
    Container                  :
    

    取得名為 notepad2 的處理序,並取得執行該處理序的使用者名稱 (需以系統管理員身份執行)

    Get-Process -Name 'notepad2' -IncludeUserName | Format-List *
    
  2. 使用 Get-CimInstance 取得執行該處理序的使用者名稱

    由於透過 Get-Process 取得使用者名稱需要以系統管理員身份執行,我個人比較不喜歡這樣,所以我找到一個方法可以繞過這個限制,那就是透過 WMI 來取得這個資訊!

    $process = (Get-CimInstance Win32_Process)
    $process | foreach {
      $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwner | select -ExpandProperty user
      Get-Process -Id $_.ProcessId | select-object Id,ProcessName,SI,StartTime,Name,@{n='UserName';e={$owner}}
    }
    

    取得 msedge.exe 處理序並顯示所有屬性

    $process = (Get-CimInstance Win32_Process -Filter "name = 'msedge.exe'")
    $process | foreach {
      $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwner | select -ExpandProperty user
      Get-Process -Id $_.ProcessId | select-object *,@{n='UserName';e={$owner}}
    }
    

使用 PowerShell 關閉處理序

透過 PowerShell 關閉處理序有好幾種方法:

  • 強制關閉處理序 (Kill the process)

    我從 PowerShell 的 Stop-Process cmdlet 的原始碼之中可以發現一個文件沒寫的秘密,那就是 Stop-Process 骨子裡就是用 ProcessKill 方法立即停止該處理序,所以是比較暴力的解法,可能會導致應用程式的資料遺失!

    例如:關閉 Notepad2 處理序,你會這樣執行:

    Get-Process -Name 'notepad2' | Stop-Process
    

    如果你正好有正在編輯但尚未儲存的資料,當處理序被砍掉後,資料也會遺失!

    image

    除了用 Stop-Process cmdlet 砍掉處理序外,你也可以用以下命令做到一樣的事:

    (Get-Process -Name 'notepad2').Kill()
    

    透過上述 Kill 方法 (原始碼) 是透過 Win32 API 對 Process 送出 SIGKILL 訊號,讓作業系統強制關閉該處理序。

    如果你的應用程式有開很多子處理序(Child process),而你希望連同子處理序一起砍掉的話,可以這樣執行:

    (Get-Process -Name 'notepad2').Kill($true)
    

    這個方法比 Stop-Process cmdlet 更暴力,但也非常有效!

    如下圖是 Microsoft Edge 強制關閉之後重開的顯示訊息:

    image

  • 優雅的關閉處理序 (Close the process)

    你透過 Get-Process 取得到的 Process 物件,都會有個 MainWindowHandle 屬性,這是處理序主視窗視窗控制代碼,只要該值大於 0 就代表這是一個有「視窗」的應用程式,也意味著這是一個含有 UI 介面的應用程式。

    注意: 你的 Windows 作業系統中,所有處理序所有視窗,都會有個唯一的 MainWindowHandle 值,在同一台電腦上是唯一識別值(Unique identifier),不會有重複的 Handle ID 出現。

    我們如果要關閉這類視窗應用程式,就可以改用 CloseMainWindow 方法來關閉。我以 Notepad2 為例,在編輯到一半的時候,收到關閉的通知,該應用程式會先問你要不要儲存檔案,因此這是一種比較溫和的關閉方法:

    image

    透過上述 CloseMainWindow 方法 (原始碼),其技術核心並非使用 Signal 的方式關閉程式,而是透過 Microsoft.Win32 命名空間下的 NativeMethods 類別 (這是一個 internal static class 所以不是一個公開的 API) 的 PostMessage 方法,對視窗應用程式送出 NativeMethods.WM_CLOSE 訊息(Message),讓該視窗應用程式自行決定該如何關閉應用程式。

如何優雅的關閉 Chrome/Edge 瀏覽器

最後回到我們的專案需求,我們需要重開 Chrome 或 Edge 瀏覽器,但是又怕使用者在瀏覽器上操作到一半,所以最完美的作法,應該要符合以下條件:

  1. 找出 Chrome 或 Edge 瀏覽器的主要處理序(Main Process)

    由於 Chrome 或 Edge 瀏覽器採用 Multi-process 策略,當你開啟瀏覽器時,事實上會從工作管理員看到數十個不同的 Process 同時執行,基本上一個頁籤就會有一個 Process 在跑。這種策略可以大幅提昇瀏覽器的穩定度,這才不會當你在一個頁籤掛掉的時候,連同其他頁籤也一起掛掉。

    在這麼多 Process 之間,其實只有一個是主要處理序(Main Process),我們只要找到該處理序,並優雅的關閉他即可!

    Get-Process -Name 'chrome' | Where-Object { $_.MainWindowHandle -gt 0 }
    
  2. 透過 CloseMainWindow 方法關閉這些主要處理序

    關閉 Google Chrome 瀏覽器的主要處理序(Main Process)

    Get-Process -Name 'chrome' | Where-Object { $_.MainWindowHandle -gt 0 } | Foreach-Item {
      $_.CloseMainWindow()
    }
    

    關閉 Microsoft Edge 瀏覽器的主要處理序(Main Process)

    Get-Process -Name 'msedge' | Where-Object { $_.MainWindowHandle -gt 0 } | Foreach-Item {
      $_.CloseMainWindow()
    }
    

    此時如果有任意頁籤的網頁有實作 beforeunload 事件,你的瀏覽器就會出現「是否要離開網站?」的提示訊息,如下圖示:

    是否要離開網站?

  3. 等待 10 秒鐘,如果使用者不回應,就強制關閉處理序

    你可以透過 .HasExited 屬性來判斷該程序是否已經退出

    # 嘗試關閉 Edge 瀏覽器
    $edge = Get-Process -Name 'msedge' | Where-Object { $_.MainWindowHandle -gt 0 }
    $edge.CloseMainWindow()
    
    # 等候 10 秒鐘
    Start-Sleep -Seconds 10
    
    # 更新 Edge 瀏覽器處理序狀態
    $edge.Refresh()
    
    # 判斷該處理序是否已經結束
    if (!$edge.HasExited) {
      # 若處理序未結束就強制結束它
      $edge | Stop-Process -Force
    }
    

總結

給大家一些懶人包,複製貼上就能跑!👍

  • 強制關閉 Google Chrome 或 Microsoft Edge 瀏覽器

    Get-Process -Name 'chrome' | Where-Object { $_.MainWindowHandle -gt 0 } | Stop-Process
    
    Get-Process -Name 'msedge' | Where-Object { $_.MainWindowHandle -gt 0 } | Stop-Process
    
  • 優雅關閉 Google Chrome 或 Microsoft Edge 瀏覽器

    (Get-Process -Name 'chrome' | Where-Object { $_.MainWindowHandle -gt 0 }).CloseMainWindow()
    
    (Get-Process -Name 'msedge' | Where-Object { $_.MainWindowHandle -gt 0 }).CloseMainWindow()
    
  • 軟硬兼施從容優雅的關閉方法

    我自己寫了一個 Close-Process cmdlet 並搭配遞迴(Recursion)呼叫,可以很方便的關閉任意視窗應用程式。它會自動倒數計時 9 秒,只要應用程式已結束就會立刻結束執行,但如果時間到還沒結束,程式就會強制關閉應用程式!

    Function Close-Process {
      [CmdletBinding()]
      param (
          [Parameter(Mandatory, ValueFromPipeline)]
          $Process,
    
          [int]$WaitSeconds = 9
      )
    
      if ($Process -eq $null) { return }
    
      "Closing $($edge.ProcessName) process. Counting down: $WaitSeconds" | Out-Default
    
      if ($Process.HasExited) {
        $Process.Close()
        return
      }
    
      if ($Process.HasExited -eq $false) {
          $Process.Refresh()
          [void]$Process.CloseMainWindow()
      }
    
      if ($WaitSeconds -eq 0) {
        $Process | Stop-Process
        return
      }
    
      Start-Sleep -Seconds 1
    
      $Process | Close-Process -WaitSeconds ($WaitSeconds - 1)
    }
    

    基本使用方式:

    Get-Process -Name 'chrome' -ErrorAction Ignore | Where-Object { $_.MainWindowHandle -gt 0 } | Close-Process
    

    指定等待秒數 (倒數計時 30 秒,若視窗應用程式依然未結束就強制關閉應用程式)

    Get-Process -Name 'msedge' -ErrorAction Ignore | Where-Object { $_.MainWindowHandle -gt 0 } | Close-Process -WaitSeconds 30
    
  • 透過 C# 來撰寫相同功能,程式碼更好看,也比 PowerShell 還容易閱讀!

    以下用 .NET 7 + Top-level statement 環境下執行:

    using System.Diagnostics;
    
    var data = from p in Process.GetProcesses()
                where p.MainWindowHandle > 0 && p.ProcessName == "msedge"
              select p;
    
    foreach (Process edge in data)
    {
        GracefullyShutdownProcess(edge);
        
        Console.WriteLine(edge.ProcessName + " closed.");
    }
    
    void GracefullyShutdownProcess(Process p, int waitSeconds = 9)
    {
        if (!p.HasExited)
        {
            p.CloseMainWindow();
    
            if (waitSeconds == 0)
            {
                p.Kill();
                return;
            }
    
            Console.WriteLine($"Closing {p.ProcessName} process. Counting down: {waitSeconds}");
    
            Thread.Sleep(1_000);
    
            p.Refresh();
            GracefullyShutdownProcess(p, waitSeconds - 1);
        }
    }
    

相關連結