Have you ever needed to audit thousands of servers, to find which ones are running IIS? If you have, you've probably discovered it's horribly slow using Get-Service inside a foreach statment, just like I did. You will likely also find, that by attempting to filter out unresponsive servers, the number of errors may go down, but the time it takes to run becomes painful.

Firstly the painful way

$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | select @{Name="ComputerName";Expression={$_.Name}}
foreach($computer in $computers){
  if((Test-Connection -ComputerName $computer -Quiet) -eq $true){
    Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
  }
}

While this works well enough for 5-10 machines, it quickly becomes unacceptable running against hundreds, let alone thousands of machines. I also don't like having to translate the name.

The path to joy

I knew the Test-Connection calls were taking a long time, as of course, it has to wait for 4 responses from each machine. I went searching and found Test-ConnectionAsync on PowerShell gallery.

At first I attempted to simply replace the calls with to Test-Connection with Test-ConnectionAsync however I quickly realised that was a mistake, as it was still being called one at a time inside the foreach loop. So I changed it up abit.

Import-Module Test-ConnectionAsync
$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | select @{Name="ComputerName";Expression={$_.Name}} | Test-ConnectionAsync -Quiet -MaxConcurrent 250 | where {$_.Success -eq $true} | select -ExpandProperty ComputerName
foreach($computer in $computers){
  Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
}

This significantly improved the time it took to get the list of machines, but it has a similar problem with Get-Service in that it must wait for the response from each server, and some of them can be pretty slow.

Open source is awesome###

I looked back at the Test-ConnectionAsync page and realised that the module source was linked, having a read through the file, I noticed exactly the same method could be applied to Get-Service, but before doing that, I tweaked my copy of Test-ConnectionAsync to remove the need for the annoying select @{Name="ComputerName";Expression={$_.Name}} statement, by renaming the ComputerName attribute to Name (plus all references), and instead adding it as an alias. Unfortunately just adding Name as an alias is not sufficient, due to some annoying issue with the ActiveDirecory PowerShell commands.

        [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [Alias('CN','IPAddress','__SERVER','Server','Destination','ComputerName')]
        [ValidateNotNullOrEmpty()]
        [System.String[]]
        ${Name},

This change allowed me to simplify the usage

Import-Module Test-ConnectionAsync
$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | Test-ConnectionAsync -Quiet -MaxConcurrent 250 | where {$_.Success -eq $true} | select -ExpandProperty Name
foreach($computer in $computers){
  Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
}

Then I made a copy of Test-ConnectionAsync and swapped out the necessary parts to call Get-Service instead, and saved it in Find-WebServerAsync.psm1

function Find-WebServerAsync
{    
    <#
    .Synopsis
       Queries the list of servers in batches looking for the world wide web service 'w3svc'
    .DESCRIPTION
       Proxy function for Get-Service
    .PARAMETER MaxConcurrent
       Specifies the maximum number of Find-WebServerAsync commands to run at a time.
    #>
    
    [CmdletBinding(DefaultParameterSetName='Default')]
    param(
        [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()] 
        [string[]] ${Name},

        [ValidateRange(1, 60)]
        [System.Int32]
        ${Delay},

        [ValidateScript({$_ -ge 1})]
        [System.UInt32]
        $MaxConcurrent = 20,

        [Parameter(ParameterSetName='Quiet')]
        [Switch]
        $Quiet
    )

    begin
    {
        if ($null -ne ${function:Get-CallerPreference})
        {
            Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        }

        $null = $PSBoundParameters.Remove('MaxConcurrent')
        $null = $PSBoundParameters.Remove('Quiet')
        
        $jobs = @{}
        $i = -1

        function ProcessCompletedJob
        {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                [hashtable]
                $Jobs,

                [Parameter(Mandatory = $true)]
                [int]
                $Index,

                [switch]
                $Quiet
            )
            
            $quietStatus = New-Object psobject -Property @{Name = $Jobs[$Index].Target; Success = $false}
            if ($Jobs[$Index].Job.HasMoreData)
            {
                foreach ($result in (Receive-Job $Jobs[$Index].Job))
                {
                    if ($Quiet)
                    {
                        $quietStatus.Success = $result
                        break
                    }
                            
                    else
                    {
                        Write-Output $result
                    }
                }
            }

            if ($Quiet)
            {
                Write-Output $quietStatus
            }

            Remove-Job -Job $Jobs[$Index].Job -Force
            $Jobs[$Index] = $null

        } # function ProcessCompletedJob

    } # begin

    process
    {
        $null = $PSBoundParameters.Remove('Name')
        foreach ($target in $Name)
        {
            while ($true)
            {
                if (++$i -eq $MaxConcurrent)
                {
                    Start-Sleep -Milliseconds 100
                    $i = 0
                }

                if ($null -ne $jobs[$i] -and $jobs[$i].Job.JobStateInfo.State -ne [System.Management.Automation.JobState]::Running)
                {
                    ProcessCompletedJob -Jobs $jobs -Index $i -Quiet:$Quiet
                }
                
                if ($null -eq $jobs[$i])
                {
                    Write-Verbose "Job ${i}: Testing ${target}."

                    $job = Start-Job -ScriptBlock {(Get-Service -ComputerName $args[0] -ServiceName "W3SVC" -ErrorAction SilentlyContinue) -ne $null} -ArgumentList $target #@PSBoundParameters
                    $jobs[$i] = New-Object psobject -Property @{Target = $target; Job = $job}

                    break
                }
            }
        }
    }

    end
    {
        while ($true)
        {
            $foundActive = $false
            for ($i = 0; $i -lt $MaxConcurrent; $i++)
            {
                if ($null -ne $jobs[$i])
                {
                    if ($jobs[$i].Job.JobStateInfo.State -ne [System.Management.Automation.JobState]::Running)
                    {
                        ProcessCompletedJob -Jobs $jobs -Index $i -Quiet:$Quiet
                    }                    
                    else
                    {
                        $foundActive = $true
                    }
                }
            }

            if (-not $foundActive)
            {
                break
            }

            Start-Sleep -Milliseconds 100
        }
    }

} # function Find-WebServerAsync

That's more like it

Now with the two async modules, I can accomplish the same task much quicker, and with a nicer syntax.

Import-Module Test-ConnectionAsync
Import-Module Find-WebServerAsync
$webServers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | Test-ConnectionAsync -Quiet | where {$_.Success -eq $true} | Find-WebServerAsync -Quiet | where {$_.Success -eq $true}

In my environment, this went from taking 10-15 mins to run, to around 1 min 30.