Translate: 
EnglishFrenchGermanItalianPolishPortugueseRussianSpanish

Limitowanie prędkości przesyłu plików w PHP

MiniaturkaPracując w sektorze hostingowym często spotykam się z potrzebą kontroli użycia łącza ze światem. Jako że łącza nie są z gumy i mają swoje fizyczne/ekonomiczne ograniczenia, w przeszłości niejednokrotnie musiałem przycinać maksymalne pasmo użytkownikom przy pomocy gotowych modułów do Apacha, lub niskopoziomowych mechanizmów wbudowanych w system operacyjny.

Co jednak jeśli użytkownik sam sobie chce zmniejszyć lub zwiększyć szerokość pasma do celów np działu download na swojej stronie?

Na shared hostingu nie jest w stanie tego osiągnąć, gdyż nie istnieją takie serwery, gdzie można sobie taki parametr regulować. Z tego też powodu postanowiłem napisać kod PHP symulujący tą funkcjonalność

Jak to działa?

Napisana przeze mnie klasa PHP potrafi:

  • Utrzymywać stały limit prędkości przesyłu pliku danych (np 40KB/sek)
  • Utrzymywać tryb burst ograniczony czasowo (np 120KB/sek przez pierwsze 20 sekund i 60KB/sek później)
  • Utrzymywać tryb burst ograniczony ilością przesłanych danych (np 180KB/sek przez pierwsze 2MB ściąniętych danych i 60KB/sek przez resztę pliku)
  • Integrować się transparentnie z gotowymi rozwiązaniami PHP (CMS’ami, forami, księgami gości, itp)

Jak widać klasa jest dosyć elastyczna, przydaje się tak w działach download różnych serwisów, jak i przy szybkim pobieraniu obrazków (małe pliki) lub wolniejszym dużych archiwów ZIP.

Skąd pobrać?

nominacja 2 miejsce

Gotowy skrypt można pobrać z serwisu softpedia (bez logowania), lub phpclasses (wymaga zalogowania) lub bezpośrednio z repozytorium mojego bloga (nie wymaga logowania).

Teoria

Limitowanie pasma w sieci (z ang. bandwidth throttling) może być dosyć rozbudowanym procesem, zależnym od tego czy limity dotyczą:

  • pojedynczego IP lub całej ich klasy (trudne do „obejścia” przez użytkownika strony)
  • pojedynczego żądania przeglądarki (dobre gdy wielu użytkowników współdzieli to samo IP)
  • rodzaju użytego protokołu (HTTP/FTP/ICMP)
  • różnych priorytetów dla pakietów

W przypadku mojego rozwiązania postanowiłem skupić się jedynie na podstawowej funkcjonalności, czyli ograniczaniu pasma per pojedyncze żądanie HTTP/HTTPs. Dzięki temu, że kod napisałem w PHP, każdy webdeveloper bazując na tym rozwiązaniu może łatwo dodać do niego pozostałe limity.

Praktyka

Najprostszym sposobem limitowania pasma jest celowe spowalnianie skryptu PHP tak, by w danej jednostce czasu nie przekazał więcej danych niż chce tego administrator strony. W tym celu pomocne okazuje się użycie wbudowanego w PHP polecenia usleep(). Umożliwia to zatrzymanie skryptu na żądaną ilość mikrosekund, bez „zajeżdżania” procesorów serwera pustymi pętlami for()/do..while() (takie rozwiązania niestety widywałem w praktyce).

Mając już funkcję spowalniającą pozostaje pytanie, jak dawkować ilość informacji przesyłanych na sekundę?

Oczywiście można pójść po najmniejszej linii oporu i używać jakichś pętli wysyłających kawałki danych pobranych funkcją substr(), to jednak mechanizm, który nie byłby ani elegancki, ani elastyczny dla gotowych rozwiązań (nie chcemy przecież przepisywać gotowych CMSów).

W tym przypadku najlepszym sposobem na dozowanie wysyłanych danych jest użycie buforów PHP (funkcja ob_start() z callbackiem do własnej funkcji obsługującej opróżnianie bufora):

        // set the output callback
        ob_start(array($this, 'onFlush'), $this->config->rateLimit);

Zmienna $this->config->rateLimit ustawiona jako drugi parametr w funkcji ob_start() umożliwia nam proste i automatyczne przerywanie wykonania programu PHP po wypełnieniu bufora ilością danych, jakie chcemy przesłać w ciągu sekundy. W trakcie tego przerwania wykonywany jest nasza funkcja onFlush() regulująca prędkość przesyłu.

Dzięki temu zabiegowi mechanizm limitujący działa bezkolizyjnie w tle głównej aplikacji (np gotowego CMS’a), przy czym taka aplikacja może nawet „nie wiedzieć”, że jest właśnie ograniczana. Symulujemy w ten sposób swego rodzaju „wielowątkowość” w PHP.

Mając już wszystkie rzeczy potrzebne do wykonania mechanizmu, pozostaje nam zbudować ciało metody onFlush().

Konfiguracja mechanizmu

Na początek parę założeń: jako że nie chcę niepotrzebnie zaśmiecać przestrzeni dostępnej dla głównej aplikacji, mechanizm limitujący zamykam w jednej klasie Throttle.

W celu śledzenia aktywności głównej aplikacji, klasa ta musi przechowywać informacje o czasie startu transferu, oraz czasie przesłania ostatniego pakietu danych. Te informacje zapisuję do właściwości $firstHeartBeat oraz $lastHeartBeat:

    /**
     * Last heartbeat time in microseconds.
     * 
     * @var int
     */
    protected $lastHeartBeat = 0;
 
    /**
     * First (starting) heartbeat time in microseconds.
     * 
     * @var int
     */
    protected $firstHeartBeat = 0;

Dodatkowo do celów czysto statystycznych wypada zaimplementować całkowity czas wysyłania danych, oraz ilość danych wysłanych:

    /**
     * Number of bytes already sent.
     * 
     * @var int
     */
    protected $bytesSent = 0;
 
    /**
     * Total sending time in microseconds.
     * 
     * @var int
     */
    protected $sendingTime = 0;

To wszystko? Nie, została jeszcze jedna rzecz do dodania. Większość rozwiązań sieciowych po roku 2000 obsługuje tzw. burst mode. Spotykane jest to często w aplikacjach multimedialnych, które pobierają dane z Internetu i polega to na tym, że aplikacja (lub serwer) w początkowych kilku sekundach przesyła dane ze znacznie większą prędkością, niż po upływie takiego czasu.

Taka funkcjonalność umożliwia szybsze rozpoczęcie odtwarzania muzyki/filmu po stronie użytkownika, a jednocześnie oszczędza łącze na serwerze po wyłączeniu trybu burst. Warto więc, by klasa Throttle obsługiwała ten mechanizm. W tym celu dodajemy dodatkowe właściwości do klasy:

    /**
     * Current transfer rate in bytes per second.
     * 
     * @var int
     */
    protected $currentLimit = 0;
 
    /**
     * Is this the last packet to send?
     * 
     * @var bool
     */
    protected $isFinishing = false;
 
    /**
     * @var ThrottleConfig
     */
    protected $config;

Tu przyda się dwa wyjaśnienia: właściwość $config potrzebna jest nam do przechowywania danych o wielkości limitu w trybie burst oraz w trybie normal, a także czasie po jakim mają się te tryby przełączać. Dane te będziemy przechowywać w klasie ThrottleConfig.

Właściwość $isFinishing przyda nam się natomiast do weryfikacji, czy mamy do czynienia z ostatnim pakietem do wysłania, jeśli tak, to nie będziemy go w żaden sposób ograniczać.

Inicjalizacja mechanizmu

Najwyższy czas przejść do konstruktora klasy Throttle:

    /**
     * Create new instance of throttler
     * 
     * @param IThrottleConfig $config Configuration object or null to use system defaults
     * @return Throttle
     */
    public function __construct(IThrottleConfig $config = null) {
        if(function_exists('apache_setenv')) {
            // disable gzip HTTP compression so it would not alter the transfer rate
            apache_setenv('no-gzip', '1');
        }
        // disable the script timeout if supported by the server
        if(false === strpos(ini_get('disable_functions'), 'set_time_limit')) {
            // suppress the warnings (in case of the safe_mode)
            @set_time_limit(0);
        }
        if($config) {
            $this->config = $config;
        } else {
            $this->config = new ThrottleConfig();
        }
 
        // set the burst rate by default as the current transfer rate
        $this->currentLimit = $this->config->burstLimit;
 
        // set the output callback
        ob_start(array($this, 'onFlush'), $this->config->rateLimit);
    }

W pierwszych linijkach konstruktora znajduje się wywołanie funkcji apache_setenv(). Wywoływana jest ona tylko w przypadku gdy PHP działa jako moduł serwera Apache i z podanymi w kodzie argumentami wyłącza transparentną kompresję GZIP dla danych wysyłanych do przeglądarki. Taka operacja ułatwia nam kontrolowanie szerokości pasma oraz zwiększa szanse, że Apache nie zacznie buforować danych po swojej stronie niwelując tym samym działanie klasy Throttle.

W następnych liniach kodu sprawdzam czy na serwerze dostępna jest funkcja set_time_limit(), gdyż domyślne ustawienia PHP sprawiają, że każdy skrypt przerywa swoje wykonywanie po upływie 30 sekund. W przypadku większych plików lub bardzo małych limitów 30 sekund może okazać się w naszym przypadku wąskim gardłem, powodując że przesył pliku zostanie przedwcześnie zerwany. Ustawienie tego limitu na wartość 0 powoduje, że skrypt może wykonywać się nieskończenie długo (w założeniu do przesłania pliku).

Dalsza część kodu konstruktora inicjuje startowe parametry pasma, oraz włącza buforowanie wyjścia z callbackiem do metody onFlush().

Metoda ograniczająca pasmo

Metoda ta nosi nazwę onFlush(), a jej kod jest niestety znacznie większy od konstruktora przedstawionego powyżej:

    /**
     * Throttling mechanism
     * 
     * @param string $buffer Input buffer
     * @return string Output buffer (the same as input)
     */
    public function onFlush(& $buffer) {
        // do nothing when buffer is empty (in case of implicit ob_flush() or script halt)
        if($buffer === "") {
            return "";
        }
 
        // cache the buffer length for futher use
        $bufferLength = strlen($buffer);
 
        // cache current microtime to save us from executing too many system request
        $now = microtime(true);
 
        // initialize last heartbeat time if this is a first iteration of the callback
        if($this->lastHeartBeat === 0) {
            $this->lastHeartBeat = $this->firstHeartBeat = $now;
        }
 
        // check if this is a last portion of the output, if it isn't - do the throttle        
        if(!$this->isFinishing) {
            // calculate how much data have we have to send to the user, so we can set the appropriate time delay
            // if the buffer is smaller than the current limit (per second) send it proportionally faster than the full
            // data package
            $usleep = $bufferLength / $this->currentLimit;
            if($usleep > 0) {
                usleep($usleep * 1000000);
                $this->sendingTime += $usleep;
            }
        } else {
            // this is the last portion of the output, so send everything without throttling
            $this->sendingTime += $timeDiff;
        } 
 
        // check if the burst rate is active, and if we should switch it off (in both if cases)
        if(isset($this->config->burstSize)) {
            if($this->config->burstSize < $this->bytesSent + $bufferLength && $this->config->burstLimit === $this->currentLimit) {
                $this->currentLimit = $this->config->rateLimit;
            }            
        } else {
            if($now > ($this->firstHeartBeat + $this->config->burstTimeout) && $this->config->burstLimit === $this->currentLimit) {
                $this->currentLimit = $this->config->rateLimit;
            }
        }
 
        // update system statistics        
        $this->bytesSent += $bufferLength;
        $this->lastHeartBeat = $now;
 
        return $buffer;
    }

Czas więc na objaśnienia, na początek deklaracja metody:

public function onFlush(& $buffer) {

Jak widać pobieram wartość bufora poprzez referencję (stąd znak &). O ile zabieg taki jest wolniejszy niż pobieranie przez wartość, to jednak pozwala nam zaoszczędzić pamięć przydzieloną procesowi PHP (na hostingach limity pamięci są dosyć małe).

Poniższy kod sprawdza, czy otrzymany bufor jest pusty – jesli tak, to nie robi już nic więcej. O ile w konstruktorze nakazaliśmy w funkcji ob_start() wywoływanie metody onFlush() dopiero po wypełnieniu bufora danymi, o tyle metoda ta może być wykonywana także wtedy, gdy aplikacja wykona celowe ob_flush(). W takim przypadku istnieje możliwość, że do użytkownika nie zostały wysłane żadne dane.

        if($buffer === "") {
            return "";
        }

Kolejne linie kodu inicjują i cache’ują dane potrzebne do obliczeń szerokości dostępnego pasma. Musimy to robić ze względu na wydajność. Każde odwołanie do poleceń systemowych (np pobierających bieżący czas) lub funkcji PHP to dodatkowe spowolnienie dla kodu:

        // cache the buffer length for futher use
        $bufferLength = strlen($buffer);
 
        // cache current microtime to save us from executing too many system request
        $now = microtime(true);
 
        // initialize last heartbeat time if this is a first iteration of the callback
        if($this->lastHeartBeat === 0) {
            $this->lastHeartBeat = $this->firstHeartBeat = $now;
        }

Mając już wszystkie parametry niezbędne do obsłużenia transferu zaczynamy obliczenia, o ile mikrosekund musimy spowolnić skrypt, by w ciągu sekundy wysłał tylko wskazaną ilość danych.

Na początek sprawdzamy, czy możemy sobie w tym przypadku darować dalsze obliczenia (robimy to tylko wtedy, gdy jesteśmy pewni, że to ostatni pakiet danych przed zakończeniem skryptu):

        // check if this is a last portion of the output, if it isn't - do the throttle        
        if(!$this->isFinishing) {

Następnie w razie konieczności obliczamy czas uśpienia skryptu, robiąc to na podstawie zwykłych proporcji:

ilość otrzymanych danych
------------------------- = opóźnienie (% jednej sekundy)
ilość danych na sekundę

W PHP obrazuje to poniższy kod przerywający wykonywanie programu na czas wysyłania danych:

            // calculate how much data have we have to send to the user, so we can set the appropriate time delay
            // if the buffer is smaller than the current limit (per second) send it proportionally faster than the full
            // data package
            $usleep = $bufferLength / $this->currentLimit;
            if($usleep > 0) {
                usleep($usleep * 1000000);
                $this->sendingTime += $usleep;
            }

Pozostała nam już tylko jedna rzecz, obsługa przełączania między trybami burst oraz normal. Za to odpowiedzialne są końcowe linijki metody onFlush():

        // check if the burst rate is active, and if we should switch it off (in both if cases)
        if(isset($this->config->burstSize)) {
            if($this->config->burstSize < $this->bytesSent + $bufferLength && $this->config->burstLimit === $this->currentLimit) {
                $this->currentLimit = $this->config->rateLimit;
            }            
        } else {
            if($now > ($this->firstHeartBeat + $this->config->burstTimeout) && $this->config->burstLimit === $this->currentLimit) {
                $this->currentLimit = $this->config->rateLimit;
            }
        }

I tutaj mała niespodzianka, w if’ie pojawiają się dwie możliwości! To dodatkowy bonus o którym wspomniałem wcześniej: klasa Throttle obsługuje dwa tryby ograniczania pasma:

  • Tryb burst —> (czas X sekund) —> tryb normalny
  • Tryb burst —> (wysłano X bajtów) —> tryb normalny

Pierwszy rodzaj ograniczenia jest standardowy, drugi zaś umożliwia łatwe przycinanie pasma dla większych plików (obrazy ISO, archiwa ZIP), pozostawiając jednak szybkie transfery dla małych (np obrazków).

W pierwszym trybie ograniczeniem jest więc czas, w drugim zaś wielkość przesłanych już danych.

Uruchomienie limitu pasma

Aby włączyć limit, należy przede wszystkim zaincludować klasę Throttle w swoim skrypcie (najlepiej na samym jego początku):

require("./throttler.php");

Oraz stworzyć dwa obiekty:

Pierwszy zawierający konfigurację dla klasy Throttle będący instancją klasy ThrottleConfig:

/**
 * Configuration class.
 */
class ThrottleConfig implements IThrottleConfig
{
    /**
     * Burst rate limit in bytes per second.
     * 
     * @var int
     */
    public $burstLimit = 80000;
 
    /**
     * Burst transfer rate time in seconds before reverting to the standard transfer rate.
     * 
     * @var int
     */        
    public $burstTimeout = 20;
 
    /**
     * Standard rate limit in bytes per second.
     * 
     * @var int
     */        
    public $rateLimit = 10000;
 
    /**
     * Enable/disable this module.
     * 
     * @var bool
     */
    public $enabled = false;
}

Na przykład w ten sposób:

// create new config
$config = new ThrottleConfig();
// enable burst rate for 30 seconds
$config->burstTimeout = 30;
// set burst transfer rate to 50000 bytes/second
$config->burstLimit = 50000;
// set standard transfer rate to 15000 bytes/second (after initial 30 seconds of burst rate)
$config->rateLimit = 15000;
// enable module (this is a default value)
$config->enabled = true;

A także drugi obiekt klasy Throttle, przyjmujący w konstruktorze konfigurację pasma:

// start throttling
$x = new Throttle($config);

Od tego momentu wszystko co spróbujemy wysłać użytkownikowi (czy to funkcją echo, czy też zwykłym HTMLem) będzie podlegało ograniczeniom pasma, co możemy łatwo sprawdzić tworząc np taki output:

header("Content-type: application/force-download");
header("Content-Disposition: attachment; filename=\"test.txt\"");
header("Content-Length: 60000000");
 
// generate 60.000.000 bytes file.  
for($i = 0; $i < 60000000; $i++) {
    echo "A";
}

Wady i zalety mechanizmu

Głównymi plusami tego rozwiązania jest:

  • małe zużycie zasobów serwera w porównaniu do innych bibliotek tego typu
  • brak konfliktów z gotowym oprogramowaniem (np systemami CMS)
  • działa z PHP 5.2+ oraz PHP 5.3+
  • prosta konfiguracja

Nie ma jednak rozwiązań idealnych, także i ten mechanizm posiada swoje wady. Oto niektóre z nich:

  • większe zużycie zasobów serwera niż w przypadku rozwiązań systemowych (np firewalli, modułów serwera HTTP)
  • w większości wypadków wymaga wyłączonego trybu SAFE_MODE w PHP (w celu wywołania funkcji set_time_limit())
  • wymaga ustawienia wyższych limitów na czas wykonywania skryptu (patrz powyżej), jeśli pracuje w trybie SAFE_MODE, limit ten musi ustawiać administrator serwera
  • może gryźć się z niektórymi konfiguracjami serwerów HTTP/FastCGI (np z opcją SendBufferSize w pliku httpd.conf serwera Apache)
  • w przypadku dużego ruchu na witrynie może wyczerpać limit równoległych procesów PHP przyznanych kontu WWW (ustawiony np poprzez stałą FCGI_MAX_CHILDREN w trybie FastCGI)

Tagi:

Dodaj odpowiedź