使用 cmux smux 對 TCP 進行復用

使用 cmux/smux 對 TCP 進行復用

只寫一下如何使用,不對實現進行大量描述,兩個庫的代碼都比較精煉,花一會看一下就行。

  • cmux 對端口進行復用,單端口可以建立不同協議的連接(本質都是 TCP),如 TCP/TLS/HTTP/gRPC 或自定義協議
  • smux 對TCP連接複用,單TCP連接承載多條 smux stream

適用使用場景

  • cmux 一些對外只提供單端口的服務,比如一個端口同時提供 HTTP/HTTPS 功能,其實還能夠更多
  • smux
    • 一些性能敏感的地方,比如大量TLS的短連接請求,對於頻繁的握手非常消耗CPU,見性能壓測
    • 反向連接,將 TCP 客戶端抽象爲服務端,方便如 HTTP/gRPC 的服務開發

cmux 的使用

借用一些官方示例,使用還是相對簡單的,23456 端口同時提供了 gRPC/HTTP/tRPC 複用。

// Create the main listener.
l, err := net.Listen("tcp", ":23456")
if err != nil {
	log.Fatal(err)
}

// Create a cmux.
m := cmux.New(l)

// Match connections in order:
// First grpc, then HTTP, and otherwise Go RPC/TCP.
grpcL := m.Match(cmux.HTTP2HeaderField("content-type", "application/grpc"))
httpL := m.Match(cmux.HTTP1Fast())
trpcL := m.Match(cmux.Any()) // Any means anything that is not yet matched.

// Create your protocol servers.
grpcS := grpc.NewServer()
grpchello.RegisterGreeterServer(grpcS, &server{})

httpS := &http.Server{ Handler: &helloHTTP1Handler{} }

trpcS := rpc.NewServer()
trpcS.Register(&ExampleRPCRcvr{})

// Use the muxed listeners for your servers.
go grpcS.Serve(grpcL)
go httpS.Serve(httpL)
go trpcS.Accept(trpcL)

// Start serving!
m.Serve()

自定義 Matcher

cmux 的實現上是對 payload 進行匹配,cmux.HTTP1Fast 是一個匹配函數,爲內置的集中匹配函數中的一種,這類匹配函數可以同時設置多個。
內部將 net.Conn 的數據讀入至 buffer 內,依次調用各個匹配函數對這個 buffer 進行分析,如果匹配成功則 httpL 被返回,httpS 服務收到請求。

所以我們可以自定義一些 Matcher,比如一些攜帶 Magic/字符串 頭的數據

const (
	PacketMagic = 0x00114514
	PacketToken = "xyz_token"
)

func PacketMagicMatcher(r io.Reader) bool {
	buf := make([]byte, 4)
	n, err := io.ReadFull(r, buf)
	if err != nil {
		return false
	}
	return binary.BigEndian.Uint32(buf[:n]) == PacketMagic
}

func PacketTokenMatcher(r io.Reader) bool {
	buf := make([]byte, len(PacketToken))
	n, err := io.ReadFull(r, buf)
	if err != nil {
		return false
	}
	return string(buf[:n]) == PacketToken
}

使用上和 cmux.HTTP1Fast 相同,需要注意的是,net.Conn 頭部的 Magic/Token 是和連接相關的,和業務數據無關在使用這些數據之前,需要先將其讀取出來

tcpMux := cmux.New(lis)
magicLis := tcpMux.Match(PacketMagicMatcher)

go func() {
	conn, _ := magicLis.Accept()
	buf := make([]byte, 4)
	io.ReadAtLeast(conn, buf, len(buf)) // Read header magic length
	// Handle data ...
}()

tcpMux.Serve()

多 mux 場景

需求:只開放一個端口 12345,需要支持

  • HTTP 協議的包下載
  • 基於 TLS 的 gRPC 服務
  • 基於 TLS 的自定義服務

分析:對於 HTTP 和 TLS 需要使用一個 mux 進行區分,TLS 中的 gRPC 和 自定義服務需要再通過一個 mux 區分

參考的實現(截取了一部分業務代碼)

// TCP 分流 http/tls
tcpMux := cmux.New(lis)
installerL := tcpMux.Match(cmux.HTTP1Fast())
anyL := tcpMux.Match(cmux.Any())

// tls.NewListener(anyL, ...)
mtlsL, err := mTLSListener(anyL, tlsEnable, tlsCertPath, tlsKeyPath, tlsCAPath)
if err != nil {
	return err
}
tlsMux := cmux.New(mtlsL)
grpcL := tlsMux.Match(cmux.HTTP2())
gwL := tlsMux.Match(gw.Matcher)

smux 的使用

還是放一些官方的簡單示例

func client() {
    // Get a TCP connection
    conn, err := net.Dial(...)
    if err != nil {
        panic(err)
    }
    // Setup client side of smux
    session, err := smux.Client(conn, nil)
    if err != nil {
        panic(err)
    }
    // Open a new stream
    stream, err := session.OpenStream()
    if err != nil {
        panic(err)
    }
    // Stream implements io.ReadWriteCloser
    stream.Write([]byte("ping"))
    stream.Close()
    session.Close()
}

func server() {
    // Accept a TCP connection
    conn, err := listener.Accept()
    if err != nil {
        panic(err)
    }
    // Setup server side of smux
    session, err := smux.Server(conn, nil)
    if err != nil {
        panic(err)
    }
    // Accept a stream
    stream, err := session.AcceptStream()
    if err != nil {
        panic(err)
    }
    // Listen for a message
    buf := make([]byte, 4)
    stream.Read(buf)
    stream.Close()
    session.Close()
}

smux.Session 和 net.Conn 對應,smux.Stream 實現了 net.Conn 接口,所以使用起來和普通的連接無異。smux.Session 是雙向的,Client/Server 的區分僅僅是內部的 Id 區別,這就爲反向連接打下了基礎

基於 smux 的反向連接

對於一個普通的 TCP 服務而言,A(client) -> B(server)。在 B 上 建立 gRPC/HTTP 服務是一件非常自然的事情。

在某些場景下,比如 A 在公網,B 在內網,不做公網映射的話,只能夠 B(client) -> A(server)。但是這個情況下,B 上面的 gRPC/HTTP 的服務就不能直接建立了。

上面說過 smux.Session 再使用上時沒有方向的,並且提供了和 net.Listener 相近的接口,如果將 smux.Session 封裝實現 net.Listener,加上 smux.Stream 是 net.Conn,那麼 B 連接 A 繼續在 B 上建立 gRPC/HTTP 服務是可以的,內部感知不到具體的實現細節。

對 smux.Session 的封裝如下

type SmuxSession struct{ *smux.Session }

func (s *SmuxSession) Addr() net.Addr            { return s.Session.LocalAddr() }
func (s *SmuxSession) Accept() (net.Conn, error) { return s.Session.AcceptStream() }
func (s *SmuxSession) Close() error              { return s.Session.Close() }

將 B(tcp:client) -> A(tcp:server) 的場景改爲 A(gRPC:client) -> B(gRPC:Server),關鍵實現如下:

// 忽略錯誤處理
// 在 A 上的實現如下,cc 爲後續使用的 gRPC client
func handleConn(conn net.Conn) {
	sess, _ := smux.Client(conn, nil)
	cc, _ := grpc.Dial(
		"",
		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { return sess.OpenStream() }),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
    // do something.
}

// 在 B 的實現如下
func dialAndServe() {
    conn, _ := net.Dial(...)
    sess, _ := smux.Server(conn, nil)
    return g.server.Serve(&SmuxSession{Session: sess})
}

cmux/smux 結合使用

其實 cmux/smux 是兩個不同的維度:單端口/單連接,所以只要保證做好 net.Listener/net.Conn 的抽象,使用起來是感知不到的。

比如上面的反向連接中,handleConn 在上面的 gwL := tlsMux.Match(gw.Matcher) 中驅動的

// A 的實現
func handleConn(conn net.Conn) {
    // 增加的代碼:讀取 Header
	io.CopyN(io.Discard, conn, int64(len(gw.MatcherToken)))

	sess, _ := smux.Client(conn, nil)
	cc, _ := grpc.Dial(
		"",
		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { return sess.OpenStream() }),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
}

// B 的實現
func dialAndServe() {
    conn, _ := net.Dial(...)
    conn.Write([]byte(gw.MatcherToken))  // 增加的代碼:發送一個頭
    sess, _ := smux.Server(conn, nil)
    return g.server.Serve(&SmuxSession{Session: sess})
}

性能壓測

性能壓測代碼見此.

長連接的讀寫

測試的連接的 case:

  • TCP 連接,作爲一個參考基準
  • TLS
  • Smux
    • TCP,底層協議爲 TCP 的情況
    • TLS,底層協議爲 TLS 的情況
  • Cmux
    • TCP,底層協議爲 TCP 的多個 Matcher
    • TLS,底層協議爲 TLS 的單個 Matcher,複合 mux
$ go test -v -benchtime=10s  -benchmem -run=^$ -bench ^BenchmarkConn .
goos: linux
goarch: amd64
pkg: benchmark/connection
cpu: 12th Gen Intel(R) Core(TM) i7-12700
BenchmarkConnCmux
BenchmarkConnCmux/MagicMatcher
BenchmarkConnCmux/MagicMatcher-20                     997550             11862 ns/op        11049.73 MB/s          0 B/op          0 allocs/op
BenchmarkConnCmux/TokenMatcher
BenchmarkConnCmux/TokenMatcher-20                     958461             11714 ns/op        11188.94 MB/s          0 B/op          0 allocs/op
BenchmarkConnCmux/TLSMatcher
BenchmarkConnCmux/TLSMatcher/TLS
BenchmarkConnCmux/TLSMatcher/TLS-20                   295111             40471 ns/op        3238.68 MB/s         192 B/op          7 allocs/op
BenchmarkConnCmux/TLSMatcher/MagicMatcher
BenchmarkConnCmux/TLSMatcher/MagicMatcher-20          296203             39566 ns/op        3312.75 MB/s         192 B/op          7 allocs/op
BenchmarkConnCmux/AnyMatcher
BenchmarkConnCmux/AnyMatcher-20                       932871             11870 ns/op        11041.90 MB/s          0 B/op          0 allocs/op
BenchmarkConnSmux
BenchmarkConnSmux/OverTCP
BenchmarkConnSmux/OverTCP-20                          438889             24703 ns/op        5305.97 MB/s        1380 B/op         26 allocs/op
BenchmarkConnSmux/OverTLS
BenchmarkConnSmux/OverTLS-20                          210336             57345 ns/op        2285.69 MB/s        1596 B/op         36 allocs/op
BenchmarkConnTCP
BenchmarkConnTCP-20                                   917894             12120 ns/op        10814.60 MB/s          0 B/op          0 allocs/op
BenchmarkConnTLS
BenchmarkConnTLS-20                                   292843             40310 ns/op        3251.57 MB/s         192 B/op          7 allocs/op
PASS
ok      benchmark/connection    106.287s

短連接的讀寫

較長連接的 case 變化,減少 Cmux 爲一個Matcher,額外引入了 net.HTTP 和 fasthttp 參與 PK。

短連接的測試,包含了連接的建立和關閉的場景。

$ go test -v -benchtime=10s  -benchmem -run=^$ -bench ^BenchmarkEcho .
goos: linux
goarch: amd64
pkg: benchmark/connection
cpu: 12th Gen Intel(R) Core(TM) i7-12700
BenchmarkEchoCmux
BenchmarkEchoCmux-20                       83162            164356 ns/op         797.49 MB/s       34005 B/op         26 allocs/op
BenchmarkEchoFastHTTP
BenchmarkEchoFastHTTP-20                  144302             95231 ns/op        1376.36 MB/s       12941 B/op         41 allocs/op
BenchmarkEchoNetHTTP
BenchmarkEchoNetHTTP-20                    65124            239187 ns/op         547.99 MB/s      370816 B/op         59 allocs/op
BenchmarkEchoSmux
BenchmarkEchoSmux/OverTCP
BenchmarkEchoSmux/OverTCP-20              153706             70494 ns/op        1859.34 MB/s       79824 B/op         85 allocs/op
BenchmarkEchoSmux/OverTLS
BenchmarkEchoSmux/OverTLS-20              106585            112120 ns/op        1169.04 MB/s       81776 B/op        102 allocs/op
BenchmarkEchoTCP
BenchmarkEchoTCP-20                       308125             39266 ns/op        3338.05 MB/s        1078 B/op         23 allocs/op
BenchmarkEchoTLS
BenchmarkEchoTLS-20                        10000           1988704 ns/op          65.91 MB/s      241188 B/op       1112 allocs/op
PASS
ok      benchmark/connection    112.673s

性能壓測的結論

對照 TCP 爲基準

  • cmu
    • 長連接下對性能的影響很小,接近 TCP,測試有的時候還會比 TCP 高一些
    • 短鏈接下,性能比較低;應該是在 Accept 返回 cmux.MuxConn 之前慢的,多了一次內存拷貝,函數匹配,chan 傳遞
  • smux
    • 底層協議爲 TCP,性能相對 TCP 50% 左右,長連接和短連接表現差不多
    • 底層協議爲 TLS,性能相對 TCP 25-30% 左右,長連接和短連接表現也接近這個比例
  • TLS 正常的 Read/Write 性能大概在 50% 左右,在短連接的情況下,性能非常差(TLS 握手攻擊原理)
  • fasthttp 速度非常快

從性能的角度看,smux 適用於頻繁建立 TLS 短連接的場景,將短連接變成了一般的 TLS 長連接,參考 BenchmarkConnSmux/OverTLS-20BenchmarkConnTLS-20 性能只下降了 30% 左右,還是比較能接受的。

參考

smux, A Stream Multiplexing Library for golang with least memory usage(TDMA)。
cmux, Connection multiplexer for GoLang: serve different services on the same port!
kcptun開發小記, smux 作者在知乎上的一篇文章,裏面提到了 smux 被開發的原因。
benchmark for connections,各種連接的壓測代碼和結果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章