Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Newsletter Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Arrow up icon
GO TO TOP
Mastering PowerShell Scripting

You're reading from   Mastering PowerShell Scripting Automate repetitive tasks and simplify complex administrative tasks using PowerShell

Arrow left icon
Product type Paperback
Published in May 2024
Publisher Packt
ISBN-13 9781805120278
Length 826 pages
Edition 5th Edition
Languages
Arrow right icon
Author (1):
Arrow left icon
Chris Dent Chris Dent
Author Profile Icon Chris Dent
Chris Dent
Arrow right icon
View More author details
Toc

Table of Contents (23) Chapters Close

Preface 1. Introduction to PowerShell FREE CHAPTER 2. Modules 3. Variables, Arrays, and Hashtables 4. Working with Objects in PowerShell 5. Operators 6. Conditional Statements and Loops 7. Working with .NET 8. Files, Folders, and the Registry 9. Windows Management Instrumentation 10. Working with HTML, XML, and JSON 11. Web Requests and Web Services 12. Remoting and Remote Management 13. Asynchronous Processing 14. Graphical User Interfaces 15. Scripts, Functions, and Script Blocks 16. Parameters, Validation, and Dynamic Parameters 17. Classes and Enumerations 18. Testing 19. Error Handling 20. Debugging 21. Other Books You May Enjoy
22. Index

Managing concurrent access

When writing code that runs asynchronously, it can be desirable to write to a resource that does not support concurrent access. For example, when writing to a log file, Windows will not allow two simultaneous write operations to a file.

Consider the following script. This script does nothing more than write a log file entry:

$script = {
    param (
        $Path,
        $RunspaceName
    )
    # Some long running activity
    $message = '{0:HH:mm:ss.fff}: Writing from runspace {1}' -f @(
        Get-Date
        $RunspaceName
    )
    [System.IO.File]::AppendAllLines(
        $Path,
        [string[]]$message
    )
}

The script uses the AppendAllLines method instead of a command like Add-Content as it better exposes an error that shows the problem with the script.

Before starting, ensure the runspace.log file does not exist:

Remove-Item runspace.log

When multiple instances of this script run, there are potentially attempts to simultaneously write to the file:

$jobs = for ($i = 0; $i -lt 5; $i++) {
    $instance = [PowerShell]::Create()
    $null = $instance.
        AddScript($script).
        AddParameters(@{
            Path         = Join-Path $pwd -ChildPath runspace.log
            RunspaceName = $instance.Runspace.Name
        })
    [PSCustomObject]@{
        Id          = $instance.InstanceId
        Instance    = $instance
        AsyncResult = $null
    } | Add-Member State -MemberType ScriptProperty -PassThru -Value {
        $this.Instance.InvocationStateInfo.State
    }
}
foreach ($job in $jobs) {
    $job.AsyncResult = $job.Instance.BeginInvoke()
}
while ($jobs.State -contains 'Running') {
    Start-Sleep -Seconds 5
}

In this example, the creation of the job and running BeginInvoke are split into separate loops to try and bring execution as close together as possible to trigger the problem.

When reviewing the log file, it will likely contain fewer lines than it should. For example, it may be like:

PS> Get-Content runspace.log
15:47:31.067: Writing from runspace Runspace2
15:47:31.081: Writing from runspace Runspace6

Reviewing the output streams from each instance should reveal the cause of the missing lines, with one or more repetitions of the error below:

PS> $jobs.Instance.Streams.Error
MethodInvocationException:
Line |
  16 |      [System.IO.File]::AppendAllLines(
     |      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Exception calling "AppendAllLines" with "2" argument(s): "The process cannot access the file 'runspace.log' because it is being used by another process."

There are a few possible solutions to this, but one of the more popular is to use a Mutex.

A Mutex can either be system-wide or local to the current process.

A system Mutex is appropriate when using Start-Job, where jobs are run in different processes. The Mutex instance is held by the operating system.

A local Mutex is appropriate when using a runspace, either directly or via a command-line Start-ThreadJob.

A local Mutex is simple to create:

$mutex = [System.Threading.Mutex]::new()

The script is adjusted to accept the Mutex as a parameter, then it calls the WaitOne method before accessing the file, and ReleaseMutex afterward. WaitOne will block the thread until all other threads have released the Mutex.

$script = {
    param (
        $Path,
        $RunspaceName,
        $Mutex
    )
    # Some long running activity
    $mutex.WaitOne()
    $message = '{0:HH:mm:ss.fff}: Writing from runspace {1}' -f @(
        Get-Date
        $RunspaceName
    )
    [System.IO.File]::AppendAllLines(
        $Path,
        [string[]]$message
    )
    $Mutex.ReleaseMutex()
}

When a script is finished, the Mutex should be disposed of. This Dispose step is highly important with a system Mutex.

$mutex.Dispose()

To try and ensure this happens, the job script makes use of try, catch, and finally. try statements are explored in more detail in Chapter 22, Error Handling.

try {
    $mutex = [System.Threading.Mutex]::new()
    $jobs = for ($i = 0; $i -lt 5; $i++) {
        $instance = [PowerShell]::Create()
        $null = $instance.
            AddScript($script).
            AddParameters(@{
                Path         = Join-Path $pwd -ChildPath runspace.log
                RunspaceName = $instance.Runspace.Name
                Mutex        = $mutex
            })
        [PSCustomObject]@{
            Id          = $instance.InstanceId
            Instance    = $instance
            AsyncResult = $null
        } | Add-Member State -MemberType ScriptProperty -PassThru -Value {
            $this.Instance.InvocationStateInfo.State
        }
    }
    foreach ($job in $jobs) {
        $job.AsyncResult = $job.Instance.BeginInvoke()
    }
    while ($jobs.State -contains 'Running') {
        Start-Sleep -Seconds 5
    }
} catch {
    Write-Error -ErrorRecord $_
} finally {
    $mutex.Dispose()
}

Because the script being run does not do anything except write the log line, this effectively makes this process synchronous. The WaitOne and ReleaseMutex methods should wrap around blocks of code that require exclusive access, not all the content of a script.

Once complete, the log file should contain entries from each of the instances, like the output below:

PS> Get-Content runspace.log
16:00:24.771: Writing from runspace Runspace3
16:01:14.292: Writing from runspace Runspace2
16:01:14.295: Writing from runspace Runspace3
16:01:14.297: Writing from runspace Runspace4
16:01:14.298: Writing from runspace Runspace5
16:01:14.299: Writing from runspace Runspace6

A system Mutex can also be created simply by giving a name to the Mutex. Each process that makes use of the Mutex creates one using the same name. This can be demonstrated by opening two PowerShell consoles.

Both consoles run the following command to create a Mutex named PSMutex:

# In both consoles
$mutex = [System.Threading.Mutex]::new($true, 'PSMutex')

In normal use, a better naming convention should be used—perhaps a value derived from a GUID as this applies to all processes.

In the first console, run:

# Inn the first console
$mutex.WaitOne()

This should return True. Then in the second console, run the same command. The command in the second console will block (not complete). There will not be any output from the command; it will continue to block (to wait).

In the first console, release the Mutex so the second process can make use of it:

# In the first console
$mutex.ReleaseMutex()

At this point, WaitOne in the second console should return True and will now have control of the Mutex.

In the second console, release the Mutex again:

# In the second console
$mutex.ReleaseMutex()

Then, in both consoles, run:

# In both consoles
$mutex.Dispose()

This mutex is system wide. If it is not disposed of, it will persist and be considered abandoned. Sharing system Mutex names across different applications can lead to very difficult problems to debug as it places an implicit wait dependency between applications.

Any system Mutex created and not correctly disposed of can be removed by restarting the operating system.

lock icon The rest of the chapter is locked
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at €18.99/month. Cancel anytime