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) = 0Valori 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
readewrite.
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.
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:
forkduplica la tabella: il figlio eredita tutti i descrittori aperti, che puntano alla stessa risorsa del padre. Per un socket significa che finché una copia resta aperta, la connessione non si chiude. Per questo il padre e il figlio chiudono ciascuno i descrittori che non usano (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 →).closerilascia il descrittore: va fatto sempre, altrimenti il processo esaurisce i descrittori (di solito 1024 per processo,ulimit -n) e si ottieneEMFILE.- Il numero del descrittore ha significato solo dentro il processo.
read e write
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:
- Si scrive sempre in un ciclo finché i byte sono finiti:
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;
}- Un socket TCP è un flusso di byte senza confini: una
writeda 100 byte può essere ricevuta da duereadda 60 e 40, e duewritepossono arrivare in una solaread. 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:
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 →).
/* 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).
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 |
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).
/* 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
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(setsockopta livelloSOL_SOCKET): permette di rifarebindsu una porta con connessioni ancora inTIME_WAIT. Senza, riavviare un server subito dopo averlo fermato dàEADDRINUSE("Address already in use").SO_RCVTIMEO: timeout dellaread; scaduto, lareadfallisce conEAGAIN. Serve per chiudere le connessioni inattive.closeeshutdown: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 segnaleSIGPIPE, che per default termina il processo. Un server non deve morire per un client che sparisce: si chiamasignal(SIGPIPE, SIG_IGN)e si controllaEPIPEdallawrite.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:
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
- Esercizio - Echo TCP e UDP, client e server (sul modello della prova pratica): tutte le chiamate di questa nota, per TCP e per UDP.
- Esercizio - Client HTTP 0.9 e 1.0 che scarica una pagina (slide HTTP 0.9 e 1.0):
getaddrinfo,connect,write_all, lettura dei messaggi.
Errori tipici
- Non controllare il valore di ritorno di
read,write,connect,bind,accept. - Credere che una
writesia unareaddall'altra parte: TCP non conserva i confini dei messaggi. - Scrivere con una sola
writesenza ciclo, ignorando una scrittura parziale. - Dimenticare
htonssulla porta omemsetsullasockaddr_in. - Non impostare
SO_REUSEADDR:bindfallisce subito dopo un riavvio. - Dimenticare
closesui descrittori dei client (esaurimento diEMFILE) o su quelli duplicati dafork(la connessione non si chiude mai). - Lasciare
SIGPIPEattivo in un server. - Usare un solo descrittore e supporre che
acceptrestituisca lo stesso socket di ascolto: restituisce uno nuovo. - Confondere
AF_INET(famiglia di indirizzi) conSOCK_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;stracemostra le chiamate. - Errori. Ritorno
-1eerrno;perror,strerror.EINTRsi riprova,EAGAINniente dati o timeout,ECONNREFUSEDporta chiusa,ECONNRESET,EPIPE,EADDRINUSE,EMFILE.getaddrinforitorna un codice tradotto dagai_strerror. - File descriptor. Intero che indicizza la tabella dei descrittori del processo; vale per file, pipe, socket. 0 stdin, 1 stdout, 2 stderr;
open/socketdanno il più basso libero.dup2(vecchio, nuovo)redirige.forkduplica la tabella: copie che puntano alla stessa risorsa. Sempreclose. - read e write.
read:> 0byte letti,0EOF,-1errore; può restituire meno del richiesto. Su TCP nessun confine fra i messaggi.
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; confork+dup2collega due processi (comels | wc -l, come il CGI). EOF solo quando tutte le copie difd[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), protocollo0.
| 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 |
- Indirizzi.
sockaddrgenerica (si passa con cast);sockaddr_in16 byte (sin_family,sin_port,sin_addr,sin_zero),sockaddr_in628 byte. Porta e indirizzo in network byte order:htons,inet_pton/inet_ntop;memseta zero;INADDR_ANY,INADDR_LOOPBACK.getaddrinfo(nome, servizio, hints) dà una lista di indirizzi (IPv4 e IPv6):socket+connectsul primo che funziona;AI_PASSIVEper i server;freeaddrinfo(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 →). - Chiamate.
bind: indirizzo locale (client TCP: porta effimera scelta aconnect).listen(backlog): socket passivo, coda di connessioni completate.accept: blocca, restituisce un nuovo fd (il socket di ascolto resta);addrleningresso/uscita.connect: ritorna a handshake concluso (ECONNREFUSED,ETIMEDOUT). Il server ha 1 socket di ascolto + 1 per client. - Opzioni e chiusura.
SO_REUSEADDR(riavvio conTIME_WAIT),SO_RCVTIMEO(readfallisce conEAGAIN).close: FIN quando chiude l'ultima copia;shutdown(SHUT_WR): half-close, si può ancora leggere.signal(SIGPIPE, SIG_IGN)nei server (scrittura su socket chiuso termina il processo).getsockname/getpeername. - poll.
poll(pf, n, timeout_ms)conPOLLIN: aspetta che un descrittore sia pronto; base del tunnel CONNECT. Più connessioni: un processo per connessione (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 →). - Schema del client (da ricordare a memoria):
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)erecvfrom(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).getsocknamedice la porta effimera scelta. - Schema del server:
socket(AF_INET, SOCK_STREAM, 0);setsockopt(SO_REUSEADDR);bindconsin_addr.s_addr = htonl(INADDR_ANY)esin_port = htons(porta);listen(fd, 16);for (;;) { cfd = accept(lfd, ...); ...; close(cfd); }. - Schema di una pipe con redirezione:
pipe(fd); nel figliodup2(fd[1], STDOUT_FILENO)eclose(fd[0]),close(fd[1]), poiexec; nel padreclose(fd[1])e lettura dafd[0]fino aread == 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:readritorna quanto è arrivato; cicloread_exactcon 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; dopoforkil padre chiude il socket di connessione, altrimenti il FIN non parte e finiscono i descrittori. - Errori tipici: non controllare i ritorni;
writesenza ciclo; credere che TCP conservi i messaggi;sin_portsenzahtons; nientememset; nienteSO_REUSEADDR; fd non chiusi (EMFILE) o duplicati daforknon chiusi;SIGPIPEattivo; confondere socket di ascolto e di connessione.
Esercizi su questo argomento
- Esercizio - Client DNS con query e risposta costruite a mano (sul modello della prova pratica)
- Esercizio - Client HTTP 0.9 e 1.0 che scarica una pagina (slide HTTP 0.9 e 1.0)
- Esercizio - Client HTTP 1.1 con Content-Length e connessione persistente (slide HTTP 1.1)
- Esercizio - Echo TCP e UDP, client e server (sul modello della prova pratica)
- Esercizio - Proxy con tunnel CONNECT (sul modello della prova pratica)
- Esercizio - Server HTTP con CGI (sul modello della prova pratica)
- Esercizio - Server HTTP concorrente con fork e connessioni persistenti (sul modello della prova pratica)
- Esercizio - Server HTTP iterativo con GET, HEAD e codici di errore (sul modello della prova pratica)
Teoria collegata
- CGI e applicazioni web dinamiche
- Client e server TCP in C - bind, listen, accept e processi concorrenti
- DNS, proxy web, HTTP CONNECT e gateway applicativi
- HTTP 0.9 e 1.0 - struttura dei messaggi, header e parsing
- HTTP 1.1 - connessioni persistenti, Content-Length e chunked transfer encoding
- Modello ISO-OSI, data plane e control plane, architetture client-server e publish-subscribe
- Richiami di C per la programmazione di rete - memoria, puntatori, struct ed endianness
- WebSocket, QUIC e HTTP-3