Retour au blogDéveloppement

Zero-copy en Go : sendfile, splice et le vrai coût de io.Copy

CZSyn
6 juillet 2026
6 min

Un simple wrapper io.Reader autour d'un fichier peut désactiver sendfile en Go et multiplier les appels système par 24. Le vrai fonctionnement de io.Copy.

Flux de données lumineux circulant directement d'un disque vers une carte réseau dans un serveur, sans passer par la mémoire du processeur
Ce qu'il faut retenir
  1. Un wrapper io.Reader anodin posé devant un *os.File désactive silencieusement sendfile(2) en Go : sur le test de référence, le CPU serveur passe de 0,27 s à 0,92 s pour transférer 5,12 Gio, soit 3,4 fois plus de consommation.
  2. io.Copy ne reconnaît que deux formes de source pour activer le chemin rapide : un *os.File direct, ou un *io.LimitedReader qui en enveloppe un. Tout autre wrapper io.Reader fait perdre l'optimisation, y compris côté proxy avec *net.TCPConn et splice(2).
  3. Le signal fiable n'est pas le débit brut (souvent proche sur les trois versions testées en local) mais le nombre de syscalls : 2 972 appels sendfile contre 131 093 read/write combinés pour déplacer le même volume de données.

Résumé généré par IA

Sur son blog Segflow, l'ingénieur Assel Meher (Grafana Labs) publie un post-mortem qui parlera à tout développeur Go servant des fichiers ou faisant tourner un reverse proxy : un simple wrapper io.Reader posé devant un *os.File a fait doubler le CPU de son service et diviser son débit par deux. La cause tient en une ligne de diff : ce wrapper a silencieusement coupé le chemin sendfile(2) que Go utilisait jusque là sans même qu'on ait besoin de le demander.

Ce que sendfile et splice font pour vous, sans qu'on vous le dise

Rappel express pour ceux qui découvrent le sujet. Envoyer un fichier sur un socket, dans le cas le plus naïf, passe par deux copies mémoire : le noyau lit le fichier depuis la page cache vers un buffer utilisateur (read()), puis ce même buffer est recopié vers le socket (write()). sendfile(2) fusionne ces deux copies en un seul transfert qui reste entièrement dans le noyau : la page cache part directement vers la file d'attente du socket, sans jamais transiter par la mémoire de votre programme.

Pour le cas socket vers socket, typique d'un proxy, l'équivalent est splice(2), qui fait transiter les octets par un tube noyau sans jamais les matérialiser côté utilisateur. Vous n'appelez jamais ces syscalls à la main en Go : le runtime le fait pour vous, à condition de ne pas lui mettre de bâtons dans les roues.

Comment io.Copy décide (ou pas) d'emprunter le chemin rapide

Tout se joue dans io.Copy. Quand la destination d'un io.Copy(dst, src) implémente l'interface io.ReaderFrom, ce qui est le cas de *net.TCPConn, l'appel est délégué à sa méthode ReadFrom. Cette méthode regarde alors la source : est-ce un *os.File ? Un *io.LimitedReader qui enveloppe un *os.File ? Si oui, elle bascule sur internal/poll.SendFile, qui boucle sur sendfile(2) jusqu'à épuisement du fichier.

Toute la détection tient dans deux fichiers du code source de Go, net/sendfile_linux.go et os/zero_copy_linux.go, et se résume à deux assertions de type suivies d'une boucle de syscalls. Rien de magique, mais rien de robuste non plus face à un wrapper mal pensé : dès que la valeur passée à io.Copy n'est plus reconnue comme l'un de ces deux types précis, le runtime abandonne et retombe sur une copie manuelle par buffers de 32 Kio.

Les chiffres, mesurés proprement

Meher a comparé trois façons d'écrire le même handler, qui sert un fichier de 512 Mio sur TCP, sur une machine Linux 6.6 (Ubuntu 24.04 sous WSL2, Ryzen 5 9600X, 16 Gio de RAM, Go 1.22.12), en observant chaque cas avec strace -c -e trace=read,write,sendfile,splice.

  • io.Copy(conn, f), directement sur le os.File : 2 958 appels sendfile relevés, pour 0,27 s de CPU serveur cumulé sur dix transferts de 512 Mio, soit environ 54 ms de CPU par Gio transféré.
  • io.Copy(conn, justReader{r: f}), où justReader est un wrapper io.Reader minimal qui ne fait que déléguer son Read() : zéro appel sendfile, mais environ 131 093 appels read et write combinés (des paquets de 32 Kio, la taille par défaut de io.copyBuffer), pour 0,92 s de CPU sur le même volume, soit environ 184 ms par Gio.
  • io.Copy(conn, io.LimitReader(f, size)) : 2 896 appels sendfile, un résultat indiscernable du cas direct, pour 0,30 s de CPU, soit environ 60 ms par Gio.

Le chiffre qui frappe : la version wrappée consomme environ 3,4 fois plus de CPU par octet transféré que la version directe, pour environ 24 fois plus de syscalls (2 972 contre 131 093 pour déplacer le même volume). Un profil CPU du handler wrappé montre que 82 % des échantillons collectés tombent dans des syscalls, essentiellement syscall.Write et syscall.Read, avec une pile d'appels caractéristique en io.Copy -> io.copyBuffer -> (*TCPConn).ReadFrom -> readFrom -> io.Copy -> io.copyBuffer, la preuve que le runtime a renvoyé la copie à la main faute d'avoir trouvé un os.File exploitable.

Détail qui compte : io.LimitReader n'est pas un wrapper comme les autres. Le runtime vérifie explicitement le type *io.LimitedReader, le déballe, retrouve le *os.File dessous et poursuit sur le chemin rapide. C'est le seul wrapper standard que Go sait traverser tout seul.

Même logique côté proxy, avec splice

Le même mécanisme s'applique dans l'autre sens pour un proxy TCP : deux io.Copy entre deux *net.TCPConn suffisent à faire basculer le runtime sur splice(2) plutôt que sendfile. Sur le test de Meher, un strace du transfert relève 10 677 appels splice pour zéro read ou write sur le chemin de données : le proxy ne touche jamais les octets qu'il relaie. Là encore, il suffit d'envelopper l'une des deux extrémités dans votre propre io.Reader ou io.Writer pour retomber sur le bounce à 32 Kio.

Trois réflexes à garder

  • Évitez d'empiler un io.Reader devant un *os.File ou une *net.TCPConn si vous pouvez faire autrement. Le runtime ne reconnaît que *os.File, *io.LimitedReader et une poignée de types liés à *net.TCPConn. Tout le reste est opaque à ses yeux, y compris un middleware de logging ou de tracing qui ne fait que compter des octets.
  • Si vous devez wrapper, préservez les interfaces optionnelles. Un wrapper qui implémente io.WriterTo (côté source) ou io.ReaderFrom (côté destination) et qui délègue peut laisser io.Copy dispatcher jusqu'en bas de la pile. C'est un peu plus de code à écrire, mais la bibliothèque standard le fait couramment.
  • Vérifiez avec strace, pas seulement avec un débit mesuré. En local, le débit brut reste comparable entre les trois versions du test (1,5 à 1,7 Gio/s), parce que les buffers TCP absorbent l'écart. C'est le CPU serveur, pas le débit apparent, qui révèle le problème. Un strace -c -e trace=read,write,sendfile,splice sur votre propre handler tranche en dix secondes.

Notre lecture chez CZSyn

Ce genre de régression est redoutable parce qu'elle ne casse rien visiblement. Le service répond, les tests passent, et c'est seulement en production, sous charge réelle, qu'un cœur se retrouve saturé sans qu'aucun log ne signale pourquoi. Nous voyons régulièrement ce type de piège chez des clients qui font transiter des fichiers ou du contenu applicatif via des services Go, en particulier sur les architectures de proxy interne ou de service de fichiers statiques, où un middleware de logging ou de métriques ajouté sans arrière-pensée peut coûter cher en infrastructure sans que personne ne fasse le lien avec la cause réelle.

La bonne nouvelle, c'est que le diagnostic est rapide et gratuit : un strace ciblé suffit à confirmer si sendfile ou splice sont bien sollicités sur votre chemin de données. Si vous exploitez un service Go qui sert des fichiers volumineux ou qui fait office de proxy réseau, ça vaut la peine de vérifier une fois, avant que la facture d'infrastructure ne double pour une raison qui tient en une ligne de code.

Votre service Go tourne au ralenti sans raison apparente ?

Nous auditons vos services de fichiers et vos proxies internes pour traquer ce genre de régression invisible. Audit gratuit sous 24h, développement sur-mesure ou dépannage ponctuel.

29 AVIS 5/5 · +200 PROJETS LIVRÉS · RÉPONSE EXPRESS

Sources primaires

Un projet en tête ?

Discutons de votre projet et voyons comment nous pouvons vous aider.

Nous contacter