🔝 Retour au Sommaire
select et poll sont les deux plus anciennes interfaces de multiplexage I/O disponibles sous Unix. Elles partagent le même modèle — readiness notification en level-triggered — et la même complexité algorithmique O(n), mais diffèrent par leur API et leurs limitations pratiques. Nous les traitons ensemble car elles représentent la même génération conceptuelle, et comprendre leurs défauts est indispensable pour apprécier les mécanismes plus modernes (epoll, io_uring) qui leur ont succédé.
⚠️ En 2026,selectetpollne sont plus recommandés pour des serveurs de production sous Linux. Leur étude reste pertinente pour trois raisons : la lecture de code legacy, la portabilité vers des systèmes non-Linux (BSD, macOS, Solaris), et la compréhension progressive des concepts avant d'aborderepoll.
select est apparue dans BSD 4.2 en 1983. C'est l'interface de multiplexage la plus ancienne et la plus universellement disponible. Son principe : l'appelant fournit trois ensembles de descripteurs (lecture, écriture, exceptions) et select bloque jusqu'à ce qu'au moins un descripteur soit prêt, qu'un timeout expire, ou qu'un signal soit reçu.
#include <sys/select.h>
int select(int nfds,
fd_set *readfds, // Descripteurs surveillés en lecture
fd_set *writefds, // Descripteurs surveillés en écriture
fd_set *exceptfds, // Descripteurs surveillés pour exceptions
struct timeval *timeout);Les paramètres méritent une explication détaillée :
nfds: la valeur du plus grand descripteur surveillé plus un. Ce n'est pas le nombre de descripteurs, mais une borne supérieure. Le noyau itère de 0 ànfds - 1pour vérifier chaque bit dans les ensembles — c'est l'une des sources d'inefficacité.readfds: ensemble des descripteurs pour lesquels on veut savoir si une lecture est possible sans bloquer. Cela inclut aussi la détection de nouvelles connexions sur un socket d'écoute (caracceptest une forme de « lecture »).writefds: ensemble des descripteurs pour lesquels on veut savoir si une écriture est possible sans bloquer.exceptfds: rarement utilisé, détecte les conditions exceptionnelles (données out-of-band TCP par exemple).timeout: durée maximale d'attente.NULLpour bloquer indéfiniment, unestruct timevalà zéro pour un poll immédiat (non bloquant).
La valeur de retour est le nombre total de descripteurs prêts, 0 en cas de timeout, ou -1 en cas d'erreur.
Les fd_set se manipulent exclusivement via quatre macros :
fd_set fds;
FD_ZERO(&fds); // Initialise l'ensemble à vide (obligatoire)
FD_SET(fd, &fds); // Ajoute fd à l'ensemble
FD_CLR(fd, &fds); // Retire fd de l'ensemble
FD_ISSET(fd, &fds); // Teste si fd est dans l'ensemble (après select) 🔥 Piège critique :
selectmodifie lesfd_setpassés en paramètre. Après l'appel, seuls les descripteurs effectivement prêts restent dans l'ensemble. Il faut donc reconstruire les ensembles à chaque itération de la boucle événementielle.
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <vector>
#include <algorithm>
#include <print>
bool set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return false;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}
int create_server(uint16_t port) {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) return -1;
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
close(server_fd);
return -1;
}
if (listen(server_fd, SOMAXCONN) == -1) {
close(server_fd);
return -1;
}
set_nonblocking(server_fd);
return server_fd;
}
int main() {
constexpr uint16_t port = 8080;
int server_fd = create_server(port);
if (server_fd == -1) {
std::print(stderr, "Erreur création serveur\n");
return 1;
}
std::print("Serveur select en écoute sur le port {}\n", port);
// Liste des descripteurs clients connectés
std::vector<int> client_fds;
while (true) {
// ── Étape 1 : Reconstruire les fd_set à chaque itération ──
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
int max_fd = server_fd;
for (int cfd : client_fds) {
FD_SET(cfd, &read_fds);
max_fd = std::max(max_fd, cfd);
}
// ── Étape 2 : Attendre un événement ──
// Timeout de 30 secondes (recréé à chaque itération car
// select modifie aussi le timeout sur certains systèmes)
timeval timeout{.tv_sec = 30, .tv_usec = 0};
int ready = select(max_fd + 1, &read_fds, nullptr, nullptr, &timeout);
if (ready == -1) {
if (errno == EINTR) continue; // Interrompu par un signal
std::print(stderr, "Erreur select : {}\n", strerror(errno));
break;
}
if (ready == 0) {
std::print("Timeout — aucune activité depuis 30s\n");
continue;
}
// ── Étape 3 : Nouvelle connexion ? ──
if (FD_ISSET(server_fd, &read_fds)) {
int client_fd = accept(server_fd, nullptr, nullptr);
if (client_fd != -1) {
set_nonblocking(client_fd);
client_fds.push_back(client_fd);
std::print("Nouveau client fd={} (total: {})\n",
client_fd, client_fds.size());
}
}
// ── Étape 4 : Données des clients existants ──
// On itère en sens inverse pour pouvoir supprimer sans invalider les indices
for (int i = static_cast<int>(client_fds.size()) - 1; i >= 0; --i) {
int cfd = client_fds[i];
if (!FD_ISSET(cfd, &read_fds)) continue;
char buf[4096];
ssize_t n = recv(cfd, buf, sizeof(buf), 0);
if (n <= 0) {
// n == 0 : déconnexion propre
// n < 0 : erreur (mais pas EAGAIN, car select a signalé le fd)
std::print("Client fd={} déconnecté\n", cfd);
close(cfd);
client_fds.erase(client_fds.begin() + i);
} else {
// Echo : renvoyer les données reçues
send(cfd, buf, n, 0);
}
}
}
// Nettoyage
for (int cfd : client_fds) close(cfd);
close(server_fd);
return 0;
}Les limitations de select sont bien documentées et constituent la motivation historique de chaque mécanisme successeur :
Limite dure sur FD_SETSIZE. La macro FD_SETSIZE est définie à 1024 sur la plupart des systèmes Linux. Cela signifie que select ne peut pas surveiller de descripteur dont la valeur numérique dépasse 1023. Ce n'est pas une limite sur le nombre de connexions, mais sur la valeur du descripteur — et sous Linux, les descripteurs sont attribués séquentiellement. Dès que votre processus dépasse ~1000 fichiers ouverts (sockets, fichiers de log, pipes...), select devient inutilisable. On peut recompiler avec un FD_SETSIZE plus grand, mais cela reste un palliatif fragile.
Complexité O(n) systématique. À chaque appel, le noyau parcourt linéairement tous les descripteurs de 0 à nfds - 1, même si seuls quelques-uns sont surveillés. Côté userspace, il faut aussi itérer sur tous les descripteurs pour appeler FD_ISSET et trouver ceux qui sont prêts. Avec 10 000 descripteurs dont 3 sont actifs, le travail est proportionnel à 10 000, pas à 3.
Reconstruction obligatoire des ensembles. Puisque select écrase les fd_set, il faut les reconstruire intégralement avant chaque appel. Cela représente une copie mémoire proportionnelle au nombre de descripteurs à chaque itération.
Le timeout peut être modifié. Sur Linux, select met à jour la struct timeval pour indiquer le temps restant. Ce comportement n'est pas portable (POSIX le laisse indéfini). Il faut recréer le timeout à chaque itération pour un comportement prévisible.
poll, apparue dans System V Release 3 (1986) et standardisée par POSIX, corrige la limitation la plus gênante de select : la borne FD_SETSIZE. Au lieu de bitsets de taille fixe, poll utilise un tableau de structures pollfd de taille arbitraire. L'API est aussi plus propre : un seul tableau au lieu de trois fd_set, et les événements demandés sont séparés des événements retournés.
#include <poll.h>
int poll(struct pollfd *fds, // Tableau de descripteurs à surveiller
nfds_t nfds, // Nombre d'éléments dans le tableau
int timeout); // Timeout en millisecondes (-1 = infini)
struct pollfd {
int fd; // Descripteur de fichier
short events; // Événements demandés (entrée)
short revents; // Événements observés (sortie, rempli par le noyau)
};La séparation events / revents est une amélioration majeure par rapport à select : le noyau écrit les résultats dans revents sans toucher à events, ce qui évite de reconstruire le tableau à chaque appel.
POLLIN // Données disponibles en lecture
POLLOUT // Écriture possible sans blocage
POLLPRI // Données urgentes (out-of-band)
POLLERR // Erreur sur le descripteur (revents uniquement)
POLLHUP // Déconnexion du pair (revents uniquement)
POLLNVAL // Descripteur invalide (revents uniquement) POLLERR, POLLHUP et POLLNVAL sont toujours surveillés implicitement — inutile de les spécifier dans events, ils apparaissent dans revents si la condition se produit.
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <vector>
#include <print>
bool set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return false;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}
int create_server(uint16_t port) {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) return -1;
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
close(server_fd);
return -1;
}
if (listen(server_fd, SOMAXCONN) == -1) {
close(server_fd);
return -1;
}
set_nonblocking(server_fd);
return server_fd;
}
int main() {
constexpr uint16_t port = 8080;
int server_fd = create_server(port);
if (server_fd == -1) {
std::print(stderr, "Erreur création serveur\n");
return 1;
}
std::print("Serveur poll en écoute sur le port {}\n", port);
// Le tableau de pollfd : le serveur est toujours en première position
std::vector<pollfd> poll_fds;
poll_fds.push_back({.fd = server_fd, .events = POLLIN, .revents = 0});
while (true) {
// ── Étape 1 : Attendre un événement ──
int ready = poll(poll_fds.data(),
static_cast<nfds_t>(poll_fds.size()),
30'000); // 30 secondes
if (ready == -1) {
if (errno == EINTR) continue;
std::print(stderr, "Erreur poll : {}\n", strerror(errno));
break;
}
if (ready == 0) {
std::print("Timeout — aucune activité depuis 30s\n");
continue;
}
// ── Étape 2 : Nouvelle connexion sur le socket serveur ? ──
if (poll_fds[0].revents & POLLIN) {
int client_fd = accept(server_fd, nullptr, nullptr);
if (client_fd != -1) {
set_nonblocking(client_fd);
poll_fds.push_back({.fd = client_fd, .events = POLLIN, .revents = 0});
std::print("Nouveau client fd={} (total: {})\n",
client_fd, poll_fds.size() - 1);
}
}
// ── Étape 3 : Traiter les clients (indices 1 à N) ──
for (size_t i = poll_fds.size() - 1; i >= 1; --i) {
auto& pfd = poll_fds[i];
// Déconnexion ou erreur
if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
std::print("Client fd={} : erreur ou déconnexion\n", pfd.fd);
close(pfd.fd);
poll_fds.erase(poll_fds.begin() + static_cast<long>(i));
continue;
}
// Données disponibles
if (pfd.revents & POLLIN) {
char buf[4096];
ssize_t n = recv(pfd.fd, buf, sizeof(buf), 0);
if (n <= 0) {
std::print("Client fd={} déconnecté\n", pfd.fd);
close(pfd.fd);
poll_fds.erase(poll_fds.begin() + static_cast<long>(i));
} else {
send(pfd.fd, buf, n, 0);
}
}
}
}
for (auto& pfd : poll_fds) close(pfd.fd);
return 0;
}- Pas de limite
FD_SETSIZE: le tableau depollfdest alloué dynamiquement, sa taille n'est bornée que par la mémoire et la limiteulimit -ndu système. - Pas de reconstruction des entrées : le noyau écrit dans
reventssans altérerevents, donc le tableau peut être réutilisé tel quel. - API plus explicite : les événements sont des flags nommés plutôt que trois ensembles de bits distincts.
- Le timeout est un simple entier en millisecondes, sans les subtilités de
struct timeval.
Malgré ses améliorations, poll conserve le défaut fondamental de select : la complexité O(n).
À chaque appel, le noyau parcourt l'intégralité du tableau poll_fds pour vérifier l'état de chaque descripteur, quel que soit le nombre de descripteurs effectivement actifs. Côté userspace, il faut également itérer sur tout le tableau pour inspecter les revents. Avec 50 000 descripteurs dans le tableau et 5 actifs, le travail est proportionnel à 50 000 — pas à 5.
De plus, le tableau entier est copié du userspace vers le kernel à chaque appel poll, puis recopié en retour. Cette double copie a un coût non négligeable en mémoire et en bande passante mémoire lorsque le nombre de descripteurs croît.
| Critère | select |
poll |
|---|---|---|
| Limite de descripteurs | FD_SETSIZE (1024 par défaut) |
Aucune (limité par ulimit -n) |
| Structure de données | 3 bitsets fd_set |
1 tableau de pollfd |
| Modification des entrées | Oui (écrase les fd_set) |
Non (events préservé) |
| Complexité noyau | O(nfds) | O(nfds) |
| Complexité userspace | O(nfds) | O(nfds) |
| Copie user↔kernel | 3 bitsets par appel | 1 tableau par appel |
| Timeout | struct timeval (modifiable) |
int en millisecondes |
| Portabilité | POSIX, Windows (Winsock) | POSIX (pas Windows natif) |
| Mode de notification | Level-triggered uniquement | Level-triggered uniquement |
Ces interfaces restent pertinentes dans des cas précis :
-
Portabilité maximale :
selectest la seule interface de multiplexage disponible partout, y compris sur Windows via Winsock (avec quelques variations). Si votre code doit tourner sur Linux, macOS, FreeBSD, Solaris et Windows sans couche d'abstraction,selectest le plus petit dénominateur commun. -
Nombre réduit de descripteurs : pour un outil CLI qui surveille un socket et stdin (2-3 descripteurs), la différence de performance avec
epollest inexistante.selectoupollsont alors plus simples à mettre en place. -
Code legacy : beaucoup de bases de code existantes utilisent
selectoupoll. Comprendre ces API est indispensable pour la maintenance et la migration progressive.
Pour tout serveur réseau visant plus d'une centaine de connexions simultanées sous Linux, epoll (section 22.3.2) est le choix approprié. Pour les workloads les plus exigeants, io_uring (section 22.3.3) offre un palier de performance supplémentaire.