Déconnexion PDO : du comptage de références en PHP

Magazine
Marque
GNU/Linux Magazine
Numéro
194
Mois de parution
juin 2016
Spécialité(s)


Résumé
Vous utilisez pour votre projet les composants populaires et éprouvés de l'écosystème PHP et mettez en œuvre les pratiques recommandées dans les manuels. Pourtant votre application se heurte à des problèmes de conception : une piste se cache peut-être ici.

Body

PHP, comme la plupart des langages dynamiques, offre un mécanisme de collecte de résidus qui affranchit le développeur de se préoccuper de la gestion de la mémoire. Il est notre ami, mais il est préférable de savoir travailler avec lui pour éviter certains désagréments liés à la structure du langage.

Nous allons étudier dans ces lignes le comportement du comptage de références en PHP et l'incidence sur le cycle de vie d'un objet, à travers un exemple : la classe PDO.

1. L'objet PDO

On ne présente plus PDO (PHP Data Objects), la couche d'abstraction qu'offre PHP pour la manipulation de bases de données, en lieu et place des bonnes vieilles familles de fonctions mysql_* ou pgsql_*, etc.

L'ancienne extension fournissait la fonction mysql_connect pour initier la connexion au serveur de bases de données. La classe PDO, elle, prend une approche différente en réalisant la connexion lors de la création d'une instance :

$pdo = new PDO($datasource, $username, $password);

En revanche, contrairement à l'approche procédurale, il n'y a plus l'équivalent de mysql_close pour terminer la connexion et libérer la ressource. Le manuel indique que c'est lors de la destruction de l'objet PDO que la déconnexion est réalisée.

Or, au grand regret de certains, il n'y a pas en PHP d'instruction delete comme on aurait eu envie de l'écrire en C++. La destruction d’un objet en PHP se produit automatiquement lorsque l'interpréteur estime qu'il n'est plus utilisé, c’est-à-dire lorsque le programme ne dispose plus d'aucun moyen d'y faire référence.
Cela peut se produire, par exemple, si l'on indique explicitement au programme qu'il peut récupérer la variable et ses ressources :

$pdo = null;

C'est également le cas si la variable qui portait l'objet avait été créée sur la pile, dans une fonction :

function doSomehtingWithDb()

{
  $pdo = new PDO( ... );
  $pdo->query( ... );
}

Dans ce listing, la variable $pdo est locale à la fonction ; elle est automatiquement détruite à sa sortie, et dans le cas de la bibliothèque PDO cela aboutit à la déconnexion. Plus généralement, comme à l'habitude en PHP, toutes les variables et ressources sont libérées en fin de script, ce qui est aussi le cas des connexions PDO (sauf pour les connexions persistantes qui sortent du cadre de cet article).

2. Cycle de vie des programmes

Programmer en PHP donne parfois l'impression que le cycle de vie d'un script est nécessairement court, car calé sur le cycle d'une seule requête-réponse HTTP. Puisque tout le monde s'emploie à répondre au navigateur dans les meilleurs délais, il est généralement conçu que toute ressource allouée dans un script PHP (connexion à une base de données, ouverture d'un fichier, instances d’objets) sera rapidement libérée de façon automatique, même sans prendre aucune disposition de fermeture explicite.
Des usages nouveaux ont pourtant émergé ces dernières années dans l'écosystème PHP, comme l'exécution de scripts longs en ligne de commandes ou de services d’arrière-plan de type daemon [1], voire des conteurs applicatifs complets [2] en pur PHP. La particularité de ces paradigmes est que la terminaison organique du script peut bien ne survenir qu'au bout d’un temps très long, voire jamais. Dans ces cas, il devient donc nécessaire de libérer soigneusement tout ce dont on n'a plus besoin le plus tôt possible, pour ne pas aboutir à des fuites mémoires ou des blocages de ressources.
Mais même dans des programmes simples, il peut s'avérer nécessaire parfois de contrôler explicitement l'instant de déconnexion à une base de données comme nous allons le voir.

3. Connexions simultanées

Soit un script qui lit des informations dans une table (un nom de compte Twitter, une adresse de vidéo YouTube, etc.) puis appelle des API distantes (extraire les mille derniers tweets de chaque abonné, trancher des images dans le clip, etc.). Supposons que la requête SQL initiale est rapide, et que l'exécution complète du script nécessite plusieurs dizaines de secondes, passées essentiellement dans la latence réseau et les API impliquées.

Remarque d'architecture : une bonne solution à ce type de problème devrait faire intervenir plusieurs composants (workers) découplés les uns des autres, avec un système de file d'attente de messages, plutôt qu'un script monolithique qui traite toute la chaîne linéairement. C'est néanmoins ce deuxième choix qui nous permettra d'illustrer ici le problème soulevé.

Le programme commence son travail par l'accès à la base de données, puis poursuit sa longue tâche que l'on peut schématiquement représenter comme ceci (instructions simplifiées délibérément pour ne pas surcharger l'exemple) :

08: $pdo = new PDO(...);

09:

10: // requêter les infos du job à effectuer (rapide)
11: $data = $pdo->query('select ...')->fetch();
12:
13: // appel aux APIs qui prend un certain temps
14: executer_travail_long_avec_des_api($data);
15:
16: // Fin de script.

L'application est capable de gérer potentiellement des dizaines de traitements simultanés, car l'essentiel du travail est effectué à l'extérieur. Pourtant, si l'on ne prend pas soin de fermer la connexion $pdo, le traitement concurrent sera limité au nombre de connexions simultanées autorisées par la base de données pour le compte applicatif utilisé. Car une fois la requête initiale effectuée, la connexion demeure, bien qu'en sommeil :

mysql> show full processlist;
+-----+----------+-----------+-------+---------+------+----------+-----------------------+
| Id  | User     | Host      | db    | Command | Time | State    | Info                  |
+-----+----------+-----------+-------+---------+------+----------+-----------------------+
| 112 | app-user | localhost | appdb | Sleep   |   31 |          | NULL                  |
| 113 | app-user | localhost | appdb | Sleep   |   28 |          | NULL                  |
| 145 | app-user | localhost | appdb | Sleep   |   24 |          | NULL                  |
| 159 | app-user | localhost | appdb | Sleep   |   12 |          | NULL                  |
| 165 | app-user | localhost | appdb | Sleep   |    2 |          | NULL                  |
| 170 | app-user | localhost | appdb | Sleep   |    1 |          | NULL                  |
| 174 | root     | localhost | NULL  | Query   | 0 | starting | show full processlist |
+-----+----------+-----------+-------+---------+------+----------+-----------------------+

Or il est généralement conseillé de maintenir ce nombre relativement bas. La nécessité d'augmenter le nombre de connexions simultanées autorisées à une base de données, trahit généralement un défaut de conception applicative, plutôt qu'un réel dépassement des capacités de passage à l'échelle.

CREATE USER 'app-user'@'localhost' IDENTIFIED BY 'app-password' WITH MAX_USER_CONNECTIONS 10;

Il faut donc modifier le script pour libérer PDO au plus tôt :

11: $data = $pdo->query('select ...')->fetch();
12: $pdo = null;  // <-- Terminaison explicite de la connexion
13: // appel aux APIs qui prend un certain temps

Car une fois les données lues depuis la base de données, le script n'a plus besoin d'y retourner pendant toute la durée du traitement des API externes. L'annulation de l'instance $pdo indique au programme qu'il peut se déconnecter de la base de données, libérant ainsi la place pour d'autres exécutions concurrentes du script.

mysql> show full processlist;
+-----+----------+-----------+-------+---------+------+----------+-----------------------+
| Id  | User     | Host      | db    | Command | Time | State    | Info                  |
+-----+----------+-----------+-------+---------+------+----------+-----------------------+
| 244 | root     | localhost | NULL  | Query   | 0 | starting | show full processlist |
+-----+----------+-----------+-------+---------+------+----------+-----------------------+

Les accès à la base, pour des requêtes légères, sont alors si furtifs qu'ils n'apparaissent généralement plus dans la process list.

4. Les références en PHP

Le langage PHP s'appuie classiquement sur un compteur de références pour pister le nombre de « copies » d'une variable en mémoire. Les affectations suivantes disent au moteur que l'instance effective de l'objet de classe PDO est référencée deux fois :

$pdo1 = new PDO(...);
$pdo2 = $pdo1;

Par suite, la mise à null d'une des variables n'a aucune incidence sur l'instance (il existe deux poignées sur la valise ; en supprimer une ne compromet pas la manipulation du bagage).

$pdo1 = null;
faire_quelque_chose_avec_la_db($pdo2); // ok

C'est seulement lorsque la dernière référence d'un objet est perdue, que le moteur PHP détruit l'objet sous-jacent.

$pdo2 = null; // conduit à la destruction de l'instance PDO

La vigilance s'impose donc, quant à l'utilisation de la classe PDO dans des scripts à exécution longue. L'augmentation par inadvertance du compteur de références pour notre objet $pdo conduirait au maintien de la connexion jusqu'à la fin du script. Or, il est parfois complexe de bien comprendre où se cachent les références dans les langages dynamiques.

5. Fermetures et fermeture

Considérons maintenant la situation suivante, où il est fait usage d'un framework de routage tel que Slim [3] à base de fonctions anonymes :

10: // Préparation des dépendances
11: $pdo = new PDO(...);
12:
13: // Déclaration des routes
14: $app->get('/exemple-precedent', function (Request $req, Response $res) use ($pdo) {
15:   $data = $pdo->query(...)->fetch();
16:   $pdo = null;    //  <- tentative de déconnexion
17:   // ... traitement long ici
18: });
// D'autres routes ici...
50: // Déclenchement de l'analyse de l'URI et invocation des handlers de routes par Slim
51: $app->run();
52: // Fin du programme.

La clause use ($pdo) en ligne 14, qui permet d'indiquer à la fonction anonyme qu'elle doit enfermer ladite variable de façon à la rendre utilisable à l'intérieur, constitue une incrémentation de son compteur de référence. Aussi en ligne 16, la tentative $pdo = null dans le corps du handler de l'URI /exemple-precedent est de bonne volonté, mais reste un échec : la copie locale de la référence $pdo est bien annulée, mais pas la référence enfermée par la closure, et l'objet sous-jacent demeure intact. La fonction anonyme poursuit son existence jusqu'à la fin de l'exécution de la méthode $app->run(), et ce n'est donc qu'à la fin du programme que l'instance PDO sera libérée et la connexion fermée.

Routage

Le routage dans le cadre d'une application web est l'association d'un morceau de code à une famille d'URIs donnée. Il est souvent réalisé par la mise en œuvre de closures, ou fermetures. Une fermeture est un bloc de code, le plus souvent obtenu par la déclaration d'une fonction anonyme, ayant la particularité d'enfermer l'ensemble des variables disponibles dans son scope de déclaration, afin de les rendre disponibles pour son exécution même si elle est invoquée depuis un scope différent.

6. Le destructeur

Bien qu'il ne soit pas possible de forcer la destruction d'un objet tant que son compteur de références n'est pas nul, il est toutefois possible d'écrire du code que le moteur promet d'exécuter automatiquement lors de la libération de l'instance. Le gestionnaire à implanter est la magic method peu connue __destruct(). Il est recommandé de lire attentivement dans le manuel [4] les particularités de son fonctionnement.

Nous allons l'utiliser pour mettre en pratique sur une classe simple, les observations étudiées plus haut.

class K

{

  private $name;


  public static function timer() { return time() - $_SERVER['REQUEST_TIME']; }


  public function __construct($name) {

    echo self::timer() . ' : ' . __FUNCTION__ . ' ' . ($this->name = $name) .  PHP_EOL;

  }

  public function __destruct() {

    echo self::timer() . ' : ' . __FUNCTION__ . ' ' . $this->name . PHP_EOL;

  }

}

Cette classe annonce sa construction et sa destruction, en indiquant le temps écoulé depuis le lancement du script (grâce à la valeur super-globale $_SERVER['REQUEST_TIME']).

Utilisons-la dans trois situations différentes : destruction explicite simple, capture d'instance dans une fermeture, et destruction automatique d'instance par la terminaison du programme.

10: $q = new K('q');

11: $k = new K('k');

12: $x = new K('x');

13:

14: $c = function () use ($k) {

15:   $k = null;

16: };

17:

18: $c();      // sans effet

19: $k = null; // sans effet

20: $q = null; // destruction de q car non capturé

21:

22: sleep(6);

23: $c = null; // Libération de la closure, et donc de k

24:

25: echo K::timer() . ' : FIN.' . PHP_EOL;

L'exécution de ce script affiche :

0 : __construct q

0 : __construct k

0 : __construct x

0 : __destruct q
6 : __destruct k

6 : FIN
6 : __destruct x

Trois instances sont construites dès le début du script (lignes 10 à 12)

La fermeture $c en ligne 14 (en réalité, la clause use) emprisonne une référence de k, que l'on libère automatiquement en restituant la référence sur la fonction anonyme en ligne 23 après le sleep.

L'instance q n'est impliquée dans aucune clause use, et elle est donc bien libérée dès que la variable $q est annulée en ligne 20. Enfin, l'instance x n'est jamais explicitement restituée : elle est donc détruite automatiquement lorsque le script termine son exécution (après la fin de l'instruction de dernière ligne 25).

7. Encapsulation

La solution, dans notre programme de routage de la section 5, est d'encapsuler l'objet PDO dans un pattern de composition :

class PDOWrapper

{

  /** @var PDO */

  private $pdo;


  public function __construct( ... ) { $this->pdo = new PDO( ... ); }

  /** @return PDO */ public function getPDO() { return $this->pdo; }

  public function terminate() { $this->pdo = null; }

}

Alors, le programme précédent devient :

10: // Préparation des dépendances

11: $pdoWrapper = new PDPWrapper(...);

12:

13: // Routes

14: $app->get(..., function (...) use ($pdoWrapper) {

15:   $pdoWrapper->getPDO()->query( ... );

16:   $pdoWrapper->terminate();

17: });

Cette fois l'instance PDO est bien détruite lors du $pdoWrapper->terminate(), car la closure a enfermé une référence vers $pdoWrapper sans pour autant augmenter le compteur de références de l'objet PDO qui la compose.

Mais attention : pour qu'une telle solution donne satisfaction, il faut se garder soigneusement d'attraper une poignée explicite sur l'objet PDO encapsulé. En d'autres termes, ne jamais stocker :

$pdo = $pdoWrapper->getPDO();

En effet, tout le bénéfice de l'encapsulation disparaîtrait à moins d'aller plus loin en employant le pattern du Proxy, supprimant la méthode getPDO() et répliquant toutes les méthodes de la classe PDO. Malheureusement, l'implémentation d'un tel wrapper n'est pas toujours envisageable (notamment lorsqu'on utilise un ORM du marché du type Doctrine [5]). Et lorsque l'application est éclatée en de nombreux fichiers ou fait appel au pattern d'injection de dépendances, on n'a pas toujours une maîtrise suffisante des différentes ressources impliquées.

Conclusion

À titre personnel, je trouve dommage que la classe PDO n'expose pas de méthode close comme on pourrait la trouver dans d'autres modules tels que Memcache ou ZipArchive par exemple. Laisser reposer le cycle de vie des ressources sur celui du garbage collector ne me semble pas être un choix judicieux de la part des auteurs du langage. J'ai proposé d'écrire une RFC pour cette classe, mais elle n'a pas suscité l'intérêtdes principaux mainteneurs qui estiment [6] que le problème trouve sa solution dans le « userspace » comme évoqué plus haut.

Chaque point de vue se défend, mais il faut reconnaître que le leur (mise à part l'incohérence de philosophie avec d'autres extensions PHP) a le mérite de forcer le développeur à se poser les bonnes questions sur son utilisation des ressources (bref, faire du C).

Références

[1] Daemonizable commands for Symfony : https://github.com/mac-cain13/daemonizable-command

[2] appserver.io : http://appserver.io/

[3] Slim :  http://slimframework.com

[4] Constructeurs et destructeurs PHP : http://php.net/manual/en/language.oop5.decon.php

[5] Doctrine : http://www.doctrine-project.org/

[6] Discussion « PDO Close Connection » : http://news.php.net/php.internals/90840




Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous