1. Introduction
Aujourd'hui on va voir comment traiter de grosses quantités de données aussi vite que possible en utilisant tous les cœurs disponibles de votre processeur (ou serveur). Pour ça, on utilisera des classes de l'espace de noms System.Threading.Tasks.Parallel, en particulier les méthodes Parallel.For et Parallel.ForEach.
Que faire si la tâche est purement CPU-bound ?
Une boucle classique for ou foreach traite les éléments les uns après les autres. Simple et fiable. Mais si vous avez un processeur multicœur, la boucle n'utilise qu'un seul cœur et les autres s'ennuient. Pourquoi ne pas répartir des portions du tableau sur différents cœurs pour un traitement simultané ?
Exemple :
// On calcule la somme des carrés de 1 à N
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
sum += i * i;
}
Ce code est simple, mais s'exécute de façon séquentielle. Et si on répartissait les tâches entre les cœurs ?
Meet the Family: Parallel.For et Parallel.ForEach
Qu'est-ce que c'est ?
- Parallel.For — fonctionne comme une boucle for normale, mais découpe le travail en morceaux et les répartit automatiquement sur des threads en utilisant tous les cœurs disponibles.
- Parallel.ForEach — parcourt une collection comme un foreach classique, mais en parallèle.
Documentation officielle :
Pourquoi c'est pratique ?
Vous n'avez pas besoin de créer, démarrer et gérer les threads vous-même. Le framework fait tout le boulot lourd pour vous. Vous écrivez du code ressemblant à une boucle normale, et le parallélisme se produit automatiquement sous le capot.
2. Syntaxe : exemples de base
Parallel.For
long total = 0;
Parallel.For(1, 1_000_001, i =>
{
// Cette lambda peut être exécutée simultanément par différents threads
Interlocked.Add(ref total, i * i); // Pour éviter les race conditions
});
Console.WriteLine($"Somme des carrés : {total}");
Remarque : la variable total est mise à jour via Interlocked.Add — pour éviter les conflits de données.
Parallel.ForEach
var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;
Parallel.ForEach(numbers, num =>
{
Interlocked.Add(ref sum, num * num); // Addition sécurisée
});
Console.WriteLine($"Somme des carrés : {sum}");
Vue interne (schéma visuel)
+-------------------+
|Collection/plage |
+---------+---------+
|
v
+----------------------+
| Parallel.ForEach |
+----------+-----------+
|
+----+----+----+----+
| | |
v v v
Task #1 Task #2 Task #3 ... (cœurs disponibles)
| | |
+--+----+ +--+-----+ +--+-----+
|Traitement| |Traitement| |Traitement|
+---------+ +---------+ +---------+
\ | /
+--------+--------+
|
v
Résultat
3. Analyse de gros fichiers (traitement CPU-bound)
Supposons qu'on ait un fichier texte avec des dizaines de milliers de lignes — par exemple, chaque ligne contient un nombre. Il faut lire le fichier, élever chaque nombre au carré et calculer la somme des carrés.
Version synchrone
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
foreach (var line in lines)
{
if (long.TryParse(line, out long n))
{
sum += n * n;
}
}
Console.WriteLine($"Somme des carrés : {sum}");
Version parallèle avec Parallel.For
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
Parallel.For(0, lines.Length, i =>
{
if (long.TryParse(lines[i], out long n))
{
Interlocked.Add(ref sum, n * n);
}
});
Console.WriteLine($"Somme des carrés : {sum}");
Qu'est-ce qui a changé : on a remplacé la boucle classique par une boucle parallèle, et sum est maintenant incrémentée via Interlocked.Add — pour éviter les conflits entre threads.
4. Que se passe-t-il sous le capot ?
Quand vous appelez Parallel.For ou Parallel.ForEach, .NET découpe automatiquement votre travail en fragments et les répartit sur les cœurs disponibles en utilisant le thread pool. Chaque fragment est traité indépendamment dans son propre thread.
Avantage : si vous avez 4 cœurs, le travail peut être presque 4 fois plus rapide (si la tâche ne dépend pas de ressources externes et ne bute pas sur d'autres limites, comme la mémoire ou la vitesse de lecture disque).
Comparaison des temps d'exécution
var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var n in numbers)
sumSync += n * n;
sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, somme: {sumSync}");
long sumParallel = 0;
sw.Restart();
Parallel.ForEach(numbers, n =>
Interlocked.Add(ref sumParallel, n * n)
);
sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, somme: {sumParallel}");
Essayez par vous-même ! Sur une machine puissante, l'accélération peut être significative, mais tout dépend de la nature des tâches et des goulots d'étranglement.
5. Astuces utiles
Contrôler le degré de parallélisme
Parfois il est utile de limiter le nombre de threads utilisés (par exemple pour ne pas surcharger la machine). Pour ça, utilisez MaxDegreeOfParallelism :
using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions {
MaxDegreeOfParallelism = 2
};
Parallel.For(0, 100, options, i =>
{
Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"Somme des carrés : {sum}");
Quand c'est utile : si vous savez qu'une partie des calculs sollicite fortement le disque plutôt que le CPU — réduisez le nombre de threads et mesurez l'impact sur les performances.
Quand utiliser des boucles parallèles
| Boucle for classique | Parallel.For/Parallel.ForEach | |
|---|---|---|
| Processeurs | Utilise un seul cœur | Utilise tous les cœurs |
| Ordre | Garanti | Non garanti |
| Vitesse | Généralement plus lente | Souvent beaucoup plus rapide |
| Simplicité | Très simple | Nécessite de penser à la sécurité des threads |
| Meilleure utilisation | Petits volumes, I/O-bound | Gros volumes, CPU-bound |
Extension : que peut encore faire Parallel ?
Parallel.Invoke() — lance plusieurs méthodes indépendantes en même temps :
static void DoTask1() => Console.WriteLine("Tâche 1 terminée");
static void DoTask2() => Console.WriteLine("Tâche 2 terminée");
static void DoTask3() => Console.WriteLine("Tâche 3 terminée");
Parallel.Invoke(
() => DoTask1(),
() => DoTask2(),
() => DoTask3()
);
Chaque méthode s'exécutera sur son propre cœur si possible.
Applications réelles
- Traitement d'images : traitement simultané de blocs différents (par ex. appliquer un filtre).
- Calculs sur tableaux : calculs financiers, simulation (évaluation de portefeuille sur des scénarios).
- Analyse de gros logs : recherche et agrégation sur plusieurs cœurs.
- Machine learning : découpage en tâches indépendantes (batches de données, feature engineering).
Et bien sûr, en entretien vous pourrez non seulement expliquer ce que sont les boucles parallèles, mais aussi commenter honnêtement leurs avantages et inconvénients.
6. Erreurs typiques en travaillant avec Parallel.For et Parallel.ForEach
Erreur n°1 : Ignorer les race conditions.
Modifier une variable partagée sans Interlocked ou lock conduit à des résultats incorrects à cause des accès simultanés des threads.
Erreur n°2 : Utiliser pour des tâches I/O-bound.
Les boucles parallèles n'accélèrent pas les tâches dépendantes du disque ou du réseau, et peuvent même les ralentir à cause des overheads.
Erreur n°3 : Supposer un ordre d'exécution.
Les boucles parallèles ne garantissent pas l'ordre de traitement des éléments, ce qui peut casser la logique si elle dépend de la séquence.
Erreur n°4 : Ignorer les effets de bord.
Modifier un état partagé (par ex. des collections) dans des boucles parallèles peut provoquer des erreurs si vous n'utilisez pas de structures thread-safe.
GO TO FULL VERSION