Implementacja serwera usługi Pigen na bazie protokołu TCP
Spis treści
Witaj, świecie!
Z okazji niedawno minionego dnia liczby pi, chciałbym przedstawić mój projekt serwera (nieco zmodyfikowanej) usługi Pigen opisanej w dokumencie IETF RFC 3091 oraz wyjaśnić stojący za nim algorytm do obliczania kolejnych cyfr tej liczby.
Kod źródłowy znajdziesz tutaj.
Specyfikacja protokołu i moje zmiany
Specyfikacja Pigen w oparciu o protokół transportowy TCP ma bardzo proste wymagania:
- Serwer nasłuchuje na porcie numer 314159.
- Po nawiązaniu połączenia z klientem, serwer wysyła strumieniowo kolejne cyfry liczby π w systemie dziesiętnym i kodowaniu ASCII, począwszy od pierwszej cyfry po przecinku, aż do momentu rozłączenia się przez klienta.
Wprowadziłem dwie drobne zmiany:
- Serwer nasłuchuje na porcie numer 31415, ponieważ największy możliwy numer portu TCP wynosi 65535 (wybór portu 314159 był najpewniej niedopatrzeniem ze strony autora oryginalnego dokumentu).
- [...] serwer wysyła kolejne cyfry liczby π (w celu zmniejszenia obciążenia sieci) [...], począwszy od cyfry jedności [...], gdyż ułatwia to implementację oraz wytłumaczenie działania zastosowanego przeze mnie algorytmu.
Specyfikacja nie narzuca konkretnej metody generowania cyfr. Choć mógłbym wykorzystać plik z milionem cyfr i wysyłać jego zawartość, takie rozwiązanie byłoby zbyt proste i mało interesujące. Co więcej, nie spełnia ono potrzeb klientów żądnych miliona jeden, miliona dwóch, czy miliona trzech pierwszych cyfr (o milionie czterech nie wspominając).
Algorytm na obliczanie kolejnych cyfr liczby pi
Zdecydowanie ciekawsze i bardziej elastyczne podejście stanowi zaprogramowanie algorytmu, który potrafi obliczyć π z dowolnie dużą precyzją, gdzie jedynymi ograniczeniami są dostępna moc obliczeniowa i pamięć.
Z pomocą przychodzi wzór podany po raz pierwszy w XVII wieku przez Williama Brouncker'a, który przedstawia ćwierć π jako ułamek łańcuchowy:
$$\dfrac \pi 4 = \dfrac 1 {1 + \cfrac {1^2} {2 + \cfrac {3^2} {2 + \cfrac {5^2} {2 + \cfrac {7^2} {2 + \cdots } } } } }$$
Jak zauważył L. J. Lange w artykule An Elegant Continued Fraction for π, przy dowodzeniu prawdziwości powyższego wzoru można wykorzystać alternatywną formę ułamka łańcuchowego dla głównej krzywej rozgałęzienia funkcji $\arctan(z)$ w płaszczyźnie liczb zespolonych, gdzie $z$ nie znajduje się na osi urojonej ani od $-i$ poniżej, ani od $i$ wzwyż:
$$\arctan(z) = \frac {z} {1+{\cfrac {(1z)^{2} } {3+{\cfrac {(2z)^{2} } { 5+{\cfrac {(3z)^{2} } {7+{\cfrac {(4z)^{2} } {9+\cdots } } } } } } } } }$$
Podstawiając 1 za $z$ oraz mnożąc obie strony równości razy 4, otrzymamy:
$$\pi = \frac {4} {1+{\cfrac {1^2} {3+{\cfrac {2^2} { 5+{\cfrac {3^2} {7+{\cfrac {4^2} {9+\cdots } } } } } } } } }$$
W tej formie, każdy kolejny zagnieżdżony ułamek wynosi $\frac { k^2 } { 2k + 1 + \cdots } $ dla $k \in \natnums$, gdzie $\cdots$ oznacza sumę pozostałych ułamków tej postaci od $k + 1$ do $\infty$.
Tym sposobem dochodzimy wreszcie do algorytmu opracowanego przez autorów podręcznika języka programowania ABC (rozdział Examples of ABC, podrozdział Numbers), który polega na obliczaniu dwóch kolejnych przybliżeń, począwszy od $\frac 4 1$ oraz $\frac 4 {1 + \frac 1 3 }$ (czyli $\frac {12} 4$) i sprawdzeniu równości ich kolejnych cyfr w rozwinięciu dziesiętnym.
Jeśli na danej pozycji znajduje się ta sama cyfra, wysyłamy ją do klienta i sprawdzamy równość cyfr na kolejnej pozycji. W przeciwnym razie, obliczamy kolejny ułamek w łańcuchu (w tej iteracji $\frac 4 {1 + \frac 1 { 3 + \frac 4 5 } }$), po czym wykonujemy sprawdzenie zgodności wiodących cyfr z $\frac 4 {1 + \frac 1 3 }$, dążąc tym samym do coraz większej dokładności z każdym następnym $k$.
Zewnętrzne biblioteki
Zanim przejdziemy do części praktycznej tego artykułu, musimy jeszcze poruszyć dwie kwestie związane z projektem serwera Pigen.
threadpool
- mechanizm puli wątków
Pierwszą z nich jest obsługa wielu klientów jednocześnie. Ponieważ mamy do czynienia z usługą, która kładzie większy nacisk na moc obliczeniową serwera niż przepustowość sieci, zdecydowałem się na wykorzystanie tradycyjnych wątków systemowych zamiast modelu async/await
.
Oczywiście, tworzenie nowego wątku za każdym razem gdy zostanie nawiązane połączenie to prosty przepis na nieszczęście. Wystarczy, że ktoś uruchomi skrypt, który w nieskończonej pętli będzie otwierał nowe połączenia, żeby nie tylko nasz program, ale też całe urządzenie padło na kolana.
Bardziej rozsądnym podejściem jest tworzenie ograniczonej liczby wątków do obsługi połączeń. W przypadku gdy zostanie ona osiągnięta, a nowy klient będzie chciał nawiązać z nami połączenie, wystarczy go poinformować o pełnym obciążeniu i zakolejkować jego żądanie. Kiedy inny klient się rozłączy, będziemy mogli wówczas obsłużyć klienta oczekującego w kolejce.
Choć mógłbym się pokusić o napisanie własnego mechanizmu puli wątków, postanowiłem sięgnąć po bibliotekę threadpool
, by skupić się na wcześniej opisanym algorytmie. Skoro znów o nim mowa, czas przejść do drugiego problemu.
rug
- duże liczby całkowite
Podstawowe typy liczb całkowitych w języku Rust mają pojemność ograniczoną do konkretnej liczby bajtów. Dla zdecydowanej większości programów dany zakres jest wystarczająco duży by przechowywać dane i wykonywać na nich obliczenia bez obaw o przekroczenie maksymalnej dopuszczalnej wartości.
Niestety, nawet 64-bitowa liczba całkowita bez znaku (która potrafi zmieścić ponad 18 trylionów), okazuje się dla użytego przeze mnie algorytmu zbyt mała do obliczenia... dziesiątej cyfry po przecinku. Nic jednak dziwnego, gdyż ten algorytm został napisany z myślą o języku ABC, którego typ liczby całkowitej ma (teoretycznie) nieograniczoną pojemność.
Mało tego, w niektórych językach głównego nurtu takich jak Python czy Ruby, liczby całkowite również są (lub mogą być w razie potrzeby) przechowywane w formie potrafiącej pomieścić dowolnie dużą wartość. Z kolei w językach pokroju Javy albo C#'a, dostępny jest oddzielny typ do reprezentowania nieskończenie wielkich liczb całkowitych.
O ile Rust nie posiada wbudowanego w język rozwiązania w stylu ostatnich dwóch ze wcześniej wspomnianych języków, tak na ratunek przychodzi społeczność z biblioteką rug
, która dostarcza wysokopoziomowy interfejs programistyczny dla GNU Multiple Precision Arithmetic Library.
Skrzynka rug
jest udostępniana na licencji GNU Lesser General Public License v3, co powinno ułatwić jej integrację z komercyjnymi projektami. Jeśli mimo to jej licencja nadal jest nieodpowiednia, polecam zapoznać się z zamiennikiem w postaci biblioteki num-bigint
.
Analiza kodu
Po tym obszernym wstępie możemy w końcu przejść do analizy kodu serwera.
Inicjalizacja serwera i puli wątków
Zacznijmy od zaimportowania potrzebnych nam cech/interfejsów i struktur oraz utworzenia ich instancji:
use ;
use Error;
use Write;
use TcpListener;
use ThreadPool;
Wykorzystałem alternatywną sygnaturę funkcji main
, aby móc posługiwać się operatorem ?
do wyłuskiwania pomyślnych wyników zamiast potencjalnie panikujących wywołań unwrap()
. W razie wystąpienia wyjątku, ten zostanie wyświetlony w postaci Debug
, a proces zakończy się z kodem błędu. Natomiast podstawienie Box<dyn Error>
jako spodziewany typ błędu umożliwia zwracanie obiektu dowolnego typu, pod warunkiem że implementuje cechę Error
.
Co do liczby wątków w puli, 5 nie jest w żaden sposób wiążącą czy najbardziej optymalną liczbą. Można dowolnie eksperymentować z jej zwiększaniem lub zmniejszaniem w zależności od wymaganej liczby klientów do równoległego obsługiwania oraz mocy obliczeniowej serwera.
Nasłuchiwanie połączeń i uruchamianie/kolejkowanie wątków
Mamy zadeklarowany nasłuchiwacz, więc pora na obsługę przychodzących połączeń. Jeśli dysponujemy wolnym wątkiem, możemy natychmiastowo rozpocząć generowanie cyfr π dla klienta. W przeciwnym razie musimy go poinformować, że serwer osiągnął limit obsługiwanych jednocześnie połączeń i zakolejkować żądanie.
W tym celu należy dodać poniższy kawałek kodu między deklaracją zmiennej listener
, a zwróceniem wartości Ok(())
:
for mut stream in listener.incoming.flatten
Metoda incoming()
zwraca iterator, który w nieskończonej pętli akceptuje przychodzące połączenia, a każdy element jest typu io::Result
, którego pomyślnym wynikiem jest obiekt reprezentujący strumień TCP. Z kolei metoda flatten()
pozwala nam wyłuskać wspomniany strumień z elementów iteratora, które są pomyślnymi wynikami, a oprócz tego pominąć wszystkie wyniki wariantu porażki.
Pomimo tego, że wykonujemy sprawdzenie równości obecnej liczby aktywnych wątków i ich maksymalnej dopuszczalnej liczby, zarówno uruchamianie jak i kolejkowanie wątków odbywa się poprzez wywołanie metody execute()
puli wątków. Pula zadba o uruchomienie pierwszego w kolejce wątku kiedy inny wątek zakończy pracę.
Ponieważ będziemy odwoływać się wewnątrz wątku do obiektu stream
, należy pamiętać o umieszczeniu słowa kluczowego move
przed definicją domknięcia. Istnieje bowiem szansa, że wątek poboczny będzie aktywny dłużej niż ten, który go uruchomił. Stąd też wymagane jest, by wszelkie dane używane w nowym wątku miały czas życia równy 'static
, czyli żeby były dostępne aż do końca działania wątku.
Domyślnie stream
zostałby jedynie pożyczony, a skoro nie mamy gwarancji, że jego czas życia będzie co najmniej równy czasowi trwania wątku, to przy próbie użycia stream
w domknięciu bez move
otrzymamy błąd kompilacji. Zatem domknięcie musi przejąć stream
na własność w celu spełnienia powyższego wymagania.
Implementacja algorytmu na obliczanie kolejnych cyfr pi
Czas na gwódź programu. Zaczynamy wewnątrz domknięcia od deklaracji zmiennych dla $k$ oraz dwóch kolejnych przybliżeń π:
let mut k = from;
let mut a = from;
let mut b = from;
let mut a1 = from;
let mut b1 = from;
Zmienne a
i a1
oznaczają liczniki, zaś b
i b1
mianowniki odpowiednich przybliżeń. Następnie rozpoczynamy nieskończoną pętlę z etykietą 'pi
, na początku której obliczamy $k$-ty ułamek w serii i odpowiednio zwiększamy a1
i b1
, by uzyskać $k$-te przybliżenie π. Poprzednie wartości tych zmiennych przypisujemy do a
i b
oraz zwiększamy $k$ o 1.
'pi: loop
Warto zwrócić uwagę chociażby na formę deklaracji zmiennych p
i q
oraz na rozbicie poszczególnych operacji na a1
i b1
zamiast jednego większego wyrażenia lub operacji przypisania jak w oryginalnej implementacji algorytmu w języku ABC. Wynika to ze zwracania przez rug
niepełnego wyniku obliczeń dla działań na dwóch obiektach Integer
zamiast nowego obiektu tego samego typu.
Takie zachowanie pozwala zmniejszyć liczbę kosztownych operacji alokacji pamięci wykonywanych przez te obiekty, chociażby poprzez przypisywanie tych wyników do istniejących obiektów z pomocą przeciążonych operatorów albo metody assign()
. Jeśli jednak nie ma innego wyjścia niż utworzenie nowej liczby typu Integer
, to możemy przekazać wynik do Integer::from
tak samo jak robiliśmy to ze zwykłymi liczbami.
Zostało nam już tylko wyciąganie i porównywanie kolejnych cyfr z rozwinięć dziesiętnych przybliżeń π Jesli cyfra na danej pozycji jest taka sama dla obu ułamków, możemy ją wysłać do klienta. W razie gdy jej przesłanie się nie powiedzie (najpewniej z powodu zerwania połączenia przez klienta), przerywamy pętlę 'pi
i tym samym kończymy wątek. Jeśli cyfry się różnią, to oznacza, że czas na obliczenie kolejnego przybliżenia π.
let mut d = from;
let mut d1 = from;
while d == d1
Warto zauważyć, że nie musimy dokonywać konwersji łańcucha ascii_d
na opakowywany przez niego wektor bajtów. Typ String
implementuje cechę AsRef
do pobierania referencji na wycinek bajtów (&[u8]
) zamiast &String
, kiedy metoda write_all()
wymaga dostarczenia danych do wysłania w formie &[u8]
.
Podsumowanie
Serwer jest gotowy! Teraz wystarczy go uruchomić i przetestować np. za pomocą klienta ogólnego użytku jak ncat
:
Oczywiście, temu projektowi daleko do miana rekordzisty prędkości, a samych sposobów na wyznaczanie kolejnych cyfr liczby π jest znacznie więcej. Moim celem było zaprezentowanie paru przydatnych bibliotek oraz wyjaśnienie całkiem prostego w implementacji aglorytmu, co mam nadzieję udało mi się osiągnąć.
Zachęcam do poeksperymentowania z innymi algorytmami, rozbijaniem samych obliczeń na wiele wątków, czy napisania od podstaw serwera usługi Pigen w oparciu o protokół UDP.
Do zobaczenia w kolejnym artykule!