Exécuteurs

Aperçu

La gestion de l’exécution dans ROS 2 est expliquée par le concept d’exécuteurs. Un exécuteur utilise un ou plusieurs threads du système d’exploitation sous-jacent pour invoquer les rappels des abonnements, des temporisateurs, des serveurs de service, des serveurs d’action, etc. sur les messages et événements entrants. La classe Executor explicite (dans executor.hpp dans rclcpp, dans executors.py dans rclpy, ou dans executor.h dans rclc) fournit plus de contrôle sur la gestion de l’exécution que le mécanisme de spin dans ROS 1, bien que l’API de base soit très similaire.

Dans ce qui suit, nous nous concentrons sur la bibliothèque cliente C++ rclcpp.

Utilisation de base

Dans le cas le plus simple, le thread principal est utilisé pour traiter les messages entrants et les événements d’un nœud en appelant rclcpp::spin(..) comme suit :

int main(int argc, char* argv[])
{
   // Some initialization.
   rclcpp::init(argc, argv);
   ...

   // Instantiate a node.
   rclcpp::Node::SharedPtr node = ...

   // Run the executor.
   rclcpp::spin(node);

   // Shutdown and exit.
   ...
   return 0;
}

L’appel à spin(node) se développe essentiellement en une instanciation et une invocation de l’exécuteur monothread, qui est l’exécuteur le plus simple :

rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();

En invoquant spin() de l’instance Executor, le thread actuel commence à interroger les couches rcl et middleware pour les messages entrants et autres événements et appelle les fonctions de rappel correspondantes jusqu’à ce que le nœud s’arrête. Afin de ne pas contrecarrer les paramètres QoS du middleware, un message entrant n’est pas stocké dans une file d’attente sur la couche Client Library mais conservé dans le middleware jusqu’à ce qu’il soit pris en charge par une fonction de rappel. (Il s’agit d’une différence cruciale avec ROS 1.) Un wait set est utilisé pour informer l’exécuteur des messages disponibles sur la couche middleware, avec un indicateur binaire par file d’attente. Le wait set est également utilisé pour détecter l’expiration des minuteries.

../_images/executors_basic_principle.png

L’exécuteur monothread est également utilisé par le processus conteneur pour components, c’est-à-dire dans tous les cas où des nœuds sont créés et exécutés sans fonction principale explicite.

Types d’exécuteurs

Actuellement, rclcpp fournit trois types d’exécuteurs, dérivés d’une classe parent partagée :

digraph Flatland {

   Executor -> SingleThreadedExecutor [dir = back, arrowtail = empty];
   Executor -> MultiThreadedExecutor [dir = back, arrowtail = empty];
   Executor -> StaticSingleThreadedExecutor [dir = back, arrowtail = empty];
   Executor  [shape=polygon,sides=4];
   SingleThreadedExecutor  [shape=polygon,sides=4];
   MultiThreadedExecutor  [shape=polygon,sides=4];
   StaticSingleThreadedExecutor  [shape=polygon,sides=4];

   }

The Multi-Threaded Executor creates a configurable number of threads to allow for processing multiple messages or events in parallel. The Static Single-Threaded Executor optimizes the runtime costs for scanning the structure of a node in terms of subscriptions, timers, service servers, action servers, etc. It performs this scan only once when the node is added, while the other two executors regularly scan for such changes. Therefore, the Static Single-Threaded Executor should be used only with nodes that create all subscriptions, timers, etc. during initialization.

Les trois exécuteurs peuvent être utilisés avec plusieurs nœuds en appelant add_node(..) pour chaque nœud.

rclcpp::Node::SharedPtr node1 = ...
rclcpp::Node::SharedPtr node2 = ...
rclcpp::Node::SharedPtr node3 = ...

rclcpp::executors::StaticSingleThreadedExecutor executor;
executor.add_node(node1);
executor.add_node(node2);
executor.add_node(node2);
executor.spin();

Dans l’exemple ci-dessus, le seul thread d’un exécuteur statique à un seul thread est utilisé pour desservir trois nœuds ensemble. Dans le cas d’un exécuteur multithread, le parallélisme réel dépend des groupes de rappel.

Groupes de rappel

ROS 2 permet d’organiser les rappels d’un nœud en groupes. Dans rclcpp, un tel groupe de rappel peut être créé par la fonction create_callback_group de la classe Node. Dans rclpy, la même chose est faite en appelant le constructeur du type de groupe de rappel spécifique. Le groupe de rappel doit être stocké tout au long de l’exécution du nœud (par exemple en tant que membre de classe), sinon l’exécuteur ne pourra pas déclencher les rappels. Ensuite, ce groupe de rappel peut être spécifié lors de la création d’un abonnement, d’un timer, etc. - par exemple par les options d’abonnement :

my_callback_group = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);

rclcpp::SubscriptionOptions options;
options.callback_group = my_callback_group;

my_subscription = create_subscription<Int32>("/topic", rclcpp::SensorDataQoS(),
                                             callback, options);

Tous les abonnements, minuteries, etc. qui sont créés sans l’indication d’un groupe de rappel sont affectés au groupe de rappel par défaut. Le groupe de rappel par défaut peut être interrogé via NodeBaseInterface::get_default_callback_group() dans rclcpp et par Node.default_callback_group dans rclpy.

Il existe deux types de groupes de rappel, où le type doit être spécifié au moment de l’instanciation :

  • Mutuellement exclusif : Les rappels de ce groupe ne doivent pas être exécutés en parallèle.

  • Réentrant : Les rappels de ce groupe peuvent être exécutés en parallèle.

Les rappels de différents groupes de rappel peuvent toujours être exécutés en parallèle. L’exécuteur multithread utilise ses threads comme un pool pour traiter autant de rappels que possible en parallèle selon ces conditions. Pour obtenir des conseils sur l’utilisation efficace des groupes de rappel, consultez Using Callback Groups.

La classe de base Executor dans rclcpp a également la fonction add_callback_group(..), qui permet de distribuer des groupes de rappel à différents Executors. En configurant les threads sous-jacents à l’aide du planificateur du système d’exploitation, des rappels spécifiques peuvent être prioritaires sur d’autres rappels. Par exemple, les abonnements et les temporisateurs d’une boucle de contrôle peuvent être prioritaires sur tous les autres abonnements et services standard d’un nœud. Le package examples_rclcpp_cbg_executor fournit une démonstration de ce mécanisme.

Sémantique de planification

Si le temps de traitement des rappels est plus court que la période pendant laquelle les messages et les événements se produisent, l’exécuteur les traite essentiellement dans l’ordre FIFO. Cependant, si le temps de traitement de certains rappels est plus long, les messages et les événements seront mis en file d’attente sur les couches inférieures de la pile. Le mécanisme d’ensemble d’attente ne rapporte que très peu d’informations sur ces files d’attente à l’exécuteur. En détail, il signale uniquement s’il y a des messages pour un certain sujet ou non. L’exécuteur utilise ces informations pour traiter les messages (y compris les services et les actions) de manière circulaire - mais pas dans l’ordre FIFO. L’organigramme suivant visualise cette sémantique de planification.

../_images/executors_scheduling_semantics.png

Cette sémantique a été décrite pour la première fois dans un article de Casini et al. à l’ECRTS 2019 <https://drops.dagstuhl.de/opus/volltexte/2019/10743/pdf/LIPics-ECRTS-2019-6.pdf>`_. (Remarque : le document explique également que les événements de minuterie sont prioritaires sur tous les autres messages. Cette hiérarchisation a été supprimée dans Eloquent.)

Perspectives

Bien que les trois exécuteurs de rclcpp fonctionnent bien pour la plupart des applications, certains problèmes les rendent inadaptés aux applications en temps réel, qui nécessitent des temps d’exécution bien définis, un déterminisme et un contrôle personnalisé sur l’ordre d’exécution. Voici un résumé de certains de ces problèmes :

  1. Sémantique d’ordonnancement complexe et mixte. Idéalement, vous souhaitez une sémantique de planification bien définie pour effectuer une analyse temporelle formelle.

  2. Les rappels peuvent souffrir d’une inversion de priorité. Les rappels de priorité supérieure peuvent être bloqués par des rappels de priorité inférieure.

  3. Aucun contrôle explicite sur l’ordre d’exécution des rappels.

  4. Aucun contrôle intégré sur le déclenchement pour des sujets spécifiques.

De plus, la surcharge de l’exécuteur en termes d’utilisation du processeur et de la mémoire est considérable. L’exécuteur statique à un seul thread réduit considérablement cette surcharge, mais cela peut ne pas être suffisant pour certaines applications.

Ces problèmes ont été partiellement résolus par les développements suivants :

  • rclcpp WaitSet : La classe WaitSet de rclcpp permet d’attendre directement sur les abonnements, les minuteurs, serveurs de service, serveurs d’action, etc. au lieu d’utiliser un exécuteur. Il peut être utilisé pour implémenter des séquences de traitement déterministes définies par l’utilisateur, traitant éventuellement plusieurs messages provenant de différents abonnements ensemble. Le package examples_rclcpp_wait_set fournit plusieurs exemples d’utilisation de ce mécanisme de jeu d’attente au niveau de l’utilisateur.

  • rclc Executor : cet exécuteur de la bibliothèque client C rclc développé pour micro-ROS donne le contrôle précis de l’utilisateur sur l’ordre d’exécution des rappels et permet des conditions de déclenchement personnalisées pour activer les rappels. De plus, il implémente les idées de la sémantique du temps d’exécution logique (LET).

Plus d’informations