Guide pratique : programmation avec les sockets

Auteur

Gordon McMillan

Résumé

Les connecteurs (sockets, en anglais) sont utilisés presque partout, mais ils sont l'une des technologies les plus méconnues. En voici un aperçu très général. Ce n'est pas vraiment un tutoriel — vous aurez encore du travail à faire pour avoir un résultat opérationnel. Il ne couvre pas les détails (et il y en a beaucoup), mais j'espère qu'il vous donnera suffisamment d'informations pour commencer à les utiliser correctement.

Connecteurs (sockets)

Je ne parlerai que des connecteurs INET (c'est-à-dire IPv4), mais ils représentent au moins 99 % des connecteurs utilisés. Et je ne parlerai que des connecteurs STREAM (c.-à-d. TCP) — à moins que vous ne sachiez vraiment ce que vous faites (auquel cas ce guide n'est pas pour vous), vous obtiendrez un meilleur comportement et de meilleures performances avec un connecteur STREAM qu'avec n'importe quel autre. Je vais essayer d'éclaircir le mystère de ce qu'est un connecteur, ainsi que quelques conseils sur la façon de travailler avec des connecteurs bloquants et non bloquants. Mais je commencerai par parler des connecteurs bloquants. Vous devez savoir comment ils fonctionnent avant de vous intéresser aux connecteurs non bloquants.

Une partie de la difficulté à comprendre ces choses est que « connecteur » peut désigner plusieurs choses très légèrement différentes, selon le contexte. Faisons donc d'abord une distinction entre un connecteur « client » — point final d'une conversation — et un connecteur « serveur », qui ressemble davantage à un standardiste. L'application cliente (votre navigateur par exemple) utilise exclusivement des connecteurs « client » ; le serveur web avec lequel elle parle utilise à la fois des connecteurs « serveur » et des connecteurs « client ».

Historique

Parmi les différentes formes d’IPC, les connecteurs sont de loin les plus populaires. Sur une plate-forme donnée, il est probable que d'autres formes d'IPC soient plus rapides, mais pour la communication entre plates-formes, les connecteurs sont à peu près la seule solution valable.

Ils ont été inventés à Berkeley dans le cadre de la déclinaison BSD d'Unix. Ils se sont répandus comme une traînée de poudre avec Internet. Et pour cause : la combinaison des connecteurs avec INET rend le dialogue avec n’importe quelle machine dans le monde entier incroyablement facile (du moins par rapport à d'autres systèmes).

Création d'un connecteur

Grosso modo, lorsque vous avez cliqué sur le lien qui vous a amené à cette page, votre navigateur a fait quelque chose comme ceci :

# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))

Lorsque l’appel à connect est terminé, le connecteur s peut être utilisé pour envoyer une requête demandant le texte de la page. Le même connecteur lira la réponse, puis sera mis au rebut. C'est exact, mis au rebut. Les connecteurs clients ne sont normalement utilisés que pour un seul échange (ou un petit ensemble d'échanges séquentiels).

Ce qui se passe dans le serveur web est un peu plus complexe. Tout d'abord, le serveur web crée un « connecteur serveur » :

# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)

Quelques remarques : nous avons utilisé socket.gethostname() pour que le connecteur soit visible par le monde extérieur. Si nous avions utilisé s.bind(('localhost', 80)) ou s.bind(('127.0.0.1', 80)), nous aurions toujours un connecteur « serveur », mais qui ne serait visible qu'à l'intérieur de la même machine. s.bind(('', 80)) précise que le connecteur est accessible par n'importe quelle adresse que la machine possède.

Une deuxième chose à noter : les ports dont le numéro est petit sont généralement réservés aux services « bien connus » (HTTP, SNMP, etc.). Si vous expérimentez, utilisez un nombre suffisamment élevé (4 chiffres).

Enfin, l'argument passé à listen indique à la bibliothèque de connecteurs que nous voulons mettre en file d'attente jusqu'à 5 requêtes de connexion (le maximum normal) avant de refuser les connexions externes. Si le reste du code est écrit correctement, cela devrait suffire.

Maintenant que nous avons un connecteur « serveur », en écoute sur le port 80, nous pouvons entrer dans la boucle principale du serveur web

while True:
    # accept connections from outside
    (clientsocket, address) = serversocket.accept()
    # now do something with the clientsocket
    # in this case, we'll pretend this is a threaded server
    ct = client_thread(clientsocket)
    ct.run()

Il y a en fait trois façons générales de faire fonctionner cette boucle : mobiliser un fil d'exécution pour gérer les clientsockets, créer un nouveau processus pour gérer les clientsockets, ou restructurer cette application pour utiliser des connecteurs non bloquants, et multiplexer entre notre connecteur « serveur » et n'importe quel clientsocket actif en utilisant select. Plus d'informations à ce sujet plus tard. La chose importante à comprendre maintenant est la suivante : c'est tout ce que fait un connecteur « serveur ». Il n'envoie aucune donnée. Il ne reçoit aucune donnée. Il ne fait que produire des connecteurs « clients ». Chaque clientsocket est créé en réponse à un autre connecteur « client » qui se connecte à l'hôte et au port auxquels nous sommes liés. Dès que nous avons créé ce clientsocket, nous retournons à l'écoute pour d'autres connexions. Les deux « clients » sont libres de discuter — ils utilisent un port alloué dynamiquement qui sera recyclé à la fin de la conversation.

IPC (Communication Entre Processus)

Si vous avez besoin d'une communication rapide entre deux processus sur une même machine, vous devriez regarder comment utiliser les tubes (pipe, en anglais) ou la mémoire partagée. Si vous décidez d'utiliser les connecteurs AF_INET, liez le connecteur « serveur » à 'localhost'. Sur la plupart des plates-formes, cela contourne quelques couches réseau et est un peu plus rapide.

Voir aussi

Le multiprocessing intègre de l’IPC multiplateformes dans une API de plus haut niveau.

Utilisation d'un connecteur

La première chose à noter, c'est que la prise « client » du navigateur web et la prise « client » du serveur web sont des bêtes identiques. C'est-à-dire qu'il s'agit d'une conversation « pair à pair ». Ou pour le dire autrement, en tant que concepteur, vous devrez décider quelles sont les règles d'étiquette pour une conversation. Normalement, la connexion via connect lance la conversation en envoyant une demande, ou peut-être un signe. Mais c'est une décision de conception — ce n'est pas une règle des connecteurs.

Il y a maintenant deux ensembles de verbes à utiliser pour la communication. Vous pouvez utiliser send et recv, ou vous pouvez transformer votre connecteur client en une bête imitant un fichier et utiliser read et write. C'est la façon dont Java présente ses connecteurs. Je ne vais pas en parler ici, sauf pour vous avertir que vous devez utiliser flush sur les connecteurs. Ce sont des « fichiers », mis en mémoire tampon, et une erreur courante est d'« écrire » via write quelque chose, puis de « lire » via read pour obtenir une réponse. Sans un flush, vous pouvez attendre la réponse pour toujours, parce que la requête peut encore être dans votre mémoire tampon de sortie.

Nous arrivons maintenant au principal écueil des connecteurs — send et recv fonctionnent sur les mémoires tampons du réseau. Ils ne traitent pas nécessairement tous les octets que vous leur passez (ou que vous attendez d'eux), car leur principal objectif est de gérer les tampons réseau. En général, leur exécution se termine lorsque les tampons réseau associés ont été remplis (send) ou vidés (recv). Ils vous indiquent alors combien d'octets ils ont traité. Il est de votre responsabilité de les rappeler jusqu'à ce que votre message ait été complètement traité.

Lorsqu'un recv renvoie 0 octet, cela signifie que l'autre partie a fermé (ou est en train de fermer) la connexion. Vous ne recevrez plus de données sur cette connexion. Jamais. Vous pouvez peut-être envoyer des données avec succès. J’en parlerai plus tard.

Un protocole comme HTTP utilise un connecteur pour un seul transfert. Le client envoie une demande, puis lit une réponse. C'est tout. Le connecteur est mis au rebut. Cela signifie qu'un client peut détecter la fin de la réponse en recevant 0 octet.

Mais si vous prévoyez de réutiliser votre connecteur pour d'autres transferts, vous devez réaliser qu'il n'y a pas d'EOT sur un connecteur. Je répète : si un appel à send ou recv se termine après avoir traité 0 octet, la connexion a été interrompue. Si la connexion n'a pas été interrompue, vous pouvez attendre sur un recv pour toujours, car le connecteur ne vous dira pas qu'il n'y a plus rien à lire (pour le moment). Maintenant, si vous y réfléchissez un peu, vous allez vous rendre compte d'une vérité fondamentale sur les connecteurs : les messages doivent être de longueur fixe (beurk), ou être délimités (bof), ou indiquer de quelle longueur ils sont (beaucoup mieux), ou terminer en coupant la connexion. Le choix est entièrement de votre côté (mais certaines façons sont meilleurs que d'autres).

En supposant que vous ne vouliez pas terminer la connexion, la solution la plus simple est un message de longueur fixe :

class MySocket:
    """demonstration class only
      - coded for clarity, not efficiency
    """

    def __init__(self, sock=None):
        if sock is None:
            self.sock = socket.socket(
                            socket.AF_INET, socket.SOCK_STREAM)
        else:
            self.sock = sock

    def connect(self, host, port):
        self.sock.connect((host, port))

    def mysend(self, msg):
        totalsent = 0
        while totalsent < MSGLEN:
            sent = self.sock.send(msg[totalsent:])
            if sent == 0:
                raise RuntimeError("socket connection broken")
            totalsent = totalsent + sent

    def myreceive(self):
        chunks = []
        bytes_recd = 0
        while bytes_recd < MSGLEN:
            chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
            if chunk == b'':
                raise RuntimeError("socket connection broken")
            chunks.append(chunk)
            bytes_recd = bytes_recd + len(chunk)
        return b''.join(chunks)

Le code d'envoi ici est utilisable pour presque tous les systèmes de messagerie — en Python, vous envoyez des chaînes de caractères, et vous pouvez utiliser len() pour en déterminer la longueur (même si elle contient des caractères \0). C'est surtout le code de réception qui devient plus complexe. (Et en C, ce n'est pas bien pire, sauf que vous ne pouvez pas utiliser strlen si le message contient des \0s).

Le plus simple est de faire du premier caractère du message un indicateur du type de message, et de faire en sorte que le type détermine la longueur. Vous avez maintenant deux recvs — le premier pour obtenir (au moins) ce premier caractère afin de pouvoir déterminer la longueur, et le second dans une boucle pour obtenir le reste. Si vous décidez de suivre la route délimitée, vous recevrez un morceau de taille arbitraire (4096 ou 8192 est fréquemment une bonne valeur pour correspondre à la taille de la mémoire tampon du réseau), et vous analyserez ce que vous avez reçu pour trouver un délimiteur.

Une subtilité dont il faut être conscient : si votre protocole de conversation permet de renvoyer plusieurs messages les uns à la suite des autres (sans aucune sorte de réponse), et que vous passez à recv une taille de morceau arbitraire, vous pouvez en arriver à lire le début du message suivant. Vous devrez alors le mettre de côté et le conserver, jusqu'à ce que vous en ayez besoin.

Préfixer le message avec sa longueur (disons, sous la forme de 5 caractères numériques) devient plus complexe, parce que (croyez-le ou non), vous pouvez ne pas recevoir les 5 caractères en un seul recv. Pour une utilisation triviale, vous vous en tirerez à bon compte ; mais en cas de forte charge réseau, votre code se cassera très rapidement, à moins que vous n’utilisiez deux boucles recv — la première pour déterminer la longueur, la deuxième pour obtenir la partie « données » du message. Vilain. C’est aussi à ce moment que vous découvrirez que « l’envoi » via send ne parvient pas toujours à tout évacuer en un seul passage. Et bien que vous ayez lu cet avertissement, vous finirez par vous faire avoir par cette subtilité !

Pour garder une longueur raisonnable à cette page, pour forger votre caractère (et afin de garder l’avantage concurrentiel que j’ai sur vous), ces améliorations ne seront pas abordées et sont laissées en exercice au lecteur. Passons maintenant au nettoyage.

Données binaires

It is perfectly possible to send binary data over a socket. The major problem is that not all machines use the same formats for binary data. For example, network byte order is big-endian, with the most significant byte first, so a 16 bit integer with the value 1 would be the two hex bytes 00 01. However, most common processors (x86/AMD64, ARM, RISC-V), are little-endian, with the least significant byte first - that same 1 would be 01 00.

Les bibliothèques de sockets ont des appels pour convertir les entiers 16 et 32 bits - ntohl, htonl, ntohs, htons où « n » signifie network (réseau) et « h » signifie host (hôte), « s » signifie short (court) et « l » signifie long. Lorsque l’ordre du réseau est l’ordre de l’hôte, ils ne font rien, mais lorsque la machine fonctionne en ordre inversé, ils échangent les octets de manière appropriée.

De nos jours, avec les machines 64 bits, la représentation ASCII des données binaires est souvent plus compacte que la représentation binaire. C’est parce que dans un nombre surprenant de cas, tous ces entiers ont la valeur 0 et parfois 1. La chaîne « 0 » serait codée sur deux octets, alors qu'elle le serait sur 8 en tant que binaire 64 bits. Bien sûr, cela ne fonctionne pas très bien avec les messages de longueur fixe. C’est une affaire de choix.

Déconnexion

À proprement parler, vous êtes censé utiliser shutdown sur un connecteur pour l’arrêter avant de le fermer via close. Le shutdown est un avertissement au connecteur de l’autre côté. Selon l’argument que vous lui passez, cela peut signifier « Je ne vais plus envoyer, mais je vais quand même écouter », ou « Je n’écoute pas, bon débarras ! ». La plupart des bibliothèques de connecteurs, cependant, sont tellement habituées à ce que les programmeurs négligent d’utiliser ce morceau d’étiquette que normalement un close est équivalent à shutdown() ; close(). Ainsi, dans la plupart des situations, un shutdown explicite n’est pas nécessaire.

Une façon d’utiliser efficacement le shutdown est d’utiliser un échange de type HTTP. Le client envoie une requête et effectue ensuite un shutdown(1). Cela indique au serveur que « ce client a fini d’envoyer, mais peut encore recevoir ». Le serveur peut détecter EOF par une réception de 0 octet. Il peut supposer qu’il a la requête complète. Le serveur envoie une réponse. Si le send se termine avec succès, alors, en effet, le client était encore en train de recevoir.

Python pousse l’arrêt automatique un peu plus loin, et dit que lorsqu’un connecteur est collecté par le ramasse-miette, il effectue automatiquement une fermeture via close si elle est nécessaire. Mais c’est une très mauvaise habitude de s’appuyer sur ce système. Si votre connecteur disparaît sans avoir fait un close, le connecteur à l’autre bout peut rester suspendu indéfiniment, pensant que vous êtes juste lent. Fermez vos connecteurs quand vous avez terminé s’il vous plaît.

Quand les connecteurs meurent

Le pire dans l'utilisation de connecteurs bloquants est probablement ce qui se passe lorsque l'autre côté s'interrompt brutalement (sans faire de fermeture via close). Votre connecteur risque d’attendre infiniment. TCP est un protocole fiable, et il attendra très, très longtemps avant d'abandonner une connexion. Si vous utilisez des fils d’exécution, le fil entier est pratiquement mort. Il n'y a pas grand-chose que vous puissiez faire à ce sujet. Du moment que vous ne faites rien de stupide, comme tenir un verrou verrouillé pendant une lecture bloquante, le fil ne consomme pas vraiment beaucoup de ressources. N'essayez pas de tuer le fil — si les fils sont plus efficients que les processus, c'est en partie parce qu'ils évitent les coûts significatifs liés au recyclage automatique des ressources. En d'autres termes, si vous parvenez à tuer le fil, tout votre processus risque d'être foutu.

Connecteurs non bloquants

Si vous avez compris ce qui précède, vous savez déjà tout ce que vous devez savoir sur la mécanique de l’utilisation des connecteurs. Vous utiliserez toujours les mêmes appels, de la même façon. Il n’y que ça. Si vous le faites bien, c’est presque dans la poche.

En Python, vous utilisez socket.setblocking(0) pour rendre non-bloquant. En C, c’est plus complexe (pour commencer, vous devez choisir entre la version BSD O_NONBLOCK et la version POSIX presque impossible à distinguer O_NDELAY, qui est complètement différente de TCP_NODELAY), mais c’est exactement la même idée. Vous le faites après avoir créé le connecteur mais avant de l’utiliser (en fait, si vous êtes fou, vous pouvez alterner).

La différence majeure de fonctionnement est que send, recv, connect et accept peuvent rendre la main sans avoir rien fait. Vous avez (bien sûr) un certain nombre de choix. Vous pouvez vérifier le code de retour et les codes d'erreur et, en général, devenir fou. Si vous ne me croyez pas, essayez un jour. Votre application va grossir, boguer et vampiriser le processeur. Alors, évitons les solutions vouées à l’échec dès le départ et faisons les choses correctement.

Utilisation de select.

En C, implémenter select est assez complexe. En Python, c'est du gâteau, mais c'est assez proche de la version C ; aussi, si vous comprenez select en Python, vous aurez peu de problèmes en C :

ready_to_read, ready_to_write, in_error = \
               select.select(
                  potential_readers,
                  potential_writers,
                  potential_errs,
                  timeout)

Vous passez à select trois listes : la première contient tous les connecteurs dont vous souhaiter lire le contenu ; la deuxième tous les connecteurs sur lesquels vous voudriez écrire, et la dernière (normalement laissée vide) ceux sur lesquels vous voudriez vérifier s’il y a des erreurs. Prenez note qu'un connecteur peut figurer dans plus d'une liste. L'appel à select est bloquant, mais vous pouvez lui donner un délai d'attente. C'est généralement une bonne chose à faire — donnez-lui un bon gros délai d'attente (disons une minute), à moins que vous n'ayez une bonne raison de ne pas le faire.

En retour, vous recevrez trois listes. Elles contiennent les connecteurs qui sont réellement lisibles, inscriptibles et en erreur. Chacune de ces listes est un sous-ensemble (éventuellement vide) de la liste correspondante que vous avez transmise.

Si un connecteur se trouve dans la liste renvoyée des connecteurs lisibles, vous pouvez être pratiquement certain qu'un recv sur ce connecteur renvoie quelque chose. Même chose pour la liste renvoyée des connecteurs inscriptibles. Vous pourrez envoyer quelque chose. Peut-être pas tout ce que vous voudrez, mais quelque chose est mieux que rien (en fait, n'importe quel connecteur raisonnablement sain sera présent dans la liste des connecteurs inscriptibles — cela signifie simplement que l'espace tampon réseau sortant est disponible).

Si vous avez un connecteur « serveur », mettez-le dans la liste des connecteurs potentiellement lisibles potential_readers. S’il apparaît dans la liste renvoyée des connecteurs que vous pouvez lire, votre accept fonctionnera (presque certainement). Si vous avez créé un nouveau connecteur pour se connecter via connect à quelqu'un d'autre, mettez-le dans la liste des connecteurs potentiellement inscriptibles. S’il apparaît dans la liste renvoyée des connecteurs sur lesquels vous pouvez écrire, vous avez une bonne chance qu'il se soit connecté.

En fait, select peut être pratique même avec des connecteurs bloquants. C'est une façon de déterminer si vous allez bloquer — le connecteur est renvoyé comme lisible lorsqu'il y a quelque chose dans les mémoires tampons. Cependant, cela n'aide pas encore à déterminer si l'autre extrémité a terminé, ou si elle est simplement occupée par autre chose.

Alerte de portabilité : Sous Unix, select fonctionne aussi bien avec les connecteurs qu'avec les fichiers. N'essayez pas cela sous Windows. Sous Windows, select ne fonctionne qu'avec les connecteurs. Notez également qu'en C, la plupart des options de connecteurs les plus avancées se font différemment sous Windows. En fait, sous Windows, j'utilise habituellement des fils d'exécution (qui fonctionnent très, très bien) avec mes connecteurs.