Go:Unix域套接字

2021-10-21  本文已影响0人  Go语言由浅入深

关于同一个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非常相似,因此支持两者可互换的成本非常低。

上一篇下一篇

猜你喜欢

热点阅读