/ Powershell

Créer des formulaires multithread grâce aux "background jobs" de Powershell

Quand on travaille avec des formulaires que ce soit en C# ou en Powershell, on se rend compte rapidement que l'interface freeze pendant le traitement d'une tâche assez longue. Ce qui est parfaitement normal puisque le traitement ce fait que dans un seul et même thread.

La solution la plus simple pour résoudre ce problème c'est d'utiliser la méthode DoEvents pendant l'exécution de la tâche :

# Création du processus d'exécution d'USMT
$process = New-Object System.Diagnostics.Process
$process.StartInfo.Filename = "scanstate.exe"
$process.StartInfo.Arguments = " $storePath /i:$MIGUSERPATH /ue:*\* /ui:DOMAIN\$nni /c"
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.RedirectStandardError = $true
$process.StartInfo.RedirectStandardOutput = $true
$process.StartInfo.CreateNoWindow = $true
	
# Démarrage du processus
$process.start()

while(-not($process.HasExited))
{
    [System.Windows.Forms.Application]::DoEvents()
}

if ([int]$process.ExitCode -ne 0)
{
    ...
}

Donc au sein d'un même thread, le programme va gérer les évènements de l'interface et la tâche de sauvegarde, de manière périodique mais avec un traitement synchrone (dans l'exemple au dessus, j'exécute USMT, l'outil de migration de poste de Microsoft).

C'est plutôt simple mais pas très optimisé (c'est assez sale même), l'utilisation de la méthode DoEvents dans ce cas là n'est pas conseillé, il vaut mieux privilégier une utilisation multithread pour avoir un traitement parallèle des tâches.

Dans un tel cas, on va se tourner vers le système de "background jobs" de Powershell, c'est un mécanisme qui permet d'éxecuter des tâches de fond, on va pouvoir bien séparer le traitement de nos diverses tâches et les exécuter en parallèle.

Plus d'info sur les "background jobs" sur le site de Microsoft.

Donc notre application va être composée de 3 threads :

  • Un thread pour la gestion des évènements de l'interface
  • Un thread pour le traitement de la sauvegarde USMT
  • Un thread pour l'affichage de la progression avec une jolie ProgressBar

Il y a quelque chose de très important à savoir avant de manipuler les jobs, les threads et les interfaces : Ne jamais accéder ou modifier un élément de l'interface depuis l'intérieur d'un background job, ça ne peut pas fonctionner puisqu'il ne s'agit pas du même thread !

Personnellement pour la gestion des jobs, j'utilise un mini-framework développé par l'équipe derrière Powershell Studio, il est composé de quelques fonctions bien utiles pour gérer facilement nos tâches.

On commence par créer le tableau qui contiendra toutes nos tâches :

$JobTrackerList = New-Object System.Collections.ArrayList

Ensuite il y a la fonction Add-JobTracker qui permet de créer un nouveau background job, on peut passer en paramètre le script à exécuter à l'intérieur de la tâche, un script qui est appelé quand la tâche est terminée et un autre quand le timer effectue une mise à jour.

Function Add-JobTracker {
	
<#
	.SYNOPSIS
		Add a new job to the JobTracker and starts the timer.

	.DESCRIPTION
		Add a new job to the JobTracker and starts the timer.

	.PARAMETER  Name
		The name to assign to the Job

	.PARAMETER  JobScript
		The script block that the Job will be performing. 
		Important: Do not access form controls from this script block.

	.PARAMETER ArgumentList
		The arguments to pass to the job

	.PARAMETER  CompleteScript
		The script block that will be called when the job is complete.
		The job is passed as an argument. The Job argument is null when the job fails.

	.PARAMETER  UpdateScript
		The script block that will be called each time the timer ticks. 
		The job is passed as an argument. Use this to get the Job's progress.

	.EXAMPLE
		Job-Begin -Name "JobName" `
		-JobScript {	
			Param($Argument1)#Pass any arguments using the ArgumentList parameter
			#Important: Do not access form controls from this script block.
			Get-WmiObject Win32_Process -Namespace "root\CIMV2"
		}`
		-CompletedScript {
			Param($Job)		
			$results = Receive-Job -Job $Job		
		}`
		-UpdateScript {
			Param($Job)
			#$results = Receive-Job -Job $Job -Keep
		}
		
#>
	
	Param (
		[ValidateNotNull()]
		[Parameter(Mandatory = $true)]
		[string]$Name,
		[ValidateNotNull()]
		[Parameter(Mandatory = $true)]
		[ScriptBlock]$JobScript,
		$ArgumentList = $null,
		[ScriptBlock]$CompletedScript,
		[ScriptBlock]$UpdateScript)
	
	#Start the Job
	$job = Start-Job -Name $Name -ScriptBlock $JobScript -ArgumentList $ArgumentList
	
	if ($job -ne $null)
	{
		#Create a Custom Object to keep track of the Job & Script Blocks
		$members = @{
			'Job' = $Job;
			'CompleteScript' = $CompletedScript;
			'UpdateScript' = $UpdateScript
		}
		
		$psObject = New-Object System.Management.Automation.PSObject -Property $members
		
		[void]$JobTrackerList.Add($psObject)
		
		#Start the Timer
		if (-not $timerJobTracker.Enabled)
		{
			$timerJobTracker.Start()
		}
	}
	elseif ($CompletedScript -ne $null)
	{
		#Failed
		Invoke-Command -ScriptBlock $CompletedScript -ArgumentList $null
	}
	
} # Function Add-JobTracker

Ensuite il y a la fonction Update-JobTracker qui permet vérifier le status de chaque tâche et d'exécuter le script block correspondant au status de la tâche :

Function Update-JobTracker {
	
<#
	.SYNOPSIS
		Checks the status of each job on the list.
#>
	
	#Poll the jobs for status updates
	$timerJobTracker.Stop() #Freeze the Timer
	
	for ($index = 0; $index -lt $JobTrackerList.Count; $index++)
	{
		$psObject = $JobTrackerList[$index]
		
		if ($psObject -ne $null)
		{
			if ($psObject.Job -ne $null)
			{
				if ($psObject.Job.State -eq 'Blocked')
				{
					#Try to unblock the job
					Receive-Job $psObject.Job | Out-Null
				}
				elseif ($psObject.Job.State -ne 'Running')
				{
					#Call the Complete Script Block
					if ($psObject.CompleteScript -ne $null)
					{
						#$results = Receive-Job -Job $psObject.Job
						Invoke-Command -ScriptBlock $psObject.CompleteScript -ArgumentList $psObject.Job
					}
					
					$JobTrackerList.RemoveAt($index)
					Remove-Job -Job $psObject.Job
					$index-- #Step back so we don't skip a job
				}
				elseif ($psObject.UpdateScript -ne $null)
				{
					#Call the Update Script Block
					Invoke-Command -ScriptBlock $psObject.UpdateScript -ArgumentList $psObject.Job
				}
			}
		}
		else
		{
			$JobTrackerList.RemoveAt($index)
			$index-- #Step back so we don't skip a job
		}
	}
	
	if ($JobTrackerList.Count -gt 0)
	{
		$timerJobTracker.Start() #Resume the timer	
	}
	
} # Function Update-JobTracker

Et pour finir, il y a la fonction Stop-JobTracker permettant de supprimer toutes les tâches en cours, généralement on l'utilise à la fermeture du formulaire pour s'assurer que rien ne tourne en tâche de fond.

Function Stop-JobTracker {
	
<#
	.SYNOPSIS
		Stops and removes all Jobs from the list.
#>
	
	#Stop the timer
	$timerJobTracker.Stop()
	
	#Remove all the jobs
	while ($JobTrackerList.Count -gt 0)
	{
		$job = $JobTrackerList[0].Job
		$JobTrackerList.RemoveAt(0)
		Stop-Job $job
		Remove-Job $job
	}
	
} # Function Stop-JobTracker

Evènement à la fermeture :

$jobTracker_FormClosed = [System.Windows.Forms.FormClosedEventHandler] {
    Stop-JobTracker
}

Evènement de mise à jour (tick) :

$timerJobTracker_Tick = {
    Update-JobTracker
}

Maintenant que nous avons nos fonctions prêtes, on peut passer à la creation de nos tâches :

$backupArgsList = @{
	arch = $arch
	storePath = $storePath
	user = $user
	targetOS = $targetOS
	logspath = $logspath
}
	
Add-JobTracker -Name "BackupJob" `
-JobScript {
	Param ([Hashtable]$params)
		
	# Création du processus d'exécution d'USMT
	$process = New-Object System.Diagnostics.Process
	$process.StartInfo.Filename = "scanstate.exe"
	$process.StartInfo.Arguments = " $($params['storePath']) /i:config\miguser.xml $($params['targetOS']) /v:1 /ue:*\* /ui:DOMAIN\$($params['user']) /c /o /l:$($params['logspath'])\scanstate.log /progress:$($params['logspath'])\progress.log"
	$process.StartInfo.UseShellExecute = $false
	$process.StartInfo.RedirectStandardError = $true;
	$process.StartInfo.RedirectStandardOutput = $true;
	$process.StartInfo.CreateNoWindow = $true;
		
	# Démarrage du processus
	$process.start()
		
	while (-Not ($process.HasExited)) { Start-Sleep -Seconds 1 }
		
	# Code de retour en résultat de la tâche 'BackupJob'
	[int]$process.ExitCode
}`
-CompletedScript {
	Param ($Job)
		
	# Arrêt du monitoring de la progression et suppression du fichier de log
	Stop-Job -Name 'ProgressJob'
		
	if (Test-Path $logspath\progress.log)
	{
		Remove-Item -Path $logspath\progress.log -Force
	}
		
	# Récupération de la valeur de retour du process scanstate
	[Int]$exitCode = Receive-Job -Job $Job | Select-Object -Last 1
		
	if ($exitCode -ne 0)
	{
		[string]$code = $exitCode.ToString()
		Show-Error -message "Une erreur est survenue pendant la sauvegarde des données. Code erreur : $code"
		return $false
	}
	else
	{
		$ProgressBar.Value = 100
		Show-Information -message "Sauvegarde effectuée avec succès !"
	}
}`
-ArgumentList $backupArgsList

Donc on retrouve dedans notre tâche de sauvegarde USMT qui s'exécutera en tâche de fond puis la récupération du code de retour de scanstate pour traitement ultérieur. A noter qu'on récupère le code de retour de notre job grâce à la fonction Receive-Job de Powershell.

On passe à notre seconde tâche, l'affichage de la progression :

Add-JobTracker -Name "ProgressJob" `
-JobScript {
	Param ([String]$logspath)
		
	While ($true)
	{
		Start-Sleep -Seconds 1
			
		if (Test-Path $logspath\progress.log)
		{
			$line = Get-Content $logspath\progress.log | Select-Object -last 1
				
			if ($line -match 'totalPercentageCompleted')
			{
				# Récupération de la progression puis envoi de la
				# valeur au script d'update de la barre de progression
				[int]$((($line.Split(','))[4]).trim())
			}
				
			if ($line -match 'Successful run')
			{
				Break
			}
		}
	}
}`
-UpdateScript {
	Param ($Job)
		
	# Récupération de la valeur de progression
	$progress = Receive-Job -Job $Job | Select-Object -Last 1
		
	if ($progress -is [int])
	{
		$ProgressBar.Value = $progress
	}
		
	# Animation du bouton
	if ($BTNBackup.ImageList -ne $null)
	{
		if ($BTNBackup.ImageIndex -lt $BTNBackup.ImageList.Images.Count - 1)
		{
			$BTNBackup.ImageIndex += 1
		}
		else
		{
			$BTNBackup.ImageIndex = 0
		}
	}
}`
-CompletedScript {
	$BTNBackup.ImageIndex = -1
}`
-ArgumentList $logspath

Rien de bien sorcier, je récupère la valeur de la progression en pourcentage dans le fichier de log de scanstate, pour l'envoyer ensuite dans le script d'update, qui lui se chargera de mettre à joueur la valeur de la barre de progression.

j'ai aussi ajouté un petit loader sur le bouton de sauvegarde, pour le fun.

Ce qui donne ceci :

exemple

Tout ça pour ça vous allez me dire ! Bah oui, mais quand on veut faire les choses proprement il faut ce qu'il faut ! Personnellement j'utilise Powershell Studio 2016 qui intègre un Control Set avec le framework de base, donc tout ceci ce gère vraiment très facilement, j'ai juste à créer mes tâches et à disposer les items dans l'interface, le reste marche out-of-box.

Créer des formulaires multithread grâce aux "background jobs" de Powershell
Share this