Sous-sections
Le multithread était à la base créé pour les systèmes
informatiques basés sur plusieurs processeurs. L'idée était de
donner un groupe d'information à traiter à chaque processeur. Le
système d'exploitation a donc un rôle primordial dans le
multithread car c'est lui qui définit la planification des processeurs.
Le principe de l'exécution en parallèle est quelque chose de
relativement nouveau et fonctionne aussi très bien sur des
configurations munies seulement d'un processeur. Pour cela le
système d'exploitation regroupe un certain nombre de processus qui
sont les programmes en cours d'exécution regroupant eux-mêmes des
threads.
L'OS accorde un certain laps de temps pour l'exécution d'un thread
en fonction de ses priorités. Avec ce principe on comprend mieux
ce que signifie l'exécution en parallèle bien qu'il ne s'agisse
pas vraiment de parallélisme dans un système monoprocesseur.
Un thread est une séquence d'instructions exécutées à l'intérieur
du contexte d'un processus. Par exemple dans notre serveur, on
trouve à l'intérieur du thread canal, exclusivement des
instructions visant à gérer les canaux disponibles.
C'est un programme codé en séquentiel, c'est-à-dire que les
instructions sont exécutées les unes après les autres. Il y a un
thread pour un processus.
C'est un programme utilisant plusieurs threads au sein d'un même
processus et qui partage un même espace mémoire.
Pour un programme multithread on parle de MT-Safe lorsqu'il
fonctionne correctement alors qu'il partage des données
"sensibles". Pour une librairie c'est à peu près le même principe.
Elle est MT-Safe lorsque chaque appel de fonction
n'engendre aucune perte de donnée dans le programme.
Comme leur nom l'indique les threads User se situent au niveau de
l'utilisateur c'est-à-dire au niveau du programme. Les
User-level Threads ne sont visibles qu'à l'intérieur du
processus dans lequel ils partagent toutes les ressources du
processus (espace d'adressage ...) Chacun de ces threads possède
les informations suivantes :
- *
- Un unique identificateur (Thread Id)
- *
- Etat du registre.
- *
- Ressource nécessaire pour supporter un flux de contrôle telle que la pile.
- *
- Une priorité et une planification.
- *
- Un emplacement spécial pour stocker des données particulières telles que les numéros d'erreur.
Ils sont rapides à synchroniser lorsque la synchronisation
s'effectue au niveau utilisateur et non au niveau du kernel sans
dépendance avec le système d'exploitation. Facilement gérable par
la librairie des threads. Ce sont les threads gérés par les
programmeurs d'applications.
Les Kernel-level Thread comme leur nom l'indique se situent
au niveau du kernel. Leur rôle est de faire le lien entre les
User-level thread et le matériel (CPU). On les trouve
beaucoup plus souvent sous le nom de Lightweight
Process(LWPs). Il n'y a pas de mappage direct entre le thread
user et le LWP. Le thread user doit être capable de changer
librement de LWP dans la zone des LWPs. C'est le système
d'exploitation qui décide quel LWP doit utiliser quel processeur
et quand. Ils sont donc gérés par l'OS et non par le programme.
Ce sont des threads qui sont gérés, planifiés dans l'espace des
PWLs. Les Unbound Threads sont plus légers que les PWLs. De
plus, seulement si cela est nécessaire, un thread utilisateur peut
être attaché définitivement à un PWL, c'est un bound thread.
Il existe une énorme différence dans la création et la
synchronisation entre les bound threads et les
unbound threads.
Tableau:
Ce tableau illustre les différences à la création
|
Création |
Micro Secondes |
Rapport |
| Unbound Thread |
101.0 |
- |
| Bound Thread |
422.0 |
4.2 |
| Fork() |
3057.0 |
30.3 |
|
Tableau:
Ce tableau montre lui les différences à la synchronisation
|
Création |
Micro Secondes |
Rapport |
| Unbound Thread |
1.8 |
- |
| Bound Thread |
48. |
26.7 |
| Fork() |
105. |
58.3 |
|
Il est donc important d'utiliser les Unbound thread le plus
souvent possible. Les Unbound threads sont les threads
créés par défaut.
Tout d'abord pour créer un programme multithread, le programmeur
doit raisonner en pensant au model multithread sans en abuser.
C'est-à-dire qu'il est inutile de mettre des threads partout dans
le programme, mais il faut savoir en placer là où il y a
nécessité. Un programme doit utiliser les threads pour faire
apparaître les activités parallèles à travers :
- 1)
- La décomposition géométrique, c'est-à-dire répartir les données que le programme utilise, et faire en sorte que chaque processeur s'occupe d'une zone de calcul prédéfinie. La décomposition géométrique découpe le projet en grosses parties.
- 2)
- La décomposition des fonctions, c'est-à-dire répartir les fonctions de telle sorte que chaque processeur s'occupe si possible d'une zone de calcul. Dans cette décomposition le projet est encore redivisé.
C'est après avoir effectué ces décompositions qu'on peut savoir quelle partie du projet aura besoin de threads.
La librairie des threads est indispensable pour :
- *
- Créer et assigner des threads aux activités parallèles
de l'application
- *
- Organiser la coopération et la synchronisation des
threads lorsqu'elles accèdent à des données partagées ou des
ressources du processus.
- *
- Faire en sorte que le programme multithread utilise le
mieux possible une machine multiprocesseur.
Pour pouvoir utiliser les threads dans notre code, il y a un
certain nombre de fonctions à savoir utiliser. Il y a la création
de thread, la destruction, la mise en pause, la reprise et la
synchronisation... Pour ce qui est de la synchronisation, il faut
savoir utiliser d'autres objets qu'on nomme objets de
synchronisation. Il peut s'agir des mutex ou des sémaphores. Il
faut alors savoir les créer et les implémenter là où il faut dans
le programme.
Pour créer un thread sous Windows, il faut appeler la fonction
CreateThread qui se trouve dans l'API Windows. Tout ce qui va être
dit ne sera vrai que sous le système d'exploitation de Microsoft.
En ce qui concerne les autres systèmes d'exploitation, un
paragraphe sera consacré au portage du serveur sous Unix. La
fonction CreateThread() prend 6 paramètres.
- *
- Le premier est la taille de la pile qui sera attribuée
au thread.
- *
- Le second est l'attribut de sécurité, qui sert à définir
par exemple qui à le droit de détruire le thread.
- *
- Le troisième est l'adresse de démarrage du thread, on
passe à ce paramètre l'adresse d'une fonction du programme.
- *
- Le quatrième représente les valeurs communiquées au
point de démarrage du thread, c'est-à-dire les paramètres de la
fonction principale du thread.
- *
- Le cinquième indique la priorité du thread. Ce paramètre
est utile pour le système d'exploitation.
- *
- Le dernier paramètre sert à stocker un identificateur de
thread.
Cette fonction retourne 0 en cas d'échec ou le handle du thread en
cas de réussite.
Dans le serveur YooGoo la création de thread intervient au
chargement du serveur pour la création des threads canal et user.
La partie communication, Winsock, se trouve également dans un
thread. Par ailleurs la thread Winsock a la possibilité de créer
des threads lorsque le nombre de connectés dépasse un multiple de
64. Notre serveur compte donc au minimum 2 threads.
Pour sortir du thread en cours, il est possible d'utiliser la
fonction ExitThread(), qui ne prend aucun paramètre et qui
est l'équivalent d'un break.
Mais parfois il est utile de pouvoir terminer un thread sur ordre
d'un autre. Par exemple pour l'extinction du serveur, le thread
principal donnera lui-même l'ordre de détruire les threads encore
actifs. Pour faire cela il utilise la fonction
TerminateThread().
Cette fonction est assez simple et ne prend que 2 paramètres :
- *
- L'handle du thread à terminer
- *
- Le code de sorti du thread concerné.
Les valeurs de retour sont TRUE ou FALSE. Le code de retour est
donc une valeur booléenne définit dans la librairie winbase.h.
Dans le serveur la destruction ne s'effectue donc que lorsque le
serveur s'éteint ou lorsque le nombre d'utilisateur passe en
dessous d'un multiple de 64.
La priorité d'un thread est quelque chose de vraiment important
car c'est une information qui interagit directement avec le
système d'exploitation. Il est possible de voir les différentes
priorités attribuées à des programmes à travers le gestionnaire
des taches de Windows par exemple. Dans notre projet, il n'a pas
encore été pensé de définir une priorité aux threads, mais cela
pourrait peut-être changé. Le programme qui est présenté à la
soutenance utilise lui le système de priorité.
Pour définir une priorité, le plus simple est d'attribuer une
priorité à la création du thread car il n'est pas recommandé de
changer la priorité d'un thread durant son exécution. Cependant
comme le montre le petit programme test, cela est tout à fait
possible. Pour cela, il faut utiliser la fonction
SetThreadPriority() qui prend les paramètres suivants :
- *
- Le handle du thread
- *
- La valeur de la priorité
Il existe des constantes textes déjà préfaites dans la librairie
winbase.h mais il faut savoir que le niveau de priorité sous
Windows s'étale jusqu'à la valeur 31 qui correspond à la valeur
RealTime fortement déconseillée car il devient alors quasiment
impossible d'effectuer quelque chose d'autre avec l'OS pendant ce
temps là. La priorité peut également être négative pour désigner
une utilité moindre.
Les threads pourront nous aider en ce qui concerne les
statistiques. En effet les threads regroupent un grand nombre
d'informations assez importantes aussi bien pour les personnes qui
utiliseront YooGoo que pour nous programmeurs. Tout d'abord à
l'intérieur du processus, il est possible de savoir pour chaque
thread :
- *
- Sa date de création
- *
- Le temps d'occupation du processeur
- *
- Et le mode dans lequel il a utiliser le processeur (Utilisateur ou
Superviseur)
Toutes ces informations utilisées intelligemment permettront par
exemple de savoir depuis combien de temps le serveur YooGoo
tourne, quel thread est le plus souvent demandé...
Pour obtenir toutes ces informations, il suffit d'appeler la
fonction GetThreadTimes() avec les paramètres suivants :
- *
- L'handle du thread
- *
- Variable qui stockera la date de création du thread
- *
- Variable qui stockera la date de destruction du thread
- *
- Variable qui stockera le temps passé en mode superviseur
- *
- Variable qui stockera le temps passé en mode
utilisateur
La valeur de retour est un booléen qui est à TRUE si l'opération
s'est bien passée.
L'un des objectifs de chaque programme et particulièrement du
notre est de faire en sorte que lorsque le serveur n'ait rien à
faire, qu'il n'utilise pas 100% du processeur, à travers une
boucle infinie par exemple. Pour cela il faut utiliser la
programmation événementielle. C'est ce que nous avons fait pour le
serveur.
Il faut également savoir qu'il est possible sous une fenêtre DOS
de pouvoir recréer de l'événementiel en analysant les entrées qui
sont présentes dans la fenêtre DOS (entrée clavier / souris par
exemple). La fonction select dans la thread Winsock fait également
la même chose. Elle bloque un certain temps (en fonction du
réglage du timer) et n'utilise que très peu de ressources systèmes.
Enfin la méthode la plus propre est de suspendre un thread
lorsqu'il n'a rien à faire. C'est le cas de la thread de la file
des messages. Lorsqu'aucun message n'est présent dans les files de
messages des utilisateurs il est inutile de faire tourner la
thread en boucle, il faut donc pouvoir la mettre en pause. Mais il
faut obligatoirement savoir quand la réveiller. Il faut la
reprendre lorsqu'un message est enfilé. A chaque défilage on
décrémente un compteur de message. Une fois arrivé à 0, on suspend
le thread qui n'occupe plus de ressources au niveau du processeur.
Et le thread est réveillé après enfilage.
La fonction pour suspendre un thread est
SuspendThread(HANDLE hThread) qui retourne -1 en cas d'erreur.
La fonction pour reprendre l'exécution d'un thread est
ResumeThread(HANDLE hThread) qui retourne -1 en cas
d'erreur.
Les threads permettent donc de faire des choses qui n'étaient pas
possible avant ou difficilement, car l'exécution en séquentielle
pouvait bloquer comme c'était le cas dans le client par exemple où
la fonction Select figeait l'affichage lorsqu'elle se trouvait
dans le thread principal. Cependant il est indispensable de
synchroniser les threads entre eux car s'il arrive qu'ils
appellent simultanément la même ressource les conséquences peuvent
être catastrophiques. Par exemple si une personne modifie un
élément de la base de données alors qu'une autre personne essaie
de lire la même entrée, le résultat est imprévisible. Il se peut
que l'élément renvoyé soit simplement erroné mais il est fort
probable
que le serveur plante.
Pour cela il est nécessaire d'utiliser des objets de
synchronisation. Il en existe plusieurs, mais seulement deux nous
intéressent, il s'agit du Mutex et du Sémaphore.
Par définition un Mutex autorise l'accès mutuellement exclusif,
c'est-à-dire qu'il n'est possible qu'à une personne d'accéder à la
ressource partagée. Un mutex ne peut appartenir qu'à un seul
thread à la fois. Cependant, si un thread possède un mutex et
qu'un autre thread en demande la propriété, le thread demandant ne
peut l'acquérir que quand le thread propriétaire libère sa
propriété.
Pour pouvoir utiliser les mutex, la première opération à effectuer
est de le créer. Pour cela il faut faire appel à la fonction
CreateMutex() qui prend trois paramètres :
- *
- Les attributs de sécurité qui déterminent si le Mutex
est " héritable " c'est-à-dire s'il peut être appelé par un
thread d'un autre processus par exemple
- *
- Le thread possesseur initial du thread
- *
- Le nom de l'objet de synchronisation (ex : " bddMutex
")
La valeur de retour en cas de succès est l'handle du mutex, sinon
le code retour est NULL.
Il faut également pouvoir libérer le mutex après la libération de
la propriété. Pour cela il faut faire appel à la fonction
ReleaseMutex() qui ne prend en paramètre que l'handle du
Mutex à libérer. Le Mutex est alors passé au thread en attente
s'il y en a.
Par définition un Sémaphore autorise l'accès à un nombre défini de
threads. Un objet Sémaphore démarre avec un compte initial
attribué à sa création. Chaque fois qu'un thread devient
propriétaire de l'objet (en utilisant la fonction d'attente), le
compteur du sémaphore est décrémenté. Chaque fois qu'un thread
libère la propriété, le compteur du sémaphore est incrémenté. Si
celui-ci est à 0, le sémaphore est bloqué sur l'état non signalé
et aucun autre thread ne peut y accéder.
De même que pour les Mutex il faut pouvoir créer l'objet
sémaphore. Pour cela il faut utiliser la fonction
CreateSemaphore() qui avec le même principe que les mutex
prend quatre paramètres :
- *
- Les attributs de sécurités
- *
- La valeur initial du compteur
- *
- La valeur maximum du compteur
- *
- L'identifiant de l'objet comme pour le mutex
La valeur de retour est l'handle du sémaphore ou NULL en cas
d'échec.
Toutefois il est intéressant de remarquer qu'un mutex n'est rien
de plus qu'un sémaphore avec un compteur initial à 1 et un maximum
de 1. Cette solution va être utilisée pour la compatibilité et le
portage sous Unix. De plus c'est la méthode employée dans le petit
exemple sur les threads.
La fonction de ReleaseSemaphore() doit être appelée pour
désigner la libération de la ressource. Elle prend 3 paramètres :
- *
- L'handle du sémaphore
- *
- Un nombre désignant la valeur de l'incrémentation
- *
- Un paramètre de sortie qui récupère la valeur du compteur avant l'incrémentation
La valeur de retour est 0 en cas d'échec et n'importe quelle autre
valeur en cas de réussite.
Après avoir créé les objets, il faut pouvoir les appeler juste
avant la propriété à protéger. Il faut donc bien veiller à
n'oublier aucune zone sensible du programme.
Les tests effectués juste avant la propriété sont en fait des
appels aux objets de synchronisation. Ces appels sont effectués
par la fonction WaitForSingleObject() qui prend 2 paramètres :
- *
- L'handle de l'objet Mutex ou Sémaphore.
- *
- Un time-out qui définit l'attente maximale d'un thread
avant de zapper la propriété. Cette valeur est un double mot
qui représente une valeur en millisecondes Dans notre cas ce
paramètre est égal à 0, ce qui signifie que le time-out est
infini.
Il existe 4 valeurs de retour :
- *
- WAIT_ABANDONED : c'est un message d'erreur dû à un problème de droit
- *
- WAIT_OBJECT_0 : c'est la valeur en cas de succès
- *
- WAIT_TIMEOUT : c'est un message d'erreur qui signifie
que le délai d'attente est passé et que le programme continue son
exécution
- *
- WAIT_FAILED : c'est un message d'erreur dû au fait que l'handle
passé n'est pas valide.
La fonction WaitForSingleObject() n'attend que pour un
objet donné, ce qui est suffisant dans notre cas. Pour pouvoir
attendre plusieurs objets de synchronisation il faut appeler la
fonction WaitForMultipleObject().