Gestion de plusieurs tâches en parallèle à l'aide des threads modifier

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 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â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 œ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[1].

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.

Client gérant l'émission et la réception simultanées modifier

Nous allons maintenant mettre en pratique la technique des threads pour construire un système de « chat »[2] 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. 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éés par dérivation, à partir de la classe Thread() du module threading. Ils s'occuperont indépendamment de la réception et de 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.

# 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.0.235'
port = 40000

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)
            print "*" + message_recu + "*"
            if message_recu =='' or message_recu.upper() == "FIN":
                break
        # Le thread <réception> se termine ici.
        # On force la fermeture du thread <émission> :
        th_E._Thread__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 = raw_input()
            self.connexion.send(message_emis)

# 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()
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(), 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()[3].

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

Serveur gérant les connexions de plusieurs clients en parallèle modifier

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.

# 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.0.235'
PORT = 40000

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)
            if msgClient.upper() == "FIN" or msgClient =="":
                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)
                    
        # 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 :
    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).

Jeu des bombardes, version réseau modifier

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

 
capture d'écran du jeu des bombardes

Programme serveur : vue d'ensemble modifier

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

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.

Protocole de communication modifier

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 :

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

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

Programme serveur : première partie modifier

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

#######################################################
# Jeu des bombardes - partie serveur                  #
# (C) Gérard Swinnen, Liège (Belgique)-  Juillet 2004 #
# Licence : GPL                                       #
# 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.0.235', 35000
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[4] 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.

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)
            print "**%s** de %s" % (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,%s" % nom
                        self.app.conn_client[cli].send(message)
                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 +"%s;%s;%s;%s;%s," % \
                              (gun.id, gun.x1, gun.y1, gun.sens, gun.coul)
                self.app.verrou.acquire()
                self.connexion.send(msg)
                # attendre un accusé de réception ('OK') :
                self.connexion.recv(100)
                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,%s,%s,%s,%s,%s" % \
                                       (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)
                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,%s," % nom
                        self.app.conn_client[cli].send(message)        
                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[-2])
                # 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,%s,%s," % (nom, t[-2])
                        self.app.conn_client[cli].send(message)
                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 dictionn.
        self.app.afficher("Client %s déconnecté.\n" % nom)
        # Le thread se termine ici

Synchronisation de threads concurrents à l'aide de « verrous » (thread locks) modifier

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

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.

Programme serveur : suite et fin modifier

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.

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

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

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)
        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)        
        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 dictionn."
        # 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')
        # forcer la terminaison du thread serveur qui attend les requêtes :
        if self.accueil != None:
            self.accueil._Thread__stop()
        self.active =0                  # empêcher accès ultérieurs à Tk

if __name__ =='__main__':
    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 Pmw[5].
  • 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.

Programme client modifier

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.

#######################################################
# Jeu des bombardes - partie cliente                  #
# (C) Gérard Swinnen, Liège (Belgique) - Juillet 2004 #
# Licence : GPL                                       #
# 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.0.235', 35000
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 1 canon et 1 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)
            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")
            elif t[0] =="canons":
                self.connexion.send("OK")       # 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')

    def signaler_angle(self, angle):
        self.connexion.send('orienter,%s,' % angle)
    
    def terminer(self):
        self.connexion.send('fin')

# Programme principal :
if __name__ =='__main__':
    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 !

Exercices

  1. Simplifiez le script correspondant au client de « chat » vu précédemment, en supprimant l'un des deux objets threads. Arrangez-vous par exemple pour traiter l'émission de messages au niveau du thread principal.
  2. Modifiez le jeu des bombardes vu précédemment (version monoposte), 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).

Solution

  1. Réfléchissez !
  2. #####################################
    # Bombardement d'une cible mobile   #
    # (C) G. Swinnen - Avril 2004 - GPL #
    #####################################
    
    from Tkinter import *
    from math import sin, cos, pi
    from random import randrange
    from threading import Thread
    
    class Canon:
        """Petit canon graphique"""
        def __init__(self, boss, num, x, y, sens):
            self.boss = boss            # référence du canevas
            self.num = num              # n° du canon dans la liste
            self.x1, self.y1 = x, y     # axe de rotation du canon
            self.sens = sens            # sens de tir (-1:gauche, +1:droite)
            self.lbu = 30               # longueur de la buse
            # dessiner la buse du canon (horizontale) :
            self.x2, self.y2 = x + self.lbu * sens, y
            self.buse = boss.create_line(self.x1, self.y1,
                                         self.x2, self.y2, width =10)
            # dessiner le corps du canon (cercle de couleur) :
            self.rc = 15                # rayon du cercle 
            self.corps = boss.create_oval(x -self.rc, y -self.rc, x +self.rc,
                                          y +self.rc, fill ='black')
            # pré-dessiner un obus (au départ c'est un simple point) :
            self.obus = boss.create_oval(x, y, x, y, fill='red')
            self.anim = 0
            # retrouver la largeur et la hauteur du canevas :
            self.xMax = int(boss.cget('width'))
            self.yMax = int(boss.cget('height'))
    
        def orienter(self, angle):
            "régler la hausse du canon"
            # rem : le paramètre <angle> est reçu en tant que chaîne.
            # il faut donc le traduire en réel, puis le convertir en radians :
            self.angle = float(angle)*2*pi/360      
            self.x2 = self.x1 + self.lbu * cos(self.angle) * self.sens
            self.y2 = self.y1 - self.lbu * sin(self.angle)
            self.boss.coords(self.buse, self.x1, self.y1, self.x2, self.y2)
            
        def feu(self):
            "déclencher le tir d'un obus"
            # référence de l'objet cible :
            self.cible = self.boss.master.cible
            if self.anim ==0:
                self.anim =1
                # position de départ de l'obus (c'est la bouche du canon) :
                self.xo, self.yo = self.x2, self.y2
                v = 20              # vitesse initiale
                # composantes verticale et horizontale de cette vitesse :
                self.vy = -v *sin(self.angle)
                self.vx = v *cos(self.angle) *self.sens
                self.animer_obus()
        
        def animer_obus(self):
            "animer l'obus (trajectoire balistique)"
            # positionner l'obus, en re-définissant ses coordonnées :
            self.boss.coords(self.obus, self.xo -3, self.yo -3,
                                        self.xo +3, self.yo +3)
            if self.anim >0:
                # calculer la position suivante :
                self.xo += self.vx
                self.yo += self.vy
                self.vy += .5
                self.test_obstacle()        # a-t-on atteint un obstacle ?
                self.boss.after(1, self.animer_obus)
            else:
                # fin de l'animation :
                self.boss.coords(self.obus, self.x1, self.y1, self.x1, self.y1) 
       
        def test_obstacle(self):
            "évaluer si l'obus a atteint une cible ou les limites du jeu"
            if self.yo >self.yMax or self.xo <0 or self.xo >self.xMax:
                self.anim =0
                return
            if self.yo > self.cible.y -3 and self.yo < self.cible.y +18 \
            and self.xo > self.cible.x -3 and self.xo < self.cible.x +43:
                # dessiner l'explosion de l'obus (cercle orange) :
                self.explo = self.boss.create_oval(self.xo -10,
                             self.yo -10, self.xo +10, self.yo +10,
                             fill ='orange', width =0)
                self.boss.after(150, self.fin_explosion)
                self.anim =0
       
        def fin_explosion(self):
            "effacer le cercle d'explosion - gérer le score"
            self.boss.delete(self.explo)
            # signaler le succès à la fenêtre maîtresse :
            self.boss.master.goal()        
    
    class Pupitre(Frame):
        """Pupitre de pointage associé à un canon""" 
        def __init__(self, boss, canon):
            Frame.__init__(self, bd =3, relief =GROOVE)
            self.score =0
            s =Scale(self, from_ =88, to =65,
                     troughcolor ='dark grey',
                     command =canon.orienter)
            s.set(45)                       # angle initial de tir
            s.pack(side =LEFT)
            Label(self, text ='Hausse').pack(side =TOP, anchor =W, pady =5)        
            Button(self, text ='Feu !', command =canon.feu).\
                                        pack(side =BOTTOM, padx =5, pady =5)
            Label(self, text ="points").pack()
            self.points =Label(self, text=' 0 ', bg ='white')
            self.points.pack()
            # positionner à gauche ou à droite suivant le sens du canon :
            gd =(LEFT, RIGHT)[canon.sens == -1]
            self.pack(padx =3, pady =5, side =gd)
    
        def attribuerPoint(self, p):
            "incrémenter ou décrémenter le score"
            self.score += p
            self.points.config(text = ' %s ' % self.score)
    
    class Cible:
        """objet graphique servant de cible"""
        def __init__(self, can, x, y):
            self.can = can             # référence du canevas
            self.x, self.y = x, y
            self.cible = can.create_oval(x, y, x+40, y+15, fill ='purple')
            
        def deplacer(self, dx, dy):
            "effectuer avec la cible un déplacement dx,dy" 
            self.can.move(self.cible, dx, dy)
            self.x += dx
            self.y += dy
            return self.x, self.y
    
    class Thread_cible(Thread):
        """objet thread gérant l'animation de la cible"""
        def __init__(self, app, cible):
            Thread.__init__(self)
            self.cible = cible          # objet à déplacer
            self.app = app              # réf. de la fenêtre d'application
            self.sx, self.sy = 6, 3     # incréments d'espace et de
            self.dt =300                # temps pour l'animation (ms)
       
        def run(self):
            "animation, tant que la fenêtre d'application existe" 
            x, y = self.cible.deplacer(self.sx, self.sy)
            if x > self.app.xm -50 or x < self.app.xm /5:
                    self.sx = -self.sx
            if y < self.app.ym /2 or y > self.app.ym -20:
                    self.sy = -self.sy
            if self.app != None:
                self.app.after(int(self.dt), self.run)
    
        def stop(self):
            "fermer le thread si la fenêtre d'application est refermée"
            self.app =None
            
        def accelere(self):
            "accélérer le mouvement"
            self.dt /= 1.5
    
    class Application(Frame):
        def __init__(self):
            Frame.__init__(self)
            self.master.title('<<< Tir sur cible mobile >>>')
            self.pack()
            self.xm, self.ym = 600, 500
            self.jeu = Canvas(self, width =self.xm, height =self.ym,
                              bg ='ivory', bd =3, relief =SUNKEN)
            self.jeu.pack(padx =4, pady =4, side =TOP)
    
            # Instanciation d'un canon et d'un pupitre de pointage :
            x, y = 30, self.ym -20
            self.gun =Canon(self.jeu, 1, x, y, 1)
            self.pup =Pupitre(self, self.gun)
            
            # instanciation de la cible mobile :
            self.cible = Cible(self.jeu, self.xm/2, self.ym -25)
            # animation de la cible mobile, sur son propre thread :
            self.tc = Thread_cible(self, self.cible)
            self.tc.start()
            # arrêter tous les threads lorsque l'on ferme la fenêtre :
            self.bind('<Destroy>',self.fermer_threads)
    
        def goal(self):
            "la cible a été touchée"
            self.pup.attribuerPoint(1)
            self.tc.accelere()
            
        def fermer_threads(self, evt):
            "arrêter le thread d'animation de la cible"
            self.tc.stop()
    
    if __name__ =='__main__':
        Application().mainloop()
    

Utilisation de threads pour optimiser les animations. modifier

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 !

Temporisation des animations à l'aide de after() modifier

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

Temporisation des animations à l'aide de time.sleep() modifier

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

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

Exemple concret modifier

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

 
capture d'écran de l'application
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()
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 angle. À 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.

Notes modifier

  1. 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.
  2. 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 ».
  3. 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 située en fin d'ouvrage.
  4. 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
  5. Voir : Python Mega Widgets
À faire... 


  1. ajouter des images