Les threads sont un concept important dans l’architecture logicielle. Ils permettent d’exécuter du code en parallèle au sein d’un même programme. Ils sont apparu pour la première fois dans la littérature scientifique en 1967 par Victor Alexander Vyssotsky1.
Ils désignent le plus petit ensemble d’instructions indépendent executé par le scheduler. Cependant avec l’apparition des processeurs à plusieurs coeurs, maintenant ils désignent plus le fait de paralléliser des tâches au sein d’un programme.

Une des implémentations les plus connues est celle de la glibc. Elle implémente la norme POSIX Thread2 pour plusieurs plateformes. Dans cet article, nous nous intéresserons à son implémentation pour Linux.

La structure pthread.

Avant toute chose, il faut présenter la struct pthread.

Cette structure représente l’état du thread et est utilisée en interne. Elle contient une multitude d’informations.

Elle est composé d’un header qui est unique pour chaque plateforme. Par exemple pour l’architecture x86_64, elle contient un pointeur sur sa structure pthread, des pointeurs à des emplacements mémoires spécifiques au thread, etc. Sur l’architecture ARM64, elle contient uniquement un pointeur dtv et un pointeur sur une zone mémoire “privée”.

Elle contient beaucoup d’informations utiles au bon déroulement du thread comme par exemple :

Cette structure est très complète et représente le coeur d’un thread.

Comment un thread est créé ?

1
2
3
4
int pthread_create(pthread_t *thread,
                          const pthread_attr_t *attr,
                          void *(*start_routine)(void *),
                          void *arg);

C’est cette fonction qui va créer le thread. start_routine est un pointeur de fonction qui pointe vers la fonction qui va être démarrée par le thread. pthread_t est un alias vers unsigned long int, d’après la documentation c’est l’identifiant du thread. Cependant, c’est plus qu’un simple identifiant, c’est l’adresse mémoire où se situe la structure pthread ! Concretement, quand la glibc a besoin de trouver la structure pthread à partir de son identifiant, elle va le caster d’un unsigned long int vers une struct pthread * 3. Ce moyen permet facilement de récuperer cette structure.

Mais alors qu’est-ce qui se passe derrière cette fonction ?

Les différentes allocations de mémoires.

Pour que le thread fonctionne correctement, avant sa création, il faut lui allouer de la mémoire. Elle comporte 3 parties distinctes : la guard, la stack et le TLS (Thread Local Storage).

Composition de la stack

Avant d’en allouer, la glibc va regarder dans son cache interne :

  • Si une zone mémoire équivalente est disponible, alors la glibc va l’utiliser.
  • Sinon, la glibc va faire appel au syscall mmap pour l’allouer.

Par défault, sa taille est donnée par getrlimit(2). Sur mon système, elle est de 8388608 octets.

Le Thread Local Storage

En haut de la zone mémoire se trouve le Thread Local Storage. Elle permet d’avoir des variables locales au thread. La glibc va y placer en premier la structure pthread. La particularité cet emplacement est que pour les processeurs x86, le registre fs va contenir l’adresse la plus haute, c’est-à-dire l’adresse où se situe pthread. À la base, ce registre servait pour mettre en place la GDT. Cependant, dans les noyaux linux récents, elle n’est quasiment plus utilisée de par l’abandon de la segmentation au profit de la pagination. Il sert maintenant à accéder facilement à la structure pthread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#define THREAD_SELF                                                            \
  ({                                                                           \
    struct pthread *__self;                                                    \
    asm("mov %%fs:%c1,%0"                                                      \
        : "=r"(__self)                                                         \
        : "i"(offsetof(struct pthread, header.self)));                         \
    __self;                                                                    \
  })
// sysdeps/x86_64/nptl/tls.h

pthread_t pthread_self(void) { return (pthread_t)THREAD_SELF; }
// nptl/thread_self.c

Comme on peut le voir, le define va chercher l’adresse à l’offset $10$, ce qui correspond au champ self de la structure tcbhead_t. Elle contient l’adresse mémoire de la structure pthread. Mais comment est mis à jour le registre fs ? Il a deux manières de le mettre à jour :

  • Pour le thread principal (ou le main thread), c’est grace au syscall arch_prctl(2) avec l’argument ARCH_SET_FS.
  • Pour les threads créés grace pthread_create, c’est le syscall clone(2) qui le met en place.

Cela permet facilement et rapidement d’accéder à son propre thread. Il existe plusieurs cas où il est nécessaire d’accéder à son propre identifiant, on peut citer par exemple les logs qu’il est plus facile d’analyser avec le thread id.

La stack d’un thread

Chaque thread à une stack qui lui est dédiée. C’est un espace mémoire pour stocker les variables locales, notamment dans les fonctions. Sur les processeurs x86, elle grossit vers les adresses basses (c’est-à-dire vers $0x00000000$).

La guard d’un thread

La guard est un garde fou d’environ une page où on ne peut ni lire ni écrire. Elle sert principalement à éviter que la stack ne déborde sur les autres zones de la mémoire.

Le sycall clone.

Le syscall qui va “créer” le thread est clone3(2). Il va dupliquer le processus en cours et va permettre de partager certaines ressources au processus fils. Ainsi le processus fils aura accès aux mêmes file descriptor, à la même address space, la même table de signaux que son processus père, etc.4 Il va aussi indiquer au kernel où sera la stack et la TLS du nouveau thread. On pourrait penser que logiquement, le fils n’aura pas le même PID que son père car le processus est “cloné”. Il s’avère que le PID dans l’espace utilisateur n’a pas le même sens dans l’espace noyau. Dans cet espace, il se nomme TGID (ou Thread Group Identier) mais porte le nom de PID dans l’espace utilisateur. Cependant, chaque thread créé par clone3(2) a des PID différents mais ils ont les même TGID dans l’espace noyau. Cette différence est apparue à la version $2.4$ du noyau Linux afin de supporter entièrement les threads POSIX. En effet, les threads noyaux ne respectent pas entièrement cette norme notamment sur la gestion des signeaux ou les primitives de syncronisation. RedHat, par la suite, a écrit une norme pour les threads noyau appelée NPTL (Native POSIX Thread Library). Ce contraste vient donc de l’implémentation de deux normes différentes.

La syncronisation entre les threads.

Tout repose sur le syscall futex(2) (Fast User-Space Locking) :

1
2
3
long syscall(SYS_futex, uint32_t *uaddr, int futex_op, uint32_t val,
                    const struct timespec *timeout,   /* or: uint32_t val2 */
                    uint32_t *uaddr2, uint32_t val3);

Il va permettre de bloquer le thread en cours jusqu’à qu’une certaine condition devienne vraie.

Le premier paramètre (uaddr) est une adresse mémoire qui va contenir le futex.
On peut voir ça comme un jeton de $32$ bits (même pour les plateformes $64$ bits) situé dans de la mémoire partagée.

La glibc utilise principalement deux opérations:

  • FUTEX_WAIT_BITSET va faire attendre le thread jusqu’à ce qu’ont le réveille
  • FUTEX_WAKE va le réveiller.

Une bonne analogie est celle d’une notification. Le thread est “endormi” (FUTEX_WAIT) jusqu’à ce qu’on le notifie (FUTEX_WAKE). Cependant, on ne peut le notifier qu’avec le bon jeton (futex).

FUTEX_WAIT_BITSET offre plus de contrôle que FUTEX_WAIT sur quels événements peuvent le réveiller. Généralement la Glibc utilise le flag FUTEX_BITSET_MATCH_ANY pour se réveiller quel que soit l’évènement déclenché.

La fin d’un thread…

Pour attendre qu’un thread se finisse, on peut utiliser la fonction pthread_join(3). Pour ça, elle va attendre que le thread mourant le réveille grace au syscall futex. Le futex utilisé est localisé dans tid de la structure pthread. Cependant, le thread mourant ne réveillera jamais la fonction pthread_join(3)5. Aucun n’appelle au syscall futex ne sera effectué.

Quelle est donc cette sorcellerie du diable ?

Le tour de passe-passe se trouve dans la création du thread. L’un des paramètres de clone3(2) quand le thread va être créé est CLONE_CHILD_SETTID. Ce paramètre va indiquer au kernel où écrire l’identifiant de thread. En l’occurrence, la glibc lui indique de l’écrire dans le champ tid, qui aussi utilisé comme futex par la fonction pthread_join(3). De plus, le paramètre CLONE_CHILD_CLEARTID est passé à clone3(2). Quand le thread va mourir, grace à ce paramètre, le kernel va réaliser deux actions :

  • Effacer l’identifiant du thread de la mémoire
  • Envoyer une notification de réveil où se situait l’identifiant du thread.

C’est cette deuxième action qui va réveiller la fonction pthread_join(3). Le thread peut mourir tranquillement.

Conclusion

L’implémentation de la glibc des thread POSIX est complète et efficace sur Linux.

Elle est facilement utilisable par les développeurs et offre beaucoup de contrôle sur les threads. De plus, elle est souvent utilisée derrière la class thread en C++.

Ainsi dans cet article, nous avons pu voir la vie d’un thread, de sa naissance jusqu’à sa mort en passant par son principal moyen de synchronisation. Cependant, la glibc a dû faire face à d’autre problématiques notamment une très important qui pourrait faire l’objet d’un futur article : la réorganisation des instructions par le CPU ou le compilateur…

Annexes


  1. https://en.wikipedia.org/wiki/Victor_A._Vyssotsky “Victor Alexander Vyssotsky” ↩︎

  2. https://standards.ieee.org/ieee/1003.1c/1393/ “Standard for Information Technology–Portable Operating System Interface (POSIX(TM)) - System Application Program Interface (API) Amendment 2: Threads Extension (C Language)” ↩︎

  3. Voici un example ici : https://elixir.bootlin.com/glibc/glibc-2.37/source/nptl/pthread_join_common.c#L40 ↩︎

  4. Les flags utilisés pour clone3(2) sont trouvable ici : https://elixir.bootlin.com/glibc/glibc-2.36/source/nptl/pthread_create.c#L277 ↩︎

  5. Un strace d’un cycle complet d’un thread :

     clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7fee4edff910, parent_tid=0x7fee4edff910, exit_signal=0, stack=0x7fee4e5ff000, stack_size=0x7fff00, tls=0x7fee4edff640}strace: Process 78549 attached
     => {parent_tid=[78549]}, 88) = 78549
    ...
    [pid 78548] futex(0x7fee4edff910, FUTEX_WAIT_BITSET|FUTEX_CLOCK_REALTIME, 78549, NULL, FUTEX_BITSET_MATCH_ANY <unfinished ...>
    ...
    [pid 78549] clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=10, tv_nsec=0}, 0x7fee4edfee00) = 0
    ...
    [pid 78549] exit(0)                     = ?
    [pid 78548] <... futex resumed>)        = 0
    [pid 78549] +++ exited with 0 +++
    exit_group(0)                           = ?
    +++ exited with 0 +++
    

    On voit bien que le futex pour réveiller le thread n’est jamais apelé. ↩︎