Partager via


Meilleures pratiques en général du runtime d'accès concurrentiel

Ce document décrit les meilleures pratiques à appliquer à plusieurs zones du runtime d'accès concurrentiel.

Sections

Ce document contient les sections suivantes :

  • Utiliser les éléments de synchronisation coopérative, dans la mesure du possible

  • Éviter les tâches longues qui ne cèdent pas

  • Utiliser le surabonnement pour compenser les opérations qui effectuent un blocage ou qui présentent une latence élevée

  • Utiliser les fonctions de gestion de la mémoire simultanée, dans la mesure du possible

  • Utiliser RAII pour gérer la durée de vie des objets d'accès concurrentiel

  • Ne pas créer d'objets d'accès concurrentiel au niveau de la portée globale

  • Ne pas utiliser d'objets d'accès concurrentiel dans les segments de données partagées

Utiliser les éléments de synchronisation coopérative, dans la mesure du possible

Le runtime d'accès concurrentiel fournit un grand nombre d'éléments sécurisés du point de vue de l'accès concurrentiel qui ne requièrent pas d'objet de synchronisation externe. Par exemple, la classe concurrency::concurrent_vector permet d'effectuer des opérations d'ajout et d'accès aux éléments sécurisées du point de vue de l'accès concurrentiel. Toutefois, lorsque vous avez besoin d'un accès exclusif à une ressource, le runtime fournit les classes concurrency::critical_section, concurrency::reader_writer_lock et concurrency::event. Ces types se comportent de manière coopérative. Par conséquent, le planificateur de tâches peut réallouer les ressources de traitement à un autre contexte pendant que la première tâche attend des données. Si possible, utilisez ces types de synchronisation au lieu d'autres mécanismes de synchronisation, tels que ceux fournis par l'API Windows, qui ne se comportent pas de manière coopérative. Pour plus d'informations sur ces types de synchronisation et pour obtenir un exemple de code, consultez Structures de données de synchronisation et Comparaison de structures de données de synchronisation avec l'API Windows.

[Premières]

Éviter les tâches longues qui ne cèdent pas

Étant donné que le planificateur de tâches se comporte de manière coopérative, il ne traite pas équitablement les tâches. Par conséquent, une tâche peut empêcher le démarrage d'autres tâches. Dans certains cas, c'est acceptable. Dans d'autres cas, cela peut provoquer un interblocage ou la privation.

L'exemple suivant effectue plus de tâches que le nombre de ressources de traitement allouées. La première tâche ne cède pas au planificateur de tâches. Par conséquent, la deuxième tâche ne démarre pas tant que la première n'est pas terminée.

// cooperative-tasks.cpp 
// compile with: /EHsc
#include <ppl.h>
#include <iostream>
#include <sstream>

using namespace concurrency;
using namespace std;

// Data that the application passes to lightweight tasks. 
struct task_data_t
{
   int id;  // a unique task identifier. 
   event e; // signals that the task has finished.
};

// A lightweight task that performs a lengthy operation. 
void task(void* data)
{   
   task_data_t* task_data = reinterpret_cast<task_data_t*>(data);

   // Create a large loop that occasionally prints a value to the console. 
   int i;
   for (i = 0; i < 1000000000; ++i)
   {
      if (i > 0 && (i % 250000000) == 0)
      {
         wstringstream ss;
         ss << task_data->id << L": " << i << endl;
         wcout << ss.str();
      }
   }
   wstringstream ss;
   ss << task_data->id << L": " << i << endl;
   wcout << ss.str();

   // Signal to the caller that the thread is finished.
   task_data->e.set();
}

int wmain()
{
   // For illustration, limit the number of concurrent  
   // tasks to one.
   Scheduler::SetDefaultSchedulerPolicy(SchedulerPolicy(2, 
      MinConcurrency, 1, MaxConcurrency, 1));

   // Schedule two tasks.

   task_data_t t1;
   t1.id = 0;
   CurrentScheduler::ScheduleTask(task, &t1);

   task_data_t t2;
   t2.id = 1;
   CurrentScheduler::ScheduleTask(task, &t2);

   // Wait for the tasks to finish.

   t1.e.wait();
   t2.e.wait();
}

Cet exemple génère la sortie suivante :

1: 250000000 1: 500000000 1: 750000000 1: 1000000000 2: 250000000 2: 500000000 2: 750000000 2: 1000000000

Il existe plusieurs façons d'activer la coopération entre les deux tâches. L'une des méthodes consiste à céder occasionnellement au planificateur de tâches lorsqu'une tâche met du temps à s'exécuter. L'exemple suivant modifie la fonction task pour appeler la méthode concurrency::Context::Yield afin de céder l'exécution au planificateur de tâches et qu'une autre tâche puisse être exécutée.

// A lightweight task that performs a lengthy operation. 
void task(void* data)
{   
   task_data_t* task_data = reinterpret_cast<task_data_t*>(data);

   // Create a large loop that occasionally prints a value to the console. 
   int i;
   for (i = 0; i < 1000000000; ++i)
   {
      if (i > 0 && (i % 250000000) == 0)
      {
         wstringstream ss;
         ss << task_data->id << L": " << i << endl;
         wcout << ss.str();

         // Yield control back to the task scheduler.
         Context::Yield();
      }
   }
   wstringstream ss;
   ss << task_data->id << L": " << i << endl;
   wcout << ss.str();

   // Signal to the caller that the thread is finished.
   task_data->e.set();
}

Cet exemple génère la sortie suivante :

  

La méthode Context::Yield ne cède qu'à un autre thread actif dans le planificateur auquel le thread actuel appartient, à une tâche légère ou à un thread d'un système d'exploitation différent. Cette méthode ne cède pas à un travail qui a été planifié pour s'exécuter dans un objet concurrency::task_group ou concurrency::structured_task_group qui n'a pas encore démarré.

Il existe d'autres façons d'activer la coopération entre des tâches de longue durée. Vous pouvez scinder une grande tâche en sous-tâches plus petites. Vous pouvez également activer le surabonnement pendant une tâche de longue durée. Le surabonnement vous permet de créer un nombre de threads plus important que le nombre de threads matériels disponibles. Le surabonnement peut s'avérer particulièrement utile lorsqu'une tâche de longue durée présente une latence élevée, par exemple, lors de la lecture des données à partir d'un disque ou d'une connexion réseau. Pour plus d'informations sur les tâches légères et le surabonnement, consultez Planificateur de tâches (runtime d'accès concurrentiel).

[Premières]

Utiliser le surabonnement pour compenser les opérations qui effectuent un blocage ou qui présentent une latence élevée

Le runtime d'accès concurrentiel fournit des primitives de synchronisation, tel que concurrency::critical_section, qui permettent aux tâches d'effectuer un blocage de manière coopérative et de céder les unes aux autres. Lorsqu'une tâche effectue un blocage ou cède de manière coopérative, le planificateur de tâches peut réaffecter les ressources de traitement à un autre contexte pendant que la première tâche attend des données.

Dans certains cas, vous ne pouvez pas utiliser le mécanisme de blocage coopératif qui est fourni par le runtime d'accès concurrentiel. Par exemple, une bibliothèque externe que vous utilisez peut utiliser un mécanisme de synchronisation différent. Ou encore, lorsque vous exécutez une opération qui peut présenter une latence élevée, par exemple lorsque vous utilisez la fonction ReadFile de l'API Windows pour lire des données à partir d'une connexion réseau. Dans ces cas, le surabonnement peut permettre à des tâches de s'exécuter lorsqu'une autre tâche est inactive. Le surabonnement vous permet de créer un nombre de threads plus important que le nombre de threads matériels disponibles.

Prenons la fonction suivante, download, qui télécharge le fichier à l'URL donnée. Cet exemple utilise la méthode concurrency::Context::Oversubscribe pour augmenter provisoirement le nombre de threads actifs.

// Downloads the file at the given URL.
string download(const string& url)
{
   // Enable oversubscription.
   Context::Oversubscribe(true);

   // Download the file.
   string content = GetHttpFile(_session, url.c_str());

   // Disable oversubscription.
   Context::Oversubscribe(false);

   return content;
}

Étant donné que la fonction GetHttpFile effectue une opération potentiellement latente, le surabonnement peut permettre à d'autres tâches de s'exécuter pendant que la tâche actuelle attend des données. Pour obtenir la version complète de cet exemple, consultez Comment : utiliser le surabonnement pour compenser la latence.

[Premières]

Utiliser les fonctions de gestion de la mémoire simultanée, dans la mesure du possible

Utilisez les fonctions de gestion de la mémoire, concurrency::Alloc et concurrency::Free, en présence de tâches de granularité fine qui allouent souvent des petits objets dont la durée de vie est relativement courte. Le runtime d'accès concurrentiel maintient un cache mémoire séparé pour chaque thread en cours de exécution. Les fonctions Alloc et Free allouent et libèrent la mémoire disponible de ces caches sans utiliser de verrous ou de barrières de mémoire.

Pour plus d'informations sur ces fonctions de gestion de la mémoire, consultez Planificateur de tâches (runtime d'accès concurrentiel). Pour obtenir un exemple qui utilise ces fonctions, consultez Comment : utiliser Alloc et Free pour améliorer les performances de la mémoire.

[Premières]

Utiliser RAII pour gérer la durée de vie des objets d'accès concurrentiel

Le runtime d'accès concurrentiel utilise la gestion des exceptions pour implémenter des fonctionnalités telles que l'annulation. Par conséquent, écrivez du code sécurisé du point de vue des exceptions lorsque vous appelez le runtime ou une autre bibliothèque qui appelle le runtime.

Le modèle RAII (Resource Acquisition Is Initialization) est un moyen de gérer sans risque la durée de vie d'un objet d'accès concurrentiel dans une portée donnée. Selon le modèle RAII, une structure de données est allouée sur la pile. Cette structure de données initialise ou acquiert une ressource lorsqu'elle est créée et détruit ou libère cette ressource lorsque la structure de données est détruite. Le modèle RAII garantit que le destructeur est appelé avant que la portée englobante ne quitte. Ce modèle est utile lorsqu'une fonction contient plusieurs instructions return. Ce modèle est également utile lorsque vous écrivez du code sécurisé du point de vue des exceptions. Lorsqu'une instruction throw provoque le déroulement de la pile, le destructeur de l'objet RAII est appelé. Par conséquent, la ressource est toujours correctement supprimée ou libérée.

Le runtime définit plusieurs classes qui utilisent le modèle RAII, par exemple, concurrency::critical_section::scoped_lock et concurrency::reader_writer_lock::scoped_lock. Ces classes d'assistance portent le nom de verrous à portée limitée. Ces classes procurent plusieurs avantages lorsque vous travaillez avec les objets concurrency::critical_section ou concurrency::reader_writer_lock. Le constructeur de ces classes acquiert l'accès à l'objet critical_section ou reader_writer_lock fourni et le destructeur libère l'accès à cet objet. Étant donné qu'un verrou à portée limitée libère automatiquement l'accès à son objet d'exclusion mutuelle lorsqu'il est détruit, vous ne déverrouillez pas l'objet sous-jacent manuellement.

Prenons l'exemple de la classe suivante, account, qui est définie par une bibliothèque externe et ne peut donc pas être modifiée.

// account.h
#pragma once
#include <exception>
#include <sstream>

// Represents a bank account.
class account
{
public:
   explicit account(int initial_balance = 0)
      : _balance(initial_balance)
   {
   }

   // Retrieves the current balance.
   int balance() const
   {
      return _balance;
   }

   // Deposits the specified amount into the account.
   int deposit(int amount)
   {
      _balance += amount;
      return _balance;
   }

   // Withdraws the specified amount from the account.
   int withdraw(int amount)
   {
      if (_balance < 0)
      {
         std::stringstream ss;
         ss << "negative balance: " << _balance << std::endl;
         throw std::exception((ss.str().c_str()));
      }

      _balance -= amount;
      return _balance;
   }

private:
   // The current balance.
   int _balance;
};

L'exemple suivant effectue plusieurs transactions sur un objet account en parallèle. L'exemple utilise un objet critical_section pour synchroniser l'accès à l'objet account, car la classe account n'est pas sécurisée du point de vue de l'accès concurrentiel. Chaque opération parallèle utilise un objet critical_section::scoped_lock pour garantir que l'objet critical_section est déverrouillé lorsque l'opération réussit ou échoue. Lorsque le solde de compte est négatif, l'opération withdraw échoue en levant une exception.

// account-transactions.cpp 
// compile with: /EHsc
#include "account.h"
#include <ppl.h>
#include <iostream>
#include <sstream>

using namespace concurrency;
using namespace std;

int wmain()
{
   // Create an account that has an initial balance of 1924.
   account acc(1924);

   // Synchronizes access to the account object because the account class is  
   // not concurrency-safe.
   critical_section cs;

   // Perform multiple transactions on the account in parallel.    
   try
   {
      parallel_invoke(
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before deposit: " << acc.balance() << endl;
            acc.deposit(1000);
            wcout << L"Balance after deposit: " << acc.balance() << endl;
         },
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before withdrawal: " << acc.balance() << endl;
            acc.withdraw(50);
            wcout << L"Balance after withdrawal: " << acc.balance() << endl;
         },
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before withdrawal: " << acc.balance() << endl;
            acc.withdraw(3000);
            wcout << L"Balance after withdrawal: " << acc.balance() << endl;
         }
      );
   }
   catch (const exception& e)
   {
      wcout << L"Error details:" << endl << L"\t" << e.what() << endl;
   }
}

Cet exemple génère l'exemple de sortie suivant :

  

Pour obtenir des exemples supplémentaires qui utilisent le modèle RAII pour gérer la durée de vie des objets d'accès concurrentiel, consultez Procédure pas à pas : suppression de travail d'un thread d'interface utilisateur, Comment : utiliser la classe Context pour implémenter un sémaphore coopératif et Comment : utiliser le surabonnement pour compenser la latence.

[Premières]

Ne pas créer d'objets d'accès concurrentiel au niveau de la portée globale

Lorsque vous créez un objet de concurrence au niveau global vous pouvez déclencher des problèmes tels que des violations d'interblocage ou d'accès de mémoire de votre application.

Par exemple, lorsque vous créez un objet de runtime d'accès concurrentiel, le runtime crée un planificateur par défaut pour vous, si aucun planificateur n'existe déjà. Objet de runtime créé pendant la construction d'objets globales forcera en conséquence le runtime à créer ce planificateur par défaut. Toutefois, ce processus utilise un verrou interne, ce qui peut perturber l'initialisation d'autres objets qui prennent en charge l'infrastructure du runtime d'accès concurrentiel. Ce verrou interne peut être requis par un autre objet d'infrastructure qui n'a pas encore été initialisé, et donc peut entraîner un interblocage dans votre application.

L'exemple suivant illustre la création d'un objet concurrency::Scheduler global. Ce modèle s'applique non seulement à la classe Scheduler, mais à tous les autres types fournis par le runtime d'accès concurrentiel. Nous vous conseillons de ne pas respecter ce modèle, étant donné qu'il peut provoquer un comportement inattendu de votre application.

// global-scheduler.cpp 
// compile with: /EHsc
#include <concrt.h>

using namespace concurrency;

static_assert(false, "This example illustrates a non-recommended practice.");

// Create a Scheduler object at global scope. 
// BUG: This practice is not recommended because it can cause deadlock.
Scheduler* globalScheduler = Scheduler::Create(SchedulerPolicy(2,
   MinConcurrency, 2, MaxConcurrency, 4));

int wmain() 
{   
}

Pour obtenir des exemples sur la façon de créer correctement des objets Scheduler, consultez Planificateur de tâches (runtime d'accès concurrentiel).

[Premières]

Ne pas utiliser d'objets d'accès concurrentiel dans les segments de données partagées

Le runtime d'accès concurrentiel ne prend pas en charge l'utilisation d'objets d'accès concurrentiel dans une section de données partagées, par exemple, une section de données créée par la directive data_seg #pragma. Un objet d'accès concurrentiel partagé au delà des limites de processus peut entraîner un état incohérent ou non valide du runtime.

[Premières]

Voir aussi

Tâches

Comment : utiliser Alloc et Free pour améliorer les performances de la mémoire

Comment : utiliser le surabonnement pour compenser la latence

Comment : utiliser la classe Context pour implémenter un sémaphore coopératif

Procédure pas à pas : suppression de travail d'un thread d'interface utilisateur

Concepts

Bibliothèque de modèles parallèles

Bibliothèque d'agents asynchrones

Planificateur de tâches (runtime d'accès concurrentiel)

Structures de données de synchronisation

Comparaison de structures de données de synchronisation avec l'API Windows

Meilleures pratiques de la Bibliothèque de modèles parallèles

Meilleures pratiques de la Bibliothèque d'agents asynchrones

Autres ressources

Meilleures pratiques sur le runtime d'accès concurrentiel