Ze względu na coraz większy poziom komplikacji systemów informatycznych, naturalną tendencją w językach programowania jest przestawianie się na coraz prostsze sposoby metody uzyskania podobnych (choć bynajmniej nie takich samych) efektów. Przykładami mogą być tutaj język Java czy też C# połączony z .Net. Proces ten w dużej mierze polega na usuwaniu konieczności poznawania sposobu działania systemów operacyjnych przez użytkownika. Użytkownik ma tylko składać w całość gotowe komponenty. Jednak takie „przyjazne dla idioty” rozwiązania prowadzą bardzo często do drastycznego spadku wydajności aplikacji.

Co wątki mogą zrobić z aplikacją?

Dobrym przykładem jest aplikacja napisana przez dwóch moich znajomych. Aby obsługiwać połączenia z bazą danych, tworzyli oni nowy wątek na każde połączenie przez gniazdo (ang. socket). Takie podejście jest maksymalnie proste i wręcz typowe dla aplikacji pisanych w języku Java, w jakim pisana była aplikacja. Niestety, okazało się, że narzut związany z tworzeniem nowych wątków (system operacyjny musi każdorazowo alokować zasoby, co jest czasochłonne), aplikacja była skrajnie nieefektywna i wolna. Ratując się przed tworzeniem i usuwaniem setek nowych wątków na sekundę, moi koledzy zdecydowali się na stworzenie puli wątków, co usunęło narzut związany z każdorazowym przydzielanie zasobów przez system. Okazało się, że aby radzić sobie z przetwarzanymi danymi, konieczne było stworzeniem aż 3000 wątków. Nie jest to raczej typowe rozwiązanie. Dość powiedzieć, że aplikacja nie mogła działać pod systemem Windows, ponieważ nie daje on możliwości tworzenia aż tak dużej liczby wątków. W końcu aplikacja została uruchomiona na serwerze opartym o Solaris.

Przypuszczam, że spora liczba programistów słysząc, że Windows nie pozwalał na tworzenie wystarczającej liczby wątków, aby aplikacja mogła działać, zaczęło narzekać na ten system operacyjny. Nie, tym razem nie była to wina pana Bill-a G. Tworzenie tak dużej liczby wątków nie jest normalnym zachowaniem. Powodem jest ogromny narzut związany z tzw. zmianą kontekstu. Każdy system operacyjny przydziela każdemu wątkowi pewien okres działania, a następnie przełącza się na inny wątek. Ten sposób działania daje wrażenie, że kilka wątków działa równolegle, chociaż w rzeczywistości pracują one na zmianę. Aby przełączyć się na inny wątek, system operacyjny musi dokonać dosyć dużo operacji związanych z podmianą części pamięci (kontekstu) wątku. Jednocześnie przełączania te nie mogą występować zbyt rzadko, ponieważ w takiej sytuacji system traci właściwości systemu czasu rzeczywistego. Kiedy użytkownik w swojej aplikacji tworzy zbyt wiele wątków, zmusza to system operacyjny do bardzo częstej zmiany kontekstu, a co się z tym wiąże, do marnowania na przełączenia bardzo dużej ilości mocy obliczeniowej. Zamiast zajmować się obsługą operacji wejścia-wyjścia, aplikacja większość swojej mocy traci na zmianę kontekstu.

Firma Dialogic, z której  bibliotek korzystałem, jednoznacznie zaleca, aby aplikacja działała w trybie asynchronicznym (za chwilę opiszę, co to znaczy), ponieważ tworzenie około 120 wątków potrzebnych do obsługi trybu synchronicznego sprawia, że aplikacja staje się zupełnie nieskalowalna. Dalsze zalecenia mówią, że tryb synchroniczny został dodany wyłącznie jako sposób na szybkie tworzenie aplikacji testowych i przykładowych (np. jako demo dla klienta). W tej sytuacji można by się zastanowić, jakie są konsekwencje stworzenia 3000 wątków? Dość powiedzieć, że aplikacja uruchamiana na specjalnym serwerze przystosowanym do potrzeb telekomunikacyjnych i posiadającym 8 rdzeni procesora oraz około 16 GB pamięci RAM, uruchamia się przez 30 sekund!!! Te 30 sekund to czas potrzebny na zaalokowanie tak dużej liczby wątków. Oczywiście w czasie działania aplikacji także widoczny jest ogromny narzut, który sprawia, że procesor jest używany w bardzo dużym stopniu, chociaż aplikacja wykonuje tylko bardzo podstawowe i proste operacje.

Dwa podejścia do operacji wejścia-wyjścia

Teraz możemy zadać sobie pytanie: w takim razie jak należy korzystać z operacji wejścia-wyjścia, takich jak odczyt z plików czy z gniazd, aby nie doprowadzić do tak dużego niepotrzebnego narzutu? Rozwiązaniem jest skorzystanie z operacji asynchronicznych. Aby lepiej zrozumieć, na czym polega różnica, postanowiłem pokazać kilka przykładów.

Operacje synchroniczne

Operacje synchroniczne są bardzo proste do zrozumienia. Są one stosowane przez 95% wszystkich programistów nowej daty, co tłumaczy powolność działania wielu współczesnych aplikacji. Operacje te polegają na tym, że kiedy wołamy funkcje czytające z pliku lub gniazda, to wątek jest zatrzymywany do momentu dotarcia tych danych. Ponieważ najlepiej znam język C/C++, więc postanowiłem przykłady umieszczać właśnie w tym języku.

//przykład oparty jest o bibliotekę standardową C++
#include <fstream>
#include <string>
using namespace std;
int main()
{
ofstream file("filename.txt"); // tutaj tworzymy strumień danych pochodzących z pliku.
string line; // tutaj tworzymy napis, do którego zaraz wczytamy pierwszą linię z pliku.
getline(line, file); // tutaj wołamy funkcję synchroniczną, która zablokuje nam wątek
/*do tego miejsca dotrzemy dopiero wtedy, kiedy zakończy się odczytywanie danych z pliku. Należy pamiętać, że operacje na dysku są stosunkowo długotrwałe w porównaniu z pracą mikroprocesora. Oznacza to, że moc procesora będzie się marnować, aż pojawi wystarczająca ilość danych pobranych z pliku, aby móc zapisać w zmiennej line pierwszą linię pliku. */
file.close(); // tutaj zamykamy plik zwalniając zasoby systemu operacyjnego
}

Upraszczając sprawę, można powiedzieć, że operacje synchroniczne (zwane także blokującymi), to takie operacje, które zatrzymują wątek do momentu pobrania lub zapisania danych do wejścia lub wyjścia. W podanym wcześniej przykładzie aplikacji moich kolegów wołali oni funkcję blokującą, pobierającą z gniazda dane. Wywołanie funkcji kończyło się dopiero wtedy, kiedy dane dotarły, a operacje na gniazdach są -podobnie do operacji dyskowych – stosunkowo wolne. Aby jednocześnie obsługiwać 3000 połączeń, potrzebne było 3000 wątków. Narzut związany z wątkami doprowadził ostatecznie do ogromnego spowolnienia aplikacji.

Operacje asynchroniczne

Operacje asynchroniczne polegają na tym, że aplikacja je wykorzystująca podejmuje reakcję (zaczyna coś robić) dopiero wtedy, kiedy już wie, że potrzebne dane zostały przetransportowane z gniazda lub pliku do pamięci komputera i korzystanie z nich jest bardzo szybkie. W opisanym wcześniej przykładzie aplikacji z 3000 wątków moglibyśmy utworzyć zaledwie jeden wątek do obsługi wszystkich gniazd. W tym jednym wątku otworzylibyśmy 3000 gniazd, a następnie kazalibyśmy systemowi operacyjnemu zatrzymać wątek do momentu, aż na jednym z podanych gniazd pojawią się jakieś dane. Wtedy obsłużylibyśmy dostępne w pamięci dane (co – przypomnijmy – byłoby błyskawiczne, ponieważ dane dotarły już do komputera i czekają w szybkiej pamięci podręcznej). Następnie ponownie zatrzymalibyśmy wątek, czekając ponownie na pojawienie się jakiejś informacji na jednym z dostępnych połączeń. Takie rozwiązanie daje możliwość obsługi wielu operacji wejścia-wyjścia w pojedynczym wątku, usuwając w ten sposób niepotrzebny koszt przełączania kontekstu między wątkami.

Operacje asynchroniczne są dostępne praktycznie w każdym języku programowania (np. w Java jest to pakiet org.apache.aio). Jednak funkcje z każdej dostępnej biblioteki jedynie ukrywają wołanie funkcji z API systemu operacyjnemu, więc przyglądnijmy się przede wszystkim im.

Na platformie Windows funkcja wykorzystywaną do czekania na wiele różnych źródeł operacji wejścia-wyjścia, jest funkcja WaitForMultipleObject(). Na platformach opartych o POSIX jest to funkcja select(). Sposób działania obu tych funkcji jest podobny. Najpierw należy otworzyć potrzebne pliki lub gniazda (poza plikami oraz gniazdami obsługiwane mogą być także np. pip-y, dla których brak dobrego tłumaczenia w języku polskim, a które służą m.in. do komunikacji międzywątkowej). Otwierając jakieś źródło danych, otrzymujemy unikalny w skali procesu identyfikator, który w przypadku platformy Windows ma postać struktury HANDLE (wewnętrznie jest ona implementowana jako wskaźnik), a na platformach opartych o POSIX będzie to zwykła liczba int. Następnie źródła danych zbiera się do postaci kolekcji i przekazuje funkcji czekającej na przyjście informacji o możliwości odczytu, zapisu lub jakiegoś problemu. Zawołanie tej funkcji zawsze blokuje wątek, kończąc się zwraca ona identyfikator źródła danych, gdzie pojawiła się możliwość odczytu lub zapisu. W tej sytuacji pozostaje nam tylko sprawdzić, na którym pliku lub gnieździe pojawiły się nowe dane i je odczytać (podczas zapisywania danych otrzymujemy informację o tym, że zwolnił się bufor i że możemy do niego zapisać dane).

Poniższy przykład prezentuje sposób odczytu danych z dwóch portów COM na platformie Linux.

#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
main()
{
int fd1, fd2; /* tworzymy dwa identyfikatory źródeł danych */
fd_set readfs; /* tworzymy kolekcję identyfikatorów */
int maxfd; /* maksymalna liczba używanych identyfikatorów */
int loop=1; /* pętla będzie chodzić w kółko, dopóki ta wartość będzie różna od 0 */
/* open_input_source otwiera urządzenie, odpowiednio ustawia port oraz zwraca identyfikator urządzenia */
fd1 = open_input_source("/dev/ttyS1"); /* COM2 */
if (fd1<0) exit(0);
fd2 = open_input_source("/dev/ttyS2"); /* COM3 */
if (fd2<0) exit(0);
maxfd = MAX (fd1, fd2)+1; /* maksymalna wartość identyfikatora, która będzie sprawdzana przez funkcję select()) */
/* główna pętla */
while (loop) {
FD_SET(fd1, &readfs); /* dodaj fd1 do kolekcji sprawdzanych identyfikatorów */
FD_SET(fd2, &readfs); /* dodaj fd2 do kolekcji sprawdzanych identyfikatorów */
/* zablokuj wątek, aż będą dostępne nowe dane */
select(maxfd, &readfs, NULL, NULL, NULL);
if (FD_ISSET(fd1)) /* jeżeli przyszły dane na porcie COM1, to obsłuż je w odpowiedniej funkcji */
handle_input_from_source1(); if (FD_ISSET(fd2)) /* jeżeli przyszły dane na porcie COM2, to obsłuż je w odpowiedniej funkcji */
handle_input_from_source2();
}

Ograniczenia operacji asynchronicznych

Żeby nie było zbyt pięknie, to niektóre systemy operacyjne nakładają pewne ograniczenia na operacje asynchroniczne. Najważniejszym czynnikiem jest liczba możliwych do obsłużenia asynchronicznych źródeł danych. W przypadku systemów opartych o POSIX wartość ta jest zależna od konkretnego systemu operacyjnego, aczkolwiek jest ona bardzo duża. Waha się w przedziale od kilkuset do kilku tysięcy. Prawdziwy problem jest z systemem Windows, który pozwala w jednym wątku obsługiwać zaledwie 64 źródła asynchronicznych sygnałów, wliczając w to operacje asynchroniczne wejścia-wyjścia, zegary, informacje o zakończeniu wątków czy prymitywy synchronizacyjne. Z tych powodów programowanie polegające na tworzeniu nowego wątku dla każdego źródła danych jest bardziej typowe w tym środowisku. Dodatkowo tworzenie aplikacji wykorzystujących asynchroniczne operacje jest trudniejsze niż korzystanie z wielowątkowości. Należy także uwzględnić, że te same operacja robi się w różny sposób na różnych platformach (dwie ostatnie wady można ominąć stosując biblioteki języka C++, które ukrywają specyficzne dla określonego systemu operacyjnego wywołania funkcji i udostępniają prosty, obiektowy i przenośny interfejs dla aplikacji. Przykładami mogą być biblioteki boost oraz ACE).