关于 huizhou | 萝卜

Golang 分布式相关的主题,读书笔记

RSS 地址: https://huizhou92.com/zh-cn/index.xml

请复制 RSS 到你的阅读器,或快速订阅到 :

huizhou | 萝卜 RSS 预览

Go runtime.SetFinalizer

2024-09-04 19:33:11

Featured image of post Go runtime.SetFinalizer

解析

如果我们希望在一个对象被gc之前,做一些资源释放的工作,我们可以使用 runtime.SetFinalizer。就像函数返回之前执行defer释放资源一样。比如下面的代码:

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

List1: example By runtime.SetFinalizer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

type MyStruct struct {  
    Name  string  
    Other *MyStruct  
}

func main() {  
    x := MyStruct{Name: "X"}  
    runtime.SetFinalizer(&x, func(x *MyStruct) {  
       fmt.Printf("Finalizer for %s is called\n", x.Name)  
    })  
    runtime.GC()  
    time.Sleep(1 * time.Second)  
    runtime.GC()  
}

官方文档中对SetFinalizer的一些解释,主要含义是对象可以关联一个SetFinalizer函数, 当GC检测到unreachable对象有关联的SetFinalizer函数时,会执行关联的SetFinalizer函数, 同时取消关联。 这样当下一次GC的时候,对象重新处于unreachable状态并且没有SetFinalizer关联, 就会被回收。

仔细看文档,还有几个需要注意的点:

list 2: runtime.SetFinalizer memory leak

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// MyStruct 是一个简单的结构体,包含一个指针字段。  
type MyStruct struct {  
    Name  string  
    Other *MyStruct  
}  
  
func main() {  
    x := MyStruct{Name: "X"}  
    y := MyStruct{Name: "Y"}  
  
    x.Other = &y  
    y.Other = &x  
    runtime.SetFinalizer(&x, func(x *MyStruct) {  
       fmt.Printf("Finalizer for %s is called\n", x.Name)  
    })  
    time.Sleep(time.Second)  
    runtime.GC()  
    time.Sleep(time.Second) 
    runtime.GC() 
}

x 永远不会被释放。正确的做法应该是, 在不需要使用 对象的时候,显式移除 Finalizer runtime.SetFinalizer(&x, nil)

实际应用

在业务代码中很少使用runtime.SetFinalizer (我没使用过)但是再Go源码中 有比较多的使用, 比如
net/http

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (fd *netFD) setAddr(laddr, raddr Addr) {  
    fd.laddr = laddr  
    fd.raddr = raddr  
    runtime.SetFinalizer(fd, (*netFD).Close)  
}  
  
func (fd *netFD) Close() error {  
    if fd.fakeNetFD != nil {  
       return fd.fakeNetFD.Close()  
    }  
    runtime.SetFinalizer(fd, nil)  
    return fd.pfd.Close()  
}

go-cache库提供了SetFinalizer的一种用法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
	items := make(map[string]Item)
	return newCacheWithJanitor(defaultExpiration, cleanupInterval, items)
}

func newCacheWithJanitor(de time.Duration, ci time.Duration, m map[string]Item) *Cache {
	c := newCache(de, m)
	C := &Cache{c}
	if ci > 0 {
		runJanitor(c, ci)
		runtime.SetFinalizer(C, stopJanitor)
	}
	return C
}

func runJanitor(c *cache, ci time.Duration) {
	j := &janitor{
		Interval: ci,
		stop:     make(chan bool),
	}
	c.janitor = j
	go j.Run(c)
}

func stopJanitor(c *Cache) {
	c.janitor.stop <- true
}

func (j *janitor) Run(c *cache) {
	ticker := time.NewTicker(j.Interval)
	for {
		select {
		case <-ticker.C:
			c.DeleteExpired()
		case <-j.stop:
			ticker.Stop()
			return
		}
	}
}

newCacheWithJanitor在ci参数大于0时,将开启后台协程,通过ticker定期清理过期缓存。一旦从stop chan中读到值,则异步协程退出。
stopJanitor为指向Cache的指针C定义了finalizer函数stopJanitor。一旦我们在业务代码中不再有指向Cache的引用时,c将会进行GC流程,首先执行stopJanitor函数,其作用是为内部的stop channel写入值,从而通知上一步的异步清理协程,使其退出。这样就实现了业务代码无感知的异步协程回收,是一种优雅的退出方式。

Mac: tmux 最佳实践

2024-08-15 18:56:30

Featured image of post Mac: tmux 最佳实践

tmux 是一个终端多路复用器:它允许从单个屏幕创建、访问和控制多个终端。 tmux 可能会与屏幕分离并继续在后台运行,然后重新连接。

第一次看到tmux 的介绍的时候,我其实没什么感觉,觉得没什么.后面用terminal多了,遇到了一些问题,尝试解决,然后我重新认真学习tmux。它改变了我电脑的习惯。
本文将会花十分钟介绍,tmux 的基本使用场景

什么是 terminal session

回忆一下,你日常工作时候使用terminal 的场景,打开一个 Iterm2  窗口,然后使用ssh 连接一台远程机器,然后进入特定目录,开始工作,完成工作后,关闭Iterm2 窗口。上面这些步骤,就是一个 terminal session 它的生命周期是跟 terminal 的生命周期绑定在一起的,关闭窗口后session就结束,然后下次我们要工作的时候,就重复上面的步骤。
有什么办法,可以将session跟terminal 开来,下次再操作的时候,就不需要重复上面的步骤? tmux可以帮助我们实现这个功能。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

我们看看再 tmux 下如何实现上面这个功能把。
ITerm2
在这个演示中,我首先用tmux new -s test 创建了一个tmux session ,然后登录我的一台开发机器,然后再将session 剥离,回到Iterm2 终端,最后我又使用 tmux attach-session 回到原来的开发机器,跟刚才退出去的时候一模一样。 这就是 tmux 基础的应用了,剥离session,并且保持session 状态。

TL;DR

tmux 可以帮助我们实现:

  1. 它允许在单个窗口中,同时访问多个会话。这对于同时运行多个命令行程序很有用。
  2. 它可以让新窗口"接入"已经存在的会话。
  3. 它允许每个会话有多个连接窗口,因此可以多人实时共享会话。
  4. 它还支持窗口任意的垂直和水平拆分。

tmux 基本用法

安装 tmux

在Mac上,可以使用brew 来安装 tmux

1
brew install  tmux

其他环境请参考:Installing tmux

启动tmux 与退出 tmux

安装完成后, 在 terminal中输入 tmux 就可以启动一个 tmux session 。输入exit 就会退出 tmux session ,返回到原来的 terminal 页面。
ITerm2 2

前缀键

跟其他软件不一样的是: tmux 中所有的快捷键都需要和前缀快捷键 ⌃b 来组合使用(注: 为 Mac 的 control 键),这样其实挺好的,减少了与其他软件冲突的概率。可以通过 ⌃b+? 来查询所有的快捷键。一般把tmux 的快捷键分成三类:窗口管理、窗格管理、以及session 管理。

session 管理

如果运行了多次 tmux 命令则会开启多个 tmux 会话(session)。在 tmux 会话中,使用前缀快捷键 ⌃b 配合以下快捷键可操作会话:

1
2
3
4
5
alias tnew='tmux new -s' # 新建一个会话
alias tls='tmux ls'
alias td='tmux detach' # 分离 会话,会保存分离之前的状态
alias ta='tmux attach -t' # 连接会话
alias tkss='tmux kill-session -t'

窗格管理

Tmux 可以将窗口分成多个窗格(pane),每个窗格运行不同的命令。以下命令都是在 Tmux 窗口中执行。

窗口管理

tmux 还有窗口(window) 的概念,当窗格变得拥挤的时候,我们可以再开一个窗口,下面是窗口一些常用的快捷键。

总结

这篇文章只是总结了一下tmux 的基本使用以及快捷键,还有很多应用场景没有涉及。比如跟vim 配合如何更加高效的在 vim中写代码。希望看过这篇文章的朋友,能够上手体验一下tmux, 使用tmux 生产力。

认识RPC

2024-08-13 18:24:42

Featured image of post 认识RPC

RPC是远程过程调用的简称,是分布式系统中不同节点间流行的通信方式。是互联网时代的基石技术,Go语言的标准库也提供了一个简单的RPC实现,Go语言的RPC包的路径为net/rpc,本篇文章的目的是利用net/rpc 实现一个简单的RPC 接口,帮助我们拨开RPC 的迷雾。


对 net/rpc 而言,一个函数需要能够被远程调用,需要满足如下五个条件

  • the method’s type is exported.
  • the method is exported.
  • the method has two arguments, both exported (or builtin) types.
  • the method’s second argument is a pointer.
  • the method has return type error.

也就是说,必须满足。

1
func (t *T) MethodName(argType T1, replyType *T2) error

简单的RPC请求

基于这五点要求,我们可以构建一个简单的RPC接口

1
2
3
4
5
6
type HelloService struct{}  
func (p *HelloService) Hello(request string, reply *string) error {  
    log.Println("HelloService Hello")  
    *reply = "hello:" + request  
    return nil  
}

然后就可以将HelloService类型的对象注册为一个RPC服务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
	_ = rpc.RegisterName("HelloService", new(HelloService))  
	listener, err := net.Listen("tcp", ":1234")  
	if err != nil {  
	    log.Fatal("ListenTCP error:", err)  
	}  
	for {  
	    conn, err := listener.Accept()  
	    if err != nil {  
	       log.Fatal("Accept error:", err)  
	    }  
	    go rpc.ServeConn(conn)  
	}
}

客户端的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
	conn, err := net.Dial("tcp", ":1234")
	if err != nil {
		log.Fatal("net.Dial:", err)
	}
	client := rpc.NewClient(conn)
	var reply string
	err = client.Call("HelloService.Hello", "hello", &reply)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(reply)
}

首先是通过rpc.Dial Dail a RPC服务,然后再通过client.Call()调用具体的RPC方法。第一个参数是用点号链接的RPC服务名字和方法名字,第二个参数是入参,第三个为返回值,是一个指针。
由这个例子可以看出RPC的使用其实非常简单。
在 Server 与Client 的代码中,我们都需要去记忆 RPC 服务的名字HelloService 以及,接口名字Hello。这在开发过程中,很容易出错,我们可以稍微封装一下。将公共的部分抽出来,完整的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// server.go
const ServerName = "HelloService"  
  
type HelloServiceInterface = interface {  
    Hello(request string, reply *string) error  
}  
  
func RegisterHelloService(srv HelloServiceInterface) error {  
    return rpc.RegisterName(ServerName, srv)  
}  
  
type HelloService struct{}  
  
func (p *HelloService) Hello(request string, reply *string) error {  
    log.Println("HelloService Hello")  
    *reply = "hello:" + request  
    return nil  
}

func main() {  
    _ = RegisterHelloService(new(HelloService))  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeConn(conn)  
    }  
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// client.go

type HelloServiceClient struct {  
    *rpc.Client  
}  
  
var _ HelloServiceInterface = (*HelloServiceClient)(nil)  

const ServerName = "HelloService" 

func DialHelloService(network, address string) (*HelloServiceClient, error) {  
    conn, err := net.Dial(network, address)  
    client := rpc.NewClient(conn)  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: client}, nil  
}

func (p *HelloServiceClient) Hello(request string, reply *string) error {  
    return p.Client.Call(ServerName+".Hello", request, reply)  
}
func main() {
	client, err := DialHelloService("tcp", "localhost:1234")  
	if err != nil {  
	    log.Fatal("net.Dial:", err)  
	}  
	var reply string  
	err = client.Hello("hello", &reply)  
	if err != nil {  
	    log.Fatal(err)  
	}  
	fmt.Println(reply)
}

是不是已经有点眼熟了?

codec

标准库的RPC默认采用Go语言特有的Gob编码,但是我们很容易在上面实现其他编码,比如ProtobufJSON 等。标准库中已经支持了jsonrpc编码,我们只需要稍微改动一下服务端与客户端代码,就能实现JSON编码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// server.go
func main() {  
    _ = rpc.RegisterName("HelloService", new(HelloService))  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))  
       //go rpc.ServeConn(conn)  
    }  
}

//client.go
func DialHelloService(network, address string) (*HelloServiceClient, error) {  
    conn, err := net.Dial(network, address)  
    //client := rpc.NewClient(conn)  
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: client}, nil  
}

请求的JSON数据对象在内部对应两个结构体:客户端是clientRequest,服务器端是 serverRequest 。clientRequest和serverRequest结构体的内容基本是一致的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type clientRequest struct {  
    Method string `json:"method"`  
    Params [1]any `json:"params"`  
    Id     uint64 `json:"id"`  
}
type serverRequest struct {  
    Method string           `json:"method"`  
    Params *json.RawMessage `json:"params"`  
    Id     *json.RawMessage `json:"id"`  
}

其中,Method 表示服务名字,它是 serviceName +Method 组成。 params部分的第一个元素为参数,id是由调用方维护的唯一的调用编号。用于在并发场场景下区分请求。
我们可以用nc 来模拟服务端,然后运行客户端代码,看看使用json编码的客户端会给服务端发送什么信息,

1
 nc -l 1234

nc 收到的数据为:

1
 {"method":"HelloService.Hello","params":["hello"],"id":0}

serverRequest 一致。
我们也可以运行 服务端代码,然后使用 nc 发送请求。

1
2
3
echo -e '{"method":"HelloService.Hello","params":["Hello"],"Id":1}' | nc localhost 1234 
--- 
{"id":1,"result":"hello:Hello","error":null}

总结

本文介绍了 Go 标准库中的rpc,它使用非常简单,性能异常强大。很多rpc的第三方库都是对rpc的封装。文章很简单,等于是给RPC研究系列开了一个头。下一篇文章,我们会将protobuf 跟RPC结合起来,最终,我们会实现一个自己的RPC框架。

RPC Action 2: protobuf in rpc

2024-08-13 16:10:53

上一篇文章中,我用net/rpc 包实现了一个简单的 RPC接口,并且尝试了net/rpc自带的Gob编码以及JSON编码,学习了Golang RPC 的一些基本知识。本篇文章,我会将net/rpc 跟protobuf结合起来,同时会创建一个自己的protobuf 插件来帮助我们生成代码,开始吧。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

我们在工作过程中一定使用过gRPC + protobuf ,但是它们两个并不是绑定关系,gRPC可以使用JSON编码,protobuf 也可以在其他语言中实现。

Protocol Buffers 是谷歌推出的编码标准,它在传输效率和编解码性能上都要优于 JSON。但其代价则是需要依赖中间描述语言(IDL)来定义数据和服务的结构( *.proto 文件),并且需要一整套的工具链(protoc 及其插件)来生成对应的序列化和反序列化代码。除了谷歌官方提供的工具和插件(比如生成 go 代码的 protoc-gen-go)外,我们还可以开发或定制自己的插件,根据业务需要按照 proto 文件的定义生成代码或者文档。由 IDL 生成代码或者文档是元编程的一种形式,可以极大的解放程序员的生产力。

一个使用protobuf 的例子

首先我们写一个proto 文件 hello-service.proto ,定义一个message “String”

1
2
3
4
5
6
7
syntax = "proto3";
package api;
option  go_package="api";

message String {
  string value = 1;
}

然后使用protoc 工具生成message String 的 Go代码

1
protoc --go_out=. hello-service.proto

然后我们修改一下Hello 函数的参数,使用proto 生成 的String。

1
2
3
type HelloServiceInterface = interface {  
    Hello(request api.String, reply *api.String) error  
}  

其实使用起来跟以前没什么区别,甚至,还不如直接使用string方便。那么我们为什么要使用Protobuf? 正如前面所说的,用Protobuf定义与语言无关的RPC服务接口以及message,然后使用protoc工具生成不同语言的代码,才是它真正的价值所在。比如使用官方提供的插件protoc-gen-go来生成gRPC代码。

1
protoc --go_out=plugins=grpc. hello-service.proto

protoc 的插件系统

如果要根据 proto 文件生成代码,必须安装protoc工具,但是protoc 工具并不能知道我们的目标语言是什么,所以我们需要插件来帮助我们生成代码。protoc 的插件系统是如何工作的?以上面的grpc 为例子:
这里有一个 --go_out 参数。因为我们调用的插件是protoc-gen-go,所以参数名字叫 go_out;如果名字叫 XXX,那参数名字就叫 XXX_out。
protoc 在运行的时候首先会解析 proto 文件的所有内容,生成一组 Protocol Buffers 编码的描述数据,首先会判断protoc 内部是否包含go插件, 然后会尝试在$PATH 里面寻找protoc-gen-go,找不到会报错,然后运行protoc-gen-go 命令,并且通过 stdin 将描述数据发送给插件命令。插件生成好文件内容后再向 stdout 输入 Protocol Buffers 编码的数据来告诉 protoc 生成具体的文件。
plugins=grpc 是 为了调用protoc-gen-go 自带的一个插件,如果不使用它,那么只会生成 Go 语言 的message信息,使用这个插件才会生成grpc 相关的代码。

自定义一个 protoc 插件

如果在protobuf 中添加Hello 接口的定时,我们是不是可以自定义一个 protoc 插件,直接生成代码?

1
2
3
4
5
6
7
8
9
syntax = "proto3";  
package api;  
option  go_package="./api";  
service HelloService {  
  rpc Hello (String) returns (String) {}  
}  
message String {  
  string value = 1;
}

目标

这篇文章,我的目标是创建一个插件,然后用来生成RPC 的服务端与客户端代码,生成的代码大致是这样的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// HelloService_rpc.pb.go
type HelloServiceInterface interface {  
    Hello(String, *String) error  
}  
  
func RegisterHelloService(  
    srv *rpc.Server, x HelloServiceInterface,  
) error {  
    if err := srv.RegisterName("HelloService", x); err != nil {  
       return err  
    }  
    return nil  
}  
  
type HelloServiceClient struct {  
    *rpc.Client  
}  
  
var _ HelloServiceInterface = (*HelloServiceClient)(nil)  
  
func DialHelloService(network, address string) (  
    *HelloServiceClient, error,  
) {  
    c, err := rpc.Dial(network, address)  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: c}, nil  
}  
  
func (p *HelloServiceClient) Hello(  
    in String, out *String,  
) error {  
    return p.Client.Call("HelloService.Hello", in, out)  
}

这样我们的业务代码就能改成下面这个样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// service
func main() {  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    _ = api.RegisterHelloService(rpc.DefaultServer, new(HelloService))  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeConn(conn)  
    }  
}  
  
type HelloService struct{}  
  
func (p *HelloService) Hello(request api.String, reply *api.String) error {  
    log.Println("HelloService.proto Hello")  
    *reply = api.String{Value: "Hello:" + request.Value}  
    return nil  
}
// client.go
func main() {  
    client, err := api.DialHelloService("tcp", "localhost:1234")  
    if err != nil {  
       log.Fatal("net.Dial:", err)  
    }  
    reply := &api.String{}  
    err = client.Hello(api.String{Value: "Hello"}, reply)  
    if err != nil {  
       log.Fatal(err)  
    }  
    log.Println(reply)  
}

基于生成的代码,我们的工作量已经小了很多,并且出错的几率已经很小了。一个不错的开始。
根据上面的api代码,我们可以抽出来一个模板文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const tmplService = `  
package {{.PackageName}}  
import (  
    "net/rpc")  
{{$root := .}}  
type {{.ServiceName}}Interface interface {  
    {{- range $_, $m := .MethodList}}    {{$m.MethodName}}({{$m.InputTypeName}}, *{{$m.OutputTypeName}}) error    {{- end}}}  
func Register{{.ServiceName}}(  
    srv *rpc.Server, x {{.ServiceName}}Interface,) error {  
    if err := srv.RegisterName("{{.ServiceName}}", x); err != nil {        return err    }    return nil}  
type {{.ServiceName}}Client struct {  
    *rpc.Client}  
var _ {{.ServiceName}}Interface = (*{{.ServiceName}}Client)(nil)  
func Dial{{.ServiceName}}(network, address string) (  
    *{{.ServiceName}}Client, error,) {  
    c, err := rpc.Dial(network, address)    if err != nil {        return nil, err    }    return &{{.ServiceName}}Client{Client: c}, nil}  
{{range $_, $m := .MethodList}}  
func (p *{{$root.ServiceName}}Client) {{$m.MethodName}}(  
    in {{$m.InputTypeName}}, out *{{$m.OutputTypeName}},) error {  
    return p.Client.Call("{{$root.ServiceName}}.{{$m.MethodName}}", in, out)}  
{{end}}  
`

整个模板很清晰,里面有一些占位符,比如 MethodName,ServiceName 等,我们后面会介绍。

如何开发 一个插件?

谷歌发布了 Go 语言 API1,其中引入了一个新包 google.golang.org/protobuf/compiler/protogen ,极大的降低了plugins 开发难度:

  1. 首先我们创建一个go 语言工程,比如protoc-gen-go-spprpc
  2. 然后我们需要定义一个protogen.Options,然后调用它的Run方法,并传入一个 func(*protogen.Plugin) error回调。主流程代码到此就结束了。
  3. 我们还可以设置protogen.OptionsParamFunc参数,这样 protogen 会自动为我们解析命令行传入的参数。诸如从标准输入读取并解码 protobuf 信息,将输入信息编码成 protobuf 写入 stdout 等操作全部由 protogen 包办了。我们要做的就是与 protogen.Plugin 交互实现代码生成逻辑。

每个服务最重要的是服务的名字,然后每个服务有一组方法。而对于服务定义的方法,最重要的是方法的名字,还有输入参数和输出参数类型的名字。我们首先定义一个ServiceData,用于描述服务的元信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ServiceData 结构体定义  
type ServiceData struct {  
    PackageName string  
    ServiceName string  
    MethodList  []Method  
}
// Method 结构体定义  
type Method struct {  
    MethodName     string  
    InputTypeName  string  
    OutputTypeName string  
}

然后就是主逻辑,以及代码生成逻辑,最后调用tmpl生成代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func main() {  
    protogen.Options{}.Run(func(gen *protogen.Plugin) error {  
       for _, file := range gen.Files {  
          if !file.Generate {  
             continue  
          }  
          generateFile(gen, file)  
       }  
       return nil  
    })  
}  
  
// generateFile 函数定义  
func generateFile(gen *protogen.Plugin, file *protogen.File) {  
    filename := file.GeneratedFilenamePrefix + "_rpc.pb.go"  
    g := gen.NewGeneratedFile(filename, file.GoImportPath)  
    tmpl, err := template.New("service").Parse(tmplService)  
    if err != nil {  
       log.Fatalf("Error parsing template: %v", err)  
    }  
    packageName := string(file.GoPackageName)  
    // 遍历每个服务生成代码  
    for _, service := range file.Services {  
       serviceData := ServiceData{  
          ServiceName: service.GoName,  
          PackageName: packageName,  
       }  
       for _, method := range service.Methods {  
          inputType := method.Input.GoIdent.GoName  
          outputType := method.Output.GoIdent.GoName  
  
          serviceData.MethodList = append(serviceData.MethodList, Method{  
             MethodName:     method.GoName,  
             InputTypeName:  inputType,  
             OutputTypeName: outputType,  
          })  
       }  
       // 执行模板渲染  
       err = tmpl.Execute(g, serviceData)  
       if err != nil {  
          log.Fatalf("Error executing template: %v", err)  
       }  
    }  
}

调试插件

最后我们将编译后的 二进制执行文件protoc-gen-go-spprpc,放在$PATH 里面, 然后运行protoc 就能生成我们想要的代码了。

1
protoc --go_out=.. --go-spprpc_out=.. HelloService.proto

因为protoc-gen-go-spprpc 必须依赖 protoc 才能运行,所以调试起来比较麻烦。我们可以使用
fmt.Fprintf(os.Stderr, "Fprintln: %v\n", err) 打印错误日志的方式调试。

总结

以上就是本文的全部内容了。我们首先使用protobuf 实现了一个rpc call,然后创建了一个protobuf 插件来帮助我们生成代码。为我们打开了一扇学习protobuf + RPC 的大门,也是我们通往彻底理解gRPC的路。希望大家都能掌握这个技术。

参考文档

  1. https://taoshu.in/go/create-protoc-plugin.html
  2. https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html

Go 高性能编程 EP9: 逃逸分析

2024-08-08 11:28:58

Featured image of post Go 高性能编程 EP9: 逃逸分析

从Go 编译器的角度来看,内存会被分配到两个地方: stackheap。对于业务开发人员来说,这两种方式,没什么区别,通常开发者并不需要关心内存分配在栈上,还是堆上,因为这都是编译器自动完成的。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收。如果分配在堆中,则是由GC算法在某个时间点进行垃圾回收,其中的原理比较复杂。总之,分配堆内存比栈内存需要更多的开销,这种将内存分配到堆的现象就是内存逃逸。我们在写代码的时候,应当尽量避免堆内存分配。

为什么会有内存逃逸?

原因其实很简单,编译器无法确定变量的生存周期,或者栈空间放不下那么大的内存。Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。可以用 -gcflags=-m 来观察变量是否逃逸。

内存逃逸对性能的影响

可以做一个很简单的benchmark测试, BenchmarkInt 是一个指针数组,&j 会产生内存逃逸, BenchmarkInt2 则不会产生 内存逃逸。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func BenchmarkInt(b *testing.B) {  
    for i := 0; i < b.N; i++ {  
  
       a := make([]*int, 100)  
       for j := 0; j < 100; j++ {  
          a[j] = &j  
       }  
    }  
}  
  
func BenchmarkInt2(b *testing.B) {  
    for i := 0; i < b.N; i++ {  
  
       a := make([]int, 100)  
       for j := 0; j < 100; j++ {  
          a[j] = j  
       }  
    }  
}

运行结果是:
Pasted image 20240815100606
BenchmarkInt2 的比BenchmarkInt 快30倍,并且没有一次内存分配,BenchmarkInt 则有101次内存分配。 从这个测试我们就知道为什么有必要进行内存逃逸分析了。

内存逃逸的典型场景

变量逃逸 & 指针逃逸

当一个变量的生命周期超出函数范围时,编译器会将其分配到堆上,我们叫这种现象为内存变量逃逸,或者指针逃逸。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func main() {
    s := makeString()
    fmt.Println(s)
}

func makeString() *string {
    str := "Hello, World!"
    return &str
}

Pasted image 20240814172956

栈溢出

操作系统对内核线程使用的栈空间是有大小限制的,在64位操作系统上面,这个大小通常是8 MB。可以使用 ulimit -a 命令查看计算机允许的最大的栈内存大小。Go runtime 在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。对于go 的编译器来说,超过一定大小的局部变量将逃逸到堆上,一般是64KB。比如这段代码尝试创建一个占用 8193 字节的数组: 8192 * 8 / 1024 = 64k

1
2
3
4
5
6
func main() {  
    a := make([]int64, 8176)  
    b := make([]int64, 8192)  
    c := make([]int64, 8193)  
    println(a, b, c)  
}

当数组大于 8192 的时候就逃逸到了堆上。
Pasted image 20240814173312

不确定大小的变量

1
2
3
4
5
6
7
func main() {  
    a := generate(3)  
    println(a)  
}  
func generate(n int) []int {  
    return make([]int, n)  
}

generate 的参数是在运行时传入的,所以编译器不能确定他的大小, 逃逸到堆上,
Pasted image 20240814193812

interface{} 动态类型逃逸

在Go语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
比如:

1
2
3
4
func main() {  
    v := "Hello,World"  
    fmt.Printf("addr of v in bar = %p\n", &v)  
}

运行结果是:
Pasted image 20240815153912
因为 fmt.Println 的参数是一个any(也就是interface{}) 所以v会发生逃逸

闭包 closure

比如下面的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 func Increase() func() int {  
	n := 0  
	return func() int {  
		n++  
		return n  
	}  
}  
  
func main() {  
	in := Increase()  
	fmt.Println(in()) // 1  
	fmt.Println(in()) // 2  
}

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

手动强制避免逃逸

interface{} 动态类型逃逸 的例子中, 我们就是打印了一个"Hello,World",但是还是产生了内存逃逸,我们可以确定的一点是:v 不需要逃逸,但若使用fmt.Printf,我们无法阻拦a的逃逸。那是否有一种方法可以干扰逃逸分析,使逃逸分析认为需要在堆上分配的内存对象而我们确定认为不需要逃逸的对象避免逃逸呢?在Go runtime代码中,我们发现了一个函数:

1
2
3
4
5
// $GOROOT/src/runtime/stubs.go
func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0) 
}

在Go标准库和rruntime实现中,该函数得到大量使用。该函数的实现逻辑使得我们传入的指针值与其返回的指针值是一样的。该函数只是通过uintptr做了一次转换,而这次转换将指针转换成了数值,这“切断”了逃逸分析的数据流跟踪,导致传入的指针避免逃逸。

Pasted image 20240815154246
我们改一下上面的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func noescape(p unsafe.Pointer) unsafe.Pointer {  
    x := uintptr(p)  
    return unsafe.Pointer(x ^ 0)  
}  
  
func main() {  
	v := "Hello,World"  
	v2 := "Hello,World1"  
	fmt.Printf("addr of v in bar = %p \n", (*int)(noescape(unsafe.Pointer(&v))))  
	fmt.Printf("addr of v in bar = %p\n", &v2) 
}

运行结果如图所示,v 没有发生内存逃逸,v2有内存逃逸。
Pasted image 20240815154140

总结

在一般的开发过程中,我们一般很少会涉及内存逃逸分析,根据经验来看,优化一个锁得到的性能提升,比你做十次内存逃逸分析结果还要好。在平时的开发过程中,我们只需要明白一件事就好:

参考资料

Go 高性能编程 EP8: 如何通过优化GC来提高Golang代码的性能

2024-08-07 02:22:18

Featured image of post Go 高性能编程 EP8: 如何通过优化GC来提高Golang代码的性能

我们在用golang 写程序的时候,一般不会去过分关注内存,因为golang 运行时能够很好的帮我们完成GC 工作,但是如果遇到了需要性能优化的场景,我们能够了解一些GC 的知识,以及如何优化GC,会有很大的收益。 这篇文章,我们通过一个解析XML文件的服务来学习一下。如何通过go trace 来优化GC,提高代码的性能。
感谢 Arden Lions 优秀的演讲Evaluating Performance In Go。这篇文章可以理解成演讲的 blog 版本。

如果您对 go trace 不太熟悉,可以先看一下@Vincent的文章Go: Discovery of the Trace Package

所有的例子都在我的MacBook Pro M1 上运行,它有十个核心。

我们的目标是实现一个从多个RSS XML文件处理程序,从title寻找包含go关键字的的item,这里,我使用我的博客的RSS XML文件作为示例,解析这个文件100次,模拟压力。
完整的代码:https://github.com/hxzhouh/blog-example/tree/main/go/go_trace%20

single

list1: 使用单协程统计key

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func freq(docs []string) int {  
    var count int  
    for _, doc := range docs {  
       f, err := os.OpenFile(doc, os.O_RDONLY, 0)  
       if err != nil {  
          return 0  
       }  
       data, err := io.ReadAll(f)  
       if err != nil {  
          return 0  
       }  
       var d document  
       if err := xml.Unmarshal(data, &d); err != nil {  
          log.Printf("Decoding Document [Ns] : ERROR :%+v", err)  
          return 0  
       }  
       for _, item := range d.Channel.Items {  
          if strings.Contains(strings.ToLower(item.Title), "go") {  
             count++  
          }  
       }  
    }  
    return count  
}

func main() {  
    trace.Start(os.Stdout)  
    defer trace.Stop()  
    files := make([]string, 0)  
    for i := 0; i < 100; i++ {  
       files = append(files, "index.xml")  
    }  
    count := freq(files)  
    log.Println(fmt.Sprintf("find key word go %d count", count))  
}

代码很简单,我们使用一个for循环就完成任务了。然后运行

1
2
3
4
5
6
➜  go_trace git:(main) ✗ go build                      
➜  go_trace git:(main)time ./go_trace 2 > trace_single.out

-- result --
2024/08/02 16:17:06 find key word go 2400 count
./go_trace 2 > trace_single.out  1.99s user 0.05s system 102% cpu 1.996 total

然后我们使用 go trace 查看 trace_single.out
RunTime :2031ms, STW: 57ms, GC Occurrences :252ms ,GC STW AVE: 0.227ms
GC 时间 占用总运行时间为: 57 / 2031 ≈ 0.02
使用最大的内存为11.28M左右
Figure 1: single: run time
Pasted image 20240802163816
Figure 2: single: GC
Pasted image 20240802164009
Figure 3: single: max heap
Pasted image 20240802190155
我们现在只使用了一个核心,资源利用率太低,如果我们想加速这个程序,最好是使用并发,这也是go最擅长的部分。

concurrent

List 2: 使用 FinOut 方式统计 key。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func concurrent(docs []string) int {  
    var count int32  
    g := runtime.GOMAXPROCS(0)  
    wg := sync.WaitGroup{}  
    wg.Add(g)  
    ch := make(chan string, 100)  
    go func() {  
       for _, v := range docs {  
          ch <- v  
       }  
       close(ch)  
    }()  
  
    for i := 0; i < g; i++ {  
       go func() {  
          var iFound int32  
          defer func() {  
             atomic.AddInt32(&count, iFound)  
             wg.Done()  
          }()  
          for doc := range ch {  
             f, err := os.OpenFile(doc, os.O_RDONLY, 0)  
             if err != nil {  
                return  
             }  
             data, err := io.ReadAll(f)  
             if err != nil {  
                return  
             }  
             var d document  
             if err = xml.Unmarshal(data, &d); err != nil {  
                log.Printf("Decoding Document [Ns] : ERROR :%+v", err)  
                return  
             }  
             for _, item := range d.Channel.Items {  
                if strings.Contains(strings.ToLower(item.Title), "go") {  
                   iFound++  
                }  
             }  
          }  
       }()  
    }  
  
    wg.Wait()  
    return int(count)  
}

使用同样的方式运行

1
2
3
4
5
go build
time ./go_trace 2 > trace_pool.out
--- 
2024/08/02 19:27:13 find key word go 2400 count
./go_trace 2 > trace_pool.out  2.83s user 0.13s system 673% cpu 0.439 total

RunTime :425ms, STW: 154ms, GC Occurrences :39 ,GC STW AVE: 3.9ms
GC 时间 占用总运行时间为: 154 /425 ≈ 0.36
最大的内存消耗为91.60MB
Figure 4: concurrent,GC count
Pasted image 20240802194803
Figure 5: concurrent, Max heap
Pasted image 20240802194902

concurrent 比single 大约快了5倍,在go trace 的结果中,我们可以看到 concurrent 版本中GC占了36%的运行时间。有没有办法能优化这个时间呢?幸运的是在go 1.19 版本中我们有两个参数可以来控制GC。

GOGC & GOMEMLIMIT

在go1.19 中添加了两个参数,可以用它来控制GC,GOGC 用于控制垃圾回收的频率,而 GOMEMLIMIT 用于限制程序的最大内存使用量。关于 GOGCGOMEMLIMIT 详细细节,可以参考官方文档 gc-guide

GOGC

根据官方文档中的这个公式:
$New heap memory = (Live heap + GC roots) * GOGC / 100$

根据官方文档,如果我们将GOGC设置为1000,理论上,会将GC触发的频率降低10倍,代价是内存占用增加十倍。(这只是一个理论模型,实际上很复杂)
试试呗?

1
2
3
➜  go_trace git:(main)time GOGC=1000 ./go_trace 2 > trace_gogc_1000.out
2024/08/05 16:57:29 find key word go 2400 count
GOGC=1000 ./go_trace 2 > trace_gogc_1000.out  2.46s user 0.16s system 757% cpu 0.346 total

RunTime :314ms, STW: 9.572ms, GC Occurrences: 5, GC STW AVE: 1.194ms
GC 时间 占用总运行时间为: 9.572/314 ≈ 0.02
最大内存占用为 451MB。
Figure 6: GOGC, Max Heap
Pasted image 20240805171630
Figure 7: GOGC, GC count
Pasted image 20240805171642

GOMEMLIMIT

GOMEMLIMIT 用来设置程序使用的内存上限,一般在关闭自动GC 的场景下使用,让我们可以手动管理程序占用的内存总数。当程序分配的内存到达上限的时候,会触发GC。需要注意,虽然GC已经很努力的在工作了,程序使用的内存上限,可能还是会超过GOMEMLIMIT 的设定。
在 single 版本中,我们的程序使用了11.28M 内存,concurrent 版本我们有十个协程一起运行,按照 gc-guide 的指导,我们需要预留10%的内存应对突发情况。所以我们可以把GOMEMLIMIT 设置为 11.28MB * 1.1 ≈ 124MB

1
2
3
➜  go_trace git:(main)time GOGC=off GOMEMLIMIT=124MiB ./go_trace 2 > trace_mem_limit.out  
2024/08/05 18:10:55 find key word go 2400 count
GOGC=off GOMEMLIMIT=124MiB ./go_trace 2 > trace_mem_limit.out  2.83s user 0.15s system 766% cpu 0.389 total

RunTime :376.455 ms, STW: 41.578ms, GC Occurrences: 14, GC STW AVE: 2.969ms
GC 时间 占用总运行时间为: 41.578/376.455 ≈ 0.11
最大内存占用为 120MB,比较接近我们设置的上限。
Figure 8: GOMEMLIMIT, GC Max Heap
Pasted image 20240805181452
Figure 9: GOMEMLIMIT GC count
Pasted image 20240805181512
如果我们继续增大 GOMEMLIMIT参数,会得到更好的结果,比如GOMEMLIMIT=248Mib 得到的trace 为下图所示
Figure 10: GOMEMLIMIT= 248Mib, GC
Pasted image 20240805183259

RunTime :320.455 ms, STW: 11.429ms, GC Occurrences: 5, GC STW AVE: 2.285ms
但是他不是没有边界的, 比如 GOMEMLIMIT=1024Mib RunTime 已经到了406ms
Figure 11: GOMEMLIMIT= 1024Mib, GC
Pasted image 20240805183727

风险

Suggested_uses 中已经个给出了很明确的建议,除非对自己的程序的运行环境,面对的负载特别熟悉,否则不要使用这两个参数。请您务必阅读 gc-guide

总结

最后我们总结一下上面的过程优化过程结果
Figure 12: Result Compare
Pasted image 20240805184357

在合适的场景使用GOGC以及GOMEMLIMIT,能够有效的提升性能。并且有一种掌控某种不确定东西的成就感。但是一定要在受控环境中合理应用,以确保性能和可靠性,而在资源共享或不受控制的环境中应谨慎,避免因设置不当导致性能下降或程序崩溃。

参考资料

[1]. https://www.youtube.com/watch?v=PYMs-urosXs&t=2684s
[2]. https://www.uber.com/en-TW/blog/how-we-saved-70k-cores-across-30-mission-critical-services/
[3]. https://tip.golang.org/doc/gc-guide

Go Action:如何避免因为大堆产生的高GC开销

2024-07-24 10:16:50

原文:Avoiding high GC overhead with large heaps
当分配的内存量相对较小时,Go垃圾收集器 (GC) 工作得非常好,但是如果堆的大小较大,GC过程可能会消耗大量 的CPU。在极端情况下,它甚至可能无法完成任务(GC算法保证GC不会使用超过50%的CPU时间,如果超过这阈值,会导致内存泄露)。

有什么问题?

GC的工作是确定哪些内存块可被释放,它通过扫描内存中分配的指针来完成这一任务。简单来说,如果没有指针指向某个内存分配,则可释放该内存分配。这种方法非常有效,但需要扫描的内存越多,所需时间就越长

这是一个大问题吗?

有多严重?我们来揭晓!以下是 demonstrate 的小程序。我们分配 1e9 个 8 字节指针,即约 8GB 的内存。我们强制执行 GC,并测量它需要多长时间。我们会重复几次以获得一个稳定的值。还调用 runtime.KeepAlive() 以防止编译器优化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
	a := make([]*int, 1e9)

	for i := 0; i < 10; i++ {
		start := time.Now()

		fmt.Printf("GC took %s\n", time.Since(start))
	}

	runtime.KeepAlive(a)
}

在我的 2015 年 MBP上,输出结果如下所示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GC took 4.275752421s  
GC took 1.465274593s  
GC took 652.591348ms  
GC took 648.295749ms  
GC took 574.027934ms  
GC took 560.615987ms  
GC took 555.199337ms  
GC took 1.071215002s  
GC took 544.226187ms  
GC took 545.682881ms  

GC需要花费0.5S以上的时间。我为它分配了10e9个指针。事实上,每检查一个指针所花费的时间不到一纳秒。对于检查指针的速度来说,这已经相当快了。

然后呢?

如果我们的应用程序真的需要在内存中维持一个巨大的Map或者数组,那可就麻烦了。如果GC坚持定期扫描我们分配的全部内存,我们就会损失大量的CPU时间。有什么办法可以解决这个问题吗? 我们基本上只有两种选择:要么对 GC 隐藏内存,要么使GC对其不感兴趣,不扫描它

让GC不扫描这部分内存

怎样才能让GC不扫描这部分内存?GC在寻找指针。如果我们分配的对象的类型不包含指针,GC还会扫描吗?
我们可以尝试一下。在下面的例子中,我们分配与之前完全相同数量的内存,但这次我们的分配中没有指针类型。我们分配了一块包含十亿个 8 字节整数(ints)的切片内存。再次强调,这是大约8GB的内存。

1
2
3
4
5
6
7
8
9
func main() {  
    a := make([]int, 1e9)  
    for i := 0; i < 10; i++ {  
       start := time.Now()  
       runtime.GC()  
       fmt.Printf("GC took %s\n", time.Since(start))  
    }  
    runtime.KeepAlive(a)  
}

再次在我的 2015 年款 MBP上运行了这个程序,结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GC took 350.941µs
GC took 179.517µs
GC took 169.442µs
GC took 191.353µs
GC took 126.585µs
GC took 127.504µs
GC took 111.425µs
GC took 163.378µs
GC took 145.257µs
GC took 144.757µs

GC 的速度快了足足一千倍,而所分配的内存数量却完全一样。原来 Go 语言内存管理器能识别每一次内存分配的类型,并且会给不包含指针的内存打上标记,这样 GC 在扫描内存时就能跳过它们。如果我们能让大内存中不包含指针的话,那就太棒了。

将内存隐藏起来

我们可以做另一件事就是将分配的内存隐藏起来,让GC看不见。如果我们直接向操作系统请求内存,GC就永远不会知道它,也就不会扫描它。与我们之前例子的做法相比,这样做稍微有些复杂!这里是我们的第一个程序的等价版本,我们使用 mmap 系统调用直接从操作系统内核分配 []*int 亿(1e9)个条目。注意,这只在 *unix 系统上有效,但在 Windows 上也有类似的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
	"fmt"
	"reflect"
	"runtime"
	"syscall"
	"time"
	"unsafe"
)

func main() {

	var example *int
	slice := makeSlice(1e9, unsafe.Sizeof(example))
	a := *(*[]*int)(unsafe.Pointer(&slice))

	for i := 0; i < 10; i++ {
		start := time.Now()
		runtime.GC()
		fmt.Printf("GC took %s\n", time.Since(start))
	}

	runtime.KeepAlive(a)
}

func makeSlice(len int, eltsize uintptr) reflect.SliceHeader {
	fd := -1
	data, _, errno := syscall.Syscall6(
		syscall.SYS_MMAP,
		0, // address
		uintptr(len)*eltsize,
		syscall.PROT_READ|syscall.PROT_WRITE,
		syscall.MAP_ANON|syscall.MAP_PRIVATE,
		uintptr(fd), // No file descriptor
		0,           // offset
	)
	if errno != 0 {
		panic(errno)
	}

	return reflect.SliceHeader{
		Data: data,
		Len:  len,
		Cap:  len,
	}
}

这次的GC耗时为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GC took 460.777µs
GC took 206.805µs
GC took 174.58µs
GC took 193.697µs
GC took 184.325µs
GC took 142.556µs
GC took 132.48µs
GC took 155.853µs
GC took 138.54µs
GC took 159.04µs

如果您想了解 a := *(*[]*int)(unsafe.Pointer(&slice)) ,请浏览 https://blog.gopheracademy.com/advent-2017/unsafe-pointer-and-system-calls/

现在,这部分内存对GC是不可见的。这会带来一个有趣的后果,即存储在这一内存中的指针,不会阻止它们指向的‘正常’分配的内存被GC回收。而这会带来糟糕的后果,我们很容易就可证明。

在这里,我们尝试将 0、1 和 2 存入堆分配的整数中,并将指向它们的指针存入mmap分配的切片中(不受GC管控)。在为每个整数分配内存并存储指向它们的指针后,我们强制执行一次垃圾回收。

我们的输出在这里。在每次垃圾回收后,支持我们整数的内存都会被释放并可能被重新使用。所以,我们的数据并不像我们预期的那样,很幸运程序没有崩溃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
a[0] is C000016090
*a[0] is 0
a[1] is C00008C030
*a[1] is 1
a[2] is C00008C030
*a[2] is 2

*a[0] is 0
*a[1] is 811295018
*a[2] is 811295018

这样显然是不行的。如果我们修改成使用通常分配的 []*int ,如下所示,就可以得到预期的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {

	a := make([]*int, 3)

	for j := range a {
		a[j] = getMeAnInt(j)
		fmt.Printf("a[%d] is %X\n", j, a[j])
		fmt.Printf("*a[%d] is %d\n", j, *a[j])
		runtime.GC()
	}

	fmt.Println()
	for j := range a {
		fmt.Printf("*a[%d] is %d\n", j, *a[j])
	}
}

输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
a[0] is C00009A000
*a[0] is 0
a[1] is C00009A040
*a[1] is 1
a[2] is C00009A050
*a[2] is 2

*a[0] is 0
*a[1] is 1
*a[2] is 2

问题的本质

所以,最终我们证明指针是我们的敌人,无论我们在堆上分配了大量内存,还是试图通过将数据移动到我们自己的非堆内存分配来规避这个问题。如果我们能在分配的类型中避免使用指针,就不会造成GC的负担,从而无需使用任何奇技淫巧。如果我们使用非堆内存分配,则需要避免存储对堆内存的指针,除非这些内存也被GC可访问内存所引用。

我们如何才能避免使用指针?

在大的堆内存中,指针是邪恶的,必须避免。但是要避免它们,你就必须能识别出来,而它们并不总是很明显。字符串、切片和 time.Time 均包含指针。如果你在内存中存储了大量这些数据,就可能需要采取一些措施。
我遇到大堆问题时,主要原因有以下几点。

将字符串slice变成一个带索引的数组。

一条字符串由两个部分组成。一个是字符串头,它会告诉你字符串的长度以及在哪里找到原始数据;另一个就是实际的字符串数据了,它只是一系列字节的顺序。

当你将一个字符串变量传递给一个函数时,只有字符串的头被写入栈中,如果你保持一个字符串切片,那么切片中的字符串头就会出现。
字符串头的定义是 reflect.StringHeader ,长这样:

1
2
3
4
type StringHeader struct {
	Data uintptr
	Len  int
}

字符串头包含指针,所以我们不想存储字符串!

  1. 如果你的字符串只取几种固定的值,可以考虑使用整数常量替代
  2. 将日期和时间作为字符串存储的话,不妨将它们解析为整数并存储起来
  3. 如果你真的需要很多string,那就请继续阅读……
    假设我们正在存储一亿条记录。为了简单起见,我们假定这是个巨大的全局变量 var mystrings []string 。
    这里有什么? mystrings 的基础架构是一个 reflect.SliceHeader ,它与我们刚刚看到的 reflect.StringHeader 相似。
1
2
3
4
5
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

对于 mystrings ,Len 和 Cap 都是 10e8,Data 会指向一个足够容纳 10e8 个 StringHeader 的连续内存区域。这一块内存包含指针,因此会被GC扫描。

字符串本身由两部分组成。这个切片中包含的是StringHeaders,以及每个字符串的数据,这些数据是单独的分配,数据本身没有包含指针。从垃圾回收的角度来看,问题在于字符串头部,而不是字符串数据本身。字符串数据不包含指针,因此不会被扫描。庞大的字符串头部数组包含指针,因此必须在每个垃圾回收周期中进行扫描。
Pasted image 20240724113559
如果所有字符串的字节都存放在一块内存中,我们可以通过每个字符串相对于内存起始和终止位置的偏移量来跟踪它们。通过跟踪偏移量,我们就可以在大型slice中消除指针,跳过垃圾回收的扫描。
Pasted image 20240724113615
这样做的坏处就是,丧失了slice 操作的便利性,slice 的修改变得特别复杂,我们为将字符串体复制到大的字节切片上增加了开销。
这里有一个小程序来演示这个想法。我们将创建 10e8个字符串,将这些字符串的字节复制到一个大的字节切片中,并存储偏移量。可以看到GC耗时非常少,然后通过显示前 10 个字符串来证明我们可以检索这些字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
	"fmt"
	"runtime"
	"strconv"
	"time"
	"unsafe"
)

func main() {
	var stringBytes []byte
	var stringOffsets []int

	for i := 0; i < 1e8; i++ {
		val := strconv.Itoa(i)
		stringBytes = append(stringBytes, val...)
		stringOffsets = append(stringOffsets, len(stringBytes))
	}

	runtime.GC()
	start := time.Now()
	runtime.GC()
	fmt.Printf("GC took %s\n", time.Since(start))

	sStart := 0
	for i := 0; i < 10; i++ {
		sEnd := stringOffsets[i]
		bytes := stringBytes[sStart:sEnd]
		stringVal := *(*string)(unsafe.Pointer(&bytes))
		fmt.Println(stringVal)

		sStart = sEnd
	}
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
GC took 187.082µs
0
1
2
3
4
5
6
7
8
9

如果你永远不需要修改这些字符串,可以将它转换为一个更大数据块的索引方式,从而避免大量指针。如果你感兴趣,我实现了一个更复杂一点的东西,它遵循这一原则。

我之前多次在博客中提到过遇到由大堆引发的垃圾回收(GC)问题。事实上,每当我遇到这个问题时,我都感到惊讶,并再次在博客中写道它。希望看到这里时,如果在你项目中发现这个问题的话,不会让你感到惊讶,你甚至会提前想到这个问题。
以下是一些资源,希望对你解决这些问题有所帮助。

Go高性能编程 EP7: 使用 SingleFlight 合并相同的请求

2024-07-23 15:49:00

Featured image of post Go高性能编程 EP7:  使用 SingleFlight 合并相同的请求

在开发业务代码过程中,我们经常会使用缓存DB数据的方式来加速查询,这样的架构会有一个问题,我们常常需要解决缓存穿透、缓存雪崩和缓存击穿问题。缓存击穿问题是指,在平常高并发的系统中,大量的请求同时查询一个 key 时,如果这个 key 正好过期失效了,就会导致大量的请求都打到数据库上,造成数据库压力很大,这就是缓存击穿。如果能够将一段时间的相同的N个请求合成一个,那么穿透到数据库的压力是不是就从N变成了1?
SingleFlight 就是这样的一个并发原语。本文介绍了SingleFlight , 的使用以及它的基本原理,还有一些使用上的细节。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

Figure 1:使用 SingleFlight 合并相同的请求。
Pasted image 20240723160249

SingleFlight 使用

Go 官方将 singleflight ,放在了golang.org/x 中,说明他可能还会有一些变化。它提供了重复函数调用抑制机制,使用它可以避免同时进行相同的函数调用。第一个调用未完成时后续的重复调用会等待,当第一个调用完成时则会与它们分享结果,这样以来虽然只执行了一次函数调用但是所有调用都拿到了最终的调用结果。

list1: 使用 SingleFlight 合并DB请求

从结果可以看出,我们我们模拟了5个请求cache miss 的情况,但是只有一个请求调用了DB,五个请求都拿到了相同的结果。这对于后端服务来说,是很可观的优化。他避免了数据库被缓存穿透。

在go 源码中,同样能看到 SingleFlight 的使用,比如/src/net/lookup.go#L165/src/cmd/go/internal/vcs/vcs.go#L1385,缓存库 groupcache 也是使用SingleFlight 类似的实现来防止缓存穿透。

singleflight 分析

singleflight包中定义了一个名为Group的结构体类型,它表示一类工作,并形成一个命名空间,在这个命名空间中,可以使用重复抑制来执行工作单元。
SingleFlight 的数据结构是 Group,它提供了三个方法。
Pasted image 20240723162629

使用细节

请求阻塞

singleflight 内部使用 waitGroup 来让同一个 key 的除了第一个请求的后续所有请求都阻塞。直到第一个请求执行 fn 返回后,其他请求才会返回。
这意味着,如果 fn 执行需要很长时间,那么后面的所有请求都会被一直阻塞。
这时候我们可以使用 DoChan 结合 ctx + select 做超时控制

Forget

singleflight 的实现为,如果第一个请求失败了,那么后续所有等待的请求都会返回同一个 error。
实际上可以根据DB使用情况定时 forget 一下 key,让更多的请求能有机会走到后续逻辑。

1
2
3
4
go func() {
       time.Sleep(100 * time.Millisecond)
       g.Forget(name)
   }()

比如1秒内有100个请求过来,正常是第一个请求能执行 GetUserFromDB,后续99个都会阻塞。
增加这个 Forget 之后,每 100ms 就能有一个请求执行 GetUserFromDB,相当于是多了几次尝试的机会,相对的也给DB造成了更大的压力,需要根据具体场景进去取舍

Go高性能编程 EP5: 更精准的benchmark

2024-07-12 15:05:32

Featured image of post Go高性能编程 EP5: 更精准的benchmark

当我们尝试去优化代码的性能时,首先得知道当前的性能怎么样,得到一个基准性能。Go语言标准库内置的 testing 测试框架提供了benchmark的能力。本文主要介绍 如何使用benchmark 进行基准测试,以及如何提高benchmark 的精准度,最后介绍了两个工具,帮助我们更加方便的进行benchmark。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

稳定的测试环境

性能测试受环境的影响很大,为了保证测试的可重复性,在进行性能测试时,尽可能地保持测试环境的稳定。

benchmark 是如何工作的

benchmark 其实就是重复调用某个函数,然后记录函数的执行时间等指标,来度量它的性能。一个典型的benchamrk 函数如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func fib(n int) int {  
    if n < 2 {  
       return n  
    }  
    return fib(n-1) + fib(n-2)  
}  
func BenchmarkFib20(b *testing.B) {  
    for i := 0; i < b.N; i++ {  
       // Call the function we're benchmarking  
       fib(20)  
    }  
}

benchmark 函数必须以 Benchmark 开始 我们可以这样运行它,都是等效的。

1
2
go test -bench .
go test -bench="BenchmarkFib20"

b.N

benchmark 用例的参数 b *testing.B,有个属性 b.N 表示这个用例需要运行的次数。b.N 对于每个用例都是不一样的。
那这个值是如何决定的呢?b.N 从 1 开始,如果该用例能够在 1s 内完成,b.N 的值便会增加,再次执行。b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快

进阶参数

提升准确度

降低系统噪音:perflock

perflock 作用是限制 CPU 时钟频率,从而一定程度上消除系统对性能测试程序的影响,减少结果的噪声,进而性能测量的结果方差更小也更加可靠。

ResetTimer

如果在 benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。

StopTimer & StartTimer

StopTimer & StartTimer 也是相同原理,每次函数调用前后需要一些准备工作和清理工作,我们可以使用 StopTimer 暂停计时以及使用 StartTimer 开始计时。

度量 benchmark

benchstat 是官方提供的一个对比工具,用于比较两次benchmark之间的性能差别

Benchstat computes statistical summaries and A/B comparisons of Go benchmarks.

我们用benchstat对比一下 bubbleSort 跟quickSort 的性能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func softNums() {  
    //bubbleSort(nums)  
    //quickSort(nums)
}  
  
func initNums(count int) {  
    rand.Seed(time.Now().UnixNano())  
    nums = make([]int, count)  
    for i := 0; i < count; i++ {  
       nums[i] = rand.Intn(count)  
    }  
}  
func BenchmarkSoftNums(b *testing.B) {  
    initNums(10000)  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       softNums()  
    }  
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
go test -bench="BenchmarkSoftNums" -count=10 |tee quicksoft.txt
go test -bench="BenchmarkSoftNums" -count=10 |tee bubblesoft.txt

➜  benchmark git:(main) ✗ benchstat bubblesoft.txt quicksoft.txt                         
goos: darwin
goarch: arm64
pkg: blog-example/go/benchmark
            │ bubblesoft.txt │            quicksoft.txt            │
            │     sec/op     │   sec/op     vs base                │
SoftNums-10    31942.4µ ± 1%   775.8µ ± 2%  -97.57% (p=0.000 n=10)

这样的结果是不是很清晰?我们可以把他放在报告或者github issue 中,很专业。

一些第三方的工具

bench

bench 是一个为 Go 程序提供集成性能测量、自动性能锁定、统计分析和颜色指示的基准测试工具。

funcbench

funcbench 是 Prometheus 项目中用于自动化 Go 代码基准测试和性能比较的工具。以下是其主要特点和功能:
支持比较本地分支与GitHub 分支、性能锁定、性能比较 等特点,有利于提高开发效率。如果项目比较大,可以参考这种方式。

参考资料

Go高性能编程 EP4: 反射

2024-07-09 09:44:18

Featured image of post Go高性能编程 EP4: 反射

reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。Go 语言标准库以及很多开源软件中都使用了 Go 语言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm/xorm 等。本文的目标是学习reflect,以及如何提高reflect的性能。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

Example Code

如何使用反射简化代码

我们利用反射实现一个简单的功能,来看看反射如何帮助我们简化代码的。
假设有一个配置类 Config,每个字段是一个配置项。我们需要从环境变量中 读取 CONFIG_xxxx 。然后初始化Config。
list 1 :MySQL config

1
2
3
4
5
6
7
8
9
// https://github.com/go-sql-driver/mysql/blob/v1.8.1/dsn.go#L37  
type Config struct {  
    User   string `json:"user"`    // Username  
    Passwd string `json:"passwd"`  // Password (requires User)  
    Net    string `json:"net"`     // Network (e.g. "tcp", "tcp6", "unix". default: "tcp")  
    Addr   string `json:"addr"`    // Address (default: "127.0.0.1:3306" for "tcp" and "/tmp/mysql.sock" for "unix")  
    DBName string `json:"db_name"` // Database name  
    // 。。。。。  
}

Footguns

我们很容易写出这样的代码,
list2: 使用 for 循环初始化 Config

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func InitConfig() *Config {  
    cfg := &Config{}  
    keys := []string{"CONFIG_MYSQL_USER", "CONFIG_MYSQL_PASSWD", "CONFIG_MYSQL_NET", "CONFIG_MYSQL_ADDR", "CONFIG_MYSQL_DB_NAME"}  
    for _, key := range keys {  
       if env, exist := os.LookupEnv(key); exist {  
          switch key {  
          case "CONFIG_MYSQL_USER":  
             cfg.User = env  
          case "CONFIG_MYSQL_PASSWORD":  
             cfg.Passwd = env  
          case "CONFIG_MYSQL_NET":  
             cfg.Net = env  
          case "CONFIG_MYSQL_ADDR":  
             cfg.Addr = env  
          case "CONFIG_MYSQL_DB_NAME":  
             cfg.DBName = env  
          }  
       }  
    }  
    return cfg  
}

但是使用硬编码的话,如果Config 结构发生改变,比如了修改 json 对应的字段、删除或新增了一个配置项等,这块的逻辑也需要发生改变。而更大的问题在于:非常容易出错,不好测试。

如果我们改成使用 反射实现,实现的代码就是这样的:

list3: 使用reflect 实现初始化Config

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func InitConfig2() *Config {  
    config := Config{}  
    typ := reflect.TypeOf(config)  
    value := reflect.Indirect(reflect.ValueOf(&config))  
    for i := 0; i < typ.NumField(); i++ {  
       f := typ.Field(i)  
       if v, ok := f.Tag.Lookup("json"); ok {  
          key := fmt.Sprintf("CONFIG_MYSQL_%s", strings.ToUpper(v))  
          if env, exist := os.LookupEnv(key); exist {  
             value.FieldByName(f.Name).Set(reflect.ValueOf(env))  
          }  
       }  
    }  
    return &config  
}

实现逻辑其实是非常简单的:

反射的性能

我们在很多地方都听说过:反射的性能很差,并且我们在对比json解析库的时候也验证了官方库JSON Unmarshal 的性能比较低.因为他需要执行更多的指令。那么反射的性能到底有多差呢? 我们做一次benchmark就知道了。
list 4 : benchmark test New And Reflect Performance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func BenchmarkNew(b *testing.B) {  
    var config *Config  
    for i := 0; i < b.N; i++ {  
       config = new(Config)  
    }  
    _ = config  
}  
  
func BenchmarkReflectNew(b *testing.B) {  
    var config *Config  
    typ := reflect.TypeOf(Config{})  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       config, _ = reflect.New(typ).Interface().(*Config)  
    }  
    _ = config  
}
----
  go_reflect git:(main)  go test --bench . 
goos: darwin
goarch: arm64
pkg: blog-example/go/go_reflect
BenchmarkNew-10                 47675076                25.40 ns/op
BenchmarkReflectNew-10          36163776                32.51 ns/op
PASS
ok      blog-example/go/go_reflect      3.895s

如果只是创建的场景,两者的性能差距不是特别大。
我们再测试一下修改字段场景,通过反射修改字段有两种方式
FieldByName
Field 下标模式
我们分别测试两种模式的性能
list5: 测试修改Field 的性能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func BenchmarkFieldSet(b *testing.B) {  
    config := new(Config)  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       config.Net = "tcp4"  
       config.Addr = "127.0.0.1:3306"  
       config.Passwd = "123456"  
       config.User = "admin"  
    }  
}  
  
func BenchmarkFieldSetFieldByName(b *testing.B) {  
    config := new(Config)  
    value := reflect.ValueOf(config).Elem()  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.FieldByName("Net").SetString("tcp4")  
       value.FieldByName("Addr").SetString("127.0.0.1:3306")  
       value.FieldByName("Passwd").SetString("123456")  
       value.FieldByName("User").SetString("admin")  
    }  
}  
func BenchmarkFieldSetField(b *testing.B) {  
    config := new(Config)  
    value := reflect.Indirect(reflect.ValueOf(config))  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.Field(0).SetString("tcp4")  
       value.Field(1).SetString("127.0.0.1:3306")  
       value.Field(2).SetString("123456")  
       value.Field(3).SetString("admin")  
    }  
}
----
  go_reflect git:(main)  go test --bench="BenchmarkFieldSet*"          
goos: darwin
goarch: arm64
pkg: blog-example/go/go_reflect
BenchmarkFieldSet-10                    1000000000               0.3282 ns/op
BenchmarkFieldSetFieldByName-10          6471114               185.3 ns/op
BenchmarkFieldSetField-10               100000000               11.88 ns/op
PASS
ok      blog-example/go/go_reflect      3.910s

差距很大,非reflect 方式跟 reflect Field 下标模式 ,差两个数量级,跟reflect FieldByName 模式 甚至达到了三个数量级,比较疑问的一个点是FieldByName 模式 跟 Field 下标模式 差距竟然也有一个数量级。不过我们能够从源码中找到答案。
list 6: reflect/value.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func (v Value) FieldByName(name string) Value {  
    v.mustBe(Struct)  
    if f, ok := toRType(v.typ()).FieldByName(name); ok {  
       return v.FieldByIndex(f.Index)  
    }  
    return Value{}  
}

// FieldByIndex returns the nested field corresponding to index.// It panics if evaluation requires stepping through a nil  
// pointer or a field that is not a struct.  
func (v Value) FieldByIndex(index []int) Value {  
    if len(index) == 1 {  
       return v.Field(index[0])  
    }  
    v.mustBe(Struct)  
    for i, x := range index {  
       if i > 0 {  
          if v.Kind() == Pointer && v.typ().Elem().Kind() == abi.Struct {  
             if v.IsNil() {  
                panic("reflect: indirection through nil pointer to embedded struct")  
             }  
             v = v.Elem()  
          }  
       }  
       v = v.Field(x)  
    }  
    return v  
}

list 7:reflect/type.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (t *rtype) FieldByName(name string) (StructField, bool) {  
    if t.Kind() != Struct {  
       panic("reflect: FieldByName of non-struct type " + t.String())  
    }  
    tt := (*structType)(unsafe.Pointer(t))  
    return tt.FieldByName(name)  
}
// FieldByName returns the struct field with the given name// and a boolean to indicate if the field was found.  
func (t *structType) FieldByName(name string) (f StructField, present bool) {  
    // Quick check for top-level name, or struct without embedded fields.  
    hasEmbeds := false  
    if name != "" {  
       for i := range t.Fields {  
          tf := &t.Fields[i]  
          if tf.Name.Name() == name {  
             return t.Field(i), true  
          }  
          if tf.Embedded() {  
             hasEmbeds = true  
          }  
       }  
    }  
    if !hasEmbeds {  
       return  
    }  
    return t.FieldByNameFunc(func(s string) bool { return s == name })  
}

在反射的内部,字段是按顺序存储的,因此按照下标访问查询效率为 O(1),而按照 Name 访问,则需要遍历所有字段,查询效率为 O(N)。结构体所包含的字段(包括方法)越多,那么两者之间的效率差距则越大。但是我们需要记忆字段的顺序,这很容易出错。

如何提高性能

尽量避免使用reflect

使用反射赋值,效率非常低下,如果有替代方案,尽可能避免使用反射,特别是会被反复调用的热点代码。例如 RPC 协议中,需要对结构体进行序列化和反序列化,这个时候避免使用 Go 语言自带的 json 的 Marshal 和 Unmarshal 方法,因为标准库中的 json 序列化和反序列化是利用反射实现的。我们可以使用fastjson等替代标准库,应该能带来十倍左右的提升。

尽量使用 Field 下标模式

从前面的benchmark 看来, Field 下标模式比FieldByName 的方式快接近一个数量级,在字段数量多的时候,更加明显,但是使用Field 下标模式会比较麻烦,我们需要记忆下标,并且很难修改。这个时候,我们可以使用一个Map 将Name 跟下标缓存起来。
比如:
list8: cache FieldByName and Field index

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func BenchmarkFieldSetFieldByNameCache(b *testing.B) {  
    config := new(Config)  
    typ := reflect.TypeOf(Config{})  
    value := reflect.ValueOf(config).Elem()  
    cache := make(map[string]int)  
    for i := 0; i < typ.NumField(); i++ {  
       cache[typ.Field(i).Name] = i  
    }  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       value.Field(cache["Net"]).SetString("tcp4")  
       value.Field(cache["Addr"]).SetString("127.0.0.1:3306")  
       value.Field(cache["Passwd"]).SetString("123456")  
       value.Field(cache["User"]).SetString("admin")  
    }  
}
----
BenchmarkFieldSetFieldByNameCache-10            32121740                36.85 ns/op

比直接使用FieldByName 提升了4倍,很可观的提升。

总结

  1. 本文没有深入介绍 reflect 的细节,如果您想要进一步学习reflect 可以参考下面的文章:
    1. https://medium.com/capital-one-tech/learning-to-use-go-reflection-822a0aed74b7
    2. https://go101.org/article/reflection.html
  2. 在各个基础库中,大量使用reflect ,合理的使用reflect 有助于简化代码,但是reflect 有性能损耗,最极端的情况可能降低3个数量级。
  3. 可以用 Field index + cache 的方式来优化 性能。
    对于reflect 您有什么其他想法嘛?留言跟我一起讨论。

Go高性能编程 EP6: 异步编程的技巧

2024-07-08 17:38:59

Golang最大的使命就是简化异步编程,当我们遇到那种需要批量处理且耗时的操作时,传统的单线程执行就显得吃力,这时就会想到异步并行处理。本篇文章介绍一些Golang异步编程的技巧。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

https://github.com/sourcegraph/conc

首先介绍一个简化并发编程的的库conc 里面封装了很多实用的工具,比如WaitGroupiter.Map等。我们并不一定要在生产代码中使用conc,但是学习他的一些思路还是可以的。

使用方式

go

最简单的最常用的方式:使用go关键词

1
2
3
4
5
6
7
8
func main() {  
 go func() {  
  fmt.Println("hello world1")  
 }()  
 go func() {  
  fmt.Println("hello world2")  
 }()  
}

或者:

1
2
3
4
5
6
7
func main() {  
 go Announce("hello world1")  
 go Announce("hello world2")  
}  
func Announce(message string) {  
 fmt.Println(message)  
}

使用匿名函数传递参数

1
2
3
4
5
data := "Hello, World!"  
go func(msg string) {  
		// Use msg to perform asynchronous task logic processing
      fmt.Println(msg)  
}(data)

这种方式不需要考虑返回值问题,如果要考虑返回值,可以使用下面的方式。

通过goroutine和channel来实现超时控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ch := make(chan int, 1)  
timer := time.NewTimer(time.Second)  
go func() {  
    time.Sleep(2 * time.Second)  
    ch <- 1  
    close(ch)  
}()  
select {  
case <-timer.C:  
    fmt.Println("timeout")  
case result := <-ch:  
    fmt.Println(result)  
}

使用sync.WaitGroup

sync.WaitGroup用于等待一组协程完成其任务。通过Add()方法增加等待的协程数量,Done()方法标记协程完成,Wait()方法阻塞直到所有协程完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    var wg sync.WaitGroup  
    // 启动多个协程  
    for i := 0; i < 5; i++ {  
       wg.Add(1)  
       go func(index int) {  
          defer wg.Done()  
          // 异步任务逻辑  
       }(i)  
    }  
    wg.Wait()  
}

1.4、使用errgroup实现goroutine group的错误处理

如果想简单获取协程返回的错误,errgroup包很适合,errgroup包是Go语言标准库中的一个实用工具,用于管理一组协程并处理它们的错误。可以使用errgroup.Group结构来跟踪和处理协程组的错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var eg errgroup.Group  
for i := 0; i < 5; i++ {  
    eg.Go(func() error {  
     return errors.New("error")  
    })  
  
    eg.Go(func() error {  
     return nil  
    })  
}  
  
if err := eg.Wait(); err != nil {  
    // 处理错误  
}

一些使用技巧

使用channel的range和close操作

range操作可以在接收通道上迭代值,直到通道关闭。可以使用close函数关闭通道,以向接收方指示没有更多的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ch := make(chan int)  
  
go func() {  
    for i := 0; i < 5; i++ {  
        ch <- i // 发送值到通道  
    }  
    close(ch) // 关闭通道  
}()  
  
// 使用range迭代接收通道的值  
for val := range ch {  
    // 处理接收到的值  
}
// do somethings

使用select语句实现多个异步操作的等待

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
ch1 := make(chan int)  
ch2 := make(chan string)  
  
go func() {  
    // 异步任务1逻辑  
    ch1 <- result1  
}()  
  
go func() {  
    // 异步任务2逻辑  
    ch2 <- result2  
}()  
  
// 在主goroutine中等待多个异步任务完成  
select {  
case res1 := <-ch1:  
    // 处理结果1  
case res2 := <-ch2:  
    // 处理结果2  
}

使用select和time.After()实现超时控制

如果需要在异步操作中设置超时,可以使用select语句结合time.After()函数实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ch := make(chan int)  
  
go func() {  
    // 异步任务逻辑  
    time.Sleep(2 * time.Second)  
    ch <- result  
}()  
  
// 设置超时时间  
select {  
case res := <-ch:  
    // 处理结果  
case <-time.After(3 * time.Second):  
    // 超时处理  
}

使用time.Tick()和time.After()进行定时操作

time.Tick()函数返回一个通道,定期发送时间值,可以用于执行定时操作。time.After()函数返回一个通道,在指定的时间后发送一个时间值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
tick := time.Tick(1 * time.Second) // 每秒执行一次操作  
  
for {  
    select {  
    case <-tick:  
        // 执行定时操作  
    }  
}  
  
select {  
case <-time.After(5 * time.Second):  
    // 在5秒后执行操作  
}

使用sync.Mutex或sync.RWMutex进行并发安全访问

当多个协程并发访问共享数据时,需要确保数据访问的安全性。sync.Mutex和sync.RWMutex提供了互斥锁和读写锁,用于在访问共享资源之前进行锁定,以避免数据竞争。sync.RWMutex是一种读写锁,可以在多个协程之间提供对共享资源的并发访问控制。多个协程可以同时获取读锁,但只有一个协程可以获取写锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var mutex sync.Mutex  
var data int  
  
// 写操作,使用互斥锁保护数据  
mutex.Lock()  
data = 123  
mutex.Unlock()  
  
// 读操作,使用读锁保护数据  
//RLock()加读锁时,如果存在写锁,则无法加读锁;当只有读锁或者没有锁时,可以加读锁,读锁可以加载多个  
mutex.RLock()  
value := data  
mutex.RUnlock()  
  
var rwMutex sync.RWMutex  
var sharedData map[string]string  
  
// 读操作,使用rwMutex.RLock读锁保护数据  
func readData(key string) string {  
    rwMutex.RLock()  
    defer rwMutex.RUnlock()  
    return sharedData[key]  
}  
  
// 写操作,使用rwMutex.Lock写锁保护数据  
func writeData(key, value string) {  
    rwMutex.Lock()  
    defer rwMutex.Unlock()  
    sharedData[key] = value  
}

注意:sync.Mutex 的锁是不可以嵌套使用的 sync.RWMutex 的 RLock()是可以嵌套使用的 sync.RWMutex 的 mu.Lock() 是不可以嵌套的 sync.RWMutex 的 mu.Lock() 中不可以嵌套 mu.RLock()

使用sync.Cond进行条件变量控制

sync.Cond是一个条件变量,用于在协程之间进行通信和同步。它可以在指定的条件满足之前阻塞等待,并在条件满足时唤醒等待的协程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var cond = sync.NewCond(&sync.Mutex{})  
var ready bool  
  
go func() {  
    // 异步任务逻辑  
    ready = true  
  
    // 通知等待的协程条件已满足  
    cond.Broadcast()  
}()  
  
// 在某个地方等待条件满足  
cond.L.Lock()  
for !ready {  
    cond.Wait()  
}  
cond.L.Unlock()

使用sync.Pool管理对象池

sync.Pool是一个对象池,用于缓存和复用临时对象,可以提高对象的分配和回收效率。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type MyObject struct {  
    // 对象结构  
}  
  
var objectPool = sync.Pool{  
    New: func() interface{} {  
        // 创建新对象  
        return &MyObject{}  
    },  
}  
  
// 从对象池获取对象  
obj := objectPool.Get().(*MyObject)  
  
// 使用对象  
  
// 将对象放回对象池  
objectPool.Put(obj)

使用sync.Once实现只执行一次的操作

sync.Once用于确保某个操作只执行一次,无论有多少个协程尝试执行它,常用于初始化或加载资源等场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
var once sync.Once  
var resource *Resource  
  
func getResource() *Resource {  
    once.Do(func() {  
        // 执行初始化资源的操作,仅执行一次  
        resource = initResource()  
    })  
    return resource  
}  
  
// 在多个协程中获取资源  
go func() {  
    res := getResource()  
    // 使用资源  
}()  
  
go func() {  
    res := getResource()  
    // 使用资源  
}()

使用sync.Once和context.Context实现资源清理

可以结合使用sync.Once和context.Context来确保在多个协程之间只执行一次资源清理操作,并在取消或超时时进行清理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var once sync.Once  
  
func cleanup() {  
    // 执行资源清理操作  
}  
  
func doTask(ctx context.Context) {  
    go func() {  
        select {  
        case <-ctx.Done():  
            once.Do(cleanup) // 只执行一次资源清理  
        }  
    }()  
  
    // 异步任务逻辑  
}

使用sync.Map实现并发安全的Map

sync.Map是Go语言标准库中提供的并发安全的映射类型,可在多个协程之间安全地进行读写操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var m sync.Map  
  
// 存储键值对  
m.Store("key", "value")  
  
// 获取值  
if val, ok := m.Load("key"); ok {  
    // 使用值  
}  
  
// 删除键  
m.Delete("key")

使用context.Context进行协程管理和取消

context.Context用于在协程之间传递上下文信息,并可用于取消或超时控制。可以使用context.WithCancel()创建一个可取消的上下文,并使用context.WithTimeout()创建一个带有超时的上下文。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ctx, cancel := context.WithCancel(context.Background())  
  
go func() {  
    // 异步任务逻辑  
    if someCondition {  
        cancel() // 取消任务  
    }  
}()  
  
// 等待任务完成或取消  
select {  
case <-ctx.Done():  
    // 任务被取消或超时  
}

使用context.WithDeadline()和context.WithTimeout()设置截止时间

context.WithDeadline()和context.WithTimeout()函数可以用于创建带有截止时间的上下文,以限制异步任务的执行时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func doTask(ctx context.Context) {  
    // 异步任务逻辑  
  
    select {  
    case <-time.After(5 * time.Second):  
        // 超时处理  
    case <-ctx.Done():  
        // 上下文取消处理  
    }  
}  
  
func main() {  
    ctx := context.Background()  
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)  
    defer cancel()  
  
    go doTask(ctx)  
  
    // 继续其他操作  
}

使用context.WithValue()传递上下文值

context.WithValue()函数可用于在上下文中传递键值对,以在协程之间共享和传递上下文相关的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type keyContextValue string  
  
func doTask(ctx context.Context) {  
    if val := ctx.Value(keyContextValue("key")); val != nil {  
        // 使用上下文值  
    }  
}  
  
func main() {  
    ctx := context.WithValue(context.Background(), keyContextValue("key"), "value")  
    go doTask(ctx)  
  
    // 继续其他操作  
}

使用atomic包进行原子操作

atomic包提供了一组函数,用于实现原子操作,以确保在并发环境中对共享变量的读写操作是原子的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var counter int64
func increment() {
	atomic.AddInt64(&counter, 1)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
			increment()
		}()
	}
	wg.Wait()
	fmt.Println("Counter:", counter)
}

总结

本篇文章,我们介绍的是一些常用的关键字,掌握这些,应对常用的并发编程已经问题没有什么问题了。相信您也已经发现了,go源码中没有提供javac# 中常用的 Barrier 功能,虽然Barrier 的功能我们使用基本的并发语句也能实现,但是不如调用现成的API接口方便。其实在 golang.org/x中有SingleFlight 以及是第三方[CyclicBarrier](https://github.com/marusama/cyclicbarrier)。下一篇文章中,我们将介绍这两个并发原语。

go 高性能编程EP3: 内存对齐

2024-07-03 15:48:59

Featured image of post go 高性能编程EP3: 内存对齐

本文写作所有的例子以 macbookpro M1 为例,该CPU为64位架构

本文是Go语言高性能编程第三篇,分析了为什么需要内存对齐,Go语言内存对齐的规则,以及实际例子中内存对齐的使用,最后分享了两个工具,帮助我们在开发过程中发现内存对齐问题。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

什么是内存对齐?

在程序员眼里,内存可能就是一个巨大的数组,我们可以在内存中写一个int16 ,占用两个字节。也可以写一个int32,占用四个字节。 比如

1
2
3
4
5
type T1 struct {  
    a int8  
    b int64  
    c int16  
}

这个 struce 不熟悉Go语言的人可能认为是下面这种布局。 总共占用11字节空间。
Figure 1: Memory layout as understood by some people
Memory layout as understood by some people
一个挨着一个,很紧凑,很完美。
但是实际上并不是这样的。如果我们打印 T1 的变量地址,会发现,他们大概长这样。总共占用 24字节空间。

Figure 2: T1 的实际内存布局
T1 的实际内存布局
List 1:T1 size

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {  
    t := T1{}  
    fmt.Println(fmt.Sprintf("%d %d %d %d", unsafe.Sizeof(t.a), unsafe.Sizeof(t.b), unsafe.Sizeof(t.c), unsafe.Sizeof(t)))  
    fmt.Println(fmt.Sprintf("%p %p %p", &t.a, &t.b, &t.c))  
    fmt.Println(unsafe.Alignof(t))    
}
// output
// 1 8 2 24
// 0x14000114018 0x14000114020 0x14000114028
// 8

因为CPU从内存里面拿数据,是根据word size 来拿的,比如 64 位的 CPU ,word size 为 8字节,那么 CPU 访问内存的单位也是 8 字节,我们将处理器访问内存的大小称为内存访问粒度。
这种现象,会造成几个严重的问题

  1. 性能降低,因为多了一次CPU指令
  2. 原本读一个变量是原子操作的,现在变得不原子
  3. 一些其他意想不到的情况。
    所以一般编译器都会实现内存对齐,用牺牲内存空间的方式,保证了:

GO语言内存对齐

go spec 中约定了 go 对齐的规则。

1
2
3
4
5
6
7
type                                 size in bytes

byte, uint8, int8                     1
uint16, int16                         2
uint32, int32, float32                4
uint64, int64, float64, complex64     8
complex128                           16
  1. For a variable x of any type: unsafe.Alignof(x) is at least 1.
  2. For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
  3. For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array’s element type.

绝大部分情况下,go编译器会帮我们自动内存对齐,我们不需要关心内存是否对齐,但是在有一种情况下,需要手动对齐。

在 x86 平台上原子操作 64bit 指针。之所以要强制对齐,是因为在 32bit 平台下进行 64bit 原子操作要求必须 8 字节对齐,否则程序会 panic。
比如下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import "sync/atomic"

type T3 struct {
	b int64
	c int32
	d int64
}

func main() {
	a := T3{}
	atomic.AddInt64(&a.d, 1)
}

在 amd64 架构下运行不会报错,但是在i386 架构下面就会panic。
Figure 3: T3 panic
T3 panic
原因就是 T3 在 32bit 平台上是 4 字节对齐,而在 64bit 平台上是 8 字节对齐。在 64bit 平台上其内存布局为:
Figure 4: T3在 amd64 的内存布局

Pasted image 20240707204743
但是在I386 的布局为:
Figure 5: T3在 i386的内存布局
T3在 i386的内存布局
这个问题在 atomic 的 文档中有写。

  • On 386, the 64-bit functions use instructions unavailable before the Pentium MMX.
    On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.
    On ARM, 386, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically via the primitive atomic functions (types Int64 and Uint64 are automatically aligned). The first word in an allocated struct, array, or slice; in a global variable; or in a local variable (because the subject of all atomic operations will escape to the heap) can be relied upon to be 64-bit aligned.

为了解决这种情况,我们必须手动 padding T3,让其 “看起来” 像是 8 字节对齐的:

1
2
3
4
5
6
type T3 struct {
	b int64
	c int32
	_ int32
	d int64
}

在go源码和开源库中也能看到很多类似的操作。
比如

  1. mgc
  2. groupcache

所幸的是,我们其实有很多工具来帮助我们识别与优化 这些问题。

工程实践

fieldalignment

fieldalignment 是golang 官方的工具,它会帮我们发现代码中可能的内存对齐优化以及自动帮我们对齐。 比如T1 它会自动 转成内存对齐的。

1
2
3
4
5
6
7
8
9
➜  go_mem_alignment git:(main) ✗ fieldalignment -fix .          
/Users/hxzhouh/workspace/github/blog-example/go/go_mem_alignment/main.go:8:8: struct of size 24 could be 16

// change
type T1 struct {  
    b int64  
    c int16  
    a int8  
}

也可以在 golangci-link 中使用它,fieldalignment 是隶属于 govet 的一个子功能,在 .golangci.yaml 中可以这样启用它:
list :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# .golangci.yml
linters:  
  disable-all: true  
  enable:  
    - govet  
  fast: false  
  
linters-settings:  
  govet:  
    # report about shadowed variables  
    check-shadowing: false  
    fast: false  
    # disable:  
    #  - fieldalignment # I'm ok to waste some bytes    enable:  
      - fieldalignment

但是,fieldalignment 有一个比较恼火的地方:它会在重新排布结构体成员的时候,将所有空行、注释通通删去。 所以有时候,你应该 git commit 一次,然后用一下这个工具,然后通过 git diff 来 review 它所做的变更,然后进行若干后处理。所以我再生产环境很少使用这个 工具,一般使用structlayout

structlayout

structlayout 可以显示struct的布局以及大小,可以输出svg或者json格式的数据。如果一个struct 比较复杂,可以用这个工具来优化。
安装方式

1
2
3
4
go install honnef.co/go/tools/cmd/structlayout@latest 
go install honnef.co/go/tools/cmd/structlayout-pretty@latest
go install honnef.co/go/tools/cmd/structlayout-optimize@latest
go install github.com/ajstarks/svgo/structlayout-svg@latest

structlayout 分析一下 T1

1
structlayout -json ./main.go T1 | structlayout-svg  >T1.svg

Figure 6: T1 Structure Layout
 T1 Structure Layout
我们可以很清楚的看到有两个padding。 7 size 和 6size
优化后的T2:

1
2
3
4
5
type T2 struct {  
    a int8
    c int16
    b int64  
}

Figure 7: T2 Structure Layout
T2 Structure Layout
只有也有两个地方有padding,但是只有5个size。

总结

在程序设计中,内存对齐是一项关键技术,旨在提高程序性能和兼容性。本文以Go语言为例,详细讲解了内存对齐的基本概念和必要性,并通过代码示例展示了不同结构体在内存中的实际布局。

Go语言中的内存对齐规则主要体现在结构体字段的排列顺序上。编译器通过自动对齐来保证性能和平台移植性,但在某些情况下需要开发者手动调整结构体字段以避免性能问题和潜在的错误。

empty struct 是内存对齐优化的一个好帮手,具体操作可以参考我的另外一篇文章:Golang High-Performance Programming EP1: Empty Struct

为帮助开发者检测和优化内存对齐问题,本文介绍了两个实用工具:

  1. fieldalignment:Go官方工具,能自动优化结构体的内存对齐。
  2. structlayout:显示结构体的内存布局,帮助开发者更直观地理解和优化内存使用。

通过合理使用这些工具,开发者可以在保证程序性能和稳定性的同时,减少内存浪费,提升开发效率。

参考资料

  1. IBM DeveloperWorks: Data Alignment
  2. Go Specification: Size and Alignment Guarantees

Go 高性能编程EP2: 通过upx 缩小可执行二进制文件的体积

2024-07-02 15:01:44

Featured image of post Go 高性能编程EP2:  通过upx 缩小可执行二进制文件的体积

我们都知道,Go有一个很重要的特点,那就是它的编译速度非常快,编译速度是Go语言设计的时候就重点考虑的问题. 但是您有没有观察过Go语言编译后的二进制可执行文件的大小?我们先用一个简单的http server 的例子来看看。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import (  
    "fmt"  
    "net/http"
    )  
func main() {  
    // create a http server and create a handler hello, return hello world  
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {  
       fmt.Fprintf(w, "Hello, World\n")  
    })  
    // listen to port 8080  
    err := http.ListenAndServe(":8080", nil)  
    if err != nil {  
       return  
    }  
}
---

编译后的体积达到了6.5M

1
2
3
➜  binary-size git:(main) ✗ go build  -o server main.go                 
➜  binary-size git:(main) ✗ ls -lh server 
-rwxr-xr-x  1 hxzhouh  staff   6.5M Jul  2 14:20 server

Go语言的编译器会对二进制文件的大小进行裁剪,如果您对这部分的内容感兴趣,请阅读我的另外一篇文章How Does the Go Compiler Reduce Binary File Size?

现在我们来尝试优化一下server 的大小。

消除调试信息

Go 编译器默认编译出来的程序会带有符号表和调试信息,一般来说 release 版本可以去除调试信息以减小二进制体积。

1
2
3
➜  binary-size git:(main) ✗ go build -ldflags="-s -w" -o server main.go
➜  binary-size git:(main) ✗ ls -lh server                              
-rwxr-xr-x  1 hxzhouh  staff   4.5M Jul  2 14:30 server

使用 upx

UPX is an advanced executable file compressor. UPX will typically reduce the file size of programs and DLLs by around 50%-70%, thus reducing disk space, network load times, download times and other distribution and storage costs.

在Mac 上可以通过brew 安装upx

1
brew install upx

单独使用upx 压缩

upx 有很多参数,最重要的则是压缩率,1-91 代表最低压缩率,9 代表最高压缩率。
接下来,我们看一下,如果只使用 upx 压缩,二进制的体积可以减小多少呢。

1
2
➜  binary-size git:(main) ✗ go build -o server main.go && upx -9 server && ls -lh server 
-rwxr-xr-x  1 hxzhouh  staff   3.9M Jul  2 14:38 server

压缩比例达到了 60%

upx + 编译器选项

同时开启 upx + -ldflags="-s -w"

1
2
➜  binary-size git:(main) ✗ go build -ldflags="-s -w"  -o server main.go && upx --brute server && ls -lh server 
-rwxr-xr-x  1 hxzhouh  staff   1.4M Jul  2 14:40 server

最终我们得到的的可执行文件的大小是 1.4M 对比不开启任何压缩的6.5M,大约节约了80%的空间,对于大型应用,还是挺可观的。

upx 的原理

upx 压缩后的程序和压缩前的程序一样,无需解压仍然能够正常地运行,这种压缩方法称之为带壳压缩,压缩包含两个部分:

执行时,也包含两个部分:

最后

https://stackoverflow.com/questions/3861634/how-to-reduce-go-compiled-file-size
这帖子里面有很多有意思的答案,

gRPC中的错误处理

2024-06-30 05:38:00

gRPC 一般不在 message 中定义错误。
毕竟每个 gRPC 服务本身就带一个 error 的返回值,这是用来传输错误的专用通道。
gRPC 中所有的错误返回都应该是 nil 或者 由 status.Status 产生的一个error。这样error可以直接被调用方Client识别。

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

1. 常规用法

当遇到一个go错误的时候,直接返回是无法被下游client识别的。
恰当的做法是:

1
2
 st := status.New(codes.NotFound, "some description")  
 err := st.Err()

传入的错误码是 codes.Code 类型。
此外还有更便捷的办法:使用 status.Error。它避免了手动转换的操作。

1
 err := status.Error(codes.NotFound, "some description")

2. 进阶用法

上面的错误有个问题,就是 code.Code 定义的错误码只有固定的几种,无法详尽地表达业务中遇到的错误场景
gRPC 提供了在错误中补充信息的机制:status.WithDetails 方法
Client 通过将 error 重新转换位 status.Status ,就可以通过 status.Details 方法直接获取其中的内容。
status.Details 返回的是个slice, 是interface{}的slice,然而go已经自动做了类型转换,可以通过断言直接使用。

服务端示例服务端示例

 // 生成一个 status.Status

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func ErrorWithDetails() error {  
    st := status.Newf(codes.Internal, fmt.Sprintf("something went wrong: %v", "api.Getter"))  
    v := &errdetails.PreconditionFailure_Violation{ //errDetails  
       Type:        "test",  
       Subject:     "12",  
       Description: "32",  
    }  
    br := &errdetails.PreconditionFailure{}  
    br.Violations = append(br.Violations, v)  
    st, _ = st.WithDetails(br)  
    return st.Err()  
}

客户端的示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 resp, err := odinApp.CreatePlan(cli.StaffId.AssetId, gentRatePlanMeta(cli.StaffId))  
   
   if status.Code(err) != codes.InvalidArgument {  
     logger.Error("create plan error:%v", err)  
   } else {  
     for _, d := range status.Convert(err).Details() {  
       //   
       switch info := d.(type) {  
       case *errdetails.QuotaFailure:  
         logger.Info("Quota failure: %s", info)  
       case *errdetails.PreconditionFailure:  
         detail := d.(*errdetails.PreconditionFailure).Violations  
         for _, v1 := range detail {  
           logger.Info(fmt.Sprintf("details: %+v", v1))  
         }  
       case *errdetails.ResourceInfo:  
         logger.Info("ResourceInfo: %s", info)  
   
       case *errdetails.BadRequest:  
         logger.Info("ResourceInfo: %s", info)  
   
       default:  
         logger.Info("Unexpected type: %s", info)  
       }  
     }  
   }  
   logger.Infof("create plan success,resp=%v", resp)

原理

这个错误是如何传递给调用方Client的呢?
是放到 metadata中的,而metadata是放到HTTP的header中的。
metadata是key:value格式的数据。错误的传递中,key是个固定值:grpc-status-details-bin。
而value,是被proto编码过的,是二进制安全的。
目前大多数语言都实现了这个机制。
Pasted image 20240511155439

注意

gRPC对响应头做了限制,上限为8K,所以错误不能太大。

参考资料
https://protobuf.dev/getting-started/gotutorial/
https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/errdetails

使用自签名证书SAN为gRPC建立TLS 连接

2024-06-29 21:41:50

Featured image of post  使用自签名证书SAN为gRPC建立TLS 连接

gRPC 是Google开发的一个高性能RPC框架,gRPC 默认内置了两种认证方式:

1
rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: x509: certificate relies on legacy Common Name field, use SANs instead"|

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

什么是SAN

SAN(Subject Alternative Name) 是 SSL 标准 x509 中定义的一个扩展。使用了 SAN 字段的 SSL 证书,可以扩展此证书支持的域名,使得一个证书可以支持多个不同域名的解析。
通俗点就是,在 SAN 证书中,可以有多个完整的 CN(CommonName),这样只需要购买一个证书就可以用在多个 URL。比如 skype.com 的证书,它就有很多 SAN。

在本地创建SAN 证书

下面 我们将用一个例子在本地生成 客户端&服务端双向SAN 证书。
假设gRPC服务端的主机名为localhost,需要为gRPC服务端和客户端之间的通信配置tls双向认证加密。

  1. 新建 openssl.conf 来放相关信息
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[req]  
req_extensions = v3_req  
distinguished_name = req_distinguished_name  
prompt = no  
  
[req_distinguished_name]  
countryName = CN  
stateOrProvinceName = state  
localityName = city  
organizationName = huizhou92  
commonName = hello-world  
  
[v3_req]  
subjectAltName = @alt_names  
  
[alt_names]  
DNS.1 = localhost

其中内容 跟 以前创建ca的时候差不多。
2. 生成ca根证书

1
openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -subj "/CN=localhost" -days 3650  -nodes

-nodes 是忽略密码,方便使用,但是请注意,这可能会降低私钥的安全性,因为任何人都可以读取未加密的私钥。
3. 生成服务端证书

1
2
 openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost" -config openssl.cnf 
 openssl x509 -req -in server.csr -out server.crt -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -extensions v3_req -extfile openssl.cnf
  1. 生成客户端证书
1
2
3
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=localhost" -config openssl.cnf

openssl x509 -req -in client.csr -out client.crt -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 -extensions v3_req -extfile openssl.cnf

最终生成的结果如下

1
2
➜  keys git:(day1) ✗ ls 
ca.crt      ca.key      ca.srl      client.crt  client.csr  client.key  openssl.cnf server.crt  server.csr  server.key

测试

我们定义一个最简单的grpc接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// helloworld.proto  
syntax = "proto3";  
option go_package = "./api;api";  
package api;  
  
// The greeting service definition.  
service Greeter {  
  // Sends a greeting  
  rpc SayHello (HelloRequest) returns (HelloReply) {}  
}  
  
// The request message containing the user's name.  
message HelloRequest {  
  string name = 1;  
}  
  
// The response message containing the greetings  
message HelloReply {  
  string message = 1;  
}

服务端实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package main  
  
import (  
    "context"  
    "crypto/tls"    
    "crypto/x509"    
    "fmt"    
    "google.golang.org/genproto/googleapis/rpc/errdetails"    
    "google.golang.org/grpc"    
    "google.golang.org/grpc/codes"    
    "google.golang.org/grpc/credentials"    
    "google.golang.org/grpc/status"    
    "hello-world/api"    
    "io"    
    "log"    
    "net"    
    "os"    
    "time"
)  
  
type server struct {  
    api.UnimplementedGreeterServer  
}  
  
func (s *server) SayHello(ctx context.Context, in *api.HelloRequest) (*api.HelloReply, error) {  
    log.Printf("Received: %v", in.GetName())  
    select {  
    case <-ctx.Done():  
       log.Println("client timeout return")  
       return nil, ErrorWithDetails()  
    case <-time.After(3 * time.Second):  
       return &api.HelloReply{Message: "Hello " + in.GetName()}, nil  
    }  
}  

func main() {  
  
    certificate, err := tls.LoadX509KeyPair("./keys/server.crt", "./keys/server.key")  
    if err != nil {  
       log.Fatalf("Failed to load key pair: %v", err)  
    }  
    // 通过CA创建证书池  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read ca: %v", err)  
    }  
  
    // 将来自CA的客户端证书附加到证书池  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certificate")  
    }  
  
    opts := []grpc.ServerOption{  
       grpc.Creds( // 为所有传入的连接启用TLS  
          credentials.NewTLS(&tls.Config{  
             ClientAuth:   tls.RequireAndVerifyClientCert,  
             Certificates: []tls.Certificate{certificate},  
             ClientCAs:    certPool,  
          },  
          )),  
    }  
  
    listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 50051))  
    if err != nil {  
       log.Fatalf("failed to listen %d port", 50051)  
    }  
    // 通过传入的TLS服务器凭证创建新的gRPC服务实例  
    s := grpc.NewServer(opts...)  
    api.RegisterGreeterServer(s, &server{})  
    log.Printf("server listening at %v", listen.Addr())  
    if err := s.Serve(listen); err != nil {  
       log.Fatalf("Failed to serve: %v", err)  
    }  
  
}  
  
func ErrorWithDetails() error {  
    st := status.Newf(codes.Internal, fmt.Sprintf("something went wrong: %v", "api.Getter"))  
    v := &errdetails.PreconditionFailure_Violation{ //errDetails  
       Type:        "test",  
       Subject:     "12",  
       Description: "32",  
    }  
    br := &errdetails.PreconditionFailure{}  
    br.Violations = append(br.Violations, v)  
    st, _ = st.WithDetails(br)  
    return st.Err()  
}

我们直接运行服务端 go run main.go

客户端

首先我们使用一个不带证书的请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func Test_server_SayHello_No_Cert(t *testing.T) {  
    conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))  
    if err != nil {  
       log.Fatalf("Connect to %s failed", "localhost:50051")  
    }  
    defer conn.Close()  
  
    client := api.NewGreeterClient(conn)  
    // 创建带有超时时间的上下文, cancel可以取消上下文  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)  
    defer cancel()  
    // 业务代码处理部分 ...    r, err := client.SayHello(ctx, &api.HelloRequest{Name: "Hello"})  
    if err != nil {  
       log.Printf("Failed to greet, error: %v", err)  
    } else {  
       log.Printf("Greeting: %v", r.GetMessage())  
    }  
    // Set up a connection to the server.  
    log.Printf("Greeting: %s", r.GetMessage())  
}

输出

1
2024/05/12 19:18:51 Failed to greet, error: rpc error: code = Unavailable desc = connection error: desc = "error reading server preface: EOF"

服务不可用

我们再使用一个携带证书的请请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func Test_server_SayHello(t *testing.T) {  
    certificate, err := tls.LoadX509KeyPair("./keys/client.crt", "./keys/client.key")  
    if err != nil {  
       log.Fatalf("Failed to load client key pair, %v", err)  
    }  
  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read %s, error: %v", "./keys/ca.crt", err)  
    }  
  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certs")  
    }  
  
    opts := []grpc.DialOption{  
       grpc.WithTransportCredentials(credentials.NewTLS(  
          &tls.Config{  
             ServerName:   "localhost",  
             Certificates: []tls.Certificate{certificate},  
             RootCAs:      certPool,  
          })),  
    }  
  
    // conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))  
    conn, err := grpc.Dial("localhost:50051", opts...)  
    if err != nil {  
       log.Fatalf("Connect to %s failed", "localhost:50051")  
    }  
    defer conn.Close()  
  
    client := api.NewGreeterClient(conn)  
    // 创建带有超时时间的上下文, cancel可以取消上下文  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)  
    defer cancel()  
    // 业务代码处理部分 ...    r, err := client.SayHello(ctx, &api.HelloRequest{Name: "Hello"})  
    if err != nil {  
       log.Printf("Failed to greet, error: %v", err)  
    } else {  
       log.Printf("Greeting: %v", r.GetMessage())  
    }  
    // Set up a connection to the server.  
    log.Printf("Greeting: %s", r.GetMessage())  
}

输出

1
2
=== RUN   Test_server_SayHello
2024/05/12 19:20:17 Greeting: Hello Hello

总结

  1. 我们可以使用tls实现gRPC 的加密通信,
  2. 从go1.15 开始,go不建议使用CA而是使用SAN证书

设计稿生成代码,web开发的未来?

2024-06-26 18:21:43

网页设计和开发是一个不断进化的领域,设计师和前端开发者们经常面临一个共同的挑战:如何快速、高效地将概念化的设计草图转化为实际可用的 HTML 代码。这一过程不仅耗时而且容易出错,尤其是在将复杂的设计想法具体实现时。在初步设计阶段,往往需要频繁地修改和调整,如果每一次修改都需要手动编写代码,无疑会大大拖慢项目的进度,增加项目成本。

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

目前我们我们有两种办法解决这个问题,第一种是低代码模式,第二种是使用AI。我个人认为,Ai将会合并低代码模式,目前已经有很多商业公司在做这件事情了,今天的主角是一个在GitHub上有一个超过13k star 的项目⁠draw-a-ui 它是这样描述自己的

Draw a mockup and generate html for it

draw-a-ui

它是基于 tldraw 和 gpt-4-vision api,旨在通过用户绘制的线框图自动生成 HTML 代码。用户只需绘制一个模拟界面的草图,draw-a-ui 就能将其转换为配备 Tailwind CSS 的 HTML 文件,极大缩短从设计到开发的时间。项目目前尚处于开发阶段,但核心功能——将绘图画布的 SVG 转换为 PNG,再将该 PNG 传送给 gpt-4-vision 以指令形式返回单个 HTML 文件——已经完善。
使用这个项目很简单, 克隆下来,然后配置一下你的openai key就好

1
2
3
echo "OPENAI_API_KEY=sk-your-key" > .env.local
npm install
npm run dev

然后你就能 通过 http://localhost:3000 访问了。
demo
https://github.com/SawyerHood/draw-a-ui/blob/main/demo.gif

它支持在线设计,也支持上传png 图像, 比如我将 medium 主页截图,然后上传上去。等待20s 左右(录屏有加速),我们就能得到结果。
draw-ui-medium 1

尽管目前 draw-a-ui 项目标注为演示用途,并不建议在生产环境中直接使用,目前看来生成的但其背后的理念和技术实现无疑展现了未来开发的趋势。该项目利用最新的人工智能技术(如 GPT-4),为前端开发带来了革命性的工作模式改变。

我真的很期待这个技术,毕竟我讨厌写css。🐶

分布式基石算法1: 一致性hash

2024-06-19 21:22:15

Featured image of post 分布式基石算法1:  一致性hash

什么是 一致性hash 算法

首先摘抄一段维基百科的定义

 一致哈希 是一种特殊的哈希算法。在使用一致哈希算法后,哈希表槽位数(大小)的改变平均只需要对𝐾/𝑛 个关键字重新映射,其中 𝐾 是关键字的数量,𝑛是槽位数量。然而在传统的哈希表中,添加或删除一个槽位的几乎需要对所有关键字进行重新映射。 — wikipedia

分布式系统中, 一致性hash无处不在,CDN,KV,负载均衡等地方都有它的影子,是分布式系统的基石算法之一。一致性hash 有以下几个优点。

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

一致性hash算法的原理

基本一致性hash 算法

最基础的一致性 hash 算法就是把节点直接分布到环上,从而划分出值域, key 经过 hash( x ) 之后,落到不同的值域,则由对应的节点处理。最常见的值域空间大小是:2^32 - 1,节点落到这个空间,来划分不同节点所属的值域。如图所示。

Pasted image 20240620095057
Node A 存储 的 hash 范围为 [0,2^12) .
Node B 存储 的 hash 范围为 [2^12,2^28) .
Node C 存储 的 hash 范围为 [2^28,0) .
上述基本的一致性哈希算法有明显的缺点:

  1. 随机分布节点的方式使得很难均匀的分布哈希值域,从上面可以看出,三个节点存储的数据不均匀。
  2. 在动态增加节点后,原先的分布就算均匀也很难再继续保证均匀;
  3. 增删节点带来的一个较为严重的缺点是:
    1. 当一个节点异常时,该节点的压力全部转移到相邻的一个节点;
    2. 当一个新节点加入时只能为一个相邻节点分摊压力;

虚拟节点

Go语言之父 rob pike 曾今说过 计算机领域里,没有什么问题是加一层间接寻址解决不了的. 一致性hash 也是一样。
如果三个节点 存在不均衡的问题,那么我们就把他虚拟成N个节点。A[a1,a2….a1024], 然后将他们映射到hash_ring 上,就是这样样子的。
Pasted image 20240620100713
每个虚拟节点都有对应的hash区间。负责一段key,然后根据虚拟node 的名字找到对应的物理node读写数据。
引入虚拟节点后,就完美的解决了上面的三个问题。

  1. 只要我们的虚拟节点足够多,各个节点的数据就能平衡,(⚠️:这个再工程上是有代价的)
  2. 如果一个节点宕机了,它的数据会均衡的分布到整个集群所有节点,同理,新增的节点 也能负担所有节点的压力。

Pasted image 20240620101803

go语言实现

完整的代码:https://github.com/hxzhouh/blog-example/tree/main/Distributed/consistent_hashing
首先定义一个hash_ring, 使用 crc32.ChecksumIEEE 作为默认的hash function

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type VirtualNode struct {   // 虚拟节点
    Hash uint32  
    Node *Node  
}
type Node struct {   // 物理节点
    ID   string  
    Addr string  
}
type HashRing struct {
	Nodes        map[string]*Node
	VirtualNodes []VirtualNode. 
	mu           sync.Mutex
	hash         HashFunc
}
func NewHashRing(hash HashFunc) *HashRing {  
    if hash == nil {  
       hash = crc32.ChecksumIEEE  
    }  
    return &HashRing{  
       Nodes:        make(map[string]*Node),  
       VirtualNodes: make([]VirtualNode, 0),  
       hash:         hash,  
    }  
}

我们来看一下怎么添加节点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (hr *HashRing) AddNode(node *Node) {  
    hr.mu.Lock()  
    defer hr.mu.Unlock()  
  
    hr.Nodes[node.ID] = node  
    for i := 0; i < VirtualNodesPerNode; i++ {  
       virtualNodeID := fmt.Sprintf("%s-%d", node.ID, i)  
       hash := hr.hash([]byte(virtualNodeID))  
       hr.VirtualNodes = append(hr.VirtualNodes, VirtualNode{Hash: hash, Node: node})  
    }  
    sort.Slice(hr.VirtualNodes, func(i, j int) bool {  
       return hr.VirtualNodes[i].Hash < hr.VirtualNodes[j].Hash  
    })  
}

我们每添加一个节点,就要创建对应数量的虚拟节点,并且要保证虚拟节点有序(这样才能查找)
同样,remove 的时候,也需要删除虚拟节点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (hr *HashRing) RemoveNode(nodeID string) {  
    hr.mu.Lock()  
    defer hr.mu.Unlock()  
  
    delete(hr.Nodes, nodeID)  
    virtualNodes := make([]VirtualNode, 0)  
    for _, vn := range hr.VirtualNodes {  
       if vn.Node.ID != nodeID {  
          virtualNodes = append(virtualNodes, vn)  
       }  
    }  
    hr.VirtualNodes = virtualNodes  
}

查询的时候,我们先找到对应的虚拟节点,然后再根据虚拟节点找到对应的物理节点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (hr *HashRing) GetNode(key string) *Node {  
    hr.mu.Lock()  
    defer hr.mu.Unlock()  
  
    if len(hr.VirtualNodes) == 0 {  
       return nil  
    }  
  
    hash := hr.hash([]byte(key))  
    idx := sort.Search(len(hr.VirtualNodes), func(i int) bool {  
       return hr.VirtualNodes[i].Hash >= hash  
    })  
    if idx == len(hr.VirtualNodes) {  
       idx = 0  
    }  
  
    return hr.VirtualNodes[idx].Node  
}

最后我们来看看,业务如何使用 这个hash_ring

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
type KVSystem struct {  
    hashRing *HashRing  
    kvStores map[string]*kvstorage.KVStore  
}  
  
func NewKVSystem(nodes int) *KVSystem {  
    hashRing := NewHashRing(crc32.ChecksumIEEE)  
    for i := 0; i < nodes; i++ {  // init node
       node := &Node{  
          ID:   fmt.Sprintf("Node%d", i),  
          Addr: fmt.Sprintf("192.168.1.%d", i+1),  
       }  
       hashRing.AddNode(node)  
    }  
    kvStores := make(map[string]*kvstorage.KVStore)   //init storage
    for id := range hashRing.Nodes {  
       kvStores[id] = kvstorage.NewKVStore()  
    }  
    return &KVSystem{  
       hashRing: hashRing,  
       kvStores: kvStores,  
    }  
}  
  
func (kv *KVSystem) Get(key string) (string, bool) {   //get value
    node := kv.hashRing.GetNode(key)  
    return kv.kvStores[node.ID].Get(key)  
}  
  
func (kv *KVSystem) Set(key string, value string) {  // set value 
    node := kv.hashRing.GetNode(key)  
    kv.kvStores[node.ID].Set(key, value)  
}  
  
func (kv *KVSystem) Delete(key string) {  
    node := kv.hashRing.GetNode(key)  
    kv.kvStores[node.ID].Delete(key)  
}  
// DeleteNode 需要将存储在节点上的数据重新分配。
func (kv *KVSystem) DeleteNode(nodeID string) {   
    allData := kv.kvStores[nodeID].GetAll()  
    kv.hashRing.RemoveNode(nodeID)  
    delete(kv.kvStores, nodeID)  
    for key, value := range allData {  
       kv.Set(key, value)  
    }  
}  
  
func (kv *KVSystem) AddNode() {  
    node := &Node{  
       ID:   fmt.Sprintf("Node%d", len(kv.hashRing.Nodes)),  
       Addr: fmt.Sprintf("192.168.1.%d", len(kv.hashRing.Nodes)+1),  
    }  
    kv.hashRing.AddNode(node)  
    kv.kvStores[node.ID] = kvstorage.NewKVStore()  
}

这样我们就实现了一个最简单的基于一致性hash的kv 存储,是不是特别简单? 但是它却支撑了我们整个网络世界的运转。

参考资料

Consistent_hashing

解密go: empty struct

2024-06-17 23:18:02

Featured image of post 解密go: empty struct

在 go语言中,正常的 struct 一定是需要占用一块内存的,但是有一种特殊情况,如果是一个空struct,那么它的大小为0. 这是怎么回事,空struct 又有什么用呢?

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  
type Test struct {  
    A int  
    B string  
}    
func main() {  
    fmt.Println(unsafe.Sizeof(new(Test)))  
    fmt.Println(unsafe.Sizeof(struct{}{}))  
}  
/*  
8  
0  
*/

Empty Struct 的 秘密

特殊变量:zerobase

空结构体是没有内存大小的结构体。这句话是没有错的,但是更准确的来说,其实是有一个特殊起点的,那就是 zerobase 变量,这是一个 uintptr 全局变量,占用 8 个字节。当在任何地方定义无数个 struct {} 类型的变量,编译器都只是把这个 zerobase 变量的地址给出去。换句话说,在 golang 里面,涉及到所有内存 size 为 0 的内存分配,那么就是用的同一个地址 &zerobase 。
例如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

type emptyStruct struct {}

func main() {
	a := struct{}{}
	b := struct{}{}
	c := emptyStruct{}

	fmt.Printf("%p\n", &a)
	fmt.Printf("%p\n", &b)
	fmt.Printf("%p\n", &c)
}

// 0x58e360
// 0x58e360
// 0x58e360

空结构体的变量的内存地址都是一样的。这是因为编译器在编译期间,遇到 struct {} 这种特殊类型的内存分配,会给他分配&zerobase,这个代码逻辑是在 mallocgc 函数里面:

1
2
3
4
5
6
7
//go:linkname mallocgc  
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {  
    ....
    if size == 0 {  
       return unsafe.Pointer(&zerobase)  
    }
    .....

这就是Empty struct 的秘密有了这个特殊的 变量,我们利用它可以完成很多功能。

Empty struct 与内存对其
一般情况下,struct 中包含 empty struct ,这个字段是不占用内存空间的,但是有一种情况是特殊的,那就是 empty struct 位于最后一位,它会触发内存对齐 。
比如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type A struct {
	x int
	y string
	z struct{}
}
type B struct {
	x int
	z struct{}
	y string
}

func main() {
	println(unsafe.Alignof(A{}))
	println(unsafe.Alignof(B{}))
	println(unsafe.Sizeof(A{}))
	println(unsafe.Sizeof(B{}))
}

/**
8
8
32
24
**/

因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。
因此,当 struct{} 作为其他 struct 最后一个字段时,需要填充额外的内存保证安全,如果 empty struct 在开始位置,或者中间位置,那么它的地址是下一个变量的地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type A struct {  
    x int  
    y string  
    z struct{}  
}  
type B struct {  
    x int  
    z struct{}  
    y string  
}  
  
func main() {  
    a := A{}  
    b := B{}  
    fmt.Printf("%p\n", &a.y)  
    fmt.Printf("%p\n", &a.z)  
    fmt.Printf("%p\n", &b.y)  
    fmt.Printf("%p\n", &b.z)  
}
/**
0x1400012c008
0x1400012c018
0x1400012e008
0x1400012e008
**/

Empty 的使用场景

空结构体 struct{ } 为什么会存在的核心理由就是为了节省内存。当你需要一个结构体,但是却丝毫不关系里面的内容,那么就可以考虑空结构体。golang 核心的几个复合结构 map ,chan ,slice 都能结合 struct{} 使用。

map & struct{}

1
2
3
4
5
6
// 创建 map
m := make(map[int]struct{})
// 赋值
m[1] = struct{}{}
// 判断 key 键存不存在
_, ok := m[1]

chan & struct{}

channel 和 struct{} 结合是一个最经典的场景,struct{} 通常作为一个信号来传输,并不关注其中内容。chan 的分析在前几篇文章有详细说明。chan 本质的数据结构是一个管理结构加上一个 ringbuffer ,如果 struct{} 作为元素的话,ringbuffer 就是 0 分配的。

chan 和 struct{} 结合基本只有一种用法,就是信号传递,空结构体本身携带不了值,所以也只有这一种用法啦,一般来说,配合 no buffer 的 channel 使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 创建一个信号通道
waitc := make(chan struct{})

// ...
goroutine 1:
    // 发送信号: 投递元素
    waitc <- struct{}
    // 发送信号: 关闭
    close(waitc)

goroutine 2:
    select {
    // 收到信号,做出对应的动作
    case <-waitc:
    }    

这种场景我们思考下,是否一定是非 struct{} 不可?其实不是,而且也不多这几个字节的内存,所以这种情况真的就只是不关心 chan 的元素值而已,所以才用的 struct{}

总结

  1. 空结构体也是结构体,只是 size 为 0 的类型而已;
  2. 所有的空结构体都有一个共同的地址:zerobase 的地址;
  3. 我们可以利用empty struct 不占用内存的特性,来优化代码,比如利用map 实现set 以及 chan 等。

参考链接

  1. The empty struct, Dave Cheney
  2. Go 最细节篇— struct{} 空结构体究竟是啥?

QUIC 如何在速度和安全性方面取代 TCP?

2024-06-14 09:38:42

Featured image of post QUIC 如何在速度和安全性方面取代 TCP?

原文链接how-quic-is-displacing-tcp-for-speed

引言

在过去的三十年中,HTTP(超文本传输协议)一直是互联网的支柱。我们能够浏览网页、下载文件、流式传输电影等,都是因为HTTP。这个协议多年来不断发展,见证了重大的改进。

HTTP协议是一个应用层协议,工作在TCP(传输控制协议)之上。TCP协议有一些限制,导致网络应用程序响应性较差。

谷歌开发了一种改变游戏规则的传输协议QUIC,以克服TCP的缺点。QUIC几年前被标准化并加入到IETF(互联网工程任务组)。

在过去几年中,QUIC的采用呈指数级增长。大多数科技公司,如谷歌、Facebook、Pinterest等,已经开始采用使用QUIC作为传输层的HTTP/3.0。这些公司在使用HTTP/3.0和QUIC后,其网站性能有了显著提升。

让我们开始我们的旅程,了解QUIC如何取代TCP。我们首先将了解一些基本的TCP和UDP网络概念。之后,我们将看看HTTP的演变,以及每个版本是如何克服前一个版本的限制的。然后,我们将了解QUIC是什么以及它的工作原理。我们将探讨为什么QUIC的性能比TCP高。

TCP和UDP是如何工作的?

TCP(传输控制协议)和UDP(用户数据报协议)是传输层协议。这些协议管理互联网数据包流向和来自任何电子设备的过程。让我们详细了解这两个协议是如何工作的。

TCP

TCP是一种基于连接的协议。客户端与服务器建立连接,然后发送数据。TCP连接是通过一种称为三次握手的机制建立的。下图展示了三次握手过程:

TCP 三次握手过程

这个过程包括三个步骤:

  1. SYN - 客户端向服务器发送一个SYN数据包。

  2. ACK - 服务器接收到SYN后,通过ACK数据包向客户端发送确认。

  3. SYN-ACK - 客户端收到服务器的ACK数据包后,最终通过SYN-ACK向服务器发送确认。

TCP是一个有状态和可靠的协议。它保证从一台设备到另一台设备的所有数据包的传输。此外,它允许客户端和服务器使用相同的连接进行通信。

UDP

UDP是一种无连接协议。与TCP不同,客户端和服务器之间没有三次握手。客户端向服务器发送数据包,不等待服务器的确认。

UDP不能保证100%的数据包传输。数据包可能会丢失,可能无法到达另一台设备。UDP不像TCP那样可靠。

由于没有初始握手,UDP比TCP快得多。出于性能原因,UDP主要用于流式数据应用程序,如音乐/视频。

这是一个流行的互联网梗,对TCP/UDP进行了调侃:

TCP VS UDP 梗

到目前为止,我们已经了解了TCP和UDP协议是如何工作的。现在让我们探索HTTP协议,这是一个应用层协议。

HTTP的演变

Tim Berners-LeeCERN开发的HTTP的第一个版本是在1989年。从那时起,该协议经历了多次优化和性能改进。大多数现代设备使用HTTP 1.1/ HTTP 2.0HTTP 3.0。让我们回顾一下HTTP的历史,了解协议经历的重大变化。

HTTP/1.0

在最初的HTTP/0.9版本之后,HTTP/1.0开始支持头、请求体、文本文件等。客户端每次使用HTTP从服务器获取数据时,都必须创建一个TCP连接。这导致在建立连接时显著浪费资源。

HTTP/1.1

这个协议增加了对重用客户端和服务器之间现有TCP连接以获取新数据的支持。这是通过HTTP头keep-alive实现的。

如果客户端想要获取10个JavaScript文件,那么它将与服务器建立一个连接。然后,它将重用相同的连接来获取这10个文件,而不是为每个文件创建一个新连接。

这导致资源浪费减少和性能提升,因为它避免了创建多余的连接。然而,一个主要的缺点是众所周知的_队头阻塞_问题。

下图展示了_队头阻塞_问题。

队头阻塞

让我们通过一个例子来理解这个概念。如上图所示,你有3个文件 - 图像、文本和视频。视频文件体积较大,传输时间会更长。由于视频文件传输时间较长,它会阻塞图像和文本文件的发送。

HTTP/2.0

HTTP 2.0通过多路复用解决了_队头阻塞_问题。通过多路复用,多个文件可以通过同一个TCP连接发送。

HTTP/2.0中的多路复用

这导致了性能提升,并解决了应用层面的队头阻塞问题。然而,在TCP层面,如果发生数据包丢失,它必须等待数据包重传。

多路复用解决方案在数据包丢失的情况下并不像预期的那样有效。实际上,如果数据包丢失超过5%,HTTP 1.1的性能比HTTP 2.0更好。_队头阻塞_问题从应用层转移到了传输层。

下图展示了单个数据包丢失如何导致多个流延迟:

HTTP/2.0中的数据包丢失导致重传和流延迟

当一个数据包丢失时,TCP将其后续数据包存储在其缓冲区中,直到收到丢失的数据包。然后TCP使用重传来获取丢失的数据包。HTTP无法看到TCP重传。因此,在这种情况下,不同的流会看到传输延迟。

什么是QUIC?

在过去的几个部分中,我们看到了TCP有一些固有的限制,如三次握手和队头阻塞。这些限制可以通过增强TCP或用新协议替换TCP来解决。

尽管增强TCP很简单,但TCP存在于最低层,与操作系统紧密耦合。简单来说,TCP的代码存在于内核层而不是用户空间。考虑到大量的设备,实施内核空间的更改将需要大量的时间才能到达所有用户。

因此,谷歌提出了一种新的协议QUIC,作为TCP的替代品。像TCP一样,QUIC也是一个传输层协议。然而,它位于用户空间而不是内核空间。这使得它容易更改和增强,与TCP不同。

QUIC在UDP之上工作。它通过使用UDP克服了TCP的限制。它只是一个在UDP之上的层或包装器。该包装器添加了TCP的功能,如拥塞控制、数据包重传、多路复用等。它内部使用UDP,并在其上添加了TCP的最佳功能。

下图显示了QUIC如何适应网络栈:

带有QUIC的网络栈

现在我们已经了解了QUIC的基础知识,让我们深入了解这个协议的工作原理。

QUIC是如何工作的?

QUIC握手

QUIC在UDP上工作,它不需要经过三次握手过程。三次握手过程增加了额外的开销,增加了延迟。因此,QUIC通过减少连接延迟来提高性能。

在TCP的情况下,还有一个额外的用于TLS的握手,这也增加了延迟。QUIC将TLS握手和QUIC握手合并为一个调用。它优化了握手过程并提高了性能。

可靠性

您可能会想“既然QUIC在UDP上工作,数据包会丢失吗?”。答案是不。QUIC在UDP堆栈上添加了可靠性。它实现了数据包重传,以防它没有收到必要的数据包。例如:如果服务器没有收到来自客户端的第5个数据包,协议将检测到它并要求客户端重新发送相同的数据包。

多路复用

与TCP类似,QUIC也实现了多路复用。客户端可以使用单个通道同时传输多个文件。QUIC为每个流(传输的文件)创建一个UUID。它使用UUID来识别流。然后,多个流通过单个通道发送。

下图展示了QUIC中多路复用是如何工作的:

QUIC中的多路复用

QUIC还通过其多路复用解决了TCP面临的队头阻塞问题。如果一个流遭受数据包丢失,只有该流会受到影响。QUIC中的流是独立的,不会影响彼此的工作。

安全性

此外,QUIC 还支持 TLS 1.3(传输层安全性)。这保证了数据的安全性和保密性。TLS 加密了 QUIC 协议的大部分内容,例如数据包编号和连接关闭信号。

为什么选择QUIC?

HTTP/3和QUIC

HTTP/3是超文本传输协议(HTTP)的最新版本。它内部使用QUIC而不是TCP。它旨在为现代网络提供更有效和安全的基础。它拥有QUIC提供的所有优势。

HTTP/3由IETF标准化。今天,很大一部分互联网流量依赖于HTTP/3。以下是显示HTTP/3采用率的图表:

HTTP/3.0 采用率
从上述图表中可以看出,采用率已经飙升至30%,并逐渐超越了HTTP/1.1。按照目前的发展速度,HTTP/3.0将在未来几年逐渐超越HTTP/2.0

结论

自三十年前HTTP诞生以来,互联网已经走过了漫长的道路。HTTP的演变使在线体验更加高效和响应迅速。随着现代应用程序需求的增长,我们意识到了底层协议如TCP的固有限制。

谷歌开发了改变游戏规则的协议QUIC。它利用UDP并解决了TCP的所有不足。降低延迟、多路复用、增强安全性和连接迁移是QUIC的一些显著特点。QUIC带来的创新解决了队头阻塞等问题。

像谷歌和Facebook这样的大型科技公司通过在HTTP/3中采用QUIC,在性能上取得了显著提升。随着采用率的上升和日益增长的支持,HTTP/3将成为互联网通信的标准。在未来几年,互联网将发展并过渡到HTTP/3,以实现效率、可靠性和性能。

参考文献

  1. TCP VS UDP 梗
  2. 为什么HTTP/3.0正在吞噬世界?
  3. Pinterest现在使用HTTP/3.0
  4. 与谷歌对等 - QUIC

深入 Go 中各个高性能 JSON 解析库

2024-06-13 17:10:25

Compare the performance, advantages and disadvantages of fastjson, gjson, and jsonparser.

对比 fastjsongjsonjsonparser 的性能以及优缺点。

这篇文章深入源码分析一下在 Go 中标准库是如何解析 JSON 的,然后再看看有哪些比较流行的 Json 解析库,以及这些库都有什么特点,在什么场景下能更好的帮助我们进行开发。

This article is first published in the medium MPP plan. If you are a medium user, please follow me on the medium. Thank you very much.

其实本来我是没打算去看 JSON 库的性能问题的,但是最近我对我的项目做了一次 pprof,从下面的火焰图中可以发现在业务逻辑处理中,有一半多的性能消耗都是在 JSON 解析过程中,所以就有了这篇文章。

image-20210519160937326

这篇文章深入源码分析一下在 Go 中标准库是如何解析 JSON 的,然后再看看有哪些比较流行的 Json 解析库,以及这些库都有什么特点,在什么场景下能更好的帮助我们进行开发。

主要介绍分析以下几个库(2024-06-13):

库名 Star
标准库 JSON Unmarshal
valyala/fastjson 2.2 k
tidwall/gjson 13.8 k
buger/jsonparser 5.4 k

标准库 JSON Unmarshal

1
func Unmarshal(data []byte, v interface{})

官方的 JSON 解析库需要传两个参数,一个是需要被序列化的对象,另一个是表示这个对象的类型。

在真正执行 JSON 解析之前会调用 reflect.ValueOf来获取参数 v 的反射对象。然后会获取到传入的 data 对象的开头非空字符来界定该用哪种方式来进行解析。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (d *decodeState) value(v reflect.Value) error {
    switch d.opcode {
    default:
        panic(phasePanicMsg)
    // 数组 
    case scanBeginArray:
        ...
    // 结构体或map
    case scanBeginObject:
        ...
    // 字面量,包括 int、string、float 等
    case scanBeginLiteral:
        ...
    }
    return nil
}

如果被解析的对象是以[开头,那么表示这是个数组对象会进入到 scanBeginArray 分支;如果是以{开头,表明被解析的对象是一个结构体或 map,那么进入到 scanBeginObject 分支 等等。

小结

通过看 Unmarshal 源码中可以看到其中使用了大量的反射来获取字段值,如果是多层嵌套的 JSON 的话,那么还需要递归进行反射获取值,可想而知性能是非常差的了。

但是如果对性能不是那么看重的话,直接使用它其实是一个非常好的选择,功能完善的同时并且官方也一直在迭代优化,说不定在以后的版本中性能也会得到质的飞跃。并且他应该是唯一一个可以直接把JSON对象转成 go struct 的。

fastjson

这个库的特点和它的名字一样就是快,它的介绍页是这么说的:

Fast. As usual, up to 15x faster than the standard encoding/json.

它的使用也是非常的简单,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
    var p fastjson.Parser
    v, _ := p.Parse(`{
                "str": "bar",
                "int": 123,
                "float": 1.23,
                "bool": true,
                "arr": [1, "foo", {}]
        }`)
    fmt.Printf("foo=%s\n", v.GetStringBytes("str"))
    fmt.Printf("int=%d\n", v.GetInt("int"))
    fmt.Printf("float=%f\n", v.GetFloat64("float"))
    fmt.Printf("bool=%v\n", v.GetBool("bool"))
    fmt.Printf("arr.1=%s\n", v.GetStringBytes("arr", "1"))
}
// Output:
// foo=bar
// int=123
// float=1.230000
// bool=true
// arr.1=foo

使用 fastjson 首先要将被解析的 JSON 串交给 Parser 解析器进行解析,然后通过 Parse 方法返回的对象来获取。如果是嵌套对象可以直接在 Get 方法传参的时候传入相应的父子 key 即可。

分析

fastjson 在设计上和标准库 Unmarshal 不同的是,它将 JSON 解析划分为两部分:Parse、Get。

Parse 负责将 JSON 串解析成为一个结构体并返回,然后通过返回的结构体来获取数据。在 Parse 解析的过程是无锁的,所以如果想要在并发地调用 Parse 进行解析需要使用 ParserPool

fastjson 是从上往下依次遍历 JSON ,然后解析好的数据存放在 Value 结构体中:

1
type Value struct { o Object a []*Value s string t Type }

这个结构体非常简成:

1
type Object struct { kvs []kv keysUnescaped bool } type kv struct { k string v *Value }

这个结构存放对象的递归结构。如果把上面例子中的 JSON 串解析完毕之后就是这样一个结构:

fastjson

代码

在代码实现上,由于没有了反射部分的代码,所以整个解析过程变得非常的清爽。我们直接看看主干部分的解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func parseValue(s string, c *cache, depth int) (*Value, string, error) {
    if len(s) == 0 {
        return nil, s, fmt.Errorf("cannot parse empty string")
    }
    depth++
    // 最大深度的json串不能超过MaxDepth
    if depth > MaxDepth {
        return nil, s, fmt.Errorf("too big depth for the nested JSON; it exceeds %d", MaxDepth)
    }
    // 解析对象
    if s[0] == '{' {
        v, tail, err := parseObject(s[1:], c, depth)
        if err != nil {
            return nil, tail, fmt.Errorf("cannot parse object: %s", err)
        }
        return v, tail, nil
    }
    // 解析数组
    if s[0] == '[' {
        ...
    }
    // 解析字符串
    if s[0] == '"' {
        ...
    } 
    ...
    return v, tail, nil
}

parseValue 会根据字符串的第一个非空字符来判断要解析的类型。这里用一个对象类型来做解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func parseObject(s string, c *cache, depth int) (*Value, string, error) {
    ...
    o := c.getValue()
    o.t = TypeObject
    o.o.reset()
    for {
        var err error
        // 获取Ojbect结构体中的 kv 对象
        kv := o.o.getKV()
        ... 
        // 解析 key 值

        kv.k, s, err = parseRawKey(s[1:])
        ... 
        // 递归解析 value 值
        kv.v, s, err = parseValue(s, c, depth)
        ...
        // 遇到 ,号继续往下解析
        if s[0] == ',' {
            s = s[1:]
            continue
        }
        // 解析完毕
        if s[0] == '}' {
            return o, s[1:], nil
        }
        return nil, s, fmt.Errorf("missing ',' after object value")
    }
}

parseObject 函数也非常简单,在循环体中会获取 key 值,然后调用 parseValue 递归解析 value 值,从上往下依次解析 JSON 对象,直到最后遇到 }退出。

小结

通过上面的分析可以知道 fastjson 在实现上比标准库简单不少,性能也高上不少。使用 Parse 解析好 JSON 树之后可以多次反复使用,避免了需要反复解析进而提升性能。

但是它的功能是非常的简陋的,没有常用的如 JSON 转 Struct 或 JSON 转 map 的操作。如果只是想简单的获取 JSON 中的值,那么使用这个库是非常方便的,但是如果想要把 JSON 值转化成一个结构体就需要自己动手一个个设值了。

GJSON

GJSON 在我的测试中,虽然性能是没有 fastjson 这么极致,但是功能是非常完善,性能也是相当 OK 的,下面先简单介绍一下 GJSON 的功能。

GJSON 的使用是和 fastjson 差不多的,也是非常的简单,只要在参数中传入 json 串以及需要获取的值即可:

1
2
json := `{"name":{"first":"li","last":"dj"},"age":18}`
lastName := gjson.Get(json, "name.last")

除了这个功能以外还可以进行简单的模糊匹配,支持在键中包含通配符*?*匹配任意多个字符,?匹配单个字符,如下:

1
2
3
4
5
6
7
json := `{
    "name":{"first":"Tom", "last": "Anderson"},
    "age": 37,
    "children": ["Sara", "Alex", "Jack"]
}`
fmt.Println("third child*:", gjson.Get(json, "child*.2"))
fmt.Println("first c?ild:", gjson.Get(json, "c?ildren.0"))

除了模糊匹配以外还支持修饰符操作:

1
2
3
4
5
6
json := `{
    "name":{"first":"Tom", "last": "Anderson"},
    "age": 37,
    "children": ["Sara", "Alex", "Jack"]
}`
fmt.Println("third child*:", gjson.Get(json, "children|@reverse"))

children|@reverse 先读取数组children,然后使用修饰符@reverse翻转之后返回,输出。

1
nestedJSON := `{"nested": ["one", "two", ["three", "four"]]}` fmt.Println(gjson.Get(nestedJSON, "nested|@flatten"))

@flatten将数组nested的内层数组平坦到外层后返回:

1
["one","two","three", "four"]

等等还有一些其他有意思的功能,大家可以去查阅一下官方文档。

分析

GJSON 的 Get 方法参数是由两部分组成,一个是 JSON 串,另一个叫做 Path 表示需要获取的 JSON 值的匹配路径。
在 GJSON 中因为要满足很多的定义的解析场景,所以解析是分为两部分的,需要先解析好 Path 之后才去遍历解析 JSON 串。

在解析过程中如果遇到可以匹配上的值,那么会直接返回,不需要继续往下遍历,如果是匹配多个值,那么会一直遍历完整个 JSON 串。如果遇到某个 Path 在 JSON 串中匹配不到,那么也是需要遍历完整个 JSON 串。

在解析的过程中也不会像 fastjson 一样将解析的内容保存在一个结构体中,可以反复的利用。所以当调用 GetMany 想要返回多个值的时候,其实也是需要遍历 JSON 串多次,因此效率会比较低。

Pasted image 20240613165903

除此之外,在解析 JSON 的时候并不会对它进行校验,即使这个放入的字符串不是个 JSON 也会照样解析,所以需要用户自己去确保放入的是 JSON 。

代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func Get(json, path string) Result {
    // 解析 path 
    if len(path) > 1 {
        ...
    }
    var i int
    var c = &parseContext{json: json}
    if len(path) >= 2 && path[0] == '.' && path[1] == '.' {
        c.lines = true
        parseArray(c, 0, path[2:])
    } else {
        // 根据不同的对象进行解析,这里会一直循环,直到找到 '{' 或 '['
        for ; i < len(c.json); i++ {
            if c.json[i] == '{' {
                i++

                parseObject(c, i, path)
                break
            }
            if c.json[i] == '[' {
                i++
                parseArray(c, i, path)
                break
            }
        }
    }
    if c.piped {
        res := c.value.Get(c.pipe)
        res.Index = 0
        return res
    }
    fillIndex(json, c)
    return c.value
}

Get 方法里面可以看到有很长一串的代码是用来解析各种 Path,然后一个 for 循环一直遍历 JSON 直到找到 ‘{‘ 或 ‘[‘,然后才进行相应的逻辑进行处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
func parseObject(c *parseContext, i int, path string) (int, bool) {
    var pmatch, kesc, vesc, ok, hit bool
    var key, val string
    rp := parseObjectPath(path)
    if !rp.more && rp.piped {
        c.pipe = rp.pipe
        c.piped = true
    }
    // 嵌套两个 for 循环 寻找 key 值
    for i < len(c.json) {
        for ; i < len(c.json); i++ {
            if c.json[i] == '"' { 
                i++
                var s = i
                for ; i < len(c.json); i++ {
                    if c.json[i] > '\\' {
                        continue
                    }
                    // 找到 key 值跳转到 parse_key_string_done
                    if c.json[i] == '"' {
                        i, key, kesc, ok = i+1, c.json[s:i], false, true
                        goto parse_key_string_done
                    }
                    ...
                }
                key, kesc, ok = c.json[s:], false, false
            // 直接break
            parse_key_string_done:
                break
            }
            if c.json[i] == '}' {
                return i + 1, false
            }
        }
        if !ok {
            return i, false
        }
        // 校验是否是模糊匹配
        if rp.wild {
            if kesc {
                pmatch = match.Match(unescape(key), rp.part)
            } else {
                pmatch = match.Match(key, rp.part)
            }
        } else {
            if kesc {
                pmatch = rp.part == unescape(key)
            } else {
                pmatch = rp.part == key
            }
        }
        // 解析 value
        hit = pmatch && !rp.more
        for ; i < len(c.json); i++ {
            switch c.json[i] {
            default:
                continue
            case '"':
                i++
                i, val, vesc, ok = parseString(c.json, i)
                if !ok {
                    return i, false
                }
                if hit {
                    if vesc {
                        c.value.Str = unescape(val[1 : len(val)-1])
                    } else {
                        c.value.Str = val[1 : len(val)-1]
                    }
                    c.value.Raw = val
                    c.value.Type = String
                    return i, true
                }
            case '{':
                if pmatch && !hit {
                    i, hit = parseObject(c, i+1, rp.path)
                    if hit {
                        return i, true
                    }
                } else {
                    i, val = parseSquash(c.json, i)
                    if hit {
                        c.value.Raw = val
                        c.value.Type = JSON
                        return i, true
                    }
                }
            ...
            break
        }
    }
    return i, false
}

在上面看 parseObject 这段代码的时候其实不是想让大家学习如何解析 JSON,以及遍历字符串,而是想要让大家看看一个 bad case 是怎样的。for 循环一层套一层,if 一个接以一个看得我 San 值狂掉,这片代码大家是不是看起来很眼熟?是不是有点像工作中遇到的某个同事写的代码?

小结

优点:

  1. 性能相对标准库来说还算不错;
  2. 可玩性高,可以各种检索、自定义返回值,这点非常方便;
    缺点:
  3. 不会校验 JSON 的正确性;
  4. 代码的 Code smell 很重。

需要注意的是,如果需要解析返回 JSON 的值的话,GetMany 函数会根据指定的 key 值来一次次遍历 JSON 字符串,解析为 map 可以减少遍历次数。

jsonparser

这也是一个比较热门,并且号称高性能,能比标准库快十倍的解析速度。

分析

jsonparser 也是传入一个 JSON 的 byte 切片,以及可以通过传入多个 key 值来快速定位到相应的值,并返回。

和 GJSON 一样,在解析过程中是不会像 fastjson 一样有个数据结构缓存已解析过的 JSON字符串,但是遇到需要解析多个值的情况可以使用 EachKey 函数来解析多个值,只需要遍历一次 JSON字符串即可实现获取多个值的操作。

如果遇到可以匹配上的值,那么会直接返回,不需要继续往下遍历,如果是匹配多个值,那么会一直遍历完整个 JSON 串。如果遇到某个 Path 在 JSON 串中匹配不到,那么也是需要遍历完整个 JSON 串。

并且在遍历 JSON 串的时候通过循环的方式来减少递归的使用,减少了调用栈的深度,一定程度上也是可以提升性能。

在功能性上 ArrayEach、ObjectEach、EachKey 等三个函数都可以传入一个自定义的函数,通过函数来实现个性化的需求,使得实用性大大增强。

对于 jsonparser 来说,代码没什么可分析的,非常的清晰,感兴趣的可以自己去看看。

小结

对于 jsonparser 来说相对标准库比较而言性能如此高的原因可以总结为:

  1. 使用 for 循环来减少递归的使用;
  2. 相比标准库而言没有使用反射;
  3. 在查找相应的 key 值找到了便直接退出,可以不用继续往下递归;
  4. 所操作的 JSON 串都是已被传入的,不会去重新再去申请新的空间,减少了内存分配;

除此之外在 api 的设计上也是非常的实用,ArrayEach、ObjectEach、EachKey 等三个函数都可以传入一个自定义的函数在实际的业务开发中解决了不少问题。

缺点也是非常的明显,不能对 JSON 进行校验,即使这个 传入的不是 JSON。

性能对比

解析小 JSON 字符串

解析一个结构简单,大小约 190 bytes 的字符串

库名 操作 每次迭代耗时 占用内存数 分配内存次数 性能
标准库 解析为map 724 ns/op 976 B/op 51 allocs/op
解析为struct 297 ns/op 256 B/op 5 allocs/op 一般
fastjson get 68.2 ns/op 0 B/op 0 allocs/op 最快
parse 35.1 ns/op 0 B/op 0 allocs/op 最快
GJSON 转map 255 ns/op 1009 B/op 11 allocs/op 一般
get 232 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 106 ns/op 232 B/op 3 allocs/op

解析中等大小 JSON 字符串

解析一个具有一定复杂度,大小约 2.3KB 的字符串

库名 操作 每次迭代耗时 占用内存数 分配内存次数 性能
标准库 解析为map 4263 ns/op 10212 B/op 208 allocs/op
解析为struct 4789 ns/op 9206 B/op 259 allocs/op
fastjson get 285 ns/op 0 B/op 0 allocs/op 最快
parse 302 ns/op 0 B/op 0 allocs/op 最快
GJSON 转map 2571 ns/op 8539 B/op 83 allocs/op 一般
get 1489 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 878 ns/op 2728 B/op 5 allocs/op

解析大 JSON 字符串

解析复杂度比较高,大小约 2.2MB 的字符串

库名 操作 每次迭代耗时 占用内存数 分配内存次数 性能
标准库 解析为map 2292959 ns/op 5214009 B/op 95402 allocs/op
解析为struct 1165490 ns/op 2023 B/op 76 allocs/op 一般
fastjson get 368056 ns/op 0 B/op 0 allocs/op
parse 371397 ns/op 0 B/op 0 allocs/op
GJSON 转map 1901727 ns/op 4788894 B/op 54372 allocs/op 一般
get 1322167 ns/op 448 B/op 1 allocs/op 一般
jsonparser get 233090 ns/op 1788865 B/op 376 allocs/op 最快

总结

在这次的分享过程中,我找了很多 JSON 的解析库分别进行对比分析,可以发现这些高性能的解析库基本上都有一些共同的特点:

尽管如此,但是功能上,每个都有一定的特色 fastjson 的 api 操作最简单;GJSON 提供了模糊查找的功能,自定义程度最高;jsonparser 在实现高性能的解析过程中,还可以插入回调函数执行,提供了一定程度上的便利。

综上,回到文章的开头中,对于我自己的业务来说,业务也只是简单的解析 http 请求返回的 JSON 串的部分字段,并且字段都是确定的,无需搜索功能,但是有时候需要做一些自定义的操作,所以对我来说 jsonparser 是最合适的。

所以如果各位对性能有一定要求,不妨结合自己的业务情况来挑选一款 JSON 解析器。

Reference

https://github.com/buger/jsonparser
https://github.com/tidwall/gjson
https://github.com/valyala/fastjson
https://github.com/json-iterator/go
https://github.com/mailru/easyjson
https://github.com/Jeffail/gabs
https://github.com/bitly/go-simplejson

Go 1.23: 新包 Iter

2024-06-11 17:33:16

上周,Go 1.23 进入冻结期,这意味着不会添加任何新功能,并且任何已添加的功能不太可能被删除。这是一个预览即将发生的变化的好机会。
这篇文章,来了解一下 1.23 转正的 iter 包。

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

在Go 1.22中,引入了range over func实验性功能,但需要通过参数GOEXPERIMENT=rangefunc启用。在Go 1.23中,可以直接使用代码实现这种迭代方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func Backward(s []string) func(yield func(string) bool) {  
   return func(yield func(string) bool) {  
     for i := len(s) - 1; i >= 0; i-- {  
       yield(strings.ToUpper(s[i]))  
     }  
   }  
 }  
   
 func ToUpperByIter() {  
   sl := []string{"hello", "world", "golang"}  
   for v := range Backward(sl) {  
     // do business   
   }  
 }

yield是传递给迭代器的可调用函数的常规名称。
我们考虑一下如何在不使用“iter”包的情况下编写代码来实现相同的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func Convert[S any, D any](src []S, mapFn func(s S) D) []D {  
     r := make([]D, 0, len(src))  
     for _, i := range src {  
        r = append(r, mapFn(i))  
     }  
     return r  
 }    
     
 func ToUpByString() {  
     sl := []string{"hello", "world", "golang"}  
     s0 := Convert(sl, func(v string) string { return strings.ToUpper(v) })  
     for _, v := range s0 {  
        // do business  
     }  
 }

性能对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
➜  huizhou92 git:(master) ✗ go test -bench . -count=3  
 goos: darwin  
 goarch: arm64  
 pkg: huizhou92  
 cpu: Apple M1 Pro  
 BenchmarkToUpByString-10         8568332               128.7 ns/op  
 BenchmarkToUpByString-10         9310351               128.6 ns/op  
 BenchmarkToUpByString-10         9344986               128.5 ns/op  
 BenchmarkToUpByIter-10          12440120                96.22 ns/op  
 BenchmarkToUpByIter-10          12436645                96.25 ns/op  
 BenchmarkToUpByIter-10          12371175                96.64 ns/op  
 PASS  
 ok      huizhou92       8.162s

结果很明显:ToUpperByIter 性能更好,因为它不会重新分配新的slice,使得它比以前的方法更高效。

iter 的目标

iter 包旨在提供统一且高效的迭代方法。它为自定义容器类(尤其是在引入泛型之后)提供了标准的迭代接口,并可以替换一些返回切片的现有 API。通过使用迭代器并利用编译器优化,可以提高性能。此外,它还提供了适合函数式编程风格的标准迭代机制。

如何使用 iter

iter支持两种类型的迭代器:

1
2
3
4
5
6
7
8
9
// Seq is an iterator over sequences of individual values.  
 // When called as seq(yield), seq calls yield(v) for each value v in the sequence,  
 // stopping early if yield returns false.  
 type Seq[V any] func(yield func(V) bool)    
     
 // Seq2 is an iterator over sequences of pairs of values, most commonly key-value pairs.  
 // When called as seq(yield), seq calls yield(k, v) for each pair (k, v) in the sequence,  
 // stopping early if yield returns false.  
 type Seq2[K, V any] func(yield func(K, V) bool)

map 包已经使用 iter 来添加了诸如 AllKeys 等方法。这里是它的实现参考:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//https://go.googlesource.com/go/blob/c83b1a7013784098c2061ae7be832b2ab7241424/src/maps/iter.go#L12  
 // All returns an iterator over key-value pairs from m.  
 // The iteration order is not specified and is not guaranteed  
 // to be the same from one call to the next.  
 func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V] {  
     return func(yield func(K, V) bool) {  
        for k, v := range m {  
           if !yield(k, v) {  
              return  
           }  
        }  
     }  
 }    
     
 // Keys returns an iterator over keys in m.  
 // The iteration order is not specified and is not guaranteed  
 // to be the same from one call to the next.  
 func Keys[Map ~map[K]V, K comparable](m Map) iter.Seq[K] {  
     return func(yield func(K) bool) {  
        for k := range m {  
           if !yield(k) {  
              return  
           }  
        }  
     }  
 }

争论

“在我看来,yield 是一个足够复杂的概念,会导致出现大量糟糕的、难以理解的代码。这个建议只提供了语法糖,用于编写语言中已经超出可能范围的内容。我认为这违背了“一个问题 - 一个解决方案”的规则。拜托,让 Go 保持无聊。” 来源

这是社区内常见的反对意见。yield 不容易理解,并且我们可以通过多种方式实现迭代器。

结论

我支持添加iter

iter包为开发人员提供了许多可能性,旨在简化代码并采用更多的函数式编程实践。然而,由于对性能、复杂性和学习曲线的担忧,它的接受度存在分歧。

与任何新工具一样,关键是在提供明显好处的地方平衡其使用,并同时注意潜在缺点。毫无疑问,Go社区将继续探索和辩论如何利用iter的力量而不损害该语言的基本原则。

参考资料

  1. 61405
  2. 56413
  3. iterators_in_go_123

Golang 1.23: 新的 unique 包

2024-06-04 09:54:42

Featured image of post Golang 1.23: 新的 unique 包

上周,Go 1.23 进入冻结期,这意味着不会添加任何新功能,并且任何已添加的功能不太可能被删除。这是一个预览即将发生的变化的好机会。
这篇文章,我们来介绍引入的新包unique

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

根据wikipedia的描述,interning是按需重复使用具有同等值对象的技术,减少创建新对象的动作。这种创建模式经常用于不同编程语言中的数和字符串,可以避免不必要的对象重复分配的开销。
unique 参考了go4.org/intern ,将它移动到了 官方库,并且做了相应的修改。 issue #62483

就像官方描述的一样 unique 这个包提供了一种轻量化(unique仅仅八个字节)的比较两个变量是否相等的实现。比如下面这段代码

性能提升还是很明显的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜  unique git:(master) ✗ /Users/hxzhouh/workspace/googlesource/go/bin/go test -bench='BenchmarkMake1' -count=5 
goos: darwin
goarch: arm64
pkg: unique
cpu: Apple M1 Pro
BenchmarkMake1-10       122033748                9.692 ns/op
BenchmarkMake1-10       123878858                9.688 ns/op
BenchmarkMake1-10       123927121                9.706 ns/op
BenchmarkMake1-10       123849468                9.759 ns/op
BenchmarkMake1-10       123306187                9.673 ns/op
PASS
ok      unique  11.055s
➜  unique git:(master) ✗ /Users/hxzhouh/workspace/googlesource/go/bin/go test -bench='BenchmarkMake2' -count=5
goos: darwin
goarch: arm64
pkg: unique
cpu: Apple M1 Pro
BenchmarkMake2-10       1000000000               0.3118 ns/op
BenchmarkMake2-10       1000000000               0.3114 ns/op
BenchmarkMake2-10       1000000000               0.3119 ns/op
BenchmarkMake2-10       1000000000               0.3136 ns/op
BenchmarkMake2-10       1000000000               0.3115 ns/op
PASS
ok      unique  1.875s

但是 你不应该把他当作一个全局变量来用,存储共享数据,unique 的底层实现其实是一个map,查询的成本也是很高的。
比如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜  huizhou92_test git:(master) ✗ /Users/hxzhouh/workspace/googlesource/go/bin/go test --bench=BenchmarkBusinessUnique --count=5 
goos: darwin
goarch: arm64
pkg: huizhou92_test
cpu: Apple M1 Pro
BenchmarkBusinessUnique-10          3114            373867 ns/op
BenchmarkBusinessUnique-10          3280            390818 ns/op
BenchmarkBusinessUnique-10          2941            376503 ns/op
BenchmarkBusinessUnique-10          3291            389665 ns/op
BenchmarkBusinessUnique-10          2954            398610 ns/op
PASS
ok      huizhou92_test  6.320s
➜  huizhou92_test git:(master) ✗ /Users/hxzhouh/workspace/googlesource/go/bin/go test --bench=BenchmarkBusinessString --count=5 
goos: darwin
goarch: arm64
pkg: huizhou92_test
cpu: Apple M1 Pro
BenchmarkBusinessString-10      526721706                2.185 ns/op
BenchmarkBusinessString-10      548612287                2.183 ns/op
BenchmarkBusinessString-10      549425077                2.188 ns/op
BenchmarkBusinessString-10      549012100                2.182 ns/op
BenchmarkBusinessString-10      548929644                2.183 ns/op
PASS
ok      huizhou92_test  7.237s

正是因为这样,关于unique的讨论其实还在继续,可能是因为用到的地方不是很多?不管怎么样, 这个新的包进入标准库已经是事实了。 net/netip 已经用unique 重构了它,用来比对IP地址的详细信息。

了解 HTTPS:关键点和流程详解

2024-05-27 18:30:32

Featured image of post 了解 HTTPS:关键点和流程详解

众所周知,HTTPS可以解决HTTP明文传输过程中的安全性问题,尤其是中间人攻击问题。其最初的全称是HTTP over SSL(或者说 http Security)。其中的SSL是指Secure Sockets Layer,后来SSL被TLS(Transport Layer Security )所取代。今天我们就来总结一下HTTPS的要点

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

HTTPS 版本

当前人们一般将SSL,TLS这两个协议统称为SSL/TLS协议,但大家日常说SSL的时,默认还是指TLS协议。
TLS 协议在版本上有1.1、1.2、1.3,其中1.2曾经是主流,现在推荐使用改进后的 TLS 1.3,它升级了HandShake和Record协议,会使得通信更加安全和高效。

图片

安全上,TLS 1.3 移除了一些在 TLS 1.2 中被认为是不安全的加密算法,如 RC4、DES、3DES、AES-CBC 和 MD5 等,这样可以减少安全漏洞的风险。

性能上,TLS 1.3 减少了握手过程中的往返次数(RTT),从而加快了连接的建立速度。在最佳情况下,TLS 1.3 只需要一次往返就可以完成握手,同时支持0-RTT扩展,而 TLS 1.2 需要两次或更多。

图片

当然,作为设计精良的互联网协议,TLS 1.3也通过hello握手消息的扩展协议考虑了最大化向前兼容,这点不再赘述。

图片

HTTPS 核心流程

依据不同版本的差异,细节流程会略有不同,不追求严谨细致的情况下,HTTPS工作流程如下。
https://blog.bytebytego.com/i/53596514/how-does-https-work

bytebytego 的这个图非常具有表现力,展示了关键的交互和核心的加密流程。最关键的几步在于如何建立TCP链接,如何通过非对称加密协商获取对称加密的密钥,以及最后通过对称加密进行通信。

HTTPS,准确来说是TLS,设计严密,其中最关键的是Record Layer和几种Protocol,前者是数据承载管道,各种子Protocol都跑在它上面 ,其中的Record是TLS数据收发传输的基本单位,类似TCP的segment,IP的Packet,这也是下面这幅图的含义。

图片

上图中Protocol里最重要的是Handshake协议,针对Client Hello进行抓包后,在Wireshark中体现得会更清晰。

图片

HTTPS SNI 扩展

互联网早期,单机服务器没那么强大,配套的HTTPS比如SSL v2也有设计缺陷。那时有一个假定,认为拥有一个IP的单台服务器只会托管一个域名服务,所以DNS解析以后,直连IP时就能非常确定要使用具体某个域名的证书。但后面云计算、虚拟主机大爆发,以及IPv4中IP的稀缺性,一台服务器托管多个域名的场景无可避免,这时服务器面临无法知道客户端到底想要访问哪个域名的SSL证书的问题,从而导致了HTTPS SNI的出现。

图片

SNI(Server Name Indication)是TLS协议的一个扩展,它允许客户端在握手过程中向服务器发送目标主机名信息。这样,服务器就可以在同一个IP地址上托管多个域名的HTTPS服务,并为每个域名提供正确的证书。

这个问题看似简单,在HTTPS逐渐普及,各互联网服务商走向全站HTTPS化的早期,很多CDN厂商甚至都是不支持SNI的。当然在2024年的今天,无论是Nginx等软件生态,还是各厂商,都已经支持了的。

SNI信息是通过TLS握手协议传输的,抓包示意大概是下面这样子。

图片

具体到实操,可以使用openssl s_client子命令中的-servername选项来指定SNI:

1
openssl s_client -connect example.com:443 -servername example.com

如果使用OpenSSL Library,也可以使用SSL_set_tlsext_host_name和BIO_set_conn_hostname等函数来在代码中设置。

HTTPS 证书机制

HTTPS通过公钥 体系里的非对称、对称及摘要算法,实现了一系列的加解密、签名、验签等功能,基本实现了安全四大特性:机密性、完整性,身份认证和不可否认。如典型的中间人攻击(Man-in-the-middle attack,MITM),也都有了解决方案。

图片

这里为了解决公钥的信任问题,又引入了证书和信任链机制。证书(Certificate)是由第三方CA(Certificate Authority,证书认证机构)颁发的,本质上是一个文件,通常是.crt、.cer 或 .pem 等扩展名存储。这个文件按照一定的标准(如X.509)编码,包含了公钥、证书持有者信息、颁发机构信息、有效期和数字签名等信息。

有一些世界知名的 CA 机构,比如 DigiCertVeriSignEntrustLet’s Encrypt 等,它们签发的证书分 DV、OV、EV 三种,对应不同的可信程度。但CA自己也有信任问题,小CA的信任靠大CA签名认证,但逐层向上到了链条的最后,就是 Root CA,就只能用“自签名证书”(Self-Signed Certificate)或者“根证书”(Root Certificate)了。

图片

大部分操作系统和浏览器都内置了各大 CA 的根证书,HTTPS通信时会顺着证书链(Certificate Chain)逐层验证到根证书。

图片

HTTPS 软件生态

HTTPS,或是说TLS,生态虽然丰富,但OpenSSL一家独大。它几乎支持所有公开的加密算法和协议,已经成为了事实上的标准,许多应用软件都会使用它作为底层库来实现 TLS 功能,比如著名的 Apache、Nginx 等。

图片

OpenSSL源于SSLeay,其后开枝散叶,形成众多分支,如 Google 的 BoringSSL、OpenBSD 的 LibreSSL。OpenSSL的内容也极其庞杂,可以优先使用openssl命令进行学习,具体可以参考ChatGPT。

HTTPS 加速方案

HTTPS很美好,但美好的事物都有成本。所以关于HTTPS全站铺开后的各种优化,基本上可以写成独立的一篇文章,这里先简单提下。

首先是优化RTT,这个在IO密集型的互联网场景下尤为重要,主要是通过升级协议,如升级HTTP/3,升级TLS 1.3,都可以通过不同原理来优化RTT。其次是优化单步骤性能,如增加TLS加速卡,设置单独的TLS集群或模块等,还有一些TLS session resumption等名词也可以关注。
我以前写过一篇文章分享为什么HTTPS为什么这么慢的文章,有兴趣可以阅读一下。
Why does HTTPS need 7 handshakes and 9 times delay?

参考资料

What’s the difference between HTTP and HTTPS?
how-does-https-work

Golang 1.23:`//go:linkname` 的变更及其对开发人员的意义

2024-05-27 09:37:25

上周 go1.23 已经进入冻结期了,应该不会再添加新功能,相应的已经添加了的功能 也不太可能会被移除。
这正好可以让我们提前尝鲜这些即将到来的新特性。
https://groups.google.com/g/golang-dev/c/vXE304_MnKM

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

今天要说的就是1.23中对//go:linkname指令的变更。
相关讨论的issue 在这里:
https://github.com/golang/go/issues/67401

TL;DR //go:linkname指令官方并不推荐使用,且不保证任何向前或者向后兼容性,因此明智的做法是尽量别用

牢记这一点之后,我们可以接着往下看了。至于为啥和“我”也就是本文的作者有关,我们先看完新版本带来的新变化再说。

linkname指令是做什么的

简单的说,linkname指令用于向编译器和链接器传递信息。具体的含义根据用法可以分为三类。

第一类叫做“pull”,意思是拉取,使用方式如下:

1
2
3
4
5
6
import _ "unsafe" // 必须有这行才能用linkname

import _ "fmt" // 被拉取的包需要显式导入(除了runtime包)

//go:linkname my_func fmt.Println
func my_func(...any) (n int, err error)

这种用法的指令格式是//go:linkname <指令下方的只有声明的函数或包级别变量名> <本包或者其他包中的有完整定义的函数或变量>

这个指令的作用就是告诉编译器和连接器,my_func的函数体直接使用fmt.Println的,my_func类似fmt.Println的别名,和它共享同一份代码,就像把指令第二个参数指定的函数和变量拉取下来给第一个参数使用一样。

正因如此,指令下方给出的声明必须和被拉取的函数/变量完全一致,否则很容易因为类型不匹配导致panic(是的没错,除非拉取的对象不存在,否则都不会出现编译错误)。

这个指令最恐怖的地方在于它能无视函数或者变量是否是export的,包私有的东西也能被拉取出来使用。因为这一点这种用法在早期的社区中很常见,比如很多人喜欢这么干://go:linkname myRand runtime.fastrand,因为runtime提供了一个性能还不错的随机数实现,但没有公开出来,所以有人会用linkname指令把它导出为己所用,当然随着1.21的发布这种用法不再有任何意义了,请永远都不要去模仿。

第二种用法叫做“push”,即推送。形式上是下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import _ "unsafe" // 必须有这行才能用linkname

//go:linkname main.fastHandle
func fastHandle(input io.Writer) error {
...
}

// package main
func fastHandle(input io.Writer) error

// 后面main包中可以直接使用fastHandle
// 这种情况下需要在main包下创建一个空的asm文件(通常以.s作为扩展名),以告诉编译器fastHandle的定义在别处

在这种用法中,我们只需要把函数/变量名当作第一个参数传给指令,注意需要给出想用这个函数/变量的包的名字,这里是main。同时指令声明的变量或函数必须要在同包内有完整的定义,通常推荐直接把完整定义写在linkname指令下方。

这种用法是告诉编译器和链接器这个函数/变量的名字就是xxx.yyy,如果遇到这个函数就使用linkname指定的函数/变量的代码,这个模式下甚至能在本包定义别的包里的函数。

当然这种用法的语义作用更明显,它意味着这个函数会在任何地方被使用,修改它需要小心,因为改变了函数的行为可能会让其他调用它的代码出bug;修改了函数的签名则很可能导致运行时panic;删除了这个函数则会导致代码无法编译。

最后一类叫做“handshake”,即握手。他是把第一类和第二类方法结合使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package mypkg

import _ "unsafe" // 必须有这行才能用linkname

//go:linkname fastHandle
func fastHandle(input io.Writer) error {
...
}

package main

import _ "unsafe" // 必须有这行才能用linkname

//go:linkname fastHandle mypkg.fastHandle
func fastHandle(input io.Writer) error

“pull”的一方没什么区别,但“push”的一方不用再写包名,同时用来告诉编译器函数定义在别的地方的空的asm文件也不需要了。这种就像通讯协议中的“握手”,一方告诉编译器这边允许某个函数/变量被linkname操作,另一边则明确像编译器要求它要使用某个包的某个函数/变量。

通常“pull”和“push”应该成对出现,也就是你只应该使用“handshake”模式。

然而不幸的是,当前(1.22)的go语言支持“pull-only”的用法,即可以随便拉取任何包里的任何函数/变量,但不需要被拉取的对象使用“push”标记自己。而被linkname拉取的一方是完全无感知的。

这就导致了非常大的隐患。

linkname带来的隐患

最大的隐患在于这个指令可以在不通知被拉取的packages的情况下随意使用包中私有的函数/变量。

举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// pkg/mymath/mymath.go
package mymath

func uintPow(n uint) uint {
	return n*n
}

// main.go
package main

import (
	"fmt"
	_ "linkname/pkg/mymath"
	_ "unsafe"
)

//go:linkname pow linkname/pkg/mymath.uintPow
func pow(n uint) uint

func main() {
	fmt.Println(pow(6)) // 36
}

正常来说,uintPow是不可能被外部使用的,然而通过linkname指令我们直接无视了接口的公开和私有,有什么就能用什么了。

这当然是非常危险的,比如我们把uintPow的参数类型改成string:

1
2
3
4
5
package mymath

func uintPow(n string) string {
return n + n
}

这时候编译还是能正常编译,但运行的时候就会出现各种bug,在我的机器上表现是卡死和段错误。为什么呢?因为我们把uint强行传递了过去,但参数需要是string,类型对不上,自然会出现稀奇古怪的bug。这种在别的语言里是严重的类型相关的内存错误。

另外如果我们直接删了uintPow或者给他改个名,链接器会在编译期间报错:

1
2
3
4
$ go build

# linkname
main.main: relocation target linkname/pkg/mymath.uintPow not defined

而且我们导出的是私有函数,通常没人会认为自己写的私有级别的帮助函数会被导出到包外并被使用,因此在开发时大家都是保证公开接口的稳定性,私有的函数/变量是随时可以被大规模修改甚至删除的。

而linkname将这种在别的语言里最基本的规矩给粉碎了。

而且事实上也是如此,从1.18开始几乎每个版本都有因为编译器或者标准库内部的私有函数被修改/删除从而导致某些第三方库在新版本无法使用的问题,因为这些库在内部悄悄用//go:linkname用了一些未公开的功能。最近一次发生在广泛使用的知名json库上类似的问题可以在这里看到。

linkname的正面作用

既然这个指令如此危险,为什么还一直存在呢?答案是有不得不用的理由,其中一个就在启动go程序的时候。

我们来看下go的runtime里是怎么用linkname的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// runtime/proc.go

//go:linkname main_main main.main
func main_main()

// runtime.main
// 所有go程序的入口
func main() {
	// 初始化runtime
	// 调用main.main
	fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
	fn()
	// main退出后做清理工作
}

因为程序的入口在runtime里(要初始化runtime,比如gc等),所以入口函数必须在runtime包里。而我们又需要调用用户定义在main包里的main函数,但main包不能被import,因此只能靠linkname指令让链接器绕过所有编译器附加的限制来调用main函数。

这是目前在go自身的源代码里看到的唯一一处不得不使用“pull-only”模式的地方。

另外“handshake”模式也有存在的必要性,因为像runtime和reflect需要共享很多实现上的细节,因此reflect作为pull的一方,runtime作为push的一方,可以极大减少代码维护的复杂度。

除了上述这些情况,绝大数linkname的使用都可以算作_abuse_。

golang1.23对linkname指令的改动

鉴于上述情况,golang核心团队决定限制linkname的使用。

第一个改动是标准库里新添加的包全部禁止使用linkname导出其中的内容,目前是通过黑名单实现的,1.23中新添加的几个包以及它们的internal依赖都在名单上,这样可以防止已有的linkname问题继续扩大。这对已有的代码也是完全无害的。

第二个变更时添加了新的ldflags: -checklinkname=1。1代表开启对linkname的限制,0代表维持1.22的行为不变。目前默认是0,但官方决定在1.23发布时默认值为1开启限制。个人建议尽量不要关闭这个限制。这个限制眼下只针对标准库,但按官方的说法效果好的话以后所有的代码不管标准库还是第三方都会启用限制。

最后也是最大的变动,禁止对标准库的 “pull-only” linkname指令,但允许“handshake”模式。

虽然go从来不保证linkname的向后兼容性,但这样还是会大量较大的破坏,因此官方已经对常见的go第三方库做了扫描,会把一些经常被人用linkname拉取的接口改成符合“handshake”模式的形式,这种改动只用加一行指令即可。而且该限制目前只针对标准库,其他第三方库暂时不受影响。

因为这个变更,下面的代码在1.23是无法编译通过的:

1
2
3
4
5
6
7
8
package main
import _ "unsafe"
//go:linkname corostart runtime.corostart
func corostart()

func main() {
corostart()
}

因为runtime.corostart并不符合handshake模式,所以对它的linkname被禁止了:

1
2
3
4
5
$ go version
go version devel go1.23-13d36a9b46 Wed May 27 21:51:49 2024 +0000 windows/amd64
$ go build -ldflags=-checklinkname=1
# linkname
link: main: invalid reference to runtime.corostart

linkname指令今后的发展

大趋势肯定是以后只允许handshake模式。不过作为过渡目前还是允许push模式的,并且官方应该会在进入功能冻结后把之前说的扫描到的常用的内部函数添加上linkname指令。

这里比较重要的是作为开发者的我们应该怎么办:

  1. 1.23发布之后或者现在就开始利用-checklinkname=1排查代码,及时清除不必要的linkname指令。
  2. 如果linkname指令非用不可,建议马上提issue或者熟悉go开发流程的立刻提pr补上handshake模式需要的指令,不过我不怎么推荐这种做法,因为内部api尤其是runtime以外的库的本来就不该随便被导出使用,没有一个强力的能说服所有人的理由,这些issue和pr多半不会被接受。
  3. 向官方提案,尝试把你要用的私有api变成公开接口,这一步难度也很高,私有api之所以当初不公开一定是有原因的,现在再想公开可能性也不高。
  4. 你的追求比较低,只要代码能跑就行,那可以在构建脚本里加上-ldflags=-checklinkname=0关闭限制,这样也许能岁月静好几个版本,直到某一天程序突然没法编译或者运行了一半被莫名其妙的panic打断。

4是万不得已时的保底方案,按优先度我推荐1 > 3 > 2的顺序去适配go1.23。2和3不仅仅适用于go标准库,常用的第三方库也可以。通过这些适配工作说不定也有机会让你成为go或者知名第三方库的贡献者。

从现在开始完全是来得及的,毕竟离1.23的第一个测试版发布还有一个月左右,离正式版发布还有两个月。而且方案2的修改并不算作新功能,不受功能冻结的影响。

当然,大部分开发者应该不用担心,比较linkname的使用是少数,一些主动使用linkname的库比如quic-go也知道兼容性问题,很小心地做了不同版本的适配,加上官方承诺的兜底这一对linkname指令的改动的影响应该比想象中小,但是是提高代码安全性的一大步。

总结

最后总结就一句话:没事别用//go:linkname 可能会留下不可预知的隐患。

为什么 Google 选择使用HTTP 2 实现 gRPC

2024-05-23 10:25:02

Featured image of post 为什么 Google 选择使用HTTP 2 实现 gRPC

背景

gRPC是google开源的高性能跨语言的RPC方案。gRPC的设计目标是在任何环境下运行,支持可插拔的负载均衡,跟踪,运行状况检查和身份验证。它不仅支持数据中心内部和跨数据中心的服务调用,它也适用于分布式计算的最后一公里,将设备,移动应用程序和浏览器连接到后端服务。
关于 GRPC设计的动机和原则 我们可以从这篇文章里面找到答案,gRPC Motivation and Design Principles

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

官方的文章令人印象深刻的点:

实际上:性能不是gRPC 设计的第一目标。那么为什么选择HTTP/2?

HTTP/2是什么

在正式讨论gRPC为什么选择HTTP/2之前,我们先来简单了解下HTTP/2。
HTTP/2可以简单用一个图片来介绍:
https://hpbn.co/

来自:https://hpbn.co/

目前很多网站都已经跑在HTTP/2上了。

gRPC Over HTTP/2

准确来说gRPC设计上是分层的,底层支持不同的协议,目前gRPC支持:

但是大多数情况下,讨论都是基于gRPC over HTTP2。
下面从一个真实的gRPC SayHello请求,查看它在HTTP/2上是怎样实现的。用Wireshark抓包:

wireshark-grpc

可以看到下面这些Header:

1
2
3
4
5
6
Header: :authority: localhost:50051
Header: :path: /helloworld.Greeter/SayHello
Header: :method: POST
Header: :scheme: http
Header: content-type: application/grpc
Header: user-agent: grpc-java-netty/1.11.0  

然后请求的参数在DATA frame里:
GRPC Message: /helloworld.Greeter/SayHello, Request
简而言之,gGRPC把元数据放到HTTP/2 Headers里,请求参数序列化之后放到 DATA frame里。

基于HTTP/2 协议的优点

HTTP/2 是一个公开的标准

Google本身把这个事情想清楚了,它并没有把内部的Stubby开源,而是选择重新做。现在技术越来越开放,私有协议的空间越来越小。

HTTP/2 是一个经过实践检验的标准

HTTP/2是先有实践再有标准,这个很重要。很多不成功的标准都是先有一大堆厂商讨论出标准后有实现,导致混乱而不可用,比如CORBA。HTTP/2的前身是Google的SPDY,没有Google的实践和推动,可能都不会有HTTP/2。

HTTP/2 天然支持物联网、手机、浏览器

实际上先用上HTTP/2的也是手机和手机浏览器。移动互联网推动了HTTP/2的发展和普及。

基于HTTP/2 多语言的实现容易

只讨论协议本身的实现,不考虑序列化。

HTTP/2支持Stream和流控

在业界,有很多支持stream的方案,比如基于websocket的,或者rsocket。但是这些方案都不是通用的。
HTTP/2里的Stream还可以设置优先级,尽管在rpc里可能用的比较少,但是一些复杂的场景可能会用到。

基于HTTP/2 在Gateway/Proxy很容易支持

HTTP/2 安全性有保证

HTTP/2 鉴权成熟

基于HTTP/2 的缺点

rpc的元数据的传输不够高效

尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码。
可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能。

HTTP/2 里一次gRPC调用需要解码两次

一次是HEADERS frame,一次是DATA frame。

HTTP/2 标准本身是只有一个TCP连接,但是实际在gRPC里是会有多个TCP连接,使用时需要注意。
gRPC选择基于HTTP/2,那么它的性能肯定不会是最顶尖的。但是对于rpc来说中庸的qps可以接受,通用和兼容性才是最重要的事情。我们可以参考一下官方的benchmark:https://grpc.io/docs/guides/benchmarking.html

Google制定标准的能力

近10年来,Google制定标准的能力越来越强。下面列举一些标准:

gRPC目前是k8s生态里的事实标准。 gRPC是否会成为更多地方,更大领域的RPC标准?

为什么会出现gRPC

准确来说为什么会出现基于HTTP/2的RPC?

个人认为一个重要的原因是,在Cloud Native的潮流下,开放互通的需求必然会产生基于HTTP/2的RPC。即使没有gRPC,也会有其它基于HTTP/2的RPC。

gRPC在Google的内部也是先用在Google Cloud Platform和公开的API上:https://opensource.google.com/projects/grpc

总结

尽管gRPC它可能替换不了内部的RPC实现,但是在开放互通的时代,不止在k8s上,gRPC会有越来越多的舞台可以施展。

参考资料

使用 wireshark 抓包GRPC

2024-05-20 05:36:25

Featured image of post 使用 wireshark 抓包GRPC

摘要

wireshark 是一个 流行的抓取网络报文的工具,他不仅自己可以抓包,也可以解析tcpdump抓包的文件。
gRPC 是Google开发的一个高性能RPC框架,基于HTTP/2协议+protobuf序列化协议.
本文主要介绍如何使用wireshark抓取gRPC的报文,并解析报文内容。

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

Wireshark version: 4.2.2

配置

因为gRPC 是基于protobuf序列化协议,所以我们需要先添加protobuf的文件地址。
点击 Wireshark -> Preferences… -> Protocols -> Protobuf -> Protobuf search paths -> Edit…
点击+ 添加您要抓包的protobuf 文件路径,不要忘记勾选右边的 Load all files
Pasted image 20240511225144

具体操作

首先我们写一个最简单的gRPC服务,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
syntax = "proto3";  
option go_package = "example.com/hxzhouh/go-example/grpc/helloworld/api";  
package api;  
  
// The greeting service definition.  
service Greeter {  
  // Sends a greeting  
  rpc SayHello (HelloRequest) returns (HelloReply) {}  
}  
  
// The request message containing the user's name.  
message HelloRequest {  
  string name = 1;  
}  
  
// The response message containing the greetings  
message HelloReply {  
  string message = 1;  
}

它仅仅就一个函数 Greeter ,补充完服务端代码,把它运行起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type server struct {  
    api.UnimplementedGreeterServer  
}  
  
func (s *server) SayHello(ctx context.Context, in *api.HelloRequest) (*api.HelloReply, error) {  
    log.Printf("Received: %v", in.GetName())  
    return &api.HelloReply{Message: "Hello " + in.GetName()}, nil  
}  
  
func main() {  
    lis, err := net.Listen("tcp", ":50051")  
    if err != nil {  
       log.Fatalf("failed to listen: %v", err)  
    }  
    s := grpc.NewServer()  
    api.RegisterGreeterServer(s, &server{})  
    if err := s.Serve(lis); err != nil {  
       log.Fatalf("failed to serve: %v", err)  
    }  
}

然后我们打开 wireshark ,选择本地网卡,监听 tcp.port == 50051

如果您以前没接触过 wireshark,我建议您先看看这篇文章:https://www.lifewire.com/wireshark-tutorial-4143298

Pasted image 20240511230443
Pasted image 20240511230512

一元函数

现在我们有一个gRPC 服务运行再本地的50051 端口, 我们可以使用BloomRPC 或者其他您任何喜欢的工具对服务端发起一个RPC请求,或者直接像我一样使用下面的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func Test_server_SayHello(t *testing.T) {  
    // Set up a connection to the server.  
    conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithBlock())  
    if err != nil {  
       log.Fatalf("did not connect: %v", err)  
    }  
    defer conn.Close()  
    c := api.NewGreeterClient(conn)  
  
    // Contact the server and print out its response.  
    name := "Hello"  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)  
    defer cancel()  
    r, err := c.SayHello(ctx, &api.HelloRequest{Name: name})  
    if err != nil {  
       log.Fatalf("could not greet: %v", err)  
    }  
    log.Printf("Greeting: %s", r.GetMessage())  
}

这个时候,wireshark 应该就能抓到流量包了。
Pasted image 20240511232027
前面我们说过,gRPC = http2+protobuf, 并且我们前面已经加载了protobuf 文件,理论上我们现在已经能解析报文了。
使用wireshark快捷键 shift+command+U 或者 用鼠标点击 Analyze -> Decode As... 然后设置一下将报文解析成HTTP2 格式。
Pasted image 20240511232316
这个时候,我们就能很清晰的看到这个请求了

Pasted image 20240511232448
Pasted image 20240511232507
Pasted image 20240511232539

metadata

我们知道 gRPC 的metadata 是通过 http2 的header 来传递的。 现在我们通过抓包来验证一下。
稍微改造一下客户端代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func Test_server_SayHello(t *testing.T) {  
    // Set up a connection to the server.  
   ..... 
   // add md 
    md := map[string][]string{"timestamp": {time.Now().Format(time.Stamp)}}  
    md["testmd"] = []string{"testmd"}  
    ctx := metadata.NewOutgoingContext(context.Background(), md)  
    // Contact the server and print out its response.  
    name := "Hello"  
    ctx, cancel := context.WithTimeout(ctx, time.Second)  
   ....
}

然后重新抓包。 我们就能看到 md 确实放在 header 里面。
Pasted image 20240512154517

并且我们还在header 看到了grpc-timeout 可见请求超时操作也是房子啊header 里面的。里面涉及的具体细节,我可能会出一篇专门的文章来说明,今天我们只关注抓包。

TLS

上面使用的例子都是明文 传输的 我们再Dial 的时候使用了 grpc.WithInsecure() ,但是在生产环境中,我们一般使用TLS 对进行加密传输。具体的细节可以参考我以前写的文章。
https://medium.com/gitconnected/secure-communication-with-grpc-from-ssl-tls-certification-to-san-certification-d9464c3d706f
我们改造一下 服务端代码
https://gist.github.com/hxzhouh/e08546cf0457d28a614d59ec28870b11

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func main() {  
  
    certificate, err := tls.LoadX509KeyPair("./keys/server.crt", "./keys/server.key")  
    if err != nil {  
       log.Fatalf("Failed to load key pair: %v", err)  
    }  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read ca: %v", err)  
    }  
  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certificate")  
    }  
  
    opts := []grpc.ServerOption{  
       grpc.Creds( // 为所有传入的连接启用TLS  
          credentials.NewTLS(&tls.Config{  
             ClientAuth:   tls.RequireAndVerifyClientCert,  
             Certificates: []tls.Certificate{certificate},  
             ClientCAs:    certPool,  
          },  
          )),  
    }  
  
    listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 50051))  
    if err != nil {  
       log.Fatalf("failed to listen %d port", 50051)  
    }  
    // 通过传入的TLS服务器凭证创建新的gRPC服务实例  
    s := grpc.NewServer(opts...)  
    api.RegisterGreeterServer(s, &server{})  
    log.Printf("server listening at %v", listen.Addr())  
    if err := s.Serve(listen); err != nil {  
       log.Fatalf("Failed to serve: %v", err)  
    }  
}

client
https://gist.github.com/hxzhouh/46a7a31e2696b87fe6fb83c8ce7e036c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func Test_server_SayHello(t *testing.T) {  
    certificate, err := tls.LoadX509KeyPair("./keys/client.crt", "./keys/client.key")  
    if err != nil {  
       log.Fatalf("Failed to load client key pair, %v", err)  
    }  
  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read %s, error: %v", "./keys/ca.crt", err)  
    }  
  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certs")  
    }  
  
    opts := []grpc.DialOption{  
       grpc.WithTransportCredentials(credentials.NewTLS(  
          &tls.Config{  
             ServerName:   "localhost",  
             Certificates: []tls.Certificate{certificate},  
             RootCAs:      certPool,  
          })),  
    }  
    // conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))  
    conn, err := grpc.Dial("localhost:50051", opts...)  
    if err != nil {  
       log.Fatalf("Connect to %s failed", "localhost:50051")  
    }  
    defer conn.Close()  
  
    client := api.NewGreeterClient(conn)  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)  
    defer cancel()  
    r, err := client.SayHello(ctx, &api.HelloRequest{Name: "Hello"})  
    if err != nil {  
       log.Printf("Failed to greet, error: %v", err)  
    } else {  
       log.Printf("Greeting: %v", r.GetMessage())  
    }  
}

这个时候我们再抓包,然后使用相同的方式解析。但是,我们会发现,使用HTTP2 已经无法解密了,但是可以解码成 TLS1.3
Pasted image 20240519210518
Pasted image 20240519211040

总结

这篇文章,首先总结了使用 Wireshark 抓gRPC 包的一个基本流程。
然后我们通过抓包知道了gRPC的参数传递是通过 HTTP2 的data-frame,CTX 等meta 是通过 header 传递的。这些知识我们以前肯定听过,但是只有动手实验才能加深理解。
通过TLS 我们可以实现 安全的gRPC 通信,下一篇文章,我们将尝试解密TLS 报文。

参考资料

知识管理的几个误区

2024-05-06 10:19:00

Featured image of post 知识管理的几个误区

我最近几年一直再打造自己的第二大脑,下面是我的几个经验教训。

频繁切换笔记软件/博客系统

我先后使用过 EverNote,WizNote,VNote,CSDN blog,Google blogspot, WordPress,最终只造成博客散落在多个互联网角落。解决办法就是 all in one 。我现在选择的是Obsidian

频繁切换笔记格式

我先后使用过 txt, orgmode, markdown,富文本等多种格式,最终只造成各种格式转换烦恼,跟第一条一样,每个笔记系统的格式可能不通用,选择Obsidian的原因就是它的markdown语法。如果我需要,我可以轻易的将它迁移到任何笔记系统,

闪念笔记和真正有用的笔记混杂

闪念笔记用于快速捕捉一瞬间的灵感,但只有你在一两天内回顾它并把它变成有用的合适的笔记才有意义。如果不及时回顾,好的想法将淹没在大量的突发奇想中。我们每天大多数的想法没有太大意义应该被丢弃,而那些可以成为重大有意义的想法我们必须将他们识别出来。

项目笔记和知识笔记混杂

只记录特定项目相关的笔记,将导致项目期间有趣的观点或者想法信息丢失。正确的做法是在项目中提取通用的知识。我推荐使用P.A.R.A 方法来整理笔记,有关P.A.R.A 您可以参考 这个网页

频繁整理笔记的「洁癖」

大量堆积的笔记将造成知识整理冲动,多来几次就会影响坚持记录的信心。解决方法是,确定自己关注的领域和负责的责任范围,并不完全采用自下而上的知识管理方法。在达到心理挤压点时,使用 MOCS(Maps of Content)的方法整理笔记(双链绝对是你值得尝试的。)。知识管理系统最重要的是在同一个地方,用同样的格式和一致的标准记录你的洞见。

计算机中的时间 线程上下文切换会用掉你多少CPU?

2024-03-20 02:45:00

进程是操作系统的伟大发明之一,对应用程序屏蔽了CPU调度、内存管理等硬件细节,而抽象出一个进程的概念,让应用程序专心于实现自己的业务逻辑既可,而且在有限的CPU上可以“同时”进行许多个任务。但是它为用户带来方便的同时,也引入了一些额外的开销。如下图,在进程运行中间的时间里,虽然CPU也在忙于干活,但是却没有完成任何的用户工作,这就是进程机制带来的额外开销。

Pasted image 20240319113416

在进程A切换到进程B的过程中,先保存A进程的上下文,以便于等A恢复运行的时候,能够知道A进程的下一条指令是啥。然后将要运行的B进程的上下文恢复到寄存器中。这个过程被称为上下文切换。上下文切换开销在进程不多、切换不频繁的应用场景下问题不大。但是现在Linux操作系统被用到了高并发的网络程序后端服务器。在单机支持成千上万个用户请求的时候,这个开销就得拿出来说道说道了。因为用户进程在请求Redis、Mysql数据等网络IO阻塞掉的时候,或者在进程时间片到了,都会引发上下文切换。

Pasted image 20240319112652

一个简单的进程上下文切换开销测试实验

废话不多说,我们先用个实验测试一下,到底一次上下文切换需要多长的CPU时间!实验方法是创建两个进程并在它们之间传送一个令牌。其中一个进程在读取令牌时就会引起阻塞。另一个进程发送令牌后等待其返回时也处于阻塞状态。如此往返传送一定的次数,然后统计他们的平均单次切换时间开销。
test04

1
2
3
4
# gcc main.c -o main
# ./main./main
Before Context Switch Time1565352257 s, 774767 us
After Context SWitch Time1565352257 s, 842852 us

每次执行的时间会有差异,多次运行后平均每次上下文切换耗时3.5us左右。当然了这个数字因机器而异,而且建议在实机上测试。

前面我们测试系统调用的时候,最低值是200ns。可见,上下文切换开销要比系统调用的开销要大。系统调用只是在进程内将用户态切换到内核态,然后再切回来,而上下文切换可是直接从进程A切换到了进程B。显然这个上下文切换需要完成的工作量更大。

进程上下文切换开销都有哪些

那么上下文切换的时候,CPU的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。
直接开销就是在切换时,cpu必须做的事情,包括:

间接开销主要指的是虽然切换到一个新进程后,==由于各种缓存并不热,速度运行会慢一些==。如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。 其实我们上面的实验并没有很好地测量到这种情况,所以实际的上下文切换开销可能比3.5us要大。
想了解更详细操作过程的同学请参考《深入理解Linux内核》中的第三章和第九章。

一个更为专业的测试工具-lmbench

lmbench用于评价系统综合性能的多平台开源benchmark,能够测试包括文档读写、内存操作、进程创建销毁开销、网络等性能。使用方法简单,但就是跑有点慢,感兴趣的同学可以自己试一试。
这个工具的优势是是进行了多组实验,每组2个进程、8个、16个。每个进程使用的数据大小也在变,充分模拟cache miss造成的影响。我用他测了一下结果如下:

1
2
3
4
5
-------------------------------------------------------------------------
Host                 OS  2p/0K 2p/16K 2p/64K 8p/16K 8p/64K 16p/16K 16p/64K  
                         ctxsw  ctxsw  ctxsw ctxsw  ctxsw   ctxsw   ctxsw  
--------- ------------- ------ ------ ------ ------ ------ ------- -------  
bjzw_46_7 Linux 2.6.32- 2.7800 2.7800 2.7000 4.3800 4.0400 4.75000 5.48000

lmbench显示的进程上下文切换耗时从2.7us到5.48之间。

线程上下文切换耗时

前面我们测试了进程上下文切换的开销,我们再继续在Linux测试一下线程。看看究竟比进程能不能快一些,快的话能快多少。

在Linux下其实本并没有线程,只是为了迎合开发者口味,搞了个轻量级进程出来就叫做了线程。轻量级进程和进程一样,都有自己独立的task_struct进程描述符,也都有自己独立的pid。从操作系统视角看,调度上和进程没有什么区别,都是在等待队列的双向链表里选择一个task_struct切到运行态而已。只不过轻量级进程和普通进程的区别是可以共享同一内存地址空间、代码段、全局变量、同一打开文件集合而已。

同一进程下的线程之所有getpid()看到的pid是一样的,其实task_struct里还有一个tgid字段。 对于多线程程序来说,getpid()系统调用获取的实际上是这个tgid,因此隶属同一进程的多线程看起来PID相同。

我们用一个实验来测试一下test06。其原理和进程测试差不多,创建了20个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。 这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。

1
2
3
# gcc -lpthread main.c -o main
0.508250  
4.363495

每次实验结果会有一些差异,上面的结果是取了多次的结果之后然后平均的,大约每次线程切换开销大约是3.8us左右。从上下文切换的耗时上来看,Linux线程(轻量级进程)其实和进程差别不太大

Linux相关命令

既然我们知道了上下文切换比较的消耗CPU时间,那么我们通过什么工具可以查看一下Linux里究竟在发生多少切换呢?如果上下文切换已经影响到了系统整体性能,我们有没有办法把有问题的进程揪出来,并把它优化掉呢?

1
2
3
4
5
6
7
8
# vmstat 1  
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----  
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st  
 2  0      0 595504   5724 190884    0    0   295   297    0    0 14  6 75  0  4  
 5  0      0 593016   5732 193288    0    0     0    92 19889 29104 20  6 67  0  7  
 3  0      0 591292   5732 195476    0    0     0     0 20151 28487 20  6 66  0  8  
 4  0      0 589296   5732 196800    0    0   116   384 19326 27693 20  7 67  0  7  
 4  0      0 586956   5740 199496    0    0   216    24 18321 24018 22  8 62  0  8

或者是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# sar -w 1  
proc/s  
     Total number of tasks created per second.  
cswch/s  
     Total number of context switches per second.  
11:19:20 AM    proc/s   cswch/s  
11:19:21 AM    110.28  23468.22  
11:19:22 AM    128.85  33910.58  
11:19:23 AM     47.52  40733.66  
11:19:24 AM     35.85  30972.64  
11:19:25 AM     47.62  24951.43  
11:19:26 AM     47.52  42950.50  
......

上图的环境是一台生产环境机器,配置是8核8G的KVM虚机,环境是在nginx+fpm的,fpm数量为1000,平均每秒处理的用户接口请求大约100左右。其中cs列表示的就是在1s内系统发生的上下文切换次数,大约1s切换次数都达到4W次了。粗略估算一下,每核大约每秒需要切换5K次,则1s内需要花将近20ms在上下文切换上。要知道这是虚机,本身在虚拟化上还会有一些额外开销,而且还要真正消耗CPU在用户接口逻辑处理、系统调用内核逻辑处理、以及网络连接的处理以及软中断,所以20ms的开销实际上不低了。
那么进一步,我们看下到底是哪些进程导致了频繁的上下文切换?

1
2
3
4
5
6
# pidstat -w 1  
11:07:56 AM       PID   cswch/s nvcswch/s  Command
11:07:56 AM     32316      4.00      0.00  php-fpm  
11:07:56 AM     32508    160.00     34.00  php-fpm  
11:07:56 AM     32726    131.00      8.00  php-fpm  
......

由于fpm是同步阻塞的模式,每当请求Redis、Memcache、Mysql的时候就会阻塞导致cswch/s自愿上下文切换,而只有时间片到了之后才会触发nvcswch/s非自愿切换。可见fpm进程大部分的切换都是自愿的、非自愿的比较少。

如果想查看具体某个进程的上下文切换总情况,可以在/proc接口下直接看,不过这个是总值。

1
2
3
grep ctxt /proc/32583/status  
voluntary_ctxt_switches:        573066  
nonvoluntary_ctxt_switches:     89260

结论

NewsLetter

2019-05-28 08:00:00

归档

2019-05-28 08:00:00

0001-01-01 08:00:00

“锁"是编程中常用的技术, 通常应用于共享内存, 多个线程向同一资源操作往往会发生很多问题, 为了防止这些问题只能用到锁解决。 虽然锁可以解决, 但是在高并发的场景下, 可能会造成性能瓶颈。
无锁编程就是不是用锁实现相同的功能。目前大多数都是基于atomic实现,本文将展示两个例子解释无锁编程,

WaitGroup

我们先看看 WaitGroup 的 源码。

1
2
3
4
5
6
type WaitGroup struct {  
    noCopy noCopy  
  
    state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.  
    sema  uint32  
}

在 WaitGroup 中,把 counter 和 waiter 看成一个 64 位整数state进行处理,但为什么要这么做呢?分成两个 32 位变量岂不是更方便?这其实是 WaitGroup 的一个性能优化手段。

counter 和 waiter 在改变时需要保证并发安全。对于这种场景,我们最简单的做法是:搞一个 Mutex 或者 RWMutex 锁, 在需要读写 counter 和 waiter 的时候,就进行加锁操作。但是我们知道加锁必然会造成额外的性能开销,作为 系统库,自然需要把性能压榨到极致。所以 WaitGroup 使用的是一个 atomic.Uint64
在需要改变 counter 时, 通过将累加值左移 32 位的方式:atomic.AddUint64(statep, uint64(delta)<<32),即可实现 count += delta 同样的效果。

在 Wait 函数中,通过 CAS 操作 atomic.CompareAndSwapUint64(statep, state, state+1), 来对 waiter 进行自增操作,如果 CAS 操作返回 false,说明 state 变量有修改,有可能是 counter 发生了变化,这个时候需要重试检查逻辑条件。
这就是lock-free 的使用。

lfstack

但是无锁编程不是万能药,因为无锁算法实现起来更复杂,它也有潜在问题,比如竞争(contention),这会极大地影响性能。从这一点出发,Herb引出了他的第一条强烈建议:

https://medium.com/gitconnected/decryption-go-waitgroup-f244e8a68052

https://gist.github.com/hxzhouh/cfa8571f5c8d70422a37fdf9bd395d91

Links

0001-01-01 08:00:00

关于

0001-01-01 08:00:00

关于这个博客

关于我

搜索

0001-01-01 08:00:00