Process CPU Usage Limit

Finds threads over consuming CPU in the selected process and reduces their average CPU consumption based on the agressiveness argument. The higher the agressiveness, the more CPU throttling is performed. The number can be between 1 and 10 including decimal places.
A duration can be set, in minutes or parts there of, for how long the selected process will be monitored/adjusted but if set to 0 then the process will be monitored/adjusted until it exits.

WARNING: This may make interactive applications become sluggish for users if they are targeted
Version 1.2.7
Created on 2019-10-03
Modified on 2019-10-29
Created by guy.leech
Downloads: 644

The Script Copy Script Copied to clipboard
#requires -version 3
<#
.SYNOPSIS

Find over consuming threads of the specified process and pause them for a while to reduce their CPU usage

.DETAILS

Pauses threads within the monitored process when that thread's CPU usage ius excessive based on the aggressiveness and resumes them when the thread's average CPU usage drops below the threshold

.PARAMETER badPid

The process id of the process which is to be monitored/clamped

.PARAMETER aggressiveness

The agressiveness of the CPU throttling where 1 is low and 10 is high

.PARAMETER durationMinutes

The duration in minutes of how long the process will monitor/control CPU for. If set to 0 then will monitor for the life of the monitored process.

.PARAMETER relaunched

Not exposed to the console. This is so the script can tell if it neds to copy itself and respawn with the same parameters and adding -relaunched

.CONTEXT

Process 

.MODIFICATION_HISTORY:

@guyrleech 29/10/19

#>

[CmdletBinding()]

Param
(
    [Parameter(Mandatory=$true,HelpMessage='Process Id to monitor')]
    [int]$badPid ,
    [Parameter(Mandatory=$true,HelpMessage='Aggressiveness of CPU clamping, 1 to 10 where 1 is low')]
    [double]$aggressiveness ,
    [double]$durationMinutes = 0 ,
    [switch]$relaunched ## not specified from the console, used to control respawning of script
)

$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { 'Continue' } else { 'SilentlyContinue' })
$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { 'Continue' } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'ErrorAction' ] ) { $ErrorActionPreference } else { 'Stop' })

Add-Type @'
using System;
using System.Runtime.InteropServices;

public static class Kernel32
{
    [DllImport( "kernel32.dll",SetLastError = true )]
    public static extern IntPtr OpenThread( 
        UInt32 dwDesiredAccess, 
        bool bInheritHandle, 
        UInt32 dwThreadId );

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool CloseHandle(
        [In] IntPtr hHandle );
            
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern int SuspendThread(
        [In] IntPtr hThread );

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern int Wow64SuspendThread(
        [In] IntPtr hThread );

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern int ResumeThread(
        [In] IntPtr hThread );

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool IsWow64Process(
        [In] IntPtr hProcess ,
        [Out,MarshalAs(UnmanagedType.Bool)] out bool wow64Process );

    public enum ThreadAccess
    {
        THREAD_SUSPEND_RESUME = 0x2 ,
        THREAD_QUERY_INFORMATION = 0x40 ,
        THREAD_QUERY_LIMITED_INFORMATION = 0x800,
    };
}
'@ -ErrorAction Stop

[int]$targetCPU = 100 - ($aggressiveness * 10) ## percent

if( $targetCPU -lt 0 -or $targetCPU -gt 100 )
{
    Throw "Illegal value for aggressiveness specified - must be between 1 and 10"
}

$PSBoundParameters.GetEnumerator() | Write-Verbose

if( ! $relaunched ) ## need to relaunch this script so the ControlUp launched instance can return and not timeout
{
    ## Copy this script since ControlUp will delete the script it launches
    ## Get folder for script and we'll use it too
    [string]$originalScript =  ( & { $myInvocation.ScriptName } )
    $process = Get-Process -Id $badPid| Select -First 1
    if( $process )
    {
        $copiedScript = Join-Path (Split-Path $originalScript -Parent) ('Async_' + $(Split-Path $originalScript -Leaf))
        Copy-Item -Path $originalScript -Destination $copiedScript -Force
        if( Test-Path $copiedScript -PathType Leaf -ErrorAction SilentlyContinue )
        {
            [string]$arguments = "-badPid $badPid -aggressiveness $aggressiveness -durationMinutes $durationMinutes -relaunched"
            Write-Verbose "Arguments are $arguments"
            $invocation = Invoke-CimMethod -ClassName Win32_Process -MethodName create -Arguments @{ 
                CommandLine = ( "Powershell.exe -NonInteractive -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File `"$copiedScript`" $arguments" ) ;
                ProcessStartupInformation = New-CimInstance -CimClass ( Get-CimClass Win32_ProcessStartup ) -Property @{ ShowWindow = 1 } -Local ;  
                CurrentDirectory = $null }
            if( ! $invocation -or ! $invocation.ProcessId )
            {
                Write-Error "Failed to launch `"$copiedScript`", return value was $($invocation.ReturnValue)"
                exit $invocation.ReturnValue
            }
            else
            {
                Write-Output "Launched `"$copiedScript`" asynchronously as process id $($invocation.ProcessId)"
            }
        }
        else
        {
            Throw "Failed to copy `"$originalScript`""
        }
    }
    else
    {
        Write-Error "Unable to get process for pid $badPid"
    }
    ## we have launched our child process so we exit
    exit 0
}

[int]$is32bitProcess = 0
## Get session for thread as we will not operate on session 0 due to deadlock potential
$theProcess = Get-Process -Id $badPid -ErrorAction Stop

if( $theProcess.SessionId -eq 0 )
{
    Throw "Process $badPid is in session 0 which could cause deadlocks if changed"
}

if( ! [kernel32]::IsWow64Process( $theProcess.Handle , [ref]$is32bitProcess ) )
{
    Throw "Failed to determine if process $badPid is 32 or 64 bit"
}

[hashtable]$adjustedThreads = @{}
[int]$adjustmentsMade = 0
[int]$samplePeriod = 100 ## milliseconds
[hashtable]$threadInfo = @{}
[int]$indexSize = 30
$thisProcess = $null
[bool]$pulse = $false

$timer = [Diagnostics.Stopwatch]::StartNew()

$theProcess | select -ExpandProperty Threads | ForEach-Object `
{
    ## Add our own CPU consumption object so that WMI won't update it
    $threadInfo.Add( $_.Id , [pscustomobject]@{ 
        'TotalCPUConsumed' = $(if( $_.TotalProcessorTime.PSObject.Properties[ 'TotalMilliseconds' ] ) { $_.TotalProcessorTime.TotalMilliseconds } else { 0 })
        'Handle' = $null
        'Consumptions' = @( 1..$indexSize | ForEach-Object { 0 } )
        'Index' = [long]0
        'Timer' = $timer.Elapsed.TotalMilliseconds
        'Pulse' = $pulse
        'Paused' = $false } )
}

[datetime]$startTime = [Datetime]::Now

try
{
    do
    {
        Start-Sleep -Milliseconds $samplePeriod 
        $pulse = ! $pulse
        [array]$threadsAfter = @( )
        ## Now see which threads have consumed the most CPU
        $thisProcess = Get-Process -Id $badPid -ErrorAction SilentlyContinue
        if( ! $thisProcess -or $thisProcess.HasExited )
        {
            Write-Warning "Process $badPid has exited"
            break
        }

        $thisProcess | select -ExpandProperty Threads | ForEach-Object `
        {
            $threadAfter = $PSItem
            $existingThread = $threadInfo[ $threadAfter.Id ]
            if( $existingThread )
            {
                [double]$cpuConsumptionNow = $(if(  $threadAfter.TotalProcessorTime.PSObject.Properties -and $threadAfter.TotalProcessorTime.PSObject.Properties[ 'TotalMilliseconds' ] ) { $threadAfter.TotalProcessorTime.TotalMilliseconds } else { 0 })
               
                [double]$threadCPUms = ( $cpuConsumptionNow - $existingThread.TotalCPUConsumed )
                [double]$timeMs = $timer.Elapsed.TotalMilliseconds - $existingThread.Timer
                [double]$totalThreadCPUPercent = $threadCPUms / $timeMs * 100
                ## If the array has not yet filled up then we take the average of the elements that have been added, not the whole array as the zeroes will bring the average down
                [int]$averageConsumption = ( $existingThread.Consumptions | Measure-Object -Sum |Select-Object -ExpandProperty Sum ) / $( if(  $existingThread.Index -and $existingThread.Index -lt $existingThread.Consumptions.Count) { $existingThread.Index } else { $existingThread.Consumptions.Count })
                if( $averageConsumption -gt $targetCPU )
                {
                    if( $existingThread.Paused )
                    {
                        Write-Verbose -Message "Thread id $($threadAfter.Id) average CPU is $($averageConsumption)% but already paused so leaving paused"
                    }
                    else ## not paused yet
                    {
                        Write-Verbose -Message "Thread id $($threadAfter.Id) is consuming $($totalThreadCPUPercent)% now, average $($averageConsumption)% exceeding $($targetCPU)% so reducing it"

                        Write-Verbose -Message "$([int]$timer.Elapsed.TotalMilliseconds)ms : Pausing thread $($threadAfter.Id) @ $($totalThreadCPUPercent)%"

                        if( ! $existingThread.Handle )
                        {
                            $existingThread.Handle = [kernel32]::OpenThread(  [Kernel32+ThreadAccess]::THREAD_SUSPEND_RESUME , $false , $threadAfter.Id ); $lastError = [ComponentModel.Win32Exception][Runtime.InteropServices.Marshal]::GetLastWin32Error()
                        }
                
                        if( $existingThread.Handle )
                        {
                            [int]$result = -1
                            if( $is32bitProcess )
                            {
                                $result = [kernel32]::Wow64SuspendThread( $existingThread.Handle ) ; $lastError = [ComponentModel.Win32Exception][Runtime.InteropServices.Marshal]::GetLastWin32Error()
                            }
                            else
                            {
                                $result = [kernel32]::SuspendThread( $existingThread.Handle ) ;  $lastError = [ComponentModel.Win32Exception][Runtime.InteropServices.Marshal]::GetLastWin32Error()
                            }
                            if( $result -ge 0 )
                            {
                                $existingThread.Paused = $true
                                $adjustmentsMade++
                                try
                                {
                                    $adjustedThreads.Add( $threadAfter.Id , $badPid )
                                }
                                catch {}
                            }
                            else
                            {
                                Write-Warning "Failed to suspend thread id $($threadAfter.Id) - $lastError"
                            }
                        }
                    }
                }
                elseif( $existingThread.Paused )
                {
                    Write-Verbose "$([int]$timer.Elapsed.TotalMilliseconds)ms : Resuming thread $($threadAfter.Id)"
                    [int]$result = [kernel32]::ResumeThread( $existingThread.Handle ) ;  $lastError = [ComponentModel.Win32Exception][Runtime.InteropServices.Marshal]::GetLastWin32Error()
                    if( $result -ge 0 )
                    {
                        $existingThread.Paused = $false
                    }
                    else
                    {
                        Write-Warning "Failed to resume thread id $($threadAfter.Id) - $lastError"
                    }
                }

                ## update our copy so that CPU counters are from last sample
                $existingThread.TotalCPUConsumed = $cpuConsumptionNow
                $existingThread.Pulse = $pulse
                $existingThread.Timer = $timer.Elapsed.TotalMilliseconds
            }
            else
            {
                $existingThread = [pscustomobject]@{ 
                    'TotalCPUConsumed' = $cpuConsumptionNow 
                    'Handle' = $null
                    'Consumptions' = @( 1..$indexSize | ForEach-Object { 0 } )
                    'Index' = [long]0
                    'Pulse' = $pulse
                    'Timer' = $timer.Elapsed.TotalMilliseconds
                    'Paused' = $false }
                $threadInfo.Add( $threadAfter.Id , $existingThread )
            }

            $existingThread.Consumptions[ $existingThread.Index % $indexSize ] = $totalThreadCPUPercent
            $existingThread.Index++
        }
        ## now remove any threads that haven't been checked as that means they are dead
        [array]$toDelete = @( $threadInfo.GetEnumerator() | ForEach-Object `
        {
            if( $_.Value.Pulse -ne $pulse )
            {
                $_.Key
            }
        })
        $toDelete | ForEach-Object `
        {
            Write-Verbose "`tRemoving thread $_"
            $threadInfo.Remove( $_ )
        }
    } while( ! $durationMinutes -or ([datetime]::Now - $startTime).TotalMinutes -le $durationMinutes ) ## loop until process exits or duration is reached
}
catch
{
    Throw $_
}
finally
{
    $timer.Stop()
    if( $thisProcess )
    {
        ## Check if any threads are still paused by us and resume them
        $threadInfo.GetEnumerator() | ForEach-Object `
        {   
            $existingThread = $_.Value
            if( $existingThread.Handle )
            {
                if( $_.value.Paused )
                {
                    [int]$result = [kernel32]::ResumeThread( $existingThread.Handle )
                    if( $result -ge 0 )
                    {
                        $existingThread.Paused = $false
                    }
                    else
                    {
                        Write-Warning "Failed to final resume of thread id $($threadAfter.Id) - $lastError"
                    }
                }
                [void][kernel32]::CloseHandle( $existingThread.Handle )
                 $existingThread.Handle = $null
            }
        }
    }
}