Php socket

De The Linux Craftsman
Aller à la navigation Aller à la recherche

Introduction

Dans le contexte des logiciels, on peut le traduire par « connecteur réseau » ou « interface de connexion ».

Apparu dans les systèmes UNIX, un socket est un élément logiciel qui est aujourd’hui répandu dans la plupart des systèmes d’exploitation. Il s’agit d’une interface logicielle avec les services du système d’exploitation, grâce à laquelle un développeur exploitera facilement et de manière uniforme les services d’un protocole réseau.

[...]

Il s’agit d’un modèle permettant la communication inter processus (IPC - Inter Process Communication) afin de permettre à divers processus de communiquer aussi bien sur une même machine qu’à travers un réseau TCP/IP.

[...]

On distingue ainsi deux modes de communication :

  • Le mode connecté [...] utilisant le protocole TCP. [...]
  • Le mode non connecté [...] utilisant le protocole UDP.

[...]

Les sockets se situent entre la couche transport et les couches applicatives du modèle OSI (protocoles UDP ou TCP utilisant IP / ARP).

Wikipedia

Mise en œuvre

Les socket vont nous permettre de faire communiquer deux processus entre eux grâce à un protocole, généralement un processus appelé serveur et l'autre processus appelé client. C'est pourquoi on parle souvent de protocole client / serveur.

Tout est détaillé sur le site php.net mais il est important de s'attarder sur certains points.

Socket TCP (Stream)

Établissement d'une connexion

Pour créer une socket, il y a deux façons, soit on utilise :

La première méthode va permettre de choisir l'adresse sur laquelle on va placer la socket en écoute ainsi que le type de socket. La deuxième méthode permet uniquement de créer des socket AF_INET (IPv4) de type SOCK_STREAM (TCP).

Si l'objectif est de créer un démon qui écoute sur toutes les interfaces en TCP, la deuxième méthode est plus appropriée.
Au contraire, si l'objectif est de faire de l'IPC, une socket UDP ou même brute (OSI 3), il vaut mieux opter pour la première méthode.

Voici un exemple de socket TCP avec la première méthode.
Ce socket écoute sur le port 1234 de l'interface 127.0.0.1 en TCP :

Voici un exemple de socket TCP avec la deuxième méthode.
Ce socket écoute sur le port 1234 de toutes les interfaces en TCP :

<?php
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, "127.0.0.1", 1234);
socket_listen($socket);
while($c = socket_accept($socket)) {
	/* Traiter la requête entrante */
}
socket_close($socket);
?>
<?php
$socket = socket_create_listen(1234);
while($c = socket_accept($socket)) {
	/* Traiter la requête entrante */
}
socket_close($socket);
?>

Que se passe t-il si le port est déjà utilisé, si les droits ne permettent pas de binder la socket, si le type de socket demandée n'existe pas ? Un erreur survient et regardons maintenant comment l'attraper !

Gestion des erreurs

Pour attraper les erreurs, il faut tester le retour des fonctions énoncées précédemment. Si celui-ci est à FALSE une erreur est survenue et on peut récupérer son code et même une explication textuelle.

Voici un exemple avec la première méthode.

Voici un exemple avec la deuxième méthode.

<?php
if (($socket = socket_create ( AF_INET, SOCK_STREAM, SOL_TCP )) == FALSE) {
	echo "socket_create_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
if(socket_bind ( $socket, "127.0.0.1", 1234 )==FALSE){
	echo "socket_bind() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
if(socket_listen ( $socket )==FALSE){
	echo "socket_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
while ( $c = socket_accept ( $socket ) ) {
	/* Traiter la requête entrante */
}
socket_close ( $socket );
?>
<?php
if (($socket = socket_create_listen ( 1234 )) == FALSE) {
	echo "socket_create_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
while ( $c = socket_accept ( $socket ) ) {
	/* Traiter la requête entrante */
}
socket_close ( $socket );
?>

Cela donnera le résultat suivant :

PHP Warning:  socket_create_listen(): unable to bind to given address [98]: Address already in use in /root/workspace/Sockets/src/socket.php on line 2
socket_create() a échoué : raison : Address already in use

Pour enlever le PHP warning il suffit d'ajouter un '@' devant la fonction qui génère le warning

$socket = socket_create_listen ( 1234 )

devient

$socket = @socket_create_listen ( 1234 )

Traitement des connexions entrantes

On va maintenant s'intéresser à la récupération des connexions entrante. Pour cela, nous allons créer un petit serveur echo.

<?php
if (($socket = socket_create_listen ( 1234 )) == FALSE) {
	echo "socket_create_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
while ( $c = socket_accept ( $socket ) ) {
	while ( $c !== FALSE ) {
		if ($buf = socket_read ( $c, 2048 )) {
			socket_write ( $c, "You said : ".$buf );
		}
	}
}
socket_close ( $socket );
?>

Ce qui nous donne :

côté serveur :

coté client, en utilisant la commande telnet :

# php -f socket.php

# telnet 127.0.01 1234
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
coucou
You said : coucou

Lorsque l'on termine le processus serveur en envoyant un SIGINT (ctrl + c), le client reçoit le message Connection closed by foreign host. et le serveur laisse la socket en TIME_WAIT. C'est un comportement tout à fait normale car la connexion, à l'initiative du client, et terminée par le serveur et le système attends un certain temps (d’où le nom de TIME_WAIT) pour laisser aux derniers segments TCP le temps d'arriver.

Tout cela nous importe peu, sauf que lors du prochain lancement du script :

# php -f test.php
PHP Warning:  socket_create_listen(): unable to bind to given address [98]: Address already in use in /root/workspace/Sockets/src/socket.php on line 2

Effectivement, lorsque l'on regarde qui occupe le port 1234 :

# netstat -atnp | grep 1234
tcp        0      0 127.0.0.1:1234              127.0.0.1:47408             TIME_WAIT   -  

C'est bien le système qui laisse la socket ouverte un certain temps, même si personne n'écoute (- à la place du nom du processus).

Nous allons maintenant modifier les options de la socket pour éviter cet effet.

Modification des options de socket

Pour régler le problème précédent, deux choix s'offre à nous :

  • SO_REUSEADDR : option qui permet, lorsqu'elle est mise à 1, de binder de nouveau une socket à un port en TIME_WAIT en assumant qu'il n'y ai aucun paquet en transit ;
  • SO_LINGER : option qui, lorsqu'elle est mise à 0, initie une fermeture anormale de la socket. Ce phénomène s'appelle en anglais slamming the connection shut ou en français raccrocher brutalement la connexion.

Nous allons préférer la première option qui est plus propre. Cependant, l'exemple précédent qui utilise socket_create_listen ne permet pas de modifier l'option avant l'étape de bind puisque les trois étapes sont regroupées. Nous allons donc basculer sur la première méthode...

if (($socket = socket_create ( AF_INET, SOCK_STREAM, SOL_TCP )) == FALSE) {
	echo "socket_create_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
// Modification de l'option SO_REUSEADDR à la valeur 1 !
if (!socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1)) {
	echo 'Impossible de définir l\'option du socket : '. socket_strerror(socket_last_error($socket)) . "\n";
	exit ( 1 );
}
if(socket_bind ( $socket, "127.0.0.1", 1234 )==FALSE){
	echo "socket_bind() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
if(socket_listen ( $socket )==FALSE){
	echo "socket_listen() a échoué : " . socket_strerror ( socket_last_error ($socket) ) . "\n";
	exit ( 1 );
}
while ( $c = socket_accept ( $socket ) ) {
	while ( $c !== FALSE ) {
		if ($buf = socket_read ( $c, 2048 )) {
			socket_write ( $c, "You said : " . $buf );
		}
	}
}
socket_close ( $socket );

On peut maintenant relancer le script sans problème !

Traitement multi-client

Le problème de notre serveur echo est qu'il ne peut pas traiter plus de un client à la fois. Ce qui offre un intérêt limité pour un serveur... Nous allons remédier à cela en passant les sockets en mode non bloquant.

Les fonctions socket_accept et socket_read vont rendre la main en renvoyant FALSE si aucune connexion n'est disponible ou si le tampon d'entrée est vide.

// Passage en mode non bloquant de la socket du serveur
socket_set_nonblock ( $socket );
$clients = array ();
while ( TRUE ) {
	if ($c = socket_accept ( $socket )) {
		// Passage en mode non bloquant de la socket du client
		socket_set_nonblock ( $c );
		// Ajout de la socket cliente au tableau
		$clients [] = $c;
	}
	// On répond au clients qui ont envoyés un message
	for($i = 0; $i < sizeof ( $clients ); $i ++) {
		$c = $clients [$i];
		if ($buf = socket_read ( $c, 2048 )) {
			socket_write ( $c, "You said : " . $buf );
		}
	}
	// On efface les sockets fermées
	for($i = 0; $i < sizeof ( $clients ); $i ++) {
		$c = $clients [$i];
		if ($c == FALSE) {
			$clients = array_splice ( $clients, $i, 1 );
		}
	}
}
socket_close ( $socket );

On constate que plusieurs clients peuvent maintenant utiliser le serveur echo.

Consommation excessive

Cependant le script prend un peu trop de ressource :

# top | grep php
  7844 root      20   0  264m 9356 5740 R 98.1  0.9   0:07.72 php

Il faut avouer, 98% de CPU pour un script PHP de ce calibre... c'est un peu exagéré ! Le faite d'avoir passé les sockets en mode non bloquant, revient à faire une boucle itérative sans temps d'arrêt, ce qui à pour conséquence de consommer la quasi totalité du CPU.

Il suffit d'ajouter un temps de repos dans notre boucle :

while ( TRUE ) {
	...
	usleep(500);
}

On constate que cette petite sieste de 500 µs fonctionne parfaitement :

# top | grep php
  7874 root      20   0  264m 9364 5748 S  2.3  0.9   0:00.07 php

L'occupation du CPU est passée de 98% à 2.3% !

Niveau réseau

On peut observer la mise en place de notre socket grâce à la commande :

# tcpdump -i lo -nn -vv -w socket_tcp.pkt tcp and port 1234

L'option -w permet d'enregistrer les trames dans un fichier qui peut être lu avec Wireshark.

On peut constater :

  • l'établissement de la connexion (SYN / ACK+SYN / ACK)

Php socket tcp establishment.png

  • l'échange des données (PSH / ACK)
    • les trames
      Php socket tcp stream.png
    • le contenu
      Php socket tcp stream data.png
  • la fin de la connexion (FIN / ACK)

Php socket tcp end.png

Socket UDP (Datagram)

Établissement d'une connexion

Pour créer une socket UDP, il n'y a qu'une seule façons : socket_create et socket_bind.

Voici un exemple de socket UDP qui écoute sur le port 1234 de toutes les interfaces :

<?php
$socket = socket_create ( AF_INET, SOCK_DGRAM, SOL_UDP );
socket_bind ( $socket, "127.0.0.1", 1234 );
socket_listen ( $socket );
while ( TRUE ) {
	$byteReceived = socket_recvfrom( $socket, $buf, 2048, MSG_DONTWAIT, $remote_ip, $remote_port);
	if ( $byteReceived > 0 ) {
		echo "New datagram received from $remote_ip:$remote_port : $buf";
		$buf = "You said : " . $buf;
		socket_sendto ( $socket, $buf, strlen($buf), MSG_EOF, $remote_ip, $remote_port );
	}
}
socket_close ( $socket );
?>

Ici la fonction socket_recvfrom permet de récupérer ce qui arrive de la socket UDP et socket_sendto permet de le renvoyer au client.

Mode RAW