[[PageOutline]] ** {{{#!html

Gestion des Threads }}} = Rappels de cours = A. Questions La majorité des réponses aux questions sont dans le cours ou dans le rappel du cours donné au début de cette page, c'est voulu. Les questions suivent à peu près l'ordre du cours, elles sont simples, mais vous avez besoin de comprendre le cours pour y répondre :-) Quand une question vous demande si quelque chose est vrai ou faux, ne répondez pas juste "oui" ou "non », mais justifiez vos réponses avec une petite phrase. Le but de ces questions est d'évaluer vos connaissances, donc plus vous êtes précis, mieux c'est. Vous avez un corrigé que vous devez consulter pour vous autocorriger, mais pour qu'il soit utile, lisez-le après avoir cherché vous-même les réponses. == A.1. Questions générales 1. Dites-en une phrase ce qu'est un processus informatique (selon Wikipédia) {{{#!protected ------------------------------------------------------------------ ''' * Un processus est un programme en cours d'exécution. Dans cette définition, le programme c'est le fichier qui contient le code. ''' }}} 1. Est-ce qu'un processus utilisateur s'exécute toujours dans le mode utilisateur du MIPS ? {{{#!protected ------------------------------------------------------------------ ''' * Non, la majorité du code s'exécute en mode utilisateur (user), mais lorsqu'il fait un appel système, il entre dans le noyau pour exécuter les fonctions rendant le service avec le droit du root, et c'est toujours le processus utilisateur qui s'exécute, mais avec les droits du root. ''' }}} 1. Nous avons vu qu'un processus utilisateur peut faire des appels système, c'est-à-dire demander des services au noyau du système d'exploitation. Est-ce qu'un processus peut faire des interruptions et des exceptions ? {{{#!protected ------------------------------------------------------------------ ''' * Non pour les interruptions, les demandes d'interruption (IRQ pour Interrupt ReQuest) sont faites par les périphériques grâce à des signaux électriques binaires (2 états). Ces demandes ne peuvent pas être faites directement par le code de l'utilisateur. Toutefois, une IRQ est la conséquence d'une commande ou d'une configuration faite par le programme, alors on pourrait dire que les IRQ sont provoquées par les programmes. * Oui pour les exceptions, une exception est toujours la conséquence de l'exécution d'une instruction que le processeur ne peut pas ou ne sait pas faire. ''' }}} 1. Un processus dispose d'un espace d'adressage pour s'exécuter, qu'y met-il ? {{{#!protected ------------------------------------------------------------------ ''' * Le processus y met son code et ses données globales et, nous le verrons plus tard, ses données dynamiques dans des segments obtenus par `malloc()` ou `mmap()`. Le processus y met aussi les piles d'exécution de ces threads. Dans l'implémentation actuelle du système, ces piles sont dans les variables globales, mais dans un vrai système, elles seraient allouées dynamiquement. ''' }}} 1. Dans un fichier exécutable, avant qu'il ne soit chargé en mémoire, on trouve le code du programme et les données globales. Est-ce qu'il y a aussi les piles d'exécution des threads ? Justifiez votre réponse. {{{#!protected ------------------------------------------------------------------ ''' * Non, en principe non, puisque les piles sont créées à la volée à chaque création des threads. Mais, dans la version actuelle du système, qui n'a pas encore de service de mémoire dynamique, les piles des threads sont dans des variables globales de type `struct thread_s`, alors on peut se demander si elles sont dans le fichier. La réponse est non, parce que les variables globales thread de type `struct thread_s` ne sont pas initialisées. Elles sont donc dans une section `BSS` qui n'est pas vraiment dans le fichier objet. ''' }}} 1. Un thread de processus informatique représente une exécution de ce processus. Il est défini par une pile d'exécution pour ses fonctions, un état des registres du processeur et des propriétés comme un état d'exécution (RUNNING, READY, DEAD, et d'autres que nous verront plus tard). Combien de threads a-t-on par processus au minimum et au maximum ? {{{#!protected ------------------------------------------------------------------ ''' * On en a au moins 1 dont la fonction principale est `main()`. Le nombre maximum est défini dans le système d'exploitation pour notre cas, mais plus généralement, il est dépend de la quantité de mémoire disponible, car chaque thread utilise une pile qui peut être grande. ''' }}} 1. Tous les threads d'un processus se partagent le même espace d'adressage, et donc le même code, les mêmes variables globales, les mêmes variables dynamiques (nous les verrons dans un prochain cours). Est-ce qu'ils se partagent aussi les piles ? {{{#!protected ------------------------------------------------------------------ ''' * Non, chaque thread a sa propre pile. On peut se dire qu'ils partagent leur pile, si on imagine créer une variable `VA` locale dans la pile d'un thread `TA`et que l'on passe l'adresse de cette variable locale `VA` à un autre thread `TB`. C'est possible, mais ce n'est pas recommandé, car certains OS interdisent cette pratique. Pour faire communiquer deux threads, il faut passer par des variables globales. Nous verrons cela plus tard dans les prochaines semaines. ''' }}} 1. Lorsque l'on crée un nouveau thread (un nouveau fil d'exécution du processus), il faut indiquer sa fonction principale, c'est-à-dire la fonction par laquelle qu'il doit exécuter. a. Est-ce que le nouveau thread pourra appeler d'autres fonctions ? {{{#!protected ------------------------------------------------------------------ ''' * Bien sûr, rien ne l'en empêche, c'est généralement le cas. Si vous appeler par exemple `printf()` dans votre thread, c'est un appel de fonction. ''' }}} a. Est-ce qu'on peut créer deux threads avec la même fonction principale ? {{{#!protected ------------------------------------------------------------------ ''' * Bien sûr, plusieurs threads peuvent se partager le même code. Ils n'auront pas la même pile et donc il n'auront pas la même histoire. ''' }}} a. Combien d'arguments la fonction principale d'un thread peut-elle prendre et de quel type ? {{{#!protected ------------------------------------------------------------------ ''' * Le prototype de la fonction principale d'un thread est imposé par la fonction `thread_create()`. Une fonction principale de thread prend un seul argument de type `void *` et elle rend un `void *`. Si on veut passer plusieurs arguments, il faut les mettre dans une structure et passer le pointeur sur cette structure. * Il y a quand même une exception pour le thread `main()` créé systématiquement au démarrage du processus. Comme vous le savez, la fonction `main()` prend jusqu'à 3 arguments : `int argc`, `char *argv[]` et un moins connu `char *arge[]`. Dans notre système `main()` n'a pas d'arguments, alors que normalement c'est grâce au shell que l'utilisateur peut définir les arguments `argc`, `argv` et `arge`, mais nous n'avons pas encore de shell. La fonction `main()` rend un `int` et non pas un `void *`. ''' }}} a. Que se passe-t-il lorsqu'on sort de la fonction principale d"un thread ? {{{#!protected ------------------------------------------------------------------ ''' * Ça dépend de quel thread on parle, c'est soit le thread `main()` de l'application, soit un thread standard créé par l'application avec `thread_create()`. * Le thread `main()` est lancé par la fonction `_start()` qui est la toute première fonction de l'application. Quand on sort de `main()`, on retourne dans `_start()` qui doit stopper l'application en exécutant la fonction `exit()` avec la valeur de retour de `main()` en argument (un int). * Un thread standard est lancé par la fonction `thread_start()`. Cette fonction `thread_start()` lance la fonction principale de thread en lui donnant son argument (la fonction principale et son argument sont des arguments de `thread_start()`). Quand on sort de la fonction principale du thread, on revient dans `thread_start()`, laquelle va appeler `thread_exit()` avec la valeur de retour de la fonction principale du thread en argument (un void *). ''' }}} 1. L'exécution en temps partagé est un mécanisme permettant d'exécuter plusieurs threads à tour de rôle sur le même processeur. Comment s'appelle le service du noyau chargé du changement de thread ? {{{#!protected ------------------------------------------------------------------ ''' * C'est l'ordonnanceur ou, en anglais, le scheduler. Il s'appelle ainsi parce que son rôle est de donner le processeur à tous les threads de l'application en respectant une politique d'ordonnancement. ''' }}} 1. La phase de changement de thread a une certaine durée, c'est un temps perdu du point de vue de l'application. Comment nomme-t-on cette phase pour indiquer que c'est un temps perdu ? {{{#!protected ------------------------------------------------------------------ ''' * C'est un ''thread switching overhead cost'', ce qui signifie ''frais de commutation''. C'est le temps que le noyau met pour sélectionner un nouveau thread (avec l'ordonnanceur), sauver le contexte du thread entrant et charger le contexte du thread entrant. Nous n'avons pas plusieurs processus dans notre application, cet ''overhead'' est donc assez court, car tous les threads partagent le même espace d'adressage, mais quand il y a plusieurs processus, le coût de changement de thread de 2 processus distincts est beaucoup plus cher, car il faut vider (''flush'') les caches, nous en parlerons au prochain cours. ''' }}} 1. Pour l'exécution en temps partagé, le noyau applique une politique, laquelle définit l'ordre d'exécution. Si les threads sont toujours prêts à être exécutés et que le noyau les exécute à tour de rôle de manière équitable, comment se nomme cette politique ? {{{#!protected ------------------------------------------------------------------ ''' * C'est une politique ''round robin'' ou ''robin des bois'', ou à tour de rôle équitablement. Attention à ne pas la confondre avec la politique ''fifo'', dans cette dernière ce que l'ordonnanceur, c'est le prochain thread entrant sera celui qui est sorti depuis le plus longtemps, ou dit autrement, quand un processeur sort (c.-à-d. perd le processeur), il sera le dernier à le regagner. Vous allez dire que c'est du ''round robin'', mais non, dans la politique ''fifo'', ce sont les threads eux-mêmes qui décident quand ils rendent le processeur, par un appel explicite à `thread_yield()` ou lorsqu'il demande une ressource indisponible. Il n'y a pas de recherche d'équité alors qu'avec la politique ''round robin'', le noyau utilise un timer pour que chaque thread dispose du même temps d'exécution en imposant des `thread_yield()`. ''' }}} 1. Dans cette politique équitable, quelle est la fréquence type de changement de thread ? Donnez une justification. {{{#!protected ------------------------------------------------------------------ ''' * Ça dépend un peu de la fréquence du processeur. Il faut que la commutation soit assez rapide pour donner l'illusion du parallélisme (l'impression que tous les threads s'exécutent en même temps), mais pas trop à cause de l'overhead de changement de thread. La réponse est entre 10 et 100Hz. Plus la fréquence du processeur est élevée, plus la fréquence de commutation peut être rapide. À 1GHz, un processeur exécute 10 millions de cycles en 10ms (100Hz), si l'overhead de changement de thread est de 1000 cycles (un ordre de grandeur), l'overhead prend 0.01% du temps d'exécution du processeur, c'est négligeable. ''' }}} 1. Le mécanisme de changement de thread (dont vous avez donné le nom précédemment) se déroule en 3 étapes, quelle que soit la politique suivie. Quelles sont ces étapes ? {{{#!protected ------------------------------------------------------------------ ''' * L'ordonnanceur réalise : * l'élection du thread entrant qu'il choisit parmi tous les threads prêts en suivant une politique explicite. Pour le ''round robin'', l'élu sera celui qui attend depuis le plus longtemps. * La sauvegarde du contexte du thread sortant. * Le chargement du contexte du thread entrant. ''' }}} 1. Comment se nomme la fonction qui provoque la perte du processeur par le thread en cours au profit d'un nouveau thread ? {{{#!protected ------------------------------------------------------------------ ''' * C'est la fonction `thread_yield()`. `yield` signifie `cession`, le thread cède ou lâche le processeur. ''' }}} 1. Qu'est-ce qui provoque le changement de thread ? {{{#!protected ------------------------------------------------------------------ ''' * C'est l'IRQ du timer pour respecter la politique ''round robin'', mais pas seulement, on retire le processeur aux threads bloqués, parce qu'ils ont demandé une ressource au noyau, mais que cette ressource n'est pas disponible. Le thread peut aussi demander à rendre le processeur. ''' }}} 1. Dans le mécanisme de changement de thread, l'une des étapes est la sauvegarde du contexte, est-ce la même chose qu'un contexte de fonction ? Dites de quoi il est composé. {{{#!protected ------------------------------------------------------------------ ''' * Alors non, il faut vraiment faire attention au vocabulaire. Le contexte d'un thread et le contexte d'une fonction sont deux concepts très différents. Cela signifie que la question ''Qu'est-ce qu'un contexte ?'' n'a pas une seule réponse et pour être précis, il faut demander ''contexte de quoi ?''. * Le contexte d'une fonction est un segment d'adresse dans la pile d'exécution, dans lequel la fonction * sauvegarde la valeur des registres persistants afin des restaurer avant de retourner dans la fonction appelante ; * alloue ses variables locales ; * alloue la place pour les arguments des fonctions qu'elle appelle. * Une fonction accède exclusivement à son propre contexte et à la partie des arguments du contexte de la fonction appelante. * Le contexte d'un thread, c'est l'état des registres du processeur pendant que le thread s'exécute. Parmi les registres, il y a le registre `PC` (Program Counter) qui pointe vers l'instruction en cours d'exécution, le registre `SP` qui pointe sur la dernière case occupée dans la pile d'exécution du thread, le registre `CO_SR` (Status Register) qui indique essentiellement le mode d'exécution du MIPS et il y a tous les registres de travail du thread. ''' }}} 1. Où est sauvé le contexte d'un thread ? Que pouvez-vous dire de la fonction de sauvegarde ? (langage, prototype, valeur de retour, etc.) {{{#!protected ------------------------------------------------------------------ ''' * Pour notre système, c'est dans un tableau présent dans la structure de données du thread (`struct thread_s`). * C'est une fonction en fonction en assembleur parce qu'elle est spécifique au processeur, on ne pourrait pas l'écrire en C. * Elle prend en argument un pointeur vers le tableau de sauvegarde. C'est un prototype générique qui fait partie de la HAL (Hardware Abstraction Layer). * Elle rend 1 quand elle vient juste de faire la sauvegarde du contexte du thread en cours. ''' }}} 1. Chaque thread dispose de sa propre pile d'exécution, doit-on aussi sauver la pile lors des changements de thread ? {{{#!protected ------------------------------------------------------------------ ''' * Non, elle reste en mémoire, mais lors des changements de thread, on change simplement le pointeur de pile, ainsi on change de pile. ''' }}} 1. Après qu'un thread a été élu et que son contexte a été chargé dans le processeur, donnez le nom de la fonction responsable du chargement et dites où elle retourne ? (attention, il y a deux cas) {{{#!protected ------------------------------------------------------------------ ''' * C'est la fonction `thread_load()` qui se charge du chargement de la restauration du contexte du thread entrant (nouvellement élu). * Quand on sort de la fonction `thread_load()`, il y a en effet 2 cas: * Le thread entrant n'a jamais été élu. Dans ce cas, le `jr $31` va nous faire entrer dans la fonction `thread_bootstrap()` dont le but est lancer le thread en allant chercher les informations dans la structure `thread_s` du thread nouvellement élu, à savoir * la fonction de démarrage `_start()` ou `thread_start()` * la fonction principale du thread (uniquement pour les threads standards, c'est inutile pour le thread `main()`, on sait que c'est `main()`) * l'argument du thread (uniquement pour les threads standards, c'est inutile pour le thread `main()`, ce sont en principe des arguments de la ligne de commande (on ne voit pas ça pour le moment). * Le thread avait déjà été élu et donc il avait perdu le processeur et appelé `thread_save()`. En conséqie ''' }}} == A.2. Questions sur l'implémentation 1. Quelles sont les fonctions de l'API utilisateur des threads et les états de threads ? Indiquer les changements d'état provoqué par l'appel des fonctions de cette API. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. La structure `thread_s` rassemble les propriétés du thread, sa pile et le tableau de sauvegarde de son contexte. Cette structure est, dans l'état actuel du code` entièrement dans dans le segment des données globales de l'application. Pouvez-vous justifier cette situation et en discuter ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Le tableau de sauvegarde du contexte d'un thread est initialisé avec des valeurs qui seront chargées dans les registres du processeur au premier chargement du thread. Tous les registres n'ont pas besoin d'être initialisés avec une valeur. Seuls les registres `$c0_sr` (`$12` du coprocesseur système) , `$sp` (`$29` des GPR) et `$ra` (`$31` des GPR) ont besoin d'avoir une valeur choisie. Pourquoi ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. `$c0_sr` est initialisé avec `0x413`, dite pourquoi. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. La fonction `sched_switch()` appelle d'abord l'électeur de thread qui choisit le thread entrant (qui gagne le processeur), puis `sched_switch()` sauve le contexte du thread sortant (qui perd le processeur) et charge le contexte du thread entrant, enfin `sched_switch()` change l'état du thread entrant à `RUNNING`. `sched_switch()` est appelée par `thread_yield()`. Pouvez-vous expliquer pourquoi avoir créé `sched_switch()` ? Ce n'est pas évident au premier abord, mais il y a une raison. {{{#!c void sched_switch (void) { // int th_curr = thread_current_idx; // n° du thread courant dans thread_tab int th_next = sched_elect (); // demande le numéro du prochain thread if (th_next != th_curr) { // Si c'est le même thread, ne rien faire ! if (thread_save (thread_tab[th_curr]->context)) { // sauve le ctx du thread sortant et rend 1 thread_current_idx = th_next; // mise à jour de thread_current_idx thread_load (thread_tab[th_next]->context); // chargement de contexte & sortie par jr $31 } // donc de thread_save(), mais qui rend 0 } thread_tab[thread_current_idx]->state= TH_STATE_RUNNING; // the thread choisi est dans l'état } int thread_yield (void) { thread_tab[thread_current_idx]->state = TH_STATE_READY; // état futur du thread sortant sched_switch (); // changement de threads (ou pas) return 0; }}} {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Quand un thread est élu pour la première fois, à la sortie de `thread_load()`, on appelle la fonction `thread_bootstrat()`. Retrouvez dans les transparents du cours les étapes qui vont mener à l'exécution de la fonction principale du thread élu, et expliquez-les. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Un thread peut perdre le processeur pour 3 raisons (dans la version actuelle du code), quelles sont ces raisons ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Quand un thread **TS** perd le processeur pour une raison X à la date `T`, il entre dans le noyau par kentry, puis il y a une séquence d'appel de fonction jusqu'à la fonction `thread_load()` du thread entrant **TE**. Lorsqu'on sort de ce `thread_load()`, on est dans le nouveau thread **TE**. Plus tard, le thread **TS** sera élu à son tour et gagnera à nouveau le processeur en sortant lui aussi d'un `thread_load()`. En conséquence, on sortira de la séquence des appels qu'il y avait eu à la date `T`.\\Expliquez, en vous appuyant sur la description du comportement précédent, pourquoi on ne sauve pas les registres temporaires dans le contexte des threads. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Dans le cours, nous suivons l'exécution du code au démarrage (vers le slide 37), nous pouvons voir que la fonction `kinit()` fait 3 choses importantes : (1) initialiser à `0` la section `BSS` (contenant les variables globales non explicitement initialisées dans le programme), (2) demander à l'architecture de s'initialiser et (3) lancer la première (et ici seule) application. {{{#!c void kinit (void) { kprintf (banner); // 1 extern int __bss_origin, __bss_end; for (int *a = &__bss_origin; a != &__bss_end; *a++ = 0); // 2 arch_init(20000); // init architecture ; arg=tick // 3 extern thread_t _main_thread; // thread struct pour main() extern int _start; // _start() point d'entrée app. thread_create_kernel (&_main_thread, 0, 0, (int)&_start); thread_load (_main_thread.context); kpanic(); } }}} a. Où sont définis les symboles `__bss_origin`, `__bss_end`, `__main_thread`, `_start` et quel est leur type ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} a. Dites ce que sont les arguments `2` et `3` de `thread_kernel()` et pourquoi, ici, on les met à `0`. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} a. Que se passe-t-il quand on sort de `thread_load()`et pourquoi avoir mis l'appel à `kpanic()` ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} a. Dans quelle pile s'exécute la fonction `kinit()` ? dans quelle section est-elle ? Pourquoi elle n'est que temporaire ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} a. Pour le chargement de thread `main()` avec `thread_load (_main_thread.context)`, on initialise les registres `$16` à `$23`, `$30`, `$c0_EPC`, est-utile ? Si oui pourquoi ? Sinon, pourquoi faire ces initialisations ? {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. Dans le deuxième TME2, vous avez dû modifier le code `syscall_handler` (gestionnaire de syscalls) pour le rendre interruptible. En effet, lorsque l'application demande un service au noyau, mais que le noyau ne peut le rendre (comme la lecture d'une touche du clavier). {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} 1. {{{#!protected ------------------------------------------------------------------ ''' * ''' }}} = B. Travaux pratiques