Gniazda BSD - moja praca magisterska

Zobacz w wersji HTML lub PDF

 Z każdym dniem Internet staje się coraz popularniejszy, a to pociąga za sobą fakt, że programy sieciowe nabierają większego znaczenia. Kilka lat temu programowanie aplikacji sieciowych było prawdziwym wyzwaniem. Programista musiał znać wiele szczegółów dotyczących sieci, aby napisać prostą aplikację działającą w Internecie. Dzisiaj sytuacja wygląda zupełnie inaczej. Istnieją wygodne narzędzia do programowania sieciowego, których możliwości zależą od systemu operacyjnego i języka programowania. Jednym z nich jest interfejs gniazdowy, zwany też gniazdami BSD (ang. Berkeley Software Distribution), wywodzący się z systemu operacyjnego BSD utworzonego na Uniwersytecie w Berkeley. Interfejs gniazd pozwala programiście skoncentrować się na tworzeniu aplikacji, ukrywając trudności związane z programowaniem sieciowym na niskim poziomie.

Celem niniejszej pracy jest przedstawienie teoretycznych i praktycznych podstaw zagadnień dotyczących programowania sieciowego, wykorzystującego interfejs gniazd BSD. Praca składa się z pięciu rozdziałów i jest podzielona na dwie części :

  • Pierwsze dwa rozdziały prezentują teoretyczny aspekt tematu. Opisałem w nich historię powstania gniazd BSD (rozdział 1) oraz podstawowe definicje z nimi związane (rozdział 2
  • Kolejne trzy rozdziały składają się na część praktyczną pracy. Przedstawiłem problematykę adresowania gniazd (rozdział 3) i funkcje systemowe wchodzące w skład interfejsu gniazd (rozdział 4). Zaawansowane zagadnienia związane z gniazdami opisałem w rozdziale 5. W tej części pracy umieściłem kilkanaście przykładowych programów, które będą pomocne w zrozumieniu przedstawionej teorii.

Wszystkie przykładowe programy napisałem w języku ANSI C i przetestowałem na komputerze z zainstalowanym systemem LINUX Red Hat 7.3 (Valhalla). Programy te umieściłem na dyskietce dołączonej do pracy.

 

Krótki rys historyczny

4 października 1957 roku Związek Radziecki umieścił na orbicie okołoziemskiej pierwszego sztucznego satelitę o nazwie “Sputnik”, bijąc tym samym Amerykanów w dziedzinie podboju kosmosu. W roku 1962 wybuchł kryzys na Kubie. Te wydarzenia spowodowały, że Stany Zjednoczone zaczęły zabezpieczać się przed ewentualną wojną atomową. Rząd amerykański nie ograniczając wydatków na rozwój nowoczesnych technologii powołał do życia agencję ARPA (Advanced Research Project Agency – Agencję do Zaawansowanych Projektów Technicznych), której celem było stworzenie zdecentralizowanego systemu komunikacji (tak jakby bez punktu centralnego), który działałby nawet po częściowym zniszczeniu w ataku nuklearnym części podłączonych do niego urządzeń. Owocem pracy agencji było powstanie w 1969 roku sieci ARPAnet - pierwszej rozległej sieci stworzonej na potrzeby ARPA. Łączyła centra badań i uczelnie, umożliwiając im pracę nad technologiami sieciowymi. Po pewnym czasie ARPA została przekształcona w agencję DARPA (ang. Defense Advanced Research Projects Agency – Agencja do spraw Wojskowych Zaawansowanych Projektów Technicznych).

Równocześnie z rozwojem ARPAnetu, od roku 1969 rozpoczął się rozwój systemu UNIX. Na Uniwersytecie w Berkeley (ang. University of California, Berkeley, w skrócie UCB) utworzono własną odmianę systemu UNIX znaną jako BSD. Rozwijany przez studentów i naukowców system stawał się coraz bardziej funkcjonalny. W miarę upływu czasu pojawiały się jego kolejne wersje, których możliwości i udogodnienia wzrastały w zaskakującym tempie. W efekcie tych prac powstał system 3BSD będący następcą wersji 1BSD i 2BSD. Ten niesamowity rozwój zwrócił uwagę agencji DARPA, która postanowiła dofinansowywać dalsze badania związane z tym systemem. W 1977 roku UCB i DARPA nawiązały współpracę, której celem było stworzenie systemu operacyjnego zdolnego pracować na licznych platformach sprzętowych używanych przez agencję. Specjalna grupa – Grupa Badań nad Systemami Operacyjnymi (ang. CSRG - Computer System Research Group) powołana przez UCB miała na celu dostosowanie systemu BSD do potrzeb agencji. W 1980 roku światło dzienne ujrzał system 4BSD, a następnie 4.1BSD ulepszony pod kątem wydajności. Jednak ARPA na tym nie poprzestała i przedłużyła kontrakt zlecając UCB włączenie do systemu obsługę sieci ARPAnet i poprawienie komunikacji międzyprocesowej. Dalekowzroczni programiści “poszli dalej” i włączyli do systemu obsługę innych protokołów sieciowych. Efektem ich pracy było powstanie w 1983 roku systemu 4.2BSD, wyposażonego w solidne oprogramowanie sieciowe, w którego skład wchodził między innymi interfejs gniazd BSD. W roku 1990 wprowadzono kilka zmian do interfejsu gniazdowego w związku z ukazaniem się systemu 4.3BSD Reno, w którym oprogramowanie protokołów OSI weszło do jądra systemu.

Punktem wyjścia dla wielu wersji systemu UNIX był kod oprogramowania sieciowego jakieś wersji systemu BSD, zawierającego oprogramowanie interfejsu gniazdowego.

 

Wprowadzenie do gniazd

 Rozdział ten poświęcony jest kilku kluczowym pojęciom związanym z tematem gniazd BSD. Większość z nich jest doskonale znana programistom pracującym w środowisku UNIX'a.

 Komputer w sieci

 System komputerowy ma charakter wspólnoty symbiotycznej – sprzęt i oprogramowanie zależą ściśle od siebie nawzajem i nie mogą działać osobno. Sprzęt składa się z urządzeń peryferyjnych, procesorów, pamięci, dysków i innych urządzeń elektronicznych, które razem stanowią całość komputera. Jednak komputer bez oprogramowania jest bezużyteczny. Oprogramowanie sterujące, niezbędne do działania komputera, nazywamy systemem operacyjnym. Jest to niskopoziomowe oprogramowanie obsługujące sprzęt i zapewniające określony zestaw usług programom użytkowym.

Popularnym systemem operacyjnym, który może pracować w sieci jest system UNIX lub jego nowoczesna implementacja LINUX – wielozoadaniowy i wielodostępowy system operacyjny klasy UNIX, dystrybuowany na podstawie licencji GNU (ang. GNU General Public License) opracowanej przez Free Software Foundation. Pomyślany początkowo jako narzędzie dla hobbystów i profesjonalnych programistów, LINUX staje się w coraz większym stopniu systemem operacyjnym dla “przeciętnego użytkownika”. Będąc zarazem systemem wydajnym, szybkim i darmowym, zdobywa domowe i profesjonalne środowiska komputerowe.

Ściśle związany z UNIX'em (LINUX'em) jest język C, będący językiem ogólnego stosowania. Charakteryzuje się prostotą wyrażeń, nowoczesnym sterowaniem, nowoczesnymi strukturami danych oraz bogatym zestawem operatorów. Język ten opracował i zrealizował Dennis Ritchie dla systemu operacyjnego UNIX działającego na mikrokomputerze DEC PDP-11.

Dysponując komputerem z zainstalowanym systemem operacyjnym (na przykład systemem LINUX), kompilatorem języka C oraz dowolnym edytorem tekstu (na przykład emacs) możemy napisać program. Program jest to plik wykonywalny, utworzony zazwyczaj przy użyciu programu łączącego i znajdujący się w pamięci dyskowej. Program staje się procesem wtedy, kiedy jest wykonywany przez system operacyjny. Często się mówi, że program jest obiektem statycznym, czyli formalnym opisem tego, co ma być wykonane, a proces jest obiektem dynamicznym, czyli ciągiem sekwencyjnie wykonywanych przez system operacyjny instrukcji programu. W jądrze UNIX'a istnieje pewna liczba (zazwyczaj ograniczona) tak zwanych funkcji systemowych (ang. shell call), za pomocą których aktywny proces może uzyskać różne usługi ze strony jądra systemu. Większość funkcji systemowych zwraca wartość całkowitą dodatnią w przypadku pomyślnego wykonania. Jeżeli podczas wykonywania funkcji wystąpi błąd, to funkcja zazwyczaj zwraca wartość -1 , a zmienna globalna errno otrzyma wartość całkowitą dodatnią, wskazującą na rodzaj błędu. Wszystkim dodatnim wartościom zmiennej errno odpowiadają stałe, których nazwy składają się z wielkich liter, zaczynają się od litery E i są zdefiniowane w pliku nagłówkowym . Żaden błąd nie ma wartości 0.

Sieć komputerowa

Sieć komputerowa jest systemem komunikacyjnym łączącym systemy końcowe, zwane stacjami sieciowymi (ang. host computer) . Komputery są połączone jakimś medium fizycznym, na przykład kablem komunikacyjnych. Niezależnie od sposobu połączenia komputerów ze sobą, celem sieci jest zapewnienie komunikacji między poszczególnymi stacjami – komputerami do niej podłączonymi.

Najpopularniejszą, siecią komputerową jest Internet – największa sieć, łącząca sieci komputerowe na całym świecie (“sieć sieci”). Składa się z setek tysięcy kilometrów światłowodów, łącz satelitarnych oraz całej masy mniej lub bardziej skomplikowanym urządzeń. Użytkownicy Internetu, których liczbę szacuje się w milionach, mogą za jego pośrednictwem mieć dostęp do “oceanu” informacji, komunikować się między sobą, wymieniać dane itp.

Większość programów użytkowych, które są przeznaczone do działania w sieci komputerowej, można podzielić na dwie grupy. Pierwsza z nich to aplikacje zwane serwerami, które dostarczają usg w Internecie, drugi zbiór składa się z programów zwanych klientami, które z tych korzystają. Termin serwer powoduje czasem nieporozumienia. Formalnie oznacza on program, który czeka biernie na wykonanie pewnej usługi, a nie komputer, który ją wykonuje. Jeśli jednak jakiś komputer jest wyznaczony do wykonywania jednego lub więcej serwerów, to sam jest czasami (niepoprawnie) nazywany serwerem. Producenci sprzętu komputerowego pogłębiają jeszcze te nieporozumienia, gdyż nazywają serwerami komputery o dostatecznie szybkim procesorze, odpowiednio pojemnej pamięci i stosownie wyrafinowanym systemie operacyjnym.

Komunikacja w sieci oparta jest na protokołach. Protokoły są to formalne reguły postępowania. Na przykład w stosunkach międzynarodowych, protokoły minimalizują problemy wynikające z różnic kulturowych przy współpracy różnych narodów. Zgadzając się na wspólny zestaw ogólnie przyjętych reguł, które są niezależne od jakichkolwiek zwyczajów narodowych, protokoły dyplomatyczne minimalizują nieporozumienia; każdy wie jak się zachować i jak interpretować zachowania innych. Podobnie przy łączności komputerów konieczne jest zdefiniowanie reguł, które rządzą komunikacją w sieci. Ponieważ zdefiniowanie takich zasad jest niesłychanie trudne, Międzynarodowa Organizacja ds. Standardów (ang. International Standards Organization [ISO]) opracowała model architektury służący do opisu zasad komunikacji w sieci. Model odniesienia łączenia systemów otwartych (ang. Open System Interconnect [OSI] Reference Model) gwarantuje uzyskanie wspólnego mianownika dla zagadnień komunikacyjnych. Wszystko co zostało zdefiniowane za pomocą tego modelu jest ogólnie rozumiane i szeroko stosowane przez osoby zajmujące się komunikacją w sieci. Model OSI składa się z siedmiu warstw na których znajduje się jeden lub więcej protokołów, które opisują sposoby realizacji zadań przeznaczonych dla danej warstwy. Zbiór protokołów obowiązujących na różnych warstwach i mogących stanowić podstawę dla użytecznej sieci nazywamy rodziną protokołów (ang. protocol family).

Rodzinę TCP/IP (ang. Transmission Control Protocol / Internet Protocol) będącą rodziną protokołów odpowiedzialną za komunikację w Internecie również możemy przedstawić za pomocą modelu OSI, ale za pomocą czterech warstw : warstwa kanałowa; warstwa sieciowa; warstwa transportowa; warstwa zastosowań. W modelu czterowarstwowym rodzinę protokołów wyznaczają dwie warstwy : transportowa (protokół TCP i protokół UDP) oraz sieciowa (protokół IP). Natomiast w jedną warstwę kanałową są połączone protokoły i cechy charakterystyczne sieci na poziomie jej topologii oraz użytego sprzętu (Ethernet albo Token ring). Przestrzenią programów użytkowych jest zaś warstwa zastosowań.

Do najważniejszych protokołów z rodziny TCP/IP należą :

  • Protokół IP (ang. Internet Protocol) jest protokołem warstwy sieciowej i jest on fundamentalnym elementem Internetu. Do najważniejszych jego zadań należą między innymi obsługa doręczania pakietów dla protokołów z warstwy transportowej takich jak TCP i UDP oraz definiowanie schematu adresowania w Internecie.
  • Protokół TCP (ang. Transmission Control Protocol) jest protokołem połączeniowym znajdującym się na warstwie transportowej. Umożliwia niezawodne i w pełni dwukierunkowe (ang. full-duplex) przesyłanie strumienia danych. W większości internetowych programów stosuje się ten protokół.
  • Protokół UDP (ang. User Datagram Protocol) jest protokołem bezpołączeniowym znajdującym się na warstwie transportowej. W odróżnieniu od protokołu TCP, który jest niezawodny, protokół UDP nie daje gwarancji, że dana wiadomość dotrze do wyznaczonego celu.

Aby komputery podłączone do Internetu mogły się ze sobą komunikować, musi istnieć pewien sposób ich identyfikacji. Innymi słowy, każda stacja podłączona do sieci musi mieć przypisany numer identyfikacyjny, który jest niepowtarzalny. Typowy adres stacji składa się z identyfikatora sieci oraz identyfikatora stacji w tej sieci. W Internecie komputery są identyfikowane za pomocą 32-bitowej liczby całkowitej, znanej jako adres IP. Adresy te są wyznaczane przez upoważniony do tego węzeł centralny – Sieciowe Centrum Informacyjne czyli NIC (ang. Network Information Center), zarządzane przez firmę SRI International. Aby adresy IP były łatwo czytelne, zostały podzielone na 8-bitowe liczby zwane oktetami (format ten często jest nazywany kropkową notacją czwórkową). Ponieważ łatwiej zapamiętać nazwy niż liczby, usługa nazewnictwa domen (ang. Domain Name Service – DNS) przyporządkowuje unikatowym adresom liczbowym IP nazwy. Na przykład komputer o nazwie quark.physics.grouch.edu ma adres IP 0x954C0C04, zapisywany jako 146.76.12.4. Nie wszystkie adresy sieci i komputerów są dostępne dla użytkowników. Na przykład adresy, których pierwszy bajt jest większy od 223 są zarezerwowane. Nas będzie najbardziej interesował adres 127.0.0.1 będący adresem sieci zwrotnej (ang. loopback adress). Sieć zwrotna składa się z jednego komputera o nazwie localhost i adresie 127.0.0.1. Oznacza to, że samotny komputer (nie podłączony do żadnej sieci) z zainstalowanym systemem UNIX działa w sieci jednoelementowej, którą stanowi ten pojedyczy komputer. W przykładach opisanych w dalszej części pracy użyłem właśnie sieci zwrotnej. Adres takiego komputera można znaleźć w pliku hostów sieciowych /etc/hosts.

Komputer w sieci może działać jako stacja jednosieciowa lub wielosieciowa, czyli stacja połączona z co najmniej dwiema sieciami. Każda sieć, z którą porozumiewa się komputer, posiada przypisany jej interfejs sprzętowy, przez który następuje dostęp do sprzętu sieciowego. Komputer może mieć odmienne nazwy w każdej z sieci, a z pewnością będzie miał odrębne adresy. Wypływa z tego wniosek, że każdy adres w Internecie określa w jednoznaczny sposób daną stację, ale nie każda stacja musi mieć dokładnie jeden adres.

Znajomość adresu IP danego komputera, czyli umiejętność zlokalizowania go w sieci nie wystarcza do nawiązania komunikacji, ponieważ na komputerze o danym adresie IP może działać wiele aplikacji pełniących rolę serwera. W celu rozróżnienia tych procesów używa się 16-bitowych numerów portów. Zarówno dla protokołów TCP jak i UDP zdefiniowano grupę portów ogólnie znanych (ang. well known ports), przeznaczonych do wykonywania ogólnie znanych usług. Na przykład, w każdej implementacji rodziny protokołów TCP/IP, która pozwala korzystać z protokołu FTP (ang. File Transfer Protocol), serwerowi FTP przypisano ogólnie znany port o numerze 21. Protokół TFTP, czyli prymitywny protokół przesyłania plików (ang. Trivial File Transfer Protocol), ma w protokole UDP przyporządkowany ogólnie znany port 69. Klienci łącząc się z serwerem używają portów efemerycznych (ang. ephemeral port), czyli krótkotrwałych. Numery tych portów są zazwyczaj automatycznie wyznaczane klientom przez protokoły TCP i UDP, które gwarantują, że nie ma portu przypisanego do dwóch procesów jednocześnie i że numer takiego portu jest zawsze większy od numerów dobrze znanych portów. Dokument RFC 1700 zawiera wykaz numerów portów wyznaczonych przez organizację IANA (ang. Internet Assigned Numbers Authority). Każdy numer portu należy do jednego z trzech zakresów :

  • Porty ogólnie znane (ang. well-known ports) mają numery z przedziału 01023. Nadzór nad tymi numerami portów i ich przyporządkowanie należy do organizacji IANA. W miarę możności ten sam numer portu jest przypisany do tej samej usługi w obu protokołach – TC
  • Porty zarejestrowane (ang. registered ports) mają numery z przedziału 102449151. Organizacja IANA nie sprawuje nad nimi nadzoru, lecz dla wygody użytkowników sieci rejestruje numery portów i sporządza wykazy ich przyporządkowania. W miarę możności ten sam numer portu jest przypisany do tej samej usługi w obu protokołach – TCP i UDP. Na przykład porty od 6000 do 6063 przyporządkowano do serwera X-Windows dla obu protokołów.
  • Porty dynamiczne lub prywatne mają numery z przedziału 49152-65535. Organizacja IANA nic nie mówi o tych portach. Są to porty nazywane portami efemerycznymi.

 

W systemie LINUX, informacja o tym, jakie usługi odpowiadają jakim portom przechowywana jest w pliku /etc/services. Do najbardziej znanych usług należą :

  • port o numerze 7 - usługa echo.
  • port o numerze 9 - usługa discard.
  • port o numerze 13 - usługa daytime.
  • port o numerze 19 - usługa chargen.
  • port o numerze 21 - usługa FTP.
  • port o numerze 23 - usługa Telnet.
  • port o numerze 25 - serwer pocztowy używający protokołu SMTP.
  • port o numerze 37 - usługa time.
  • port o numerze 53 - usługa DNS.
  • port o numerze 80 - serwer WWW.

 

Model komunikacji klient-serwer

Standardowym modelem komunikacji w sieci jest model “klient-serwer”. Polega on na tym, że jeden proces zwany serwerem dostarcza usługi pod określonym adresem IP i numerem portu. Drugi proces zwany klientem znając adres IP i numer portu serwera, może skorzystać z jego usługi. Przykładowy scenariusz mógłby wyglądać tak :

  • Proces, zwany serwerem, rozpoczyna pracę w pewnym systemie komputerowym. Po zainicjowaniu pracy serwer “zasypia” czekając na proces, zwany klientem, który skontaktuje się z nim, zamawiając jakąś usługę.
  • Proces zwany klientem, rozpoczyna pracę albo w tym samym systemie, co serwer, albo w innym systemie połączonym z systemem serwera za pomocą sieci komputerowej. Klient wysyła zamówienie do serwera, prosząc o pewien rodzaj usługi (na przykład serwer musi podać klientowi dokładną godzinę).
  • Kiedy serwer zakończy wykonywanie usługi dla klienta, wtedy znowu zasypia i oczekuje na nadejście następnego żądania.

Definicja gniazda

 Kombinacja adresu IP i numeru portu nosi nazwę gniazda (ang. socket). Można powiedzieć, że gniazdo jest programową abstrakcją używaną do reprezentowania końcówki połączenia między dwoma komputerami. Dla każdego połączenia istnieją gniazda na obydwu uczestniczących w nim komputerach (aplikacjach działających na tych komputerach). Wyobraźmy sobie rozciągający się pomiędzy komputerami kabel, którego końce są wpięte do tych gniazd. Formalnie gniazdo powinniśmy zdefiniować jako zbiór trzyelementowy :

  • {protokół komunikacyjny, adres-obcy, proces-obcy} - pierwsze gniazdo
  • {protokół komunikacyjny, adres-lokalny, proces-lokalny} – drugie gniazdo

Wartością adres-lokalny i adres-obcy są identyfikatory stacji lokalnej oraz stacji odległej. Natomiast elementy proces-lokalny i proces-obcy określają konkretne procesy działające na obu komputerach. Te trzyelementowe zbiory stanowią punkty końcowe komunikacji w sieci zwane półasocjacjami.

Jeżeli rozpocznie się komunikacja między powyższymi komputerami, zostanie utworzony pięcioelementowy zbiór wystarczający do pełnego określenia obu komunikujących się ze sobą procesów.

  • {protokół, adres-lokalny, proces-lokalny, adres-obcy, proces, obcy}

Ten zbiór nazywamy asocjacją.

Interfejs gniazd

Interfejs wykorzystywany przez program użytkowy przy interakcji z oprogramowaniem protokołów warstwy transportowej nazywa się interfejsem programu użytkowego (ang. Application Program Interface – API). Interfejs API określa zestaw operacji, które program użytkowy może wykonywać w ramach interakcji z oprogramowaniem protokołów. Większość systemów oprogramowania definiuje interfejs API, podając zestaw funkcji, które program może wywoływać, oraz argumentów, których każda z nich się spodziewa. Zwykle API zawiera oddzielną funkcję dla każdej operacji podstawowej. Interfejs API może na przykład zawierać funkcję, która jest wykorzystywana do ustanawiania komunikacji, oraz inną funkcję, która jest używana do wysyłania danych.

Standardy protokołów komunikacyjnych nie określają zwykle interfejsu, którego programy mają używać przy interakcji z nimi. Protokoły określają ogólne operacje, które powinny być udostępnione, oraz pozwalają systemowi operacyjnemu na zdefiniowanie konkretnego interfejsu API, którego programy będą używać do wykonywania tych operacji. Chociaż standardy protokołów pozwalają projektantom systemów operacyjnych na wybranie interfejsu API, to wielu z nich wybrało interfejs gniazd BSD, który stał się standardem interfejsów API.

Atrybuty gniazd

Znając definicje gniazd można zrobić krok naprzód i zająć się podstawowymi parametrami, które mają wpływ na nowo utworzone gniazdo. Gniazda muszą mieć swoją domenę, czyli określone środowisko, w którym będą działać. Powinny mieć typ, czyli sposób komunikowania się w sieci. Zazwyczaj do pełnego zdefiniowania gniazda wystarczają powyższe atrybuty. Czasem zdarzają się sytuację, gdy programista musi ręcznie przypisać gniazdu protokół komunikacyjny. Gniazda mają również swój adres, który jest kojarzony z ich nazwą.

Rodzaj transmisji

Rodzaj transmisji informuje nas w jaki sposób gniazda będą traktować transmitowane dane. Gniazdo może przyjąć jedną z poniższych transmisji :

  • Transmisja połączeniowa gwarantuje dostarczenie wszystkich pakietów danych w takiej kolejności, w jakiej zostały wysłane. Najpierw jest ustanawiane łącze pomiędzy dwoma komunikującymi się procesami, a dopiero potem zachodzi wymiana danych. Rozwiązanie takie oznacza, że pomiędzy tymi procesami jest wyznaczona trasa oraz że obydwaj uczestnicy transmisji są aktywni. Ustanowienie kanału komunikacyjnego wymaga dodatkowego czasu. Ponadto większość protokołów połączeniowych gwarantuje dostarczenie wiadomości, co jeszcze bardziej wydłuża transmisję, gdyż zachodzi konieczność przeprowadzania obliczeń i weryfikowania poprawności. Protokoły połączeniowe są niezawodne, ale niestety ta niezawodność jest uzyskana kosztem czasu. Transmisja połączeniowa przypomina rozmowę telefoniczną. Najpierw ustanawia się połączenie z rozmówcą, następnie przez pewien czas wymienia się dane i wreszcie zamyka się połączenie. Rozważmy typowy scenariusz transmisji połączeniowej. Serwer jest procesem oczekującym na określoną liczbę połączeń klienta. Jego zadaniem jest obsługa żądań klientów. Serwer musi oczekiwać połączeń pod powszechnie znaną nazwą. Na przykład w protokole TCP/IP nazwą tą będzie adres IP oraz numer portu. Najpierw aplikacja serwera tworzy gniazdo, które jest po prostu zasobem systemu operacyjnego przypisanym do procesu serwera. Służy do tego funkcja systemowa socket(). Następnie należy związać to gniazdo z jego powszechnie znaną nazwą (adresem zdefiniowanym w gniazdowej strukturze adresowej). Zadanie to jest realizowane za pomocą funkcji bind(). Teraz należy przełączyć gniazdo w tryb nasłuchiwania, co umożliwia wywołanie systemowe listen(). Na zakończenie, gdy klient próbuje nawiązać łączność, serwer może zaakceptować to połączenie za pomocą funkcji accept(), która tworzy nowe gniazdo niezależne od gniazda nazwanego i przeznaczone do komunikacji z danym klientem. Od strony klienta sytuacja jest o wiele prostsza, gdyż nawiązanie połączenia wymaga mniejszej liczby kroków. Aplikacja klienta tworzy gniazdo nienazwane używając funkcji socket(). Następnie próbuje nawiązać połączenie z serwerem, wykorzystując jego nazwane gniazdo jako adres. Realizuje to używając wywołania systemowego connect() . Po nawiązaniu połączenia oba gniazda za pomocą funkcji systemowych read() i write() zapewniają dwukierunkową komunikację.
  • Transmisja bezpołączeniowa nie gwarantuje dostarczenia danych ani przekazania ich w odpowiedniej kolejności. Pakiety mogą zostać zgubione lub pomieszane w czasie transmisji w związku z błędami sieci lub z innych powodów. Transmisja bezpołączeniowa przypomina usługi pocztowe. Każda przesyłka zawiera pełny adres odbiorcy. Przesyłki wysyłane pod ten sam adres mogą przebywać różne trasy, zanim zostaną doręczone. Dlatego istnieje możliwość, że dwa listy nadane pod ten sam adres w krótkim odstępie czasu, dotrą do adresata w odwrotnej kolejności. Nie ma też pełnej gwarancji, że przesyłka będzie pomyślnie dostarczona. Programista implementujący gniazda oparte na transmisji bezpołączeniowej musi sam zabezpieczyć swój program przed skutkami ich zawodności. Transmisja bezpołączeniowa jest wielokrotnie szybsza niż połączeniowa. Może być wykorzystywana do takich aplikacji, w których nie ma większego znaczenia to, iż kilka pakietów danych zostanie tu i tam zagubionych, ponieważ szybkość jest dla nich najważniejsza. Przykładem takiej aplikacji może być gra sieciowa przeznaczona dla wielu użytkowników. Każdy gracz stosuje datagramy, aby wysłać do innych graczy informacje o swojej aktualnej pozycji w grze. Jeżeli nawet któryś z pakietów nie dotrze do któregoś z graczy, bardzo szybko otrzyma on następny pakiet, dzięki czemu będzie miał wrażenie płynności gry. Oto przykład typowego scenariusza transmisji bezpołączeniowej. Aplikacja serwera tworzy gniazdo nienazwane za pomocą funkcji socket(). Następnie nadaje mu nazwę używając wywołania systemowego bind(). Odmienność polega na tym, że proces serwera nie nasłuchuje ani też nie akceptuje połączeń. Zamiast tego aplikacja serwera po prostu oczekuje na nadchodzące dane. Serwer może odbebrać dane używając funkcji recvfrom(). Podobnie klient nie ustanawia połączenia z serwerem, ale po prostu wysyła do niego datagram za pośrednictwem wywołania systemowego sendto().

Domena gniazd

Domena gniazd określa środowisko sieciowe, z którego będą korzystać gniazda oraz sposób zapisu adresów gniazd identyfikujących jeden z końców połączenia komunikacyjnego. Gniazda mogą występować w następujących środowiskach :

  • Gniazda w dziedzinie UNIX'a Z gniazd w dziedzinie UNIX'a (ang. Unix domain protocols) możemy korzystać do komunikowania się z procesami tylko w obrębie tego samego systemu operacyjnego. W tej dziedzinie można implementować zarówno gniazda połączeniowe jak i bezpołączeniowe. Można przyjąć, że obie implementacje są niezawodne, ponieważ znajdują się wewnątrz jądra systemu i nie są przesyłane przez urządzenia zewnętrzne, takie jak linie komunikacyjne łączące komputery. Nie są potrzebne sumy kontrolne ani inne środki zapewniające niezawodność. Protokołem obsługującym gniazda połączeniowe jest unixstr, a gniazda bezpołączeniowe – unixdg. Adresem gniazda jest nazwa pliku przechowywana w systemie plików.
  • Gniazda w dziedzinie Internetu Gniazd z domeny Internetu możemy używać do komunikacji między procesami znajdującymi się na różnych komputerach podłączonych do sieci. W tym środowisku możemy implementować zarówno gniazda połączeniowe jak i bezpołączeniowe. Używanym protokołem dla gniazd połączeniowych jest TCP (ang. Transmission Control Protocol), a dla gniazd bezpołączeniowych – UDP (ang. User Datagram Protocol). Oba protokoły działają na bazie protokołu niższego poziomu IP (ang. Internet Protocol). Gniazda połączeniowe zapewniają niezawodne dostarczanie danych dzięki pełnodupleksowemu połączeniu implementowanemu za pomocą datagramów IP. Niezawodność uzyskuje się przez użycie mechanizmu potwierdzeń i retransmisji. Jeżeli nadawca nie dostanie na czas potwierdzenia odebrania pakietu przez odbiorcę, to zakłada, że dane zginęły i retransmituje je. Po pewnej liczbie ponownych retransmisji oprogramowanie TCP zaniecha wysyłania danych, przy czym cały czas przeznaczony na próby wysyłania danych wynosi od 4 do 10 minut (zależnie od implementacji systemu). Gniazda bezpołączeniowe są zawodne, więc jeżeli chcemy uzyskać pewność, że datagram dojdzie do odbiorcy, to musimy nasze oprogramowanie użytkowe uzupełnić o wiele właściwości takich jak potwierdzenie uzyskania danych przez odbiorcę, obsługę czasu oczekiwania na odpowiedź, ponawianie retransmisji itp. Adres gniazda internetowego zajmuje 32 bity i składa się z dwóch części : identyfikatora sieci oraz identyfikatora stacji w tej sieci. Oprócz tego używa się 16-bitowego numeru portu służącego do identyfikacji konkretnego procesu na komputerze o danym adresie IP.

Protokół komunikacyjny

Czasem zdarza się, że mechanizm transportowy potrafi korzystać z różnych protokołów komunikacyjnych, aby określić żądany typ gniazda. Wtedy programista może wskazać najwłaściwszy protokół dla danego gniazda. Zaleca się wyrażenie zgody, aby system operacyjny wybrał domyślny protokół komunikacyjny.

 

Adresowanie gniazd

W tym i następnym rozdziale zajmę się interfejsem gniazd BSD, będącym zestawem funkcji, które mogą być wykorzystane przez program użytkowy do interakcji z oprogramowaniem protokołów warstwy transportowej. Najpierw omówię problematykę adresowania gniazd, co jest chyba najbardziej kłopotliwym aspektem pisania oprogramowania sieciowego.

Gniazda mogą być używane z dowolnymi protokołami, zatem format adresu zależy od tego, który z protokołów będzie użyty. Interfejs gniazd definiuje ogólną postać reprezentacji adresów, a następnie wymaga, aby każda rodzina protokołów określała sposób korzystania z niej przez adresy protokołowe.

Ogólna gniazdowa struktura adresowa

W 1982 roku została zdefiniowana w pliku nagłówkowym ogólna gniazdowa struktura adresowa sockaddr.

struct sockaddr
{
sa_family_t sa_family; // rodzina adresu //
char sa_data[14] // właściwy adres //
}

 

 

 

 

Obecnie sa_family_t to krótka liczba całkowita (ang. short integer), której długość w systemie LINUX wynosi dwa bajty. Cała struktura ma 16 bajtów długości. Element sa_data[14] struktury reprezentuje 14 bajtów, zawierających dane o adresie.

Ogólna struktura adresu gniazda nie jest specjalnie użyteczna dla programisty. Niemniej dostarcza schematu, do którego muszą się dostosować wszystkie inne struktury adresów.

 

Adresowanie gniazd w dziedzinie Internetu

 Dla każdej rodziny protokołów zdefiniowano właściwą jej gniazdową strukturę adresową. Nazwy tych struktur zaczynają się od członu sockaddr_, po którym występuje drugi człon nazwy, identyfikujący daną rodzinę protokołów. Na przykład rodzina protokołów TCP/IP jako definicji adresu używa struktury sockaddr_in, zdefiniowanej w pliku nagłówkowym .

struct in_addr
{
unsigned long int s_addr; // 32 bitowy adres IP
}

struct sockaddr_in
{
sa_family_t sin_family; // Tutaj należy wpisać domenę AF_INET //
unsigned short int sin_port // 16-bitowy numer portu TCP lub UDP //
struct in_addr sin_addr // 32-bitowy adres IP //
char sin_zero[8] // nie używane (wartości zerowe)
}

 

Strukturę powinno się wypełniać w następujący sposób :

  •  Pole “sin_family” określa rodzinę adresów gniazdowej struktury adresowej. Wypełniając je wartością AF_INET, sprawiamy, że adres jest zgodny z regułami adresowania protokołu IP.
  • Pole “sin_port” definiuje numer portu TCP/IP na potrzeby adresu gniazda. Jest to 16-bitowa liczba całkowita bez znaku, która musi być podana w porządku sieciowym (definicję porządku sieciowego omówię w dalszej części rozdziału).
  • Pole “sin_addr” przechowuje numer IP hosta, podany w porządku sieciowym. Po dokładnym przyjrzeniu się strukturze in_addr można zauważyć, że jest to 32-bitowa liczba całkowita bez znaku. Każdy bajt jest 8-bitową liczbą bez znaku. W ten sposób w każdym bajcie może być zapisana dowolna liczba dziesiętna z przedziału od 0 do 255. Ponieważ jest to wartość bez znaku, liczby nie mogą być ujemne.
  • Ostatnie pole struktury definiuje 8-bitowe pole zer. Dzieje się tak, ponieważ TCP/IP wymaga tylko sześć bajtów do zapisania pełnego adresu, a ogólna struktura adresu rezerwuje na ten cel 14 bajtów. Stąd wynika istnienie końcowego pola struktury, które uzupełnia strukturę sockaddr_in do rozmiaru ogólnej struktury sockaddr.

 

Adresowanie gniazd w dziedzinie Unix'a

Ten format adresu jest używany w odniesieniu do gniazd, które znajdują się lokalnie w hoście użytkownika (na przykład w komputerze klasy PC z zainstalowanym systemem LINUX). Adres lokalny określony jest za pomocą gniazdowej struktury adresowej dla dziedziny UNIX'a, która nosi nazwę sockaddr_un.

 

#include
struct sockaddr_un
{
sa_family_t sun_family // Tutaj należy wpisać domenę AF_UNIX //
char sun_path[]; // tutaj wpisujemy ścieżkę pliku //
}

 

Strukturę powinno się wypełniać w następujący sposób :

  •  W polu “sun_family” określającego rodzinę adresów należy wpisać wartość AF_UNIX lub AF_LOCAL. Tak wpisana wartość oznacza, że adresy będą formowane zgodnie z lokalnymi (UNIX'owymi) zasadami.
  • W polu “sun_path[]” wpisujemy UNIX'ową nazwę ścieżki do pliku. Należy pamiętać, że adresami protokołowymi używanymi do identyfikowania klientów i serwerów w dziedzinie UNIX'a są nazwy ścieżkowe w obrębie zwykłego systemu plików. Jednak nie są to zwykłe pliki – nie można w zwyczajny sposób ani pobrać danych z tych plików, ani odsyłać do nich danych. Może to robić tylko program, który powiązał taką nazwę ścieżkową (tak jakby adres gniazda) z gniazdem w dziedzinie UNIX'a.

 

Podstawowe funkcje gniazd

 W tym rozdziale opiszę podstawowe funkcje wchodzące w skład interfejsu gniazd BSD.

Tworzenie nowego gniazda

Nowe gniazdo można utworzyć za pomocą funkcji systemowej socket().

 

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

 

Pierwszy argument domain określa rodzinę protokołów i jest jedną ze stałych wymienionych w tabeli 4.1. Parametr type definiuje typ gniazda i jest jedną ze stałych przedstawionych w tabeli 4.2. Nie wszystkie pary argumentów domain i type są poprawne. Pary poprawne i wybrany dla danej pary protokół przedstawia tabela 4.3. Słowo “tak” w klatce oznacza, że dana para jest poprawna, ale nie istnieje dla niej poprawny akronim.

 

Dziedzina gniazda Opis
AF_UNIX lub AF_LOCAL Protokoły dziedziny UNIX'a
AF_INET Protokoły rodziny TCP/IP

Tabela 4.1. Rodziny protokołów dla funkcji socket().

Typ gniazda Opis
SOCK_STREAM Gniazdo strumieniowe
SOCK_DGRAM Gniazdo datagramowe

Tabela 4.2. Typy gniazd dla funkcji socket() Tabela 4.2. Typy gniazd dla funkcji socket()

  AF_UNIX (AF_LOCAL) AF_INET
SOCK_STREAM tak TCP
SOCK_DGRAM tak UDP

Tabela 4.3. Pary argumentów domain i type dla funkcji socket().

 

Mogłoby się wydawać, że po zdefiniowaniu rodziny protokołów i typu gniazda nie ma potrzeby definiowania niczego więcej. Jednak mimo iż dla danej rodziny protokołów i typu gniazda jest tylko jeden protokół, są sytuacje, gdy tych protokołów jest więcej. Parametr protocol pozwala w takiej sytuacji wybrać jeden z dostępnych protokołów. W praktyce programista powinien ustawić argument protocol na wartość 0, co spowoduje, że jądro systemu wybierze domyślny protokół.

 

Zestawienie parametrów funkcji socket() i ich wpływ na sposób komunikacji przedstawia tabela 4.4.

 

Domena Typ gniazda Opis
AF_UNIX SOCK_STREAM Dostarcza gniazdo strumieniowe do komunikacji w obrębie lokalnego hosta. Usługa jest połączeniowa i bezpieczna.
AF_UNIX SOCK_DGRAM Dostarcza gniazdo datagramowe do komunikacji w obrębie lokalnego hosta. Usługa jest bezpołączeniowa i zazwyczaj bezpieczna.
AF_INET SOCK_STREAM Stosowane dla potrzeb przesyłania danych przez Internet między dwoma połączonymi gniazdami. Używa się tutaj protokołu TCP, który jest niezawodny.
AF_INET SOCK_DGRAM Służy do przesyłania danych przez Internet między dwoma niepołączeniowymi gniazdami. Używa się tutaj protokołu UDP, który nie gwarantuje bezpieczeństwa przesyłania danych.

Tabela 4.4. Zestawiene parametrów funkcji socket() związanych z komunikacją lokalną i internetową

Jeżeli powiedzie się wykonanie funkcji socket() to przekaże ona małą nieujemną liczbę całkowitą, która jest deskryptorem gniazda (ang. socket descriptor) i służy jako element identyfikujący to gniazdo we wszystkich następnych wywołaniach funkcji systemowych (np. dla funkcji connect(), którą opiszę później). W przypadku błędu funkcja zwróci wartość -1 i ustawi zmienną errno na wartość całkowitą dodatnią wskazującą na rodzaj błędu. Wybrane stałe opisujące rodzaj błędu przedstawia tabela 4.5.

Errno Opis
EINVAL Błędne dane
EPROTONOSUPPORT Wartość typu i protokołu nie są dozwolone w podanej dziedzinie gniazda
EMFILE Przepełniona tablica deskryptorów procesu
ENFILE Przepełniona tablica plików
EACCES Brak uprawnień do utworzenia gniazda o podanych parametrach.
ENOSR Brak zasobów systemowych
ENOBUFS Brak pamięci na bufory gniazda

Tabela 4.5. Wybrane stałe opisujące rodzaj błędu dla funkcji socket().

 

Związanie gniazda z adresem

Gniazdo utworzone za pomocą funkcji socket() jest anonimowe. Aby związać to gniazdo z danym adresem protokołowym należy zastosować wywołanie systemowe bind().

#include <sys/types.h>
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *adress, socklen_t adress_len)

Funkcja bind() przypisuje adres określony parametrem adress do nienazwanego gniazda o deskryptorze socket. Długość struktury adresowej adress jest przekazywana w argumencie adress_len. Dla gniazd internetowych adres składa się z 32-bitowego adresu IP oraz 16-bitowego numeru portu TCP lub UDP. Natomiast dla gniazd lokalnych adresem jest po prostu nazwa pliku. Wywołując funkcję bind() dla gniazda internetowego możemy określić : numer portu, adres IP, albo obie te wielkości, albo też żadnej nie podawać.

Adres IP Numer portu Wynik funkcji bind()
Brak Brak Jądro systemu wybiera adres IP oraz numer portu.
Brak Podany Jądro wybiera adres IP, proces określa numer portu.
Podany Brak Proces określa adres IP, jądro wybiera numer portu.
Podany Podany Proces określa adres IP oraz numer portu.

Tabela 4.6. Sposób działania funkcji bind() w zależności od określenia adresu IP i numeru portu.

Jeżeli wywołując funkcję bind() nie określimy numeru portu, to jądro systemu wybierze port efemeryczny dla danego gniazda, jeśli będzie wywołana funkcja connect() (dla gniazda klienta) lub listen() (dla gniazda serwera). W przypadku nieokreślenia adresu IP, jądro systemu wybierze adres źródłowy IP wtedy, kiedy gniazdo będzie połączone. Jednak procesy pełniące rolę serwerów nie powinny wyrażać zgody, aby jądro systemu dokonywało za nich wyboru, ponieważ są one zazwyczaj rozpoznawane na podstawie ich ogólnie znanych numerów portów.

Errno Opis
EBADF Błędny deskryptor
ENOTSOCK Deskryptor nie odnosi się do gniazda
EINVAL Deskryptor odnosi się do już nazwanego gniazda
EADDRNOTAVAIL Adres jest niedostępny
EADDRINUSE Adres ma już przypisane gniazdo
EACCESS Nie można utworzyć nazwy w systemie plików ze względu na brak zezwolenia (dotyczy gniazd AF_UNIX)
ENOTDIR Niefortunnie wybrana nazwa pliku (dotyczy gniazd AF_UNIX)
ENAMETOOLONG Niefortunnie wybrana nazwa pliku (dotyczy gniazd AF_UNIX)

Tabela 4.7. Wybrane stałe opisujące rodzaj błędu dla funkcji bind().

W przypadku pomyślnego wykonania funkcja zwraca 0. W przypadku błędu, zwraca -1 i ustawia errno na wartość całkowitą dodatnią wskazującą na rodzaj błędu.

Proces może dowiązać konkretny adres IP do swojego gniazda jeżeli ten adres należy do interfejsu danej stacji. W wypadku procesu klienta dowiązany konkretny adres IP staje się adresem źródłowym IP, którego używa się dla datagramów IP wysyłanych z tego gniazda. W przypadku procesu serwera takie dowiązanie powoduje, że gniazdo może przyjmować tylko te nadchodzące od klientów połączenia, które są przeznaczone dla tego adresu IP. Aby umożliwić działanie serwerów na komputerach wielosieciowych, interfejs gniazd zawiera specjalną stałą symboliczną, INADDR_ANY, która pozwala, aby serwer korzystał z danego portu na każdym z adresów IP danego komputera

 

Tworzenie kolejki na gnieździe

Wywołując funkcję listen(), przekształcamy gniazdo połączeniowe serwera utworzone za pomocą funkcji socket() w gniazdo nasłuchujące, w którym przychodzące od klientów połączenia będą akceptowane przez jądro systemu. Te następujące po sobie wywołania trzech funkcji : socket(), bind() i listen() to trzy czynności, które trzeba wykonać dla każdego serwera działającego zgodnie z protokołem TCP.

 

#include <sys/types.h>
#include <sys/socket.h>
int listen(int socket, int backlog)

 

Pierwszym argumentem funkcji jest deskryptor gniazda serwera, natomiast używając parametru backlog możemy ograniczyć maksymalną liczbę oczekujących na obsłużenie klientów, którzy mogą być przetrzymywani w kolejce. Próby połączenia przekraczające tę liczbę będą odrzucane przez jądro systemu. Dawniej w przykładowych program używano zawsze liczby pięć jako argumentu backlog, ponieważ była to największa jego wartość dopuszczana w systemie 4.2BSD. Było to dobre rozwiązanie w latach osiemdziesiątych, kiedy serwery obsługiwały tylko kilkaset połączeń dziennie. Dzisiaj kiedy obciążone serwery mogą obsługiwać nawet kilka milionów połączeń dziennie, tak mała wartość parametru backlog okazuje się zupełnie nieodpowiednia.

 

Funkcja listen() zwraca 0 w przypadku pomyślnego wykonania. W przypadku błędu funkcja zwraca wartość -1, a jego przyczynę będzie można ustalić poprzez zbadanie zmiennej errno.

 

Errno Opis
EBADF Argument socket nie jest poprawnym deskryptorem pliku.
ENOTSOCK Argument socket nie jest deskryptorem gniazda.
EOPNOTSUPP Gniazdo o deskryptorze socket nie zezwala na wykonanie tej operacji.

Tabela 4.8. Wybrane stałe opisujące rodzaj błędu dla funkcji listen().

 

Akceptowanie połączeń

Serwer używający gniazda połączeniowego wywołuje funkcję accept(), aby z wierzchu kolejki utworzonej przez funkcję listen() pobrała następne oczekujące połączenie. Jeżeli kolejka jest pusta, to proces serwera zostanie uśpiony.

#include <sys/types.h>
#include <sys/socket.h>
int accept(int socket, struct sockaddr *adress, socklen_t adress_len)

 

Pierwszym argumentem jest jak zwykle deskryptor gniazda serwera utworzonego przez funkcję socket(). Adres wywołującego klienta zostanie umieszczony w strukturze sockaddr, na którą wskazuje argument adress. Długość tej struktury znajduje się w parametrze adress_len. Jeżeli adres klienta będzie dłuższy niż ta wartość, zostanie on obcięty. Jeżeli podczas wykonywania funkcji accept() nie wystąpi błąd, to wartość przekazana przez funkcję będzie deskryptorem nowego gniazda, automatycznie utworzonego przez jądro systemu i mającego ten sam typ co gniazdo utworzone przez funkcję socket(). Omawiając funkcję accept() mamy do czynienia z dwoma gniazdami. Pierwszy argument funkcji jest deskryptorem pierwszego gniazda, zwanego gniazdem nasłuchującym (ang. listening socket), utworzonego przez funkcje socket() i następnie użytego w wywołaniach systemowych bind() oraz listen(). Natomiast wartością przekazaną przez funkcję accept() jest deskryptor gniazda zwanego gniazdem połączonym (ang. connected socket).

To rozróżnienie gniazd jest bardzo ważne. Serwer zazwyczaj tworzy tylko jedno gniazdo nasłuchujące, które istnieje przez cały czas jego działania. Wywołanie accept() powoduje utworzenie gniazda połączonego dla każdego połączenia z klientem, które zostało zaakceptowane. Celem takiego gniazda jest tylko komunikacja z danym klientem. Kiedy serwer zakończy obsługę tego klienta, wtedy połączone gniazdo zostanie zamknięte.

 

Żądanie nawiązania połączenia

Klient korzystający z gniazda połączeniowego używa funkcji connect() do ustanowienia połączenia z serwerem.

 

#include <sys/types.h>
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *adress, socklen_t adress_len)

 

Argument socket oznacza deskryptor gniazda, który przekazuje funkcja socket(). Drugi i trzeci argument to wskaźnik do gniazdowej struktury adresowej i jej rozmiar. W gniazdowej strukturze adresowej musi znajdować się adres serwera. Jeżeli komunikacja odbywa się w środowisku Internetu to musimy podać adres IP oraz numer portu serwera, jeżeli komunikacja odbywa się wewnątrz jednego systemu operacyjnego to podajemy nazwę pliku związaną z gniazdem serwera. Przed wywołaniem funkcji connect() klient nie musi wywoływać funkcji bind(), ponieważ w razie potrzeby jądro systemu samo wybierze dla niego port efemeryczny i źródłowy adres IP. Powrót z funkcji nastąpi tylko wtedy, kiedy będzie ustanowione połączenie z serwerem albo pojawi się błąd. Jeżeli połączenie klienta z serwerem nie może być od razu zrealizowane, funkcja connect() zablokuje się na jakiś nieokreślony czas. Po upłynięciu limitu czasu połączenie zostanie zaniechane i funkcja zwróci błąd.

 

Wartością zwracaną przez funkcję connect() jest zero w przypadku pomyślnego wykonania oraz -1 w przeciwnym przypadku. W razie błędu zmienna errno przyjmie dodatnią wartość całkowitą.

 

Errno Opis
EBADF W parametrze socket określono niepoprawny deskryptor.
EALREADY Gniazdo nawiązało już połączenie.
ECONNREFUSED Serwer odrzucił próbę połączenia.
EINTR Wywołanie connect zostało przerwane przez sygnał.

Tabela 4.9. Wybrane stałe opisujące rodzaj błędu dla funkcji connect().

 

Funkcje systemowe read() i write()

Działanie tych funkcji na deskryptorze gniazda jest analogicznie do ich działania na deskryptorach plików. Funkcje te służą do wymiany danych między dwoma gniazdami już połączonymi (transmisja połączeniowa).

 

#include <unistd.h>
int read(int sockfd, char *buf, int nbytes)
int write(int sockfd, char *buf, int nbytes)

 

Parametr sockfd określa deskryptor gniazda, utworzony przez funkcję socket() lub accept(). Argument buf jest wskaźnikiem do bufora, zawierającego dane do wysłania przez funkcje write(), lub pod którym zostaną umieszczone dane do odebrania przez funkcję read(). Parametr nbytes definiuje liczbę bajtów do wysłania przez funkcję write() lub maksymalną liczbę bajtów, którą można jednorazowo odebrać, wywołując funkcję read().

Funkcja zwraca liczbę przeczytanych lub zapisanych bajtów w przypadku sukcesu lub -1 w razie błędu. Liczba odebranych/wysłanych bajtów może być mniejsza niż wartość nbytes.

 

Funkcje systemowe sendto() i recvfrom()

Funkcje sendto() i recvfrom() są używane do komunikacji procesów klienta i serwera przy użyciu gniazd bezpołączeniowych. Klient nie ustanawia połączenia z serwerem. W zamian klient po prostu wysyła datagram do serwera, korzystając z funkcji sendto(), która wymaga podanie adresu docelowego (adresu serwera) jako argumentu wywołania. Podobnie też serwer nie akceptuje połączenia z klientem. W zamian serwer po prostu wywołuje funkcję recvfrom(), która czeka, aż nadejdą dane od jakiegoś klienta. Funkcja recvfrom() przekazuje adres protokołowy klienta razem z datagramem, więc serwer może wysłać odpowiedź do właściwego klienta. W sensie konstrukcyjnym, powyższe funkcje są podobne do funkcji read() i write(), ale mają trzy dodatkowe argumenty.

#include <sys/types.h>
#include <sys/socket.h>
int sendto(int sockfd,char *buff, int nbytes, int flags, struct sockaddr *to, int addrlen);
int recvfrom(int sockfd,char *buff, int nbytes, int flags, struct sockaddr *from, int *addrlen);

 

Pierwsze trzy argumenty sockfd, buff oraz nbytes są takie same jak pierwsze trzy argumenty funkcji read() oraz write() i oznaczają odpowiednio : deskryptor, wskaźnik do bufora, do którego się pobiera lub z którego się odsyła dane, oraz liczbę pobranych lub odesłanych bajtów. Argument flags określający sygnalizatory jest przeważnie równy 0. Argument to funkcji send() wskazuje na gniazdową strukturę adresową, zawierającą adres protokołowy (adres IP oraz numer portu dla gniazd internetowych lub nazwę pliku dla gniazd lokalnych), pod który mają być wysłane dane. Rozmiar tej struktury jest określony przez argument addrlen. Funkcja recvfrom() umieszcza adres protokołowy nadawcy datagramu w gniazdowej strukturze adresowej wskazywanej przez argument from. Liczba bajtów zapamiętanych w tej strukturze będzie również przekazana do programu, który wywołał funkcję recvfrom(), jako liczba całkowita wskazywana przez argument addrlen. Zauważmy, że ostatni argument przekazywany do funkcji sendto() jest liczbą całkowitą, podczas gdy ostatni argument funkcji recvfrom() jest wskaźnikiem do wartości całkowitej. Dzieje się tak, ponieważ ostatni argument funkcji recvfrom() służy również do przekazywania dodatkowego wyniku tej funkcji. Obie funkcje jako swoją wartość przekazują rozmiar danych lub odesłanych.

 

Zamykanie gniazda

Możemy zamknąć połączenie od strony klienta lub serwera, używając funkcji close().

#include <unistd.h>
int close(int socket)

 

Funkcja zawiera tylko jeden parametr będący deskryptorem zamykanego gniazda. Jeżeli zamykane gniazdo jest związane z protokołem zapewniającym niezawodne doręczenie danych (na przykład protokół TCP), to system operacyjny musi zadbać o to, aby wysłać wszystkie dane, które pozostają wewnątrz jądra, a muszą być przesłane. Zazwyczaj następuje natychmiastowy powrót z funkcji systemowej close() do systemu, ale jądro próbuje jeszcze wysłać wszystkie dane znajdujące się w kolejce. Funkcja zwróci wartość zero, jeżeli gniazdo zostanie zamknięte z powodzeniem.

Większe możliwości daje funkcja shutdown(), która pozwala zlikwidować połączenie całkowicie lub częściowo.

 

#include <sys/socket.h>
int shutdown(int socket, int how)

 Argument socket oznacza deskryptor gniazda, a parametr how określa sposób likwidacji połączenia. Dopuszczalne są następujące wartości argumentu how :

  • SHUT_RD – następuje zamknięcie części czytającej połączenia. Nie można odbierać z gniazda żadnym nowych danych, a wszystkie dane obecnie znajdujące się w buforze odbiorczym są odrzucane.
  • SHUT_WR – z danego gniazda nie można już wysyłać żadnych danych.
  • SHUT_RDWR – dane gniazdo nie może ani pobierać, ani wysyłać danych. Równoważne działanie można uzyskać, wywołując funkcję shutdown() dwukrotnie : najpierw z argumentem SHUT_RD, a potem z argumentem SHUT_WR.

 

Przykład 1

W poprzednich podrozdziałach omówiłem interfejs programowy gniazd, uwzględniając poszczególne funkcje, ich parametry i spełniane przez nie operacje na gniazdach. Teraz będę dalej omawiał ten interfejs, analizując przykładowe programy klienta i serwera komunikujące się przy pomocy gniazd. Dla zmniejszenia rozmiaru przykładowych programów i uwypuklenia odwołań do funkcji interfejsu gniazdowego, zdecydowałem się na implementację bardzo prostej usługi :

  • Proces klienta wysyła do procesu serwera znak 'a'.
  • Proces serwera odbiera od procesu klienta wysłany znak.
  • Proces serwera zmienia znak 'a' na znak 'b'.
  • Proces serwera odsyła procesowi klienta zmieniony znak.
  • Proces klienta otrzymuje zmieniony znak.

Wszystkie przedstawione poniżej programy będą wykonywać te same zadanie wykorzystując różne typy gniazd (strumieniowe, datagramowe) i działając w różnych środowiskach (system LINUX, Internet).

Pierwszy przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd połączeniowych. Środowiskiem gniazd jest system LINUX.

Opis powyższego programu (od strony serwera)

  • Za pomocą funkcji socket() tworzę lokalne (stała AF_UNIX) gniazdo strumieniowe (stała SOCK_STREAM), czyli gniazdo działające w obrębie jednego systemu operacyjnego. Funkcja ta przekazuje małą liczbę całkowitą, która służy jako deskryptor identyfikujący to gniazdo w następnych wywołaniach systemowych.
  • W gniazdowej strukturze adresowej dla dziedziny UNIX'a (struktura sockaddr_un określona identyfikatorem adres_serwera) umieszczam adres serwera, odpowiednio wypełniając pola wchodzące w skład tej struktury. W polu sun_family wpisuję wartość AF_UNIX, co spowoduje, że adres będzie formowany zgodnie z lokalnymi (UNIX'owymi) zasadami adresowania. Natomiast pole sun_path wypełniam nazwą ścieżki do pliku będącej adresem gniazda serwera (w tym przypadku wpisuję wartość gniazdo_serwera). Po poprawnym zdefiniowaniu struktury adresowej wiążę gniazdo utworzone za pomocą funkcji socket() z adresem zdefiniowanym w tej strukturze. Dokonuję tego używając funkcji systemowej bind().
  • Wywołując funkcje listen(), przekształcam gniazdo serwera w gniazdo nasłuchujące, w którym przychodzące od klientów połączenia będą akceptowane przez jądro systemu.
  • W pętli while umieszczam funkcję accept(), która powoduje, że proces serwera popada w stan uśpienia w oczekiwaniu na nadejście i zaakceptowanie połączenia z klientem.
  • Po nawiązaniu połączenia używam funkcji read() i write() do komunikacji z danym klientem.

 

Opis powyższego programu (od strony klienta)

  • Za pomocą funkcji socket() tworzę lokalne gniazdo strumieniowe.
  • W gniazdowej strukturze adresowej dla dziedziny UNIX'a (struktura sockaddr_un określona identyfikatorem adres_serwera) umieszczam adres gniazda, z którym proces klienta będzie chciał nawiązać połączenie. W tym przypadku jest to gniazdo serwera. Pole sun_family wypełniam wartością AF_UNIX, natomiast w polu sun_path wpisuję wartość gniazdo_serwera.
  • Za pomocą funkcji connect() próbuję ustanowić połączeniem z gniazdem serwera wyznaczonym przez gniazdową strukturę adresową określoną identyfikatorem adres_serwera.
  • Po nawiązaniu połączenia używam funkcji read() i write() do komunikacji z serwerem.

 

Przykład 2

Drugi przykład jest analogiczny do pierwszego, z tą różnicą, że środowiskiem gniazd jest teraz Internet.

 W tym przykładzie serwer i klient pracują w sieci. Ale nie należy zakładać, że gniazda sieciowe bywają użyteczne tylko w sieci złożonej z wielu komputerów z zainstalowanym systemem UNIX (LINUX). Należy pamiętać, że nawet pojedynczy komputer połączonym z Internetem (za pomocą modemu) może korzystać z gniazd, aby porozumiewać z innymi komputerami. A co więcej można używać sieciowych programów na izolowanych komputerze, ponieważ większość maszyn z zainstalowanym system UNIX (LINUX) skonfigurowana jest do korzystania z sieci zwrotnej (ang. loopback network), która zawiera tylko samą siebie. Sieć zwrotna składa się z jednego komputera, o nazwie localhost i adresie IP 127.0.0.1. Ponieważ komputer na którym pisałem i testowałem ten program jest komputerem izolowanym (nie podłączonym do żadnej sieci), wykorzystałem w tym przykładzie adres sieci zwrotnej.

Opis powyższego programu : od strony serwera.

  • Tworzę gniazdo domeny AF_INET za pomocą funkcji socket().
  • Wypełniam internetową strukturę gniazdową w następujący sposób :
  • adres_serwera.sin_family=AF_INET;
    adres_serwera.sin_addr.s_addr=inet_addr("127.0.0.1");
    adres_serwera.sin_port=9734;

    Gniazdo będzie związane z wybranym przeze mnie portem (w tym przypadku jest to port o numerze 9734). Określony adres wskazuje, którym komputerom wolno się łączyć z gniazdem. Ponieważ wybrałem adres sieci zwrotnej, komunikacja będzie ograniczona do lokalnego komputera. Jeżeli chciałbym zezwolić na komunikację ze zdalnymi klientami, musiałbym skorzystać ze specjalnej wartości INADDR_ANY, aby określić, że będę akceptował połączenia ze wszystkich interfejsów sieciowych, w które wyposażony jest mój komputer. Stała INADDR_ANY jest 32-bitową liczbą całkowitą i musi być umieszczona w polu sin_addr.s_addr struktury adresowej. Należy pamiętać, aby przetłumaczyć swoją wewnętrzną reprezentację liczb całkowitych na sieciowy porządek. Można wykorzystać omawianą już w trzecim rozdziale funkcję htonl().

    adres_serwera.sin_addr.s_addr=htonl(INADDR_ANY)

     

  • Następnie wiążę gniazdo utworzone za pomocą funkcji socket() z adresem zdefiniowanym w powyższej strukturze. Służy do tego funkcja bind().
  • Dalszy opis jest analogiczny do opisu serwera lokalnego z poprzedniego przykładu.

 

Opis powyższego programu : od strony klienta

  •  Tworzę gniazdo domeny AF_INET za pomocą funkcji socket().
  • W internetowej strukturze adresowej (struktura sockaddr_in określona identyfikatorem adres_serwera) podaję adres gniazda, z którym proces klienta będzie chciał nawiązać połączenie. Jest to serwer działający na hoście o adresie 127.0.0.1 i numerze portu 9734. Wykorzystuje tutaj funkcję inet_addr(), aby zamienić tekstową reprezentację adresu IP na formę pozwalającą na adresowanie gniazd.

 

  • adres_serwera.sin_addr.s_addr=inet_addr("127.0.0.1");

     

  • Dalszy opis jest analogiczny do opisu klienta lokalnego z poprzedniego przykładu.

 

Przykład 3

Trzeci przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd datagramowych. Środowiskiem jest system LINUX

Opis powyższego programu : od strony serwera.

  • Tworzone jest gniazdo datagramowe za pomocą funkcji socket().
  • Tworzony jest adres serwera (plik o nazwie adres_serwera) i wiązany z gniazdem za pomocą wywołania bind().
  • Proces serwera oczekuje na datagram przywołując funkcję recvfrom().
  • Proces serwera przetwarza otrzymany datagram.

Wynik jest przesyłany z powrotem do klienta za pomocą funkcji sendto(), używając adresu klienta (nadawcy), który serwer otrzymał po wykonaniu funkcji recvfrom().

 

Opis powyższego programu : od strony klienta

  •  Tworzone jest gniazdo datagramowe za pomocą funkcji socket().
  • Tworzony jest adres klienta (plik o nazwie : adres_klienta) i wiązany z gniazdem za pomocą wywołania bind().
  • Proces klienta wysyła datagram do serwera poprzez wywołanie funkcji sendto().
  • Proces klienta oczekuje na odpowiedź od serwera używając funkcji recvfrom()

 

Przykład 4

Ostatni przykład opisuje dwa procesy komunikujące się ze sobą za pomocą gniazd datagramowych. Teraz środowiskiem jest Internet.

 

Opis powyższego programu : od strony serwera.

 

  • Tworzone gniazdo datagramowe za pomocą funkcji socket().
  • Tworzony jest adres serwera (adres IP : 127.0.0.1 oraz numer portu : 9734) i wiązany z gniazdem za pomocą wywołania bind().
  • Proces serwera oczekuje na datagram przywołując funkcję recvfrom().
  • Proces serwera przetwarza otrzymany datagram.
  • Wynik jest przesyłany z powrotem do klienta za pomocą funkcji sendto(), używając adresu klienta (nadawcy), który serwer otrzymał po wykonaniu funkcji recvfrom().

 

Opis powyższego programu : od strony klienta.

  • Tworzone jest gniazdo datagramowe za pomocą funkcji socket().
  • Tworzony jest adres klienta (adres IP : 127.0.0.1 oraz numer portu : 9735) i wiązany z gniazdem za pomocą wywołania bind().
  • Proces klienta wysyła datagram do serwera poprzez wywołanie funkcji systemowej sendto() i oczekuje na odpowiedź od serwera, używając funkcji recvfrom().

 

Przykład 5 - serwer współbieżny

W poprzednich rozdziałach zajmowałem się implementacją systemów opartych na architekturze “klient-serwer”, w których serwer był iteracyjny, czyli mógł aktualnie obsługiwać tylko jednego klienta. Teraz omówię serwery współbieżne, czyli takie, które w danej chwili potrafią obsłużyć jednocześnie wielu klientów. Najpierw należy zdefiniować funkcję fork(). Wywołanie tej funkcji jest jedynym sposobem utworzenia nowego procesu. Funkcja ta tworzy kopię tego procesu, który wywołał funkcję fork(). Proces wywołujący tę funkcję nazywa się procesem macierzystym (ang. parent process), a nowy proces utworzony przez funkcję fork() nazywany jest procesem potomnym (ang. child process). Specyfika tej funkcji polega na tym, że związane są z nią dwa powroty. Najpierw powraca ona do procesu macierzystego, który ją wywołał i przekazuje mu identyfikator nowo utworzonego procesu potomnego. Następnie powraca do procesu potomnego i przekazuje mu wartość zero. Dlatego na podstawie wartości przekazanych przez funkcję fork() można odróżnić proces macierzysty od potomnego. W praktyce proces macierzysty wywołuje funkcję fork(), aby on mógł się zając jedną operacją, podczas gdy proces potomny (jego kopia) mógłby wykonywać inne zadanie. Ważny jest tutaj fakt, że wszystkie otwarte deskryptory (między innymi deskryptory gniazd), które były otwarte w procesie macierzystym przed wywołaniem funkcji fork(), będą po powrocie z niej dostępne również dla procesu potomnego. Właśnie ze względu na powyższą własność tej funkcji, zdecydowałem się wykorzystać ją do implementacji serwera współbieżnego.

 

Bibliografia

  •  Douglas E. Comer: Sieci komputerowe i intersieci, Wydawnictwa Naukowo-Techniczne, Warszawa 2000, 2001;
  • Warren W. Gay: Linux - gniazda w programowaniu w przykładach, Wydawnictwo “MIKOM”, Warszawa 2001;
  • Craig Hunt: TCP/IP – administracja sieci;
  • Michael K. Johnson, Erik W. Troan: Oprogramowanie użytkowe w systemie Linux;
  • Neil Matthew, Richard Stones: Linux – programowanie, Wydawnictwo RM, Warszawa 1999;
  • Mark Mitchell i in.: Programowanie dla zaawansowanych;
  • Jerzy Skurczyński: Programowanie współbieżne, Instytut Matematyki Uniwersytetu Gdańskiego, Gdańsk 2000;
  • W. Richard Stevens: Programowanie zastosowań sieciowych w systemie Unix, Wydawnictwa Naukowo-Techniczne, Warszawa 1995, 1996, 1998
  • W. Richard Stevens: Unix – programowanie usług sieciowych, Wydawnictwa Naukowo-Techniczne, Warszawa 2000