PowerShell How-To

Understanding Background Jobs in PowerShell

Synchronous code execution is fine for small scripts, but for more time-consuming scripts, consider running your code in the background as a job. Here's how.

There are two kinds of ways PowerShell code can be executed: synchronously or asynchronously.

When you think of a PowerShell script, you're probably thinking of synchronous execution. This means that PowerShell executes code in the script one line at a time. It starts one command, waits for it to complete, and then starts another. Each line waits for the one before it to complete.

This is fine for small scripts and for scripts that indeed depend on the line before it to complete before it can execute, but most of the time, it doesn't have to be built like this. It's just easier to do so at the expense of performance.

When creating a script that may potentially take many minutes or even hours, you have other options. You can instead choose to execute code asynchronously with a concept called jobs. In PowerShell, a job is a piece of code that is executed in the background. It's code that starts but then immediately returns control to PowerShell to continue processing other code. Jobs are great for performance and when a script doesn't depend on the results of prior code execution.

As an example, perhaps I have a script that performs some action on a bunch of remote servers. Each action is unique in that the action for one server doesn't depend on the action of another server. Let's say we have a script that first tests to see if a server is online. If so, it creates a folder and adds a few text files to that folder. The task is irrelevant. The script currently looks like this:

param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string[]]$ServerName
)

foreach ($s in $ServerName) {
    if (Test-Connection -ComputerName $s -Quiet -Count 1) {
        $null = New-Item -Path "\\$s\c$\SomeFolder" -ItemType Directory
        Set-Content -Path "\\$s\c$\SomeFolder\foo.txt" -Value 'something'
    }
}

As-is, this script will run the Test-Connection command against a server and create the folder and text file if the server is available. It will do this for one server, then another, then another. Let's say you point this script at 100 servers and half of them either hang on Test-Connection and are extremely slow for some reason. It's going to take forever to run this script. Let's see how it can be done with background jobs.

To create a simple background job, we use the Start-Job command using the ScriptBlock parameter containing the code we want to execute within that job. For example, below I'm creating a background job to return the hostname of my local computer.

PS> Start-Job -ScriptBlock { hostname }

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command
--     ----            -------------   -----         -----------     --------             -------
1      Job1            BackgroundJob   Running       True            localhost             hostname

Notice that instead of returning the hostname, it returns an object with a bunch of properties. The job is running. I can check out the status of this job by using Get-Job and specifying the ID.

PS> Get-Job -Id 1

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command
--     ----            -------------   -----         -----------     --------             -------
1      Job1            BackgroundJob   Completed     True            localhost             hostname

The state is complete, so I can now check on the results. To see what output the background job generated, I use the Receive-Job command. To simplify things, I can simply pass the output of Get-Job directly into Receive-Job to inspect what the output is.

PS> Get-Job -Id 1 | Receive-Job
AdamTheAutomator-PC.local

Let's now take this concept and use it in our script.

param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string[]]$ServerName
)

foreach ($s in $ServerName) {
    $scriptBlock = {
        if (Test-Connection -ComputerName $s -Quiet -Count 1) {
            $null = New-Item -Path "\\$s\c$\SomeFolder" -ItemType Directory
            Set-Content -Path "\\$s\c$\SomeFolder\foo.txt" -Value 'something'
        }
    }
    Start-Job -ScriptBlock $scriptBlock
}

In the above example, I've just wrapped all of the code within the foreach loop in a scriptblock and then passed that scriptblock to Start-Job to send each iteration of that loop to a job. I attempt to run it and notice that it fails.

PS> C:\New-ServerFiles.ps1 -ServerName CLIENTSERVER1

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command
--     ----            -------------   -----         -----------     --------             -------
1      Job1            BackgroundJob   Running       True            localhost            ...


PS> Get-Job

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command
--     ----            -------------   -----         -----------     --------             -------
1      Job1            BackgroundJob   Completed     True            localhost            ...


PS> Get-job | Receive-Job
Cannot validate argument on parameter 'ComputerName'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command
    + CategoryInfo          : InvalidData: (:) [Test-Connection], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.PowerShell.Commands.TestConnectionCommand
    + PSComputerName        : localhost

It failed because the $s variable inside of that script is now in a scriptblock in a different scope. To pass variables from the local scope to a job, I have to use the ArgumentList parameter on Start-Job and change the $s variable to $args[0] to reflect the automatic array $args that is part of every scriptblock.

param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string[]]$ServerName
)

foreach ($s in $ServerName) {
    $scriptBlock = {
        if (Test-Connection -ComputerName $args[0] -Quiet -Count 1) {
            $null = New-Item -Path "\\$($args[0])\c$\SomeFolder" -ItemType Directory -Force
            Set-Content -Path "\\$($args[0])\c$\SomeFolder\foo.txt" -Value 'something'
        }
    }
    Start-Job -ScriptBlock $scriptBlock -ArgumentList $s
}

I now rerun the script against a couple of servers and get a couple of background jobs back immediately. I run Receive-Job and get no output, which is good.

PS> C:\New-ServerFiles.ps1 -ServerName CLIENTSERVER1,CLIENTSERVER2

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command
--     ----            -------------   -----         -----------     --------             -------
9      Job9            BackgroundJob   Running       True            localhost            ...
11     Job11           BackgroundJob   Running       True            localhost            ...


PS> Get-Job | Receive-Job

I then test each server to ensure the text file is there and it is!

PS> 'CLIENTSERVER1','CLIENTSERVER2' | foreach { Test-Path "\\$_\c$\SomeFolder\foo.txt" }
True
True

About the Author

Adam Bertram is a 20-year veteran of IT. He's an automation engineer, blogger, consultant, freelance writer, Pluralsight course author and content marketing advisor to multiple technology companies. Adam also founded the popular TechSnips e-learning platform. He mainly focuses on DevOps, system management and automation technologies, as well as various cloud platforms mostly in the Microsoft space. He is a Microsoft Cloud and Datacenter Management MVP who absorbs knowledge from the IT field and explains it in an easy-to-understand fashion. Catch up on Adam's articles at adamtheautomator.com, connect on LinkedIn or follow him on Twitter at @adbertram or the TechSnips Twitter account @techsnips_io.


comments powered by Disqus
Most   Popular