Beej-ov vodič za mrežno programiranje

Prethodno

 

Dalje


6. Nešto naprednije tehnike

Nisu ovo stvarno napredne tehnike, ali idu malo dalje od dosadašnjeg nivoa učenja. Ustvari, ako si došao dovde, treba da znaš da se možeš smatrati da si završio osnove mrežnog programiranja. Svaka čast!

I evo nas idemo u svijet tajnih mogućnosti UNIX-ovih soketa. Izvoli!

6.1. Blokiranje

Blokiranje. Čuo si za to – ali šta je to? U UNIX-u, "blokirati" znači "spavati". Vjerovatno si primijetio da, kad pokreneš program listener, u prethodnom poglavlju, da on šeka dok ne stigne neki paket. Šta se desilo? Pozvao je funkciju recvfrom(), a podaci nisu dolazili, tako da je recvfrom() "blokirao" (zaspao) čekajući da podaci stignu.

Gomila funkcija blokira. accept() blokira. Sve recv() funkcije blokiraju. Funkcijama se može reći da ne blokiraju u takvim situacijama. Kad tek napraviš soket, i imaš njegov soket-deskriptor, jezgro mu kaže da blokira. Ako hoćeš da to ne radi, onda pozoveš funkciju fcntl():

    #include <unistd.h>
    #include <fcntl.h>
    .
    .
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(sockfd, F_SETFL, O_NONBLOCK);
    .
    . 

Kad podesiš soket da ne blokira, možeš bezbijedno napraviti petlju u čijem bi svakom obrtu pokušavao čitati iz tog soketa; pokušaš da čitaš, podataka nema – funkcija vraća -1 i podešava errno na EWOULDBLOCK.

Uopšteno govoreći, ovakvo učitavanje iz bilo kakvog fajl-deskriptora je loša ideja. Ako ostaviš program da ovako očekuje podatke, iscrpšće svu snagu procesora. Elegantniji način provjere da li ima podataka koji čekaju učitavanje je prikazano u sledećoj sekciji koja se tiče funkcije select().

6.2. select() – sinhrono (istovremeno, paralelno (prim. prev.) U/I multipleksiranje

Ova funkcija je malo ;udna, ali je jak korisna. Pazi ovu situaciju: imaš server za koji hoćeš da čeka pridolazeće pozive, i istovremeno čita podatke iz već spojenih.

Nema problema, kažeš, samo funkcija accept() i par funkcija recv(). Stani, štetočino! Šta ako ti poziv accept() blokira? Kako ćeš primati (recv()) podatke dok ovaj blokira? "E pa koristiću sokete kojima ću reći da ne blokiraju!" E pa nećeš! Ne želiš da oduzimaš snagu procesora. Šta sad?

select() ti daje moć da posmatraš više soketa odjednom. Reći će ti koji su spremni za čitanje, koji su spremni za pisanje, i kod kojih soketa je došlo do izuzetka, ako te to baš zanima.

Bez daljeg zadržavanja, evo sintakse funkcije select():

       #include <sys/time.h>
       #include <sys/types.h>
       #include <unistd.h>
       int select(int numfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout); 

Ova funkcija posmatra skupove fajl-deskritpra; konkretno readfds, writefds, i exceptfds. Ako želiš da znaš da li možeš čitati sa standardnog ulaza i nekog soketa, čiji je soket-deskriptor sockfd, dodaš fajl-deskriptor 0 i soket-deskriptor sockfd skupu readfds. Parametar numfds treba da bude podešen na vrijednost najvišeg fajl-deskriptora plus jedan. U ovom primjeru, to treba da bude sockfd+1, pošto je sockfd sigurno veći od vrijednosti fajl-deskriptora za standardni ulaz (0).

Kad se funkcija select() završi, readfds će se promijeniti da bi prikazao koji od fajl-deskriptora je spreman za čitanje. Možeš ih provjeriti makroom FD_ISSET(), objašnjenom ispod.

Dok ne krenemo dalje, reći đu malo o tome kako rukovati ovim skupovima. Svaki skup je promjenjiva tipa fd_set. Sledeći makroi rade nad promjenjivama ovog tipa:

·         FD_ZERO(fd_set *set) – isprazni skup

·         FD_SET(int fd, fd_set *set) – dodaje fd skupu

·         FD_CLR(int fd, fd_set *set) – briše fd iz skupa

·         FD_ISSET(int fd, fd_set *set) – provjerava da li je fd u skupu

I na kraju, šta je ova glupa struktura struct timeval? Pa, desi se da ne želiš yauivijek da čekaš da ti stignu podaci. Možda želiš svakih 96 sekundi da odštampaš na ekran poruku "Još čekam..." jer se još ništa ne dešava. Ova struktura ti omogućava da definišeš vremenski period. Ako dato vrijeme prođe, a funkcija select() još nije našla nikakve spremne fajl-deskriptore, onda će se završiti.

Struktura struct timeval ima sledeća polja:

    struct timeval {
        int tv_sec;     // sekunde
        int tv_usec;    // mikrosekunde
    }; 

Podesi tv_sec nba broj sekundi koje će se čekati da prođu, i tv_usec na broj mikrosekundi koje će se čekati. Da, mikrosekundi, ne milisekundi. Ima 1,000 mikrosekundi u milisekundi, a 1,000 milisekundi u mikrosekundi. Tako, ima 1,000,000 mikrosekundi u sekundi. Zašto se zove "usec"? "u" liči na grčko slovo μ (mi) koje koristimo za "mikro". Takođe, kad se funkcija select() završi, možda se struktura struct timeval timeout promijeni da prikaže koliko je bilo još vremena ostalo. Ovo zavisi od UNIX-a koji koristite.

Jao! Imamo časovnik rezolucije tako male da broji mikrosekunde! Ne računaj na njega. Standardni UNIX-ov časovnik broji po oko 100 milisekundi, pa ćeš vjerovatno čekati toliko bez obzira koliko malu vrijednost podesio u promjenjivoj tipa struct timeval.

Još nešto zanimljivo: Ako podesiš sve vrijednosti u strukturi struct timeval na 0, select() će odmah izaći, uspješno provjeravajući fajl-deskriptore u skupovima. Ako postaviš parametar funkcije select() timeout na NULL, onda ona neće mariti za vrijeme, i čekaće dok god nije nijedan fajl-deskriptor spreman. I na kraju, ako te nije briga za neki od skupova, samo proslijedi odgovarajući parametar kao NULL, pri pozivu funkcije select().

U sledećem kôdu se čeka 2.5 sekundi da se nešto pojavi na standardnom ulazu:

    /*
    ** select.c – primjer programa koji sadrži funkciju select()
    */
    #include <stdio.h>
    #include <sys/time.h>
    #include <sys/types.h>
    #include <unistd.h>
    #define STDIN 0  // fajl-deskriptor za standardni ulaz
    int main(void)
    {
        struct timeval tv;
        fd_set readfds;
        tv.tv_sec = 2;
        tv.tv_usec = 500000;
        FD_ZERO(&readfds);
        FD_SET(STDIN, &readfds);
        // nije te briga za writefds i exceptfds:
        select(STDIN+1, &readfds, NULL, NULL, &tv);
        if (FD_ISSET(STDIN, &readfds))
            printf("Taster je pritisnut!\n");
        else
            printf("Isteklo vrijeme.\n");
        return 0;
    } 

Ako imaš terminal koji učitava liniju po liniju, moraš pritisnuti taster RETURN ili će vrijeme isteći.

E sad, neki mogu da misle da je ovo super način da se čekaju podaci na datagram soketu – i u pravu su: mogao bi biti. Neki UNIX sistemi koriste select() na ovaj način, neki ne. Trebalo bi da pogledaš man strane prije nego išta probaš.

Neki UNIX sistemi ažuriraju vrijeme u strukturi struct timeval da prikažu koliko je još vremena bilo ostalo do kraja. Ali neki ne ažuriraju. Nemoj da se oslanjaš na pretpostavku ako želiš imati prenosive programe. (Koristi gettimeofday() ako ti treba da znaš koliko je vremena prošlo. Bruka, znam, ail tako je.)

Šta se desi ako se zatvori veza na soketu koji je u skupu za čekanje? Pa u tom slučaju se funkcija select() završava kao da je taj soket spreman za čitanje. I kad onda pokušaš da čitaš iz njega, pomoću funkcije recv(), recv() vraća 0. I tek tada znaš da je klijent zatvorio vezu.

I joč jedan komentar za select(): Ak imaš soket na kome čekaš (listen()), možeš provjeravati da li ima novih poziva na njemu smještajuđi njegov fajl-deskriptor u skup readfds.

I to je, prijatelju moj, kratak pregled svemoćne funkcije select().

Ali, evo i velikog primjera. Pročitaj ovaj primjer, a zatim njegov opis, ispod.

Ovaj program se ponaša kao jednostavan višekorisnički server za razgovor (chat). Pokreni ga u jednom prozoru, onda se spoji na njega pomoću telnet-a ("telnet hostname 9034") iz par drugih prozora. Kad odštampaš neki tekst u jednom primjerku telnet-a, trebalo bi da se pojavi i u ostalim.

    /*
    ** selectserver.c – mali višekorisnički chat-server
    */
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #define PORT 9034   // port na kom čekamo
    int main(void)
    {
        fd_set master;   // glavni skup fajl-deskriptora
        fd_set read_fds; // privremeni skup fajl-deskriptora za select()
        struct sockaddr_in myaddr;     // adresa servera
        struct sockaddr_in remoteaddr; // adresa klijenta
        int fdmax;        // maksimalni broj fajl-deskriptora
        int listener;     // soket koji čeka pozive
        int newfd;        // soket-deskriptor koji predstavlja novu vezu, upravo primljenu (accept())
        char buf[256];    // za skladištenje podataka koje šalje klijent
        int nbytes;
        int yes=1;        // za setsockopt() SO_REUSEADDR, ispod
        int addrlen;
        int i, j;
        FD_ZERO(&master);    // isprazni privremeni i glavni skup fajl-deskriptora
        FD_ZERO(&read_fds);
        // get the listener
        if ((listener = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
            perror("socket");
            exit(1);
        }
        // onemogući poruku "adresa već u upotrebi"
        if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes,
                                                            sizeof(int)) == -1) {
            perror("setsockopt");
            exit(1);
        }
        // bind
        myaddr.sin_family = AF_INET;
        myaddr.sin_addr.s_addr = INADDR_ANY;
        myaddr.sin_port = htons(PORT);
        memset(&(myaddr.sin_zero), '\0', 8);
        if (bind(listener, (struct sockaddr *)&myaddr, sizeof(myaddr)) == -1) {
            perror("bind");
            exit(1);
        }
        // listen
        if (listen(listener, 10) == -1) {
            perror("listen");
            exit(1);
        }
        // dodaj soket-deskritpr na kom se čeka – glavnom skupu
        FD_SET(listener, &master);
        // pamti koji je najveći fajl-deskriptor
        fdmax = listener; // dosad je to ovaj
        // main loop
        for(;;) {
            read_fds = master; // kopiraj ga
            if (select(fdmax+1, &read_fds, NULL, NULL, NULL) == -1) {
                perror("select");
                exit(1);
            }
            // idi kroz sve povezane sokete i vidi da li je ko poslao kakve podatke
            for(i = 0; i <= fdmax; i++) {
                if (FD_ISSET(i, &read_fds)) { // evo ih!!
                    if (i == listener) {
                        // obradi nove pozive
                        addrlen = sizeof(remoteaddr);
                        if ((newfd = accept(listener, (struct sockaddr *)&remoteaddr,
                                                                 &addrlen)) == -1) { 
                            perror("accept");
                        } else {
                            FD_SET(newfd, &master); // dodaj ga glavnom skupu
                            if (newfd > fdmax) {    // pamti najveći fajl-deskriptor
                                fdmax = newfd;
                            }
                            printf("selectserver: nova veza od %s na soketu", 
                                "%d\n", inet_ntoa(remoteaddr.sin_addr), newfd);
                        }
                    } else {
                        // obradi podatke od klijenta
                        if ((nbytes = recv(i, buf, sizeof(buf), 0)) <= 0) {
                            // došlo je do greške ili je klijent zatvorio vezu
                            if (nbytes == 0) {
                                // zatvorena veza
                                printf("selectserver: socket %d hung up\n", i);
                            } else {
                                perror("recv");
                            }
                            close(i); // ćao!
                            FD_CLR(i, &master); // izbriši iz glavnog skupa
                        } else {
                            // dobili smo podatke od klijenta
                            for(j = 0; j <= fdmax; j++) {
                                // šalji svima!...
                                if (FD_ISSET(j, &master)) {
                                    // ...osim nama i onome ko je poslao
                                    if (j != listener && j != i) {
                                        if (send(j, buf, nbytes, 0) == -1) {
                                            perror("send");
                                        }
                                    }
                                }
                            }
                        }
                    } // kako je ovo RUŽNO!!!
                }
            }
        }
        
        return 0;
    } 

Primijeti da imam dva skupa fajl-deskriptora: master i read_fds. Prvi, master, čuva sve soket-deskriptore koji su trenutno spojeni, kao i soket-deskriptor koji čeka nove pozive.

Razlog što imam master skup je što select() ustvari mijenja skup koji mu proslijediš, da prikaže koji su soketi spremni za čitanje. Pošto hoću da pratim sve veze ("konekcije") od jednog poziva funkcije of select() do drugog, moram da ih skladištim negdje gdje će biti bezbijedne. U zadnjem trenutku iskopiram master skup u read_fds, i onda s njim pozovem funkciju select().

Ali zar ovo ne znači da, svaki put kad ostvarim novu vezu, moram da je dodam u skup master? Da! I svaki put kad se neka veza zatvori, moram da je izbrišem iz skupa master? Da, to znači to.

Primijeti da provjeravam da li je soket listener spreman za čitanje. Kad je spreman, to znači da imam novi poziv u listi za čekanje, i onda pozivam funkciju accept() i dodajem tu novu vezu skupu master. Slično, kad je veza sa klijentom spremna za čitanje, a recv() vrati 0, to znači da je veza zatvorena, i moram da je uklonim iz skupa master.

Ako recv() tada vrati nešto različito od nule, to znači da se neki podaci primljeni. Tada ih preuzmem, i prođem kroz listu master da svim ostalim pošaljem te podatke.

I to je, prijatelju moj, krajnje-prost pregled svemoćne funkcije select().

6.3. Rukovanje parcijalnim send() funkcijama

Prisjeti se, još iz sekcije o komandi send(), iznad, kad sam rekao da send() može nekad poslati manje bajtova nego što si mu rekao? Znači, ti mu kažeš da pošalje 512 bajtova, a on pošalje 412. Šta se desilo sa ostalih 100 bajtova?

Pa, oni se još uvijek nalaze u tvom malom skladištu podataka (nekom nizu isl.) i čekaju da se pošalju. Zbog nekih stvari van tvoje kontrole, jezgro je odlučilo da ne pošalje sve podatke u jednom zalogaju, i sad je na tebi, prijatelju, da ponovo pošalješ ostatak koji nije već poslat

Mogao bi da napišeš ovakvu funkciju:

    #include <sys/types.h>
    #include <sys/socket.h>
    int sendall(int s, char *buf, int *len)
    {
        int total = 0;        // koliko bajtova si poslao
        int bytesleft = *len; // koliko nam je preostalo da pošaljemo
        int n;
        while(total < *len) {
            n = send(s, buf+total, bytesleft, 0);
            if (n == -1) { break; }
            total += n;
            bytesleft -= n;
        }
        *len = total; // broj bajtova koji je zapravi poslat
        return n==-1?-1:0; // vrati -1 ako nisi uspio, 0 ako jesi
    } 

U ovom primjeru, s je soket na koji hoćeš da pošalješ podatke, buf je skladište sa podacima, a len je pokazivač na cio broj koji predstavlja broj podataka koji se nalaze u tom skladištu.

Funkcija vraća -1 ako je došlo do greške (a errno se samo podešava funkcijom send().) Takođe, broj bajtova koji su poslati se vidi u len. Ovo će biti isti onaj broj koji si zatražio da se toliko podataka pošalje, osim ako je došlo do greške. sendall() će uraditi sve što može, iz sve snage, da pošalje podatke, ali ako dođe do greške, odmah izlazi.

Radi cjelovitosti, evo primjera poziva funkcije:

    char buf[10] = "Beej!";
    int len;
    len = strlen(buf);
    if (sendall(s, buf, &len) == -1) {
        perror("sendall");
        printf("Poslao sam samo %d bajtova jer je došlo do greške!\n", len);
    } 

Šta se dešava sa druge strane, tamo gdje se podaci primaju? Ako su paketi promjenjive dužine, kako primaoc zna kad se završava a kad započinje neki paket? Da, da, u pravom svijetu je mnogo bola. Trebao bi da enkapsuliraš (prisjeti se toga iz sekcije o enkapsulaciji?) Čitaj dalje ako te zanimaju detalji!

6.4. O enkapsulaciji podataka

Šta to uopšte znaši enkapsulirati podatke? Najjednostavnije rečeno, postaviš zaglavlje sa dužinom paketa ili identifikacijom, ili oboje.

Kako bi trebalo izgledati zaglavlje? Pa, to su samo neki binarni podaci koji predstavljaju što god treba da predstavljaju da bi imao završen projekat.

Uh! To je prilično neodređeno.

Dobro. Na primjer, recimo da imaš višekorisnički program za razgovaranje (chat) koje koristi SOCK_STREAM. Kad korisnik nešto otkuca, dva špdatka treba da budu poslata serveru: šta je otkucano, i ko je otkucao.

Zasad dobro? "U čemu je problem?", pitaš.

Problem je što poruke mogu biti različite dužine. Osoba pod imanom  "Toma" može reći, "Zdravo", a neka druga osoba "Pera" može reći, "Hej ljudi, šta ima?"

Eh, i sad ti šalješ klijentima sve podatke kako dolaze sa raznih strana. To što šalješ izgleda ovako:

    t o ma Z d r a v o P e r a H e j ,   l j u d i ,   š t a  i m a ?

I tako dalje. Kako klijent da zna kad koji paket počinje i završava se? Mogao bi, ako bi htio, učiniti sve poruke iste dužine i slati ih funkcijom sendall(), iznad. Ali to je suludo! Nećemo valjda slati (send()) 1024 bajta samo zato što je "Toma" rekao "Zdravo".

Tako mi enkapsuliramo podatke u majušno zaglavlje i zapakuhjemo strukturu. I server i klijent zanju kako otpakovati i kako zapakovati podatke. Nemoj sad da gledaš, upravo definišemo sopstveni protokol kako klijent i server komuniciraju!

U ovom slučaju, pretpostavimo da je ime korisnika fiksne dužine od, recimo, 8 karaktera i završava se znakom '\0'. A onda pretpostavimo da je poruka koju korisnik pošalje promjenjive dužine, najviše 128 karaktera. Pogledajmo primjer strukture koja bi predstavljala paket:

1.      len (1 bajt, neoznačeni) – ukupna dužina paketa, brojeći i ime korisnika i tekst poruke.

2.      name (8 bajtova) – ime korisnika, koje se završava znakom '\0'.

3.      chatdata (n-bajtova) – tekst poruke, ne duži od 128 bajtova. Dužina paketa se broji kao broj bajtova ovde plus osam.

Zašto sam izabrao ograničenja od 8 i 128 bajtova? Izvukao sam ih iz rukava, uz pretpostavku da su sasvim dovoljna. Možda će nekome biti preuska, pa može imati polja od 30 i 1000 znakova. Tvoj izbor.

Koristećo gornju definiciju paketa, prvi paket bi se sastojao od sledećih podataka (heksadecimalno i tekstualno):

      0B     54 6F 6D 61 00 00 00 00      5A 64 72 61 76 6F
   (dužina)  T  o  m  a  \0               Z  d  r  a  v  o

a drugi bi bio sličan:

      17     50 65 72 61    48 65 6A 20 6C 6A 75 64 69 2C 20 5B 74 61 20 69 6D 61 3F
   (dužina)  P  e  r  a     H  e  j     l  j  u  d  i  ,     š  t  a     i  m  a  ?

(Podatak o dužini je u mrežnom uređenju podataka, naravno. U ovom slučaju, u pitanju je samo jedan bajt pa nema veze, ali generalno govoreći svi cijeli brojevi u paketima treba da budu u mrežnom uređenju podataka.)

Kad šalješ podatke, treba da budeš siguran te ih šalješ funkcijom sendall(), iznad, pa da znaš da su svi podaci otišli, pa čak i ako to zahtijeva nekoliko poziva funkcije send().

Isto tako, kad primaš podatke, treba da uradiš nešto više posla. Da budeš siguran, treba da pretpostaviš da mogu stići djelimični podaci. Treba pozivati recv() sve dok ne stignu svi podaci.

Ali kako? Pa znamo koliko ukupno bajtova treba da primimo da bi paket bio cjelovit, jer je taj broj nalijepljen na prednji di paketa. Takođe znamo da je maksimaldna dužina paketa 1+8+128, ili 137 bajtova (jer smo tako sami definisali paket.)

Možeš da napraviš niz dovoljno veliki da primi dva paketa. To će ti biti radni niz i tu ćeš rekonstruisati pakete kako budu pristizali.

Svaki put kad primiš (recv()) podatke, stavljećeš ih u radni niz i provjeravati da li je stigao cio paket. Dakle, broj bajtova u nizu je veći ili jednak dužini napisanoj u zaglavlju (+1, jer dužina ne broji i onaj bajt u kom se sama dužina skladišti.) Ako je dužina niza manja od 1, paket nije cjelovit, očito. Za takvu situaciju moraš obraditi poseban slučaj, pošto se ne možeš osloniti na samo jedan bajt.

Kad je paket gotov, možeš s njim da radiš šta god hoćeš. Koristi ga, i izbaci ga iz radnog niza.

Uf! Miješa li ti se u glavi? E pa evo još jedan od dva udarca: Može se desiti da pročitaš preko kraja prvog paketa i komad sledećeg paketa u samo jednom pozivu funkcije recv(). To znači, imaš radni paket sa jednim cijelim paketom, i komadom sledećeg paketa! Do vraga. (Ali zato i jesi napravio paket dovoljno velik da sadrži dva paketa – za slučaj da se ovo desi!)

Pošto znaš dužinu prvog paketa – iz zaglavlja, a i pratio si koliko bajtova imaš u nizu, možeš izračunati koliko bajtova u nizu pripada drugom (nekompletnom) paketu. Kad si obradio prvi paket, možeš da ga ukloniš iz radnog niza i drugi pmjeriš na njegov početak. Tada možeš krenuti sa novim pozivom funkcije recv().

(Neki od čitalaca će primijetiti da pomjeranje komada drugog paketa oduzima vrijeme, i da bi bilo bolje napraviti neku cikličnu skladišnu strukturu. Diskusija o cikličnim strukturama je van dosega ovog dokumenta, nažalost. Ako si radoznao, čitaj o tome iz knjiga.)

Nisam ni rekao da je lako. Pa dobro, rekao sam. I jeste lako; treba ti samo dosta vježbe i uskoro će ti samo doći. Kunem se majkom Srbijom :)!


Prethodno

Glavna strana

Dalje

Pozadina klijent-servera

 

Više podataka o ovoj temi