Site icon A DevOps journey

L’asynchrone avec .Net Core : Les boucles

Maintenant que nous avons mieux compris le fonctionnement du multithread, nous allons pouvoir attaquer son utilisation la plus importante pour le travail de lourdes charges : les boucles.
Si leurs utilisations semblent assez simples, il existe quelques subtilités à connaitre, notamment de savoir quand et dans quel cas on les utilise .
Il est recommandé d’avoir les deux premiers chapitres en tête.

Les boucles parallélisées sur process synchrone

Le cas le plus simple a utiliser, c’est de paralléliser des travaux synchrones au travers de fonctions asynchrones. Cela permet souvent de profiter pleinement des capacités de la machine en optimisant le travail, réduisant ainsi drastiquement le temps de traitement.

Au travers des 4 fonctions proposées ci-dessous, nous avons établi un process simple : deux listes sont générées. On peut ainsi traiter une boucle au sein d’une autre, et comparer le temps de traitement global. La fonction PrepareAndWatchLoopProcessDuration permet, quand à elle, d’analyser ce temps et de le reporter dans la console. Les exemples proposés ci-dessous sont, comme d’habitude, accessiblent sur le projet disponible sur ce github.

Les différentes fonctions
Petit benchmark des différentes utilisations de boucles. L’utilisation de la parallélisation permet d’augmenter par 9 les performances de cet exemple !

Comme on peut le voir sur la capture du temps d’exécution, la boucle non parallélisée prend 18 secondes.
Les deux fonctions suivantes parallélisent le traitement au travers de l’instruction Parallel.Foreach : la première au travers de la boucle extérieure, la seconde seulement la boucle intérieure. La seconde est bien plus rapide car elle parallélise un grand nombre de petites instructions, ce qui est plus efficace.

Parallel.ForEach(elementList, element => {
    //Do your work here
});

Le dernier exemple parallélise cette fois les deux boucles, l’une à l’intérieur de l’autre. le gain est ici présent encore (20% par rapport à la seule parallélisation de la boucle intérieure).

Attention : les exemples donnés ci-dessus, ainsi que les métriques, dépendent fortement du cas utilisé (machine, code, etc.). Les métriques sont simplement données à titre indicatif.

Les boucles parallélisées sur process asynchrone

On va parler maintenant du traitement avec les process asynchrones que l’on souhaiterait paralléliser. Et oui, malheureusement, ce n’est pas aussi simple que la version synchrone.

Les process synchrones ont l’avantage d’avoir un retour clair, ce qui est facilement gérable par la fonction de parallélisation car elle attend chaque process sous-jacent. Si elle peut gérer 10 process parallélisable, elle va donc en lancer 10, attendre que l’un d’eux se termine pour remplir à nouveau le slot avec un 11eme élément.

Exemple de la fonction LoopWithSyncProcess qui exécute les fonctions les une après les autres.

Cependant, dans le cadre de process asynchrone, il est impossible pour la libraire Parallel de savoir quand un des process est terminé et quand le remplacer. Les fonctions sont alors créées et détruites dans la foulée, sans avoir eu le temps de s’exécuter.

Exemple de la fonction LoopWithAsyncProcess. Les exécutions se lancent instantanément, sans jamais se terminer. Le programme se termine en < 1sc.

Le code ci-dessous montre un exemple de gestion correct d’une boucle utilisant un process asynchrone. Nous allons réutiliser la fonction Task.WhenAll vue dans le premier chapitre, qui prend en paramètre, un énumérable de Task, et qui retourne une task donnant l’état de l’ensemble des tâches sous-jacentes. Si on fait un await sur le résultat de WhenAll, alors l’instruction sera bloquée jusqu’à ce que l’ensemble des tâches parallélisées soient traitées.

Exécution des process avec utilisation de WhenAll

/// <summary>
/// Loop who can manage async function
/// </summary>
static public async Task LoopCanManagesAsyncProcessAsync()
{
	var numberList = new List<int>();
	for(int i = 0; i < 100; i++) numberList.Add(i);

	var taskList = new List<Task>();

	numberList.ForEach(number =>
	{
		taskList.Add(SimpleExamples.ProcessWithTimer(number, useAsyncTimer: true));
	});

	await Task.WhenAll(taskList);
}

Les clés pour comprendre le « lock »

Lorsque vous utilisez des process asynchrones, et à fortiori des boucles, il est probable que vous ayez des problèmes d’accès concurrentiel à une ressource ou une variable donnée.

Le .Net Framework propose une fonctionnalité de lock permettant, au sein d’une boucle asynchrone, de bloquer une ressource afin d’assurer sa manipulation de façon synchrone malgré l’utilisation à l’intérieur d’un process asynchrone.
Cela peut être extrêmement utile dans le cadre de manipulation de liste, d’une même référence, ou d’enregistrement dans la base de données via Entity Framwork par exemple.

Les locks prennent en paramètre une variable, qui sera celle manipulée, afin que lors de la manipulation de cette dernière le process s’assure que seul un Thread accède à la ressource en même temps.

Les locks étant des process synchrones au sein de process asynchrones, ils doivent être les plus légers et rapides possibles afin de garder des performances importantes.

static public async Task LoopLockTest()
{
	var loopEndedTasks = new List<int>();

	var numberList = new List<int>();
	for(int i = 0; i < 100; i++) numberList.Add(i);

	var taskList = new List<Task>();

	numberList.ForEach(number =>
	{
		taskList.Add(Task.Run(async () =>
		{
			Console.WriteLine("begin : " + number);
			await Task.Delay(1000);
			Console.WriteLine("after : " + number);

			lock(loopEndedTasks)
			{
				loopEndedTasks.Add(number);
			}
		}));
	});

	await Task.WhenAll(taskList);

	Console.WriteLine("Nb loop : " + loopEndedTasks.Count);
}
Quitter la version mobile