Apprendre à programmer avec Python 3

Image de couverture python 3


précédentsommairesuivant

20. Communications à travers un réseau & Multithreading.

Le développement extraordinaire de l'internet a amplement démontré que les ordinateurs peuvent être des outils de communication extrêmement efficaces. Dans ce chapitre, nous allons explorer les bases de cette technologie, en effectuant quelques expériences avec la plus fondamentale des méthodes d'interconnexion entre programmes, afin de mettre en évidence ce qui doit être mis en place pour assurer la transmission simultanée d'informations entre plusieurs partenaires.

Pour ce qui va suivre, nous supposerons donc que vous collaborez avec d'autres personnes, 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 œuvre 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 œuvre dans des fenêtres indépendantes.

20-A. 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 adresseIP spécifique(103). 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 œuvre un objet logiciel associé à ce port, que l'on appelle un socket.

Depuis 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. À 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 donc des sockets. Ceux-ci peuvent mettre en œuvre 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 connexioncontinue, ou streamsocket, qui est un peu plus simple.

20-A-1. Construction d'un serveur rudimentaire

Pour nos premières expériences, nous allons utiliser la technique des streamsockets.
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 œuvre, 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 œuvre est un peu plus complexe. Nous ne l'étudierons pas dans ce livre.

Le premier 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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
# Définition d'un serveur réseau rudimentaire
# Ce serveur attend la connexion d'un client
 
import socket, sys
 
HOST = '192.168.1.168'
PORT = 50000
counter =0	 # compteur de connexions actives
 
# 1) création du socket :
mySocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
# 2) liaison du socket à une adresse précise :
try:
  mySocket.bind((HOST, PORT))
except socket.error:
  print("La liaison du socket à l'adresse choisie a échoué.")
  sys.exit
 
while 1:
  # 3) Attente de la requête de connexion d'un client :
  print("Serveur prêt, en attente de requêtes ...")
  mySocket.listen(2)
 
  # 4) Etablissement de la connexion :
  connexion, adresse = mySocket.accept()
  counter +=1
  print("Client connecté, adresse IP %s, port %s" % (adresse[0], adresse[1]))
 
  # 5) Dialogue avec le client :
  msgServeur ="Vous êtes connecté au serveur Marcel. Envoyez vos messages."
  connexion.send(msgServeur.encode("Utf8"))
  msgClient = connexion.recv(1024).decode("Utf8")
  while 1:
      print("C>", msgClient)
      if msgClient.upper() == "FIN" or msgClient =="":
      break
      msgServeur = input("S> ")
      connexion.send(msgServeur.encode("Utf8"))
      msgClient = connexion.recv(1024).decode("Utf8")
 
  # 6) Fermeture de la connexion :
  connexion.send("fin".encode("Utf8"))
  print("Connexion interrompue.")
  connexion.close()
 
  ch = input("<R>ecommencer <T>erminer ? ")
  if ch.upper() =='T':
      break

20-A-1-A. Commentaires

  • Ligne 4 : Le module socket contient toutes les fonctions et les classes nécessaires pour construire des programmes communicants. 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.
  • Lignes 10-11 : 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 13 à 18 : 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 concernant la ligne 15 : 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 20 : 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 21 à 23 : 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 25 à 28 : 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()(104), 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 no de port qu'il utilise lui-même).
  • Lignes 30 à 33 : 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 impérativement être des chaînes d'octets. À l'émission, il faut donc prévoir explicitement la conversion des chaînes de caractères en données de type bytes, et faire l'inverse à la réception.
    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 34 à 40 : 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 42 à 45 : Sixième étape. Fermeture de la connexion.

20-A-2. 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é.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
# Définition d'un client réseau rudimentaire
# Ce client dialogue avec un serveur ad hoc
 
import socket, sys
 
HOST = '192.168.1.168'
PORT = 50000
 
# 1) création du socket :
mySocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
# 2) envoi d'une requête de connexion au serveur :
try:
  mySocket.connect((HOST, PORT))
except socket.error:
  print("La connexion a échoué.")
  sys.exit()
print("Connexion établie avec le serveur.")
 
# 3) Dialogue avec le serveur :
msgServeur = mySocket.recv(1024).decode("Utf8")
 
while 1:
  if msgServeur.upper() == "FIN" or msgServeur =="":
      break
  print("S>", msgServeur)
  msgClient = input("C> ")
  mySocket.send(msgClient.encode("Utf8"))
  msgServeur = mySocket.recv(1024).decode("Utf8")
 
# 4) Fermeture de la connexion :
print("Connexion interrompue.")
mySocket.close()

20-A-2-A. 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.

20-B. Gestion de plusieurs tâches en parallèle à l'aide de 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, 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 entre-temps 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âche 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 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 œuvre 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 threads(105).

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 threadprincipal. À partir de celui-ci, d'autres threadsenfants 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.

20-B-1. Client réseau 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(106) 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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
# Définition d'un client réseau gérant en parallèle l'émission
# et la réception des messages (utilisation de 2 THREADS).
 
host = '192.168.1.168'
port = 46000
 
import socket, sys, threading
 
class ThreadReception(threading.Thread):
  """objet thread gérant la réception des messages"""
  def __init__(self, conn):
      threading.Thread.__init__(self)
      self.connexion = conn	     # réf. du socket de connexion
 
  def run(self):
      while 1:
      message_recu = self.connexion.recv(1024).decode("Utf8")
      print("*" + message_recu + "*")
      if not message_recu or message_recu.upper() =="FIN":
	  break
      # Le thread <réception> se termine ici.
      # On force la fermeture du thread <émission> :
      th_E._stop()
      print("Client arrêté. Connexion interrompue.")
      self.connexion.close()
 
class ThreadEmission(threading.Thread):
  """objet thread gérant l'émission des messages"""
  def __init__(self, conn):
      threading.Thread.__init__(self)
      self.connexion = conn	     # réf. du socket de connexion
 
  def run(self):
      while 1:
      message_emis = input()
      self.connexion.send(message_emis.encode("Utf8"))
 
# Programme principal - Établissement de la connexion :
connexion = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
  connexion.connect((host, port))
except socket.error:
  print("La connexion a échoué.")
  sys.exit()
print("Connexion établie avec le serveur.")
 
# Dialogue avec le serveur : on lance deux threads pour gérer
# indépendamment l'émission et la réception des messages :
th_E = ThreadEmission(connexion)
th_R = ThreadReception(connexion)
th_E.start()
th_R.start()

20-B-1-A. 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 _stop()(107).
  • 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 pourra donc se terminer que contraint et forcé par la 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é).

20-B-2. Serveur réseau 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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
# Définition d'un serveur réseau gérant un système de CHAT simplifié.
# Utilise les threads pour gérer les connexions clientes en parallèle.
 
HOST = '192.168.1.168'
PORT = 46000
 
import socket, sys, threading
 
class ThreadClient(threading.Thread):
  '''dérivation d'un objet thread pour gérer la connexion avec un client'''
  def __init__(self, conn):
      threading.Thread.__init__(self)
      self.connexion = conn
 
  def run(self):
      # Dialogue avec le client :
      nom = self.getName()	    # Chaque thread possède un nom
      while 1:
      msgClient = self.connexion.recv(1024).decode("Utf8")
      if not msgClient or msgClient.upper() =="FIN":
	  break
      message = "%s> %s" % (nom, msgClient)
      print(message)
      # Faire suivre le message à tous les autres clients :
      for cle in conn_client:
	  if cle != nom:	  # ne pas le renvoyer à l'émetteur
	  conn_client[cle].send(message.encode("Utf8"))
 
      # Fermeture de la connexion :
      self.connexion.close()	  # couper la connexion côté serveur
      del conn_client[nom]	# supprimer son entrée dans le dictionnaire
      print("Client %s déconnecté." % nom)
      # Le thread se termine ici
 
# Initialisation du serveur - Mise en place du socket :
mySocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
  mySocket.bind((HOST, PORT))
except socket.error:
  print("La liaison du socket à l'adresse choisie a échoué.")
  sys.exit()
print("Serveur prêt, en attente de requêtes ...")
mySocket.listen(5)
 
# Attente et prise en charge des connexions demandées par les clients :
conn_client = {}	# dictionnaire des connexions clients
while 1:
  connexion, adresse = mySocket.accept()
  # Créer un nouvel objet thread pour gérer la connexion :
  th = ThreadClient(connexion)
  th.start()
  # Mémoriser la connexion dans le dictionnaire :
  it = th.getName()	  # identifiant du thread
  conn_client[it] = connexion
  print("Client %s connecté, adresse IP %s, port %s." %\
     (it, adresse[0], adresse[1]))
  # Dialogue avec le client :
  msg ="Vous êtes connecté. Envoyez vos messages."
  connexion.send(msg.encode("Utf8"))

20-B-2-A. 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 d'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 d'où 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).

20-C. Jeu des bombardes, version réseau

Image non disponible

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 (Linux ↔ Windows ↔ MacOS !).

20-C-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.

À 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.

20-C-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 efficaces ou plus simples.

Vous savez déjà que les messages échangés sont de simples chaînes d'octets. 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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
*serveur OK*
client OK
*canons,Thread-3;104;228;1;dark red,Thread-2;454;166;-1;dark blue,*
OK
*nouveau_canon,Thread-4,481,245,-1,dark green,le_vôtre*
orienter,25,
feu
*mouvement_de,Thread-4,549,280,*
feu
*mouvement_de,Thread-4,504,278,*
*scores,Thread-4;1,Thread-3;-1,Thread-2;0,*
*angle,Thread-2,23,*
*angle,Thread-2,20,*
*tir_de,Thread-2,*
*mouvement_de,Thread-2,407,191,*
*départ_de,Thread-2*
*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.

20-C-2-A. 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).

20-C-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.) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
#######################################################
# Jeu des bombardes - partie serveur		 #
# (C) Gérard Swinnen, Verviers (Belgique) - July 2004 #
# Licence : GPL 	       rév. 2010 #
# Avant d'exécuter ce script, vérifiez que l'adresse  #
# IP ci-dessous soit bien celle de la machine hôte.   #
# Vous pouvez choisir un numéro de port différent, ou #
# changer les dimensions de l'espace de jeu.	     #
# Dans tous les cas, vérifiez que les mêmes choix ont #
# été effectués pour chacun des scripts clients.      #
#######################################################
 
host, port = '192.168.1.168', 36000
largeur, hauteur = 700, 400	   # dimensions de l'espace de jeu
 
from tkinter import *
import socket, sys, threading, time
import canon03
from canon04 import Canon, AppBombardes
 
class Pupitre(canon03.Pupitre):
  """Pupitre de pointage amélioré"""
  def __init__(self, boss, canon):
      canon03.Pupitre.__init__(self, boss, canon)
 
  def tirer(self):
      "déclencher le tir du canon associé"
      self.appli.tir_canon(self.canon.id)
 
  def orienter(self, angle):
      "ajuster la hausse du canon associé"
      self.appli.orienter_canon(self.canon.id, angle)
 
  def valeur_score(self, sc =None):
      "imposer un nouveau score <sc>, ou lire le score existant"
      if sc == None:
      return self.score
      else:
      self.score =sc
      self.points.config(text = ' %s ' % self.score)
 
  def inactiver(self):
      "désactiver le bouton de tir et le système de réglage d'angle"
      self.bTir.config(state =DISABLED)
      self.regl.config(state =DISABLED)
 
  def activer(self):
      "activer le bouton de tir et le système de réglage d'angle"
      self.bTir.config(state =NORMAL)
      self.regl.config(state =NORMAL)
 
  def reglage(self, angle):
      "changer la position du curseur de réglage"
      self.regl.config(state =NORMAL)
      self.regl.set(angle)
      self.regl.config(state =DISABLED)
  • La classe Pupitre() est construite par dérivation de la classe de même nom importée du module canon03. Elle hérite donc toutes les caractéristiques de celle-ci, mais nous devons surcharger(108) 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 manœuvres 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 œuvre 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).
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
class ThreadConnexion(threading.Thread):
  """objet thread gestionnaire d'une connexion client"""
  def __init__(self, boss, conn):
      threading.Thread.__init__(self)
      self.connexion = conn	     # réf. du socket de connexion
      self.app = boss		   # réf. de la fenêtre application
 
  def run(self):
      "actions entreprises en réponse aux messages reçus du client"
      nom = self.getName()	    # id. du client = nom du thread
      while 1:
      msgClient = self.connexion.recv(1024).decode("Utf8")
      print("**{}** de {}".format(msgClient, nom))
      deb = msgClient.split(',')[0]
      if deb == "fin" or deb =="":
	  self.app.enlever_canon(nom)
	  # signaler le départ de ce canon aux autres clients :
	  self.app.verrou.acquire()
	  for cli in self.app.conn_client:
	  if cli != nom:
	      message = "départ_de,{}".format(nom)
	      self.app.conn_client[cli].send(message.encode("Utf8"))
	  self.app.verrou.release()
	  # fermer le présent thread :
	  break
      elif deb =="client OK":
	  # signaler au nouveau client les canons déjà enregistrés :
	  msg ="canons,"
	  for g in self.app.guns:
	  gun = self.app.guns[g]
	  msg =msg +"{};{};{};{};{},".\
	      format(gun.id, gun.x1, gun.y1, gun.sens, gun.coul)
	  self.app.verrou.acquire()
	  self.connexion.send(msg.encode("Utf8"))
	  # attendre un accusé de réception ('OK') :
	  self.connexion.recv(100).decode("Utf8")
	  self.app.verrou.release()
	  # ajouter un canon dans l'espace de jeu serveur.
	  # la méthode invoquée renvoie les caract. du canon créé :
	  x, y, sens, coul = self.app.ajouter_canon(nom)
	  # signaler les caract. de ce nouveau canon à tous les
	  # clients déjà connectés :
	  self.app.verrou.acquire()
	  for cli in self.app.conn_client:
	  msg ="nouveau_canon,{},{},{},{},{}".\
	      format(nom, x, y, sens, coul)
	  # pour le nouveau client, ajouter un champ indiquant
	  # que le message concerne son propre canon :
	  if cli == nom:
	      msg =msg +",le_vôtre"
	  self.app.conn_client[cli].send(msg.encode("Utf8"))
	  self.app.verrou.release()
      elif deb =='feu':
	  self.app.tir_canon(nom)
	  # Signaler ce tir à tous les autres clients :
	  self.app.verrou.acquire()
	  for cli in self.app.conn_client:
	  if cli != nom:
	      message = "tir_de,{},".format(nom)
	      self.app.conn_client[cli].send(message.encode("Utf8"))
	  self.app.verrou.release()
      elif deb =="orienter":
	  t =msgClient.split(',')
	  # on peut avoir reçu plusieurs angles. utiliser le dernier :
	  self.app.orienter_canon(nom, t[-1])
	  # Signaler ce changement à tous les autres clients :
	  self.app.verrou.acquire()
	  for cli in self.app.conn_client:
	  if cli != nom:
	      # virgule terminale, car messages parfois groupés :
	      message = "angle,{},{},".format(nom, t[-1])
	      self.app.conn_client[cli].send(message.encode("Utf8"))
	  self.app.verrou.release()
 
      # Fermeture de la connexion :
      self.connexion.close()	      # couper la connexion
      del self.app.conn_client[nom]   # suppr. sa réf. dans le dictionnaire
      self.app.afficher("Client %s déconnecté.\n" % nom)
      # Le thread se termine ici

20-C-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().

À 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é.

20-C-4-A. 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 essaie d'activer 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é tout en refermant le verrou, 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.

20-C-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 graphique(109).

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.).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
138.
139.
140.
141.
142.
143.
144.
145.
class ThreadClients(threading.Thread):
  """objet thread gérant la connexion de nouveaux clients"""
  def __init__(self, boss, connex):
      threading.Thread.__init__(self)
      self.boss = boss		 # réf. de la fenêtre application
      self.connex = connex	    # réf. du socket initial
 
  def run(self):
      "attente et prise en charge de nouvelles connexions clientes"
      txt ="Serveur prêt, en attente de requêtes ...\n"
      self.boss.afficher(txt)
      self.connex.listen(5)
      # Gestion des connexions demandées par les clients :
      while 1:
      nouv_conn, adresse = self.connex.accept()
      # Créer un nouvel objet thread pour gérer la connexion :
      th = ThreadConnexion(self.boss, nouv_conn)
      th.start()
      it = th.getName()       # identifiant unique du thread
      # Mémoriser la connexion dans le dictionnaire :
      self.boss.enregistrer_connexion(nouv_conn, it)
      # Afficher :
      txt = "Client %s connecté, adresse IP %s, port %s.\n" %\
	 (it, adresse[0], adresse[1])
      self.boss.afficher(txt)
      # Commencer le dialogue avec le client :
      nouv_conn.send("serveur OK".encode("Utf8"))
 
class AppServeur(AppBombardes):
  """fenêtre principale de l'application (serveur ou client)"""
  def __init__(self, host, port, larg_c, haut_c):
      self.host, self.port = host, port
      AppBombardes.__init__(self, larg_c, haut_c)
      self.active =1	      # témoin d'activité
      # veiller à quitter proprement si l'on referme la fenêtre :
      self.bind('<Destroy>',self.fermer_threads)
 
  def specificites(self):
      "préparer les objets spécifiques de la partie serveur"
      self.master.title('<<< Serveur pour le jeu des bombardes >>>')
 
      # widget Text, associé à une barre de défilement :
      st =Frame(self)
      self.avis =Text(st, width =65, height =5)
      self.avis.pack(side =LEFT)
      scroll =Scrollbar(st, command =self.avis.yview)
      self.avis.configure(yscrollcommand =scroll.set)
      scroll.pack(side =RIGHT, fill =Y)
      st.pack()
 
      # partie serveur réseau :
      self.conn_client = {}	     # dictionn. des connexions clients
      self.verrou =threading.Lock()   # verrou pour synchroniser threads
      # Initialisation du serveur - Mise en place du socket :
      connexion = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
      try:
      connexion.bind((self.host, self.port))
      except socket.error:
      txt ="La liaison du socket à l'hôte %s, port %s a échoué.\n" %\
	 (self.host, self.port)
      self.avis.insert(END, txt)
      self.accueil =None
      else:
      # démarrage du thread guettant la connexion des clients :
      self.accueil = ThreadClients(self, connexion)
      self.accueil.start()
 
  def depl_aleat_canon(self, id):
      "déplacer aléatoirement le canon <id>"
      x, y = AppBombardes.depl_aleat_canon(self, id)
      # signaler ces nouvelles coord. à tous les clients :
      self.verrou.acquire()
      for cli in self.conn_client:
      message = "mouvement_de,%s,%s,%s," % (id, x, y)
      self.conn_client[cli].send(message.encode("Utf8"))
      self.verrou.release()
 
  def goal(self, i, j):
      "le canon <i> signale qu'il a atteint l'adversaire <j>"
      AppBombardes.goal(self, i, j)
      # Signaler les nouveaux scores à tous les clients :
      self.verrou.acquire()
      for cli in self.conn_client:
      msg ='scores,'
      for id in self.pupi:
	  sc = self.pupi[id].valeur_score()
	  msg = msg +"%s;%s," % (id, sc)
      self.conn_client[cli].send(msg.encode("Utf8"))
      time.sleep(.5)	       # pour mieux séparer les messages
      self.verrou.release()
 
  def ajouter_canon(self, id):
      "instancier un canon et un pupitre de nom <id> dans 2 dictionnaires"
      # on alternera ceux des 2 camps :
      n = len(self.guns)
      if n %2 ==0:
      sens = -1
      else:
      sens = 1
      x, y = self.coord_aleat(sens)
      coul =('dark blue', 'dark red', 'dark green', 'purple',
	 'dark cyan', 'red', 'cyan', 'orange', 'blue', 'violet')[n]
      self.guns[id] = Canon(self.jeu, id, x, y, sens, coul)
      self.pupi[id] = Pupitre(self, self.guns[id])
      self.pupi[id].inactiver()
      return (x, y, sens, coul)
 
  def enlever_canon(self, id):
      "retirer le canon et le pupitre dont l'identifiant est <id>"
      if self.active == 0:	# la fenêtre a été refermée
      return
      self.guns[id].effacer()
      del self.guns[id]
      self.pupi[id].destroy()
      del self.pupi[id]
 
  def orienter_canon(self, id, angle):
      "régler la hausse du canon <id> à la valeur <angle>"
      self.guns[id].orienter(angle)
      self.pupi[id].reglage(angle)
 
  def tir_canon(self, id):
      "déclencher le tir du canon <id>"
      self.guns[id].feu()
 
  def enregistrer_connexion(self, conn, it):
      "Mémoriser la connexion dans un dictionnaire"
      self.conn_client[it] = conn
 
  def afficher(self, txt):
      "afficher un message dans la zone de texte"
      self.avis.insert(END, txt)
 
  def fermer_threads(self, evt):
      "couper les connexions existantes et fermer les threads"
      # couper les connexions établies avec tous les clients :
      for id in self.conn_client:
      self.conn_client[id].send('fin'.encode("Utf8"))
      # forcer la terminaison du thread serveur qui attend les requêtes :
      if self.accueil != None:
      self.accueil._stop()
      self.active =0		  # empêcher accès ultérieurs à Tk
 
if __name__ =='__main__':
  AppServeur(host, port, largeur, hauteur).mainloop()

20-C-5-A. 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, vous revoyez ici la technique d'association d'une barre de défilement à un widget Text (Voir aussi page ).
  • Ligne 190 : Instanciation de l'objet-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.

20-C-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 fichiers-modules 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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
138.
139.
140.
141.
142.
143.
144.
#######################################################
# Jeu des bombardes - partie cliente		 #
# (C) Gérard Swinnen, Liège (Belgique) - Juillet 2004 #
# Licence : GPL 	     Révis.  2010 #
# Avant d'exécuter ce script, vérifiez que l'adresse, #
# le numéro de port et les dimensions de l'espace de  #
# jeu indiquées ci-dessous correspondent exactement   #
# à ce qui a été défini pour le serveur.	 #
#######################################################
 
from tkinter import *
import socket, sys, threading, time
from canon_serveur import Canon, Pupitre, AppServeur
 
host, port = '192.168.1.168', 36000
largeur, hauteur = 700, 400	   # dimensions de l'espace de jeu
 
class AppClient(AppServeur):
  def __init__(self, host, port, larg_c, haut_c):
      AppServeur.__init__(self, host, port, larg_c, haut_c)
 
  def specificites(self):
      "préparer les objets spécifiques de la partie client"
      self.master.title('<<< Jeu des bombardes >>>')
      self.connex =ThreadSocket(self, self.host, self.port)
      self.connex.start()
      self.id =None
 
  def ajouter_canon(self, id, x, y, sens, coul):
      "instancier un canon et un pupitre de nom <id> dans 2 dictionnaires"
      self.guns[id] = Canon(self.jeu, id, int(x), int(y), int(sens), coul)
      self.pupi[id] = Pupitre(self, self.guns[id])
      self.pupi[id].inactiver()
 
  def activer_pupitre_personnel(self, id):
      self.id =id	       # identifiant reçu du serveur
      self.pupi[id].activer()
 
  def tir_canon(self, id):
      r = self.guns[id].feu()	       # renvoie False si enrayé
      if r and id == self.id:
      self.connex.signaler_tir()
 
  def imposer_score(self, id, sc):
      self.pupi[id].valeur_score(int(sc))
 
  def deplacer_canon(self, id, x, y):
      "note: les valeurs de x et y sont reçues en tant que chaînes"
      self.guns[id].deplacer(int(x), int(y))
 
  def orienter_canon(self, id, angle):
      "régler la hausse du canon <id> à la valeur <angle>"
      self.guns[id].orienter(angle)
      if id == self.id:
      self.connex.signaler_angle(angle)
      else:
      self.pupi[id].reglage(angle)
 
  def fermer_threads(self, evt):
      "couper les connexions existantes et refermer les threads"
      self.connex.terminer()
      self.active =0		  # empêcher accès ultérieurs à Tk
 
  def depl_aleat_canon(self, id):
      pass		    # => méthode inopérante
 
  def goal(self, a, b):
      pass		    # => méthode inopérante
 
 
class ThreadSocket(threading.Thread):
  """objet thread gérant l'échange de messages avec le serveur"""
  def __init__(self, boss, host, port):
      threading.Thread.__init__(self)
      self.app = boss	      # réf. de la fenêtre application
      # Mise en place du socket - connexion avec le serveur :
      self.connexion = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
      try:
      self.connexion.connect((host, port))
      except socket.error:
      print("La connexion a échoué.")
      sys.exit()
      print("Connexion établie avec le serveur.")
 
  def run(self):
      while 1:
      msg_recu = self.connexion.recv(1024).decode("Utf8")
      print("*%s*" % msg_recu)
      # le message reçu est d'abord converti en une liste :
      t =msg_recu.split(',')
      if t[0] =="" or t[0] =="fin":
	  # fermer le présent thread :
	  break
      elif t[0] =="serveur OK":
	  self.connexion.send("client OK".encode("Utf8"))
      elif t[0] =="canons":
	  self.connexion.send("OK".encode("Utf8"))   # accusé de réception
	  # éliminons le 1er et le dernier élément de la liste.
	  # ceux qui restent sont eux-mêmes des listes :
	  lc = t[1:-1]
	  # chacune est la description complète d'un canon :
	  for g in lc:
	  s = g.split(';')
	  self.app.ajouter_canon(s[0], s[1], s[2], s[3], s[4])
      elif t[0] =="nouveau_canon":
	  self.app.ajouter_canon(t[1], t[2], t[3], t[4], t[5])
	  if len(t) >6:
	  self.app.activer_pupitre_personnel(t[1])
      elif t[0] =='angle':
	  # il se peut que l'on ait reçu plusieurs infos regroupées.
	  # on ne considère alors que la première :
	  self.app.orienter_canon(t[1], t[2])
      elif t[0] =="tir_de":
	  self.app.tir_canon(t[1])
      elif t[0] =="scores":
	  # éliminons le 1er et le dernier élément de la liste.
	  # ceux qui restent sont eux-mêmes des listes :
	  lc = t[1:-1]
	  # chaque élément est la description d'un score :
	  for g in lc:
	  s = g.split(';')
	  self.app.imposer_score(s[0], s[1])
      elif t[0] =="mouvement_de":
	  self.app.deplacer_canon(t[1],t[2],t[3])
      elif t[0] =="départ_de":
	  self.app.enlever_canon(t[1])
 
      # Le thread <réception> se termine ici.
      print("Client arrêté. Connexion interrompue.")
      self.connexion.close()
 
  def signaler_tir(self):
      self.connexion.send("feu".encode("Utf8"))
 
  def signaler_angle(self, angle):
      msg ="orienter,{}".format(angle)
      self.connexion.send(msg.encode("Utf8"))
 
  def terminer(self):
      self.connexion.send("fin".encode("Utf8"))
 
# Programme principal :
if __name__ =='__main__':
  AppClient(host, port, largeur, hauteur).mainloop()

20-C-6-A. 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).

20-C-6-B. 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 !

Exercices

.Simplifiez le script correspondant au client de chat décrit à la page , en supprimant l'un des deux objets threads. Arrangez-vous par exemple pour traiter l'émission de messages au niveau du thread principal.

.Modifiez le jeu des bombardes (version monoposte) du chapitre 15 (voir pages 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).

20-D. 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 !

20-D-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 œuvre 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 ), 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 durant cet intervalle de 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 (année 2004, 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 elle-mê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.

20-D-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 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 cœur 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.

- Attention : 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.).

20-D-3. Exemple concret

Le petit script reproduit ci-dessous illustre la mise en œuvre 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().

Image non disponible
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
from tkinter import *
from math import sin, cos
import time, threading
 
class App(Frame):
  def __init__(self):
      Frame.__init__(self)
      self.pack()
      can =Canvas(self, width =400, height =400,
	  bg ='ivory', bd =3, relief =SUNKEN)
      can.pack(padx =5, pady =5)
      cercle = can.create_oval(185, 355, 215, 385, fill ='red')
      tb = Thread_balle(can, cercle)
      Button(self, text ='Marche', command =tb.start).pack(side =LEFT)
      # Button(self, text ='Arrêt', command =tb.stop).pack(side =RIGHT)
      # arrêter l'autre thread si l'on ferme la fenêtre :
      self.bind('<Destroy>', tb.stop)
 
class Thread_balle(threading.Thread):
  def __init__(self, canevas, dessin):
      threading.Thread.__init__(self)
      self.can, self.dessin = canevas, dessin
      self.anim =1
 
  def run(self):
      a = 0.0
      while self.anim == 1:
      a += .01
      x, y = 200 + 170*sin(a), 200 +170*cos(a)
      self.can.coords(self.dessin, x-15, y-15, x+15, y+15)
      time.sleep(0.010)
 
  def stop(self, evt =0):
      self.anim =0
 
App().mainloop()

20-D-3-A. 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 no 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 angle(110).
    À 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.
- Rappel Vous pouvez vous procurer le code source de tous nos exemples sur le site :
http://inforef.be/swi/python.htm , ou bien :
http://main.pythomium.net/download/cours_python.zip
Vous y trouverez notamment, dans un fichier nommé cibles_multiples.py, un petit programme de jeu dans lequel l'utilisateur doit tirer au canon sur une série de cibles mobiles qui deviennent de plus en plus rapides et nombreuses au cours du temps. Ce jeu utilise les techniques d'animation expliquées ci-dessus.
Image non disponible

précédentsommairesuivant
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 un ouvrage sur les réseaux pour en savoir davantage.
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 livre.
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 ».
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 XVI).
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 ).
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 .
Vous pouvez trouver quelques explications complémentaires à ce sujet à la page .

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Licence Creative Commons
Le contenu de cet article est rédigé par Gérard Swinnen et est mis à disposition selon les termes de la Licence Creative Commons Attribution 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.