Reliable 은 Go의 UDP 연결을 위한 신뢰성 레이어입니다.
패킷 오버헤드가 최대 9바이트에 불과한 UDP 기반 애플리케이션의 안정성 은 다음과 같습니다.
** 이 프로젝트는 아직 WIP 상태입니다! 소스 코드를 살펴보고, 단위 테스트를 작성하고, 문서화에 도움을 주고, 도움을 주고 싶거나 질문이 있는 경우 Github 문제를 공개하세요!
Reliable은 networkprotocol/reliable.io
에 설명된 것과 동일한 패킷 헤더 레이아웃을 사용합니다.
모든 패킷은 8개의 서로 다른 플래그를 나타내는 단일 바이트(8비트)로 시작합니다. 패킷은 순차적이며 패킷이 신뢰할 수 없는 것으로 표시되지 않는 한 패킷 헤더에 포함된 부호 없는 16비트 정수를 사용하여 번호가 지정됩니다.
패킷 승인(ACK)은 총 5바이트를 사용하여 전송된 모든 패킷에 중복적으로 포함됩니다. 2바이트는 부호 없는 16비트 패킷 시퀀스 번호(ack)를 나타내고 3바이트는 32비트 비트 필드(ackBits)를 나타냅니다.
networkprotocol/reliable.io
와 매우 유사한 패킷 헤더 레이아웃은 패킷당 크기 오버헤드를 줄이기 위해 델타 인코딩 및 RLE 인코딩됩니다.
피어로부터 방금 받은 패킷이 주어지면 비트 필드(ackBits)의 각 설정된 비트(i)에 대해 시퀀스 번호가 (ack - i)인 경우 확인되도록 전송한 패킷을 표시합니다.
피어 A가 B에게 패킷을 보내는 경우, B는 A에게 전혀 패킷을 보내지 않고 B는 A로부터 수신된 32개의 패킷마다 빈 패킷을 보내므로 A는 B가 패킷을 승인했음을 알 수 있습니다.
보다 명확하게 말하면, 우리가 수신한 마지막 연속 패킷 시퀀스 번호를 나타내는 카운터(lui)가 유지되며, 피어에게 확인을 전달한 것입니다.
예를 들어 (lui)가 0이고 시퀀스 번호가 2, 3, 4, 6인 패킷에 대한 확인을 보낸 다음 패킷 시퀀스 번호 1을 확인했다면 lui는 4가 됩니다.
업데이트(lui) 시 다음 32개의 연속 시퀀스 번호가 이전에 수신한 패킷의 시퀀스 번호인 경우 32만큼 증가하고(lui) 다음 패킷 승인이 포함된 단일 빈 패킷을 보냅니다. (ack=lui+31, ackBits=[루이,루이+31]).
우리가 보낸 패킷(wq)과 받은 패킷(rq)에 대해 두 개의 고정 크기 시퀀스 버퍼가 유지됩니다. 이러한 버퍼에 대해 고정된 크기는 부호 없는 16비트 정수의 최대값(65536)으로 균등하게 나누어야 합니다. 데이터 구조는 Glenn Fiedler의 이 블로그 게시물에 설명되어 있습니다.
우리는 우리가 보낸 패킷의 마지막 연속 시퀀스 번호를 나타내는 카운터(oui)를 추적합니다. 예를 들어, 시퀀스 번호가 [0, 256] 범위에 있는 패킷을 보냈고 패킷(0, 1, 2, 3, 4, 8, 9, 10, 11, 12)에 대한 승인을 받았다면, 그러면 (oui)는 4가 됩니다.
cap(q)를 시퀀스 버퍼 q의 고정된 크기 또는 용량으로 설정합니다.
패킷을 보내는 동안 더 많은 패킷을 보내면 수신자의 읽기 버퍼가 오버플로될 것이라고 판단되면 간헐적으로 패킷 전송을 중지하고 버퍼링합니다. 보다 명확하게 말하면, 우리가 보낸 다음 패킷에 (oui + cap(rq))보다 큰 패킷 번호가 할당되면 피어의 패킷 수신자를 통해 (oui)가 증가할 때까지 모든 전송을 중지합니다.
오래되고 확인되지 않은 전송 패킷을 재전송하고 확인을 유지하는 논리는 Glenn Fiedler의 이 블로그 게시물에서 가져왔습니다.
100ms 후에도 수신자가 확인하지 않으면 패킷이 손실된 것으로 의심됩니다. 패킷이 손실된 것으로 의심되면 다시 전송됩니다. 현재로서는 최대 10회까지 패킷이 재전송됩니다.
패킷이 제한된 횟수만큼 재전송되는 것을 허용하지 않고 개발자에게 맡기는 것이 현명할 수 있습니다. 그러나 그것은 내 Discord 서버나 Github 문제를 통해 논의할 수 있게 되어 기쁘게 생각합니다.
TCP HOL 차단에 대한 실행 가능한 솔루션을 찾기 위해 Go에서 신뢰할 수 있는 UDP 라이브러리를 많이 살펴보았는데, 대부분은 주로 파일 전송이나 게임에 적합했습니다.
그 모든 일을 겪으면서 나는 그들이 나에게 너무 많은 일을 했다는 느낌을 받았습니다. 내 작업과 사이드 프로젝트를 위해 나는 분산형 p2p 네트워킹 프로토콜에 집중적으로 노력해 왔습니다. 이러한 프로토콜의 특성은 대기 시간이 길고 패킷 손실이 높은 환경에서 작동하는 TCP 헤드 오브 라인 차단으로 인해 심각한 문제를 겪고 있다는 것입니다.
대부분의 경우 이러한 라이브러리에서 제공하는 많은 기능은 필요하지 않았거나 솔직히 이러한 라이브러리를 사용하는 개발자가 처리하고 고려하는 것이 가장 좋을 것이라고 느꼈습니다. 예를 들어:
그래서 저는 모듈식 접근 방식을 연구하기 시작했고 제가 구축한 프로토콜의 신뢰성 부분을 별도의 라이브러리에 추상화하기로 결정했습니다.
나는 이 접근 방식이 상황에 따라 너무 많은 일을 할 수 있는 QUIC 또는 SCTP와 같은 인기 있는 대안에 비해 가장 좋다고 생각합니다. 결국, UDP 기반 프로토콜의 신뢰성 비트를 정확하고 잘 테스트하는 것만 으로도 충분히 어렵습니다.
net.PacketConn
관련 비트를 캡슐화합니다.net.UDPAddr
의 문자열 표현 캐시를 유지합니다.신뢰할 수 있는 Go 모듈을 사용합니다. 프로젝트에 포함하려면 다음 명령을 실행하세요.
$ go get github.com/lithdew/reliable
프로젝트나 데모를 빠르게 시작하고 실행하려면 Endpoint
사용하세요. 더 많은 유연성이 필요한 경우 Conn
과 직접 작업하는 것을 고려해보세요.
일종의 연결 유지 메커니즘이나 하트비트 시스템을 맨 위에 부트스트랩해야 합니다. 그렇지 않으면 패킷이 승인되지 않아 무기한 재전송될 수 있습니다.
WithReadBufferSize
사용하여 구성할 수 있습니다. 기본 읽기 버퍼 크기는 256입니다.WithWriteBufferSize
사용하여 구성할 수 있습니다. 기본 쓰기 버퍼 크기는 256입니다.WithResendTimeout
사용하여 구성할 수 있습니다. 기본 재전송 제한 시간은 100밀리초입니다.WithEndpointPacketHandler
또는 WithProtocolPacketHandler
사용하여 구성할 수 있습니다. 기본적으로 들어오는 모든 패킷을 무시하는 nil 핸들러가 제공됩니다.WithEndpointErrorHandler
또는 WithProtocolErrorHandler
사용하여 구성할 수 있는 연결에서 오류가 발생할 때 호출되는 오류 처리기입니다. 기본적으로 모든 오류를 무시하는 nil 핸들러가 제공됩니다.WithBufferPool
사용하여 바이트 버퍼 풀을 전달할 수 있습니다. 기본적으로 새 바이트 버퍼 풀이 인스턴스화됩니다. 벤치마크는 일본의 cmd/benchmark
사용하여 DigitalOcean 2GB/60GB 디스크/NYC3 서버에 대해 수행되었습니다.
벤치마크 작업은 일본에서 뉴욕으로 1400바이트 패킷을 스팸으로 보내는 것이었습니다. 약 220밀리초의 핑 대기 시간을 고려하면 처리량은 약 1.2MiB/초였습니다.
아래와 같이 단위 테스트 벤치마크도 수행되었습니다.
$ 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
다음 명령을 실행하여 아래 예제를 실행할 수 있습니다.
$ go run github.com/lithdew/reliable/examples/basic
이 예에서는 다음을 보여줍니다.
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
}