Skip to content

Latest commit

 

History

History
513 lines (355 loc) · 21.3 KB

File metadata and controls

513 lines (355 loc) · 21.3 KB

🔝 Retour au Sommaire

22.1.1 — Création de sockets

Section 22.1 : Sockets TCP/UDP — API POSIX


L'appel système socket()

Toute communication réseau commence par un seul appel système :

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

Cet appel demande au noyau d'allouer les ressources internes nécessaires (buffers, structures d'état, entrée dans la table des file descriptors) et retourne un file descriptor — un entier positif qui sera votre point d'accès à ce socket pour toutes les opérations suivantes. En cas d'échec, la fonction retourne -1 et positionne errno.

Les trois paramètres déterminent la nature exacte du socket créé.


Le paramètre domain : la famille d'adresses

Le domaine spécifie la famille d'adresses que le socket utilisera. Comme vu en section 22.1, les trois valeurs courantes sont :

Constante Famille Usage
AF_INET IPv4 Adresses 32 bits (192.168.1.1)
AF_INET6 IPv6 Adresses 128 bits (::1, 2001:db8::1)
AF_UNIX Local Communication inter-processus via le filesystem

Pour du code réseau moderne destiné à fonctionner en production, privilégiez AF_INET6 avec le mode dual-stack activé (c'est le comportement par défaut sur Linux). Un socket IPv6 dual-stack peut accepter des connexions IPv4 et IPv6, ce qui simplifie considérablement le code.

Si vous utilisez getaddrinfo pour résoudre les adresses (ce qui est recommandé), le domaine sera déterminé automatiquement par le résultat de la résolution — vous n'aurez même pas à le choisir explicitement.


Le paramètre type : le mode de communication

Le type détermine la sémantique de communication du socket :

Constante Protocole typique Comportement
SOCK_STREAM TCP Flux d'octets fiable, ordonné, orienté connexion
SOCK_DGRAM UDP Datagrammes indépendants, sans connexion, non fiable
SOCK_RAW IP brut Accès direct au protocole réseau (requiert CAP_NET_RAW)

SOCK_RAW est réservé aux cas très spécifiques : implémentation de protocoles personnalisés, outils de diagnostic comme ping ou traceroute, ou analyse de paquets. Vous n'en aurez pas besoin dans 99 % des situations.

Flags combinables avec le type

Depuis Linux 2.6.27, le paramètre type peut être combiné par OR binaire avec deux flags utiles :

SOCK_NONBLOCK — Crée le socket en mode non-bloquant dès sa création. Sans ce flag, le socket est bloquant par défaut : un appel à recv() sur un socket sans données disponibles bloquera le thread appelant indéfiniment. En mode non-bloquant, le même appel retourne immédiatement avec errno positionné à EAGAIN ou EWOULDBLOCK.

// Équivalent à socket() suivi de fcntl(fd, F_SETFL, O_NONBLOCK)
int fd = socket(AF_INET6, SOCK_STREAM | SOCK_NONBLOCK, 0);

SOCK_CLOEXEC — Positionne le flag close-on-exec sur le file descriptor. Cela garantit que le socket sera automatiquement fermé si le processus appelle exec() pour lancer un autre programme. Sans ce flag, un processus enfant créé par fork() + exec() hériterait du socket ouvert — une fuite de file descriptor silencieuse et un risque de sécurité.

// Bonne pratique : toujours utiliser SOCK_CLOEXEC
int fd = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0);

En C++ moderne, utilisez systématiquement SOCK_CLOEXEC. La combinaison des deux flags est courante pour les sockets non-bloquants :

int fd = socket(AF_INET6, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);

💡 Ces flags existent pour résoudre des race conditions entre socket() et fcntl() / ioctl(). Dans un programme multi-threadé, un autre thread pourrait appeler fork() entre la création du socket et le positionnement du flag — le socket fuiterait dans le processus enfant. Les flags atomiques éliminent ce problème.


Le paramètre protocol : généralement 0

Le troisième paramètre spécifie le protocole exact à utiliser. En pratique, la combinaison domaine + type détermine presque toujours le protocole de manière univoque :

  • AF_INET + SOCK_STREAM → TCP (protocole 6)
  • AF_INET + SOCK_DGRAM → UDP (protocole 17)

Passer 0 demande au système de choisir le protocole par défaut pour la combinaison domaine/type donnée — c'est le bon choix dans la quasi-totalité des cas. Les constantes IPPROTO_TCP et IPPROTO_UDP existent si vous avez besoin d'être explicite, mais 0 est parfaitement idiomatique.


Exemples de création

Voici les combinaisons les plus courantes :

#include <sys/socket.h>

// TCP IPv4
int tcp4 = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);

// TCP IPv6 (dual-stack : accepte aussi IPv4)
int tcp6 = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0);

// UDP IPv4
int udp4 = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);

// UDP IPv6
int udp6 = socket(AF_INET6, SOCK_DGRAM | SOCK_CLOEXEC, 0);

// TCP IPv6 non-bloquant
int tcp6_nb = socket(AF_INET6, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);

Gestion des erreurs à la création

socket() peut échouer. Les causes les plus fréquentes sont :

errno Signification Cause typique
EACCES Permission refusée Tentative de créer un SOCK_RAW sans privilèges
EMFILE Trop de fichiers ouverts (processus) Limite par processus atteinte (ulimit -n)
ENFILE Trop de fichiers ouverts (système) Limite système atteinte (/proc/sys/fs/file-max)
ENOBUFS / ENOMEM Mémoire insuffisante Ressources noyau épuisées
EPROTONOSUPPORT Protocole non supporté Combinaison domaine/type/protocole invalide

La vérification est obligatoire :

int sockfd = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0);  
if (sockfd == -1) {  
    // En C++ moderne, on préfère lancer une exception ou retourner std::expected
    throw std::system_error(errno, std::system_category(), "socket()");
}

L'utilisation de std::system_error est idiomatique en C++ pour les erreurs système : elle encapsule le code d'erreur (errno), la catégorie (système) et un message descriptif dans une seule exception. Nous reviendrons sur les stratégies alternatives (comme std::expected) dans la section sur la gestion d'erreurs réseau.


Options de socket : setsockopt()

Un socket fraîchement créé utilise les options par défaut du système. Avant de l'utiliser, vous aurez souvent besoin de modifier certaines options avec setsockopt() :

#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname,
               const void* optval, socklen_t optlen);

Le paramètre level indique à quelle couche protocolaire l'option s'applique (SOL_SOCKET pour les options génériques, IPPROTO_TCP pour les options TCP, etc.), et optname identifie l'option spécifique.

SO_REUSEADDR — L'option indispensable pour les serveurs

Quand un serveur TCP se ferme, le port qu'il utilisait entre dans un état TIME_WAIT pendant quelques minutes (typiquement 60 secondes sur Linux). Si vous relancez immédiatement le serveur, bind() échouera avec EADDRINUSE — « Address already in use ». C'est un comportement normal de TCP, conçu pour éviter que des paquets retardataires de l'ancienne connexion soient confondus avec ceux d'une nouvelle.

SO_REUSEADDR permet de réutiliser l'adresse malgré cet état :

int opt = 1;  
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {  
    throw std::system_error(errno, std::system_category(), "setsockopt(SO_REUSEADDR)");
}

Activez SO_REUSEADDR sur tous vos sockets serveur, sans exception. Ne pas le faire est une source de frustration récurrente en développement (le serveur refuse de redémarrer après un crash ou un arrêt propre) et un problème en production (redémarrage de service bloqué pendant le TIME_WAIT).

SO_REUSEPORT — Partage de port entre processus

Disponible depuis Linux 3.9, SO_REUSEPORT permet à plusieurs sockets (dans des processus ou threads différents) de se bind sur le même couple adresse:port. Le noyau distribue alors les connexions entrantes entre les sockets de manière équitable.

int opt = 1;  
setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));  

C'est le mécanisme utilisé par Nginx en mode multi-worker pour distribuer les connexions sans goulot d'étranglement sur un seul processus accept(). Cela permet d'éviter le « thundering herd problem » où tous les workers se réveillent pour une seule connexion entrante.

TCP_NODELAY — Désactiver l'algorithme de Nagle

Par défaut, TCP utilise l'algorithme de Nagle qui agrège les petits paquets pour réduire l'overhead réseau. C'est bénéfique pour les transferts de gros volumes, mais catastrophique pour les protocoles interactifs ou le RPC où la latence de chaque message compte.

int opt = 1;  
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));  

Activez TCP_NODELAY quand vous avez besoin de latence minimale : communication requête/réponse, protocoles interactifs, streaming de données en temps réel. Laissez Nagle activé pour les transferts bulk où le throughput prime.

SO_KEEPALIVE — Détection de connexions mortes

TCP ne détecte pas naturellement une connexion morte (par exemple, un câble débranché ou un processus distant tué par kill -9). Sans mécanisme supplémentaire, un recv() bloquant attendrait indéfiniment.

SO_KEEPALIVE demande au noyau d'envoyer périodiquement des « probes » sur les connexions inactives pour vérifier qu'elles sont toujours vivantes :

int opt = 1;  
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));  

// Paramètres fins (spécifiques Linux)
int idle = 60;     // Délai avant le premier probe (secondes)  
int interval = 10; // Intervalle entre les probes  
int count = 5;     // Nombre de probes avant abandon  

setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));  
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));  
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));  

Avec ces paramètres, le noyau enverra un probe après 60 secondes d'inactivité, puis un probe toutes les 10 secondes. Si 5 probes consécutifs restent sans réponse, la connexion est déclarée morte et le prochain recv() retournera une erreur.

IPV6_V6ONLY — Contrôle du mode dual-stack

Par défaut sur Linux, un socket AF_INET6 accepte à la fois les connexions IPv4 et IPv6 (mode dual-stack). Pour restreindre un socket à IPv6 uniquement :

int opt = 1;  
setsockopt(sockfd, IPPROTO_IPV6, IPV6_V6ONLY, &opt, sizeof(opt));  

À l'inverse, pour s'assurer explicitement que le dual-stack est actif (utile pour la portabilité, car le défaut varie selon les OS) :

int opt = 0;  
setsockopt(sockfd, IPPROTO_IPV6, IPV6_V6ONLY, &opt, sizeof(opt));  

Récapitulatif des options essentielles

Option            Niveau        Quand l'utiliser
──────────────    ──────────    ────────────────────────────────────────
SO_REUSEADDR      SOL_SOCKET    Toujours, sur les sockets serveur  
SO_REUSEPORT      SOL_SOCKET    Multi-worker (Nginx-style)  
TCP_NODELAY       IPPROTO_TCP   Protocoles interactifs, faible latence  
SO_KEEPALIVE      SOL_SOCKET    Connexions longue durée  
IPV6_V6ONLY       IPPROTO_IPV6  Contrôle dual-stack explicite  
SO_RCVBUF         SOL_SOCKET    Ajuster la taille du buffer de réception  
SO_SNDBUF         SOL_SOCKET    Ajuster la taille du buffer d'envoi  
SO_LINGER         SOL_SOCKET    Contrôler le comportement de close()  

Lecture d'options : getsockopt()

Pour interroger la valeur actuelle d'une option :

int opt;  
socklen_t opt_len = sizeof(opt);  
if (getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &opt, &opt_len) == 0) {  
    // Note : Linux retourne le double de la valeur réelle (overhead noyau inclus)
    std::println("Buffer de réception : {} octets", opt);
}

C'est utile pour le diagnostic et le monitoring, ou pour vérifier que le système a bien appliqué une option que vous avez demandée (le noyau peut ajuster les valeurs de buffers aux limites système).


Encapsulation RAII : Socket wrapper

Un socket est une ressource système : il occupe un file descriptor et des buffers noyau. L'oublier, c'est créer une fuite de file descriptors — un problème qui ne se manifeste qu'en charge, quand le processus atteint sa limite (EMFILE), et qui est notoirement difficile à diagnostiquer après coup.

La solution en C++ est le pattern RAII : le constructeur acquiert la ressource, le destructeur la libère. Voici un wrapper minimal mais fonctionnel :

#include <sys/socket.h>
#include <unistd.h>
#include <utility>   // std::exchange
#include <cerrno>
#include <system_error>

class Socket {  
public:  
    // Constructeur : crée le socket
    Socket(int domain, int type, int protocol = 0)
        : fd_{socket(domain, type | SOCK_CLOEXEC, protocol)}
    {
        if (fd_ == -1) {
            throw std::system_error(errno, std::system_category(), "socket()");
        }
    }

    // Constructeur depuis un fd existant (retour de accept() par exemple)
    explicit Socket(int fd) noexcept : fd_{fd} {}

    // Destructeur : ferme le socket
    ~Socket() {
        if (fd_ != -1) {
            close(fd_);  // close() ne lance pas d'exception
        }
    }

    // Non copiable — un socket ne peut pas être dupliqué implicitement
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;

    // Déplaçable — transfert de propriété
    Socket(Socket&& other) noexcept
        : fd_{std::exchange(other.fd_, -1)} {}

    Socket& operator=(Socket&& other) noexcept {
        if (this != &other) {
            if (fd_ != -1) {
                close(fd_);
            }
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    // Accès au file descriptor brut
    [[nodiscard]] int fd() const noexcept { return fd_; }

    // Conversion implicite pour les appels POSIX
    [[nodiscard]] int get() const noexcept { return fd_; }

    // Vérification de validité
    [[nodiscard]] bool valid() const noexcept { return fd_ != -1; }

    // Libération du fd sans fermeture (pour transfert à une autre API)
    int release() noexcept { return std::exchange(fd_, -1); }

    // Helpers pour setsockopt
    void set_option(int level, int optname, int value) {
        if (setsockopt(fd_, level, optname, &value, sizeof(value)) == -1) {
            throw std::system_error(errno, std::system_category(), "setsockopt()");
        }
    }

    void enable_reuse_addr() {
        set_option(SOL_SOCKET, SO_REUSEADDR, 1);
    }

    void enable_tcp_nodelay() {
        set_option(IPPROTO_TCP, TCP_NODELAY, 1);
    }

private:
    int fd_ = -1;
};

Analyse des choix de design

Non copiable, déplaçable — Un socket est une ressource unique, exactement comme std::unique_ptr ou std::fstream. Copier un file descriptor sans appeler dup() créerait deux objets tentant de fermer le même fd — un double-free classique. Le constructeur de copie et l'opérateur de copie sont donc supprimés. En revanche, le déplacement transfère la propriété proprement, en utilisant std::exchange pour invalider le fd source.

SOCK_CLOEXEC automatique — Le constructeur ajoute SOCK_CLOEXEC au type demandé. C'est une décision de sécurité : par défaut, le socket ne fuira pas vers les processus enfants.

[[nodiscard]] — Les accesseurs sont marqués nodiscard car ignorer la valeur de retour de fd() ou valid() est presque certainement un bug.

release() — Parfois, vous devez transférer la propriété du fd à une API tierce (Asio, par exemple). release() retourne le fd et invalide le wrapper, exactement comme std::unique_ptr::release().

Utilisation

#include <netinet/in.h>
#include <netinet/tcp.h>

// Création d'un socket serveur TCP IPv6
Socket server_sock{AF_INET6, SOCK_STREAM};  
server_sock.enable_reuse_addr();  
server_sock.enable_tcp_nodelay();  

// Le socket sera automatiquement fermé quand server_sock sort du scope,
// même en cas d'exception entre la création et la fin du bloc.

// Avec un socket non-bloquant :
Socket async_sock{AF_INET6, SOCK_STREAM | SOCK_NONBLOCK};

Le pattern accept() avec RAII

Un usage courant est d'encapsuler le résultat d'accept() dans un Socket :

// accept() retourne un nouveau fd pour le client
int client_fd = accept4(server_sock.fd(), 
                        reinterpret_cast<sockaddr*>(&client_addr),
                        &addr_len,
                        SOCK_CLOEXEC);
if (client_fd == -1) {
    // Gérer l'erreur...
}

// Encapsuler immédiatement dans le wrapper RAII
Socket client_sock{client_fd};
// À partir d'ici, la fermeture est automatique

💡 Notez l'utilisation de accept4() au lieu de accept(). Disponible depuis Linux 2.6.28, accept4 accepte un paramètre de flags (SOCK_CLOEXEC, SOCK_NONBLOCK) pour configurer le nouveau socket de manière atomique — exactement comme les flags de socket().


Wrapper RAII pour getaddrinfo

Tant qu'on parle de RAII, voici comment encapsuler proprement le résultat de getaddrinfo que nous avons vu en section 22.1 :

#include <netdb.h>
#include <memory>

struct AddrInfoDeleter {
    void operator()(addrinfo* p) const noexcept {
        if (p) freeaddrinfo(p);
    }
};

using AddrInfoPtr = std::unique_ptr<addrinfo, AddrInfoDeleter>;

// Fonction helper qui retourne un unique_ptr RAII
AddrInfoPtr resolve(const char* host, const char* service, 
                    int family = AF_UNSPEC, int socktype = SOCK_STREAM)
{
    addrinfo hints{};
    hints.ai_family = family;
    hints.ai_socktype = socktype;
    hints.ai_flags = AI_PASSIVE;  // Adapté pour bind() si host == nullptr

    addrinfo* result = nullptr;
    int status = getaddrinfo(host, service, &hints, &result);
    if (status != 0) {
        throw std::runtime_error(
            std::string("getaddrinfo: ") + gai_strerror(status)
        );
    }

    return AddrInfoPtr{result};
}

Utilisation :

// Résolution pour un serveur écoutant sur le port 8080
auto addr = resolve(nullptr, "8080", AF_INET6);

// Création du socket avec les paramètres résolus
Socket sock{addr->ai_family, addr->ai_socktype, addr->ai_protocol};

// addr->ai_addr et addr->ai_addrlen sont prêts pour bind()
// La mémoire sera libérée automatiquement quand addr sort du scope

Ce pattern élimine toute possibilité d'oublier freeaddrinfo(). Le unique_ptr avec deleter personnalisé est la manière idiomatique d'encapsuler des ressources C en RAII sans écrire une classe complète.


Limites système et dimensionnement

En production, un serveur peut ouvrir des milliers voire des dizaines de milliers de sockets simultanés. Il est important de connaître les limites et de les ajuster :

Limite par processus — Par défaut sur Ubuntu, un processus peut ouvrir 1024 file descriptors. C'est insuffisant pour un serveur réseau. Vérifiez et ajustez avec :

# Afficher la limite actuelle
ulimit -n

# Augmenter pour la session courante
ulimit -n 65535

# Rendre permanent via /etc/security/limits.conf
# myapp  soft  nofile  65535
# myapp  hard  nofile  65535

Limite système — Le nombre total de file descriptors ouverts sur le système :

# Afficher la limite
cat /proc/sys/fs/file-max

# Modifier (temporaire)
sudo sysctl -w fs.file-max=2097152

# Modifier (permanent via /etc/sysctl.conf)
# fs.file-max = 2097152

Ports éphémères — Quand un client se connecte sans spécifier de port source, le noyau en attribue un dans la plage éphémère. La plage par défaut sur Linux est 32768-60999, soit environ 28 000 ports. Pour des clients qui ouvrent beaucoup de connexions sortantes (load balancers, proxies), cette plage peut devenir limitante :

# Afficher la plage actuelle
cat /proc/sys/net/ipv4/ip_local_port_range

# Élargir
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"

Résumé

Créer un socket en C++ moderne sur Linux, c'est combiner trois éléments : l'appel système socket() avec les bons paramètres et flags, la configuration via setsockopt() des options adaptées à votre cas d'usage, et une encapsulation RAII qui garantit la fermeture automatique quelles que soient les circonstances.

Les points à retenir :

  • Utilisez toujours SOCK_CLOEXEC pour éviter les fuites de fd vers les processus enfants.
  • Activez SO_REUSEADDR sur tous les sockets serveur.
  • Activez TCP_NODELAY pour les protocoles interactifs et le RPC.
  • Encapsulez les file descriptors dans un wrapper RAII dès leur création — jamais de close() manuel dans du code de haut niveau.
  • Préférez AF_INET6 en dual-stack pour supporter IPv4 et IPv6 avec un seul socket.

Prochaine étape → Section 22.1.2 : bind, listen, accept, connect — les opérations qui transforment un socket brut en un point de communication actif, côté serveur comme côté client.

⏭️ bind, listen, accept, connect