Start-ThreadJob

Die meisten PowerShell-Script laufen sequentiell, auch wenn Sie z.B. mit "Start-Job" durchaus Code in den Hintergrund senden können. Leider wird dabei eine neue Laufzeitumgebung gestartet. Das kostet nicht zur Zeit und Ressourcen sondern auch die aktuelle Umgebung ist nicht vorhanden.

Thread statt Job

Schon länger habe ich mir vorgenommen mal das Thema "Multithreading" anzugehen, d.h. ein Prozess startet keinen komplett neuen weiten Prozess sondern nur einen Thread im gleichen Prozess. Das sollte ja sehr viel sparsamer und schneller sein. Einige Ansätze dazu habe ich auf der Seite PowerShell und parallele Verarbeitung beschrieben. Auch die Verarbeitung über die Pipeline (Siehe PowerShell Pipeline) unterliegt bestimmten Einschränkungen.

Mit PowerShell 7 kommt nun direkt eine Funktion, um For-Schleifen parallel abzuarbeiten und mit PowerShell 6 habe ich das Commandlet "Start-ThreadedJob" gefunden. Das hat mich neugierig gemacht, insbesondere weil Microsoft selbst dazu schreibt:

Start-ThreadJob creates background jobs similar to the Start-Job cmdlet. The main difference is that the jobs which are created run in separate threads within the local process. By default, the jobs use the current working directory of the caller that started the job.
Quelle: Start-ThreadJob https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob

Erste Tests

Zuerst hat mich mal interessiert, wie sich ein Thread von einem Job unterscheiden. Ich habe daher eine ganz kurze Testserie gemacht

Aktion Start-Job Start-ThreadedJob

Eine Variable setzen

$a=2

$a=2

Job im Hintergrund laufen lassen

$job= start-job -ScriptBlock {$a+=1;$a}

$job= Start-ThreadJob -ScriptBlock {$a+=1;$a}

Ausgabe der Variable im aufrufenden Job. Inhalt wurde nicht verändert

$a
2

$a
2

Ausgabe des Jobs. Er hat wohl bei "0" begonnen, d.h. bekam auch keine Kopie der Umgebung

receive-job $job
1

receive-job $job
1

Viele Jobs anlegen

measure-command { `
   1..100  `
   | %{ Start-ThreadJob  `
           -ScriptBlock {$global:b+=1;$global:b} `
      }`
} 
Days : 0
Hours : 0
Minutes : 0
Seconds : 0
Milliseconds : 147
Ticks : 1475901
TotalDays : 1,70821875E-06
TotalHours : 4,099725E-05
TotalMinutes : 0,002459835
TotalSeconds : 0,1475901
TotalMilliseconds : 147,5901

measure-command { `
   1..100  `
   | %{ Start-Job  `
           -ScriptBlock {$global:b+=1;$global:b} `
      } `
}
Days : 0
Hours : 0
Minutes : 0
Seconds : 23
Milliseconds : 201
Ticks : 232011936
TotalDays : 0,000268532333333333
TotalHours : 0,006444776
TotalMinutes : 0,38668656
TotalSeconds : 23,2011936
TotalMilliseconds : 23201,1936

So gesehen gibt es erst erst mal keinen Unterschied. Der Thread ist genau so mit eigenen lokalen Variablen versehen wie der aufrufende Task. Auch der Versuch per "$global:a" den Scope zu erweitern, hat nichts verändert. Allerdings hat das Anlegen der Jobs deutlich weniger Zeit benötigt. Bei 100 Jobs ist der "Start-ThreadedJob" in dem Beispiel 157mal schneller fertig. Meine CPU war beim "Start-Job" auch deutlich zu hören.

Über den PSJobType können Sie auch gut erkennen, wie der Job angelegt wurde.

Auch wenn Sie einen Auftrag mit "Start-ThreadJob" gestartet haben, wird er weiter mit den normalen Commandlets wie "Get-Job, Receive-Job, Remove-Job" etc. verwaltet.

CPU/Speicherbedarf

Ein Job, der ich gleich beendet, ist ja aus Ressourcensicht überschaubar, da Powershell die Umgebung wieder abräumt und nur den Buffer für die Rückgabe und Job-Verwaltung bestehen lässt. Also habe ich einen abgewandelten Job gleich mehrfach gestartet, der etwas CPU-Last und Speicher belegt.

Start-Job Start-ThreadJob

1..10 `
| % {`
   $job= Start-Job  `
            -ScriptBlock {while ($true){start-sleep -seconds 1}}`
}

1..10 `
| % {`
   $job= Start-ThreadJob `
            -ThrottleLimit 10  `
            -ScriptBlock {while ($true){start-sleep -seconds 1}}`
}

Das Job werden viele eigene Instanzen aufgemacht, die allesamt auch einen deutlichen Speicherbedarf haben.

Als Thread komme ich mit deutlich weniger Ressourcen aus.

Das neue "Start-ThreadJob Commandlet ist also wirklich deutlich besser

Throttling auf 5 Jobs

Mit Start-Job musste ich mit noch selbst Gedanken um ein Throttling machen. Jobs sind ja schon aufwändig und ich sollte nicht gerade hunderte Jobs im Hintergrund starten. Also merke ich mir die laufenden Jobs und starte weitere erst, nachdem andere Jobs beendet und aufgeräumt wurden. Auch das ist mit Start-ThreadJob deutlich besser geworden und fällt auf, wenn ich z.B. viele Jobs starte:

1..10 `
| % {`
   $job= Start-ThreadJob -ScriptBlock {while ($true){start-sleep -seconds 1}} `
}

Die Ausgabe von "Get-Job" zeigt dann folgendes Bild:

Die ersten fünf Jobs laufen los, während die anderen Jobs nicht automatisch gestartet werden. Erst wenn ein Job beendet wird, wird der nächste Job automatisch angestartet.

Wenn Sie mehr Jobs parallel laufen lassen wollen, dann hilft der Parameter "ThrottleLimit".

1..20 `
| % {`
   $job= Start-ThreadJob  `
            -ThrottleLimit 20 `
            -ScriptBlock {while ($true){start-sleep -seconds 1}} `
}

Damit starten dann bis zu 20 Jobs parallel.

Das Limit ist global für die jeweilige Session, z.B. wenn ich Start-ThreadJob mit einem Throttle-Limit aufrufe, dann passt er das Limit an und startet ggfls. noch wartende Jobs. Es reicht bei einem Aufruf den Wert zu setzen

Aktiv laufende Jobs werden aber nicht mehr angehalten, wenn das Throttle-Limit niedriger gesetzt wird.

Variablen Scope und Synchronisation

Wenn ich mehrere Code-Teile als Hintergrund-Aufgabe laufen lassen, dann stellt sich natürlich die Frage, wie solche Jobs miteinander kommunizieren können oder ob es gemeinsame Variablen oder Semaphoren gibt. Leider habe ich in PowerShell selbst nichts gefunden und folgendes Beispiel funktioniert anders: 

$global:a=2

$job = start-job -ScriptBlock { $using:a }

$a
2

$job = start-job -ScriptBlock { $using:a++ }
At line:1 char:33
+ $job = start-job -ScriptBlock { $using:b++ }
+ ~~~~~~~~
The '++' operator works only on variables or on properties.

Hierbei übernimmt die PowerShell den Inhalt der Variable $a in den Skriptblock. Es ist aber kein Pointer oder ein Verweis. Der Skript-Block kann also den Wert als Kopie nutzen und den Wert in in seinem lokalen Scope auch nicht ändern. Es ist eine Konstante und nur eine etwas einfacherer Ansaatz einer Parameter-Übergabe

In Invoke-Command, the $using: variables are values copied to the remote machine. No data is exchanged between individual target machines.
In the new ForEach-Object -Parallel, $using: is (as you point out in the article) not thread-safe, as the actual reference to the data can and is shared between threads.
Quelle: https://github.com/PowerShell/PowerShell/issues/10499

D.h. bei dem neuen "Foreach"-Funktion ab PowerShell7 zur Parallelisierung von Abschnitten kann man sich hier schon selbst ärgern. Parallele Programmierung erfordert also schon etwas Vorsicht beim Zugriff auf gemeinsame Dateien.

Exchange

Meine bisherigen Tests haben ja schon festgestellt, dass auch die Threads eigenständige Umgebungen haben. Dennoch habe ich mal schnell einen Test mit der klassischen Exchange-PowerShell gemacht. Ich habe in einer PowerShell 6 einfach eine Exchange Remote PowerShell eingebunden und einen Get-Mailbox gegen meinen Office 365-Tenant gemacht.

Ich habe dann ein Get-Mailbox mit Start-Job und Start-ThreadJob" gestartet.

Sie sehen aber schon hier, dass ein Code mit Start-Job noch "Start-ThreadJob" auf die Session der bestehenden Powershell zugreifen kann. Ich habe natürlich auch noch mal versucht, die Session zu übergeben und in jedem Modul eigenständig zu übernehmen.

$Session = New-PSSession `
    -ConfigurationName Microsoft.Exchange `
    -ConnectionUri https://outlook.office365.com/powershell-liveid/ `
    -Credential (get-credential) `
    -Authentication Basic `
    -AllowRedirection 

$job1 = start-job -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job2 = start-job -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job3 = start-threadjob -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
$job4 = start-threadjob -ScriptBlock { import-PSSession $using:session; get-mailbox | ft}
Import-PSSession $session

Ich habe dann die Ergebnisse abgefragt. Der Abruf der Ergebnisse von JOB1 und JOB2 im Beispiel funktioniert nicht.

$result1 =receive-job $job1
Cannot bind parameter 'Session'. Cannot convert the "[PSSession]Runspace1" value of 
type "Deserialized.System.Management.Automation.Runspaces.PSSession" to type 
"System.Management.Automation.Runspaces.PSSession".

Der Abruf der JOB3/JOB4 hingegen funktioniert tatsächlich:

PS C:\> $result1 =receive-job $job3
WARNING: The names of some imported commands from the module 'tmp_yxuay5cd.wkf' include unapproved verbs
that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module 
command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.
PS C:\> $result1

Name                Alias     Database           ProhibitSendQuota    ExternalDirectoryObjectId
----          -----     --------           -----------------    -------------------------
fctest1       fctest1   NAMPR20DG007-db027       99 GB (106,300,440,… 17857064-d5b5-458f-b99d-…
fctest2       fctest2   NAMPR20DG006-db017       99 GB (106,300,440,… cafa6404-7d35-43fa-a3f7-…
…

Hier funktioniert die Übergabe von "Session". Die Variable kann sogar von zwei oder mehr Jobs genutzt werden und selbst das aufrufende Hauptprogramm kann sich auch noch mal eine Session verbinden.

Hier verhält sich Start-ThreadJob anders. Sie sollten aber nun nicht viele Sessions mit Exchange starten, denn auch Exchange Server nutzen "Throttling", um Überlastsituationen zu vermeiden. Exchange Online beschränkt den Zugriff auf 3 parallele Sessions pro Tenant.

"To help prevent denial-of-service (DoS) attacks, you're limited to three open remote PowerShell connections to your Exchange Online organization. Quelle:  https://docs.microsoft.com/en-us/powershell/exchange/exchange-online/connect-to-exchange-online-powershell/connect-to-exchange-online-powershell

Weitere Links