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.