🔝 Retour au Sommaire
Les quatre politiques d'exécution ne diffèrent pas seulement par le nombre de threads utilisés. Chaque politique impose un contrat sur le code utilisateur — le callable (lambda, foncteur) passé à l'algorithme. Plus la politique est agressive en termes de parallélisme, plus les contraintes sur ce callable sont strictes. Comprendre ces contrats est essentiel pour éviter les data races et les comportements indéfinis.
Toutes les politiques sont définies dans l'en-tête <execution> :
#include <execution>
// Les quatre constantes globales
std::execution::seq // séquentielle
std::execution::par // parallèle
std::execution::par_unseq // parallèle + vectorisée
std::execution::unseq // vectorisée (C++20) La politique seq exécute l'algorithme séquentiellement, sur le thread appelant, exactement comme la version sans politique d'exécution. Chaque élément est traité dans l'ordre, un par un.
std::vector<int> v = {5, 3, 8, 1, 9};
// Ces deux appels sont fonctionnellement identiques
std::sort(v.begin(), v.end());
std::sort(std::execution::seq, v.begin(), v.end()); seq semble redondant avec l'appel sans politique, mais il a un rôle : rendre le choix de la séquentialité explicite et documenté. C'est particulièrement utile quand la politique est un paramètre configurable :
template<typename ExecPolicy>
void process_data(ExecPolicy policy, std::vector<double>& data) {
std::sort(policy, data.begin(), data.end());
std::transform(policy, data.begin(), data.end(), data.begin(),
[](double x) { return std::sqrt(x); });
}
// Choix à l'exécution ou à la configuration
process_data(std::execution::seq, small_data); // peu de données → séquentiel
process_data(std::execution::par, large_data); // beaucoup → parallèle Aucune contrainte particulière. Le callable peut utiliser des mutex, faire de l'I/O, modifier des variables partagées — tout fonctionne comme en code séquentiel classique.
- Les éléments sont traités dans l'ordre de la séquence.
- L'exécution a lieu sur le thread appelant exclusivement.
- Les exceptions propagent normalement.
La politique par autorise l'algorithme à répartir le travail sur plusieurs threads. L'implémentation peut utiliser un pool de threads interne pour traiter les éléments simultanément sur différents cœurs CPU.
std::vector<int> v(10'000'000);
std::iota(v.begin(), v.end(), 0);
// Le tri peut être distribué sur plusieurs cœurs
std::sort(std::execution::par, v.begin(), v.end());Le callable passé à l'algorithme peut être invoqué simultanément depuis plusieurs threads. Il doit donc être thread-safe. Concrètement :
Interdit — Accéder à des données partagées sans synchronisation :
int total = 0;
// ⚠️ DATA RACE — comportement indéfini
std::for_each(std::execution::par, v.begin(), v.end(), [&total](int x) {
total += x; // Plusieurs threads modifient total simultanément
});Autorisé — Utiliser des mutex ou des atomiques pour protéger les accès partagés :
std::atomic<int> total{0};
// ✅ Thread-safe grâce à std::atomic
std::for_each(std::execution::par, v.begin(), v.end(), [&total](int x) {
total.fetch_add(x, std::memory_order_relaxed);
});Autorisé — Utiliser des mutex (mais attention aux performances) :
std::mutex mtx;
std::vector<int> results;
std::for_each(std::execution::par, v.begin(), v.end(), [&](int x) {
if (x > threshold) {
std::lock_guard lock(mtx);
results.push_back(x); // Protégé par le mutex
}
});Cependant, utiliser un mutex dans un algorithme parallèle est généralement un anti-pattern : la contention sur le mutex annule une grande partie du gain de la parallélisation. Pour le cas ci-dessus, std::copy_if parallèle serait bien plus approprié :
// ✅ Meilleure approche — pas de mutex, l'algorithme gère la synchronisation
std::vector<int> filtered(v.size());
auto it = std::copy_if(std::execution::par, v.begin(), v.end(),
filtered.begin(), [](int x) { return x > threshold; });
filtered.erase(it, filtered.end());Le cas le plus fréquent de data race avec par est l'accumulation dans for_each. La solution idiomatique n'est pas d'ajouter un mutex — c'est d'utiliser le bon algorithme :
std::vector<double> data = /* ... */;
// ❌ Data race
double sum = 0.0;
std::for_each(std::execution::par, data.begin(), data.end(),
[&sum](double x) { sum += x; });
// ❌ Techniquement correct mais lent (contention atomique)
std::atomic<double> atomic_sum{0.0};
std::for_each(std::execution::par, data.begin(), data.end(),
[&atomic_sum](double x) {
double old = atomic_sum.load();
while (!atomic_sum.compare_exchange_weak(old, old + x));
});
// ✅ Correct et performant — utiliser std::reduce
double sum = std::reduce(std::execution::par, data.begin(), data.end(), 0.0);std::reduce est conçu pour la réduction parallèle : il partitionne les données, réduit chaque partition indépendamment, puis fusionne les résultats. Pas de contention, pas de synchronisation visible par l'utilisateur.
- Les éléments peuvent être traités dans n'importe quel ordre.
- Les invocations du callable peuvent se chevaucher entre threads différents.
- Les invocations du callable ne se chevauchent pas au sein d'un même thread — chaque thread traite un élément à la fois.
- Si le callable lève une exception,
std::terminateest appelé (voir section 15.7.3).
par_unseq autorise à la fois l'exécution sur plusieurs threads et la vectorisation SIMD (Single Instruction, Multiple Data) au sein de chaque thread. La vectorisation signifie que le processeur peut appliquer la même opération à plusieurs éléments simultanément dans un seul cycle d'horloge, en utilisant des registres larges (SSE, AVX).
std::vector<float> data(10'000'000);
// Peut utiliser plusieurs threads ET les instructions SIMD
std::transform(std::execution::par_unseq,
data.begin(), data.end(), data.begin(),
[](float x) { return x * 2.0f + 1.0f; }
);par_unseq impose les contraintes les plus strictes. Le callable peut être exécuté simultanément sur plusieurs threads (comme par), mais en plus il peut être entrelacé au sein d'un même thread par la vectorisation. Cela signifie qu'une invocation du callable peut être interrompue en plein milieu pour traiter un autre élément, puis reprise.
Cette propriété interdit toute opération qui bloque ou qui suppose une exécution atomique :
Interdit — Mutex, locks et toute synchronisation bloquante :
std::mutex mtx;
// ⚠️ COMPORTEMENT INDÉFINI — risque de deadlock intra-thread
std::for_each(std::execution::par_unseq, v.begin(), v.end(), [&mtx](int x) {
std::lock_guard lock(mtx); // Un thread pourrait tenter de verrouiller
// un mutex qu'il détient déjà (entrelacement)
// ...
});Le scénario catastrophe : le thread prend le mutex pour l'élément A, est interrompu par la vectorisation, tente de prendre le même mutex pour l'élément B — deadlock intra-thread.
Interdit — Allocation mémoire (dans la plupart des implémentations, les allocateurs utilisent des mutex internes) :
// ⚠️ Potentiellement dangereux — l'allocation peut utiliser un mutex interne
std::for_each(std::execution::par_unseq, v.begin(), v.end(), [](int x) {
auto ptr = new int(x); // Allocation → mutex interne potentiel
delete ptr;
});Autorisé — Opérations pures sans effet de bord, sans synchronisation, sans allocation :
// ✅ Opération pure — idéal pour par_unseq
std::transform(std::execution::par_unseq,
data.begin(), data.end(), data.begin(),
[](double x) { return std::sin(x) * std::cos(x); }
);par_unseq est optimal pour les opérations numériques pures sur de grands volumes de données : transformations mathématiques, traitement de signal, calcul scientifique. Le callable doit être une fonction simple sans état, sans allocation, sans I/O.
- Parallélisme entre threads (comme
par). - Vectorisation SIMD au sein de chaque thread.
- Les invocations du callable peuvent s'entrelacer au sein d'un même thread.
- Aucune garantie d'ordre.
- Exception →
std::terminate.
Ajoutée en C++20, unseq autorise la vectorisation SIMD mais pas le parallélisme multi-thread. L'algorithme s'exécute sur le thread appelant, mais le compilateur et le runtime peuvent vectoriser les opérations.
std::vector<float> a(1'000'000), b(1'000'000), c(1'000'000);
// ... initialisation ...
// Vectorisation SIMD sur le thread appelant
std::transform(std::execution::unseq,
a.begin(), a.end(), b.begin(), c.begin(),
[](float x, float y) { return x * y + 1.0f; }
);Mêmes restrictions que par_unseq concernant l'entrelacement intra-thread : pas de mutex, pas d'allocation, pas de synchronisation bloquante. La différence est qu'il n'y a pas de multi-threading, donc les data races entre threads ne sont pas un risque.
// ✅ Correct avec unseq — pas de multi-threading, pas de mutex nécessaire
std::atomic<int> count{0}; // Atomique non nécessaire mais pas interdit
// Mais éviter les mutex :
// std::lock_guard lock(mtx); // ⚠️ Interdit même avec unseq (entrelacement)unseq est le bon choix quand on veut la vectorisation SIMD mais qu'on ne veut pas le surcoût du pool de threads. Typiquement :
- Données de taille moyenne (trop petites pour justifier le parallélisme multi-thread, assez grandes pour bénéficier de SIMD).
- Environnements monothread contraints (systèmes embarqués, contextes temps-réel).
- Opérations numériques simples qui se vectorisent bien.
- Exécution sur le thread appelant uniquement.
- Vectorisation SIMD autorisée.
- Les invocations du callable peuvent s'entrelacer au sein du thread.
- Exception →
std::terminate.
| Propriété | seq |
par |
par_unseq |
unseq |
|---|---|---|---|---|
| Threads multiples | Non | Oui | Oui | Non |
| Vectorisation SIMD | Non | Non | Oui | Oui |
| Entrelacement intra-thread | Non | Non | Oui | Oui |
| Mutex dans le callable | ✅ Autorisé | ✅ Autorisé | ❌ Interdit | ❌ Interdit |
| Allocation dans le callable | ✅ Autorisé | ✅ Autorisé | ||
| Callable thread-safe requis | Non | Oui | Oui | Non* |
| Ordre garanti | Oui | Non | Non | Non |
| Exceptions | Propagées | terminate |
terminate |
terminate |
| Standard | C++17 | C++17 | C++17 | C++20 |
* unseq n'exige pas la thread-safety au sens multi-thread, mais exige l'absence de synchronisation bloquante à cause de l'entrelacement.
Le choix dépend de deux axes : la nature du callable et le volume de données.
Le callable utilise-t-il des mutex ou de l'allocation ?
/ \
Oui Non
/ \
Volume de données ? Le callable est-il pur ?
/ \ (pas d'effet de bord)
Petit Grand / \
| | Oui Non
seq par | |
Volume ? Volume ?
/ \ / \
Petit Grand Petit Grand
| | | |
unseq par_unseq unseq par
En résumé :
seq— Choix sûr par défaut. Callable sans contrainte. Petits volumes ou code avec mutex/I/O.par— Le plus courant pour paralléliser. Callable thread-safe mais peut allouer et utiliser des mutex (bien que déconseillé pour les performances). Grands volumes.par_unseq— Performance maximale. Callable pur, sans synchronisation, sans allocation. Calcul numérique intensif sur très grands volumes.unseq— Vectorisation sans le surcoût multi-thread. Mêmes contraintes quepar_unseqsur le callable. Volumes moyens ou contextes monothread.
En pratique, la majorité du code utilise seq ou par. Les politiques unseq et par_unseq sont réservées aux cas de calcul numérique intensif où le callable est une opération mathématique pure.
Un pattern courant consiste à paramétrer la politique d'exécution pour la rendre configurable :
template<typename ExecutionPolicy>
double compute_rms(ExecutionPolicy policy, const std::vector<double>& data) {
// Somme des carrés
double sum_sq = std::transform_reduce(policy,
data.begin(), data.end(),
0.0,
std::plus<>{},
[](double x) { return x * x; }
);
return std::sqrt(sum_sq / static_cast<double>(data.size()));
}
// Utilisation
double rms_small = compute_rms(std::execution::seq, small_data);
double rms_large = compute_rms(std::execution::par, large_data); On peut même choisir automatiquement :
template<typename Container>
auto choose_policy(const Container& c) {
if (c.size() > 100'000) {
return std::execution::par;
}
return std::execution::seq;
}Attention toutefois : std::execution::seq et std::execution::par sont des types différents (sequenced_policy et parallel_policy). La fonction ci-dessus ne compile pas telle quelle car les deux branches du if renvoient des types différents. Pour contourner cette limitation, on peut utiliser un std::variant ou simplement deux appels distincts :
void process(std::vector<double>& data) {
if (data.size() > 100'000) {
std::sort(std::execution::par, data.begin(), data.end());
} else {
std::sort(data.begin(), data.end()); // ou std::execution::seq
}
}Les algorithmes parallèles exigent au minimum des forward iterators. Les input/output iterators (single-pass) ne sont pas compatibles avec la parallélisation, car les threads doivent pouvoir parcourir indépendamment des sous-séquences de la même source.
// ✅ vector → random-access iterator → compatible
std::sort(std::execution::par, vec.begin(), vec.end());
// ✅ list → bidirectional iterator → compatible (mais pas pour sort)
std::for_each(std::execution::par, lst.begin(), lst.end(), func);
// ❌ istream_iterator → input iterator → incompatible
// std::for_each(std::execution::par,
// std::istream_iterator<int>(std::cin),
// std::istream_iterator<int>(),
// func);En pratique, la majorité des algorithmes parallèles sont utilisés avec std::vector (random-access, mémoire contiguë), qui offre les meilleures caractéristiques pour la parallélisation : accès aléatoire en O(1) et excellente localité de cache.
Les quatre politiques d'exécution forment un spectre de contraintes croissantes. seq n'impose rien et ne parallélise rien. par autorise le multi-threading mais exige un callable thread-safe. unseq autorise la vectorisation SIMD mais interdit les opérations bloquantes. par_unseq combine les deux et impose les restrictions les plus strictes — un callable pur sans état partagé. Le choix de la politique est un compromis entre la liberté d'écriture du callable et le potentiel de parallélisation. Quand on hésite, par est le choix pragmatique : il offre le parallélisme multi-thread avec des contraintes raisonnables (thread-safety), et couvre la majorité des cas d'usage.
⏭️ Parallélisation de std::sort, std::transform, std::reduce