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