W pierwszym artykule z cyklu PHP Daemons przedstawione zostało rozwiązanie oparte o mechanizm forkowania. Następnie, z uwagi na powstawanie procesów zombie, wprowadzone zostało rozwiązanie pozwalające wyeliminować ten problem. Jednak dalsze testy i eksploatacja tego daemona wykazały jego kolejną słabość - przy jednoczesnej obsłudze wielu połączeń, które kończą swoje działanie w tym samym czasie licznik aktywnych połączeń pomniejszany jest o jeden zamiast rzeczywistą liczbę połączeń, które zakończyły działanie w tym momencie. W efekcie, bardzo szybko może wystąpić przekroczenie max liczby połączeń (nie będziemy przyjmowali nowych połączeń), chociaż tak naprawdę będzie uruchomiony tylko jeden proces. Wspomniana sytuacja wynika z tego, że obsługa sygnałów w systemach Unix została zaprojektowana w taki sposób, aby w danym momencie czasu, z uwagi na potencjalne wyścigi, przyjąć tylko jeden sygnał danego typu, np. SIGUSR2. Dlatego też konieczne jest zastosowanie innego mechanizmu do kontroli liczby aktywnych połączeń.

IPC

Informacja o rozpoczęciu obsługi nowego połączenia bądź zakończeniu jego działania powinna zostać przekazana do procesu głównego (ang. parent) celem kontroli przekraczania max liczby połączeń. Zatem wymagana będzie komunikacja między procesami. W systemach Unixowych dostępne są następujące mechanizmy IPC:

  • sygnały (ang. signals)
  • potoki nienazwane (ang. pipes)
  • potoki nazwane / kolejki FIFO (ang. First In First Out)
  • pliki i blokady (ang. file locks)
  • kolejki wiadomości (ang. message queues)
  • semafory (ang. semaphores)
  • pamięć współdzieloną (ang. shared memory)
  • gniazdka (ang. sockets)

Shared memory

Wymienione powyżej mechanizmy obsłużone mogą być również za pomocą PHP. Przykładowo, pipes realizowane są za pomocą funkcji popen() / proc_open() / pclose(), kolejki FIFO - posix_mkfifo(), locks - flock() itd. Poszczególne rozwiązania różnią się między sobą i nie każde nadaje się do obsługi danych funkcjonalności. W naszym przypadku, do implementacji globalnego licznika aktywnych połączeń, najlepszym mechanizmem będzie pamięć współdzielona między procesami dzieci i procesem głównym.

public function __construct()
{
    $this->shm = shm_attach($this->getShmKey(), 512, 0666);
    $this->sem = sem_get($this->getSemKey(), 1, 0666, 1);
} 

Shared memory została wybrana, ponieważ wszystkie procesy będą działały w obrębie jednej lokalnej maszyny, dodatkowo zmiany licznika aktywnych połączeń powinny być bardzo szybkie stąd też oparcie tego mechanizmu na plikach czy gniazdkach nie będzie najlepszym wyborem. W pamięci współdzielonej przechowywana będzie liczba procesów utworzonych do obsługi poszczególnych klientów - w momencie forkowania (przez proces główny) licznik będzie podbijany, natomiast kończąc swoje działanie wartość ta będzie pomniejszana (przez proces dziecka).

protected function processConnection(DaemonConnection $conn)
{
    $pid = pcntl_fork();
    if (-1 == $pid) {
        trigger_error('Unable to fork parent process - ' . pcntl_strerror(pcntl_get_last_error()), E_USER_ERROR);
    } elseif ($pid) {
        $this->connections = $this->counter->increase();
       
        return true;
    }

    // kod procesu dziecka...
}

protected function closeConnection()
{
    if (null != $this->connection and $this->connection instanceof DaemonConnection) {
        $this->connection->close();
    }

    // zmniejszamy licznik aktywnych polaczen
    $this->counter->decrease();

    return true;
} 

Semafory

Szybkość pamięci współdzielonej wynika w głównej mierze z tego, oprócz faktu że realizowana jest w bezpośrednio w pamięci, iż do jej obsługi nie jest wymagana komunikacja z kernelem, jak to ma miejsce chociażby w przypadku potoków (ang. pipes). Jednak rozwiązanie to nie jest idealne - jak sugeruje sama nazwa mechanizmu, dostęp do współdzielonego obszaru pamięci będzie miało wiele procesów a więc tym samym czasie może próbować wprowadzać zmiany naszego licznika. Aby zapobiec tym wyścigom (ang. race conditions) konieczne będzie dodanie synchronizacji. Wśród dostępnych mechanizmów komunikacji między procesami dostępny jest taki mechanizm - semafory. Zatem schemat działania będzie następujący:

  1. utworzenie nowego procesu do obsługi połączenia
  2. założenie semafora - zablokowanie dostępu do wprowadzania zmian licznika
  3. zwiększenie licznika
  4. zwolnienie semafora - usunięcie blokady dostępu do wprowadzania zmian licznika
  5. obsługa połączenia
  6. założenie semafora, zmniejszenie licznika, zwolnienie semafora
  7. zakończenie działania procesu dziecka
public function get()
{
    $value = 0;

    sem_acquire($this->sem);

    $counter_key = $this->getCounterKey();
    if (is_resource($this->shm) and shm_has_var($this->shm, $counter_key)) {
        $value = (int) shm_get_var($this->shm, $counter_key);
    }
    
    sem_release($this->sem);

    return $value;
}

public function increase($value = 1)
{
    $increased_value = 0;

    sem_acquire($this->sem);

    $counter_key = $this->getCounterKey();
    if (is_resource($this->shm) and shm_has_var($this->shm, $counter_key)) {
        $value = (int) shm_get_var($this->shm, $counter_key);
    }
    
    $increased_value += $value;

    shm_put_var($this->shm, $counter_key, (int) $increased_value);
    sem_release($this->sem);

    return $value;
} 

Warto zwrócić uwagę jeszcze na jedną istotną kwestię. Otóż, współdzielonej pamięci nie obejmuje mechanizm reference counter wykorzystywany przez PHPowy GC. Oznacza to, iż kończąc działanie daemona nie zostanie zwolniona pamięć zarezerwowana dla segmentu shared memory. W związku z tym konieczne jest ręczne zwolnienie tej pamięci, w przeciwnym razie będzie ona zajęta do czasu restartu maszyny.

public function cleanUp()
{
    if (is_resource($this->shm)) {
        shm_remove($this->shm);
        $this->shm = null;
    }
} 

Wprowadzenie licznika opartego o współdzieloną pamięć nieco zwolni szybkość działania naszego daemona z uwagi na konieczność synchronizacji dostępu opartej o semafory. Niemniej, dzięki temu, nasz daemon będzie odporny na problemy opisane powyżej (nieprawidłowe zmniejszanie licznika w przypadku równoczesnego kończenia pracy przez wiele połączeń) a oparta na tym liczniku kontrola max liczby połączeń zabezpieczy nas przed zbytnim obciążeniem maszyny. Jeśli uzyskana w ten sposób wydajność daemona będzie za mała, będziemy mogli skalować się dokładając kolejne node’y do naszego clustra. Ciekawą alternatywą dla zaproponowanego tutaj licznika aktywnych połączeń opartego o shared memory może być APC. Jednak, przynajmniej według mnie, bezpośrednia kontrola nad tym co dokładnie dzieje się w aplikacji jest lepszym rozwiązaniem a koniec końców APC i tak działa w oparciu o shared memory bądź mmap (w zależności od konfiguracji). Inne godne rozważenia alternatywy to rozwiązania oparte o /dev/shm, php://memory czy też tmpfs. Zachęcam do eksperymentów, korzystania z proponowanego przeze mnie rozwiązania no i zgłaszania własnych uwag i wniosków.

Kompletny kod proponowanego rozwiązania znajdziecie tutaj.

Przydatne linki:

tagi:  ,  ,  ,  ,  ,  ,  ,  ,  ,  , 

Komentarze