Comment peut-on gérer plusieurs choses à la fois dans un programme informatique ? Avec plusieurs threads, évidemment ! C’est bien la seule solution, non ?
C’était jeudi dernier. J’avais réservé la salle 404 (la salle bien nommée « Error ») pour une réunion dans l’après-midi. Après manger, j’ai voulu vérifier que ma présentation passait correctement au vidéoprojecteur, malgré la qualité médiocre du convertisseur VGA/HDMI. Et là, qui vois-je ? Peter ! Vous savez, Peter, je vous en ai parlé l’année dernière… Le gars qui voulait tester si les prouesses informatiques vues dans une série US étaient vraiment réalisables [1] ! Bref, j’étais un peu surpris de le voir ici. Lui par contre n’avait montré aucun signe de surprise : concentré sur son ordi, il n’avait même pas remarqué ma présence. Ce gars a une capacité de concentration impressionnante. Quand il est concentré comme ça, il est presque imperturbable. Mais c’est plutôt pénible pour les autres !! « PETER !! » criais-je un bon coup. Cette fois, l’interruption fut bien déclenchée dans son cerveau et il daigna enfin tourner la tête vers moi, même si son regard vague trahissait encore une attention très partielle.
1. Un examen raté
- Hein quoi !? Ça va pas ? Tu vois pas que je suis concentré sur un truc ?
- Ben si, justement, et je connais pas 36 techniques pour te déconcentrer ! Qu’est-ce que tu fais là ?
- Je fais des corrections. Tu sais, je donne quelques heures de cours sur les réseaux, pour l’école d’ingés, en tant que vacataire… Et l’examen était hier.
- Je vois. Et ça va, les copies sont bonnes dans l’ensemble ?
- Non. C’est même plutôt mauvais. En tout cas vis-à-vis de ce que je pensais trouver.
- C’était sur quoi exactement ?
- Les sockets. Ils devaient réaliser un serveur de chat en Python.
- Ah oui, c’est assez classique pourtant. Et les sockets c’est pas bien difficile…
- Ben ouais. Mais pour être précis, ce n’est pas sur le fonctionnement des sockets qu’ils ont eu le plus de soucis. C’est plutôt dans la gestion concurrente des différents sockets.
- C’est vrai que dès qu’on veut gérer plusieurs clients, on a plusieurs sockets… Mais avec des threads on doit pouvoir gérer ça en parallèle sans soucis non ?
- Quoi ??? Ne me parle pas de threads !!! C’est justement ce que je leur reproche, d’avoir introduit des threads !! Je crois que toi aussi tu aurais bien besoin d’une petite mise à niveau !
- Ah ? Ben euh c’est vrai que j’ai pas pratiqué ces trucs-là depuis un bail. Dans ce cas, vas-y, montre-moi, j’ai 1h de libre.
2. La copie du cancre
- OK. Au moins, toi ton cas n’est pas désespéré. Parce que j’ai aussi quelques copies qui sont pires que celles avec les threads. Regarde celle-ci par exemple :
#!/usr/bin/env python3
from socket import socket, AF_INET, \
SOCK_STREAM, \
SOL_SOCKET, SO_REUSEADDR
conn_sockets = []
def send_line(sender, line):
for recv_s in conn_sockets:
recv_s.send(line)
def create_server_sock():
s = socket(AF_INET, SOCK_STREAM)
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s.bind(('', 5555))
s.listen(0)
return s
def conn_sock_manager(conn_s):
conn_sockets.append(conn_s)
line = b''
while True:
c = conn_s.recv(1)
if c == b'':
break
line += c
if c == b'\n':
send_line(conn_s, line)
line = b''
conn_s.close()
conn_sockets.remove(conn_s)
def server_sock_manager():
s = create_server_sock()
while True:
conn_s, addr = s.accept()
conn_sock_manager(conn_s)
server_sock_manager()
- Ce gars ne gère qu’un client à la fois, tout simplement. Si un deuxième client essaie de se connecter, le serveur le laisse en attente jusqu’à ce que le premier client se déconnecte.
- Tu testes avec quel programme client ?
- Je leur ai proposé d’utiliser telnet. Regarde, il faut déjà lancer le serveur :
[term1] $ ./chat-server.py
Ensuite un client dans un autre terminal :
[term2] $ telnet 127.0.0.1 5555
Connected to 127.0.0.1.
Escape character is '^]'.
hello
hello
Tu vois, avec un client, ça marche. Le serveur te renvoie ce que tu as tapé. Mais regarde, si je branche un autre client :
[term3] $ telnet 127.0.0.1 5555
Connected to 127.0.0.1.
Escape character is '^]'.
hello again
Là on est bloqué. Il faut arrêter le client sur term2 pour débloquer celui-ci.
- C’est vrai que c’est original, un chat qui ne gère qu’un seul client ! Tu pourrais au moins lui mettre quelques points pour l’originalité :-)
- Mouais. Ça me fait pas trop rire. Copie suivante.
3. La copie avec des threads
#!/usr/bin/env python3
[...]
from threading import Thread
[...]
def conn_sock_manager(conn_s):
[...]
def server_sock_manager():
[...]
while True:
conn_s, addr = s.accept()
t = Thread(target=conn_sock_manager, args=(conn_s,))
t.start()
server_sock_manager()
(note : pour faciliter l’analyse, j’ai mis en évidence uniquement les différences avec la copie précédente)
- Non, mais ils le font exprès ou quoi !! Encore une sale copie de…
Peter s’interrompit tout d’un coup. Il me regarda et me dit :
- Il vaut mieux que je surveille mon langage, avec toi à côté ! La dernière fois qu’on a discuté d’informatique, la discussion s’est retrouvée dans GLMF un mois plus tard ! Je me méfie de toi maintenant !
- Tu as raison de te méfier, je pourrais bien te refaire ce coup-là, à l’occasion…
- Bref. Encore une copie avec des threads… Ça me déprime.
- Je ne vois pas ce qui t’embête. Ça fonctionne, non ?
- Bof. C’est vrai qu’on gère effectivement plusieurs clients. Mais si on introduit des threads pour gérer un problème aussi simple, où va-t-on ??
- J’avoue que je ne vois pas où est le problème.
- Tu ne vois pas ?? Bon, admettons que je sois un super fan de threads, on va voir où ça nous mène. On a besoin de gérer 2 sockets, hop, on met 2 threads. Bon, après il faut juste garder à l’esprit qu’on a délégué le séquencement de notre programme au système d’exploitation. C’est-à-dire que l’OS peut, à tout moment, entre 2 instructions quelconques du thread A, switcher sur le thread B. Ou inversement. Il y a donc un risque d’incohérence lors de la manipulation de ressources communes (comme la variable conn_sockets de cette copie). Mais c’est pas bien grave, il suffit juste de réexaminer tout le programme pour vérifier que ce genre de switch ne va poser problème nulle part. S’il y a un risque quelque part, ce n’est pas grave, on mettra juste un petit verrou pour s’assurer du séquencement correct des opérations. Après, il faudra juste vérifier que nos verrous ne vont pas poser de problème d’interblocage. Bon, vu que ça devient un tout petit peu compliqué, on a juste à faire appel à quelques cerveaux de collègues supplémentaires. Et voilà ! On a un programme fonctionnel. En tout cas la plupart du temps, puisque les bugs qu’on n’a pas encore détectés sont, par définition, rares ! Entre nous, un programme qui buggue une fois toutes les 48h, cela reste un niveau de service acceptable pour beaucoup d’applications ! Le seul souci restant, par contre, c’est que quand il buggue, il ne sort pas en erreur (auquel cas on pourrait juste le lancer en boucle). Au lieu de ça, il se bloque !! Parce que quand un des threads buggue et s’arrête, le programme reste vivant à cause des threads restants ! Mais c’est pas grave, on a juste à coder un autre petit programme qui détecte quand il est bloqué, lui envoie un kill ‑9 adéquat, et le relance… Et voilà. Notre programme gère nos 2 sockets de manière quasi optimale !!
- Tu exagères un peu ! Il y a quand même des bonnes pratiques à respecter, ça permet d’avoir moins de problèmes ! Par exemple, il y a moyen d’arrêter les threads correctement. Je crois que la méthode recommandée est de leur envoyer une notification, d’une manière ou d’une autre, pour qu’elles s’arrêtent d’elles-mêmes.
- Ah oui ? « D’une manière ou d’une autre » ? Et quelle est donc cette « manière » si tes threads sont en lecture bloquante, donc non interruptibles, sur un socket par exemple ? C’est justement le cas de notre programme !
- … Euh…
- Si tu pars dans cette direction, alors il ne te reste plus qu’à introduire des timeouts en lecture pour aller de temps en temps vérifier une éventuelle notification. Mais ne compte pas sur moi pour cautionner ce genre de code !
- … OK, tu as peut-être raison sur ce point…
- Bien sûr que j’ai raison ! Si tu prends notre copie d’étudiant, qui est quand même un cas d’école super simple, et bien il me faut 2 <Ctrl> + <C> successifs pour arrêter le programme ! Ce gars espère avoir une bonne note alors qu’il aggrave les soucis de canal carpien de son correcteur !
- Bon OK, tu m’as convaincu. Effectivement, en grattant un peu, ça peut rapidement se complexifier. Mais je ne vois pas quelle autre option avait tes étudiants ? Quand on a plusieurs choses à gérer en même temps, le réflexe est bien d’introduire des threads, non ?
- Non, bien sûr que non !! Le réflexe, quand on a plusieurs choses à gérer en même temps, est de faire du multiplexage !!
4. Le multiplexage
- Le multiplexage ? C’est quoi ? Et surtout, comment pouvais-tu espérer que tes étudiants allaient te sortir ce genre de technique alors que même moi qui suis expérimenté, j’aurais fait des threads ??
- Eh bien, tout simplement parce que le premier exercice était fait pour rappeler ce concept ! Regarde :
Exercice 1 : Le programme telnet est capable de lire sans délai sur deux canaux de communication à la fois. Le premier canal est son entrée standard qui lui permet de lire ce qui est tapé par l’utilisateur. Le deuxième est le socket connecté au serveur. À l’aide de l’outil strace, et en lançant telnet par exemple sur le port 80 de www.google.fr, expliquez comment il y parvient.
- OK. Je comprends mieux. Alors voyons ce que ça donne, si je le fais, moi, cet exercice… strace c’est l’outil qui liste les appels-système d’un programme qui s’exécute, c’est bien ça ?
- Oui. J’imagine que la plupart des étudiants ne connaissaient pas cette commande, mais ils connaissent la commande man…
- OK. Alors voyons :
$ strace telnet www.google.fr 80
socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 3
[...]
connect(3, {[...], 80, "172.217.22.131"}, 16) = 0
[...]
select(4, [0 3], [], [3], NULL
On a donc le socket qui est créé avec l’appel socket(), et on obtient le file descriptor 3. Ensuite, quelques lignes plus bas, il se connecte au serveur de Google via connect(). Après il y a quelques lignes qui ne nous intéressent pas trop a priori… Et au final, on arrive sur un appel select(). On est visiblement bloqué dessus. J’ai déjà rencontré ce select() il me semble, mais je ne sais plus trop à quoi ça sert…
S’il y a une page de manuel, ça devrait nous aider…
$ man select
SELECT(2) Manuel du programmeur Linux
NOM
select, [...] - Multiplexage d'entrées-sorties synchrones
SYNOPSIS
[...]
DESCRIPTION
Les fonctions select() et pselect() permettent à un programme de surveiller plusieurs descripteurs de fichier, attendant qu'au moins l'un des descripteurs de fichier devienne « prêt » pour certaines classes d'opérations d'entrées-sorties (par exemple, entrée possible). Un descripteur de fichier est considéré comme prêt s'il est possible d'effectuer l'opération d'entrées-sorties correspondante (par exemple, un read(2)) sans bloquer. [...]
Ah, ben voilà ! telnet doit justement utiliser ce select() pour attendre l’arrivée de caractères à la fois sur son entrée standard et sur le socket ! Et là, il bloque jusqu’à ce que ça arrive ! D’ailleurs… Oui, le deuxième paramètre de notre appel-système select() est un tableau de descripteurs de fichiers. On y lit 0 et 3, ce qui correspond bien à l’entrée standard et au socket !
Je suppose que le principe de fonctionnement de telnet est donc de faire une boucle, avec un select() pour attendre les prochains caractères qui arriveront, sur un des 2 canaux. Quand cela se produit, on traite ces caractères de manière appropriée (a priori on les renvoie sur l’autre canal) puis on reboucle pour se remettre en attente.
- Tout à fait. Tu aurais eu tout bon à cet exercice 1.
- Les étudiants n’ont pas réussi, eux ?
- Eh bien figure-toi que si, pour la plupart. Ils m’ont expliqué noir sur blanc que « select() permet de gérer une attente sur plusieurs descripteurs de fichiers à la fois ». Mais pour l’exercice 2, qui était normalement une simple mise en application, ils oublient tout et me ressortent des threads !! C’est déprimant. Allez, copie suivante.
5. La copie attendue !
#!/usr/bin/env python3
[...]
from select import select
[...]
def conn_sock_readline(conn_s):
line = b''
while True:
c = conn_s.recv(1)
if c == b'': # disconnected
conn_s.close()
conn_sockets.remove(conn_s)
break
line += c
if c == b'\n': # end-of-line
send_line(conn_s, line)
break
def main():
server_sock = create_server_sock()
while True:
fds = tuple(conn_sockets) + (server_sock,)
r, w, e = select(fds, [], [])
fd = r[0]
if fd == server_sock:
conn_s, addr = server_sock.accept()
conn_sockets.append(conn_s)
else:
conn_sock_readline(fd)
main()
Le visage de Peter s’illumina tout à coup.
- Enfin une copie avec un select() !! Ça montre bien que je ne leur demandais pas l’impossible !! Tiens, regarde et dis-moi si tu comprends comment ça fonctionne.
- Alors… Commençons par la fonction main() pour avoir une vision globale… Apparemment, c’est à peu près le fonctionnement que j’imaginais pour telnet tout à l’heure. On a donc une boucle avec à chaque fois un select() pour attendre les prochains « événements » à traiter. Ici, si je ne m’abuse, on a deux types d’événements complètement différents qui peuvent survenir. Le premier correspond à la connexion d’un nouveau client. Le deuxième correspond à l’arrivée de nouveaux caractères sur une des connexions clientes établies précédemment. Si je me rappelle bien la page de manuel, on doit fournir en paramètre de la fonction select() 3 ensembles de descripteurs de fichiers, qui correspondent aux 3 types d’événements qu’on peut attendre : lecture, écriture, ou erreur. Ici visiblement, on attend uniquement des événements de type « lecture ». J’en déduis que l’événement « connexion d’un nouveau client » déclenche un événement de type « lecture » sur la socket serveur ?
- Oui tout à fait.
- Donc c’est bien ça. On établit une liste nommée fds de tous les descripteurs de fichiers sur lesquels on attend une lecture et on appelle select() avec cette liste. Quand il nous rend la main, on obtient en retour les listes r, w et e des descripteurs qui ont interrompu l’attente, respectivement en lecture, en écriture, et en erreur. Vu ce qu’on a donné en paramètres, a priori la seule liste non vide est r. Bon là, bizarrement, le programme ne traite que le premier élément de cette liste. Mais peut-être que si on a 2 événements retournés et qu’on n’en traite qu’un, le select() nous retournera à nouveau le deuxième à la prochaine boucle ?
- Oui tout à fait. C’est un choix d’implémentation qui fonctionne.
- OK. Ensuite, on regarde si l’événement est intervenu sur server_sock ou bien sur un des sockets connectés. Dans le premier cas, on fait l’accept() et on obtient un nouveau socket de connexion, qui sera pris en compte au prochain select(). Dans le deuxième cas, on appelle la fonction conn_sock_readline() qui va se charger de traiter les nouveaux caractères arrivés sur ce socket de connexion.
- Oui. C’est là que je vois le seul défaut d’implémentation de cette copie. Il y a un principe à respecter, quand on se base sur un select() : on doit traiter les événements rapidement, de façon à reboucler très vite sur le select() et pouvoir recevoir les événements suivants. Dans cette copie, ce principe n’est pas bien respecté : dans le cas où un client tape quelques caractères sans saut de ligne, alors cette implémentation serveur ne reboucle pas sur le select() et tous les autres clients sont bloqués.
- C’est vrai ça ! Et comment tu suggères de corriger ça ?
- Eh bien en théorie il faut gérer caractère par caractère, et non pas ligne par ligne. Si le select() a été réveillé, alors on sait qu’on a au moins 1 caractère à lire, mais rien ne dit qu’il y en a plus. Le plus sage est donc de reboucler sur le select() après chaque caractère traité.
- D’accord… Mais ça veut dire qu’il faut sauvegarder, pour chaque connexion, les caractères reçus au fur et à mesure ?
- En théorie oui.
6. Changement de paradigme
J’avais un peu levé les sourcils, parce que même si la belle théorie de Peter avec son select() me plaisait pas mal, au final c’était un peu comme avec les threads : en grattant un peu, ça se complexifie… Mais il reprit la parole.
- Je vois bien ce que tu penses. Mais efface-moi ce sourire en coin. Ce n’est pas une petite boucle et une variable supplémentaire qui vont apporter une grande complexité, quand même ! Je te rappelle qu’avec les threads on est dans une autre dimension ! Utiliser des threads, cela revient tout bonnement à dire au système d’exploitation « tiens, j’ai N trucs à faire, exécute-les dans l’ordre que tu veux ! ». En comparaison, ici on n’a qu’un seul fil d’exécution, et à tout moment du programme, on sait exactement où on en est !…
- C’est vrai. Tu as raison sur ce point. Par contre, quand même, la structure du programme me semble moins lisible qu’avec les threads. Tu vois, avant on avait tout simplement 1 thread pour gérer 1 client. Maintenant la gestion d’un client est éclatée en deux parties, dans la fonction conn_sock_readline() bien sûr, mais aussi un peu dans le main() pour la gestion du select().
- Ça c’est vrai. En fait, en utilisant select(), on change de paradigme, on fonctionne en mode gestion d’événements. Quand on a compris ça, on peut réorganiser le code de façon à regrouper les éléments de manière plus cohérente. Normalement, on obtient un élément central qui est la « boucle d’événements ». Au cœur de ce composant, on a donc la boucle avec le select(). Et à cet élément central, on agrège ce qu’on pourrait appeler des « gestionnaires de ressources ». Par exemple, dans notre exemple, on aurait un gestionnaire de ressources pour le socket server_sock, et un autre pour chaque client connecté. Chaque gestionnaire de ressources est capable de dire quel événement il attend (par exemple « lecture sur fd=5 »), de façon à alimenter le select(), et bien sûr il fournit une procédure à exécuter quand l’événement se produit.
- Intéressant.
- Il y a tout un tas de librairies et frameworks qui fonctionnent sur ce modèle : libev, libevent, twisted, etc. Le problème est que le vocabulaire n’est pas normalisé, donc par exemple si tu étudies twisted il y a tout un vocabulaire à réapprendre. Par exemple, un « Reactor » correspond à ce que j’ai appelé « gestionnaire de ressources » si je me souviens bien. Suivant le projet sur lequel tu travailles, il peut être intéressant ou non de faire cet effort d’apprentissage. Il faut faire la part des choses quant à l’ajout d’une dépendance supplémentaire sur du code externe et peu maîtrisé. L’alternative étant de développer en interne un code de quelques dizaines de lignes pour gérer cette boucle d’événements. Parce que tu as pu voir que ce n’était quand même pas très complexe.
- D’accord. Mais donc, il n’y a pas d’alternative, il faut forcément penser son programme sur ce mode « gestion d’événements » quand on fait du select() ?
- Voilà une question intéressante. En fait, il y a bien une alternative. On peut, en quelque sorte, simuler plusieurs fils d’exécution alors qu’on reste en mono-thread. J’avais testé une technique de ce genre un jour, je pourrais te la montrer à l’occasion.
- Pourquoi pas maintenant ?
- Comme tu veux, mais accroche-toi alors, parce que là on sort des sentiers battus et ce n’est pas le même niveau de complexité.
- OK.
7. La copie que Peter aurait faite
Peter réfléchit un instant puis commença son explication.
- Bon. Si on fait ça en Python, il nous faut un select() et des générateurs.
- Des générateurs ? C’est quoi déjà ?
- Regarde.
$ python3
[...]
>>> def my_range(i):
... j = 0
... while j < i:
... yield j
... j += 1
...
>>> for k in my_range(3):
... print(k)
...
0
1
2
Tu vois, cette fonction my_range fonctionne grosso modo comme la fonction range interne au langage. L’instruction très particulière que j’ai utilisée est yield. On peut voir ça un peu comme un « return partiel » : plutôt que de retourner une seule valeur, on va retourner successivement toute une série de valeurs. Et donc, contrairement à un return, on ne termine pas la fonction au moment du yield. La fonction est juste « mise en pause », en quelque sorte, pour redonner la main à l’appelant. Et quand l’appelant demande la valeur suivante, on reprend la fonction là où on s’était arrêté, c’est-à-dire juste après le yield.
On voit donc que le simple fait d’avoir écrit un yield dans la fonction a complètement changé son comportement. Explorons un peu plus :
>>> gen = my_range(3)
>>> gen
<generator object my_range at 0xb78b759c>
>>> gen.__next__()
0
>>> gen.__next__()
1
>>> gen.__next__()
2
>>> gen.__next__()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Tu vois, quand on exécute une fonction qui comprend un yield, on obtient un objet appelé « générateur ». Cet objet comporte une méthode __next__() qui permet d’obtenir les valeurs successives générées, jusqu’à ce qu’on arrive à la fin de la fonction, ce qui déclenche une exception StopIteration. En fait, Python nous cache un peu cette complexité, mais derrière la boucle for de tout à l’heure, il s’est passé exactement la même chose.
En tout cas, ce qu’il faut bien comprendre, c’est que gen génère les valeurs au fil de l’eau. Les valeurs ne sont pas pré-calculées. C’est l’appelant qui lui passe la main, gen exécute alors un bout de code jusqu’au prochain yield de façon à générer une valeur, et il rend la main à l’appelant. On a donc bien un seul fil d’exécution, mais il fait des sauts entre l’appelant et le générateur pour générer chaque valeur.
- Bizarre…
- Oui, mais très utile. En travaillant comme ça, en flux tendu, on utilise beaucoup moins de mémoire que si on avait pré-calculé les valeurs.
- J’imagine. Et donc, quel est le rapport avec notre select() ?
- Regarde… En modifiant très légèrement le code de la copie avec les threads… On peut arriver à ceci… Commençons par conn_sock_manager() :
def conn_sock_manager(conn_s):
conn_sockets.append(conn_s)
line = b''
while True:
yield conn_s
c = conn_s.recv(1)
if c == b'':
break
line += c
if c == b'\n':
send_line(conn_s, line)
line = b''
conn_s.close()
conn_sockets.remove(conn_s)
Tu vois, j’ai juste ajouté l’instruction yield conn_s avant la lecture bloquante sur conn_s. Le reste est identique à la version « threads ».
def server_sock_manager():
s = create_server_sock()
while True:
yield s
conn_s, addr = s.accept()
scheduler.start_generator(conn_sock_manager(conn_s))
Pour server_sock_manager(), c’est à peu près pareil, ça ressemble beaucoup à la version « threads », mais avec un yield s juste avant le s.accept() qui est bloquant lui aussi. Et quand on a un nouveau client, plutôt que de créer un thread, j’utilise la méthode start_generator() de l’objet scheduler qu’on verra plus loin.
Comme on a vu, le fait d’avoir mis une instruction yield va transformer ces fonctions en générateurs. Et en l’occurrence, ce qu’ils vont nous générer, c’est une suite de numéros de socket.
S’ils pouvaient parler, ces 2 générateurs t’expliqueraient leur stratégie de cette façon :
« Plutôt que de lancer une opération qui va bloquer le fil d’exécution, je repasse la main à mon appelant grâce à l’instruction yield. Je lui précise aussi (paramètre du yield) le socket qui m’intéresse. Il devra me redonner la main quand un événement se produira sur ce socket. À ce moment-là, je pourrai exécuter l’opération sans bloquer. »
Passons à la suite.
class Scheduler:
def __init__(self):
self.socks = {}
def run(self):
while True:
generator = self.wait()
self.step_generator(generator)
def wait(self):
socks = list(self.socks.keys())
r, w, e = select(socks, [], [])
sock = r[0]
generator = self.socks.pop(sock)
return generator
def step_generator(self, generator):
try:
# run the generator up to the next yield
sock = generator.__next__()
except StopIteration:
return # end of generator
# save the fact that generator is now waiting on sock
self.socks[sock] = generator
def start_generator(self, generator):
self.step_generator(generator) # start = run 1st step
scheduler = Scheduler()
scheduler.start_generator(server_sock_manager())
scheduler.run()
Tu vois, cet objet scheduler, c’est un peu le chef d’orchestre du programme. Il maintient dans sa variable socks l’ensemble des sockets et générateurs en attente. En fait, il s’agit d’un dictionnaire, les clés sont les sockets, et les valeurs sont les générateurs correspondants.
Regardons maintenant la méthode run(). C’est une boucle infinie, avec à chaque itération un appel à la méthode wait() qui permet d’attendre le prochain événement, puis le traitement de cet événement par le générateur approprié. La méthode wait() doit te paraître assez limpide : elle est bien sûr basée sur un select(), paramétré en fonction du contenu de la variable socks. Quand un événement survient sur un des sockets, elle retourne le générateur qui était en attente sur ce socket. L’événement est alors traité par la méthode step_generator(), qui se contente de redonner la main à ce générateur en appelant sa méthode __next__(). Le générateur s’exécute donc jusqu’à son prochain yield. Ce segment d’exécution lui permet de traiter l’événement, et il repasse ensuite la main au scheduler (via yield donc), juste avant l’instruction bloquante suivante.
- Je crois que je comprends le principe… C’est plutôt malin, tout ça :-) Il reste la fonction start_generator() ?
- Oui, eh bien comme tu vois, start_generator(), c’est la même chose que step_generator(). En effet, pour démarrer un générateur, on doit lancer son exécution jusqu’au premier yield, en faisant donc là aussi appel à sa méthode __next__(). Mais si la méthode à appeler pour démarrer un générateur s’appelait step_generator(), ça paraîtrait bizarre, c’est pour ça que j’ai préféré déclarer cette fonction start_generator(), même si elle n’est pas très utile.
- OK. Au final, tu détournes pas mal la fonction normale des générateurs quand même.
- Oui. En fait, dans ce cas d’utilisation, où on implémente plusieurs fils d’exécution virtuels dans un même thread, normalement on ne parle pas de « générateur », mais plutôt de « coroutine ». Je t’ai introduit le concept via les générateurs parce qu’on les rencontre souvent en Python, et que je ne voulais pas te perdre avec trop de nouveaux concepts.
- OK. Une dernière remarque : on ne pourrait pas simplifier un peu ce scheduler ? D’après ce que je vois, chacun de nos 2 générateurs attendent en réalité toujours sur le même socket. Donc on pourrait imaginer qu’au moment où on démarre un générateur, on indique une fois pour toutes le socket qui l’intéresse ?
- C’est une bonne remarque. Mais en fait, ce que j’ai voulu te montrer, c’est un objet scheduler suffisamment générique pour gérer des cas plus complexes que ce simple cas d’école. Si tu reprends juste le code de ce scheduler, tu peux l’utiliser pour adapter tout un tas de programmes réseau, moyennant de rajouter un yield devant les instructions bloquantes. Et si tu as un thread qui écoute d’abord sur un socket s1, puis sur un socket s2, ce ne sera pas un problème pour cette implémentation du scheduler.
- D’accord. Et si on voulait utiliser ce scheduler pour coder le client cette fois, ce serait possible ?
- À ton avis ?
- Euh… À bien y réfléchir, je suppose que non. Parce que ton scheduler est visiblement prévu pour gérer des sockets. Or côté client, on n’a pas que le socket connecté, on a aussi l’entrée standard, qui n’est pas un socket.
- Détrompe-toi ! Ce scheduler peut gérer n’importe quel descripteur de fichier. Pour un serveur de chat, effectivement, on a uniquement des sockets, c’est pour ça que j’ai utilisé des noms de variable comme sock ou socks. Mais tu peux éventuellement renommer ces variables en fd et fds, et lui passer l’entier 0 pour l’entrée standard, ça marchera aussi bien !
- Je vois… En gros, cet objet que tu m’as écrit en 5 minutes et quelques lignes de code, il peut à peu près tout faire, y compris le café ?
- Tu me flattes, là. Mais pour être honnête, si tu cherches les limites de cette technique, on peut quand même en trouver. Personnellement, j’y vois deux soucis. Le premier, c’est qu’en mettant des yield, on utilise une fonctionnalité particulière du langage, et que donc, on ne pourra plus utiliser cette fonctionnalité au même endroit pour faire autre chose. Imagine que dans mon générateur my_range() de tout à l’heure, je doive implémenter une lecture bloquante. Alors je ne vais pas pouvoir utiliser cette technique parce que les yield sont déjà utilisés pour faire autre chose.
- Effectivement. Mais ça ne doit pas arriver très souvent, ce genre de conflit.
- C’est vrai, c’est rare a priori. Mais passons au deuxième souci, qui est plus embêtant. Cela vient des limites internes de l’instruction yield. On a vu que quand le programme arrive sur un yield, le fil d’exécution revient à l’appelant, puis, quand l’appelant appelle __next__(), on revient sur le générateur. Cela implique qu’au moment du yield, l’interpréteur Python doit sauvegarder la position du programme, de façon à pouvoir y revenir plus tard. On pourrait imaginer que l’interpréteur sauvegarde toute la pile d’appels (backtrace), mais ce n’est pas le cas. Il sauvegarde uniquement l’élément tout en haut de cette pile d’appels. Cet élément renseigne donc uniquement la position du pointeur d’exécution au sein du générateur, pas tout le reste. Cela suffit, en réalité. Le truc, c’est que pour repasser la main au générateur, on utilise <generator>.__next__() ; on précise donc bien le générateur considéré, et comme on a sauvegardé la position au sein de ce générateur, l’interpréteur aura toutes les informations pour repartir au bon endroit.
- Moui… Et donc, où est le problème ?
- Eh bien, cela prouve que l’effet de l’instruction yield est borné à la fonction où elle est écrite. Et ça, c’est limitant. Regarde ce code par exemple :
from peter.article.glmf import Scheduler
def manage_sock(s):
setsockopt(s, [...])
yield s
c = s.read(1)
[...]
def coroutine1():
manage_sock(s1)
manage_sock(s2)
[...]
sched = Scheduler()
sched.start_coroutine(coroutine1())
sched.run()
Tu vois, le gars utilise mon scheduler, et un yield avant l’appel bloquant s.read(1). Il a essayé de factoriser un peu les opérations en introduisant la sous-fonction manage_sock(). C’était une bonne intention, mais là, ça ne marche plus. En effet, le yield n’est plus dans la fonction coroutine1, il est dans manage_sock. La fonction qui est transformée en générateur (ou en coroutine pour parler « pro ») est donc maintenant manage_sock, et coroutine1 reste une bête fonction. Pour que ça marche, il faudrait aussi modifier coroutine1 :
def coroutine1():
for fd in manage_sock(s1):
yield fd
for fd in manage_sock(s2):
yield fd
Les versions récentes de Python introduisent la syntaxe yield from qui permet quand même de simplifier un peu :
def coroutine1():
yield from manage_sock(s1)
yield from manage_sock(s2)
Quoi qu’il en soit, on voit que notre méthode de multiplexage commence à s’immiscer un peu partout dans notre programme, et ce n’est vraiment pas idéal. Dans cet exemple, on a maintenant transformé à la fois coroutine1 et manage_sock en générateurs. Si on voulait, par exemple, ajouter un code de retour à manage_sock pour indiquer si tout s’est bien passé, on ne peut plus. Ce n’est plus une simple fonction, c’est un générateur qui doit renvoyer des descripteurs de fichiers !
- Aïe aïe aïe…
- Comme tu dis. Le seul moyen d’étendre le fonctionnement d’un générateur sur plusieurs niveaux de sous-fonctions, c’est celui-là : il faut transformer tous les niveaux intermédiaires en générateur. Forcément, c’est plutôt intrusif !
Ce qui serait pratique, ce serait d’avoir une instruction superyield, qui permettrait d’appliquer l’effet du yield à l’appelant, voire à l’appelant de l’appelant, etc. Si on indique par exemple combien de niveaux de fonction on doit remonter (0 correspondant au yield standard), on pourrait par exemple écrire ceci :
def manage_sock(s):
setsockopt(s, [...])
superyield 1, s
c = s.read(1)
[...]
def coroutine1():
manage_sock(s1)
manage_sock(s2)
Et là, c’est coroutine1 qui serait vue comme un générateur, et manage_sock resterait une simple fonction. Mais je pense que ce genre d’instruction poserait des soucis d’implémentation assez insurmontables du côté de l’interpréteur.
- OK. Bon, pour ne rien te cacher, je me demande pourquoi tu es allé aussi loin dans tes explications, si c’est pour arriver sur ce genre d’impasse.
- Une impasse ? C’est un bien grand mot. Tu as vu que sur un programme simple, ça marche très bien cette technique. Il faut juste en connaître les limites. Je crois d’ailleurs que le module asyncio, que l’on trouve dans la librairie standard de Python, fonctionne grosso modo suivant ce même principe. En tout cas, on y retrouve la même contrainte concernant la transformation des niveaux intermédiaires en coroutines. Mais bon, comme tu as été sage, je vais te montrer une autre techno qui dépasse cette contrainte.
- Ah ? Bon alors fait vite parce que dans 10min tu dois me laisser la salle.
8. greenlets, gevent, pour aller plus loin
- OK, ce ne sera pas long. En fait, tu imagines bien qu’un certain nombre de développeurs ont exploré tous ces mécanismes, et sont arrivés jusqu’à ce yield et ses limites. Certains ont alors pris le taureau par les cornes et implémenté ce qu’il faut pour dépasser ces limites. La techno que je connais sur ce sujet, en Python, c’est le projet greenlet [2]. Il s’agit d’une librairie Python qui permet de lancer plusieurs coroutines, qu’on appelle bien sûr greenlets. Et ces greenlets n’ont pas les limitations du yield. En particulier, on peut « passer la main » sans souci dans une sous-fonction. Le secret, c’est que cette librairie est une extension Python codée en C. On peut donc aller beaucoup plus loin qu’avec du Python natif, et donc, en particulier, sauvegarder toute la pile d’exécution pour revenir plus tard à un endroit donné du programme.
- Je vois.
- Il y a une autre librairie célèbre sur ce sujet : c’est gevent [3]. En fait, gevent propose une implémentation alternative de certains objets et fonctions de la librairie standard Python, comme les modules socket, ssl, multiprocessing.pool, la fonction time.sleep(), etc., de manière à les rendre utilisables dans des coroutines. Tu te rappelles, tout à l’heure, je te disais qu’on devait placer un yield devant chaque appel bloquant pour que ça fonctionne avec notre scheduler. Eh bien, il faut imaginer que les développeurs de gevent ont réalisé le même genre d’adaptation sur une partie importante du code de la librairie standard. Et concernant l’implémentation du moteur de coroutines, gevent repose sur la librairie greenlet, tout simplement.
- D’accord… Et ce n’est pas trop difficile à utiliser ?
- Non au contraire. Tu crées des greenlets comme tu créerais des threads. Et ensuite, tu utilises par exemple la version gevent du module socket, de façon à ce qu’une lecture sur un socket ne bloque pas tout le programme.
- Effectivement ça a l’air simple.
- En fait, la plupart des exemples qu’on trouve sur Internet vont encore plus loin et commencent par :
import gevent.monkey
gevent.monkey.patch_all()
Ce module gevent.monkey permet de remplacer les modules et fonctions de la librairie standard par les versions patchées de gevent. Donc, si ensuite tu fais import socket, c’est en réalité le module gevent.socket qui sera importé ! Cette fonctionnalité permet de porter très rapidement du code qui n’a pas été conçu pour des coroutines. Par contre, en ce qui me concerne, je te déconseille vivement d’utiliser ce mécanisme de monkey-patching, en tout cas pour tout projet qui dépasse l’état de prototype. Déjà, au niveau sécurité, tu penses bien que si on détecte un problème de sécurité quelque part dans la librairie standard de Python, il sera corrigé bien plus vite que dans le code patché de gevent, donc il est prudent de limiter l’utilisation de ce code patché. Et puis, mine de rien, si tu emploies ce genre de mécanisme, tu maîtrises moins bien le comportement de ton code. En particulier, il est possible qu’une coroutine rende la main à un moment que tu n’as pas prévu, simplement parce que tu as patché un maximum de choses par gevent, même la gestion des fichiers par exemple ! Donc, tu ne maîtrises plus qui fait quoi à quel moment. En gros, tu as délégué l’ordonnancement de ton programme à gevent, et dans une certaine mesure, tu retombes dans le même genre de piège que celui des threads.
- Cela me paraît un peu violent effectivement. Donc toi tu ferais par exemple juste :
import gevent.socket
[...]
s = gevent.socket.socket([...])
[...]
- Oui par exemple. Là on sait ce qu’on fait. On sait que potentiellement, si on appelle s.read(), notre coroutine sera temporairement suspendue pour en faire travailler une autre. Mais pas à un autre moment plus ou moins imprévu de son exécution.
- OK. Cela me paraît plus sage en effet…
Conclusion
Peter jeta un coup d’œil sur sa montre, comme pour vérifier s’il n’avait pas encore quelques minutes pour me refiler deux ou trois astuces. Mais non, cette fois, il fallait conclure.
- Bon on a à peu près fait le tour de la question, dit-il, comme à regret.
- Oui. Mais du coup, je me demande quelle est ta méthode de prédilection, parmi celles qu’on a vues ?
- Eh bien ça dépend. Même les threads, parfois, c’est utile, et nécessaire. Si tu fais du calcul scientifique par exemple, et que tu essaies de diminuer un temps de calcul. Dans ce cas, pour faire travailler tous les cœurs de ton processeur à 100%, tu es obligé de faire du multithreading ou du multiprocessing.
- Effectivement.
- Par contre, pour un programme standard, clairement, j’évite les threads. Il y a 2 ans, je suis intervenu dans la phase de conception d’un projet. J’ai choisi d’utiliser gevent, avec parcimonie, comme on a vu. Ça marche bien. Et sur le projet précédent, j’avais architecturé le code autour d’une boucle d’événements home-made. En quelques dizaines de lignes, ça fait le job. Et même un peu plus que ce qu’on a vu. Par exemple, cette boucle d’événements permet aussi de planifier des traitements dans le temps. C’est facile parce que select() propose un paramètre optionnel timeout. Au final, je n’ai pas retouché à cette partie du code depuis des années, cela prouve que c’est robuste.
Parfois, j’ai quand même été amené à introduire des threads, pour pouvoir m’interfacer avec du code externe qui n’était pas prévu pour fonctionner avec des coroutines. L’exemple qui me vient en tête est l’interfaçage avec une librairie Docker.
- OK. Bon. Je n’ai plus qu’à expérimenter pour me familiariser avec la chose. Surtout avec ce paradigme évènementiel parce que ça ne me paraît pas trop naturel au premier abord.
- Ah bon ?? Moi, je crois que la première fois où j’ai fait man select, ça devait être en 2003, et je me suis aussitôt dit « bon sang, cette fonction c’est le chaînon manquant que j’attendais depuis toujours !! ».
- Ah. Ça prouve que nos cerveaux respectifs ne fonctionnent pas pareil. D’ailleurs… Le tien ne fonctionnerait pas avec une boucle d’événements par hasard ??
- Comment ça ?
- Eh bien tu sais, quand tu es en train de réfléchir à quelque chose, rien ne peut te déconcentrer, c’est comme si les autres événements étaient mis en attente… Et après, tu les dépiles pour les traiter un par un… Maintenant je sais pourquoi ce mode évènementiel te paraît si naturel : tu fonctionnes de la même façon !!
- Ah ? Euh oui c’est sans doute un peu vrai… Bon. Sur ce, je te laisse la salle. Bonne réunion !
- Merci, à plus !
Références
[1] DUBLÉ É., « Le test de Peter », GNU/Linux Magazine n°206, juillet 2017, p.52, et lisible gratuitement en ligne : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-206/Le-test-de-Peter
[2] Site du projet greenlet : https://greenlet.readthedocs.io
[3] Site de gevent : http://www.gevent.org