Search code examples
powershellmemory-leaks

Powershell cmdlet Get-WinEvent causing a memory leak


I am writting a simple Powershell script to forward Windows EventLogs to an external Syslog server - the script is supposed to run continuously as a service. In order to get the events from the EventLog I'm using the Get-EventLog cmdlet in a loop with an appropriate sleep interval - say one second. It appears that running Get-EventLog in a loop causes a memory leak as the memory used by the cmdlet is not being released after it's no longer used. The script consumes more and more memory after each cmdlet call. Here is example Powershell code that causes the issue:

$interval = 0 # 0 seconds to make the issue more apparent
$channel = "System"

while ($true){
    Get-WinEvent -LogName $channel -MaxEvents 1 | Out-Null
    Start-Sleep -Seconds $interval
}

I tried the following things to fix it - none of which worked.

  1. Storing the result of the cmdlet in a variable and the disposing it with .Dispose()
while ($true){
    $event = Get-WinEvent -LogName $channel -MaxEvents 1
    $event.Dispose()
    Start-Sleep -Seconds $interval
}
  1. Storing the result of the cmdlet in a variable and removing it with Remove-Variable.
while ($true){
    $event = Get-WinEvent -LogName $channel -MaxEvents 1
    Remove-Variable -Name event
    Start-Sleep -Seconds $interval
}
  1. Forcefully running the garbage collector.
while ($true){
    Get-WinEvent -LogName $channel -MaxEvents 1 | Out-Null
    [System.GC]::Collect()
    Start-Sleep -Seconds $interval
}

Is there any way to fix this issue in the script or is this a problem with the cmdlet itself?

EDIT 1 I tried the suggestion made by @mclayton - did not work.

while ($true){
    $event = Get-WinEvent -LogName $channel -MaxEvents 1 | Out-Null
    if ($event -is [IDisposable]) {$event.Dispose()};
    [System.GC]::WaitForPendingFinalizers();
    [System.GC]::Collect()
    Start-Sleep -Seconds $interval
}

Solution

  • Update:

    • Apparently, older versions of Windows PowerShell did exhibit a memory leak.

      • Per your feedback, versions 5.1.17763.2931 and 5.1.19041.3803 do have the problem...
      • ... whereas 5.1.22621.2506 (which comes with Windows 11 22H2) no longer does (which I can confirm).
    • You can use the code below to determine if your specific version leaks or not.


    As far as I can tell, there is no memory leak:

    • The Get-WinEvent cmdlet (typically) outputs instances of type System.Diagnostics.Eventing.Reader.EventLogRecord, which (indirectly) implements the System.IDisposable interface:

      • .NET types that use unmanaged resources (i.e. resources not managed by the .NET runtime) implement this interface, so as to allow on-demand release of such resources, via calling the interface's .Dispose() method ideally as soon as a given .NET type's instance is no longer needed.
        (.NET languages such as C# - but not PowerShell - have syntactic sugar for that, namely the using (...) { ... } pattern, which implicitly calls .Dispose() on exiting the block).

      • However, IDisposable implementer are expected to ensure eventual release of unmanaged resources via finalizers, which are called after the .NET garbage collector reclaims a given object.

    • Therefore:

      • As with any .NET object, the memory it occupies is only reclaimed either periodically (at unspecified intervals) or as needed, based on memory pressure.

      • Additionally, you can invoke the garbage collector on demand - invariably in a synchronous (blocking) fashion - with [System.GC]::Collect()


    It follows from the above:

    • If you want to release unmanaged resources as quickly as possible, call .Dispose() on an object that implements IDisposable.

      • This alone is independent of and does not guarantee garbage collection of the .NET instance the method is called on: garbage collection is solely based on whether a given instance is still being referenced by other .NET-managed runtime objects.
    • To force garbage collection of no-longer-referenced objects - in an invariably synchronous and therefore blocking manner - call [GC]::Collect()

      • Instances of types that happen to implement finalizers are eventually called by the runtime. Proper implementers of the IDispose interface ensure release of unmanaged resources in their finalizers, unless they've already been released explicitly via a call to .Dispose().

      • To also await processing of the finalizers of all just garbage-collected objects, call [GC]::WaitForPendingFinalizers() afterwards.


    The following sample code demonstrates that that no memory leak is present:

    • It synchronously garbage-collects the Get-WinEvent output objects right away, and also awaits running their finalizers.

    • It reports the amount of memory used by the current process - via the running process' .WorkingSet64 property - after every N (configurable) calls.

    • You should see that - over the long run - memory usage does not trend upward indefinitely.

      • You will see fluctuations (the ways of .NET memory management are mysterious), but what matters is that memory usage will NOT keep growing.
    # Helper function for printing the current memory usage (working set)
    function Get-MemoryUsage {
      # Perform garbage collection before the first call.
      if ($i -eq 1) { [GC]::Collect(); [GC]::WaitForPendingFinalizers() }
      $thisProcess.Refresh() # Get the latest memory stats.
      [pscustomobject] @{
        CallCount                      = $i - 1
        'Memory Use (Working Set, MB)' = '{0:N2}' -f ($thisProcess.WorkingSet64 / 1mb)
      }   
    }
    
    $thisProcess = Get-Process -Id $PID # get a process-info object for this process
    $channel = 'System'                 # the event log to query
    $sleepIntervalMsecs = 0             # msecs. to sleep between calls.
    $memoryReportInterval = 500         # report memory usage every N calls
    
    # Enter an infinite loop; press Ctrl-C to abort.
    $i = 0
    while ($true) {
      # Report the memory usage at the start and after every N calls.
      # Note: Because of how implicitly table-formatted output behaves, the
      #       first memory snapshot won't display until the second one is available.
      if (++$i % $memoryReportInterval -eq 1) { Get-MemoryUsage }
    
      # Discard the object via a $null = ... assignment,
      # which makes it eligible for garbage collection.
      # Its disposal (release of unmanaged resources) will be triggered 
      # by its garbage collection, but will by default occur *later*.
      $null = Get-WinEvent -LogName $channel -MaxEvents 1
    
      # Garbage-collect now, then also wait for finalizers to run, 
      # which takes care of disposal.
      [GC]::Collect(); [GC]::WaitForPendingFinalizers()
    
      # Sleep before the next call.
      Start-Sleep -Milliseconds $sleepIntervalMsecs
    }
    

    Here's sample output from running the above in Windows PowerShell on Windows 11 (22H2), showing no long-term growth of memory usage:

    CallCount Memory Use (Working Set, MB)
    --------- ----------------------------
            0 83.23
          500 85.82
         1000 85.83
         1500 84.86
         2000 84.79
         2500 85.05
         3000 84.84
         3500 84.88
         4000 84.87
         4500 84.91
         5000 84.98
         5500 84.95
         6000 84.90
         6500 84.91
         7000 84.89
         7500 84.88
         8000 84.93
         8500 84.95
         9000 84.88
         9500 84.98
        10000 84.93
        10500 84.90
        11000 84.88
        11500 84.98
        12000 84.88
        12500 84.93
        13000 84.89
        13500 85.04
        14000 84.93
        14500 84.99
        15000 84.89
        15500 84.86
        16000 84.94
        16500 84.96
        17000 84.93
        17500 84.95
        18000 84.89
        18500 84.89
        19000 84.86
        19500 84.89
        20000 84.84
        20500 84.89