Chapitre 18 : Communications à travers un réseau▲
Le développement extraordinaire de l'internet a amplement démontré que les ordinateurs peuvent
être des outils de communication très efficaces. Dans ce chapitre, nous allons expérimenter la plus
simple des techniques d'interconnexion de deux programmes, qui leur permette de s'échanger des
informations par l'intermédiaire d'un réseau.
Pour ce qui va suivre, nous supposerons donc que vous collaborez avec un ou plusieurs de vos
condisciples, et que vos postes de travail Python sont connectés à un réseau local dont les
communications utilisent le protocole TCP/IP. Le système d'exploitation n'a pas d'importance : vous
pouvez par exemple installer l'un des scripts Python décrits ci-après sur un poste de travail
fonctionnant sous Linux, et le faire dialoguer avec un autre script mis en oeuvre sur un poste de
travail confié aux bons soins d'un système d'exploitation différent, tel que MacOS ou Windows.
Vous pouvez également expérimenter ce qui suit sur une seule et même machine, en mettant les
différents scripts en oeuvre dans des fenêtres indépendantes.
18-1. Les sockets▲
Le premier exercice qui va vous être proposé consistera à établir une communication entre deux
machines seulement. L'une et l'autre pourront s'échanger des messages à tour de rôle, mais vous
constaterez cependant que leurs configurations ne sont pas symétriques. Le script installé sur l'une
de ces machines jouera en effet le rôle d'un logiciel serveur, alors que l'autre se comportera comme
un logiciel client.
Le logiciel serveur fonctionne en continu, sur une machine dont l'identité est bien définie sur le
réseau grâce à une adresse IP spécifique73. Il guette en permanence l'arrivée de requêtes expédiées
par les clients potentiels en direction de cette adresse, par l'intermédiaire d'un port de
communication bien déterminé. Pour ce faire, le script correspondant doit mettre en oeuvre un objet
logiciel associé à ce port, que l'on appelle un socket.
Au départ d'une autre machine, le logiciel client tente d'établir la connexion en émettant une
requête appropriée. Cette requête est un message qui est confié au réseau, un peu comme on confie
une lettre à la Poste. Le réseau pourrait en effet acheminer la requête vers n'importe quelle autre
machine, mais une seule est visée : pour que la destination visée puisse être atteinte, la requête
contient dans son en-tête l'indication de l'adresse IP et du port de communication destinataires.
Lorsque la connexion est établie avec le serveur, le client lui assigne lui-même l'un de ses
propres ports de communication. A partir de ce moment, on peut considérer qu'un canal privilégié
relie les deux machines, comme si on les avait connectées l'une à l'autre par l'intermédiaire d'un fil
(les deux ports de communication respectifs jouant le rôle des deux extrémités de ce fil). L'échange
d'informations proprement dit peut commencer.
Pour pouvoir utiliser les ports de communication réseau, les programmes font appel à un
ensemble de procédures et de fonctions du système d'exploitation, par l'intermédiaire d'objets
interfaces que l'on appelle des sockets. Ceux-ci peuvent mettre en oeuvre deux techniques de
communication différentes et complémentaires : celle des paquets (que l'on appelle aussi des
datagrammes), très largement utilisée sur l'internet, et celle de la connexion continue, ou stream
socket, qui est un peu plus simple.
73 Une machine particulière peut également être désignée par un nom plus explicite, mais à la condition qu'un mécanisme ait été mis en place sur le réseau (DNS) pour traduire automatiquement ce nom en adresse IP. Veuillez consulter votre cours sur les systèmes d'exploitation et les réseaux pour en savoir davantage.
18-2. Construction d'un serveur élémentaire▲
Pour nos premières expériences, nous allons utiliser la technique des stream sockets.
Celle-ci est en effet parfaitement appropriée lorsqu'il s'agit de faire communiquer des ordinateurs
interconnectés par l'intermédiaire d'un réseau local. C'est une technique particulièrement aisée à
mettre en oeuvre, et elle permet un débit élevé pour l'échange de données.
L'autre technologie (celle des paquets) serait préférable pour les communications expédiées via
l'internet, en raison de sa plus grande fiabilité (les mêmes paquets peuvent atteindre leur destination
par différents chemins, être émis ou ré-émis en plusieurs exemplaires si cela se révèle nécessaire
pour corriger les erreurs de transmission), mais sa mise en oeuvre est un peu plus complexe. Nous
ne l'étudierons pas dans ce cours.
Le script ci-dessous met en place un serveur capable de communiquer avec un seul client. Nous
verrons un peu plus loin ce qu'il faut lui ajouter afin qu'il puisse prendre en charge en parallèle les
connexions de plusieurs clients.
1.
# Définition d'un serveur réseau rudimentaire
2.
# Ce serveur attend la connexion d'un client, pour entamer un dialogue avec lui
3.
4.
import
socket, sys
5.
6.
HOST =
'192.168.14.152'
7.
PORT =
50000
8.
9.
# 1) création du socket :
10.
mySocket =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
11.
12.
# 2) liaison du socket à une adresse précise :
13.
try
:
14.
mySocket.bind
((
HOST, PORT))
15.
except
socket.error:
16.
print
"La liaison du socket à l'adresse choisie a échoué."
17.
sys.exit
(
)
18.
19.
while
1
:
20.
# 3) Attente de la requête de connexion d'un client :
21.
print
"Serveur prêt, en attente de requêtes ..."
22.
mySocket.listen
(
5
)
23.
24.
# 4) Etablissement de la connexion :
25.
connexion, adresse =
mySocket.accept
(
)
26.
print
"Client connecté, adresse IP
%s
, port
%s
"
%
(
adresse[0
], adresse[1
])
27.
28.
# 5) Dialogue avec le client :
29.
connexion.send
(
"Vous êtes connecté au serveur Marcel. Envoyez vos messages."
)
30.
msgClient =
connexion.recv
(
1024
)
31.
while
1
:
32.
print
"C>"
, msgClient
33.
if
msgClient.upper
(
) ==
"FIN"
or
msgClient ==
""
:
34.
break
35.
msgServeur =
raw_input(
"S> "
)
36.
connexion.send
(
msgServeur)
37.
msgClient =
connexion.recv
(
1024
)
38.
39.
# 6) Fermeture de la connexion :
40.
connexion.send
(
"Au revoir !"
)
41.
print
"Connexion interrompue."
42.
connexion.close
(
)
43.
44.
ch =
raw_input(
"<R>ecommencer <T>erminer ? "
)
45.
if
ch.upper
(
) ==
'T'
:
46.
break
Commentaires :
- Ligne 4 : Le module socket contient toutes les fonctions et les classes nécessaires pour construire
des programmes communiquants. Comme nous allons le voir dans les lignes suivantes,
l'établissement de la communication comporte six étapes.
- Lignes 6 & 7 : Ces deux variables définissent l'identité du serveur, telle qu'on l'intégrera au
socket. HOST doit contenir une chaîne de caractères indiquant l'adresse IP du serveur sous la
forme décimale habituelle, ou encore le nom DNS de ce même serveur (mais à la condition qu'un
mécanisme de résolution des noms ait été mis en place sur le réseau). PORT doit contenir un
entier, à savoir le numéro d'un port qui ne soit pas déjà utilisé pour un autre usage, et de
préférence une valeur supérieure à 1024 (Cfr. votre cours sur les services réseau).
- Lignes 9 & 10 : Première étape du mécanisme d'interconnexion. On instancie un objet de la
classe socket(), en précisant deux options qui indiquent le type d'adresses choisi (nous utiliserons
des adresses de type « internet ») ainsi que la technologie de transmission (datagrammes ou
connexion continue (stream) : nous avons décidé d'utiliser cette dernière).
- Lignes 12 à 17 : Seconde étape. On tente d'établir la liaison entre le socket et le port de
communication. Si cette liaison ne peut être établie (port de communication occupé, par
exemple, ou nom de machine incorrect), le programme se termine sur un message d'erreur.
Remarque : la méthode bind() du socket attend un argument du type tuple, raison pour laquelle nous devons enfermer nos deux variables dans une double paire de parenthèses. - Ligne 19 : Notre programme serveur étant destiné à fonctionner en permanence dans l'attente des
requêtes de clients potentiels, nous le lançons dans une boucle sans fin.
- Lignes 20 à 22 : Troisième étape. Le socket étant relié à un port de communication, il peut à
présent se préparer à recevoir les requêtes envoyées par les clients. C'est le rôle de la méthode
listen(). L'argument qu'on lui transmet indique le nombre maximum de connexions à accepter en
parallèle. Nous verrons plus loin comment gérer celles-ci.
- Lignes 24 à 26 : Quatrième étape. Lorsqu'on fait appel à sa méthode accept(), le socket attend
indéfiniment qu'une requête se présente. Le script est donc interrompu à cet endroit, un peu
comme il le serait si nous faisions appel à une fonction input() pour attendre une entrée clavier.
Si une requête est réceptionnée, la méthode accept() renvoie un tuple de deux éléments : le
premier est la référence d'un nouvel objet de la classe socket()74, qui sera la véritable interface de
communication entre le client et le serveur, et le second un autre tuple contenant les coordonnées
de ce client (son adresse IP et le n° de port qu'il utilise lui-même).
- Lignes 28 à 30 : Cinquième étape. La communication proprement dite est établie. Les méthodes
send() et recv() du socket servent évidemment à l'émission et à la réception des messages, qui
doivent être de simples chaînes de caractères.
Remarques : la méthode send() renvoie le nombre d'octets expédiés. L'appel de la méthode recv
() doit comporter un argument entier indiquant le nombre maximum d'octets à réceptionner en
une fois (Les octets surnuméraires sont mis en attente dans un tampon. Ils sont transmis lorsque
la même méthode recv() est appelée à nouveau).
- Lignes 31 à 37 : Cette nouvelle boucle sans fin maintient le dialogue jusqu'à ce que le client
décide d'envoyer le mot « fin » ou une simple chaîne vide. Les écrans des deux machines
afficheront chacune l'évolution de ce dialogue.
- Lignes 39 à 42 : Sixième étape. Fermeture de la connexion.
74 Nous verrons plus loin l'utilité de créer ainsi un nouvel objet socket pour prendre en charge la communication, plutôt que d'utiliser celui qui a déjà créé à la ligne 10. En bref, si nous voulons que notre serveur puisse prendre en charge simultanément les connexions de plusieurs clients, il nous faudra disposer d'un socket distinct pour chacun d'eux, indépendamment du premier que l'on laissera fonctionner en permanence pour réceptionner les requêtes qui continuent à arriver en provenance de nouveaux clients.
18-3. Construction d'un client rudimentaire▲
Le script ci-dessous définit un logiciel client complémentaire du serveur décrit dans les pages précédentes. On notera sa grande simplicité.
1.
# Définition d'un client réseau rudimentaire
2.
# Ce client dialogue avec un serveur ad hoc
3.
4.
import
socket, sys
5.
6.
HOST =
'192.168.14.152'
7.
PORT =
50000
8.
9.
# 1) création du socket :
10.
mySocket =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
11.
12.
# 2) envoi d'une requête de connexion au serveur :
13.
try
:
14.
mySocket.connect
((
HOST, PORT))
15.
except
socket.error:
16.
print
"La connexion a échoué."
17.
sys.exit
(
)
18.
print
"Connexion établie avec le serveur."
19.
20.
# 3) Dialogue avec le serveur :
21.
msgServeur =
mySocket.recv
(
1024
)
22.
23.
while
1
:
24.
if
msgServeur.upper
(
) ==
"FIN"
or
msgServeur ==
""
:
25.
break
26.
print
"S>"
, msgServeur
27.
msgClient =
raw_input(
"C> "
)
28.
mySocket.send
(
msgClient)
29.
msgServeur =
mySocket.recv
(
1024
)
30.
31.
# 4) Fermeture de la connexion :
32.
print
"Connexion interrompue."
33.
mySocket.close
(
)
Commentaires :
- Le début du script est similaire à celui du serveur. L'adresse IP et le port de communication
doivent être ceux du serveur.
- Lignes 12 à 18 : On ne crée cette fois qu'un seul objet socket, dont on utilise la méthode connect
() pour envoyer la requête de connexion.
- Lignes 20 à 33 : Une fois la connexion établie, on peut dialoguer avec le serveur en utilisant les méthodes send() et recv() déjà décrites plus haut pour celui-ci.
18-4. Gestion de plusieurs tâches en parallèle à l'aide des threads▲
Le système de communication que nous avons élaboré dans les pages précédentes est vraiment
très rudimentaire : d'une part il ne met en relation que deux machines seulement, et d'autre part il
limite la liberté d'expression des deux interlocuteurs. Ceux-ci ne peuvent en effet envoyer des
messages que chacun à leur tour. Par exemple, lorsque l'un d'eux vient d'émettre un message, son
système reste bloqué tant que son partenaire ne lui a pas envoyé une réponse. Lorsqu'il vient de
recevoir une telle réponse, son système reste incapable d'en réceptionner une autre, tant qu'il n'a pas
entré lui-même un nouveau message, ... et ainsi de suite.
Tous ces problèmes proviennent du fait que nos scripts habituels ne peuvent s'occuper que d'une
seule chose à la fois. Lorsque le flux d'instructions rencontre une fonction input(), par exemple, il
ne se passe plus rien tant que l'utilisateur n'a pas introduit la donnée attendue. Et même si cette
attente dure très longtemps, il n'est habituellement pas possible que le programme effectue d'autres
tâches pendant ce temps. Ceci n'est toutefois vrai qu'au sein d'un seul et même programme : vous
savez certainement que vous pouvez exécuter d'autres applications entretemps sur votre ordinateur,
car les systèmes d'exploitation modernes sont « multi-tâches ».
Les pages qui suivent sont destinées à vous expliquer comment vous pouvez introduire cette
fonctionnalité multi-tâches dans vos programmes, afin que vous puissiez développer de véritables
applications réseau, capables de communiquer simultanément avec plusieurs partenaires.
Veuillez à présent considérer le script de la page précédente. Sa fonctionnalité essentielle réside
dans la boucle while des lignes 23 à 29. Or, cette boucle s'interrompt à deux endroits :
- à la ligne 27, pour attendre les entrées clavier de l'utilisateur (fonction raw_input()) ;
- à la ligne 29, pour attendre l'arrivée d'un message réseau.
Ces deux attentes sont donc successives, alors qu'il serait bien plus intéressant qu'elles soient
simultanées. Si c'était le cas, l'utilisateur pourrait expédier des messages à tout moment, sans devoir
attendre à chaque fois la réaction de son partenaire. Il pourrait également recevoir n'importe quel
nombre de messages, sans l'obligation d'avoir à répondre à chacun d'eux pour recevoir les autres.
Nous pouvons arriver à ce résultat si nous apprenons à gérer plusieurs séquences d'instructions
en parallèle au sein d'un même programme. Mais comment cela est-il possible ?
Au cours de l'histoire de l'informatique, plusieurs techniques ont été mises au point pour partager
le temps de travail d'un processeur entre différentes tâches, de telle manière que celles-ci paraissent
être effectuées en même temps (alors qu'en réalité le processeur s'occupe d'un petit bout de chacune
d'elles à tour de rôle). Ces techniques sont implémentées dans le système d'exploitation, et il n'est
pas nécessaire de les détailler ici, même s'il est possible d'accéder à chacune d'elles avec Python.
Dans les pages suivantes, nous allons apprendre à utiliser celle de ces techniques qui est à la fois
la plus facile à mettre en oeuvre, et la seule qui soit véritablement portable (elle est en effet
supportée par tous les grands systèmes d'exploitation) : on l'appelle la technique des processus
légers ou threads75.
Dans un programme d'ordinateur, les threads sont des flux d'instructions qui sont menés en
parallèle (quasi-simultanément), tout en partageant le même espace de noms global.
En fait, le flux d'instructions de n'importe quel programme Python suit toujours au moins un
thread : le thread principal. À partir de celui-ci, d'autres threads « enfants » peuvent être amorcés,
qui seront exécutés en parallèle. Chaque thread enfant se termine et disparaît sans autre forme de
procès lorsque toutes les instructions qu'il contient ont été exécutées. Par contre, lorsque le thread
principal se termine, il faut parfois s'assurer que tous ses threads enfants « meurent » avec lui.
75 Dans un système d'exploitation de type Unix (comme Linux), les différents threads d'un même programme font partie d'un seul processus. Il est également possible de gérer différents processus à l'aide d'un même script Python (opération fork), mais l'explication de cette technique dépasse largement le cadre de ce cours.
18-5. Client gérant l'émission et la réception simultanées▲
Nous allons maintenant mettre en pratique la technique des threads pour construire un système
de « chat »76 simplifié. Ce système sera constitué d'un seul serveur et d'un nombre quelconque de
clients. Contrairement à ce qui se passait dans notre premier exercice, personne n'utilisera le serveur
lui-même pour communiquer, mais lorsque celui-ci aura été mis en route, plusieurs clients pourront
s'y connecter et commencer à s'échanger des messages.
Chaque client enverra tous ses messages au serveur, mais celui-ci les ré-expédiera
immédiatement à tous les autres clients connectés, de telle sorte que chacun puisse voir l'ensemble
du trafic. Chacun pourra à tout moment envoyer ses messages, et recevoir ceux des autres, dans
n'importe quel ordre, la réception et l'émission étant gérées simultanément, dans des threads
séparés.
Le script ci-après définit le programme client. Le serveur sera décrit un peu plus loin. Vous
constaterez que la partie principale du script (ligne 38 et suivantes) est similaire à celle de l'exemple
précédent. Seule la partie « Dialogue avec le serveur » a été remplacée. Au lieu d'une boucle while,
vous y trouvez à présent les instructions de création de deux objets threads (aux lignes 49 et 50),
dont on démarre la fonctionnalité aux deux lignes suivantes. Ces objets threads sont crées par
dérivation, à partir de la classe Thread() du module threading. Ils s'occuperont indépendamment
de la réception et le l'émission des messages. Les deux threads « enfants » sont ainsi parfaitement
encapsulés dans des objets distincts, ce qui facilite la compréhension du mécanisme.
1.
# Définition d'un client réseau gérant en parallèle l'émission
2.
# et la réception des messages (utilisation de 2 THREADS).
3.
4.
host =
'192.168.0.235'
5.
port =
40000
6.
7.
import
socket, sys, threading
8.
9.
class
ThreadReception
(
threading.Thread):
10.
"""objet thread gérant la réception des messages"""
11.
def
__init__
(
self, conn):
12.
threading.Thread.__init__
(
self)
13.
self.connexion =
conn # réf. du socket de connexion
14.
15.
def
run
(
self):
16.
while
1
:
17.
message_recu =
self.connexion.recv
(
1024
)
18.
print
"*"
+
message_recu +
"*"
19.
if
message_recu ==
''
or
message_recu.upper
(
) ==
"FIN"
:
20.
break
21.
# Le thread <réception> se termine ici.
22.
# On force la fermeture du thread <émission> :
23.
th_E._Thread__stop
(
)
24.
print
"Client arrêté. Connexion interrompue."
25.
self.connexion.close
(
)
26.
27.
class
ThreadEmission
(
threading.Thread):
28.
"""objet thread gérant l'émission des messages"""
29.
def
__init__
(
self, conn):
30.
threading.Thread.__init__
(
self)
31.
self.connexion =
conn # réf. du socket de connexion
32.
33.
def
run
(
self):
34.
while
1
:
35.
message_emis =
raw_input(
)
36.
self.connexion.send
(
message_emis)
37.
38.
# Programme principal - Établissement de la connexion :
39.
connexion =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
40.
try
:
41.
connexion.connect
((
host, port))
42.
except
socket.error:
43.
print
"La connexion a échoué."
44.
sys.exit
(
)
45.
print
"Connexion établie avec le serveur."
46.
47.
# Dialogue avec le serveur : on lance deux threads pour gérer
48.
# indépendamment l'émission et la réception des messages :
49.
th_E =
ThreadEmission
(
connexion)
50.
th_R =
ThreadReception
(
connexion)
51.
th_E.start
(
)
52.
th_R.start
(
)
Commentaires :
- Remarque générale : Dans cet exemple, nous avons décidé de créer deux objets threads
indépendants du thread principal, afin de bien mettre en évidence les mécanismes. Notre
programme utilise donc trois threads en tout, alors que le lecteur attentif aura remarqué que deux
pourraient suffire. En effet : le thread principal ne sert en définitive qu'à lancer les deux autres !
Il n'y a cependant aucun intérêt à limiter le nombre de threads. Au contraire : à partir du moment
où l'on décide d'utiliser cette technique, il faut en profiter pour compartimenter l'application en
unités bien distinctes.
- Ligne 7 : Le module threading contient la définition de toute une série de classes intéressantes
pour gérer les threads. Nous n'utiliserons ici que la seule classe Thread(), mais une autre sera
exploitée plus loin (la classe Lock()), lorsque nous devrons nous préoccuper de problèmes de
synchronisation entre différents threads concurrents.
- Lignes 9 à 25 : Les classes dérivées de la classe Thread() contiendront essentiellement une
méthode run(). C'est dans celle-ci que l'on placera la portion de programme spécifiquement
confiée au thread. Il s'agira souvent d'une boucle répétitive, comme ici. Vous pouvez
parfaitement considérer le contenu de cette méthode comme un script indépendant, qui s'exécute
en parallèle avec les autres composants de votre application. Lorsque ce code a été complètement
exécuté, le thread se referme.
- Lignes 16 à 20 : Cette boucle gère la réception des messages. À chaque itération, le flux
d'instructions s'interrompt à la ligne 17 dans l'attente d'un nouveau message, mais le reste du
programme n'est pas figé pour autant : les autres threads continuent leur travail indépendamment.
- Ligne 19 : La sortie de boucle est provoquée par la réception d'un message 'fin' (en majuscules
ou en minuscules), ou encore d'un message vide (c'est notamment le cas si la connexion est
coupée par le partenaire). Quelques instructions de « nettoyage » sont alors exécutées, et puis le
thread se termine.
- Ligne 23 : Lorsque la réception des messages est terminée, nous souhaitons que le reste du
programme se termine lui aussi. Il nous faut donc forcer la fermeture de l'autre objet thread, celui
que nous avons mis en place pour gérer l'émission des messages. Cette fermeture forcée peut être
obtenue à l'aide de la méthode _Thread__stop()77.
- Lignes 27 à 36 : Cette classe définit donc un autre objet thread, qui contient cette fois une boucle
de répétition perpétuelle. Il ne se pourra donc se terminer que contraint et forcé par méthode
décrite au paragraphe précédent. À chaque itération de cette boucle, le flux d'instructions
s'interrompt à la ligne 35 dans l'attente d'une entrée clavier, mais cela n'empêche en aucune
manière les autres threads de faire leur travail.
- Lignes 38 à 45 : Ces lignes sont reprises à l'identique des scripts précédents.
- Lignes 47 à 52 : Instanciation et démarrage des deux objets threads « enfants ». Veuillez noter qu'il est recommandé de provoquer ce démarrage en invoquant la méthode intégrée start(), plutôt qu'en faisant appel directement à la méthode run() que vous aurez définie vous-même. Sachez également que vous ne pouvez invoquer start() qu'une seule fois (une fois arrêté, un objet thread ne peut pas être redémarré).
76 Le « chat » est l'occupation qui consiste à « papoter » par l'intermédiaire d'ordinateurs. Les canadiens francophones
ont proposé le terme de clavardage pour désigner ce « bavardage par claviers interposés ».
77 Que les puristes veuillent bien me pardonner : j'admets volontiers que cette astuce pour forcer l'arrêt d'un thread
n'est pas vraiment recommandable. Je me suis autorisé ce raccourci afin de ne pas trop alourdir ce texte, qui se veut
seulement une initiation. Le lecteur exigeant pourra approfondir cette question en consultant l'un ou l'autre des
ouvrages de référence mentionnés dans la bibliographie (voir page 8)
18-6. Serveur gérant les connexions de plusieurs clients en parallèle▲
Le script ci-après crée un serveur capable de prendre en charge les connexions d'un certain
nombre de clients du même type que ce que nous avons décrit dans les pages précédentes.
Ce serveur n'est pas utilisé lui-même pour communiquer : ce sont les clients qui communiquent
les uns avec les autres, par l'intermédiaire du serveur. Celui-ci joue donc le rôle d'un relais : il
accepte les connexions des clients, puis attend l'arrivée de leurs messages. Lorsqu'un message arrive
en provenance d'un client particulier, le serveur le ré-expédie à tous les autres, en lui ajoutant au
passage une chaîne d'identification spécifique du client émetteur, afin que chacun puisse voir tous
les messages, et savoir de qui ils proviennent.
1.
# Définition d'un serveur réseau gérant un système de CHAT simplifié.
2.
# Utilise les threads pour gérer les connexions clientes en parallèle.
3.
4.
HOST =
'192.168.0.235'
5.
PORT =
40000
6.
7.
import
socket, sys, threading
8.
9.
class
ThreadClient
(
threading.Thread):
10.
'''dérivation d'un objet thread pour gérer la connexion avec un client'''
11.
def
__init__
(
self, conn):
12.
threading.Thread.__init__
(
self)
13.
self.connexion =
conn
14.
15.
def
run
(
self):
16.
# Dialogue avec le client :
17.
nom =
self.getName
(
) # Chaque thread possède un nom
18.
while
1
:
19.
msgClient =
self.connexion.recv
(
1024
)
20.
if
msgClient.upper
(
) ==
"FIN"
or
msgClient ==
""
:
21.
break
22.
message =
"
%s
>
%s
"
%
(
nom, msgClient)
23.
print
message
24.
# Faire suivre le message à tous les autres clients :
25.
for
cle in
conn_client:
26.
if
cle !=
nom: # ne pas le renvoyer à l'émetteur
27.
conn_client[cle].send
(
message)
28.
29.
# Fermeture de la connexion :
30.
self.connexion.close
(
) # couper la connexion côté serveur
31.
del
conn_client[nom] # supprimer son entrée dans le dictionnaire
32.
print
"Client
%s
déconnecté."
%
nom
33.
# Le thread se termine ici
34.
35.
# Initialisation du serveur - Mise en place du socket :
36.
mySocket =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
37.
try
:
38.
mySocket.bind
((
HOST, PORT))
39.
except
socket.error:
40.
print
"La liaison du socket à l'adresse choisie a échoué."
41.
sys.exit
(
)
42.
print
"Serveur prêt, en attente de requêtes ..."
43.
mySocket.listen
(
5
)
44.
45.
# Attente et prise en charge des connexions demandées par les clients :
46.
conn_client =
{} # dictionnaire des connexions clients
47.
while
1
:
48.
connexion, adresse =
mySocket.accept
(
)
49.
# Créer un nouvel objet thread pour gérer la connexion :
50.
th =
ThreadClient
(
connexion)
51.
th.start
(
)
52.
# Mémoriser la connexion dans le dictionnaire :
53.
it =
th.getName
(
) # identifiant du thread
54.
conn_client[it] =
connexion
55.
print
"Client
%s
connecté, adresse IP
%s
, port
%s
."
%
\
56.
(
it, adresse[0
], adresse[1
])
57.
# Dialogue avec le client :
58.
connexion.send
(
"Vous êtes connecté. Envoyez vos messages."
)
Commentaires :
- Lignes 35 à 43 : L'initialisation de ce serveur est identique à celle du serveur rudimentaire décrit
au début du présent chapitre.
- Ligne 46 : Les références des différentes connexions doivent être mémorisées. Nous pourrions
les placer dans une liste, mais il est plus judicieux de les placer dans un dictionnaire, pour deux
raisons : La première est que nous devrons pouvoir ajouter ou enlever ces références dans
n'importe quel ordre, puisque les clients se connecteront et se déconnecteront à leur guise. La
seconde est que nous pouvons disposer aisément d'un identifiant unique pour chaque connexion,
lequel pourra servir de clé d'accès dans un dictionnaire. Cet identifiant nous sera en effet fourni
automatiquement par La classe Thread().
- Lignes 47 à 51 : Le programme commence ici une boucle de répétition perpétuelle, qui va
constamment attendre l'arrivée de nouvelles connexions. Pour chacune de celles-ci, un nouvel
objet ThreadClient() est créé, lequel pourra s'occuper d'elle indépendamment de toutes les
autres.
- Lignes 52 à 54 : Obtention d'un identifiant unique à l'aide de la méthode getName(). Nous
pouvons profiter ici du fait que Python attribue automatiquement un nom unique à chaque
nouveau thread : ce nom convient bien comme identifiant (ou clé) pour retrouver la connexion
correspondante dans notre dictionnaire. Vous pourrez constater qu'il s'agit d'une chaîne de
caractères, de la forme : « Thread-N » (N étant le numéro d'ordre du thread).
- Lignes 15 à 17 : Gardez bien à l'esprit qu'il se créera autant d'objets ThreadClient() que de
connexions, et que tous ces objets fonctionneront en parallèle. La méthode getName() peut alors
être utilisée au sein de l'un quelconque de ces objets pour retrouver son identité particulière.
Nous utiliserons cette information pour distinguer la connexion courante de toutes les autres
(voir ligne 26).
- Lignes 18 à 23 : L'utilité du thread est de réceptionner tous les messages provenant d'un client
particulier. Il faut donc pour cela une boucle de répétition perpétuelle, qui ne s'interrompra qu'à
la réception du message spécifique : « fin », ou encore à la réception d'un message vide (cas où
la connexion est coupée par le partenaire).
- Lignes 24 à 27 : Chaque message reçu d'un client doit être ré-expédié à tous les autres. Nous
utilisons ici une boucle for pour parcourir l'ensemble des clés du dictionnaire des connexions,
lesquelles nous permettent ensuite de retrouver les connexions elles-mêmes. Un simple test (à la
ligne 26) nous évite de ré-expédier le message au client dont il provient.
- Ligne 31 : Lorsque nous fermons un socket de connexion, il est préférable de supprimer sa référence dans le dictionnaire, puisque cette référence ne peut plus servir. Et nous pouvons faire cela sans précaution particulière, car les éléments d'un dictionnaire ne sont pas ordonnés (nous pouvons en ajouter ou en enlever dans n'importe quel ordre).
18-7. Jeu des bombardes, version réseau▲
Au chapitre 15, nous avons commenté le développement d'un petit jeu de combat dans lequel des joueurs s'affrontaient à l'aide de bombardes. L'intérêt de ce jeu reste toutefois fort limité, tant qu'il se pratique sur un seul et même ordinateur. Nous allons donc le perfectionner, en y intégrant les techniques que nous venons d'apprendre. Comme le système de « chat » décrit dans les pages précédentes, l'application complète se composera désormais de deux programmes distincts : un logiciel serveur qui ne sera mis en fonctionnement que sur une seule machine, et un logiciel client qui pourra être lancé sur toute une série d'autres. Du fait du caractère portable de Python, il vous sera même possible d'organiser des combats de bombardes entre ordinateurs gérés par des systèmes d'exploitation différents (MacOS <> Linux <> Windows !).
18-7-1. Programme serveur : vue d'ensemble▲
Les programmes serveur et client exploitent la même base logicielle, elle-même largement
récupérée de ce qui avait déjà été mis au point tout au long du chapitre 15. Nous admettrons donc
pour la suite de cet exposé que les deux versions précédentes du jeu ont été sauvegardées dans les
fichiers-modules canon03.py et canon04.py, installés dans le répertoire courant. Nous pouvons en
effet réutiliser une bonne partie du code qu'ils contiennent, en nous servant judicieusement de
l'importation et de l'héritage de classes.
Du module canon04, nous allons réutiliser la classe Canon() telle quelle, aussi bien pour le
logiciel serveur que pour le logiciel client. De ce même module, nous importerons également la
classe AppBombardes(), dont nous ferons dériver la classe maîtresse de notre application serveur :
AppServeur(). Vous constaterez plus loin que celle-ci produira elle-même la sous-classe
AppClient(), toujours par héritage.
Du module canon03, nous récupérerons la classe Pupitre() dont nous tirerons une version plus
adaptée au « contrôle à distance ».
Enfin, deux nouvelles classes viendront s'ajouter aux précédentes, chacune spécialisée dans la
création d'un objet thread : la classe ThreadClients(), dont une instance surveillera en permanence
le socket destiné à réceptionner les demandes de connexion de nouveaux clients, et la classe
ThreadConnexion(), qui servira à créer autant d'objets sockets que nécessaire pour assurer le
dialogue avec chacun des clients déjà connectés.
Ces nouvelles classes seront inspirées de celles que nous avions développées pour notre serveur
de « chat » dans les pages précédentes. La principale différence par rapport à celui-ci est que nous
devrons activer un thread spécifique pour le code qui gère l'attente et la prise en charge des
connexions clientes, afin que l'application principale puisse faire autre chose pendant ce temps.
A partir de là, notre plus gros travail consistera à développer un protocole de communication
pour le dialogue entre le serveur et ses clients. De quoi est-il question ? Tout simplement de définir
la teneur des messages que vont s'échanger les machines connectées. Rassurez-vous : la mise au
point de ce « langage » peut être progressive. On commence par établir un dialogue de base, puis on
y ajoute petit à petit un « vocabulaire » plus étendu.
L'essentiel de ce travail peut être accompli en s 'aidant du logiciel client développé
précédemment pour le système de « chat ». On se sert de celui-ci pour envoyer des « ordres » au
serveur en cours de développement, et on corrige celui-ci jusqu'à ce qu'il « obéisse » : en clair, les
procédures que l'on met en place progressivement sur le serveur sont testées au fur et à mesure, en
réponse aux messages correspondants émis « à la main » à partir du client.
18-7-2. Protocole de communication▲
Il va de soi que le protocole décrit ci-après est tout à fait arbitraire. Il serait parfaitement possible
de choisir d'autres conventions complètement différentes. Vous pouvez bien évidemment critiquer
les choix effectués, et vous souhaiterez peut-être même les remplacer par d'autres, plus efficients ou
plus simples.
Vous savez déjà que les messages échangés sont de simples chaînes de caractères. Prévoyant que
certains de ces messages devront transmettre plusieurs informations à la fois, nous avons décidé que
chacun d'eux pourrait comporter plusieurs champs, que nous séparerons à l'aide de virgules. Lors de
la réception de l'un quelconque de ces messages, nous pourrons alors aisément récupérer tous ses
composants dans une liste, à l'aide de la méthode intégrée split().
Voici un exemple de dialogue type, tel qu'il peut être suivi du côté d'un client. Les messages
entre astérisques sont ceux qui sont reçus du serveur ; les autres sont ceux qui sont émis par le client
lui-même :
1.
*
serveur OK*
2.
client OK
3.
*
canons,Thread-
3
;104
;228
;1
;dark red,Thread-
2
;454
;166
;-
1
;dark blue,*
4.
OK
5.
*
nouveau_canon,Thread-
4
,481
,245
,-
1
,dark green,le_vôtre*
6.
orienter,25
,
7.
feu
8.
*
mouvement_de,Thread-
4
,549
,280
,*
9.
feu
10.
*
mouvement_de,Thread-
4
,504
,278
,*
11.
*
scores,Thread-
4
;1
,Thread-
3
;-
1
,Thread-
2
;0
,*
12.
*
angle,Thread-
2
,23
,*
13.
*
angle,Thread-
2
,20
,*
14.
*
tir_de,Thread-
2
,*
15.
*
mouvement_de,Thread-
2
,407
,191
,*
16.
*
départ_de,Thread-
2
*
17.
*
nouveau_canon,Thread-
5
,502
,276
,-
1
,dark green*
Lorsqu'un nouveau client démarre, il envoie une requête de connexion au serveur, lequel lui
expédie en retour le message : « serveur OK ». À la réception de ce dernier, le client répond alors en
envoyant lui-même : « client OK ». Ce premier échange de politesses n'est pas absolument
indispensable, mais il permet de vérifier que la communication passe bien dans les deux sens. Étant
donc averti que le client est prêt à travailler, le serveur lui expédie alors une description des canons
déjà présents dans le jeu (éventuellement aucun) : identifiant, emplacement sur le canevas,
orientation et couleur (ligne 3).
En réponse à l'accusé de réception du client (ligne 4), le serveur installe un nouveau canon dans
l'espace de jeu, puis il signale les caractéristiques de cette installation non seulement au client qui l'a
provoquée, mais également à tous les autres clients connectés. Le message expédié au nouveau
client comporte cependant une différence (car c'est lui le propriétaire de ce nouveau canon) : en plus
des caractéristiques du canon, qui sont fournies à tout le monde, il comporte un champ
supplémentaire contenant simplement « le_vôtre » (comparez par exemple la ligne 5 avec la ligne
17, laquelle signale la connexion d'un autre joueur). Cette indication supplémentaire permet au
client propriétaire du canon de distinguer parmi plusieurs messages similaires éventuels, celui qui
contient l'identifiant unique que lui a attribué le serveur.
Les messages des lignes 6 et 7 sont des commandes envoyées par le client (réglage de la hausse
et commande de tir). Dans la version précédente du jeu, nous avions déjà convenu que les canons se
déplaceraient quelque peu (et au hasard) après chaque tir. Le serveur effectue donc cette opération,
et s'empresse ensuite d'en faire connaître le résultat à tous les clients connectés. Le message reçu du
serveur à la ligne 8 est donc l'indication d'un tel déplacement (les coordonnées fournies sont les
coordonnées résultantes pour le canon concerné).
La ligne 11 reproduit le type de message expédié par le serveur lorsqu'une cible a été touchée.
Les nouveaux scores de tous les joueurs sont ainsi communiqués à tous les clients.
Les messages serveur des lignes 12, 13 et 14 indiquent les actions entreprises par un autre joueur
(réglage de hausse suivi d'un tir). Cette fois encore, le canon concerné est déplacé au hasard après
qu'il ait tiré (ligne 15).
Lignes 16 et 17 : lorsque l'un des clients coupe sa connexion, le serveur en avertit tous les autres,
afin que le canon correspondant disparaisse de l'espace de jeu sur tous les postes. À l'inverse, de
nouveaux clients peuvent se connecter à tout moment pour participer au jeu.
Remarques complémentaires :
Le premier champ de chaque message indique sa teneur. Les messages envoyés par le client sont
très simples : ils correspondent aux différentes actions entreprises par le joueur (modifications de
l'angle de tir et commandes de feu). Ceux qui sont envoyés par le serveur sont un peu plus
complexes. La plupart d'entre eux sont expédiés à tous les clients connectés, afin de les tenir
informés du déroulement du jeu. En conséquence, ces messages doivent mentionner l'identifiant du
joueur qui a commandé une action ou qui est concerné par un changement quelconque. Nous avons
vu plus haut que ces identifiants sont des noms générés automatiquement par le gestionnaire de
threads du serveur, chaque fois qu'un nouveau client se connecte.
Certains messages concernant l'ensemble du jeu contiennent plusieurs informations par champ.
Dans ce cas, les différents « sous-champs » sont séparés par des points-virgules (lignes 3 et 11).
18-7-3. Programme serveur : première partie▲
Vous trouverez dans les pages qui suivent le script complet du programme serveur. Nous vous le présentons en trois morceaux successifs afin de rapprocher les commentaires du code correspondant, mais la numérotation de ses lignes est continue. Bien qu'il soit déjà relativement long et complexe, vous estimerez probablement qu'il mérite d'être encore perfectionné, notamment au niveau de la présentation générale. Nous vous laisserons le soin d'y ajouter vous-même tous les compléments qui vous sembleront utiles (par exemple, une proposition de choisir les coordonnées de la machine hôte au démarrage, une barre de menus, etc.) :
1.
#######################################################
2.
# Jeu des bombardes - partie serveur #
3.
# (C) Gérard Swinnen, Liège (Belgique)- Juillet 2004 #
4.
# Licence : GPL #
5.
# Avant d'exécuter ce script, vérifiez que l'adresse #
6.
# IP ci-dessous soit bien celle de la machine hôte. #
7.
# Vous pouvez choisir un numéro de port différent, ou #
8.
# changer les dimensions de l'espace de jeu. #
9.
# Dans tous les cas, vérifiez que les mêmes choix ont #
10.
# été effectués pour chacun des scripts clients. #
11.
#######################################################
12.
13.
host, port =
'192.168.0.235'
, 35000
14.
largeur, hauteur =
700
, 400
# dimensions de l'espace de jeu
15.
16.
from
Tkinter import
*
17.
import
socket, sys, threading, time
18.
import
canon03
19.
from
canon04 import
Canon, AppBombardes
20.
21.
class
Pupitre
(
canon03.Pupitre):
22.
"""Pupitre de pointage amélioré"""
23.
def
__init__
(
self, boss, canon):
24.
canon03.Pupitre.__init__
(
self, boss, canon)
25.
26.
def
tirer
(
self):
27.
"déclencher le tir du canon associé"
28.
self.appli.tir_canon
(
self.canon.id)
29.
30.
def
orienter
(
self, angle):
31.
"ajuster la hausse du canon associé"
32.
self.appli.orienter_canon
(
self.canon.id, angle)
33.
34.
def
valeur_score
(
self, sc =
None
):
35.
"imposer un nouveau score <sc>, ou lire le score existant"
36.
if
sc ==
None
:
37.
return
self.score
38.
else
:
39.
self.score =
sc
40.
self.points.config
(
text =
'
%s
'
%
self.score)
41.
42.
def
inactiver
(
self):
43.
"désactiver le bouton de tir et le système de réglage d'angle"
44.
self.bTir.config
(
state =
DISABLED)
45.
self.regl.config
(
state =
DISABLED)
46.
47.
def
activer
(
self):
48.
"activer le bouton de tir et le système de réglage d'angle"
49.
self.bTir.config
(
state =
NORMAL)
50.
self.regl.config
(
state =
NORMAL)
51.
52.
def
reglage
(
self, angle):
53.
"changer la position du curseur de réglage"
54.
self.regl.config
(
state =
NORMAL)
55.
self.regl.set(
angle)
56.
self.regl.config
(
state =
DISABLED)
La classe Pupitre() est construite par dérivation de la classe de même nom importée du modune
canon03. Elle hérite donc toutes les caractéristiques de celle-ci, mais nous devons surcharger78 ses
méthodes tirer() et orienter() :
Dans la version monoposte du logiciel, en effet, chacun des pupitres pouvait commander
directement l'objet canon correspondant. Dans cette version réseau, par contre, ce sont les clients
qui contrôlent à distance le fonctionnement des canons. Par conséquent, les pupitres qui
apparaissent dans la fenêtre du serveur ne peuvent être que de simples répétiteurs des manoeuvres
effectuées par les joueurs sur chaque client. Le bouton de tir et le curseur de réglage de la hausse
sont donc désactivés, mais les indications fournies obéissent aux injonctions qui leur sont adressées
par l'application principale.
Cette nouvelle classe Pupitre() sera également utilisée telle quelle dans chaque exemplaire du
programme client. Dans la fenêtre de celui-ci comme dans celle du serveur, tous les pupitres seront
affichés comme des répétiteurs, mais l'un d'entre eux cependant sera complètement fonctionnel :
celui qui correspond au canon du joueur.
Toutes ces raisons expliquent également l'apparition des nouvelles méthodes : activer(),
desactiver(), reglage() et valeur_score(), qui seront elles aussi invoquées par l'application
principale, en réponse aux messages-instructions échangés entre le serveur et ses clients.
La classe ThreadConnexion() ci-dessous sert à instancier la série d'objets threads qui
s'occuperont en parallèle de toutes les connexions lancées par les clients. Sa méthode run() contient
la fonctionnalité centrale du serveur, à savoir la boucle d'instructions qui gère la réception des
messages provenant d'un client particulier, lesquels entraînent chacun toute une cascade de
réactions. Vous y trouverez la mise en oeuvre concrète du protocole de communication décrit dans
les pages précédentes (certains messages étant cependant générés par les méthodes
depl_aleat_canon() et goal() de la classe AppServeur() décrite plus loin).
58.
class
ThreadConnexion
(
threading.Thread):
59.
"""objet thread gestionnaire d'une connexion client"""
60.
def
__init__
(
self, boss, conn):
61.
threading.Thread.__init__
(
self)
62.
self.connexion =
conn # réf. du socket de connexion
63.
self.app =
boss # réf. de la fenêtre application
64.
65.
def
run
(
self):
66.
"actions entreprises en réponse aux messages reçus du client"
67.
nom =
self.getName
(
) # id. du client = nom du thread
68.
while
1
:
69.
msgClient =
self.connexion.recv
(
1024
)
70.
print
"**
%s
** de
%s
"
%
(
msgClient, nom)
71.
deb =
msgClient.split
(
','
)[0
]
72.
if
deb ==
"fin"
or
deb ==
""
:
73.
self.app.enlever_canon
(
nom)
74.
# signaler le départ de ce canon aux autres clients :
75.
self.app.verrou.acquire
(
)
76.
for
cli in
self.app.conn_client:
77.
if
cli !=
nom:
78.
message =
"départ_de,
%s
"
%
nom
79.
self.app.conn_client[cli].send
(
message)
80.
self.app.verrou.release
(
)
81.
# fermer le présent thread :
82.
break
83.
elif
deb ==
"client OK"
:
84.
# signaler au nouveau client les canons déjà enregistrés :
85.
msg =
"canons,"
86.
for
g in
self.app.guns:
87.
gun =
self.app.guns[g]
88.
msg =
msg +
"
%s
;
%s
;
%s
;
%s
;
%s
,"
%
\
89.
(
gun.id, gun.x1, gun.y1, gun.sens, gun.coul)
90.
self.app.verrou.acquire
(
)
91.
self.connexion.send
(
msg)
92.
# attendre un accusé de réception ('OK') :
93.
self.connexion.recv
(
100
)
94.
self.app.verrou.release
(
)
95.
# ajouter un canon dans l'espace de jeu serveur.
96.
# la méthode invoquée renvoie les caract. du canon créé :
97.
x, y, sens, coul =
self.app.ajouter_canon
(
nom)
98.
# signaler les caract. de ce nouveau canon à tous les
99.
# clients déjà connectés :
100.
self.app.verrou.acquire
(
)
101.
for
cli in
self.app.conn_client:
102.
msg =
"nouveau_canon,
%s
,
%s
,
%s
,
%s
,
%s
"
%
\
103.
(
nom, x, y, sens, coul)
104.
# pour le nouveau client, ajouter un champ indiquant
105.
# que le message concerne son propre canon :
106.
if
cli ==
nom:
107.
msg =
msg +
",le_vôtre"
108.
self.app.conn_client[cli].send
(
msg)
109.
self.app.verrou.release
(
)
110.
elif
deb ==
'feu'
:
111.
self.app.tir_canon
(
nom)
112.
# Signaler ce tir à tous les autres clients :
113.
self.app.verrou.acquire
(
)
114.
for
cli in
self.app.conn_client:
115.
if
cli !=
nom:
116.
message =
"tir_de,
%s
,"
%
nom
117.
self.app.conn_client[cli].send
(
message)
118.
self.app.verrou.release
(
)
119.
elif
deb ==
"orienter"
:
120.
t =
msgClient.split
(
','
)
121.
# on peut avoir reçu plusieurs angles. utiliser le dernier:
122.
self.app.orienter_canon
(
nom, t[-
2
])
123.
# Signaler ce changement à tous les autres clients :
124.
self.app.verrou.acquire
(
)
125.
for
cli in
self.app.conn_client:
126.
if
cli !=
nom:
127.
# virgule terminale, car messages parfois groupés :
128.
message =
"angle,
%s
,
%s
,"
%
(
nom, t[-
2
])
129.
self.app.conn_client[cli].send
(
message)
130.
self.app.verrou.release
(
)
131.
132.
# Fermeture de la connexion :
133.
self.connexion.close
(
) # couper la connexion
134.
del
self.app.conn_client[nom] # suppr. sa réf. dans le dictionn.
135.
self.app.afficher
(
"Client
%s
déconnecté.
\n
"
%
nom)
136.
# Le thread se termine ici
78 Rappel : dans une classe dérivée, vous pouvez définir une nouvelle méthode avec le même nom qu'une méthode de la classe parente, afin de modifier sa fonctionnalité dans la classe dérivée. Cela s'appelle surcharger cette méthode (voir aussi page 167).
18-7-4. Synchronisation de threads concurrents à l'aide de « verrous » (thread locks)▲
Au cours de votre examen du code ci-dessus, vous aurez certainement remarqué la structure
particulière des blocs d'instructions par lesquelles le serveur expédie un même message à tous ses
clients. Considérez par exemple les lignes 74 à 80 :
La ligne 75 active la méthode acquire() d'un objet « verrou » qui a été créé par le constructeur de
l'application principale (voir plus loin). Cet objet est une instance de la classe Lock(), laquelle fait
partie du module threading que nous avons importé en début de script. Les lignes suivantes
(76 à 79) provoquent l'envoi d'un message à tous les clients connectés (sauf un). Ensuite, l'objet
« verrou » est à nouveau sollicité, cette fois pour sa méthode release().
A quoi cet objet « verrou » peut-il donc bien servir ? Puisqu'il est produit par une classe du
module threading, vous pouvez deviner que son utilité concerne les threads. En fait, de tels objets
« verrous » servent à synchroniser les threads concurrents. De quoi s'agit-il ?
Vous savez que le serveur démarre un thread différent pour chacun des clients qui se connecte.
Ensuite, tous ces threads fonctionnent en parallèle. Il existe donc un risque que de temps à autre,
deux ou plusieurs de ces threads essaient d'utiliser une ressource commune en même temps.
Dans les lignes de code que nous venons de discuter, par exemple, nous avons affaire à un thread
qui souhaite exploiter quasiment toutes les connexions présentes pour poster un message. Il est donc
parfaitement possible que pendant ce temps, un autre thread tente d'exploiter lui aussi l'une ou
l'autre de ces connexions, ce qui risque de provoquer un dysfonctionnement (en l'occurrence, la
superposition chaotique de plusieurs messages).
Un tel problème de concurrence entre threads peut être résolu par l'utilisation d'un objet-verrou
(thread lock). Un tel objet n'est créé qu'en un seul exemplaire, dans un espace de noms accessible à
tous les threads concurrents. Il se caractérise essentiellement par le fait qu'il se trouve toujours dans
l'un ou l'autre de deux états : soit verrouillé, soit déverrouillé. Son état initial est l'état déverrouillé.
Utilisation :
Lorsqu'un thread quelconque s'apprête à accéder à une ressource commune, il active d'abord la
méthode acquire() du verrou. Si celui-ci était dans l'état déverrouillé, il se verrouille, et le thread
demandeur peut alors utiliser la ressource commune, en toute tranquillité. Lorsqu'il aura fini
d'utiliser la ressource, il s'empressera cependant d'activer la méthode release() du verrou, ce qui le
fera repasser dans l'état déverrouillé.
En effet : Si un autre thread concurrent active lui aussi la méthode acquire() du verrou, alors que
celui-ci est dans l'état verrouillé, la méthode « ne rend pas la main », provoquant le blocage de ce
thread, lequel suspend donc son activité jusqu'à ce que le verrou repasse dans l'état déverrouillé.
Ceci l'empêche donc d'accéder à la ressource commune durant tout le temps où un autre thread s'en
sert. Lorsque le verrou est déverrouillé, l'un des threads en attente (il peut en effet y en avoir
plusieurs) reprend alors son activité, et ainsi de suite.
L'objet verrou mémorise les références des threads bloqués, de manière à n'en débloquer qu'un
seul à la fois lorsque sa méthode release() est invoquée. Il faut donc toujours veiller à ce que
chaque thread qui active la méthode acquire() du verrou avant d'accéder à une ressource, active
également sa méthode release() peu après.
Pour autant que tous les threads concurrents respectent la même procédure, cette technique
simple empêche donc qu'une ressource commune soit exploitée en même temps par plusieurs
d'entre eux. On dira dans ce cas que les threads ont été synchronisés.
18-7-5. Programme serveur : suite et fin▲
Les deux classes ci-dessous complètent le script serveur. Le code implémenté dans la classe
ThreadClients() est assez similaire à celui que nous avions développé précédemment pour le corps
d'application du logiciel de « Chat ». Dans le cas présent, toutefois, nous le plaçons dans une classe
dérivée de Thread(), parce que devons faire fonctionner ce code dans un thread indépendant de
celui de l'application principale. Celui-ci est en effet déjà complètement accaparé par la boucle
mainloop() de l'interface graphique79.
La classe AppServeur() dérive de la classe AppBombardes() du module canon04. Nous lui
avons ajouté un ensemble de méthodes complémentaires destinées à exécuter toutes les opérations
qui résulteront du dialogue entamé avec les clients. Nous avons déjà signalé plus haut que les
clients instancieront chacun une version dérivée de cette classe (afin de profiter des mêmes
définitions de base pour la fenêtre, le canevas, etc.).
138.
class
ThreadClients
(
threading.Thread):
139.
"""objet thread gérant la connexion de nouveaux clients"""
140.
def
__init__
(
self, boss, connex):
141.
threading.Thread.__init__
(
self)
142.
self.boss =
boss # réf. de la fenêtre application
143.
self.connex =
connex # réf. du socket initial
144.
145.
def
run
(
self):
146.
"attente et prise en charge de nouvelles connexions clientes"
147.
txt =
"Serveur prêt, en attente de requêtes ...
\n
"
148.
self.boss.afficher
(
txt)
149.
self.connex.listen
(
5
)
150.
# Gestion des connexions demandées par les clients :
151.
while
1
:
152.
nouv_conn, adresse =
self.connex.accept
(
)
153.
# Créer un nouvel objet thread pour gérer la connexion :
154.
th =
ThreadConnexion
(
self.boss, nouv_conn)
155.
th.start
(
)
156.
it =
th.getName
(
) # identifiant unique du thread
157.
# Mémoriser la connexion dans le dictionnaire :
158.
self.boss.enregistrer_connexion
(
nouv_conn, it)
159.
# Afficher :
160.
txt =
"Client
%s
connecté, adresse IP
%s
, port
%s
.
\n
"
%
\
161.
(
it, adresse[0
], adresse[1
])
162.
self.boss.afficher
(
txt)
163.
# Commencer le dialogue avec le client :
164.
nouv_conn.send
(
"serveur OK"
)
165.
166.
class
AppServeur
(
AppBombardes):
167.
"""fenêtre principale de l'application (serveur ou client)"""
168.
def
__init__
(
self, host, port, larg_c, haut_c):
169.
self.host, self.port =
host, port
170.
AppBombardes.__init__
(
self, larg_c, haut_c)
171.
self.active =
1
# témoin d'activité
172.
# veiller à quitter proprement si l'on referme la fenêtre :
173.
self.bind
(
'<Destroy>'
,self.fermer_threads)
174.
175.
def
specificites
(
self):
176.
"préparer les objets spécifiques de la partie serveur"
177.
self.master.title
(
'<<< Serveur pour le jeu des bombardes >>>'
)
178.
179.
# widget Text, associé à une barre de défilement :
180.
st =
Frame
(
self)
181.
self.avis =
Text
(
st, width =
65
, height =
5
)
182.
self.avis.pack
(
side =
LEFT)
183.
scroll =
Scrollbar
(
st, command =
self.avis.yview)
184.
self.avis.configure
(
yscrollcommand =
scroll.set)
185.
scroll.pack
(
side =
RIGHT, fill =
Y)
186.
st.pack
(
)
187.
188.
# partie serveur réseau :
189.
self.conn_client =
{} # dictionn. des connexions clients
190.
self.verrou =
threading.Lock
(
) # verrou pour synchroniser threads
191.
# Initialisation du serveur - Mise en place du socket :
192.
connexion =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
193.
try
:
194.
connexion.bind
((
self.host, self.port))
195.
except
socket.error:
196.
txt =
"La liaison du socket à l'hôte
%s
, port
%s
a échoué.
\n
"
%
\
197.
(
self.host, self.port)
198.
self.avis.insert
(
END, txt)
199.
self.accueil =
None
200.
else
:
201.
# démarrage du thread guettant la connexion des clients :
202.
self.accueil =
ThreadClients
(
self, connexion)
203.
self.accueil.start
(
)
204.
205.
def
depl_aleat_canon
(
self, id):
206.
"déplacer aléatoirement le canon <id>"
207.
x, y =
AppBombardes.depl_aleat_canon
(
self, id)
208.
# signaler ces nouvelles coord. à tous les clients :
209.
self.verrou.acquire
(
)
210.
for
cli in
self.conn_client:
211.
message =
"mouvement_de,
%s
,
%s
,
%s
,"
%
(
id, x, y)
212.
self.conn_client[cli].send
(
message)
213.
self.verrou.release
(
)
214.
215.
def
goal
(
self, i, j):
216.
"le canon <i> signale qu'il a atteint l'adversaire <j>"
217.
AppBombardes.goal
(
self, i, j)
218.
# Signaler les nouveaux scores à tous les clients :
219.
self.verrou.acquire
(
)
220.
for
cli in
self.conn_client:
221.
msg =
'scores,'
222.
for
id in
self.pupi:
223.
sc =
self.pupi[id].valeur_score
(
)
224.
msg =
msg +
"
%s
;
%s
,"
%
(
id, sc)
225.
self.conn_client[cli].send
(
msg)
226.
time.sleep
(
.5
) # pour mieux séparer les messages
227.
self.verrou.release
(
)
228.
229.
def
ajouter_canon
(
self, id):
230.
"instancier un canon et un pupitre de nom <id> dans 2 dictionn."
231.
# on alternera ceux des 2 camps :
232.
n =
len(
self.guns)
233.
if
n %
2
==
0
:
234.
sens =
-
1
235.
else
:
236.
sens =
1
237.
x, y =
self.coord_aleat
(
sens)
238.
coul =(
'dark blue'
, 'dark red'
, 'dark green'
, 'purple'
,
239.
'dark cyan'
, 'red'
, 'cyan'
, 'orange'
, 'blue'
, 'violet'
)[n]
240.
self.guns[id] =
Canon
(
self.jeu, id, x, y, sens, coul)
241.
self.pupi[id] =
Pupitre
(
self, self.guns[id])
242.
self.pupi[id].inactiver
(
)
243.
return
(
x, y, sens, coul)
244.
245.
def
enlever_canon
(
self, id):
246.
"retirer le canon et le pupitre dont l'identifiant est <id>"
247.
if
self.active ==
0
: # la fenêtre a été refermée
248.
return
249.
self.guns[id].effacer
(
)
250.
del
self.guns[id]
251.
self.pupi[id].destroy
(
)
252.
del
self.pupi[id]
253.
254.
def
orienter_canon
(
self, id, angle):
255.
"régler la hausse du canon <id> à la valeur <angle>"
256.
self.guns[id].orienter
(
angle)
257.
self.pupi[id].reglage
(
angle)
258.
259.
def
tir_canon
(
self, id):
260.
"déclencher le tir du canon <id>"
261.
self.guns[id].feu
(
)
262.
263.
def
enregistrer_connexion
(
self, conn, it):
264.
"Mémoriser la connexion dans un dictionnaire"
265.
self.conn_client[it] =
conn
266.
267.
def
afficher
(
self, txt):
268.
"afficher un message dans la zone de texte"
269.
self.avis.insert
(
END, txt)
270.
271.
def
fermer_threads
(
self, evt):
272.
"couper les connexions existantes et fermer les threads"
273.
# couper les connexions établies avec tous les clients :
274.
for
id in
self.conn_client:
275.
self.conn_client[id].send
(
'fin'
)
276.
# forcer la terminaison du thread serveur qui attend les requêtes :
277.
if
self.accueil !=
None
:
278.
self.accueil._Thread__stop
(
)
279.
self.active =
0
# empêcher accès ultérieurs à Tk
280.
281.
if
__name__
==
'__main__'
:
282.
AppServeur
(
host, port, largeur, hauteur).mainloop
(
)
Commentaires :
- Ligne 173 : Il vous arrivera de temps à autre de vouloir « intercepter » l'ordre de fermeture de
l'application que l'utilisateur déclenche en quittant votre programme, par exemple parce que vous
voulez forcer la sauvegarde de données importantes dans un fichier, ou fermer aussi d'autres
fenêtres, etc. Il suffit pour ce faire de détecter l'événement <Destroy>, comme nous le faisons ici
pour forcer la terminaison de tous les threads actifs.
- Lignes 179 à 186 : Au passage, voici comment vous pouvez associer une barre de défilement
(widget Scrollbar) à un widget Text (vous pouvez faire de même avec un widget Canvas), sans
faire appel à la bibliothèque Pmw80.
- Ligne 190 : Instanciation de l'obet « verrou » permettant de synchroniser les threads.
- Lignes 202, 203 : Instanciation de l'objet thread qui attendra en permanence les demandes de
connexion des clients potentiels.
- Lignes 205 à 213, 215 à 227 : Ces méthodes surchargent les méthodes de même nom héritées de
leur classe parente. Elles commencent par invoquer celles-ci pour effectuer le même travail
(lignes 207, 217), puis ajoutent leur fonctionnalité propre, laquelle consiste à signaler à tout le
monde ce qui vient de se passer.
- Lignes 229 à 243 : Cette méthode instancie un nouveau poste de tir, chaque fois qu'un nouveau client se connecte. Les canons sont placés alternativement dans le camp de droite et dans celui de gauche, procédure qui pourrait bien évidemment être améliorée. La liste des couleurs prévues limite le nombre de clients à 10, ce qui devrait suffire.
79 Nous détaillerons cette question quelques pages plus loin, car elle ouvre quelques perspectives intéressantes.
Voir : Optimiser les animations à l'aide des threads, page 300.
80 Voir : Python Mega Widgets, page 207.
18-7-6. Programme client▲
Le script correspondant au logiciel client est reproduit ci-après. Comme celui qui correspond au
serveur, il est relativement court, parce qu'il utilise lui aussi l'importation de modules et l'héritage de
classes. Le script serveur doit avoir été sauvegardé dans un fichier-module nommé
canon_serveur.py. Ce fichier doit être placé dans le répertoire courant, de même que les fichiersmodules
canon03.py et canon04.py qu'il utilise lui-même.
De ces modules ainsi importés, le présent script utilise les classes Canon() et Pupitre() à
l'identique, ainsi qu'une forme dérivée de la classe AppServeur(). Dans cette dernière, de
nombreuses méthodes ont été surchargées, afin d'adapter leur fonctionnalité. Considérez par
exemple les méthodes goal() et depl_aleat_canon(), dont la variante surchargée ne fait plus rien du
tout (instruction pass), parce que le calcul des scores et le repositionnement des canons après
chaque tir ne peuvent être effectués que sur le serveur seulement.
C'est dans la méthode run() de la classe ThreadSocket() (lignes 86 à 126) que se trouve le code
traitant les messages échangés avec le serveur. Nous y avons d'ailleurs laissé une instruction print
(à la ligne 88) afin que les messages reçus du serveur apparaissent sur la sortie standard. Si vous
réalisez vous-même une forme plus définitive de ce jeu, vous pourrez bien évidemment supprimer
cette instruction.
1.
#######################################################
2.
# Jeu des bombardes - partie cliente #
3.
# (C) Gérard Swinnen, Liège (Belgique) - Juillet 2004 #
4.
# Licence : GPL #
5.
# Avant d'exécuter ce script, vérifiez que l'adresse, #
6.
# le numéro de port et les dimensions de l'espace de #
7.
# jeu indiquées ci-dessous correspondent exactement #
8.
# à ce qui a été défini pour le serveur. #
9.
#######################################################
10.
11.
from
Tkinter import
*
12.
import
socket, sys, threading, time
13.
from
canon_serveur import
Canon, Pupitre, AppServeur
14.
15.
host, port =
'192.168.0.235'
, 35000
16.
largeur, hauteur =
700
, 400
# dimensions de l'espace de jeu
17.
18.
class
AppClient
(
AppServeur):
19.
def
__init__
(
self, host, port, larg_c, haut_c):
20.
AppServeur.__init__
(
self, host, port, larg_c, haut_c)
21.
22.
def
specificites
(
self):
23.
"préparer les objets spécifiques de la partie client"
24.
self.master.title
(
'<<< Jeu des bombardes >>>'
)
25.
self.connex =
ThreadSocket
(
self, self.host, self.port)
26.
self.connex.start
(
)
27.
self.id =
None
28.
29.
def
ajouter_canon
(
self, id, x, y, sens, coul):
30.
"instancier 1 canon et 1 pupitre de nom <id> dans 2 dictionnaires"
31.
self.guns[id] =
Canon
(
self.jeu, id, int(
x),int(
y),int(
sens), coul)
32.
self.pupi[id] =
Pupitre
(
self, self.guns[id])
33.
self.pupi[id].inactiver
(
)
34.
35.
def
activer_pupitre_personnel
(
self, id):
36.
self.id =
id # identifiant reçu du serveur
37.
self.pupi[id].activer
(
)
38.
39.
def
tir_canon
(
self, id):
40.
r =
self.guns[id].feu
(
) # renvoie False si enrayé
41.
if
r and
id ==
self.id:
42.
self.connex.signaler_tir
(
)
43.
44.
def
imposer_score
(
self, id, sc):
45.
self.pupi[id].valeur_score
(
int(
sc))
46.
47.
def
deplacer_canon
(
self, id, x, y):
48.
"note: les valeurs de x et y sont reçues en tant que chaînes"
49.
self.guns[id].deplacer
(
int(
x), int(
y))
50.
51.
def
orienter_canon
(
self, id, angle):
52.
"régler la hausse du canon <id> à la valeur <angle>"
53.
self.guns[id].orienter
(
angle)
54.
if
id ==
self.id:
55.
self.connex.signaler_angle
(
angle)
56.
else
:
57.
self.pupi[id].reglage
(
angle)
58.
59.
def
fermer_threads
(
self, evt):
60.
"couper les connexions existantes et refermer les threads"
61.
self.connex.terminer
(
)
62.
self.active =
0
# empêcher accès ultérieurs à Tk
63.
64.
def
depl_aleat_canon
(
self, id):
65.
pass
# => méthode inopérante
66.
67.
def
goal
(
self, a, b):
68.
pass
# => méthode inopérante
69.
70.
71.
class
ThreadSocket
(
threading.Thread):
72.
"""objet thread gérant l'échange de messages avec le serveur"""
73.
def
__init__
(
self, boss, host, port):
74.
threading.Thread.__init__
(
self)
75.
self.app =
boss # réf. de la fenêtre application
76.
# Mise en place du socket - connexion avec le serveur :
77.
self.connexion =
socket.socket
(
socket.AF_INET, socket.SOCK_STREAM)
78.
try
:
79.
self.connexion.connect
((
host, port))
80.
except
socket.error:
81.
print
"La connexion a échoué."
82.
sys.exit
(
)
83.
print
"Connexion établie avec le serveur."
84.
85.
def
run
(
self):
86.
while
1
:
87.
msg_recu =
self.connexion.recv
(
1024
)
88.
print
"*
%s
*"
%
msg_recu
89.
# le message reçu est d'abord converti en une liste :
90.
t =
msg_recu.split
(
','
)
91.
if
t[0
] ==
""
or
t[0
] ==
"fin"
:
92.
# fermer le présent thread :
93.
break
94.
elif
t[0
] ==
"serveur OK"
:
95.
self.connexion.send
(
"client OK"
)
96.
elif
t[0
] ==
"canons"
:
97.
self.connexion.send
(
"OK"
) # accusé de réception
98.
# éliminons le 1er et le dernier élément de la liste.
99.
# ceux qui restent sont eux-mêmes des listes :
100.
lc =
t[1
:-
1
]
101.
# chacune est la description complète d'un canon :
102.
for
g in
lc:
103.
s =
g.split
(
';'
)
104.
self.app.ajouter_canon
(
s[0
], s[1
], s[2
], s[3
], s[4
])
105.
elif
t[0
] ==
"nouveau_canon"
:
106.
self.app.ajouter_canon
(
t[1
], t[2
], t[3
], t[4
], t[5
])
107.
if
len(
t) >
6
:
108.
self.app.activer_pupitre_personnel
(
t[1
])
109.
elif
t[0
] ==
'angle'
:
110.
# il se peut que l'on ait reçu plusieurs infos regroupées.
111.
# on ne considère alors que la première :
112.
self.app.orienter_canon
(
t[1
], t[2
])
113.
elif
t[0
] ==
"tir_de"
:
114.
self.app.tir_canon
(
t[1
])
115.
elif
t[0
] ==
"scores"
:
116.
# éliminons le 1er et le dernier élément de la liste.
117.
# ceux qui restent sont eux-mêmes des listes :
118.
lc =
t[1
:-
1
]
119.
# chaque élément est la description d'un score :
120.
for
g in
lc:
121.
s =
g.split
(
';'
)
122.
self.app.imposer_score
(
s[0
], s[1
])
123.
elif
t[0
] ==
"mouvement_de"
:
124.
self.app.deplacer_canon
(
t[1
],t[2
],t[3
])
125.
elif
t[0
] ==
"départ_de"
:
126.
self.app.enlever_canon
(
t[1
])
127.
128.
# Le thread <réception> se termine ici.
129.
print
"Client arrêté. Connexion interrompue."
130.
self.connexion.close
(
)
131.
132.
def
signaler_tir
(
self):
133.
self.connexion.send
(
'feu'
)
134.
135.
def
signaler_angle
(
self, angle):
136.
self.connexion.send
(
'orienter,
%s
,'
%
angle)
137.
138.
def
terminer
(
self):
139.
self.connexion.send
(
'fin'
)
140.
141.
# Programme principal :
142.
if
__name__
==
'__main__'
:
143.
AppClient
(
host, port, largeur, hauteur).mainloop
(
)
Commentaires :
- Lignes 15, 16 : Vous pouvez vous-même perfectionner ce script en lui ajoutant un formulaire qui
demandera ces valeurs à l'utilisateur au cours du démarrage.
- Lignes 19 à 27 : Le constructeur de la classe parente se termine en invoquant la méthode
specificites(). On peut donc placer dans celle-ci ce qui doit être construit différemment dans le
serveur et dans les clients. (Le serveur instancie notamment un widget text qui n'est pas repris
dans les clients ; l'un et l'autre démarrent des objets threads différents pour gérer les connexions).
- Lignes 39 à 42 : Cette méthode est invoquée chaque fois que l'utilisateur enfonce le bouton de
tir. Le canon ne peut cependant pas effectuer des tirs en rafale. Par conséquent, aucun nouveau
tir ne peut être accepté tant que l'obus précédent n'a pas terminé sa trajectoire. C'est la valeur
« vraie » ou « fausse » renvoyée par la méthode feu() de l'objet canon qui indique si le tir a été
accepté ou non. On utilise cette valeur pour ne signaler au serveur (et donc aux autres clients)
que les tirs qui ont effectivement eu lieu.
- Lignes 105 à 108 : Un nouveau canon doit être ajouté dans l'espace de jeu de chacun (c'est-à-dire dans le canevas du serveur, et dans le canevas de tous les clients connectés), chaque fois qu'un nouveau client se connecte. Le serveur envoie donc à ce moment un même message à tous les clients pour les informer de la présence de ce nouveau partenaire. Mais le message envoyé à celui-ci en particulier comporte un champ supplémentaire (lequel contient simplement la chaîne « le_vôtre »), afin que ce partenaire sache que ce message concerne son propre canon, et qu'il puisse donc activer le pupitre correspondant, tout en mémorisant l'identifiant qui lui a été attribué par le serveur (voir également les lignes 35 à 37).
Conclusions et perspectives :
Cette application vous a été présentée dans un but didactique. Nous y avons délibérément
simplifié un certain nombre de problèmes. Par exemple, si vous testez vous-même ces logiciels,
vous constaterez que les messages échangés sont souvent rassemblés en « paquets », ce qui
nécessiterait d'affiner les algorithmes mis en place pour les interpréter. De même, nous avons à
peine esquissé le mécanisme fondamental du jeu : répartition des joueurs dans les deux camps,
destruction des canons touchés, obstacles divers, etc. Il vous reste bien des pistes à explorer !
(18) Exercices :
18.1. Simplifiez le script correspondant au client de « chat » décrit à la page 283, en supprimant
l'un des deux objets threads. Arrangez-vous par exemple pour traiter l'émission de messages
au niveau du thread principal.
18.2. Modifiez le jeu des bombardes (version monoposte) du chapitre 15 (voir pages 227 et
suivantes), en ne gardant qu'un seul canon et un seul pupitre de pointage. Ajoutez-y une
cible mobile, dont le mouvement sera géré par un objet thread indépendant (de manière à
bien séparer les portions de code qui contrôlent l'animation de la cible et celle du boulet).
18-8. Utilisation de threads pour optimiser les animations▲
Le dernier exercice proposé à la fin de la section précédente nous suggère une méthodologie de
développements d'applications qui peut se révéler particulièrement intéressante, dans le cas de jeux
vidéo impliquant plusieurs animations simultanées.
En effet : si vous programmez les différents éléments animés d'un jeu comme des objets
indépendants fonctionnant chacun sur son propre thread, alors non seulement vous vous simplifiez
la tâche et vous améliorez la lisibilité de votre script, mais encore vous augmentez la vitesse
d'exécution et donc la fluidité de ces animations. Pour arriver à ce résultat, vous devrez abandonner
la technique de temporisation que vous avez exploitée jusqu'ici, mais celle que vous allez utiliser à
sa place est finalement plus simple !
18-8-1. Temporisation des animations à l'aide de after()▲
Dans toutes les animations que nous avons décrites jusqu'à présent, le « moteur » était constitué
à chaque fois par une fonction contenant la méthode after(), laquelle est associée d'office à tous les
widgets Tkinter. Vous savez que cette méthode permet d'introduire une temporisation dans le
déroulement de votre programme : un chronomètre interne est activé, de telle sorte qu'après un
intervalle de temps convenu, le système invoque automatiquement une fonction quelconque. En
général, c'est la fonction contenant after() qui est elle-même invoquée : on réalise ainsi une boucle
récursive, dans laquelle il reste à programmer les déplacements des divers objets graphiques.
Vous devez bien comprendre que pendant l'écoulement de l'intervalle de temps programmé à
l'aide de la méthode after(), votre application n'est pas du tout « figée ». Vous pouvez par exemple
pendant ce temps : cliquer sur un bouton, redimensionner la fenêtre, effectuer une entrée clavier,
etc. Comment cela est-il rendu possible ?
Nous avons mentionné déjà à plusieurs reprises le fait que les applications graphiques modernes
comportent toujours une sorte de moteur qui « tourne » continuellement en tâche de fond : ce
dispositif se met en route lorsque vous activez la méthode mainloop() de votre fenêtre principale.
Comme son nom l'indique fort bien, cette méthode met en oeuvre une boucle répétitive perpétuelle,
du même type que les boucles while que vous connaissez bien. De nombreux mécanismes sont
intégrés à ce « moteur ». L'un d'entre eux consiste à réceptionner tous les événements qui se
produisent, et à les signaler ensuite à l'aide de messages appropriés aux programmes qui en font la
demande (voir : Programmes pilotés par des événements, page 85), d'autres contrôlent les actions à
effectuer au niveau de l'affichage, etc. Lorsque vous faites appel à la méthode after() d'un widget,
vous utilisez en fait un mécanisme de chronométrage qui est intégré lui aussi à mainloop(), et c'est
donc ce gestionnaire central qui déclenche l'appel de fonction que vous souhaitez, après un certain
intervalle de temps.
La technique d'animation utilisant la méthode after() est la seule possible pour une application
fonctionnant toute entière sur un seul thread, parce que c'est la boucle mainloop() qui dirige
l'ensemble du comportement d'une telle application de manière absolue. C'est notamment elle qui se
charge de redessiner tout ou partie de la fenêtre chaque fois que cela s'avère nécessaire. Pour cette
raison, vous ne pouvez pas imaginer de construire un moteur d'animation qui redéfinirait les
coordonnées d'un objet graphique à l'intérieur d'une simple boucle while, par exemple, parce que
pendant tout ce temps l'exécution de mainloop() resterait suspendue, ce qui aurait pour
conséquence que pendant tout ce temps aucun objet graphique ne serait redessiné (en particulier
celui que vous souhaitez mettre en mouvement !). En fait, toute l'application apparaîtrait figée, aussi
longtemps que la boucle while ne serait pas interrompue.
Puisqu'elle est la seule possible, c'est donc cette technique que nous avons utilisée jusqu'à présent
dans tous nos exemples d'applications mono-thread. Elle comporte cependant un inconvénient
gênant : du fait du grand nombre d'opérations prises en charge à chaque itération de la boucle
mainloop(), la temporisation que l'on peut programmer à l'aide de after() ne peut pas être très
courte. Par exemple, elle ne peut guère descendre en dessous de 15 ms sur un PC typique
(processeur de type Pentium IV, f = 1,5 GHz). Vous devez tenir compte de cette limitation si vous
souhaitez développer des animations rapides.
Un autre inconvénient lié à l'utilisation de la méthode after() réside dans la structure de la boucle
d'animation (à savoir une fonction ou une méthode « récursive », c'est-à-dire qui s'appelle ellemême)
: il n'est pas toujours simple en effet de bien maîtriser ce genre de construction logique, en
particulier si l'on souhaite programmer l'animation de plusieurs objets graphiques indépendants,
dont le nombre ou les mouvements doivent varier au cours du temps.
18-8-2. Temporisation des animations à l'aide de time.sleep()▲
Vous pouvez ignorer les limitations de la méthode after() évoquées ci-dessus, si vous en confiez
l'animation de vos objets graphiques à des threads indépendants. En procédant ainsi, vous vous
libérez de la tutelle de mainloop(), et il vous est permis alors de construire des procédures
d'animation sur la base de structures de boucles plus « classiques », utilisant l'instruction while ou
l'instruction for par exemple.
Au coeur de chacune de ces boucles, vous devez cependant toujours veiller à insérer une
temporisation pendant laquelle vous « rendez la main » au système d'exploitation (afin qu'il puisse
s'occuper des autres threads). Pour ce faire, vous ferez appel à la fonction sleep() du module time.
Cette fonction permet de suspendre l'exécution du thread courant pendant un certain intervalle de
temps, pendant lequel les autres threads et applications continuent à fonctionner. La temporisation
ainsi produite ne dépend pas de mainloop(), et par conséquent, elle peut être beaucoup plus courte
que celle que vous autorise la méthode after().
Attention : cela ne signifie pas que le rafraîchissement de l'écran sera lui-même plus rapide, car
ce rafraîchissement continue à être assuré par mainloop(). Vous pourrez cependant accélérer
fortement les différents mécanismes que vous installez vous-même dans vos procédures
d'animation. Dans un logiciel de jeu, par exemple, il est fréquent d'avoir à comparer périodiquement
les positions de deux mobiles (tels qu' un projectile et une cible), afin de pouvoir entreprendre une
action lorsqu'ils se rejoignent (explosion, ajout de points à un score, etc.). Avec la technique
d'animation décrite ici, vous pouvez effectuer beaucoup plus souvent ces comparaisons et donc
espérer un résultat plus précis. De même, vous pouvez augmenter le nombre de points pris en
considération pour le calcul d'une trajectoire en temps réel, et donc affiner celle-ci.
Remarque : Lorsque vous utilisez la méthode after(), vous devez lui indiquer la temporisation
souhaitée en millisecondes, sous la forme d'un argument entier. Lorsque vous faites appel à la
fonction sleep(), par contre, l'argument que vous transmettez doit être exprimé en secondes, sous la
forme d'un réel (float). Vous pouvez cependant utiliser des très petites valeurs (0.0003 par ex.).
18-8-3. Exemple concret▲
Le petit script reproduit ci-dessous illustre la mise en oeuvre de cette technique, dans un exemple volontairement minimaliste. Il s'agit d'une petite application graphique dans laquelle une figure se déplace en cercle à l'intérieur d'un canevas. Son « moteur » mainloop() est lancé comme d'habitude sur le thread principal. Le constructeur de l'application instancie un canevas contenant le dessin d'un cercle, un bouton et un objet thread. C'est cet objet thread qui assure l'animation du dessin, mais sans faire appel à la méthode after() d'un widget. Il utilise plutôt une simple boucle while très classique, installée dans sa méthode run().
1.
from
Tkinter import
*
2.
from
math import
sin, cos
3.
import
time, threading
4.
5.
class
App
(
Frame):
6.
def
__init__
(
self):
7.
Frame.__init__
(
self)
8.
self.pack
(
)
9.
can =
Canvas
(
self, width =
400
, height =
400
,
10.
bg =
'ivory'
, bd =
3
, relief =
SUNKEN)
11.
can.pack
(
padx =
5
, pady =
5
)
12.
cercle =
can.create_oval
(
185
, 355
, 215
, 385
, fill =
'red'
)
13.
tb =
Thread_balle
(
can, cercle)
14.
Button
(
self, text =
'Marche'
, command =
tb.start).pack
(
side =
LEFT)
15.
# Button(self, text ='Arrêt', command =tb.stop).pack(side =RIGHT)
16.
# arrêter l'autre thread si l'on ferme la fenêtre :
17.
self.bind
(
'<Destroy>'
, tb.stop)
18.
19.
class
Thread_balle
(
threading.Thread):
20.
def
__init__
(
self, canevas, dessin):
21.
threading.Thread.__init__
(
self)
22.
self.can, self.dessin =
canevas, dessin
23.
self.anim =
1
24.
25.
def
run
(
self):
26.
a =
0.0
27.
while
self.anim ==
1
:
28.
a +=
.01
29.
x, y =
200
+
170
*
sin
(
a), 200
+
170
*
cos
(
a)
30.
self.can.coords
(
self.dessin, x-
15
, y-
15
, x+
15
, y+
15
)
31.
time.sleep
(
0.010
)
32.
33.
def
stop
(
self, evt =
0
):
34.
self.anim =
0
35.
36.
App
(
).mainloop
(
)
Commentaires :
- Lignes 13 & 14 : Afin de simplifier notre exemple au maximum, nous créons l'objet thread
chargé de l'animation, directement dans le constructeur de l'application principale. Cet objet
thread ne démarrera cependant que lorsque l'utilisateur aura cliqué sur le bouton « Marche », qui
active sa méthode start() (rappelons ici que c'est cette méthode intégrée qui lancera elle-même la
méthode run() où nous avons installé notre boucle d'animation).
- Ligne 15 : Vous ne pouvez par redémarrer un thread qui s'est terminé. De ce fait, vous ne pouvez
lancer cette animation qu'une seule fois (tout au moins sous la forme présentée ici). Pour vous en
convaincre, activez la ligne n° 15 en enlevant le caractère # situé au début (et qui fait que Python
considère qu'il s'agit d'un simple commentaire) : lorsque l'animation est lancée, un clic de souris
sur le bouton ainsi mis en place provoque la sortie de la boucle while des lignes 27-31, ce qui
termine la méthode run(). L'animation s'arrête, mais le thread qui la gérait s'est terminé lui aussi.
Si vous essayez de le relancer à l'aide du bouton « Marche », vous n'obtenez rien d'autre qu'un
message d'erreur.
- Lignes 26 à 31 : Pour simuler un mouvement circulaire uniforme, il suffit de faire varier
continuellement la valeur d'un angle a. Le sinus et le cosinus de cet angle permettent alors de
calculer les coordonnées x et y du point de la circonférence qui correspond à cet angle81.
À chaque itération, l'angle ne varie que d'un centième de radian seulement (environ 0,6°), et il faudra donc 628 itérations pour que le mobile effectue un tour complet. La temporisation choisie pour ces itérations se trouve à la ligne 31 : 10 millisecondes. Vous pouvez accélérer le mouvement en diminuant cette valeur, mais vous ne pourrez guère descendre en dessous de 1 milliseconde (0.001 s), ce qui n'est déjà pas si mal.
81 Vous pouvez trouver quelques explications complémentaires à ce sujet, à la page 230.