Performance van code verbeteren met static variables

Door Bèr (28 juli 2010)
Een goedgeschreven module roept allerlei subroutines (functies) aan om berekeningen uit te voeren, informatie uit de database te halen enzovoort. Wanneer één zo'n subroutine diverse malen per pagina aangeroepen wordt, is er een heel makkelijke truc om de performance van je module flink op te schroeven. Deze manier wordt vaak aangeduid als static var caching. Het principe is eenvoudig: wanneer een functie aangeroepen wordt met dezelfde parameter, heeft het meestal geen zin om alle berekeningen, database-opzoekingen, lijstbewerkingen enzovoort opnieuw te gaan uitvoeren: het resultaat was al eerder berekend of opgehaald en zou zonder veel moeite uit een oude opberglade gehaald kunnen worden. Zo'n opberglade heet een static var(iable). Omdat Drupal niet object geörienteerd is, werkt dit alleen met variabelen en hebben static functions of classes geen zin. Een variabele static zetten, betekent dat binnen één thread de inhoud van een variabele bewaard blijft. Ze wordt dus nog altijd gewoon 'weggegooid' bij het einde van de pagina-load (of van een commando enzovoort). En is dus niet bewaard in een database of elders en dus niet beschikbaar bij een volgende page-load. Het gaat enkel en alleen om het bewaren van de inhoud binnen de huidige thread. De uitgewerkte voorbeeeldcode ziet er als volgt uit. Daaronder leggen we per regel uit wat er gebeurt.
<?php
/** Loads the pirates for a pirate_id.
 *
 * @param $pirate_id
 *  integer: unique id of the price.
 *
 * @param $force_reload
 *  bool: flag to enforce a reload from the database. E.g. after you have updated that database, you want the new object.
 *
 * @return
 *   A pirate in the form of a keyed array. aka "pirate object"
 */
function pirates_db_read_pirate($pirate_id$force_reload FALSE) {
  static 
$pirates;
  
  if (
$force_reload || !array_key_exists($pirate_id$pirates)) { 
    
$pirate db_fetch_array(db_query("
      SELECT * FROM {pirates} w where pirate_id = %d LIMIT 1"
,
      
$pirate_id));
    
$pirates[$pirate_id] = $pirate;
  }
  
  return 
$pirates[$pirate_id];
}
?>
Allereerst documenteren we onze functie keurig. Dat doet uiteraard idereen altijd meteen :) Daarna declareren we de functie, met een extra parameter $force_reload. Deze is optioneel en maakt het mogelijk voor de aanroeper om te forceren dat hij een verse , opnieuw berekende of opgehaalde waarde krijgt. Om de static cache te omzeilen dus. Je hoeft deze niet altijd op te nemen, maar vaak onstaan kleine bugs door 'stale values', values die je dacht veranderd te hebben. Bijvoorbeeld een update die je zojuist in de pirates-table deed. <?phpfunction pirates_db_read_pirate($pirate_id$force_reload FALSE) {?> Dan declareren we de variabele en melden aan PHP dat we de inhoud bewaard willen hebben. Het essentiële onderdeel van het staic var cachen. <?php  static $pirates;?> Vervolgens bekijken we, of we a) de cache willen omzeilen, of dat we b) de waarde nog niet hebben. We gaan dus alleen maar spullen uit de database halen als we dat niet al eerder gedaan hebben en we niet persé nieuwe waarden willen. <?php  if ($force_reload || !array_key_exists($pirate_id$pirates)) { ?> Dan halen we een en ander uit de database. Hier zou je ook je ingewikkelde berekeningen, zware iteraties enzovoort kunnen hebben. Om het voorbeeld simpel te houden, halen we één rij uit de database.
<?php    $pirate db_fetch_array(db_query("
      SELECT * FROM {pirates} w where pirate_id = %d LIMIT 1"
,
      
$pirate_id));
?>
En het resultaat bergen we op in de static var. <?php    $pirates[$pirate_id] = $pirate;?> Dan geven we het resultaat terug aan de aanroper. Merk op dat deze voorbeeldcode heel simpel is, en daarmee niet echt keurig. In werkelijkheid dien je iets netter om te gaan met situaties wanneer er bijvoorbeeld géén resultaat bestaat, of waneer een fout optrad. In praktijk gebeurt dit in de PHP-wereld (en dus bij Drupal) echter zelden zo netjes, en programmeert men in het algemeen vrij lui. PHP is immers zeer vergevingsgezind. <?php  return $pirates[$pirate_id];?> Enkele opmerkingen: Wanneer een functie meerdere parameters bevat, neemt de kans dat een functieaanroep met precies dezelfde parameters twee keer of meer plaatsheeft, af. Een voorbeeld: <?phpfunction pirates_db_read_pirate($has_eye_pathc$has_parrot$has_wooden_leg$include_ships_name$force_reload FALSE) {?> Iedere mogelijke combinatie kán worden aangeroepen. Je moet dus het resultaat voor iedere mogelijke combinatie in je static cache gaan bewaren. De kans op cache-hits (keren dat je dezelfde waarde uit je cache kunt aanleveren) neemt enorm af. Bedenk of het in dat geval nog zin heeft. Meestal is een functie met veel parameters echter ook een signaal dat je de code iets beter kunt (en moet?) indelen. Maar dat is een heel ander onderwerp. Wanneer je telkens onderdelen uit een zelfde kleine lijst ophaalt, heeft het ook zin om éénmaal de hele lijst in te lezen en in een static var te stoppen. Een voorbeeld:
<?php
    $res 
db_query("SELECT * FROM {pirates}");
    while (
$pirate db_fetch_array($res)) {
      
$pirates[$pirate['id']] = $pirate;
    }
?>
De logica van het testen of een waarde al bestaat moet uiteraard ook iets veranderen, maar duidelijk is dat we hier gewoon even alle piraten in een static cache -het geheugen- opslaan. De kans dat we meerdere keren de database moeten raadplegen is dan nóg kleiner: we halen gewoon eerst de lijst piraten op en serveren daarna de piraten op, uit ons geheugen. Uiteraard moet je dit alleen doen met vaststaande, kleine hoeveelheden, anders loopt je geheugen al heel snel erg vol. Denk ook aan de nadelen, voordat je dit zonder meer op alle routines gaat uitvoeren. Static variables blijven in het geheugen hangen. Dat betekent dus dat voor static variables die grote hoeveelheden informatie bevatten, veel geheugen beschikbaar moet zijn. Als je (vrijwel) zeker weet dat je deze functie maar één keer gaat aanroepen per thread, weegt dit geheugenverlies niet op tegen de mogelijke toekomstige performance-winst. Deze performancewinst treed enkel op, wanneer een functie meer dan eens aangeroepen wordt. Ook weegt vaak de extra code, die extra complexiteit introduceert, niet op tegen de mogelijke toekomstige performance-winst. Het hele onderwerp van caching in de database komt hier niet aan de orde. Daarmee kun je een resultaat ook voor langere tijd dan enkel de huidige thread, opslaan. Maar dat is een heel ander hoofstuk. Al lijkt de techniek daarvan op static var caching, de inzetbaarheid, voor en nadelen zijn heel anders dan bij een static var cache. Negen van de tien zwaardere functies kun je met een simpele ingreep enorm veel sneller maken. Ik ben meerdere malen tegengekomen dat een enorm zware functie tientallen keren per pagina werd uitgevoerd en daarmee meteen de hele Drupal site vertraagde. Het meest recente voobeeld was een snelheidswinst van tussen de 700 en 2000 keer. De site werd door één simpele static var caching dus soms 2000 (tweeduizend!) keer zo snel. Van minuten laadtijd terug naar miliseconden. Gebruik dit trucje waar mogelijk. En maak je bezoekers blij met een snellere site, je klant blij met een minder dure energierekening (en CO2 uitstoot) en maak jezelf blij met elegantere en slimmere modules. EDIT: markup ging fout. Aangepast.
Drupalversie:  Drupal 5 Drupal 6

Reacties

In Drupal 7 kan je best de functie drupal_static() gebruiken om deze pattern uit te voeren.
Zie http://api.drupal.org/api/function/drupal_static/7

Die kun je vaak gebruiken, maar is, zoals de documentatie aangeeft, geen één op één vervanging. Er zullen nog heel veel siutaties blijven waarin een simpele static var in je functie beter werkt, performt enzo dan via de centrale storage.

Reactie toevoegen