SE205: Travaux Pratiques sur les Patrons pour la Concurrente en POSIX

Laurent Pautet (pautet@telecom-paristech.fr)

Index


1 TP de Patrons pour la Concurrence en POSIX

Objectifs

Ce TP vise à découvrir le modèle d’exécution asynchrone proposé par Java et à l’implanter en C. Le modèle consiste à définir des objets de type Runnable ou Callable et à les faire exécuter par un ensemble contraint de threads sous la responsabilité d’un ordonnanceur ExecutorService. Celui-ci propose différentes sémantiques d’exécution et différentes configurations des ressources. On peut retrouver sa spécification sous ExecutorService.

Ce TP ne couvre pas tous les services par ExecutorService. Il illustre le fonctionnement de quelques uns d’entre eux. Le support de cours sur les tâches se trouve ici. Vous pouvez consulter la documentation complète des fonctions POSIX concernant les threads en suivant ce lien.

Sources

Vous trouverez dans cette archive compressée l’intégralité des sources. Plusieurs fichiers de scenario sont fournis pour vérifier vos solutions. Par ailleurs, une implantation en Java utilisant ExecutorService vous permettra de vérifier le résultat attendu de votre implantation en C / POSIX.

Vous devez réutiliser l’implantation du tampon circulaire protégé faite dans un TP précédent. Il faut que vous ayez fait au moins les 4 premières questions. C’est à dire l’implantation avec les variables conditionnelles pour les sémantiques bloquante, non-bloquante et temporisée.

Pour décompresser, utiliser GNU tar:

tar zxf src.tar.gz

Debuggage

Pour trouver vos erreurs, nous vous conseillons fortement d’utiliser gdb. Il est critique de mettre au point vos programmes C en utilisant gdb et non pas en truffant votre programme de printf au petit bonheur. Si vous avez un problème de mémoire (SIGSEGV, ...), faites :

gdb ./main_executor
(gdb) run test-20.txt

En cas de problème, le programme stoppera sur l’accès mémoire suspect. Pour identifier la ligne, utiliser les commandes de gdb:

MacOS

Pour les utilisateurs de MacOS, il faudra rendre votre gdb opérationnel et MacOS ne facilite pas la tâche. Vous trouverez la marche à suivre en suivant ce lien. Si ce lien n’est pas suffisant, il existe de nombreux guides pour règler ce problème.


1.1 Présentation de l’architecture du programme

Le programme principal se trouve dans main_executor.c dont nous détaillons le fonctionnement par la suite.

Le code de main consiste, après initialisation de structures internes au programme, à lire un fichier de scenario passé en ligne de commande et à créer autant de travaux (job_t) qu’il est indiqué dans le scenario grâce à la variable job_table_size.

Chaque travail est décrit par une structure job_t, décrite dans scenario.h, contenant un indice sur sa position dans la table jobs et un temps d’exécution exec_time (ou temps de calcul du travail).

// scenario.h

typedef struct {
  int    id;
  long   exec_time;
} job_t;

Exécuter un travail consiste à exécuter la procédure principale main_job, décrite au début de main_executor.c, qui elle-même consiste à signaler son démarrage ("initiate"), à simuler un travail dont la durée aura été passée en paramètre, en l’occurrence l’attribut exec_time de la structure job_t, et enfin à signaler sa terminaison ("complete").

Si l’on progresse dans main_executor.c, un executor est créé en spécifiant des paramètres de configuration.

// executor.h

typedef struct _executor_t {
  thread_pool_t      * thread_pool;
  long                 keep_alive_time;
  protected_buffer_t * futures;
} executor_t;

Lors de la création, on indique les paramètres de configuration, core_pool_size et max_pool_size, du gestionnaire de threads thread_pool, mais aussi blocking_queue_size, le nombre maximum de callables qui peuvent rester en attente dans la file futures, et le temps keep_alive_time pendant lequel les threads peuvent rester inactives avant d’être détruites. Chaque future est une structure faisant référence à un callable et à son résultat (voir cours).

executor a la charge de traiter les travaux qui lui sont soumis. Après création de l’executor, le programme crée autant de callables que de jobs et les soumet à l’executor par la procédure submit_callable. Pour chaque callable soumis, submit_callable retourne un future. Par la suite, le programme va collecter les résultats auprès des futures par get_callable_result en bloquant éventuellement si les résultats ne sont pas encoree disponibles.

Après avoir créé l’executor, le programme renseigne le tableau de callables et de futures à l’aide de la table des jobs. La structure callable_t, décrite dans executor.h, dispose d’un pointeur vers le code de la procédure principale à exécuter (attribut main) et des caractéristiques du travail (attribut params). Les autres attributs de callable_t seront expliqués plus tard.

// executor.h

typedef void * (*main_func_t)(void *);

typedef struct {
  void       * params;
  main_func_t  main;
  long         period;
} callable_t;

A chaque callable est associé un future. Ainsi, la structure future_t fait référence au callable auquel elle est associée (attribut callable) ainsi qu’au résultat (attribut result) qu’elle aura produit une fois qu’elle sera dans l’état terminé (attribut completed).

// executor.h

typedef struct {
  int             completed;
  callable_t    * callable;
  void          * result;
} future_t;

1.2 Implantation d’un gestionnaire simple de threads

Si l’on reprend le fonctionnement du programme, on pourra noter que dans le fichier executor.c, la fonction submit_callable demande la création d’une thread du gestionnaire de threads thread_pool.

Nous allons implanter une première version du gestionnaire de threads dans les fichiers thread_pool.c et thread_pool.h. Nous rappelons brièvement le principe du gestionnaire de threads. Un gestionnaire de threads (threads pool) maintient plusieurs threads en attente d’exécution de travaux par le programme. Comme indiqué dans la structure ci-dessous, nous ne maintenons pas la liste des threads allouées, mais uniquement leur nombre ainsi que les paramètres principaux core_pool_size et max_pool_size.

// thread_pool.h

typedef struct {
  int             core_pool_size;
  int             max_pool_size;
  int             size;
  int             shutdown;
} thread_pool_t;

Cependant, comme la structure thread_pool est accessible de manière concurrente, il faut la complèter afin de la protéger contre les accès concurrents en lui rajoutant un objet de synchronisation comme attribut. Il faut également initialiser cet objet de synchronisation dans thread_pool_init.

On ne s’intéresse pour l’instant qu’au cas où le nombre de threads créé est inférieur à core_pool_size. Il faudra complèter la fonction create_pool_thread pour qu’elle crée une thread en utilisant les paramètres qui lui sont passés puis mette à jour les attributs de la structure thread_pool_t.

En ayant partiellement complèté thread_pool_create, on peut utiliser le scenario test-20.txt pour vérifier l’implantation. Un exemple de sortie attendue est donné ci-dessous. Peu importent les messages concernant les callables puisque pour l’instant, leurs résultats ne sont pas correctement gérés. Par contre, il faut que les travaux se terminent dans l’ordre croissant de temps d’exécution (1000, 3000, 4000, 7000) alors qu’ils sont lancés dans un ordre différent (1000, 7000, 3000, 4000). En effet, core_pool_size valant 4, 4 threads sont obligatoirement créées et chacune exécute un travail en parallèle des autres. Donc les travaux commencent toujours à la date 0 et se terminent à la date de leur temps d’exécution prévu. On remarque que 4 threads sont créées et que 4 threads se terminent.

core_pool_size = 4
max_pool_size = 4
blocking_queue_size = 4
keep_alive_time = -1
000000 [pool_thread] created
000000 [main_job] initiate execution=1000 period=0
000000 [submit_callable] id 0
000000 [pool_thread] created
000000 [submit_callable] id 1
000000 [main_job] initiate execution=7000 period=0
000000 [pool_thread] created
000000 [main_job] initiate execution=3000 period=0
000000 [submit_callable] id 2
000000 [pool_thread] created
000000 [submit_callable] id 3
000000 [get_callable_result] id 0
000000 [main_job] initiate execution=4000 period=0
000000 [get_callable_result] id 1
000000 [get_callable_result] id 2
000000 [get_callable_result] id 3
001004 [main_job] complete execution=1000 period=0
001004 [pool_thread] terminated
003000 [main_job] complete execution=3000 period=0
003000 [pool_thread] terminated
004005 [main_job] complete execution=4000 period=0
004005 [pool_thread] terminated
007003 [main_job] complete execution=7000 period=0
007003 [pool_thread] terminated
010003 [executor_shutdown]

1.3 Récupération des résultats d’exécution

Nous allons maintenant faire en sorte de bloquer en attendant que le résultat de l’exécution d’un callable soit disponible. Pour cela, il faudra modifier le fichier executor.c. Le résultat de l’exécution d’un callable se trouve stocké dans la structure future_t retournée par submit_callable. La fonction get_callable_result doit bloquer en attendant que le callable se termine, c’est à dire que l’attribut completed de la structure future_t soit vrai.

Pour ce faire, nous utiliserons un mutex et une variable conditionnelle. Le premier pour protéger la structure contre les accès concurrents, le second pour attendre que le résultat du calcul associé au callable soit disponible. Il faudra donc complèter la fonction get_callable_result pour qu’elle bloque en attendant que l’attribut completed passe à true.

De manière symétrique, chaque callable s’exécute au travers de la procédure principale main_pool_thread d’une pool thread. Il faudra complèter la fonction main_pool_thread pour que, le callable exécuté, la pool thread signale que le resultat est disponible en mettant à jour l’attribut completed de future ainsi que les objets de synchronisation.

Vous pourrez utiliser le scenario test-20.txt pour vérifier votre implantation. Dans ce scenario, les résultats doivent apparaitre dans le bon ordre et aux instants prévus. Il faut noter que les résultats des callables sont récupérés dans le même ordre que ceux-ci sont soumis auprès de l’executor. Donc, dans la sortie proposée ci-dessous, le résultat du callable 0 soumis en premier est récupéré à la date 1000 puisque son temps d’exécution vaut 1000 millisecondes. Puis, le résultat du callable 1 soumis en deuxième est récupéré à la date 7000. Par contre, ce n’est qu’à la date 7000 que l’on peut récupérer le résultat du callable 3. En effet, celui-ci s’est bien terminé à la date 3000 mais le programme principal récupère les résultats dans l’ordre et a du attendre de récupérer le résultats du callable 2 à 7000 pour pouvoir récupérer celui du callable 3. Le même phénomène survient pour le callable 4.

core_pool_size = 4
max_pool_size = 4
blocking_queue_size = 4
keep_alive_time = -1
000000 [pool_thread] created
000000 [submit_callable] id 0
000000 [main_job] initiate execution=1000 period=0
000000 [pool_thread] created
000000 [main_job] initiate execution=7000 period=0
000000 [submit_callable] id 1
000000 [pool_thread] created
000000 [submit_callable] id 2
000000 [pool_thread] created
000000 [submit_callable] id 3
000000 [main_job] initiate execution=4000 period=0
000000 [main_job] initiate execution=3000 period=0
001005 [main_job] complete execution=1000 period=0
001005 [pool_thread] terminated
001005 [get_callable_result] id 0
003005 [main_job] complete execution=3000 period=0
003005 [pool_thread] terminated
004000 [main_job] complete execution=4000 period=0
004000 [pool_thread] terminated
007003 [main_job] complete execution=7000 period=0
007003 [pool_thread] terminated
007003 [get_callable_result] id 1
007003 [get_callable_result] id 2
007003 [get_callable_result] id 3
017007 [executor_shutdown]

1.4 Stockage des callables dans une file d’attente

Lorsqu’un nombre de threads égal à core_pool_size a été créé, l’executor suspend la création de threads. Dans submit_callable, il est fait appel à pool_thread_create dont le dernier paramètre est à 0 (faux), ce qui indique que l’on ne souhaite pas dépasser la limite de core_pool_size. Ainsi, pool_thread_create retourne 0 (faux) pour indiquer que la limite a été franchie.

// If the current thread pool size is not greater than core_pool_size,
// create a new thread. If it is and force is true, create a new
// thread as well. If a thread is created, increment the current
// thread pool size. Use main as a thread main procedure.

int pool_thread_create(thread_pool_t * thread_pool,
                       main_func_t     main,
                       void          * executor,
                       int             force);

Dès lors, il faut complèter submit_callable pour que les nouveaux callables soient stockés dans la file d’attente futures de l’executor. Aucune autre thread n’est créée tant que la file d’attente n’est pas remplie.

Par ailleurs, les threads déjà créées, après avoir exécuté leur travail courant, doivent consulter la file d’attente pour en extraire un objet future et exécuter l’objet callable auquel il fait référence. Une fois que le callable, fourni initialement, a été executé, il faut complèter main_pool_thread, pour que la pool_thread extraie un autre future de la file d’attente de l’executor. En l’absence de keep_alive_time (FOREVER), la pool_thread bloque tant qu’aucun callable n’est présent dans la file.

Vous pourrez utiliser le scenario test-21.txt pour vérifier votre implantation. Dans cet exemple, il n’y a que 2 pool threads et une file d’attente de taille 4 pour 4 travaux à effectuer de temps d’exécution 1000 ms, 7000 ms, 3000 ms et 4000 ms. La première thread exécute le premier travail de 1000 ms et la seconde celui de 7000 ms. A la différence des cas précédents, à la date 1000 ms, la première thread va extraire de la file d’attente le troisième travail qui dure 3000 ms et qui va donc se terminer à la date 4000 ms. Comme le deuxième travail de 7000 ms n’est toujours pas terminé, la thread va extraire de la file le dernier travail de 4000 ms et se terminer à la date 8000 ms.

core_pool_size = 2
max_pool_size = 4
blocking_queue_size = 4
keep_alive_time = -1
000000 [pool_thread] created
000000 [main_job] initiate execution=1000 period=0
000000 [submit_callable] id 0
000000 [pool_thread] created
000000 [submit_callable] id 1
000000 [submit_callable] id 2
000000 [main_job] initiate execution=7000 period=0
000000 [submit_callable] id 3
001005 [main_job] complete execution=1000 period=0
001005 [main_job] initiate execution=3000 period=0
001005 [get_callable_result] id 0
004010 [main_job] complete execution=3000 period=0
004010 [main_job] initiate execution=4000 period=0
007003 [main_job] complete execution=7000 period=0
007003 [get_callable_result] id 1
007003 [get_callable_result] id 2
008013 [main_job] complete execution=4000 period=0
008013 [get_callable_result] id 3
018013 [executor_shutdown]

On peut noter que les 2 threads sont bien créées mais ne se terminent plus puisqu’elles sont en attente infinie de travaux dans la file d’attente. Le programme se termine volontairement de manière incorrecte puisque l’opération executor_shutdown ne se charge pas d’arrêter ces threads. Ce problème sera traité ultérieurement.


1.5 Implantation d’un gestionnaire avancé de threads

Nous cherchons désormais à complèter le mécanisme de création des threads du gestionnaire de threads. Il s’agit de créer de nouvelles threads lorsque la file d’attente des callables est pleine. Toutefois, il ne faut pas dépasser un maximum de max_pool_size de threads.

Comme indiqué précédemment, la fonction pool_thread_create comporte un paramètre force qui permet de forcer la création d’un thread si le nombre de threads créés est supérieur ou égal à core_pool_size.

Il faut tout d’abord complèter pool_thread_create pour qu’une thread soit créée lorsque le nombre de threads créées est supérieur ou égal à core_pool_size, inférieur à max_pool_size et que le paramètre force est vrai.

Pour mettre en oeuvre cette fonctionnalité, il faut également complèter submit_callable afin qu’une fois la file d’attente pleine, une pool_thread soit créée par un appel à pool_thread_create dans les circonstances décrites précédemment. Il faut cependant faire attention à préserver l’ordre des callables. Si le callable courant ne peut pas être traité, il faut extraire le premier callable de la file d’attente, insérer le callable courant et attribuer le premier callable à la thread récemment créée.

Vous pourrez utiliser le scenario test-22.txt pour vérifier votre implantation. Dans cet exemple, de nouveau, 4 travaux de 1000 ms, 7000 ms, 3000 ms et 4000 ms sont soumis à l’executor. Le gestionnaire permet la création immédiate de 2 pool threads puisque core_pool_size vaut 2. Donc 2 callables (1000 ms et 7000 ms) vont être traités dès la date 0. Le troisième callable va être stocké dans la file d’attente. Or, celle-ci étant de taille 1, la file d’attente étant pleine avec le callable de 3000 ms, le quatrième callable de 4000 ms va provoquer la création d’une troisième thread, sachant que max_pool_size vaut 4. Il faut donc bien vérifier que le travail de 4000 ms est bien traité en dernier et que seulement 3 threads sont créées.

core_pool_size = 2
max_pool_size = 4
blocking_queue_size = 1
keep_alive_time = -1
000000 [pool_thread] created
000000 [submit_callable] id 0
000000 [pool_thread] created
000000 [submit_callable] id 1
000000 [main_job] initiate execution=1000 period=0
000000 [submit_callable] id 2
000000 [main_job] initiate execution=7000 period=0
000000 [pool_thread] created
000000 [submit_callable] id 3
000000 [main_job] initiate execution=3000 period=0
001000 [main_job] complete execution=1000 period=0
001000 [main_job] initiate execution=4000 period=0
001000 [get_callable_result] id 0
003004 [main_job] complete execution=3000 period=0
005005 [main_job] complete execution=4000 period=0
007005 [main_job] complete execution=7000 period=0
007005 [get_callable_result] id 1
007005 [get_callable_result] id 2
007005 [get_callable_result] id 3
017006 [executor_shutdown]

1.6 Expiration d’un thread après un temps d’inactivité

Nous allons faire expirer un thread s’il reste en inactivité pendant un temps spécifié par keep_alive_time alors que le nombre de threads créées est supérieur à core_pool_size et que la file d’attente des callable est vide.

Il faut donc complèter main_pool_thread, pour qu’une fois que la thread a terminé son travail courant, elle attende pendant un délai keep_alive_time qu’un callable soit extrait de la file d’attente. Si aucun future n’est retourné, la thread doit faire appel à pool_thread_remove, pour savoir s’il doit s’arrêter car le nombre de threads créés est supérieur à core_pool_size.

Il faut donc également complèter pool_thread_remove pour que la fonction confirme ou non que la thread peut se terminer, le nombre de threads créées étant supérieur à core_pool_size. Comme le nombre de threads créées et donc la structure thread_pool vont être modifiés, il faut faire attention de protéger thread_pool contre les accès concurrents.

Vous pourrez utiliser le scenario test-23.txt pour vérifier votre implantation. Dans cet exemple, de nouveau, 4 travaux de 1000 ms, 7000 ms, 3000 ms et 4000 ms sont soumis à l’executor. Le gestionnaire ne permet la création d’aucun pool threads puisque core_pool_size vaut 0. Comme la file d’attente est de taille 1, le premier callable est stocké dans cette file. Puis 3 pool threads sont créées pour exécuter les callables de 1000 ms, 7000 ms et 3000 ms. A la date 1000, la thread en charge du callable de 1000 ms, prend en charge le callable de 4000 ms pour se terminer à la date 5000 ms. Comme keep_alive_time vaut 500 ms et que core_pool_size vaut 0, les 3 threads callables devront se terminer à 3500 ms (3000 ms + 500 ms), 5500 ms (1000 ms + 4000ms + 500 ms) et 7500 ms (7000ms + 500 ms).

core_pool_size = 0
max_pool_size = 4
blocking_queue_size = 1
keep_alive_time = 500
000000 [submit_callable] id 0
000000 [pool_thread] created
000000 [submit_callable] id 1
000000 [pool_thread] created
000000 [submit_callable] id 2
000000 [pool_thread] created
000000 [submit_callable] id 3
000000 [main_job] initiate execution=1000 period=0
000000 [main_job] initiate execution=7000 period=0
000000 [main_job] initiate execution=3000 period=0
001002 [main_job] complete execution=1000 period=0
001002 [main_job] initiate execution=4000 period=0
001002 [get_callable_result] id 0
001002 [get_callable_result] id 1
003002 [main_job] complete execution=3000 period=0
003502 [pool_thread] terminated
005002 [main_job] complete execution=4000 period=0
005502 [pool_thread] terminated
007000 [main_job] complete execution=7000 period=0
007000 [get_callable_result] id 2
007000 [get_callable_result] id 3
007502 [pool_thread] terminated
017002 [executor_shutdown]

1.7 Arrêt correct des gestionnaires d’exécution et de threads

Pour l’instant, la fonction executor_shutdown dans le fichier executor.c ne fait rien, donc le programme se termine sans prendre le soin d’arrêter les threads actives. Ce qui n’a pas présenté de problèmes jusqu’à présent car les scenarii étaient conçus pour. Mais il va falloir désormais traiter ce problème.

Comme on peut le voir dans executor.c, executor_shutdown fait appel à thread_pool_shutdown pour signaler aux threads du thread pool de se terminer dès que possible. Ce qui n’est pas suffisant car certaines threads peuvent être bloquées indéfiniment en attente de callables ou de futures (keep_alive_time infini). Pour les débloquer, il faut complèter le executor_shutdown afin de remplir la file d’attente de callables vides (NULL) (jusqu’à ce que les ajouts soient refusés). Dans le code de main_pool_thread, les threads seront débloquées (avec des future nuls) et pourront potentiellement se terminer si pool_thread_remove le permet.

if ((future == NULL) && pool_thread_remove (executor->thread_pool))
  break;

Ceci fait, executor_shutdown peut se terminer en bloquant wait_thread_pool_empty. Il faudra cependant complèter pool_thread_remove et wait_thread_pool_empty. Pour ce faire, il faut ajouter à la structure thread_pool_t de thread_pool.h un objet de synchronisation qui fera bloquer tout thread attendant la terminaison de toutes les pool threads actives. Puis il faut utiliser cet objet dans wait_thread_pool_empty et pool_thread_remove.

Pour complèter pool_thread_remove, il faut permettre à une thread de se terminer lorsque le thread pool est en phase de shutdown, que le nombre de threads actives devienne inférieur à core_pool_size ou non. Lorsque toutes les threads sont devenues inactives, pool_thread_remove, il faut signaler par l’objet de synchronisation que the thread pool est véritablement vide.

Vous pourrez utiliser le scenario test-24.txt pour vérifier votre implantation. Dans cet exemple, de nouveau, 4 travaux de 1000 ms, 7000 ms, 3000 ms et 4000 ms sont soumis à l’executor. Une seule thread traite ces travaux et termine leur exécution à la date 15000. executor_shutdown débute à la date 25000. La seule thread doit donc se terminer à cette date, comme c’est le cas ci-dessous, avec le message [pool_thread] terminated.

core_pool_size = 1
max_pool_size = 1
blocking_queue_size = 4
keep_alive_time = 20000
000000 [pool_thread] created
000000 [main_job] initiate execution=1000 period=0
000000 [submit_callable] id 0
000000 [submit_callable] id 1
000000 [submit_callable] id 2
000000 [submit_callable] id 3
001000 [main_job] complete execution=1000 period=0
001001 [main_job] initiate execution=7000 period=0
001001 [get_callable_result] id 0
008004 [main_job] complete execution=7000 period=0
008004 [main_job] initiate execution=3000 period=0
008004 [get_callable_result] id 1
011006 [main_job] complete execution=3000 period=0
011006 [main_job] initiate execution=4000 period=0
011006 [get_callable_result] id 2
015009 [main_job] complete execution=4000 period=0
015009 [get_callable_result] id 3
025011 [pool_thread] terminated
025011 [executor_shutdown]

1.8 Implantation des threads périodiques

Nous souhaitons mettre en oeuvre des threads périodiques suivant la politique dite à fréquence fixe (withFixedRate). Lorsque la variable de configuration period sera positionnée à une valeur non nulle en millisecondes, les callable créés seront considérés comme périodiques de période period. En tant que tâche périodique, ils ne fourniront plus de résultats puisque leur exécution est désormais infinie.

Il faut donc complèter main_pool_thread pour que la thread ait un comportement périodique si la période du callable n’est pas nulle.

Vous pourrez utiliser le scenario test-25.txt pour vérifier votre implantation. On crée 4 callables dont l’exécution se répète toutes les 8000 ms. Comme shutdown survient après 10000 ms, les callables ne se termineront qu’après leur deuxième exécution soit à 16000 ms.

core_pool_size = 4
max_pool_size = 4
blocking_queue_size = 4
keep_alive_time = 5000
000000 [pool_thread] created
000000 [main_job] initiate execution=1000 period=8000
000000 [submit_callable] id 0
000000 [pool_thread] created
000000 [submit_callable] id 1
000000 [pool_thread] created
000000 [submit_callable] id 2
000000 [pool_thread] created
000000 [submit_callable] id 3
000000 [main_job] initiate execution=7000 period=8000
000000 [main_job] initiate execution=3000 period=8000
000000 [main_job] initiate execution=4000 period=8000
001004 [main_job] complete execution=1000 period=8000
003002 [main_job] complete execution=3000 period=8000
004001 [main_job] complete execution=4000 period=8000
007001 [main_job] complete execution=7000 period=8000
008000 [main_job] initiate execution=1000 period=8000
008000 [main_job] initiate execution=3000 period=8000
008000 [main_job] initiate execution=7000 period=8000
008000 [main_job] initiate execution=4000 period=8000
009005 [main_job] complete execution=1000 period=8000
011004 [main_job] complete execution=3000 period=8000
012004 [main_job] complete execution=4000 period=8000
015004 [main_job] complete execution=7000 period=8000
016003 [pool_thread] terminated
016003 [pool_thread] terminated
016003 [pool_thread] terminated
016003 [pool_thread] terminated
016003 [executor_shutdown]