美文网首页
Go:Unix域套接字

Go:Unix域套接字

作者: Go语言由浅入深 | 来源:发表于2021-10-21 07:07 被阅读0次

    关于同一个Linux主机上的进程之间的进程间通信(IPC)方式,有多个选择:例如FIFO、管道、共享内存、套接字等等。最有趣的选项之一是Unix域套接字,它结合了socket的API和其他更高性能单机方法。

    这篇文章展示了一些在Go中使用Unix域套接字的简单例子,并与TCP回环套接字的基准测试进行对比。

    Unix域套接字(unix domain sockets-UDS)

    Unix域套接字(UDS)已经有很长的历史了,可追溯到20世纪80年代的原始BSD套接字规范。维基百科中的定义:

    Unix域套接字或IPC套接字(进程间通信套接字)是一个数据通信端点,用于在同一主机操作系统上执行进程之间数据交换。

    UDS支持流(类似TCP)和数据报(类似UDP);本文主要关注流API。使用UDS来实现进程间通信和使用回环接口(localhost或127.0.0.1)基于常规的TCP套接字类似,但有一个关键不同点:性能。虽然TCP环回接口也可以跳过完整的TCP/IP网络堆栈的一些复杂性,但它保留了许多其他的功能(例如ack、TCP流控制等)。这些是为可靠的跨主机通信而设计的,但在单机上,它们是不必要的负担。本文将探讨UDS的一些性能优势。

    还有一些额外的差异。例如,UDS使用文件系统中的路径作为其地址,我们可以使用目录和文件权限来控制对套接字的访问,从而简化认证过程。在这里就不列出所有的区别了;如需更多信息,请查看维基百科和Beej的UNIX IPC指南等资源。

    当然,与TCP套接字相比,UDS最大的缺点是单机限制。对于使用TCP套接字编写的代码,我们只需要将地址从本地更改为远程主机IP地址,就可正常工作。也就是说,UDS的性能优势非常显著,而且API与TCP套接字非常相似,因此编写同时支持这两种类型的代码(单机使用UDS,远程IPC使用TCP)非常容易。

    在Go中使用Unix domain socket

    我们从一个简单例子开始,使用Go监听Unix域套接字:

    package main
    
    import (
        "io"
        "log"
        "net"
        "os"
    )
    
    const SockAddr = "/tmp/echo.sock"
    
    func echoServer(c net.Conn) {
        log.Printf("Client connected [%s]", c.RemoteAddr().Network())
        io.Copy(c, c)
        c.Close()
    }
    
    func main() {
        if err := os.RemoveAll(SockAddr); err != nil {
            log.Fatal(err)
        }
    
        l, err := net.Listen("unix", SockAddr)
        if err != nil {
            log.Fatal("listen error:", err)
        }
        defer l.Close()
    
        for {
            // Accept new connections, dispatching them to echoServer
            // in a goroutine.
            conn, err := l.Accept()
            if err != nil {
                log.Fatal("accept error:", err)
            }
    
            go echoServer(conn)
        }
    }
    

    UDS通过文件系统中的路径进行识别;对于服务器端代码,我们使用/tmp/echo.sock。以上代码从删除这个文件开始,如果文件存在说明已经有服务在监听会报错。

    当服务器关闭时,表示套接字的文件可以保留在文件系统中,除非服务停止后自动清理。如果我们用相同的套接字路径重新运行另一个服务器,会得到以下错误:

    2021/10/20 23:23:24 listen error:listen unix /tmp/echo.sock: bind: address already in use
    

    为了防止这种情况,服务端首先删除套接字文件(如果存在的话)。 运行服务端代码,我们可以使用Netcat与它进行交互,使用-U标志请求连接到UDS:

    $ nc -U /tmp/echo.sock
    

    连接上以后无论你输入什么,服务器都会返回输入的内容。按Ctl+c终止会话。或者,我们可以在Go中编写一个简单的客户端,它连接到服务器,发送消息,等待响应并退出。客户端代码如下:

    package main
    
    import (
        "io"
        "log"
        "net"
        "time"
    )
    
    func reader(r io.Reader) {
        buf := make([]byte, 1024)
        n, err := r.Read(buf[:])
        if err != nil {
            return
        }
        println("Client got:", string(buf[0:n]))
    }
    
    func main() {
        c, err := net.Dial("unix", "/tmp/echo.sock")
        if err != nil {
            log.Fatal(err)
        }
        defer c.Close()
    
        go reader(c)
        _, err = c.Write([]byte("hi"))
        if err != nil {
            log.Fatal("write error:", err)
        }
        reader(c)
        time.Sleep(100 * time.Millisecond)
    }
    

    我们可以看到,编写UDS服务器和客户端与编写常规套接字服务和客户端非常相似。唯一的不同就是必须传入“unix”作为网络类型参数到net.Listen和net.Dial中。其余代码都是一样的。显然,这使得编写通用的服务端和客户端代码变得非常容易,这些代码与所使用的套接字的实际类型无关。

    基于UDS实现HTTP和RPC协议

    网络协议如HTTP和各种形式的RPC,并不特别关心网络栈的底层是如何实现的,只要能对一些功能保持一致就可以。
    Go标准库自带rpc包,使得实现RPC服务器和客户端变得非常简单。下面是一个使用UDS实现简单的服务器:

    const SockAddr = "/tmp/rpc.sock"
    
    type Greeter struct {
    }
    
    func (g Greeter) Greet(name *string, reply *string) error {
        *reply = "Hello, " + *name
        return nil
    }
    
    func main() {
        if err := os.RemoveAll(SockAddr); err != nil {
            log.Fatal(err)
        }
    
        greeter := new(Greeter)
        rpc.Register(greeter)
        rpc.HandleHTTP()
        l, e := net.Listen("unix", SockAddr)
        if e != nil {
            log.Fatal("listen error:", e)
        }
        fmt.Println("Serving...")
        http.Serve(l, nil)
    }
    

    注意,我们使用的是rpc服务器的HTTP版本。它用HTTP包注册一个HTTP处理程序,实际的服务使用标准HTTP.serve完成。这里的网络栈看起来像这样:



    这里提供了一个可以连接到上面所示服务器的RPC客户端。它使用标准的rpc.Client.Call方法连接到服务器。

    package main
    
    import (
        "fmt"
        "log"
        "net/rpc"
    )
    
    func main() {
        client, err := rpc.DialHTTP("unix", "/tmp/rpc.sock")
        if err != nil {
            log.Fatal("dialing:", err)
        }
        // Synchronous call
        name := "Joe"
        var reply string
        err = client.Call("Greeter.Greet", &name, &reply)
        if err != nil {
            log.Fatal("greeter error:", err)
        }
        fmt.Printf("Got '%s'\n", reply)
    }
    

    回环套接和Unix域套接字的基准测试对比

    基准测试是很难的标准到,所以这里的对比结果仅供参考。这里运行两种基准测试:一种是延迟,另一种是吞吐量。

    延时的基准测试代码在这里使用-help命令行参数查看如何使用,代码非常简单。其思想是在服务端和客户端口之间发送一个小数据包(默认为128字节)。客户端测量发送一条这样的消息和接收一条消息所需的时间,并通过发送多次取平均值。

    在我的机器上,我看到TCP环回套接字的平均延迟为3.6微秒,UDS的平均延迟为2.3微秒。

    吞吐量/带宽基准测试在概念上比延迟基准简单。服务器侦听套接字并获取它能获得的所有数据(然后丢弃它)。客户端发送大数据包(数百KB或更多),并测量每个数据包发送所需的时间;发送是同步完成的,客户端希望在单个调用中发送整个消息,所以如果包的大小足够大,可近似带宽。

    显然,吞吐量度量对于较大的消息更具有代表性。我尝试增加,直到吞吐量提升逐渐减少。

    对于更小的数据包,我认为UDS优于TCP: 在512K包中,分别为10GB /秒和9.4 GB/秒。对于更大的数据包(16-32 MB),差异变得微不足道(两者都以大约13 GB/秒的速度减少)。有趣的是,对于一些数据包(比如64K), TCP套接字在我的机器上更占优势。

    对于小的数据包,UDS的性能比TCP的块很多超过2倍以上。在大多数情况下,我认为延迟更重要—它们更适用于衡量RPC服务器和数据库性能。在某些情况下,比如在套接字上的视频流或其他“大数据”,您可能需要仔细选择包的大小,以优化所使用的特定机器的性能。

    Unix域套接字在实际项目中使用

    我很好奇在真实的Go项目中是否真的使用了UDS。没错!在GitHub上搜索了几分钟,很快发现用go写的开源云基础设施项目都使用了UDS,例如:runc、moby (Docker)、k8s、lstio—几乎每个项目都是我们熟悉的。

    正如基准测试所证明的,当客户端和服务器都在同一台主机上时,使用UDS具有显著的性能优势。而且UDS和TCP套接字的API非常相似,因此支持两者可互换的成本非常低。

    相关文章

      网友评论

          本文标题:Go:Unix域套接字

          本文链接:https://www.haomeiwen.com/subject/qwvxaltx.html