Protokoły datagramowe #
W przeciwieństwie do protokołów strumieniowych jak TCP, w protokołach datagramowych przesyłamy paczki danych, czyli datagramy. Tak jak w przypadku protokołów strumieniowych będziemy zajmować się nimi w kontekście gniazd sieciowych (UDP) oraz lokalnych (UNIX).
UNIX #
Lokalne gniazdo datagramowe tworzymy podobnie jak strumieniowe, po prostu specyfikując protokół na SOCK_DGRAM:
socket(PF_UNIX, SOCK_DGRAM, 0)
Obowiązują wszystkie zasady związane z gniazdami lokalnymi (man 7 unix).
Warto zauważyć że, w przeciwieństwie do internetowych gniazd datagramowych (UDP), lokalne są niezawodne - as on most UNIX implementations, UNIX domain datagram sockets are always reliable and don't reorder datagrams.
Jest to więc dość wygodny sposób na przesyłanie wiadomości między procesami.
Nie mamy np. znanego z protokołów strumieniowych problemu sklejania się wiadomości.
UDP #
Sieciowe gniazdo datagramowe, czyli gniazdo UDP tworzymy analogicznie do TCP, wystarczy zmienić protokół na SOCK_DGRAM, np:
socket(AF_INET, SOCK_DGRAM, 0)
dla gniazda IPv4 (AF_INET6 dla IPv6).
Aby dowiedzieć się więcej o protokole UDP przeczytaj koniecznie man 7 udp oraz przejrzyj jeszcze raz wykład o gniazdach sieciowych.
W szczególności jest ważne, żeby rozumieć, że protokół UDP jest zawodny - wiadomości mogą zaginąć, przyjść w złej kolejności, przyjść zduplikowane etc.
Ponieważ jednak protokół UDP nie ma narzutu związanego z niezawodnością jak TCP, pozwala przesyłać wiadomości z mniejszym opóźnieniem.
Jest więc wykorzystywany w zastosowaniach, gdzie szybkie przesłanie danych jest ważniejsze niż niezawodność, jak np. sieciowe gry komputerowe.
Ponieważ protokół UDP jest bezpołączeniowy wspiera tzw. broadcast czyli wysyłanie jednej wiadomości na wiele adresów.
Komunikacja #
Protokoły datagramowe są bezpołączeniowe - nie wywołujemy connect w kliencie ani listen oraz accept po stronie serwera.
Zamiast tego mamy po prostu dwie funkcje do wysyłania i odbierania danych:
ssize_t recvfrom(int socket, void *restrict buffer, size_t length,
int flags, struct sockaddr *restrict address, socklen_t *restrict address_len);
ssize_t sendto(int socket, const void *message, size_t length,
int flags, const struct sockaddr *dest_addr, socklen_t dest_len);
Przeczytaj ich strony w manualu - man 3p recvfrom oraz man 3p sendto.
Jak widać funkcje te są analogiczne do recv oraz send, po prostu biorą po dwa dodatkowe parametry - adres oraz jego rozmiar.
Przy odbieraniu wiadomości używając recvfrom warto zwrócić uwagę, na specyficzny sposób jego działania.
Funkcja ta zwraca ilość przeczytanych bajtów, jednak zawsze odczytuje tylko jedną wiadomość (datagram) na raz.
Parametr length wskazuje na wielkość dostarczanego bufora buffer i oznacza maksymalny rozmiar wiadomości jakiej się spodziewamy.
Jeśli wiadomość jest krótsza, po prostu zostanie wczytana w całości, natomiast jeśli jest dłuższa - nadmiarowe bajty zostaną zignorowane i porzucone.
Dlatego ważne jest, żeby buffer miał odpowiedni rozmiar, a parametr length był ustawiony na rozmiar największej wiadomości jaką obsługujemy w programie, a nie takiej, jakiej akurat się spodziewamy.
W protokole UDP nie mamy gwarancji, że wiadomości przyjdą w dobrej kolejności.
W manualu man 2 recvfrom znajdziesz nieco dodatkowych informacji specyficznych dla systemu Linux. Istnieje np. funkcja recvmsg, która w niektórych przypadkach pozwala uzyskać lepszą wydajność, oraz np. zwrócić flagę sygnalizującą, że wiadomość została obcięta. Pamiętaj jednak, że zawartość tej strony manuala nie należy do standardu POSIX i tym samym jest nieprzenośna na inne systemy unixowe (np. MacOS, BSD). Na laboratoriach generalnie używamy funkcji ze standardu.
Funkcja sendto jest jeszcze prostsza w użyciu - albo wyśle cały datagram, albo zakończy się błędem i nie wyśle nic.
Zadanie #
Cel:
Napisz dwa programy pracujące w architekturze klient-serwer poprzez połączenie UDP. Zadanie programu klienta polega na wysłaniu pliku tekstowego podzielonego na datagramy. Zadanie programu serwera polega na odbieraniu plików przesyłanych przez socket i wypisywaniu ich na ekran (bez informacji o pliku z którego dane pochodzą).
Każdy wysłany do serwera pakiet musi być potwierdzony odpowiednim komunikatem zwrotnym, w razie braku takiego zwrotnego komunikatu (czekamy 0,5s) należy ponawiać wysłanie pakietu. W razie 5 kolejnych niepowodzeń program klienta powinien zakończyć działanie. Potwierdzenia też mogą zaginąć w sieci, ale program powinien sobie i z tym radzić - serwer nie może dwa razy wypisać tego samego fragmentu tekstu.
Wszystkie dodatkowe dane (wszystko poza tekstem z pliku) przesyłane między serwerem i klientem mają mieć postać liczb typu int32_t. Należy przyjąć, że rozmiar przesyłanych jednorazowo danych (tekst z pliku i dane sterujące) nie może przekroczyć 576B. Naraz serwer może odbierać maksymalnie 5 plików, szósta jednoczesna transmisja ma być zignorowana.
Program serwer jako parametr przyjmuje numer portu na którym będzie pracował, program klient przyjmuje jako parametry adres i port serwera oraz nazwę pliku.
Co student musi wiedzieć:
- man 7 udp
- man 3p sendto
- man 3p recvfrom
- man 3p recv
- man 3p send
rozwiązanie l8-1_server.c:
#include "l8_common.h"
#define BACKLOG 3
#define MAXBUF 576
#define MAXADDR 5
struct connections
{
int free;
int32_t chunkNo;
struct sockaddr_in addr;
};
int make_socket(int domain, int type)
{
int sock;
sock = socket(domain, type, 0);
if (sock < 0)
ERR("socket");
return sock;
}
int bind_inet_socket(uint16_t port, int type)
{
struct sockaddr_in addr;
int socketfd, t = 1;
socketfd = make_socket(PF_INET, type);
memset(&addr, 0, sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (setsockopt(socketfd, SOL_SOCKET, SO_REUSEADDR, &t, sizeof(t)))
ERR("setsockopt");
if (bind(socketfd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
ERR("bind");
if (SOCK_STREAM == type)
if (listen(socketfd, BACKLOG) < 0)
ERR("listen");
return socketfd;
}
int findIndex(struct sockaddr_in addr, struct connections con[MAXADDR])
{
int i, empty = -1, pos = -1;
for (i = 0; i < MAXADDR; i++)
{
if (con[i].free)
empty = i;
else if (0 == memcmp(&addr, &(con[i].addr), sizeof(struct sockaddr_in)))
{
pos = i;
break;
}
}
if (-1 == pos && empty != -1)
{
con[empty].free = 0;
con[empty].chunkNo = 0;
con[empty].addr = addr;
pos = empty;
}
return pos;
}
void doServer(int fd)
{
struct sockaddr_in addr;
struct connections con[MAXADDR];
char buf[MAXBUF + 1];
for (int i = 0; i < MAXADDR; i++)
con[i].free = 1;
while (1)
{
socklen_t size = sizeof(addr);
int receivedBytes;
if ((receivedBytes = TEMP_FAILURE_RETRY(recvfrom(fd, buf, MAXBUF, 0, (struct sockaddr *)&addr, &size))) < 0)
ERR("read:");
buf[receivedBytes] = 0;
int index = -1;
if ((index = findIndex(addr, con)) >= 0)
{
int32_t chunkNo = ntohl(*((int32_t *)buf));
if (chunkNo > con[index].chunkNo + 1)
{
continue;
}
else if (chunkNo == con[index].chunkNo + 1)
{
if (ntohl(*(((int32_t *)buf) + 1))) // last message bit is set
{
printf("Last Part %d\n%s\n", chunkNo, buf + 2 * sizeof(int32_t));
con[index].free = 1;
}
else
{
printf("Part %d\n%s\n", chunkNo, buf + 2 * sizeof(int32_t));
}
con[index].chunkNo++;
}
if (TEMP_FAILURE_RETRY(sendto(fd, buf, MAXBUF, 0, (struct sockaddr *)&addr, size)) < 0)
{
if (EPIPE == errno)
con[index].free = 1;
else
ERR("send:");
}
}
}
}
void usage(char *name) { fprintf(stderr, "USAGE: %s port\n", name); }
int main(int argc, char **argv)
{
int fd;
if (argc != 2)
{
usage(argv[0]);
return EXIT_FAILURE;
}
if (sethandler(SIG_IGN, SIGPIPE))
ERR("Seting SIGPIPE:");
fd = bind_inet_socket(atoi(argv[1]), SOCK_DGRAM);
doServer(fd);
if (TEMP_FAILURE_RETRY(close(fd)) < 0)
ERR("close");
fprintf(stderr, "Server has terminated.\n");
return EXIT_SUCCESS;
}
rozwiązanie l8-1_client.c:
#include "l8_common.h"
#define MAXBUF 576
volatile sig_atomic_t last_signal = 0;
void sigalrm_handler(int sig) { last_signal = sig; }
int make_socket()
{
int sock;
sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock < 0)
ERR("socket");
return sock;
}
void usage(char *name) { fprintf(stderr, "USAGE: %s domain port file \n", name); }
void sendAndConfirm(int fd, struct sockaddr_in addr, char *sendbuf, char *recvbuf, ssize_t size)
{
struct itimerval ts;
if (TEMP_FAILURE_RETRY(sendto(fd, sendbuf, size, 0, (struct sockaddr *)&addr, sizeof(addr))) < 0)
ERR("sendto:");
memset(&ts, 0, sizeof(struct itimerval));
ts.it_value.tv_usec = 500000;
setitimer(ITIMER_REAL, &ts, NULL);
last_signal = 0;
while (recv(fd, recvbuf, size, 0) < 0)
{
if (EINTR != errno)
ERR("recv:");
if (SIGALRM == last_signal)
break;
}
}
void doClient(int fd, struct sockaddr_in addr, int file)
{
char sendbuf[MAXBUF];
char recvbuf[MAXBUF];
int offset = 2 * sizeof(int32_t);
int32_t chunkNo = 0;
ssize_t size;
int counter;
do
{
memset(sendbuf, 0, MAXBUF);
memset(recvbuf, 0, MAXBUF);
if ((size = bulk_read(file, sendbuf + offset, MAXBUF - offset)) < 0)
ERR("read from file:");
*((int32_t *)sendbuf) = htonl(++chunkNo);
if (size < MAXBUF - offset)
{
memset(sendbuf + offset + size, 0, MAXBUF - offset - size);
*(((int32_t *)sendbuf) + 1) = htonl(1);
}
counter = 0;
do
{
counter++;
sendAndConfirm(fd, addr, sendbuf, recvbuf, MAXBUF);
} while (*((int32_t *)recvbuf) != (int32_t)htonl(chunkNo) && counter <= 5);
if (*((int32_t *)recvbuf) != (int32_t)htonl(chunkNo) && counter > 5)
break;
} while (size == MAXBUF - offset);
}
int main(int argc, char **argv)
{
int fd, file;
struct sockaddr_in addr;
if (argc != 4)
{
usage(argv[0]);
return EXIT_FAILURE;
}
if (sethandler(SIG_IGN, SIGPIPE))
ERR("Seting SIGPIPE:");
if (sethandler(sigalrm_handler, SIGALRM))
ERR("Seting SIGALRM:");
if ((file = TEMP_FAILURE_RETRY(open(argv[3], O_RDONLY))) < 0)
ERR("open:");
fd = make_socket();
addr = make_address(argv[1], argv[2]);
doClient(fd, addr, file);
if (TEMP_FAILURE_RETRY(close(fd)) < 0)
ERR("close");
if (TEMP_FAILURE_RETRY(close(file)) < 0)
ERR("close");
return EXIT_SUCCESS;
}
Zwróć uwagę, że w protokole UDP nie nawiązujemy połączenia, gniazda komunikują się ze sobą “ad hoc”. Nie ma gniazda nasłuchującego. Możliwe są straty, duplikaty i zmiany kolejności datagramów. W przykładzie występują kolejne przydatne do biblioteki wariacje funkcji: make_socket, bind_inet_socket, ponieważ mają te same nazwy co funkcje użyte w poprzednim zadaniu trzeba je inaczej ponazywać.
W tym zadaniu kontekst połączenia jest ważny i wymaga wysiłku aby go utrzymać. Co jest kontekstem połączenia?Odpowiedź:
Kontekstem jest liczba poprawnie przesłanych pakietów do danej chwili.
Jakie dane są przesyłane w pojedynczym datagramie? Czemu służą przesyłane metadane?Odpowiedź:
Pakiet składa się z (1) 32 bitowego numeru fragmentu, (2) 32 bitowej informacji czy to ostatni fragment oraz (3) z fragmentu pliku. Metadane służą do kontroli kontekstu (1) oraz do zakończenia transmisji (2).
Czemu i na jakich deskryptorach są używane funkcje bulk_read i bulk_write, czy nie powinno się rozszerzyć tego użycia na wszystkie deskryptory?Odpowiedź:
Funkcje są potrzebne do restartowania read i write w sytuacji przerwania w trakcie operacji IO ( w odróżnieniu od EINTR czyli przerwania przed operacją). Funkcje te są używane tylko do działań na plikach ponieważ przesyłanie datagramów jest atomowe i nie może być przerwane w trakcie. W tym programie występuje obsługa sygnałów ale tam gdzie się ich spodziewamy nie dokonujemy operacji na plikach. To zabezpieczenie jest nadmiarowe, dodane z myślą o przenoszeniu tego kodu do innych programów.
Czy może wystąpić sytuacja zerwania połączenia? Czy nie powinniśmy tego rozpoznawać?Odpowiedź:
Nie może, udp nie wytwarza połączenia, które mogłoby być zerwane.
Przeanalizuj jak działa findIndex w serwerze, zwłaszcza jak są porównywane adresy. W jakim byte orderze są? Jak zachowa się ta funkcja jeśli adres jest nowy?Odpowiedź:
Porównywane adresy są w byte order sieci, nie mamy potrzeby ich konwertować skoro jedynie je porównujemy a nie np. wyświetlamy. Funkcja dla nowego adresu zakłada nowy rekord (o ile ma jeszcze wolne miejsce w tablicy).
Jak sobie poradzimy z duplikatami datagramów?Odpowiedź:
Trzymamy tablicę stanu połączeń “struct connections”, wiemy, który fragment już wypisaliśmy i nie powtarzamy go.
Jak sobie poradzimy, z odwrotną kolejnością datagramów, czyli gdy otrzymamy fragment dalszy niż aktualnie oczekiwany?Odpowiedź:
Odwrócenie nie może się zdarzyć, bo klient nie prześle dalszych części dopóki nie potwierdzimy wcześniejszych.
Jak sobie poradzimy z ginącymi pakietami ?Odpowiedź:
Obsługuje to retransmisja po stronie klienta.
Co się stanie jeśli zaginie potwierdzenie pakietu a nie sam pakiet?Odpowiedź:
Klient uzna, że pakiet nie dotarł i prześle go ponownie. Serwer nie wyświetli pakietu drugi raz ale odeśle potwierdzenie po raz kolejny.
Co zawierają potwierdzenia?Odpowiedź:
Odsyłamy to co dostaliśmy, cały pakiet bez zmiany.
Jak jest zaimplementowany timeout na odpowiedź od serwera?Odpowiedź:
W funkcji sendAndConfirm najpierw ustawiamy alarm na 0.5 sekundy (setitimer) następnie program stara się odebrać potwierdzenie. Brak restartu funkcji recv makrem nie jest przypadkowy, po ew. przerwaniu musimy móc sprawdzić czy to nie był oczekiwany timeout.
Czemu konwertujemy tylko byte order numeru fragmentu i znacznika ostatniego elementu a reszta danych nie jest odwracana?Odpowiedź:
Tylko te dwie dane są przesyłane jako liczby binarne, reszta to tekst, który nie wymaga tego zabiegu.
Przeanalizuj jak działa limitowanie do 5 połączeń, zwróć uwagę na pole free w strukturze i znaczenie znacznika ostatniego fragmentu przesyłanego przez klienta.
Wielowątkowe serwery #
Na poprzednim laboratorium ćwiczyliśmy pisanie serwerów działających w oparciu o jeden wątek. Taka architektura ma wiele sensu gdy musimy oszczędzać zasoby a spodziewamy się niezbyt dużego obciążenia.
Często jednak jest tak, że nasz serwer musi obsłużyć bardzo dużą ilość zapytań. W takiej sytuacji, żeby uzyskać odpowiednią wydajność na nowoczesnym sprzęcie, konieczne jest wykorzystanie wielu wątków. Typową i naturalną architekturą jest jeden wątek odbierający wiadomości oraz przekazujący zadania do wątków roboczych, które je wykonują i wysyłają rezultaty do klientów. Z drugiej strony atomowość operacji na datagramach pozwala też na wiele wątków jednocześnie czekać na wiadomość na jednym gnieździe. Oczywiście często w takiej sytuacji jest z nim związany jakiś dodatkowy stan, dlatego i tak może być konieczna synchronizacja np. za pomocą mutexu.
W celu napisania wydajnych wielowątkowych programów na laboratorium warto powtórzyć sobie L4 (synchronizacja), w szczególności mutexy, semafory i zmienne warunkowe. Ponadto typowe struktury danych używane do tego typu zadań jak pula wątków czy bufor cykliczny. Jeśli słabo pamiętasz te zagadnienia przejrzyj tutoriale do L3 i L4 oraz slajdy i programy wykładowe z synchronizacji.
Przykładowe zadania #
Wykonaj przykładowe zadania. Podczas laboratorium będziesz miał więcej czasu oraz dostępny startowy kod, jeśli jednak wykonasz poniższe zadania w przewidzianym czasie, to znaczy, że jesteś dobrze przygotowany do zajęć.
- Zadanie 1 ~100
dniminut - Zadanie 2 z L7 ~120 minut na całość, etapy 4-5 dotyczą L8
- nie mamy więcej konkretnych zadań, ale do przećwiczenia tematu dobrze nadają się zadania z L5 oraz L7 - po prostu przepisz komunikację tak, żeby używała UDP.