Tips - Traitements parallèles avec async/await

3 minutes read

J’ai lu récemment quelques articles sur le parallelisme avec async await et j’ai décidé de faire un petit test pour vérifier pour voir ce qui serai le plus rapide à l’execution.

Le test se déroule de la manière suivante. J’appelle la même méthode asynchrone 5 fois et chacun de ces appels va mettre un temps prédeterminé à se finir. Le choix des durées n’est pas totalement fortuit. Je souhaitait que le premier appel soit le plus long pour montrer un problème décrit plus tard.

Voici la méthode en question :

private int[] _waitDurations = new[] { 5000, 2500, 1250, 625, 312 };

private async Task<string> DoWorkAsync(int i)
{
    await Task.Delay(_waitDurations[i]);
    return _waitDurations[i].ToString();
}

Je vais donc faire une boucle allant de 0 à 5 (non compris), appeler la méthode et récupérer son résultat pour le mettre dans une liste.La méthode 1 a un fonctionnement assez classique. On lance toutes les tâches en parallèle et on await le résultat ensuite dans un boucle.

private async Task Test1()
{
	var sw = new Stopwatch();
	sw.Start();

	var tasks = new Task<string>[5];
	for (int i = 0; i < 5; i++)
	{
		tasks[i] = DoWorkAsync(i);
	}

	foreach (Task<string> task in tasks)
	{
		string item = await task;
		myList.Items.Add(item);
	}

	sw.Stop();
	myTimeList.Items.Add(String.Format("{0} ms", sw.ElapsedMilliseconds.ToString()));
}

Vu qu’ici la tâche 0 est celle qui met le plus longtemps à s’executer on aura l’impression de recevoir tout les résultat en même temps alors qu’en fait les tâches de 1 à 4 se seront terminées il y a longtemps. C’est dû ici au fait qu’on await la tâche zéro avant de passer aux autres. Sur ma machine le temps d’execution total était 5050 ms environ.

La méthode 2 est une légère amélioration de la 1. Ici on va attendre toutes les tâches en même temps au lieu de le faire une par une.

private async Task Test2()
{
	var sw = new Stopwatch();
	sw.Start();

	var tasks = new Task<string>[5];
	for (int i = 0; i < 5; i++)
	{
		tasks[i] = DoWorkAsync(i);
	}

	await Task.WhenAll(tasks);

	foreach (Task<string> task in tasks)
	{
		string item = task.Result;
		myList.Items.Add(item);
	}

	sw.Stop();
	myTimeList.Items.Add(String.Format("{0} ms", sw.ElapsedMilliseconds.ToString()));
}

La particularité est qu’on ne repasse pas sur le thread initial (ici le Dispatcher) entre chaque récupération de résultat mais on ne le fait qu’une fois lorsque tout les résultats seront arrivés. Au final on fait beaucoup moins de changements de contextes avec une impression identique pour l’utilisateur dû au fait que le premier appel est le plus long dans le premier cas. Sur ma machine le gain est de l’ordre de 15 ms environ par rapport à la méthode précedente.

La méthode 3 est surtout valable lorsque l’ordre d’arrivé des résultats importe peu. Ici on va envoyer le résultat dès qu’on le reçoit.

private async Task Test3()
{
	var sw = new Stopwatch();
	sw.Start();

	var tasks = new Task<string>[5];
	for (int i = 0; i < 5; i++)
	{
		var task = DoWorkAsync(i);
		task.ContinueWith(t => myList.Items.Add(t.Result), TaskScheduler.FromCurrentSynchronizationContext());
		tasks[i] = task;
	}

	await Task.WhenAll(tasks);

	sw.Stop();
	myTimeList.Items.Add(String.Format("{0} ms", sw.ElapsedMilliseconds.ToString()));
}

L’astuce consiste à repasser en mode Task classique et de faire un ContinueWith sur le thread initial de l’ajout dans la liste. Avec cette méthode je gagne 50ms. Bien entendu ici le temps d’ajout dans la liste n’est pas compté par la Stopwatch car il s’execute dans le ContinueWith donc le gain effectif est probablement un peu plus faible mais tout de même. En ce qui concerne l’utilisateur par contre lui ça l’arrange car les éléments arrivent à dès qu’ils sont disponibles.

Il y a encore surement bien des cas où d’autres façons de faires vos appels seront plus efficaces, le tout étant de bien analyser les besoins pour choisir la bonne méthode.

A vos claviers maintenant !

Updated:

Leave a Comment