Salta al contenuto
Note per Studenti System call POSIX, file descriptor e API delle socket

System call POSIX, file descriptor e API delle socket

In questa pagina 6

Questa nota descrive l'interfaccia fra un programma C e il sistema operativo che serve per scrivere client e server di rete: le system call POSIX, i file descriptor e l'API delle socket. Il modello a livelli e il ruolo del SAP sono in Modello ISO-OSI, data plane e control plane, architetture client-server e publish-subscribeIl modello ISO/OSI (7 livelli) e la pila TCP/IP (4 livelli) dividono la comunicazione in strati; ogni livello riceve una SDU dal livello superiore, aggiunge la sua intestazione e produce una PDU; il confine fra due livelli adiacenti e' il SAP, identificato da un indirizzo (EtherType per Ethernet, numero di protocollo per IP, numero di porta per TCP e UDP) e per un programma C il SAP del trasporto e' l'API delle socket; il data plane inoltra i pacchetti seguendo le tabelle, il control plane le costruisce con i protocolli di instradamento; client/server funziona come una chiamata di funzione (richiesta e risposta), peer-to-peer mette tutti i nodi alla pari, publish/subscribe funziona come un interrupt: un broker inoltra gli eventi agli iscritti, con disaccoppiamento nello spazio, nel tempo e nella sincronizzazione.Modello ISO-OSI, data plane e control plane, architetture client-server e publish-subscribe →; i programmi completi che usano queste chiamate sono in Client e server TCP in C - bind, listen, accept e processi concorrentiUn client TCP in C fa getaddrinfo, socket, connect, poi write e read; un server fa socket, setsockopt(SO_REUSEADDR), bind, listen e un ciclo di accept. Un server iterativo serve un client alla volta e un client lento blocca tutti; un server concorrente con fork crea un processo figlio per ogni connessione: il figlio chiude il socket di ascolto e serve la connessione, il padre chiude il socket di connessione e torna ad accept. I figli terminati diventano zombie finche' il padre non li raccoglie con waitpid, di solito in un gestore di SIGCHLD con WNOHANG in un ciclo; i descrittori non chiusi tengono viva la connessione (nessun FIN) o esauriscono EMFILE; uno stato CLOSE_WAIT che si accumula indica una close mancante. Alternative: thread, pre-fork, I/O multiplexing con poll o epoll.Client e server TCP in C - bind, listen, accept e processi concorrenti → e negli esercizi collegati in fondo.

System call e funzioni di libreria

Un programma gira in user space, senza accesso diretto all'hardware. Per fare qualunque cosa che lo richieda (leggere un file, creare un processo, mandare dati in rete) deve chiedere al kernel con una system call: l'esecuzione passa in modalità kernel, il kernel esegue l'operazione, e il controllo ritorna al programma con il risultato.

system call funzione di libreria
esempi open, read, write, fork, socket printf, fopen, malloc, strlen
chi la esegue il kernel codice utente della libreria C
costo alto (cambio di modalità) basso, se non chiama una system call
sezione del manuale 2 3

Molte funzioni di libreria chiamano system call: printf riempie un buffer in user space e prima o poi chiama write; fopen chiama open. Il manuale si consulta con man: man 2 socket per la system call, man 3 printf per la libreria (le sezioni sono 1 comandi, 2 system call, 3 funzioni di libreria, 4 file speciali, 5 file di configurazione, 7 varie, 8 amministrazione). Una pagina di manuale riporta nome, sintassi, descrizione, valori di ritorno, errori ed esempi; per le socket conviene leggere socket(2), connect(2), bind(2), listen(2), accept(2), getaddrinfo(3).

Il comando strace mostra le system call di un programma. Per un client HTTP si vede qualcosa come (esempio indicativo):

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = 0
write(3, "GET / HTTP/1.0\r\n\r\n", 18)  = 18
read(3, "HTTP/1.0 200 OK\r\n...", 4096)  = 1256
close(3)                                = 0

Valori di ritorno ed errno

Quasi tutte le system call restituiscono -1 in caso di errore e scrivono il motivo nella variabile errno (<errno.h>); perror("testo") stampa testo: descrizione, strerror(errno) restituisce la descrizione. Si controlla sempre il valore di ritorno. Alcuni errori non sono guasti:

errno significato cosa fare
EINTR la chiamata è stata interrotta da un segnale ripetere la chiamata
EAGAIN / EWOULDBLOCK niente dati ora (socket non bloccante) oppure timeout SO_RCVTIMEO scaduto riprovare più tardi o dichiarare timeout
ECONNREFUSED nessun processo in ascolto su quella porta (è arrivato un RST) errore di connessione
ECONNRESET il peer ha azzerato la connessione chiudere
EPIPE scrittura su una connessione già chiusa dall'altra parte chiudere
EADDRINUSE bind su una porta già usata cambiare porta, o impostare SO_REUSEADDR
EMFILE il processo ha troppi descrittori aperti chiudere quelli inutili

getaddrinfo fa eccezione: restituisce direttamente un codice d'errore, da tradurre con gai_strerror, e non usa errno.

File descriptor

Definizione (file descriptor). Un file descriptor è un piccolo intero non negativo che il kernel restituisce quando un processo apre una risorsa di I/O. Indicizza la tabella dei descrittori del processo, che punta alla risorsa aperta nel kernel. Vale per file normali, pipe, terminali e socket: tutti si leggono e si scrivono con le stesse read e write.

Ogni processo parte con tre descrittori già aperti:

numero nome costante di default
0 standard input STDIN_FILENO tastiera
1 standard output STDOUT_FILENO schermo
2 standard error STDERR_FILENO schermo

open e socket restituiscono sempre il numero libero più basso: il primo open in un processo appena avviato restituisce 3. Questa regola è alla base della redirezione: se si chiude il descrittore 1 e poi si apre un file, il file ottiene il 1 e printf scrive nel file. dup2(vecchio, nuovo) fa la stessa cosa in modo esplicito: chiude nuovo (se aperto) e lo fa puntare alla stessa risorsa di vecchio.

c
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);   /* 0644: rw- per il proprietario, r-- per gli altri */
dup2(fd, STDOUT_FILENO);      /* da ora ogni scrittura su stdout va in log.txt */
close(fd);                    /* la copia originale non serve piu' */

Altre regole che servono nei server:

read e write

c
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

read trasferisce al più count byte e restituisce quanti ne ha letti: > 0 byte letti, 0 fine del flusso (EOF: sul socket TCP, il peer ha chiuso), -1 errore. write restituisce i byte scritti, -1 in caso di errore. Su file regolari di solito si legge e si scrive tutto, ma su pipe e socket si ottengono spesso meno byte del richiesto: read ritorna appena c'è qualcosa; write può fermarsi se il buffer di invio è pieno o arriva un segnale.

Le conseguenze sono due regole per tutto il corso:

  1. Si scrive sempre in un ciclo finché i byte sono finiti:
c
static int write_all(int fd, const char *buf, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, buf, n);
        if (w < 0) {
            if (errno == EINTR) continue;      /* interrotta da un segnale: si riprova */
            return -1;
        }
        buf += w;                              /* si avanza di quanto scritto */
        n -= (size_t)w;
    }
    return 0;
}
  1. Un socket TCP è un flusso di byte senza confini: una write da 100 byte può essere ricevuta da due read da 60 e 40, e due write possono arrivare in una sola read. Il protocollo applicativo deve dire dove finisce un messaggio: una riga vuota dopo gli header HTTP, Content-Length, il chunk di lunghezza zero (HTTP 1.1 - connessioni persistenti, Content-Length e chunked transfer encodingHTTP/1.1 (oggi RFC 9110 e 9112) rende la connessione persistente di default (si chiude solo con "Connection: close"), rende obbligatorio l'header Host (virtual hosting) e introduce i nuovi metodi PUT, DELETE, OPTIONS, TRACE, Expect: 100-continue, richieste di intervalli (206) e Transfer-Encoding: chunked; con la connessione persistente il client deve sapere dove finisce ogni risposta: lunghezza del corpo nell'ordine HEAD/1xx/204/304 senza corpo, Transfer-Encoding chunked, Content-Length, altrimenti fino alla chiusura; il chunked divide il corpo in blocchi preceduti dalla lunghezza in esadecimale, termina con un chunk 0 e un trailer facoltativo, e si decodifica contando i byte dichiarati (non cercando CRLF).HTTP 1.1 - connessioni persistenti, Content-Length e chunked transfer encoding →). Per leggere un numero noto di byte:
c
static int read_exact(int fd, char *buf, size_t n)
{
    while (n > 0) {
        ssize_t r = read(fd, buf, n);
        if (r < 0 && errno == EINTR) continue;
        if (r <= 0) return -1;                 /* errore o EOF prima del previsto */
        buf += r;
        n -= (size_t)r;
    }
    return 0;
}

Per leggere una riga (HTTP è testo a righe) o si legge un byte alla volta, così non si legge mai oltre la fine della riga (semplice, lento, usato nelle slide del corso), oppure si usa un buffer di lettura che accumula i byte letti a blocchi e li consegna a righe, come nel codice degli esercizi (Esercizio - Server HTTP iterativo con GET, HEAD e codici di errore (sul modello della prova pratica)).

stdio e descrittori

Le funzioni di <stdio.h> lavorano su FILE *, che ha un buffer in user space. fdopen(fd, "r") crea un FILE * su un descrittore (anche un socket) e permette di usare fgets; ma il buffering di stdio causa trappole: dati letti ma non consumati restano nel buffer del FILE, le scritture non partono finché manca fflush, e fclose chiude anche il descrittore. Per i socket è più sicuro read/write e un buffer proprio.

Pipe

Una pipe è un canale unidirezionale nel kernel: pipe(fd) crea due descrittori, fd[0] da cui leggere e fd[1] su cui scrivere. Combinata con fork e dup2 collega due processi: è il meccanismo con cui un server collega standard input e standard output di un programma CGI (CGI e applicazioni web dinamicheLa Common Gateway Interface (CGI, RFC 3875) e' l'interfaccia fra un server web e un programma esterno che genera la risposta: per ogni richiesta il server fa fork ed exec del programma, gli passa i dati con le meta-variabili d'ambiente (REQUEST_METHOD, QUERY_STRING, CONTENT_LENGTH, CONTENT_TYPE, SCRIPT_NAME, PATH_INFO, SERVER_*, REMOTE_ADDR, HTTP_* per gli header) e con lo standard input (corpo della POST, CONTENT_LENGTH byte); il programma scrive sullo standard output header CGI (Content-Type obbligatorio, Status, Location), una riga vuota e il corpo, e il server li trasforma in una risposta HTTP completa; i punti delicati sono pipe e dup2, la chiusura delle estremita' inutilizzate, il timeout, i limiti di dimensione e la sicurezza (injection, path traversal, header Proxy, shellshock); il costo di un processo per richiesta ha portato a FastCGI, ai moduli del server e ai server applicativi.CGI e applicazioni web dinamiche →).

c
/* pipe_demo.c - equivalente di "ls | wc -l": due processi collegati da una pipe, con dup2 per redirigere stdin e stdout.
 * Compilare e provare:  gcc -Wall -Wextra -o pipe_demo pipe_demo.c && ./pipe_demo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    int fd[2];                                     /* fd[0]: estremita' di lettura, fd[1]: estremita' di scrittura */
    if (pipe(fd) < 0) {
        perror("pipe");
        return 1;
    }

    pid_t p1 = fork();
    if (p1 == 0) {                                 /* figlio 1: "ls", scrive nella pipe */
        dup2(fd[1], STDOUT_FILENO);                /* ora il descrittore 1 (stdout) e' la pipe */
        close(fd[0]);                              /* tutte le copie originali vanno chiuse */
        close(fd[1]);
        execlp("ls", "ls", (char *)NULL);
        perror("execlp ls");
        _exit(127);
    }

    pid_t p2 = fork();
    if (p2 == 0) {                                 /* figlio 2: "wc -l", legge dalla pipe */
        dup2(fd[0], STDIN_FILENO);                 /* il descrittore 0 (stdin) e' la pipe */
        close(fd[0]);
        close(fd[1]);
        execlp("wc", "wc", "-l", (char *)NULL);
        perror("execlp wc");
        _exit(127);
    }

    /* il padre NON usa la pipe: chiude le sue copie, altrimenti wc non vedrebbe mai la fine del file
     * (EOF arriva solo quando TUTTE le copie dell'estremita' di scrittura sono chiuse) */
    close(fd[0]);
    close(fd[1]);
    waitpid(p1, NULL, 0);
    waitpid(p2, NULL, 0);
    return 0;
}

La fine del file arriva a chi legge solo quando tutte le copie del descrittore di scrittura sono chiuse: dimenticare di chiudere fd[1] nel padre fa restare wc in attesa per sempre.

L'API delle socket

Definizione (socket). Un socket è un punto finale di comunicazione, identificato nel programma da un file descriptor. Un socket TCP o UDP è associato a un indirizzo locale (indirizzo IP + porta). Una connessione TCP è identificata dalla quaterna (IP locale, porta locale, IP remoto, porta remota).

I socket sono il SAP del livello di trasporto visto dall'applicazione (Modello ISO-OSI, data plane e control plane, architetture client-server e publish-subscribeIl modello ISO/OSI (7 livelli) e la pila TCP/IP (4 livelli) dividono la comunicazione in strati; ogni livello riceve una SDU dal livello superiore, aggiunge la sua intestazione e produce una PDU; il confine fra due livelli adiacenti e' il SAP, identificato da un indirizzo (EtherType per Ethernet, numero di protocollo per IP, numero di porta per TCP e UDP) e per un programma C il SAP del trasporto e' l'API delle socket; il data plane inoltra i pacchetti seguendo le tabelle, il control plane le costruisce con i protocolli di instradamento; client/server funziona come una chiamata di funzione (richiesta e risposta), peer-to-peer mette tutti i nodi alla pari, publish/subscribe funziona come un interrupt: un broker inoltra gli eventi agli iscritti, con disaccoppiamento nello spazio, nel tempo e nella sincronizzazione.Modello ISO-OSI, data plane e control plane, architetture client-server e publish-subscribe →).

c
int socket(int domain, int type, int protocol);
parametro valori significato
domain AF_INET, AF_INET6, AF_UNIX famiglia di indirizzi: IPv4, IPv6, locale
type SOCK_STREAM, SOCK_DGRAM flusso affidabile con connessione (TCP) / datagrammi senza connessione (UDP)
protocol di solito 0 il sistema sceglie il protocollo associato al tipo

Socket con connessione e senza connessione

SOCK_STREAM (TCP) SOCK_DGRAM (UDP)
connessione sì: handshake a tre vie, poi uno stream no: ogni datagramma è indipendente
affidabilità e ordine garantiti (ritrasmissioni, numeri di sequenza) no: perdita, duplicazione e riordino possibili
unità dei dati flusso di byte, senza confini datagramma: una sendto = una recvfrom
chiamate server socket, bind, listen, accept, read/write socket, bind, recvfrom/sendto
chiamate client socket, connect, read/write socket, sendto/recvfrom
usi HTTP, SMTP, SSH DNS, streaming, giochi, QUIC

Dettagli del trasporto in TCP - connessione, affidabilità e controllo di flussoTCP (Transmission Control Protocol) è il protocollo di trasporto con connessione e affidabile: trasforma il servizio senza connessione e inaffidabile di IP in un flusso di byte ordinato, senza errori né duplicati. La connessione si apre con l'handshake a tre vie (SYN, SYN+ACK, ACK) e si chiude con tre o quattro segmenti (FIN). I byte sono numerati: il numero di sequenza è quello del primo byte del segmento, il numero di ACK (cumulativo) è il prossimo byte atteso. Il mittente può inviare $\min(\text{rwnd},\text{cwnd})$ byte non ancora confermati; rwnd (finestra del ricevitore, in un campo di 16 bit) è il controllo di flusso. L'errore si gestisce con checksum, ACK, timeout di ritrasmissione (RTO) e ritrasmissione rapida dopo tre ACK duplicati. Per usare tutto il canale la finestra deve valere almeno il prodotto banda-ritardo (BDP); il throughput massimo è $\text{MSS}\cdot W_{\max}/\text{RTT}$.TCP - connessione, affidabilità e controllo di flusso → e Protocollo UDPUDP (User Datagram Protocol) è il protocollo di trasporto senza connessione e inaffidabile: rispetto a IP aggiunge soltanto la comunicazione processo-processo (numeri di porta) e un controllo d'errore facoltativo. L'intestazione è di soli 8 byte (porta sorgente, porta destinazione, lunghezza, checksum). Il checksum copre pseudo-intestazione (indirizzi IP, protocollo 17, lunghezza), intestazione e dati, ed è il complemento a uno della somma a 16 bit; se vale 0 significa "non calcolato", e un risultato 0 si trasmette come 0xFFFF. UDP non ha connessione, numeri di sequenza, controllo di flusso, di errore né di congestione: si sceglie per i messaggi brevi (DNS, DHCP, RIP, SNMP) e per le applicazioni in tempo reale, dove conta non aggiungere ritardo.Protocollo UDP →.

Con UDP sendto(fd, buf, n, 0, indirizzo, lunghezza) manda un datagramma, e recvfrom(fd, buf, cap, 0, &mittente, &lunghezza) ne riceve uno: se cap è minore del datagramma, i byte in più vanno persi (il massimo è 65507 byte di dati). L'indirizzo del mittente arriva con ogni datagramma, perché non c'è connessione. Poiché UDP non ritrasmette e recvfrom altrimenti aspetterebbe per sempre un datagramma perso, il client imposta un timeout con SO_RCVTIMEO (Esercizio - Echo TCP e UDP, client e server (sul modello della prova pratica)).

Le porte sono numeri a 16 bit: 0-1023 sono quelle note (HTTP 80, HTTPS 443, DNS 53; in Linux servono privilegi per fare bind), 1024-49151 sono registrate, le restanti sono effimere e il kernel le assegna ai client a ogni connect (Livello di trasporto - porte e multiplexingIl livello di trasporto (transport layer) offre la comunicazione logica end-to-end tra processi applicativi di host diversi, ed è realizzato solo negli host finali, non nei router. Il livello di rete consegna al computer giusto (indirizzo IP), il trasporto consegna al processo giusto (numero di porta di 16 bit, 0-65535). Una porta più un indirizzo IP formano un socket; la quaterna (IP sorgente, porta sorgente, IP destinazione, porta destinazione) identifica una connessione. I servizi sono: comunicazione processo-processo, indirizzamento, incapsulamento/decapsulamento, multiplexing/demultiplexing e, se il protocollo è affidabile, controllo di errore, di flusso e di congestione. I protocolli sono UDP (senza connessione, inaffidabile), TCP (con connessione, affidabile) e SCTP (combina i due).Livello di trasporto - porte e multiplexing →).

Indirizzi: sockaddr, sockaddr_in, sockaddr_in6

Le funzioni delle socket accettano un puntatore a struct sockaddr, una struttura generica che serve solo come tipo comune. Gli indirizzi veri hanno strutture specifiche, di cui si passa l'indirizzo con un cast:

struttura famiglia campi principali dimensione
struct sockaddr_in IPv4 sin_family = AF_INET, sin_port (16 bit, rete), sin_addr (32 bit, rete), sin_zero[8] di riempimento 16 byte
struct sockaddr_in6 IPv6 sin6_family, sin6_port, sin6_flowinfo, sin6_addr (128 bit), sin6_scope_id 28 byte
struct sockaddr_storage qualunque spazio abbastanza grande per ogni famiglia 128 byte

La porta e l'indirizzo vanno in network byte order (Richiami di C per la programmazione di rete - memoria, puntatori, struct ed endiannessIn C un programma di rete maneggia byte, non oggetti: un processo ha codice, dati statici, heap e stack; i tipi hanno dimensioni fisse solo se si usano <stdint.h> (uint8_t, uint16_t, uint32_t); i dati che arrivano da un socket sono un buffer di byte con una lunghezza, NON una stringa C terminata da '\0'; i puntatori e l'aritmetica dei puntatori (buf + totale) permettono di riempire un buffer a pezzi; una struct puo' contenere byte di riempimento (padding) per l'allineamento, quindi non si spedisce con write(&s, sizeof s); sulla rete i numeri a piu' byte viaggiano in big endian (network byte order) e si convertono con htons, htonl, ntohs, ntohl, oppure si serializzano a mano con shift e maschere.Richiami di C per la programmazione di rete - memoria, puntatori, struct ed endianness →).

c
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);                 /* azzera anche sin_zero */
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);                   /* porta in ordine di rete */
inet_pton(AF_INET, "192.0.2.10", &addr.sin_addr);   /* testo -> 4 byte in ordine di rete */
/* oppure, per un server: addr.sin_addr.s_addr = htonl(INADDR_ANY);  tutte le interfacce */
connect(fd, (struct sockaddr *)&addr, sizeof addr);

inet_pton converte il testo in binario e inet_ntop fa l'inverso (valgono sia per AF_INET sia per AF_INET6); la vecchia inet_addr converte solo IPv4 e restituisce INADDR_NONE in caso di errore, che coincide con un indirizzo valido (255.255.255.255). Gli indirizzi speciali sono INADDR_ANY (0.0.0.0, tutte le interfacce, per bind) e INADDR_LOOPBACK (127.0.0.1).

getaddrinfo: risolvere nomi e porte

Indirizzi scritti a mano sono scomodi e legati a IPv4. getaddrinfo accetta un nome (o un indirizzo testuale) e un servizio ("80" o "http") e restituisce una lista di indirizzi pronti per connect o bind, per IPv4 e IPv6, con il DNS (Livello applicazione - DNSIl DNS (Domain Name System) traduce i nomi (www.amazon.com) negli indirizzi IP, perché le persone preferiscono i nomi e i protocolli TCP/IP usano gli indirizzi. È un database distribuito e gerarchico: albero rovesciato con radice, domini di primo livello e sottodomini (al più 128 livelli); le informazioni sono su tanti server (13 server radice) e i nuovi domini si registrano presso un registrar accreditato ICANN. Ogni ISP ha un DNS locale, il cui indirizzo l'host riceve con DHCP: l'host (resolver) gli manda la richiesta, di solito su UDP, e il DNS locale interroga radice, dominio di primo livello e server dell'organizzazione. Ogni server che impara un'associazione la tiene in cache, marcando la risposta non autoritativa, e la scarta dopo il TTL. Record (nome, tipo, valore, classe, TTL). Con il NAT il DNS deve restituire l'indirizzo pubblico. Quattro attacchi: macchina compromessa, risposta falsa all'host, avvelenamento della cache del DNS locale, server DNS malevolo.Livello applicazione - DNS →). Le informazioni di hints restringono la ricerca: famiglia, tipo di socket, e AI_PASSIVE per ottenere l'indirizzo "jolly" di un server (con node = NULL).

c
/* addr_demo.c - mostra gli indirizzi che getaddrinfo ricava da un nome (IPv4 e IPv6), con la porta in ordine host.
 * Compilare e provare:  gcc -Wall -Wextra -o addr_demo addr_demo.c && ./addr_demo www.example.com 80
 */
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "uso: %s host servizio_o_porta\n", argv[0]);
        return 1;
    }
    struct addrinfo hints, *res, *p;
    memset(&hints, 0, sizeof hints);               /* ai_flags = 0, campi non usati a zero */
    hints.ai_family = AF_UNSPEC;                   /* sia IPv4 sia IPv6 */
    hints.ai_socktype = SOCK_STREAM;               /* solo TCP: senza questo comparirebbero tre voci per indirizzo */
    int rc = getaddrinfo(argv[1], argv[2], &hints, &res);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));   /* gli errori NON sono in errno */
        return 1;
    }
    for (p = res; p != NULL; p = p->ai_next) {     /* lista concatenata: si prova un indirizzo dopo l'altro */
        char ip[INET6_ADDRSTRLEN];
        if (p->ai_family == AF_INET) {
            struct sockaddr_in *a = (struct sockaddr_in *)p->ai_addr;
            inet_ntop(AF_INET, &a->sin_addr, ip, sizeof ip);   /* binario (network order) -> testo */
            printf("IPv4  %-15s porta %d  (sockaddr_in: %u byte)\n", ip, ntohs(a->sin_port), (unsigned)p->ai_addrlen);
        } else if (p->ai_family == AF_INET6) {
            struct sockaddr_in6 *a = (struct sockaddr_in6 *)p->ai_addr;
            inet_ntop(AF_INET6, &a->sin6_addr, ip, sizeof ip);
            printf("IPv6  %-39s porta %d  (sockaddr_in6: %u byte)\n", ip, ntohs(a->sin6_port), (unsigned)p->ai_addrlen);
        }
    }
    freeaddrinfo(res);                             /* la lista e' allocata dalla libreria: va liberata */
    return 0;
}

Lo schema standard di un client è: getaddrinfo, poi per ogni elemento della lista socket + connect; il primo che riesce vince; freeaddrinfo libera la lista. Si trova negli esercizi (Esercizio - Client HTTP 0.9 e 1.0 che scarica una pagina (slide HTTP 0.9 e 1.0)). Un server si scrive con hints.ai_flags = AI_PASSIVE, getaddrinfo(NULL, "8080", ...) e poi bind. gethostbyname è la vecchia interfaccia (solo IPv4, non rientrante): non si usa più.

bind, listen, accept, connect

c
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
chiamata chi cosa fa
bind server (e client UDP) assegna al socket l'indirizzo locale: IP e porta. Un client TCP di solito non la chiama: il kernel sceglie una porta effimera a connect
listen server TCP rende il socket passivo: il kernel accetta le connessioni in arrivo (completa gli handshake) e le mette in una coda lunga al più backlog
accept server TCP blocca finché c'è una connessione completata in coda; restituisce un nuovo descrittore per quel client e (se richiesto) ne scrive l'indirizzo. Il socket in ascolto resta aperto e continua ad accettare
connect client TCP avvia l'handshake a tre vie e ritorna quando la connessione è stabilita (o con un errore: ECONNREFUSED se la porta è chiusa, ETIMEDOUT se il server non risponde)

Quindi un server ha due tipi di socket: uno di ascolto, uno solo, e uno di connessione per ogni client. addrlen di accept è un parametro in ingresso e in uscita: si inizializza con la dimensione del buffer e il kernel ci scrive la dimensione reale dell'indirizzo.

L'ordine delle chiamate:

        SERVER TCP                                CLIENT TCP
  socket()                                  socket()
  bind(porta)
  listen()
  accept()  <-------- handshake -------->   connect()
     |  (nuovo fd)                              |
  read() <------------ richiesta ----------- write()
  write() ------------ risposta ------------> read()
  close(fd connessione)                      close()

Opzioni, chiusura e segnali

  • SO_REUSEADDR (setsockopt a livello SOL_SOCKET): permette di rifare bind su una porta con connessioni ancora in TIME_WAIT. Senza, riavviare un server subito dopo averlo fermato dà EADDRINUSE ("Address already in use").
  • SO_RCVTIMEO: timeout della read; scaduto, la read fallisce con EAGAIN. Serve per chiudere le connessioni inattive.
  • close e shutdown: close(fd) rilascia il descrittore e, quando l'ultima copia è chiusa, avvia la chiusura della connessione (FIN). shutdown(fd, SHUT_WR) chiude solo la direzione di scrittura (invia FIN) ma permette ancora di leggere: serve per dire "ho finito di mandare" e aspettare ancora la risposta (half-close).
  • SIGPIPE: scrivere su un socket chiuso dall'altra parte fa arrivare il segnale SIGPIPE, che per default termina il processo. Un server non deve morire per un client che sparisce: si chiama signal(SIGPIPE, SIG_IGN) e si controlla EPIPE dalla write.
  • getsockname / getpeername: indirizzo locale e remoto di un socket (utili per sapere la porta effimera scelta dal kernel).

Più connessioni insieme: poll

Una read bloccante su un socket non permette di ascoltare altro. Per gestire più descrittori in un processo si aspetta con poll (o select) che uno sia pronto:

c
struct pollfd pf[2] = { {.fd = a, .events = POLLIN}, {.fd = b, .events = POLLIN} };
int n = poll(pf, 2, 60000);                    /* attende al massimo 60000 ms; n = quanti descrittori sono pronti, 0 = timeout */
if (pf[0].revents & POLLIN) { /* c'e' qualcosa da leggere su a: read non bloccherà */ }

È la base di un tunnel CONNECT (Esercizio - Proxy con tunnel CONNECT (sul modello della prova pratica)). L'alternativa concorrente più semplice, un processo per connessione, è in Client e server TCP in C - bind, listen, accept e processi concorrentiUn client TCP in C fa getaddrinfo, socket, connect, poi write e read; un server fa socket, setsockopt(SO_REUSEADDR), bind, listen e un ciclo di accept. Un server iterativo serve un client alla volta e un client lento blocca tutti; un server concorrente con fork crea un processo figlio per ogni connessione: il figlio chiude il socket di ascolto e serve la connessione, il padre chiude il socket di connessione e torna ad accept. I figli terminati diventano zombie finche' il padre non li raccoglie con waitpid, di solito in un gestore di SIGCHLD con WNOHANG in un ciclo; i descrittori non chiusi tengono viva la connessione (nessun FIN) o esauriscono EMFILE; uno stato CLOSE_WAIT che si accumula indica una close mancante. Alternative: thread, pre-fork, I/O multiplexing con poll o epoll.Client e server TCP in C - bind, listen, accept e processi concorrenti →.

Esercizi collegati

Errori tipici

  • Non controllare il valore di ritorno di read, write, connect, bind, accept.
  • Credere che una write sia una read dall'altra parte: TCP non conserva i confini dei messaggi.
  • Scrivere con una sola write senza ciclo, ignorando una scrittura parziale.
  • Dimenticare htons sulla porta o memset sulla sockaddr_in.
  • Non impostare SO_REUSEADDR: bind fallisce subito dopo un riavvio.
  • Dimenticare close sui descrittori dei client (esaurimento di EMFILE) o su quelli duplicati da fork (la connessione non si chiude mai).
  • Lasciare SIGPIPE attivo in un server.
  • Usare un solo descrittore e supporre che accept restituisca lo stesso socket di ascolto: restituisce uno nuovo.
  • Confondere AF_INET (famiglia di indirizzi) con SOCK_STREAM (tipo di socket).

Domande d'esame

1. Descrivere in ordine le chiamate di un server TCP e di un client TCP, spiegando cosa fa accept e quanti socket ha il server. Traccia. Server: socket, bind (porta), listen (backlog), accept in ciclo, poi read/write sul descrittore restituito e close. Client: socket, connect, write/read, close. accept blocca finché c'è una connessione completata nella coda e restituisce un nuovo descrittore per il client, lasciando aperto il socket di ascolto: il server ha un socket di ascolto e uno per ogni connessione.

2. Una write da 100 byte sul client è seguita da una read da 100 byte sul server che restituisce 60. Perché? Cosa deve fare il server? Traccia. TCP è un flusso di byte senza confini: il livello di trasporto consegna quello che è arrivato, e read ritorna appena ci sono dati. Il server deve leggere in ciclo finché ha ricevuto i byte attesi (read_exact), stabiliti dal protocollo applicativo: la riga vuota dopo gli header, il Content-Length, la dimensione di un chunk.

3. Differenza fra close e shutdown(fd, SHUT_WR) e perché dopo fork bisogna chiudere il socket nel padre. Traccia. close rilascia il descrittore e la connessione si chiude quando è chiusa l'ultima copia; shutdown(SHUT_WR) invia subito il FIN ma lascia leggere (half-close). Dopo fork padre e figlio hanno entrambi il descrittore del socket: se il padre non chiude la sua copia, il FIN non parte mai e il client non vede la fine della connessione; inoltre il padre esaurirebbe i descrittori.

Versione ripasso

  • System call. Richiesta al kernel dallo user space (cambio di modalità): open, read, write, close, fork, pipe, dup2, socket, bind, listen, accept, connect. Funzioni di libreria (printf, fopen) a volte le chiamano (printf -> write). Manuale: man 2 socket, man 3 printf; strace mostra le chiamate.
  • Errori. Ritorno -1 e errno; perror, strerror. EINTR si riprova, EAGAIN niente dati o timeout, ECONNREFUSED porta chiusa, ECONNRESET, EPIPE, EADDRINUSE, EMFILE. getaddrinfo ritorna un codice tradotto da gai_strerror.
  • File descriptor. Intero che indicizza la tabella dei descrittori del processo; vale per file, pipe, socket. 0 stdin, 1 stdout, 2 stderr; open/socket danno il più basso libero. dup2(vecchio, nuovo) redirige. fork duplica la tabella: copie che puntano alla stessa risorsa. Sempre close.
  • read e write. read: > 0 byte letti, 0 EOF, -1 errore; può restituire meno del richiesto. Su TCP nessun confine fra i messaggi.
c
while (n > 0) { ssize_t w = write(fd, buf, n);
    if (w < 0) { if (errno == EINTR) continue; return -1; }
    buf += w; n -= (size_t)w; }                    /* write_all */

read_exact analogo (r <= 0 = errore o EOF anticipato). Righe: un byte alla volta, oppure buffer di lettura. fdopen + fgets ha trappole (buffer, fflush, fclose chiude anche il descrittore).

  • Pipe. pipe(fd): fd[0] lettura, fd[1] scrittura; con fork + dup2 collega due processi (come ls | wc -l, come il CGI). EOF solo quando tutte le copie di fd[1] sono chiuse.
  • Socket. Punto finale di comunicazione, SAP del trasporto. socket(domain, type, protocol): AF_INET/AF_INET6, SOCK_STREAM (TCP) / SOCK_DGRAM (UDP), protocollo 0.
TCP (stream) UDP (datagram)
server socket, bind, listen, accept socket, bind, recvfrom/sendto
client socket, connect socket, sendto/recvfrom
dati flusso senza confini, affidabile un datagramma per chiamata, può perdersi
c
struct addrinfo hints = {0}, *res, *p;
hints.ai_family = AF_UNSPEC;  hints.ai_socktype = SOCK_STREAM;
int rc = getaddrinfo(host, port, &hints, &res);      /* rc != 0: gai_strerror(rc) */
int fd = -1;
for (p = res; p; p = p->ai_next) {
    fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
    if (fd < 0) continue;
    if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;
    close(fd); fd = -1;
}
freeaddrinfo(res);
  • UDP. sendto(fd, buf, n, 0, addr, len) e recvfrom(fd, buf, cap, 0, &mittente, &len): una chiamata = un datagramma; se il buffer è più piccolo il resto va perso (massimo 65507 byte di dati); l'indirizzo del mittente arriva con ogni datagramma; nessuna ritrasmissione, serve un timeout (SO_RCVTIMEO).
  • Porte. 0-1023 note (servono privilegi per bind), 1024-49151 registrate, il resto effimere (assegnate dal kernel ai client). getsockname dice la porta effimera scelta.
  • Schema del server: socket(AF_INET, SOCK_STREAM, 0); setsockopt(SO_REUSEADDR); bind con sin_addr.s_addr = htonl(INADDR_ANY) e sin_port = htons(porta); listen(fd, 16); for (;;) { cfd = accept(lfd, ...); ...; close(cfd); }.
  • Schema di una pipe con redirezione: pipe(fd); nel figlio dup2(fd[1], STDOUT_FILENO) e close(fd[0]), close(fd[1]), poi exec; nel padre close(fd[1]) e lettura da fd[0] fino a read == 0.
  • Risposte brevi alle domande d'esame. (1) Server: socket, bind, listen, accept (nuovo fd, socket di ascolto resta), read/write, close; client: socket, connect, write/read, close; server = 1 socket di ascolto + 1 per connessione. (2) TCP non ha confini: read ritorna quanto è arrivato; ciclo read_exact con il numero di byte dettato dal protocollo (riga vuota, Content-Length, chunk). (3) close = FIN all'ultima copia chiusa; shutdown(SHUT_WR) = FIN subito, si legge ancora; dopo fork il padre chiude il socket di connessione, altrimenti il FIN non parte e finiscono i descrittori.
  • Errori tipici: non controllare i ritorni; write senza ciclo; credere che TCP conservi i messaggi; sin_port senza htons; niente memset; niente SO_REUSEADDR; fd non chiusi (EMFILE) o duplicati da fork non chiusi; SIGPIPE attivo; confondere socket di ascolto e di connessione.

Esercizi su questo argomento

Teoria collegata