TCP
Komunikace pomocí TCP/IP je nejčastější internetová komunikace. V této kapitole se ji naučíte v jazyku C. Díky přípravě v předchozích dvou kapitolách to bude poměrně snadné.
Ve windows můžete najít knihovnu WinSock, která funguje velmi podobně jako linuxové sockety. Popisovat ji tu sice nebudu, ale pokud porozumíte linuxovým socketům, WinSocku porozumíte o mnoho snadněji. Viz třeba is.muni.cz/th/51885/fi_b/WinSock.pdf.
Úvod
V této kapitole naváži na kapitolu o Soketech. Použiji stejné kódy, jako v příkladu z této kapitoly. Všechny zdrojové soubory zůstanou stejné, jenom se změní implementace funkcí z hlavičkového souboru connection.h. Pro připomenutí jej znovu uvádím:
/* 40socket/connection.h */
#include <stdbool.h>
typedef struct {
int r; /* read descriptor */
int w; /* write descriptor */
} descriptors;
/* server */
bool startServer(void);
void stopServer(void);
descriptors waitForClientConnection(void);
void closeClientConnection(descriptors * fd);
void serverWork(descriptors * fd);
/* client */
descriptors connectClientToServer(void);
void disconnectClientFromServer(descriptors * fd);
void clientWork(descriptors * fd);
/*------------------------------------------------*/
Funkce serverWork()
a clientWork()
zůstávají také beze změny.
Mění se jen způsob připojení a odpojení.
IPv4 server
Tentokrát budu využívat sokety pro komunikaci pomocí TCP se starší, ale stále dominující IPv4 adresou.
Všechny výše deklarované funkce budou definované v souboru connection-inet.c.
Hlavní změnou oproti komunikaci skrze unixové sokety je použití jiné adresy. Namísto
sockaddr_un
se použije sockaddr_in
.
Struktura in_addr
obsahuje jen jednu položku:
struct in_addr {
in_addr_t s_addr; /* = inet_addr('127.0.0.1'); nebo htonl(INADDR_ANY); */
}
Při návrhu této struktury se počítalo s tím, že bude časem položka obsahovat více položek, ale k tomu nikdy nedošlo.
Pro IPV4 je sin_family vždy AF_INET. sin_port se nastavuje
na hodnotu portu pomocí funkce htons() (viz dále).
Položka s_addr je v případě serveru adresa, ze které bude server přijímat spojení.
Hodnota INADDR_ANY znamená, že server bude přijímat spojení
odkudkoliv.
Převody endianity
Port je 16 bitové číslo (4 bajty). Bajty tohoto portu musí být ve
správném pořadí. Nikdy nevíte, jakou
endianitu bude používat počítač na druhé straně sítě, proto musí existovat
nějaká shoda. A k tomu vám dopomůže funkce htons()
, která převádí
číslo s bajty v pořadí, jaké podporuje váš počítač (h = host) do (to)
pořadí používaného na netu (n = network). Konkrétně funkce htons()
převádí short int (s). Kromě této funkce na převod short int (16 bitového) máte
k dispozici i funkce na převod 32 bitového int.
Funkce ntohl()
a ntohs()
převádějí formát čísla přijatého z internetu na
formát přirozený pro váš počítač. Všiměte si datových typů, které konkrétně uvádějí počet bitů.
Problémy s endianitou se nevztahují jen na IP adresu a port, ale i na data, která posíláte.
Když posíláte nějaká binární data tak je na vás, abyste se postarali o to, aby se na každém počítači,
kde bude váš program přeložen, pracovalo se správnou endianitou a taky se správnou velikostí
datového typu. Když budete posílat typ int a na jednom počítači bude mít int 32 bajtů a na druhém 64,
nedopadne to dobře. Výše zmíněné funkce vám pomohou s endianitou, s velikostí bytů vám může
pomoci používání nestandardních datových typů, jako je uint32_t
. Já se o toto v příkladu
nestarám, ale v reálné aplikaci byste měli.
Parametry serveru
Teď už máte k dispozici všechny potřebné informace pro spuštění serveru. Začne se
funkcí socket(), která dostane jako argument AF_INET,
SOCK_STREAM, a 0. První argument znamená IPv4, druhý argument, že chci spolehlivé
spojení a třetí vybere defaultní protokol. Následuje
nastavení adresy, na které server poslouchá, pomocí bind()
a
spuštění poslouchání pomocí listen()
.
Je tu ale ještě něco navíc, co jsem si pro vás připravil. Nastavení parametrů socketu.
K tomu se používá funkce setsockopt()
.
Tato funkce se používá k nastavování všemožných vlastností socketu. Bohužel není úplně jednoduché zjistit, co a jak se s tím dá nastavit.
První použití setsockopt()
v příkladu nastavuje SO_REUSEADDR. To je vlastnost,
která říká, zda se může použitá adresa otevřít po uzavření ihned. Konkrétně tedy jde o to,
že když ukončíte server, tak port, na kterém poslouchal, zůstane několik (desítek) sekund
ve stavu WAIT. V tomto stavu port nemůže nikdo jiný použít. Je to taková malá ochrana před
tím, aby nemohl nějaký jiný server získat data přicházející od klienta k původně spuštěnému
serveru. Pomocí setsockopt()
se dá toto čekání zapnout nebo vypnout. Defaultně
je SO_REUSEADDR vypunté, což při vývoji a stestování zdržuje. Proto jej zapnu:
Prvním argumentem je socket. Druhým je tzv. level, který je při nastavování vlastností socketu vždy SOL_SOCKET. Třetí parametr říká, co chci v daném levelu nastavit, v tomto případě se tedy jedná o SO_REUSEADDR. Další parametr je odkaz na nastavovanou hodnotu. V tomto případě odkaz na int, který může být buď 1, nebo 0. A poslední parametr je velikost nastavované hodnoty.
Druhé použití setsockopt()
nastaví, co se má stát v případě, že zavíráte socket
pomocí close()
ve chvíli, kdy má socket ve vyrovnávací paměti ještě nějaké neodeslané
zprávy. Jde o vlastnost SO_LINGER, která se nastavuje pomocí struktury struct linger
.
V příkladu nastavím socket tak, aby se při zavírání zablokoval až na 30 sekund, během kterých se pokusí odeslat zprávy z vyrovnávací paměti.
Zdrojový kód serveru
A teď už se můžete podívat na první část souboru connection-socket.c, kde
jsou definované funkce startServer()
a stopServer()
.
- /*------------------------------------------------*/
- /* 40socket/connection-socket.c */
- #include <sys/types.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <arpa/inet.h>
- #include <stdbool.h>
- #include <string.h>
- #include <stdio.h>
- #include <errno.h>
- #include <unistd.h>
- #include "connection.h"
- #define SERVER_IPv4 "127.0.0.1"
- #define SERVER_PORT 50505
- {
- struct sockaddr_in server_address;
- int ret;
- server_fd = socket(AF_INET, SOCK_STREAM, 0);
- }
- ret =
- struct linger linger;
- linger.l_onoff = 1;
- linger.l_linger = 30;
- ret =
- server_address.sin_family = AF_INET;
- server_address.sin_addr.s_addr = htonl(INADDR_ANY);
- server_address.sin_port = htons(SERVER_PORT);
- }
- listen(server_fd, 5);
- }
- {
- close(server_fd);
- }
Na začátku je definována adresa SERVER_IPv4, na které bude klient hledat server (viz dále). 127.0.0.1 je tzv. loopback, tedy IP adresa, která ukazuje na počítač, na kterém je použita.
Server port jsem si vymyslel 50505. Pokud by vám na tomto portu náhodou už něco
běželo, můžete si ho změnit na nějaký jiný, dynamický port.
Seznam aplikací poslouchajících na nějakém portu vám dá program netstat
.
Port 50505 je port, na kterém bude server poslouchat a ke kterému se bude klient
připojovat.
Přijetí spojení
K přijetí spojení se používá funkce accept()
.
Tady se oproti kódu s unix socketovými soubory vlastně nic nezměnilo. Jen místo
struktury struct sockaddr_un
se ukládá adresa připojeného klienta do
struct sockaddr_in
. V příkladu s adresou klienta server nic nedělá,
ale můžete z ní zjistit IP adresu klienta a jeho port. K tomu se používají funkce,
které proberu až v další kapitole.
Vzpomeňte si, že client_fd je obousměrný deskriptor, který se používá pro čtení i zápis. Proto jej přiřazuji do fd.r i fd.w.
IPv4 client
Připojení klienta se také moc neliší od jeho verze
s unix socketovými soubory. Rozdíl je jen
v arugmentech funkce socket()
, které jsem popsal u vytváření serveru
a v použití adresy struct sockaddr_in
.
Při nastavování této adresy je zajímavé nastavení address.sin_addr.s_addr.
POSIX norma k tomu pužívá funkci inet_addr()
, která převede řetězcový
zápis IP adresy, jako je "127.0.0.1" na číslo (se správným „internetovým“ pořadím bajtů).
Tahle funkce má ale jeden drobný nedostatek. K oznámení chyby používá návratovou hodnotu -1. Jenomže (platná) IP adresa "255.255.255.255" se také převede na číslo -1.
Kvůli tomu se používá funkce inet_aton()
. Ta sice není v POSXI normě, ale je implementovaná
na skoro každém Linuxu a Unixu.
Na její použití se podívejte v příkladu:
- {
- struct sockaddr_in address;
- int sockfd;
- int ret;
- descriptors fd = { -1, -1 };
- sockfd = socket(AF_INET, SOCK_STREAM, 0);
- return fd;
- }
- address.sin_family = AF_INET;
- address.sin_port = htons(SERVER_PORT);
- /*address.sin_addr.s_addr = inet_addr(SERVER_IPv4); */
- return fd;
- }
- fd.r = fd.w = sockfd;
- return fd;
- }
- {
- }
- }
- /*------------------------------------------------*/
Zkuste si přeložit příklad bez nastavení SO_REUSEADDR. Pak spusťe server, alespoň jedno připojení klienta, server ukončete a znovu ho zkuste spustit.
Server:
Čekám na připojení od klienta ...
Klient:
Connect ...
Write ...
Read ...
3 + 8 = 11
Znovu server:
Čekám na připojení od klienta ...
Read ...
Sčítám 3 + 8.
Write ...
Čekám na připojení od klienta ...
^C
Closing server ...
$ ./inet_server
bind: Address already in use
$ netstat | egrep 50505
tcp 0 0 localhost:50505 localhost:38771 TIME_WAIT
Jak vidíte, port 50505 je ve stavu TIME_WAIT. Takže sem vám nelhal :-). Port se uvolní až po několika vteřinách. 38771 je číslo portu, který použil pro komunikaci klient. Toto číslo bylo klientovi vybráno „náhodně“ z volných portů, které měl systém k dispozici. Netstat jej pořád zobrazuje, ikdyž klient už svou práci ukončil a spojení uzavřel.
Obsluha více klientů
K serveru se často připojuje mnoho klientů naráz. Díky funkci listen()
zůstává jejich požadavek na připojení ve frontě, dokud není vyzvednut voláním
accept()
. To by mělo být voláno dostatečně rychle na to, aby se fronta
nezaplnila.
Pokud zpracování požadavku zabere nějakou dobu, obvykle je dobré zpracovávat komunikaci s klientem v jiném procesu, nebo vlákně.
Hlavní vlákno/proces serveru jenom volá v cyklu accept()
a vytváří pro obsluhu
nový proces či vlákno.
Forkování vypadá nějak takto:
{
client_fd = accept(server_fd, (struct sockaddr *)&client_address, &client_len);
pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
/* Nový proces obslouží klienta */
close(server_fd);
serverWork(client_fd);
exit(0);
}
else {
/* Hlavní proces client_fd nepotřebuje. */
close(client_fd);
}
}
Při forkování si musíte dávat pozor, aby vám nevznikali zombie. Jednou z možností, jak zombie nedovolit, je ignorování signálu SIGCHLD. Tento signál se generuje, když proces skončí a doručí se rodičovskému procesu. Když jej rodičovský proces ignoruje, ukončený proces nečeká na přečtení své návratové hodnoty a je ihned odstraňen (nestane se z něj zombie).
Bohužel, ne na všech unixech/linuxech je to tak jednoduché. Někde prostě musíte volat wait(), k čemuž můžete použít tuto funkci:
Funkce select()
Pokud je server velká aplikace, pak může být vytváření nového procesu forkováním poměrně zdlouhavé.
Použití vláken je lepší, ale zase klade větší nároky na synchronizaci zdrojů.
Určitým kompromisem může být použití funkce select()
.
Tato funkce testuje sady deskriptorů, zda je některý z nich připraven na čteni, zápis,
nebo obsahuje chybu. Toho se dá využít tak, že do sady deskriptorů přidáte deskriptor
socketu a pak každý deskriptor získaný voláním funkce accept()
.
Když vám funkce select()
vrátí deskriptor socketu že je připraven ke čtení,
můžete zavolat accept()
beze strachu ze zablokování čekáním na klienta.
Získaný deskriptor voláním funkce accept()
hned přidáte do sady
testovaných deskriptorů a můžete zase spustit select()
.
Když je připraven ke čtení deksriptor klienta, můžete jej hned obsloužit (načíst data).
Zase beze strachu ze zablokování čtením.
Přijímání nových spojení a jejich obsluha tak probíhá bez jakéhokoliv zbytečného blokování. Toto bude fungovat dobře ale jen v případě, že obsluha klientů je rychlá a požadavky od klientů chodí pomalu. Zkrátka, všechno se musí obsloužit dostatečně rychle na to, aby se stačili přijímat nový klienti.
Z toho důvodu si myslím, že je lepší investovat trochu námahy a používat vlákna,
než funkci select()
. Proto tady nenajdete její podrobnější
popis ani příklad použití.