fiable est une couche de fiabilité pour les connexions UDP dans Go.
Avec seulement 9 octets de surcharge de paquets au maximum, ce que fiable apporte à votre application basée sur UDP est :
** Ce projet est encore un WIP ! Parcourez le code source, écrivez des tests unitaires, aidez avec la documentation ou ouvrez un problème Github si vous souhaitez aider ou si vous avez des questions !
fiable utilise la même disposition d'en-tête de paquet décrite dans networkprotocol/reliable.io
.
Tous les paquets commencent par un seul octet (8 bits) représentant 8 indicateurs différents. Les paquets sont séquentiels et sont numérotés à l'aide d'un entier non signé de 16 bits inclus dans l'en-tête du paquet, sauf si le paquet est marqué comme non fiable.
Les accusés de réception de paquets (ACK) sont inclus de manière redondante dans chaque paquet envoyé en utilisant un total de 5 octets : deux octets représentant un numéro de séquence de paquet non signé de 16 bits (ack) et trois octets représentant un champ de bits de 32 bits (ackBits).
La disposition de l'en-tête du paquet, tout comme networkprotocol/reliable.io
, est codée en delta et en RLE pour réduire la surcharge de taille par paquet.
Étant donné un paquet que nous venons de recevoir de notre homologue, pour chaque bit défini (i) dans le champ de bits (ackBits), nous marquons un paquet que nous avons envoyé pour être accusé de réception si son numéro de séquence est (ack - i).
Dans le cas d'un homologue A envoyant des paquets à B, B n'envoyant aucun paquet à A, B enverra un paquet vide tous les 32 paquets reçus de A afin que A sache que B a accusé réception de ses paquets.
Plus explicitement, un compteur (lui) est maintenu représentant le dernier numéro de séquence de paquets consécutifs que nous avons reçu et dont nous avons informé notre homologue de l'accusé de réception.
Par exemple, si (lui) vaut 0 et que nous avons envoyé des accusés de réception pour les paquets dont les numéros de séquence sont 2, 3, 4 et 6, et que nous avons ensuite accusé le numéro de séquence de paquet 1, alors lui serait 4.
Lors de la mise à jour (lui), si les 32 numéros de séquence consécutifs suivants sont des numéros de séquence de paquets que nous avons reçus précédemment, nous incrémenterons (lui) de 32 et enverrons un seul paquet vide contenant les accusés de réception de paquets suivants : (ack=lui+31, ackBits=[lui,lui+31]).
Deux tampons de séquence de taille fixe sont conservés pour les paquets que nous avons envoyés (wq) et les paquets que nous avons reçus (rq). La taille fixée pour ces tampons doit être divisée uniformément en la valeur maximale d'un entier non signé de 16 bits (65536). La structure des données est décrite dans cet article de blog de Glenn Fiedler.
Nous gardons la trace d'un compteur (oui), représentant le dernier numéro de séquence consécutif d'un paquet que nous avons envoyé et qui a été reconnu par notre homologue. Par exemple, si nous avons envoyé des paquets dont les numéros de séquence sont compris dans la plage [0, 256] et que nous avons reçu des accusés de réception pour les paquets (0, 1, 2, 3, 4, 8, 9, 10, 11, 12), alors (oui) serait 4.
Soit cap(q) la taille ou la capacité fixe du tampon de séquence q.
Lors de l'envoi de paquets, nous arrêtons et mettons en mémoire tampon par intermittence l'envoi de paquets si nous pensons que l'envoi de plus de paquets ferait déborder le tampon de lecture de notre destinataire. Plus explicitement, si le prochain paquet que nous envoyons se voit attribuer un numéro de paquet supérieur à (oui + cap(rq)), nous arrêtons tous les envois jusqu'à ce que (oui) ait incrémenté via le destinataire d'un paquet de notre homologue.
La logique de retransmission des paquets envoyés périmés et sans accusé de réception et de conservation des accusés de réception a été attribuée à ce billet de blog de Glenn Fiedler.
Les paquets sont suspectés d'être perdus s'ils ne sont pas reconnus par leur destinataire après 100 ms. Dès qu’un paquet est suspecté d’être perdu, il est renvoyé. À l’heure actuelle, les paquets sont renvoyés au maximum 10 fois.
Il pourrait être judicieux de ne pas autoriser le renvoi des paquets un nombre limité de fois et de laisser le choix au développeur. Cependant, cela est ouvert à la discussion que je suis heureux d'avoir sur mon serveur Discord ou via un problème Github.
Dans ma quête d'une solution réalisable contre le blocage de tête de ligne TCP, j'ai parcouru de nombreuses bibliothèques UDP fiables dans Go, la majorité étant principalement adaptée au transfert de fichiers ou aux jeux :
En les parcourant tous, j’ai senti qu’ils en faisaient un peu trop pour moi. Pour mon travail et mes projets parallèles, j'ai beaucoup travaillé sur les protocoles de réseau p2p décentralisés. La nature de ces protocoles est qu'ils souffrent fortement du blocage de tête de ligne TCP fonctionnant dans des environnements à latence élevée et à perte de paquets élevée.
Dans de nombreux cas, de nombreuses fonctionnalités fournies par ces bibliothèques n'étaient pas nécessaires ou semblaient honnêtement être mieux gérées et réfléchies par le développeur utilisant ces bibliothèques. Par exemple:
J'ai donc commencé à travailler sur une approche modulaire et j'ai décidé de faire abstraction de la partie fiabilité des protocoles que j'ai intégrés dans une bibliothèque distincte.
Je pense que cette approche est la meilleure par rapport aux alternatives populaires comme QUIC ou SCTP qui peuvent, selon votre situation, en faire un peu trop pour vous. Après tout, il est déjà assez difficile d’obtenir que les éléments de fiabilité d’un protocole basé sur UDP soient corrects et bien testés.
net.PacketConn
pour une abstraction plus fine.net.UDPAddr
transmis.fiable utilise les modules Go. Pour l'inclure dans votre projet, exécutez la commande suivante :
$ go get github.com/lithdew/reliable
Si vous cherchez simplement à mettre rapidement en place un projet ou une démo, utilisez Endpoint
. Si vous avez besoin de plus de flexibilité, envisagez de travailler directement avec Conn
.
Notez qu'une sorte de mécanisme de maintien en vie ou de système de battement de cœur doit être amorcé par le haut, sinon les paquets peuvent être renvoyés indéfiniment car ils n'auront pas pu être reconnus.
WithReadBufferSize
. La taille du tampon de lecture par défaut est de 256.WithWriteBufferSize
. La taille du tampon d'écriture par défaut est de 256.WithResendTimeout
. Le délai de renvoi par défaut est de 100 millisecondes.WithEndpointPacketHandler
ou WithProtocolPacketHandler
. Par défaut, un gestionnaire nil est fourni qui ignore tous les paquets entrants.WithEndpointErrorHandler
ou WithProtocolErrorHandler
. Par défaut, un gestionnaire nil est fourni qui ignore toutes les erreurs.WithBufferPool
. Par défaut, un nouveau pool de mémoire tampon d'octets est instancié. Un benchmark a été réalisé en utilisant cmd/benchmark
du Japon sur un serveur DigitalOcean 2 Go/60 Go Disk/NYC3.
La tâche de référence consistait à spammer des paquets de 1 400 octets du Japon vers New York. Avec une latence de ping d'environ 220 millisecondes, le débit était d'environ 1,2 Mio/s.
Des tests de référence de tests unitaires ont également été effectués, comme indiqué ci-dessous.
$ cat /proc/cpuinfo | grep 'model name' | uniq
model name : Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz
$ go test -bench=. -benchtime=10s
goos: linux
goarch: amd64
pkg: github.com/lithdew/reliable
BenchmarkEndpointWriteReliablePacket-8 2053717 5941 ns/op 183 B/op 9 allocs/op
BenchmarkEndpointWriteUnreliablePacket-8 2472392 4866 ns/op 176 B/op 8 allocs/op
BenchmarkMarshalPacketHeader-8 749060137 15.7 ns/op 0 B/op 0 allocs/op
BenchmarkUnmarshalPacketHeader-8 835547473 14.6 ns/op 0 B/op 0 allocs/op
Vous pouvez exécuter l'exemple ci-dessous en exécutant la commande suivante :
$ go run github.com/lithdew/reliable/examples/basic
Cet exemple démontre :
package main
import (
"bytes"
"errors"
"github.com/davecgh/go-spew/spew"
"github.com/lithdew/reliable"
"io"
"log"
"net"
"os"
"os/signal"
"sync"
"sync/atomic"
"time"
)
var (
PacketData = bytes . Repeat ([] byte ( "x" ), 1400 )
NumPackets = uint64 ( 0 )
)
func check ( err error ) {
if err != nil && ! errors . Is ( err , io . EOF ) {
log . Panic ( err )
}
}
func listen ( addr string ) net. PacketConn {
conn , err := net . ListenPacket ( "udp" , addr )
check ( err )
return conn
}
func handler ( buf [] byte , _ net. Addr ) {
if bytes . Equal ( buf , PacketData ) || len ( buf ) == 0 {
return
}
spew . Dump ( buf )
os . Exit ( 1 )
}
func main () {
exit := make ( chan struct {})
var wg sync. WaitGroup
wg . Add ( 2 )
ca := listen ( "127.0.0.1:44444" )
cb := listen ( "127.0.0.1:55555" )
a := reliable . NewEndpoint ( ca , reliable . WithEndpointPacketHandler ( handler ))
b := reliable . NewEndpoint ( cb , reliable . WithEndpointPacketHandler ( handler ))
defer func () {
check ( ca . SetDeadline ( time . Now (). Add ( 1 * time . Millisecond )))
check ( cb . SetDeadline ( time . Now (). Add ( 1 * time . Millisecond )))
close ( exit )
check ( a . Close ())
check ( b . Close ())
check ( ca . Close ())
check ( cb . Close ())
wg . Wait ()
}()
go a . Listen ()
go b . Listen ()
// The two goroutines below have endpoint A spam endpoint B, and print out how
// many packets of data are being sent per second.
go func () {
defer wg . Done ()
for {
select {
case <- exit :
return
default :
}
check ( a . WriteReliablePacket ( PacketData , b . Addr ()))
atomic . AddUint64 ( & NumPackets , 1 )
}
}()
go func () {
defer wg . Done ()
ticker := time . NewTicker ( 1 * time . Second )
defer ticker . Stop ()
for {
select {
case <- exit :
return
case <- ticker . C :
numPackets := atomic . SwapUint64 ( & NumPackets , 0 )
numBytes := float64 ( numPackets ) * 1400.0 / 1024.0 / 1024.0
log . Printf (
"Sent %d packet(s) comprised of %.2f MiB worth of data." ,
numPackets ,
numBytes ,
)
}
}
}()
ch := make ( chan os. Signal , 1 )
signal . Notify ( ch , os . Interrupt )
<- ch
}