lesismal / nbio

Pure Go 1000k+ connections solution, support tls/http1.x/websocket and basically compatible with net/http, with high-performance and low memory cost, non-blocking, event-driven, easy-to-use.
MIT License
2.16k stars 153 forks source link

UDP support #93

Closed UladzimirTrehubenka closed 2 years ago

UladzimirTrehubenka commented 3 years ago

Any plans to add UDP? I have seen lines in gopher.go

    // tcp* supported only by now, there's no plan for other protocol such as udp,
    // because it's too easy to write udp server/client.

but well, e.g. I want to implement DNS proxy which should respond on UDP/TCP port 53, it is ridiculous to implement another one flow instead using nbio itself.

Side question: as I understand onData() have to return ASAP to avoid blocking the event loop, is call go handler() inside onData() enough or need to use some goroutine pool (which one actually)?

lesismal commented 3 years ago

Any plans to add UDP? I have seen lines in gopher.go

For TCP, we aim to solve 1000k problem, we support poller to avoid serving each connection with 1 or more goroutines which cost lots of goroutines then memory/gc/schedule.

There are some details of differences we need to consider between TCP and UDP if we both support them in nbio:

  1. Shall we support OnOpen/OnClose for UDP?
  2. If we did 1, we need to maintain UDP connection info, then we may need use timer to do some alive management for each UDP socket.

There should be more details for real business that we need to handle TCP and UDP in a different way.

It is easy for golang to wrap UDP using std and no need to make that many goroutines. Since std+UDP is easy, what can we benefit from the support for UDP? I think we can get nothing but the cost is greater than the benefit.

lesismal commented 3 years ago

Side question: as I understand onData() have to return ASAP to avoid blocking the event loop, is call go handler() inside onData() enough or need to use some goroutine pool (which one actually)?

Usually, we should use some goroutine pool. In the implementation of nbio-http, we accept the user's argument, if it's nil, we use a goroutine pool: https://github.com/lesismal/nbio/blob/master/nbhttp/server.go#L261

acgreek commented 3 years ago

I vote no state, no OnOpen/OnClose; UDP should be stateless

On Mon, Sep 20, 2021 at 9:37 AM lesismal @.***> wrote:

Any plans to add UDP? I have seen lines in gopher.go

For TCP, we aim to solve 1000k problem, we support poller to avoid serving each connection with 1 or more goroutines which cost lots of goroutines then memory/gc/schedule.

There are some details of differences we need to consider between TCP and UDP if we both support them in nbio:

  1. Shall we support OnOpen/OnClose for UDP?
  2. If we did 1, we need to maintain UDP connection info, then we may need use timer to do some alive management for each UDP socket.

There should be more details for real business that we need to handle TCP and UDP in a different way.

It is easy for golang to wrap UDP using std and no need to make that many goroutines. Since std+UDP is easy, what can we benefit from the support for UDP? I think we can get nothing but the cost is greater than the benefit.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/lesismal/nbio/issues/93#issuecomment-922936077, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJSWEXOS2XNBAX7PD66NKLUC42KFANCNFSM5ELY2Q4Q . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

UladzimirTrehubenka commented 3 years ago

I also guess so - no OnOpen/OnClose. And regarding There should be more details for real business that we need to handle TCP and UDP in a different way https://github.com/tidwall/evio has separated flows for UDP and TCP (but has no TLS support). And the idea is to use nbio as a base for a DNS service with performance comparable with Unbound DNS resolver written on C lang.

And regarding hack TLS from golang - probably need to propose appropriate changes in golang itself because I don't know who would use the lib with such kind of tricks except original repo authors.

lesismal commented 3 years ago

I list the differences is to say that I don't want to support UDP, but not to say I want to support UDP and thinking about what we want to do for UDP:joy:.

Unlike TCP, the UDP protocol does not promise the packets comes by order, if you want to support TLS on UDP, you need to implement reliability as TCP, then why not use KCP or UTP directly?

For DNS like services, one request packet, one response packet, we don't need to worry about the packets' order and even don't care if the packet is lost on the way, we don't need to implement features about the reliability such as ack, window size, and retransmission. I think it's much easier for std+UDP, and no need to implement a poller that supports UDP in golang.

Also, I think evio, or other poller frameworks which have already supported UDP, are doing jobs that are meaningless on UDP.

lesismal commented 3 years ago

For TCP, we aim to solve 1000k problem, we support poller to avoid serving each connection with 1 or more goroutines which cost lots of goroutines then memory/gc/schedule.

But for UDP, it's much easier to control the num of goroutines, and the std+UDP is better than a poller-like framework.

lesismal commented 3 years ago

Since we don't need OnOpen/OnClose, std+UDP is so easy:

socket, err := net.ListenUDP("udp4", &net.UDPAddr{
    IP:   net.IPv4(0, 0, 0, 0),
    Port: 8888,
})
if err != nil {
    log.Fatal(err)
}
defer socket.Close()

// GoroutineNum like the num of poller goroutine
for i := 0; i < GoroutineNum; i++ {
    go func() {
        data := make([]byte, YourMaxUDPPacketLength)
        for {
            read, remoteAddr, err := socket.ReadFromUDP(data)
            if err != nil {
                log.Fatal(err)
                continue
            }

            // handle the request
            senddata := []byte("some data")
            _, err = socket.WriteToUDP(senddata, remoteAddr)
            if err != nil {
                log.Fatal(err)
            }
        }
    }()
}

So, why we must handle UDP with another poller framework that is much more complex?

lesismal commented 3 years ago

Let's see how easy it is to implement a full example that use the same onData handler for both TCP(nbio) and UDP(std, without OnOpen/OnClose):

// server.go
package main

import (
    "errors"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"
    "runtime"
    "time"

    "github.com/lesismal/nbio"
)

var (
    port = 8888

    gopher      *nbio.Gopher
    udpListener *net.UDPConn
)

// echo handler
func onDataBothForTcpAndUdp(c net.Conn, data []byte) {
    c.Write(data)
}

func main() {
    startUDPServer()
    defer stopUDPServer()

    startTCPServer()
    defer stopTCPServer()

    interrupt := make(chan os.Signal, 1)
    signal.Notify(interrupt, os.Interrupt)
    <-interrupt
}

func startTCPServer() {
    gopher = nbio.NewGopher(nbio.Config{
        Network:            "tcp",
        Addrs:              []string{fmt.Sprintf(":%v", port)},
        MaxWriteBufferSize: 6 * 1024 * 1024,
    })

    gopher.OnData(func(c *nbio.Conn, data []byte) {
        onDataBothForTcpAndUdp(c, data)
    })

    err := gopher.Start()
    if err != nil {
        log.Fatal(err)
    }
}

func stopTCPServer() {
    gopher.Stop()
}

func startUDPServer() {
    var err error
    udpListener, err = net.ListenUDP("udp4", &net.UDPAddr{
        IP:   net.IPv4(0, 0, 0, 0),
        Port: port,
    })
    if err != nil {
        log.Fatal(err)
    }

    for i := 0; i < runtime.NumCPU(); i++ {
        go func() {
            data := make([]byte, 4096)
            for {
                read, remoteAddr, err := udpListener.ReadFromUDP(data)
                if err != nil {
                    log.Println("udp read failed:", err)
                    continue
                }
                onDataBothForTcpAndUdp(&UDPConn{udpSocket: udpListener, remoteAddr: remoteAddr}, data[:read])
            }
        }()
    }
}

func stopUDPServer() {
    udpListener.Close()
}

type UDPConn struct {
    remoteAddr *net.UDPAddr
    udpSocket  *net.UDPConn
}

func (c *UDPConn) Read(b []byte) (n int, err error) {
    return 0, errors.New("unsupported")
}

func (c *UDPConn) Write(b []byte) (n int, err error) {
    return c.udpSocket.WriteToUDP(b, c.remoteAddr)
}

func (c *UDPConn) Close() error {
    return errors.New("unsupported")
}

func (c *UDPConn) LocalAddr() net.Addr {
    return c.udpSocket.LocalAddr()
}

func (c *UDPConn) RemoteAddr() net.Addr {
    return c.remoteAddr
}

func (c *UDPConn) SetDeadline(t time.Time) error {
    return errors.New("unsupported")
}

func (c *UDPConn) SetReadDeadline(t time.Time) error {
    return errors.New("unsupported")
}

func (c *UDPConn) SetWriteDeadline(t time.Time) error {
    return errors.New("unsupported")
}
// client.go
package main

import (
    "fmt"
    "log"
    "net"
    "time"
)

var (
    port = 8888
)

func udpEchoClient() {
    socket, err := net.DialUDP("udp4", nil, &net.UDPAddr{
        IP:   net.IPv4(127, 0, 0, 1),
        Port: port,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer socket.Close()

    sendBuf := []byte("hello from udp")
    recvBuf := make([]byte, 1024)
    for i := 0; true; i++ {
        _, err = socket.Write(sendBuf)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("udp send %v: %v\n", i, string(sendBuf))

        nread, _, err := socket.ReadFromUDP(recvBuf)
        if err != nil {
            return
        }
        fmt.Printf("udp recv %v: %v\n", i, string(recvBuf[:nread]))
        time.Sleep(time.Second)
    }
}

func tcpEchoClient() {
    socket, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", port))
    if err != nil {
        log.Fatal(err)
    }
    defer socket.Close()

    sendBuf := []byte("hello from tcp")
    recvBuf := make([]byte, 1024)
    for i := 0; true; i++ {
        _, err = socket.Write(sendBuf)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("tcp send %v: %v\n", i, string(sendBuf))

        nread, err := socket.Read(recvBuf)
        if err != nil {
            return
        }
        fmt.Printf("tcp recv %v: %v\n", i, string(recvBuf[:nread]))
        time.Sleep(time.Second)
    }
}

func main() {
    go udpEchoClient()
    tcpEchoClient()
}
lesismal commented 3 years ago

So, I really suggest any poller frameworks of golang give up to support UDP.

UladzimirTrehubenka commented 3 years ago

Usually processing takes some time and code should look like this:

        for {
            read, remoteAddr, err := socket.ReadFromUDP(data)
            if err != nil {
                log.Fatal(err)
                continue
            }
                        go func(data []byte, remoteAddr *net.UDPAddr) {
                    // handle the request
                    senddata, err := handle(data)
                                ...
                    _, err = socket.WriteToUDP(senddata, remoteAddr)
                    ...
            }(data, remoteAddr)
        }

to avoid influence different UDP packets processing time each other. And ReadFromUDP() costs some CPU time, where as with events flow a data is read when it is exactly getting.

lesismal commented 3 years ago

Usually processing takes some time and code should look like this:

I know this, I use go or goroutine pool in lots of other places, users should control the onData handler themselves. In my example, I just want to show the point that how to handle TCP and UDP with the same handler. And furthermore, Read and parse operations which are cpu cost, and to reuse the read buffer and avoid copy and more buffer allocation, we should usually not use go directly, but should go when you get a full message. We need avoid dirty buffer too:

data := make([]byte, 4096)
for {
    read, remoteAddr, err := udpListener.ReadFromUDP(data)
    if err != nil {
        log.Println("udp read failed:", err)
        continue
    }

    go func(data []byte, remoteAddr *net.UDPAddr) {
        // befor you have handled the data, it may have been changed by next ReadFromUDP
        senddata, err := handle(data)
        _, err = socket.WriteToUDP(senddata, remoteAddr)
    }(data, remoteAddr)
}

BTW, even for TCP, poller frameworks are not faster than std for normal scenarios that do not have a huge num of online connections, only when there are too many connections that cost lots of goroutines will make std slow.

lesismal commented 3 years ago

Here is some benchmark: https://github.com/lesismal/go-net-benchmark https://github.com/lesismal/go-net-benchmark/issues/1#issuecomment-901185627

But for the normal scenario without too large num of online connections, poller frameworks do not perform better than std. Because we need to implement async-streaming-parser, solve problems of buffer life cycle management, buffer pool and object pool, goroutine pool, which are stack unfriendly and much more difficult than std. If your online num is smaller than 100k, std should be better.

lesismal commented 2 years ago

Summary: The standard library udp is simpler and easier to use, and the support for udp will have a bad impact on tcp (many functions that support tcp need to be left blank for udp), we will not plan to support udp.

Thank you for your feedback, I'll close this issue.