Ce qui suit propose quelques solutions possibles aux exercices que vous trouverez sur exercice-apprivoiser-multiprog.html
L'enjeu est de comparer la vitesse d'exécution des deux boucles suivantes :
|
|
Si vous comparez le temps d'exécution des deux versions, vous remarquerez que celle de gauche est significativement plus rapide que celle de droite, même si le calcul fait est le même dans les deux cas. La raison tient à l'accès à l'antémémoire (la Cache), qui est favorisé par le parcours de gauche qui touche à des cases adjacentes en mémoire du tableau parcouru, alors que le parcours de droite fait des sauts ici et là en mémoire.
Une solution possible serait celle-ci (je n'ai pas exactement pris le CompterSi de l'énoncé, mais vous pouvez l'adapter si tel est votre désir) :
using System;
using System.Threading;
using System.Diagnostics;
const int N = 25_000;
var tab = CréerTableau(N * N);
for(int i = 1; i <= 16; ++i)
{
var (r0, dt0) = Tester(() => CompterSiMT(tab, n => n % 2 != 0, i));
if(i < 10) // bof, mais je ne me souviens plus du code de formatage...
Console.WriteLine($"Compté {r0} impairs avec {i} fils en {dt0} ms");
else
Console.WriteLine($"Compté {r0} impairs avec {i} fils en {dt0} ms");
}
static short[] CréerTableau(int n)
{
short[] tab = new short[n];
for (int i = 0; i != tab.Length; ++i)
tab[i] = (short)(i * 2 + 1);
return tab;
}
static (T rés, long dt) Tester<T>(Func<T> f)
{
var sw = new Stopwatch();
sw.Start();
T rés = f();
sw.Stop();
return (rés, sw.ElapsedMilliseconds);
}
static int CompterSiMT(short[] tab, Func<short, bool> pred, int nthrs)
{
var thrs = new Thread[nthrs-1]; // initialisés à null en C#
var nimpairs = new int[nthrs]; // initialisés à 0 en C#
int tailleBloc = tab.Length / nthrs;
for(int i = 0; i < thrs.Length; ++i)
{
int monIndice = i;
int début = i * tailleBloc; // inclus
int fin = (i + 1) * tailleBloc; // exclue
thrs[i] = new Thread(() =>
{
int m = 0;
for (; début != fin; ++début)
if (pred(tab[début]))
++m;
nimpairs[monIndice] = m;
//for (; début != fin; ++début)
// if (pred(tab[début]))
// ++nimpairs[monIndice];
});
}
foreach (var th in thrs)
th.Start();
{
int début = (nthrs - 1) * tailleBloc; // inclus
int fin = tab.Length; // exclue
int m = 0;
for (; début != fin; ++début)
if (pred(tab[début]))
++m;
nimpairs[nthrs - 1] = m;
//for (; début != fin; ++début)
// if (pred(tab[début]))
// ++nimpairs[nthrs - 1];
}
foreach (var th in thrs)
th.Join();
int cumul = 0;
foreach (int n in nimpairs)
cumul += n;
return cumul;
}Vous remarquerez que le code sera plus rapide avec l'ajout de fils d'exécution... mais si vous remplacez le code qui compte les impairs dans CompterSiMT par le code qui le suit mais est présentement en commentaires, le temps d'exécution deviendra erratique. C'est l'impact du faux-partage que vous constatez alors...
Une solution possible serait :
// ...
class ZoneTransit<T>
{
List<T> Data { get; } = new List<T>();
object mutex = new object();
public void Ajouter(List<T> elems)
{
lock (mutex)
Data.AddRange(elems);
}
public List<T> Extraire()
{
var temp = new List<T>();
lock (mutex)
{
temp.AddRange(Data);
Data.Clear();
}
return temp;
}
}
// ...Une autre solution, meilleure encore, serait :
// ...
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
class ZoneTransit<T>
{
List<T> data = new List<T>();
object mutex = new object();
public void Ajouter(List<T> elems)
{
lock (mutex)
data.AddRange(elems);
}
public List<T> Extraire()
{
var temp = new List<T>();
lock (mutex)
Permuter(ref temp, ref data);
return temp;
}
}
// ...Quelques remarques :
Il n'est pas très difficile d'implémenter les services suggérés votre collègue. Une implémentation possible, code de test inclus, serait la suivante :
const int N = 1_000_000;
{
var zt = new ZoneTransit<string>();
string msg = "J'aime mon prof";
var (r0, dt0) = Tester(() =>
{
for (int i = 0; i != N; ++i)
zt.Ajouter(msg);
return zt;
});
Console.WriteLine($"Ajout un à un : {dt0} ms");
}
{
var zt = new ZoneTransit<string>();
string msg = "J'aime mon prof";
var (r0, dt0) = Tester(() =>
{
var lst = new List<string>();
for (int i = 0; i != N; ++i)
lst.Add(msg);
zt.Ajouter(lst);
return zt;
});
Console.WriteLine($"Ajout en bloc : {dt0} ms");
}
// ...
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
static (T rés, long dt) Tester<T>(Func<T> f)
{
var sw = new Stopwatch();
sw.Start();
T rés = f();
sw.Stop();
return (rés, sw.ElapsedMilliseconds);
}
class ZoneVideException : Exception { }
class ZoneTransit<T>
{
List<T> data = new ();
object mutex = new ();
public void Ajouter(List<T> elems)
{
lock (mutex)
data.AddRange(elems);
}
public List<T> Extraire()
{
var temp = new List<T>();
lock (mutex)
Permuter(ref temp, ref data);
return temp;
}
// EX01.0 : est-ce une bonne idée?
public void Ajouter(T elem)
{
lock (mutex)
data.Add(elem);
}
public T ExtraireUn()
{
lock (mutex)
{
if (data.Count == 0)
throw new ZoneVideException();
T elem = data[0];
data.RemoveAt(0);
return elem;
}
}
public int Count
{
get
{
lock (mutex)
return data.Count;
}
}
public bool EstVide => Count == 0;
}
// ...Si vous comparez le temps d'un ajout en bloc avec celui de multiples ajouts à la pièce, vous constaterez le coût de la prise de mutex à de multiples reprises quand on fait des ajouts à la pièce. Ce coût est non-négligeable, et montre que le service Ajouter(T) par exemple ne serait pas avantageux; il est préférable de décourager ce genre de pratique, et d'encourager plutôt les opérations de granularité plus grossière (ce qui amortit le coût inhérent à la synchronisation)
L'enjeu pour les propriétés EstVide et Count est différent : ces propriétés sont totalement inutiles ici, car elles ne peuvent pas être utilisées correctement.
En effet, supposons un extrait de programme comme celui-ci :
Pourquoi ce code est-il dangereux? En fait, il ne le serait pas dans un programme séquentiel, mais ici, entre la ligne où se trouve le test et la ligne où se trouvent les conséquences du test, l'état de zt peut avoir changé de par l'action d'un autre fil d'exécution. En pratique, il n'est pas possible d'offrir une fonctionnalité de fine granularité comme EstVide ou Count qui soit utilisable concrètement dans un outil destiné à de la synchronisation.
Il y a plusieurs manières de faire cela; ce qui suit est un exemple possible. Notez que j'ai fait tous mes fils d'exécution dans le programme principal, à l'aide d'expressions λ, mais que des fonctions nommées auraient aussi pu être utilisées (vous pouvez vous amuser à le réécrire ainsi) :
J'ai réutilisé ZoneTransit<T> telle que décrite dans le présent solutionnaire pour EX01 dans sa déclinaison la plus efficace. |
|
Pour le programme principal, où se trouvera l'essentiel du code pour cet exemple :
Pour cet exemple, j'ai présumé un tableau fichiers de fichiers, mais idéalement nous aurions utilisé un string[] passé en paramètre à Main |
|
Puisque nous sommes en C# où tout doit être alloué dynamiquement, même un tableau dont la taille est connue à la compilation (ce qui déprime quiconque préfère le code efficace), nous allouerons ensuite un Thread[] que nous initialiserons avec les NETAPES fils d'exécution de notre pipeline |
|
Le premier fil, notre lecteur, consommera chaque fichier à traiter et en mettra le contenu dans la zone de transit qu'il utilise en sortie. Une fois les lectures terminées, il signalera à son successeur qu'il ne l'alimentera plus désormais. |
|
Le fil suivant, le « majusculeur », consommera les fichiers de la zone de transit en entrée, et insèrera chacun d'entre eux dans la zone de transit en sortie le texte de ces fichiers une fois celui-ci transformé en majuscules. Une fois le traitement de tous les fichiers terminé, il signalera à son successeur qu'il ne l'alimentera plus désormais. |
|
Le fil suivant, le « censeur », consommera les fichiers de la zone de transit en entrée, et insèrera chacun d'entre eux dans la zone de transit en sortie le texte de ces fichiers une fois celui-ci censuré. Une fois le traitement de tous les fichiers terminé, il signalera à son successeur qu'il ne l'alimentera plus désormais. |
|
Le dernier fil, le « scripteur », consommera les fichiers de la zone de transit en entrée, et projettera chacun sur disque avec un nouveau nom, inspiré du nom original. |
|
Ne reste plus qu'à lancer le tout et attendre que le traitement se complète. |
|
À venir...
EX03 – sur la base de vos travaux en EX01 et EX02, écrivez un programme dans lequel on trouvera un pipeline transformant des fichiers .cs en fichiers .html. La forme attendue sera :
Assurez-vous que le programme se termine normalement, que les fichiers résultants soient complets, et que les fils travaillent concurremment.