在 Go 语言中，空结构体（struct{}） 是一个特殊的存在，它不占用任何内存（大小为 0 字节），但却有重要的实际用途。以下是它的 4大经典应用场景，结合代码示例详细说明： 1. 作为通道信号（无数据事件通知） 空结构体常用于 chan struct{} 实现纯信号通知，不传递数据，仅通过通道的开关或事件触发行为。 优势：零内存开销，语义明确（仅关注事件本身，不关心数据）。 func worker(done chan struct{}) { fmt.Println("working...") time.Sleep(1 * time.Second) done <- struct{}{} // 发送信号 } func main() { done := make(chan struct{}) go worker(done) <-done // 阻塞等待信号 fmt.Println("done!") } 2. 实现集合（Set 数据结构） Go 没有内置 Set 类型，通常用 map[T]struct{} 模拟集合。优势： 比 map[T]bool 更节省内存（struct{} 不占空间，bool 占 1 字节）。 语法清晰（用 _, exists := set[key] 判断存在性）。 func main() { set := make(map[string]struct{}) // 添加元素 set["apple"] = struct{}{} set["banana"] = struct{}{} // 检查元素是否存在 if _, ok := set["apple"]; ok { fmt.Println("apple exists") } } 3. 作为方法接收器（节省内存） 当方法不需要访问结构体字段时，可用空结构体作为接收器，避免为无状态操作分配额外内存。典型场景： 工具类方法（如单例模式）。 接口实现（仅需方法，不存储数据）。 type NullReceiver struct{} // 无状态方法 func (n NullReceiver) Print(msg string) { fmt.Println(msg) } func main() { var nr NullReceiver nr.Print("Hello, 空结构体接收器!") } 是的！使用空结构体作为 context.Context 的键（Key） 是 Go 中的另一个经典应用场景，尤其在需要高效、无内存占用的上下文值传递时。以下是详细说明和示例： 4. 作为 context.Context 的键（Context Key） 4.1. 1 为什么用空结构体？ 零内存开销：context.WithValue 的键通常是 interface{} 类型，但键本身的值会被存储。如果用空结构体作为键的类型（而非值），可以避免为键分配额外内存。 类型安全：通过自定义空结构体类型，避免键的字符串或基本类型冲突。 4.2. 示例代码 package main import ( "context" "fmt" ) // 定义自定义空结构体类型作为键的类型 type privateKey struct{} // 全局唯一的键（避免字符串键的命名冲突） var key = &privateKey{} func main() { ctx := context.Background() // 存储值到上下文（键是 key，值是 "secret"） ctx = context.WithValue(ctx, key, "secret") // 从上下文获取值 if val := ctx.Value(key); val != nil { fmt.Println("获取到的值:", val) // 输出: 获取到的值: secret } } 4.3. 关键点 避免键冲突：如果用字符串（如 ctx.WithValue(ctx, "my-key", value)），其他包可能意外覆盖你的键。而自定义空结构体类型是全局唯一的（指针地址唯一），天然防冲突。 极简内存：键 key 是一个指向空结构体的指针，但空结构体本身不占内存（privateKey{} 大小为 0），仅存储指针地址。 对比其他实现： 用 string 作为键：占用内存（字符串长度）。 用 int 作为键：可能与其他包的数字键冲突。 空结构体指针：最优解。 5. 其他类似场景 作为同步原语的标记：例如在 sync.WaitGroup 或 sync.Pool 中，空结构体可用于标记状态（尽管实际较少见）。 装饰器模式的无状态占位： 在需要实现接口但无需数据的装饰器中，空结构体可作为轻量级接收器。 6. 总结表：空结构体的四大应用 场景 代码示例 优势 通道信号 chan struct{} 零内存事件通知 集合（Set） map[T]struct{} 比 map[T]bool 更省内存 方法接收器 func (s struct{}) Method() 无状态方法节省内存 Context 键 ctx.WithValue(&key{}, value) 防冲突、零内存开销 空结构体在 Go 中是一种“零成本抽象”的利器，合理使用能提升性能、减少资源浪费，同时保持代码简洁。
1. 方式一：Sleep package main import "time" func main() { go func() {}() time.Sleep(time.Second) } 这种方式，主协程为了等待子协程结束，阻塞了 1s 时间。 好处是：主协程什么时候解除阻塞，不依赖子协程 缺点是：主协程需要等待多久不好确定 2. 方案二：WaitGroup func main() { wg := sync.WaitGroup{} wg.Add(1) go func() { // do something //如果忘记这个的话，主协程永远阻塞，runtime判断没事可错了，不如直接死锁panic wg.Done() }() wg.Wait() } 这种方式，主协程的解除阻塞条件依赖于子协程 3. 方案三：Channel func main() { // 零缓冲channel finish := make(chan struct{}) go func() { // do something //如果忘记这个的话，主协程永远阻塞，runtime判断没事可错了，不如直接死锁panic finish <- struct{}{} }() <-finish } 这种方式依赖于读取无缓冲通道会阻塞的特性，主协程依赖于子协程 4. 方案四：死循环 package main import "time" func main() { go func() { // do something }() for { time.Sleep(time.Second) } } 死循环里必须 sleep，不然的话比较消耗 cpu。for {} 是一个 忙等待（busy loop），会导致一个逻辑 CPU 核心持续满载（即使 goroutine 里没有实际工作） 5. 方案五：Select package main import "time" func main() { go func() { // do something // 如果没有一直执行的话，主协程永远阻塞，runtime判断没事可错了，不如直接死锁panic for { time.Sleep(time.Second) } }() select {} } 通过 select{} 来永久阻塞，但 不消耗 CPU，同时允许其他 goroutine 执行。 6. 方案六：锁 package main import ( "sync" "time" ) func main() { mutex := sync.Mutex{} // 第一次顺利抢到锁 mutex.Lock() go func() { // do something time.Sleep(5 * time.Second) //如果忘记这个的话，主协程永远阻塞，runtime判断没事可错了，不如直接死锁panic mutex.Unlock() // 锁释放 }() // 第二次拿不到锁，等待锁释放 mutex.Lock() }
req - Go语言HTTP客户端库 | 简单易用 & 强大功能 | imroc/req
今天介绍一个可以跟fasthttp比肩的http库imroc/req 一、简介 imroc/req 是一个用于 Golang 的 HTTP 请求客户端库，其设计理念类似于 Python 的 Requests 库。该库提供了简单、直观的 API，使得开发者能够快速构造并发送 HTTP 请求，同时方便地处理响应数据。它适用于处理各种常见的 HTTP 请求场景，包括 GET、POST、PUT、DELETE 等，同时支持 JSON、表单数据、文件上传等多种内容类型。 二、特点 简单易用的 API 通过链式调用和简化的函数接口，极大地降低了发送 HTTP 请求的门槛，代码更加清晰易读。 丰富的数据处理能力 内置对 JSON 数据的自动编码与解码支持，能够快速将响应数据绑定到结构体或 Map 中，方便后续处理。 多种请求方式支持 支持 GET、POST、PUT、DELETE、PATCH 等常见的 HTTP 方法，可以轻松应对各类接口请求。 灵活的请求配置 可以通过设置 Header、Query 参数、Cookies 等自定义 HTTP 请求，同时支持超时、代理、重试等高级配置。 文件上传和多部分表单 内置对多部分表单数据（multipart/form-data）的支持，方便实现文件上传等需求。 错误处理与调试 对网络错误、超时等情况进行了良好的封装，同时支持调试模式，帮助开发者快速定位问题。 三、安装 使用 go get 命令即可安装该库： go get -u github.com/imroc/req 四、基本用法 1. GET 请求 下面示例展示了如何使用 req 发送一个 GET 请求，并将返回的 JSON 数据解析到一个 Map 中： package main import ( "fmt" "log" "github.com/imroc/req" ) func main() { // 发送 GET 请求，并带上自定义 Header resp, err := req.Get("https://httpbin.org/get", req.Header{ "User-Agent": "imroc-req", }) if err != nil { log.Fatalf("请求失败: %v", err) } // 将响应的 JSON 数据解析到 map 中 var result map[string]interface{} err = resp.ToJSON(&result) if err != nil { log.Fatalf("解析 JSON 失败: %v", err) } fmt.Println("GET 请求返回数据:", result) } 2. POST 请求 使用 POST 请求时，可以通过内置方法自动将数据编码为 JSON 或表单数据： package main import ( "fmt" "log" "github.com/imroc/req" ) func main() { // 构造 JSON 请求体 payload := req.BodyJSON(map[string]string{ "name": "imroc", "lang": "go", }) // 发送 POST 请求 resp, err := req.Post("https://httpbin.org/post", payload) if err != nil { log.Fatalf("请求失败: %v", err) } // 输出响应内容 fmt.Println("POST 请求返回:", resp) } 3. 文件上传 通过 multipart 表单上传文件同样十分简单： package main import ( "fmt" "log" "os" "github.com/imroc/req" ) func main() { // 打开需要上传的文件 file, err := os.Open("example.txt") if err != nil { log.Fatalf("打开文件失败: %v", err) } defer file.Close() // 构造 multipart/form-data 的请求体，其中 req.File 用于指定文件内容及文件名 payload := req.BodyFormMultipart(req.Param{ "file": req.File{ File: file, FileName: "example.txt", }, "description": "这是一个文件上传示例", }) // 发送 POST 请求 resp, err := req.Post("https://httpbin.org/post", payload) if err != nil { log.Fatalf("请求失败: %v", err) } fmt.Println("文件上传返回:", resp) } 五、进阶 1. 超时和代理 可以通过 req.SetTimeout、req.SetProxy 等方法设置全局超时或者代理。例如： package main import ( "fmt" "log" "time" "github.com/imroc/req" ) func main() { // 设置请求超时时间为 10 秒 req.SetTimeout(10 * time.Second) // 设置 HTTP 代理 req.SetProxy("http://127.0.0.1:8080") resp, err := req.Get("https://httpbin.org/get") if err != nil { log.Fatalf("请求失败: %v", err) } fmt.Println("返回结果:", resp) } 2. 全局 Header 与 Cookie 如果有需要为每个请求添加一些公共 Header 或 Cookie，也可以通过设置全局配置来实现： package main import ( "fmt" "log" "github.com/imroc/req" ) func main() { // 设置全局 Header req.SetCommonHeader(req.Header{ "Authorization": "Bearer your-token", "Accept": "application/json", }) // 设置全局 Cookie req.SetCommonCookie("sessionid=your-session-id") resp, err := req.Get("https://httpbin.org/get") if err != nil { log.Fatalf("请求失败: %v", err) } fmt.Println("全局 Header 和 Cookie 返回:", resp) } 3. 请求调试模式 在开发过程中，如果需要查看请求和响应的详细信息，可以开启调试模式： package main import ( "github.com/imroc/req" ) func main() { // 开启调试模式，会打印详细的请求和响应日志 req.Debug = true _, _ = req.Get("https://httpbin.org/get") } 六、简单总结一下 易用性：imroc/req 以简洁直观的 API 帮助开发者快速发送 HTTP 请求，类似于 Python 的 Requests 库，降低了使用门槛。 丰富功能：支持 JSON 编码解码、表单提交、文件上传、全局设置等常见需求，同时可灵活定制请求参数、超时、代理等高级配置。 适用场景：适用于各种需要 HTTP 通信的应用场景，如 API 调用、数据抓取、自动化测试以及微服务之间的通信等。 总体而言，imroc/req 是一个轻量级且功能强大的 HTTP 客户端库，非常适合在 Golang 项目中使用。
Go开源包asynq：基于Redis的简单、可靠、高效的分布式任务队列
今天给大家推荐一个基于 redis实现的简单、可靠且高效的分布式任务队列：asynq。该队列出自谷歌员工Ken Hibino。 项目的开源地址：https://github.com/hibiken/asynq ，星标star 6.1k，目前有29位贡献者。 asynq的架构设计 基于redis的集群，支持哨兵模式。因此具备了存储可横向扩展及高可用性。以下是asynq运行的顶层设计原理： 客户端将任务入队。 服务端从队列拉取任务，并对每个任务启动一个协程（称之为worker）进行处理。 队列中的任务能够被多个worker并发处理。 以下是该包的架构图： 主要特点 保证每个任务至少被消费一次。 支持定时任务 支持任务消费失败重试 支持延时队列功能 支持redis集群模式（可横向扩展）和Sentinals（高可用）模式 支持批量消费任务 自带完善的监控功能 接下来，我们通过基于async包来编写一个客户端程序和一个服务端程序，用于发送消息和消费消息。 安装包 通过go get命令安装包 go get -u github.com/hibiken/asynq 核心数据类型 首先，我们来看一下在使用该包时要用到的两个核心数据类型， redis连接配置类型：RedisClientOpt asynq包使用Redis作为消息代理，我们称之为 message broker。在和redis建立连接时，通过该类型来自定和redis建立连接的一些属性。比如redis的地址、密码等。 redisConnOpt := async.RedisClientOpt{ Addr: "localhost:6379", // Omit if no password is required Password: "mypassword", // Use a dedicated db number for asynq. // By default, Redis offers 16 databases (0..15) DB: 0, } 任务: Tasks 在 asynq中，一个工作单元被包装成一个 Task，在 Task类型中有两个字段：Type和 Payload。 // Type是一个字符串类型，代表该任务的类型. func (t *Task) Type() string // Payload 是消息体. func (t *Task) Payload() []byte 生产任务 往队列中发送消息包括3步：和redis建立连接、构建任务Task、发送Task到redis。 和redis建立连接 通过 NewClient函数和 redis建立连接 client := asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"}) 支持redis集群 在和redis建立连接时，通过 asynq.RedisClusterClientOpt结构体配置集群。如下： client := asynq.NewClient(asynq.RedisClusterClientOpt{ Addrs: []string{":7000", ":7001", ":7002", ":7003", ":7004", ":7005"}, }) 同样，在消费侧和redis建立连接的时候也采用集群模式。 支持redis哨兵模式 为了支持高可用性，在出现故障时redis服务能够起到自动恢复的目的，asynq包还支持redis的哨兵模式。如下： var redis = &asynq.RedisFailoverClientOpt{ MasterName: "mymaster", SentinelAddrs: []string{"localhost:5000", "localhost:5001", "localhost:5002"}, } client := asynq.NewClient(redis) 构建任务 通过NewTask构建任务。但在构建任务之前，需要先根据自己的业务定义消息体的结构。这里我们定义了一个 EmailTaskPayload的消息体结构，被消费时用于业务处理的数据。 type EmailTaskPayload struct { // ID for the email recipient. UserID int } payload, err := json.Marshal(EmailTaskPayload{UserID: 42}) //构建了2个任务，一个任务类型为"email:welcome" t1 := asynq.NewTask("email:welcome", payload) //构建任务，任务类型是 "email:reminder" t2 := asynq.NewTask("email:reminder", payload) 将任务发送至队里 通过 Enqueue函数就能将任务发送至队列，并且会被立即消费。 info, err := client.Enqueue(t1) 指定任务被消费的时间 有时候，我们不希望任务立即被消费，而是等待一定的时间后再被消费，也就是常说的延时消费或延时队列。在asynq包中有两种方法可以实现。 特定时间段后再被消费 通过 ProcessIn函数指定一个时间段，如下，代表是在24小时后再被消费。 info, err = client.Enqueue(t2, asynq.ProcessIn(24*time.Hour)) 通过 ProcessAt函数指定一个具体的时间点，如下，也是代表在24小时后再被消费。 info, err = client.Enqueue(t2, asynq.ProcessAt(time.Now().Add(24*time.Hour)) 指定任务保存时间 通过 Retention函数指定一个时间段，代表当任务被处理完后，还可以保留一定的时间。如下是当task3被成功消费后再保留2个小时。 client.Enqueue(task3, asynq.Retention(2*time.Hour)) 指定入队的队列 通过 Queue选项函数指定将消息发送至哪个队列。如下，将消息发送至redis的名为“high”的队列中。 client.Enqueue(t2, asynq.Queue("high")) 消费任务 要消费redis队列中的任务，首先要和redis建立连接，我们称之为server。通过以下方式和redis服务实例以及要订阅的队列进行关联。 func main() { srv := asynq.NewServer( asynq.RedisClientOpt{Addr: "localhost:6379"}, ) // NOTE: We'll cover what this `handler` is in the section below. if err := srv.Run(handler); err != nil { log.Fatal(err) } } 指定要消费的队列 比如，我们要消费redis中的名为"high"、"slow"两个队列，那么通过asynq.Config结构体指定即可。如下： srv := asynq.NewServer( asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{ Queues: map[string]int{ "high": 6, "slow": 4, } }, ) 队列中的6和4代表两个队列的优先级别。 指定worker数量 在NewServer中，通过 asynq.Config结构体中的 Concurrency字段，可以指定启动的worker数量。多个worker可以并发的消费队列中的任务。如下，代表启动10个worker。如果不指定该配置参数，默认启动的worker数量是服务器的CPU个数。 srv := asynq.NewServer( asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{ Concurrency: 10, Queues: map[string]int{ "high": 6, "slow": 4, } }, ) 指定消费处理器 从redis队列中获取到任务后应该如何被处理呢？就是在执行server的Run方法时指定一个 Handler，该 Handler是一个接口类型，需要实现如下接口： type Handler interface { ProcessTask(context.Context, *Task) error } 接下来，我们定义一个实现了该接口的类型handler，在该handler中执行具体的业务处理。在该handler中，根据接收到的任务类型做不同的处理。 func handler(ctx context.Context, t *asynq.Task) error { switch t.Type() { case "email:welcome": var p EmailTaskPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { return err } log.Printf(" [*] Send Welcome Email to User %d", p.UserID) case "email:reminder": var p EmailTaskPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { return err } log.Printf(" [*] Send Reminder Email to User %d", p.UserID) default: return fmt.Errorf("unexpected task type: %s", t.Type()) } return nil } 然后，启动Server，指定该handler。如下： func main() { srv := asynq.NewServer( asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{Concurrency: 10}, ) // Use asynq.HandlerFunc adapter for a handler function if err := srv.Run(asynq.HandlerFunc(handler)); err != nil { log.Fatal(err) } } 通过asynq包的Mux注册处理器 上面的任务消费的handler的代码可读性比较低。在asynq包中还提供了一个 Mux类型，用于进行注册任务类型和对应的消费逻辑的功能。如下： func main() { srv := asynq.NewServer( asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{Concurrency: 10}, ) mux := asynq.NewServeMux() mux.HandleFunc("email:welcome", sendWelcomeEmail) mux.HandleFunc("email:reminder", sendReminderEmail) if err := srv.Run(mux); err != nil { log.Fatal(err) } } 在该代码中，通过 mux.HandleFunc函数分别注册了任务类型 email:welcome的任务的处理逻辑是 sendWelcomeEmail函数；任务类型是 email:reminder的任务的处理逻辑是 sendReminderEmail函数。 func sendWelcomeEmail(ctx context.Context, t *asynq.Task) error { var p EmailTaskPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { return err } log.Printf(" [*] Send Welcome Email to User %d", p.UserID) return nil } func sendReminderEmail(ctx context.Context, t *asynq.Task) error { var p EmailTaskPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { return err } log.Printf(" [*] Send Reminder Email to User %d", p.UserID) return nil } 批量处理任务 asynq包还支持批量处理任务。即在入队上将多个任务发送到相同的组和相同的队列。在消费的时候就可以通过指定的聚合函数将多个任务聚合成一个任务，再发送给任务处理器进行处理。 当达到以下两个中的任意一个条件时，就可以进行聚合并发送给任务处理器： 最大聚合时间：若达到了一定的时间，则无论当前组任务有多少，都会进行一次聚合。 最大聚合任务数：若还没有达到聚合时间的情况下，已经有指定数量的任务存在了，则也进行聚合。 指定任务所属组 在发送任务时，通过Group函数指定任务所属组： // Enqueue three tasks to the same group. client.Enqueue(task1, asynq.Queue("notifications"), asynq.Group("user1:email")) client.Enqueue(task2, asynq.Queue("notifications"), asynq.Group("user1:email")) client.Enqueue(task3, asynq.Queue("notifications"), asynq.Group("user1:email")) 指定任务聚合相关配置 在进行消费启动服务时，可以指定聚合相关的函数选项。如下： // This function is used to aggregate multiple tasks into one. func aggregate(group string, tasks []*asynq.Task) *asynq.Task { // ... Your logic to aggregate the given tasks and return the aggregated task. // ... Use NewTask(typename, payload, opts...) to create a new task and set options if needed. // ... (Note) Queue option will be ignored and the aggregated task will always be enqueued to the same queue the group belonged. } srv := asynq.NewServer( redisConnOpt, asynq.Config{ GroupAggregator: asynq.GroupAggregatorFunc(aggregate), //指定聚合函数为aggregate GroupMaxDelay: 10 * time.Minute, //最晚10分钟聚合一次 GroupGracePeriod: 2 * time.Minute, //每2分钟聚合一次 GroupMaxSize: 20, // 每20个任务聚合一次 Queues: map[string]int{"notifications": 1}, }, ) // 相同组的聚合函数，最终将多个任务组成一个任务，发送个任务处理器 func aggregate(group string, tasks []*asynq.Task) *asynq.Task { log.Printf("Aggregating %d tasks from group %q", len(tasks), group) var b strings.Builder for _, t := range tasks { b.Write(t.Payload()) b.WriteString("\n") } return asynq.NewTask("aggregated-task", []byte(b.String())) } 监控 一个完善的队列系统，监控是必不可少的。asynq包还配备了两种形式的监控：webUI和命令行工具。 webUI监控 webUI监控是通过开源的asynqmon包实现的，地址是https://github.com/hibiken/asynqmon。如下图所示： 队列视角 任务视角 命令行监控 另外一种就是命令行监控。通过go install安装asynq工具，如下： go install github.com/hibiken/asynq/tools/asynq 然后在终端输入命令 asynq dash 启动 命令行的dashboard。效果如下： 好了，以上就是给大家分享的基于redis的分布式队列包。更多功能请查看文档：https://github.com/hibiken/asynq/wiki
提到本地缓存大家都不陌生，只要是个有点经验的后台开发人员，都知道缓存的作用和弊端。本篇文章我们就来简单聊聊在golang做业务开发的过程中，本地缓存的一些可选的开源方案。分析它们的特点，以及内部的实现原理。 1.本地缓存需求分析 首先来梳理一下业务开发过程中经常面临的本地缓存的一些需求。我们一般做缓存就是为了能提高系统的读写性能，缓存的命中率越高，也就意味着缓存的效果越好。其次本地缓存一般都受限于本地内存的大小，所有全量的数据一般存不下。那基于这样的场景一方面是想缓存的数据越多，则命中率理论上也会随着缓存数据的增多而提高；另外一方面是想，既然所有的数据存不下那就想办法利用有限的内存存储有限的数据。这些有限的数据需要是经常访问的，同时有一定时效性(不会频繁改变)的。基于这两个点展开，我们一般对本地缓存会要求其满 足支持过期时间、支持淘汰策略。最后再使用自动管理内存的语言例如golang等开发时，还需要考虑在加入本地缓存后引发的GC问题。 分析完我们日常本地缓存的诉求，再结合我们日常开发用到的golang语言，我们可以提炼得到golang本地缓存组件必须具备以下几个能力： 分析清楚了我们的需求，也明确了我们需要的能力。那自然优先考虑golang内置的标准库中是否存在这样的组件可以直接使用呢？ 很遗憾，没有。golang中内置的可以直接用来做本地缓存的无非就是map和sync.Map。而这两者中，map是非并发安全的数据结构，在使用时需要加锁；而sync.Map虽然是线程安全的。但是需要在并发读写时加锁。此外二者均无法支持数据的过期和淘汰，同时在存储大量数据时，又会产生比较频繁的GC问题，更严重的情况下导致线上服务无法稳定运行。 既然标准库中没有我们满足上述需求的本地缓存组件，那我们就想只有两种解决方案了 业界是否有开源成熟的方案可供选择 业界无可用组件时，自己动手写一个 那首先面临的第一个问题就是方案的调研和选型，没有合适的方案时自己再来动手构建。下面我们就来给大家介绍下golang中哪些可以直接来使用的本地缓存组件吧。 2.golang本地缓存组件概览 golang中本地缓存方案可选的有如下一些： 1. freecache 2. bigcache 3. fastcache 4. offheap 5. groupcache 6. ristretto 下面通过笔者一段时间的调研和研究，将golang可选的开源本地缓存组件汇总为下表，方便大家在方案选型时作参考。 在上述方案中，freecache、bigcache、fastcache、ristretto、groupcache这几个大家根据实际的业务场景首选，offheap有定制需求时可考虑。 通过上表的总结，个人想在此再谈几点关于本地缓存组件的理解： 1.上述本地缓存组件中，实现零GC的方案主要就两种： a.无GC：分配堆外内存(Mmap) b.避免GC：map非指针优化(map[uint64]uint32)或者采用slice实现一套无指针的map c.避免GC：数据存入[]byte slice(可考虑底层采用环形队列封装循环使用空间) 2.实现高性能的关键在于： a.数据分片(降低锁的粒度) 上述几种缓存组件每种组件的实现都是1和2的几个分支的组合。下面我们大概给大家介绍每种组件的核心原理。 3. 主流缓存组件实现原理剖析 在本节中我们会重点分析下freecache、bigcache、fastcache、offheap这几个组件内部的实现原理。 3.1 freecache实现原理 首先分析下freecache的内部实现原理。在freecache中它通过segment来进行对数据分片，freecache内部包含256个segment，每个segment维护一把互斥锁，每一条kv数据进来后首先会根据k进行计算其hash值，然后根据hash值决定当前的这条数据落入到哪个segment中。 对于每个segment而言，它由索引、数据两部分构成。 索引：其中索引最简单的方式采用map来维护，例如map[uint64]uint32这种。而freecache并没有采用这种做法，而是通过采用slice来底层实现一套无指针的map，以此避免GC扫描。 数据：数据采用环形缓冲区来循环使用，底层采用[]byte进行封装实现。数据写入环形缓冲区后，记录写入的位置index作为索引，读取时首先读取数据header信息，然后再读取kv数据。 在freecache中数据的传递过程是：freecache->segment->(slot,ringbuffer) 下图是freecache的内部实现框架图。 总结： freecache通过利用数据分片减小锁的粒度，然后再存储时索引并没有采用内置的map来维护而是采用自建map减少指针来避免GC，同时数据存储时采用预先分配内存然后后边循环使用。通过上述两种方法保证了在堆上分配内存同时减少GC对系统性能的影响。 3.2 bigcache实现原理 bigcache和freecache类似，也是一个零GC、高性能的cache组件，但是它的实现和freecache还是有些差异，这儿有篇英文博客介绍bigcache设计原理的，内容稍长感兴趣的可以阅读下，下面我们介绍一下bigcache的实现原理。 bigcache同样是采用分片的方式构成，一个bigcache对象包含2n 个cacheShard对象，默认是1024个。每个cacheShard对象维护着一把sync.RWLock锁(读写锁)。所有的数据会分散到不同的cacheShard中。 每个cacheShard同样由索引和数据构成。索引采用map[uint64]uint32来存储，数据采用entry([]byte)环形队列存储。索引中存储的是该条数据在entryBuffer写入的位置pos。每条kv数据按照TLV的格式写入队列。 不过值得注意的是，和bigcache和freecache不同的一点在于它的环形队列可以自动扩容。同时bigcache中数据的过期是通过全局的时间窗口维护的，每个单独的kv无法设置不同的过期时间。 下面是bigcache的内容实现原理框架图。 总结：bigcache思路和freecache大体相同，只不过在索引存储时更为巧妙，直接采用内置的map结构加上基础数据类型来实现。同时底层存储数据的队列也可以根据空间大小来决定是否扩容。唯一的缺陷是无法针对每个key进行设置不同的过期时间。这个个人认为如果想用bigcache同时想要这个特性，可以进行二次开发一下。 通过性能测试数据来看，bigcache性能要比freecache稍微好一点。大家可以思考下他们性能的差异可能会在哪里呢？ 3.3 fastcache实现原理 本节介绍下fastcache的实现原理，根据fastcache官方文档介绍，它的灵感来自于bigcache。所以整体的思路和bigcache很类似，数据通过bucket进行分片。fastcache由512个bucket构成。每个bucket维护一把读写锁。在bucket内部数据同理是索引、数据两部分构成。索引用map[uint64]uint64存储。数据采用chunks二维的切片(二维数组)存储。不过值得注意的是fastcache有一个很大的特性是，它的内存分配是在堆外分配的，而不是在堆上分配的。堆外分配的内存。这样做也就避免了golang GC的影响。下图是fastcache内部实现框架图。 总结： fastcache一方面充分利用了分片来降低锁的粒度，另一方面在索引存储时采用了对map的优化，同时在分配内存时，直接从堆外申请内存，自己实现了分配和释放内存的逻辑。通过上述手段使得GC的影响降到了最低。fastcache唯一的缺陷是官方提供的版本没有提供针对kv数据的过期时间这个特性。所以如果需要这个特性的话，需要自己动手二次开发。整体从性能上来看是比bigcache和freecache都更优。 3.4 offheap实现原理 本节介绍下offheap的相关内容，offheap其实功能就比较简单了，就是一个基于堆外内存构建的哈希表。它通过直接调用系统调用函数来分配内存。然后在内部通过数组来实现哈希表。实现过程中当发生哈希冲突时，它是采用探测法来解决。由于是在堆外分配的内存上构建的哈希表。导致它的GC开销非常的小。下图是offheap的内部实现框架图。 总结：offheap内部由于是采用探测法解决哈希冲突的，所以当哈希冲突严重时数据删除、查询都会带来非常复杂的处理流程。而且性能也会有一些损耗。可以作为学习和研究的项目还是非常不错的。 4.总结 本文主要从日常需求出发，分析了日常业务过程中对本地缓存的需求，再调研了业界可选的一些组件并进行了对比，希望对本地缓存选型上起到一些参考和帮助。最后再对其中比较重要的几个组件如freecache、bigcache、fastcache、offheap等做了内部实现的简单介绍。上述内容只是从架构层面展开介绍，后续有时间再从源码层面做一些分析。由于篇幅限制本篇内容并未对map、sync.Map、go-cache、groupcache进行介绍。感兴趣的读者可以自行搜索资料进行阅读。如果大致理解了上述原理的童鞋也可以自己动手实践起来，造个轮子看看。 5.参考资料 Writing a very fast cache service with millions of entries in Go Go语言入门学习之Groupcache源码分析 深入理解Freecache 如何打造高性能的Go缓存库 freecache和bigcache性能测试数据对比
介绍 我们在日常开发中，常常会对JSON进行序列化和反序列化。Golang提供了encoding/json包对JSON进行Marshal/Unmarshal操作。但是在大规模数据场景下，该包的性能和开销确实会有点不够看。在生产环境下，JSON 序列化和反序列化会被频繁的使用到。在测试中，CPU使用率接近 10%，其中极端情况下超过 40%。因此，JSON 库的性能是提高机器利用率的关键问题。 Sonic是一款由字节跳动开发的一个全新的高性能、适用广泛的 JSON 库。在设计上借鉴了多款JSON库，同时为了实现对标准库的真正插拔式替换，Sonic使用了 JIT** （即时编译）**。 Sonic的特色 我们可以看出：Sonic是一个主打快的JSON库。 运行时对象绑定，无需代码生成 完备的 JSON 操作 API 快，更快，还要更快！ Sonic的设计 针对编解码动态汇编的函数调用开销，使用 JIT 技术在运行时组装与模式对应的字节码（汇编指令），最终将其以 Golang 函数的形式缓存在堆外内存上。 针对大数据和小数据共存的实际场景，使用预处理判断（字符串大小、浮点数精度等）将 SIMD 与标量指令相结合，从而实现对实际情况的最佳适应。 对于 Golang 语言编译优化的不足，使用 C/Clang 编写和编译核心计算函数，并且开发了一套 asm2asm 工具，将经过充分优化的 x86 汇编代码转换为 Plan9 格式，最终加载到 Golang 运行时中。 考虑到解析和跳过解析之间的速度差异很大， 惰性加载机制当然也在 AST 解析器中使用了，但以一种更具适应性和高效性的方式来降低多键查询的开销。 在细节上，Sonic进行了一些进一步的优化： 由于 Golang 中的原生汇编函数不能被内联，发现其成本甚至超过了 C 编译器的优化所带来的改善。所以在 JIT 中重新实现了一组轻量级的函数调用： 全局函数表+静态偏移量，用于调用指令 使用寄存器传递参数 Sync.Map 一开始被用来缓存编解码器，但是对于准静态（读远多于写），元素较少（通常不足几十个）的场景，它的性能并不理想，所以使用开放寻址哈希和 RCU 技术重新实现了一个高性能且并发安全的缓存。 详细设计文档可查阅introduction 安装使用 当前我使用的go version是1.21。 官方建议的版本: Go 1.16~1.21 Linux / MacOS / Windows（需要 Go1.17 以上） Amd64 架构 # 下载sonic依赖 $ go get github.com/bytedance/sonic 基本使用 sonic提供了许多功能。本文仅列举其中较为有特色的功能。感兴趣的同学可以去看一下官方的examples 序列化/反序列化 sonic的使用类似于标准包encoding/json包的使用. func base() { m := map[string]interface{}{ "name": "z3", "age": 20, } // sonic序列化 byt, err := sonic.Marshal(&m) if err != nil { log.Println(err) } fmt.Printf("json: %+v\n", string(byt)) // sonic反序列化 um := make(map[string]interface{}) err = sonic.Unmarshal(byt, &um) if err != nil { log.Println(err) } fmt.Printf("unjson: %+v\n", um) } // print // json: {"name":"z3","age":20} // unjson: map[age:20 name:z3] sonic还支持流式的输入输出 Sonic 支持解码 io.Reader 中输入的 json，或将对象编码为 json 后输出至 io.Writer，以处理多个值并减少内存消耗 func base() { m := map[string]interface{}{ "name": "z3", "age": 20, } // 流式io编解码 // 编码 var encbuf bytes.Buffer enc := sonic.ConfigDefault.NewEncoder(&encbuf) if err := enc.Encode(m); err != nil { log.Fatal(err) } else { fmt.Printf("cutomize encoder: %+v", encbuf.String()) } // 解码 var decbuf bytes.Buffer decbuf.WriteString(encbuf.String()) clear(m) dec := sonic.ConfigDefault.NewDecoder(&decbuf) if err := dec.Decode(&m); err != nil { log.Fatal(err) } else { fmt.Printf("cutomize decoder: %+v\n", m) } } // print // cutomize encoder: {"name":"z3","age":20} // cutomize decoder: map[age:20 name:z3] 配置 在上面的自定义流式编码解码器，细心的朋友可能看到我们创建编码器和解码器的时候，是通过sonic.ConfigDefault.NewEncoder() / sonic.ConfigDefault.NewDecoder()这两个函数进行调用的。那么sonic.ConfigDefault是什么？ 我们可以通过查看源码: var ( // ConfigDefault is the default config of APIs, aiming at efficiency and safty. // ConfigDefault api的默认配置,针对效率和安全。 ConfigDefault = Config{}.Froze() // ConfigStd is the standard config of APIs, aiming at being compatible with encoding/json. // ConfigStd是api的标准配置，旨在与encoding/json兼容。 ConfigStd = Config{ EscapeHTML : true, SortMapKeys: true, CompactMarshaler: true, CopyString : true, ValidateString : true, }.Froze() // ConfigFastest is the fastest config of APIs, aiming at speed. // ConfigFastest是api的最快配置，旨在提高速度。 ConfigFastest = Config{ NoQuoteTextMarshaler: true, }.Froze() ) sonic提供了三种常用的Config配置。这些配置中对一些场景已经预定义好了对应的Config。 其实我们使用的sonic.Marshal()函数就是调用了默认的ConfigDefault // Marshal returns the JSON encoding bytes of v. func Marshal(val interface{}) ([]byte, error) { return ConfigDefault.Marshal(val) } // Unmarshal parses the JSON-encoded data and stores the result in the value pointed to by v. // NOTICE: This API copies given buffer by default, // if you want to pass JSON more efficiently, use UnmarshalString instead. func Unmarshal(buf []byte, val interface{}) error { return ConfigDefault.Unmarshal(buf, val) } 但是在一些场景下我们不满足于sonic预定义的三个Config。此时我们可以定义自己的Config进行个性化的编码和解码。 首先先看一下Config的结构。 // Config is a combination of sonic/encoder.Options and sonic/decoder.Options type Config struct { // EscapeHTML indicates encoder to escape all HTML characters // after serializing into JSON (see https://pkg.go.dev/encoding/json#HTMLEscape). // WARNING: This hurts performance A LOT, USE WITH CARE. EscapeHTML bool // SortMapKeys indicates encoder that the keys of a map needs to be sorted // before serializing into JSON. // WARNING: This hurts performance A LOT, USE WITH CARE. SortMapKeys bool // CompactMarshaler indicates encoder that the output JSON from json.Marshaler // is always compact and needs no validation CompactMarshaler bool // NoQuoteTextMarshaler indicates encoder that the output text from encoding.TextMarshaler // is always escaped string and needs no quoting NoQuoteTextMarshaler bool // NoNullSliceOrMap indicates encoder that all empty Array or Object are encoded as '[]' or '{}', // instead of 'null' NoNullSliceOrMap bool // UseInt64 indicates decoder to unmarshal an integer into an interface{} as an // int64 instead of as a float64. UseInt64 bool // UseNumber indicates decoder to unmarshal a number into an interface{} as a // json.Number instead of as a float64. UseNumber bool // UseUnicodeErrors indicates decoder to return an error when encounter invalid // UTF-8 escape sequences. UseUnicodeErrors bool // DisallowUnknownFields indicates decoder to return an error when the destination // is a struct and the input contains object keys which do not match any // non-ignored, exported fields in the destination. DisallowUnknownFields bool // CopyString indicates decoder to decode string values by copying instead of referring. CopyString bool // ValidateString indicates decoder and encoder to valid string values: decoder will return errors // when unescaped control chars(\u0000-\u001f) in the string value of JSON. ValidateString bool } 由于字段较多。笔者就选择几个字段进行演示，其他字段使用方式都是一致。 假设我们希望对JSON序列化按照key进行排序以及将JSON编码成紧凑的格式。我们可以配置Config进行Marshal操作 func base() { snc := sonic.Config{ CompactMarshaler: true, SortMapKeys: true, }.Froze() snc.Marshal(obj) } 考虑到排序带来的性能损失（约 10% ）， sonic 默认不会启用这个功能。 Sonic 默认将基本类型（ struct ， map 等）编码为紧凑格式的 JSON ，除非使用 json.RawMessage or json.Marshaler 进行编码： sonic 确保输出的 JSON 合法，但出于性能考虑，不会加工成紧凑格式。我们提供选项 encoder.CompactMarshaler 来添加此过程， Ast.Node sonic提供了Ast.Node的功能。Sonic/ast.Node 是完全独立的 JSON 抽象语法树库。它实现了序列化和反序列化，并提供了获取和修改通用数据的鲁棒的 API。 先来简单介绍一下Ast.Node：ast.Node 通常指的是编程语言中的抽象语法树（Abstract Syntax Tree）节点。抽象语法树是编程语言代码在编译器中的内部表示，它以树状结构展现代码的语法结构，便于编译器进行语法分析、语义分析、优化等操作。 在很多编程语言的编译器或解释器实现中，抽象语法树中的每个元素（节点）都会有对应的数据结构表示，通常这些数据结构会被称为 ast.Node 或类似的名字。每个 ast.Node 表示源代码中的一个语法结构，如表达式、语句、函数声明等。 抽象语法树的节点可以包含以下信息： 节点的类型：例如表达式、语句、函数调用等。 节点的内容：节点所代表的源代码的内容。 子节点：一些节点可能包含子节点，这些子节点也是抽象语法树的节点，用于构建更复杂的语法结构。 属性：一些节点可能会包含附加的属性，如变量名、操作符类型等。 我们通过几个案例理解一下Ast.Node的使用。 准备数据 data := `{"name": "z3","info":{"num": [11,22,33]}}` 将数据转换为Ast.Node 通过传入bytes或者string返回一个Ast.Node。其中你可以指定path获取JSON中的子路径元素。 每个路径参数必须是整数或者字符串 整数是目标索引(>=0)，表示以数组形式搜索当前节点。 字符串为目标key，表示搜索当前节点为对象。 // 函数签名: func Get(src []byte, path ...interface{}) (ast.Node, error) { return GetFromString(string(src), path...) } // GetFromString与Get相同，只是src是字符串，这样可以减少不必要的内存拷贝。 func GetFromString(src string, path ...interface{}) (ast.Node, error) { return ast.NewSearcher(src).GetByPath(path...) } 获取当前节点的完整数据 func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // no path return all json string root, err := sonic.GetFromString(data) if err != nil { log.Panic(err) } if raw, err := root.Raw(); err != nil { log.Panic(err) } else { log.Println(raw) } } // print // 2023/08/26 17:15:52 {"name": "z3","info":{"num": [11,22,33]}} 根据path或者索引获取数据 func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // no path return all json string root, err := sonic.GetFromString(data) if err != nil { log.Panic(err) } // according to path(根据key,查询当前node下的元素) if path, err := root.GetByPath("name").Raw(); err != nil { log.Panic(err) } else { log.Println(path) } // indexOrget (同时提供index和key进行索引和key的匹配) if path, err := root.IndexOrGet(1, "info").Raw(); err != nil { log.Panic(err) } else { log.Println(path) } // index (按照index进行查找当前node下的元素) // root.Index(1).Index(0).Raw()意味着 // root.Index(1) == "info" // root.Index(1).Index(0) == "num" // root.Index(1).Index(0).Raw() == "[11,22,33]" if path, err := root.Index(1).Index(0).Raw(); err != nil { log.Panic(err) } else { log.Println(path) } } // print // 2023/08/26 17:17:49 "z3" // 2023/08/26 17:17:49 {"num": [11,22,33]} // 2023/08/26 17:17:49 [11,22,33] Ast.Node支持链式调用。故我们可以从root node节点，根据path路径向下搜索指定的元素。 index和key混用 user := root.GetByPath("statuses", 3, "user") // === root.Get("status").Index(3).Get("user") 根据path进行修改数据 func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // no path return all json string root, err := sonic.GetFromString(data) if err != nil { log.Panic(err) } // according to path if path, err := root.GetByPath("name").Raw(); err != nil { log.Panic(err) } else { log.Println(path) } // indexOrget (同时提供index和key进行索引和key的匹配) if path, err := root.IndexOrGet(1, "info").Raw(); err != nil { log.Panic(err) } else { log.Println(path) } // index if path, err := root.Index(1).Index(0).Raw(); err != nil { log.Panic(err) } else { log.Println(path) } // set // ast提供了很多go类型转换node的函数 if _, err := root.Index(1).SetByIndex(0, ast.NewArray([]ast.Node{ ast.NewNumber("101"), ast.NewNumber("202"), })); err != nil { log.Panic(err) } raw, _ := root.Raw() log.Println(raw) } // print // 2023/08/26 17:23:55 "z3" // 2023/08/26 17:23:55 {"num": [11,22,33]} // 2023/08/26 17:23:55 [11,22,33] // 2023/08/26 17:23:55 {"name":"z3","info":{"num":[101,202]}} 序列化 func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // no path return all json string root, err := sonic.GetFromString(data) if err != nil { log.Panic(err) } bts, _ := root.MarshalJSON() log.Println("Ast.Node(Marshal): ", string(bts)) btes, _ := json.Marshal(&root) log.Println("encoding/json (Marshal): ", string(btes)) } // print // 2023/08/26 17:39:06 Ast.Node(Marshal): {"name": "z3","info":{"num": [11,22,33]}} // 2023/08/26 17:39:06 encoding/json (Marshal): {"name":"z3","info":{"num":[11,22,33]}} ⚠: 使用json.Marshal() （必须传递指向节点的指针） API Ast.Node提供了许多有特色的API，感兴趣的朋友可以去试一下。 合法性检查： Check(), Error(), Valid(), Exist() 索引： Index(), Get(), IndexPair(), IndexOrGet(), GetByPath() 转换至 go 内置类型： Int64(), Float64(), String(), Number(), Bool(), Map[UseNumber|UseNode](), Array[UseNumber|UseNode](), Interface[UseNumber|UseNode]() go 类型打包： NewRaw(), NewNumber(), NewNull(), NewBool(), NewString(), NewObject(), NewArray() 迭代： Values(), Properties(), ForEach(), SortKeys() 修改： Set(), SetByIndex(), Add() 最佳实践 预热 由于 Sonic 使用 golang-asm 作为 JIT 汇编器，这个库并不适用于运行时编译，第一次运行一个大型模式可能会导致请求超时甚至进程内存溢出。为了更好地稳定性，我们建议在运行大型模式或在内存有限的应用中，在使用 Marshal()/Unmarshal() 前运行 Pretouch()。 拷贝字符串 当解码 没有转义字符的字符串时， sonic 会从原始的 JSON 缓冲区内引用而不是复制到新的一个缓冲区中。这对 CPU 的性能方面很有帮助，但是可能因此在解码后对象仍在使用的时候将整个 JSON 缓冲区保留在内存中。实践中我们发现，通过引用 JSON 缓冲区引入的额外内存通常是解码后对象的 20% 至 80% ，一旦应用长期保留这些对象（如缓存以备重用），服务器所使用的内存可能会增加。我们提供了选项 decoder.CopyString() 供用户选择，不引用 JSON 缓冲区。这可能在一定程度上降低 CPU 性能 func base() { // 在sonic.Config中进行配置 snc := sonic.Config{ CopyString: true, }.Froze() } 传递字符串还是字节数组 为了和 encoding/json 保持一致，我们提供了传递 []byte 作为参数的 API ，但考虑到安全性，字符串到字节的复制是同时进行的，这在原始 JSON 非常大时可能会导致性能损失。因此，你可以使用 UnmarshalString() 和 GetFromString() 来传递字符串，只要你的原始数据是字符串，或零拷贝类型转换对于你的字节数组是安全的。我们也提供了 MarshalString() 的 API ，以便对编码的 JSON 字节数组进行零拷贝类型转换，因为 sonic 输出的字节始终是重复并且唯一的，所以这样是安全的。 零拷贝类型转换是一种技术，它允许你在不进行实际数据复制的情况下，将一种数据类型转换为另一种数据类型。这种转换通过操作原始内存块的指针和切片来实现，避免了额外的数据复制，从而提高性能并减少内存开销. 需要注意的是，零拷贝类型转换虽然可以提高性能，但也可能引入一些安全和可维护性的问题，特别是当直接操作指针或内存映射时。 性能优化 在 完全解析的场景下， Unmarshal() 表现得比 Get()+Node.Interface() 更好。 func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // complete parsing m := map[string]interface{}{} sonic.Unmarshal([]byte(data), &m) } 但是如果你只有特定 JSON的部分模式，你可以将 Get() 和 Unmarshal() 结合使用： func base() { data := `{"name": "z3","info":{"num": [11,22,33]}}` // complete parsing m := map[string]interface{}{} sonic.Unmarshal([]byte(data), &m) // partial parsing clear(m) node, err := sonic.GetFromString(data, "info", "num", 1) if err != nil { panic(err) } log.Println(node.Raw()) } 参考 github.com/sonic
概述 垃圾回收（Garbage Collection），简称 GC，对于高级语言是有必需的，因为高级语言要更加关注的是业务，不能花太多精力去关注底层内存释放的问题。 v 1.3 的标记清除算法 标记清除算法，可以概括为以下 4 步骤，循环重复，直到 process 程序生命周期结束： 暂停程序（不然期间会产生新的对象），从 main 根函数出发，找到可达对象和不可达对象。 然后将可达对象变标记上。 然后，清除未标记的对象。 恢复程序运行。 缺点： stw（stop the world）让程序暂停，会表现出卡顿现象，这个是最重要的问题 标记步骤，需要扫描整个 heap（堆），耗费时间多 清除数据，会产生 heap 碎片 在 go V1.3 之前使用的是这个算法。不过 go 做了点优化，为了缩短了 stw 的时间范围，将第三步和第四步骤做了替换。mark 标记完成后，就恢复程序的运行，清除工作和业务程序并行运行。 v 1.5 的三色标记算法 三色标记法有白、灰、黑三个颜色的列表。 白色的列表代表没有被遍历到的， 灰色代表正在被遍历的临时状态， 黑色代表是遍历过的。 经过遍历后，最终只会剩下白色和黑色的两种，把白色的不可达对象给清除掉即可。 工作过程如下所示： 第一步，只要是新创建的对象，默认的颜色都是标记为"白色"。所以，以下的这些对象都会在白色列表中。 第二步，每次 GC 回收开始，首先遍历根节点，非递归，只遍历一次。把遍历到的对象从白色集合放入"灰色"集合。 如下图中，根结点遍历会操作，会遍历到对象 1 和对象 4。然后把着两个对象放到灰色标记表里。 第三步，开始遍历灰色集合，将灰色对象引用的对象从白色集合放入灰色集合中，之后将已经遍历完成的灰色对象放入黑色集合。 如上图所示，遍历灰色 对象 1 和灰色 对象 4，得到他们的引用 对象 2 和 对象 7，然后把 对象 2 和 对象 7 放到灰色列表里，对象 1 和对象 4 放到黑色列表里。 第四步，重复第三步，直到灰色中无任何对象。遍历灰色列表里的对象 2 和对象 7，把对象 2 的引用对象 3 变成灰色，然后对象 2 和对象 7 已经遍历完就变成黑色。 重复几次后，最终会得到如下所示的表 第五步：回收所有的白色标记表的对象 5 和对象 6. 也就是回收垃圾。 和普通标记清除算法比有什么不同点 三色标记法是一层层的开始标记，不是一次性全部标记 三色标记法，没有 stw，什么情况下会出现对象丢失？ 如果此时 gc 执行到下图这个环节的时候，因为没有 stw，程序还在运行，如果发生以下两个动作 白色对象被黑色对象 4引用 灰色 对象 2 丢失了和该 白色对象3 的关系 此时因为灰色 对象 2 已经没有引用 对象 3 了，所以遍历 对象 2 的时候，没法找到 对象 3，对象 3 就会被当做垃圾误清除 强弱三色不变式，确保对象不丢失 强三色不变式：允许黑色引用灰色（已经遍历了灰色，不会丢失灰色），不允许黑色引用白色（白色如果是上述的这个两个条件就会丢失，所以直接不允许），这样就会破坏上述中的条件 1 弱三色不变式：允许黑色引用白色，但是，该白色的上游链中必须有一个灰色引用到，这样该灰色最终会遍历到这个白色。 屏障机制，确保不被误删除 插入屏障： 对象被引用的时候触发的机制，在 a 对象引用 b 对象的时候，b 对象被标记成灰色，满足了强三色不变式，这样就不存在黑色引用白色的了。 不足点是：因为这个插入屏障只有在写的堆上才有，栈（用于函数调用啥的）上的空间比较少，要求效率要高，出于效率的考虑，栈上没有这个机制。所以三色标记结束的时候，要 stw 来重新扫描栈，大约 10-100ms。 删除屏障： 被删除的对象，如果自身是灰色或者白色，那么被标记成灰色，满足弱三色不变公式。如下图中，对象 5 和对象 1 之间的引用关系被删除的话，这个时候对象 5 设置成灰色就好。 但是这操作的话有个问题，这个对象 5 其实已经是垃圾了，却还存在，只能等到下一轮 gc 的时候，才会被清除。虽然会有删除精度不高的问题，但是能确保不会被错删除 v 1.8 三色标记法+混合写屏障 混合写屏障，就是把删除和插入屏障放在一起，加上一个栈上的默认全部黑操作，整个过程不会有stw，具体操作： GC 开始将栈上的对象可达结点全部扫描并标记为黑色 (之后不再进行第二次重复扫描，无需 STW) GC 期间，任何在栈上创建的新对象，均为黑色，下一轮 gc 删除 堆上被删除的对象标记为灰色 堆上被添加的对象标记为灰色
什么是 pprof pprof 是 Go 官方提供的性能分析工具，可以分析程序的运行情况，并且提供可视化的功能。prof 是 profile（画像）的缩写，使用 pprof 可以分析以下几种指标数据： allocs：程序启动之后内存分配的情况 block：导致阻塞操作的一些堆栈跟踪信息 cmdline：当前程序启动的命令行 goroutine：所有当前 goroutine 的堆栈跟踪信息 heap：程序在当前堆上内存分配的情况 mutex：锁资源的竞争的堆栈信息 profile：CPU profile 文件。可以在 debug/pprof?seconds=x秒 GET 参数中指定持续时间。获取 pprof 文件后，使用 go tool pprof x.prof 命令分析 pprof 文件。 threadcreate：系统线程的使用情况 trace：当前系统的代码执行的链路情况 使用 pprof 工具主要分析以下几种指标： CPU Profiling：CPU 分析，按照一定的频率采集所监听的应用程序 CPU（含寄存器）的使用情况，可确定应用程序在主动消耗 CPU 周期时花费时间的位置 Memory Profiling：内存分析，在应用程序进行堆分配时记录堆栈跟踪，用于监视当前和历史内存使用情况，以及检查内存泄漏 Block Profiling：阻塞分析，记录 goroutine 阻塞等待同步（包括定时器通道）的位置 Mutex Profiling：互斥锁分析，报告互斥锁的竞争情况。 当程序存在内存或者 CPU 飙升的情况时，我们可以通过 pprof 工具来查询问题出现的根源。 怎么使用 pprof pprof 包含两个相关的库： runtime/pprof 主要应用于工具型应用。包含脚本、定时任务等。 如：对于只跑一次的程序，例如每天只跑一次的离线预处理程序，调用 pprof 包提供的函数，手动开启性能数据采集 net/http/pprof 主要应用于服务型应用。包含 HTTP 服务，GRPC 服务等。 如：对于在线服务，对于一个 HTTP Server，访问 pprof 提供的 HTTP 接口，获得性能数据。当然，实际上这里底层也是调用的 runtime/pprof 提供的函数，封装成接口对外提供网络访问。 工具型应用 工具型应用主要使用 runtime/pprof 包实现性能分析。 func main() { // --- cpu 分析示例 start--- // 创建cpu分析文件 fc, err := os.Create("./cpu.prof") if err != nil { fmt.Println("create cpu.prof err:", err.Error()) return } defer fc.Close() // 开始分析cpu err = pprof.StartCPUProfile(fc) if err == nil { defer pprof.StopCPUProfile() } // --- cpu 分析示例 end--- var count int for i := 0; i < 10000; i++ { count++ } // --- 内存 分析示例 start--- fm, err := os.Create("./memory.prof") if err != nil { fmt.Println("create memory.prof err:", err.Error()) return } defer fm.Close() // 开始分析内存 err = pprof.WriteHeapProfile(fm) if err != nil { fmt.Println("write heap prof err:", err.Error()) return } // --- 内存 分析示例 end--- for i := 0; i < 10000; i++ { count++ } fmt.Println("do finish......count:", count) } 执行 go run main.go 后，在代码目录下，可以看到生成了 cpu.prof 和 memory.prof 文件。 通过执行 go tool pprof ./memory.prof 或者 go tool pprof -http=:8888 ./memory.prof 进入命令行模式或者 web 页面进行性能分析。 执行 go tool pprof ./memory.prof 进入命令行： 执行 go tool -http=:8888 pprof ./memory.prof 可进入 web 页面，更方便查看： 页面展示效果： SAMPLE 各个标签的含义解释： 服务型应用 对于服务类型的应用，主要在服务内部匿名引入 net/http/pprof 包，然后通过 HTTP 访问 pprof 页面。 匿名引入方式为：import _ "net/http/pprof" package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { http.HandleFunc("/", hello) err := http.ListenAndServe(":8080", nil) if err != nil { fmt.Println("ListenAndServe Err:", err.Error()) return } } func hello(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello World, Are You OK?") } 执行 http://localhost:8080/debug/pprof/ 可以看到画像信息： 但是需要注意，如果 HTTP 服务不是通过 http.ListenAndServe(":8080", nil) 启动的，而是指定第二个参数启动的话，需要自己注册 pprof 路由。 在 net/http/pprof/pprof.go 的官方源码注释中也提到此种情况： If you are not using DefaultServeMux, you will have to register handlers with the mux you are using. 如果您不使用 DefaultServeMux，则必须向所使用的多路复用器注册 pprof 处理程序 http. ListenAndServe 函数可以传递 handler，如果 handler 不为 nil，则说明研发自定义了 ServeMux，否则用的是默认 DefaultServeMux net/http/pprof 包中，有 init 函数 func init() { http.HandleFunc("/debug/pprof/", Index) http.HandleFunc("/debug/pprof/cmdline", Cmdline) http.HandleFunc("/debug/pprof/profile", Profile) http.HandleFunc("/debug/pprof/symbol", Symbol) http.HandleFunc("/debug/pprof/trace", Trace) } 所以如果使用默认 ServeMux，则不需要注册，但是如果使用自定义的 ServeMux，则需要增加注册后，才能获取到 pprof。 // 自己注册这几个函数 r.HandleFunc("/debug/pprof/", pprof.Index) r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) r.HandleFunc("/debug/pprof/profile", pprof.Profile) r.HandleFunc("/debug/pprof/symbol", pprof.Symbol) r.HandleFunc("/debug/pprof/trace", pprof.Trace) 另外一种启动 pprof 的方式在生产环境中更加常用：通过协程另起一个 HTTP 服务，单独用作 pprof 分析： func RegisterProf() { go func() { if err := http.ListenAndServe(":6060", nil); err != nil { panic("pprof server start error: " + err.Error()) } }() } pprof 数据分析 GO 官方提供了 go tool pprof 工具来帮助我们分析 pprof 生成的数据文件。 使用 go tool pprof 分析数据，主要有两种模式： 命令行交互模式 go tool pprof [file_pprof|url_pprof] web 页面模式 go tool pprof -http=:6666 [file_pprof|url_pprof] 其中，file_pprof 表示生成的 prof 分析文件，如 cpu.prof；url_pprof 表示远端服务开启的 pprof 访问，如 http://localhost:8080/debug/pprof/profile 进入命令行交互模式后，可以使用 help 查看所有子命令，使用 help <cmd|option> 查看子命令使用方法。如 help、help top 等 CPU Profiling 浏览器访问 /debug/pprof/profile 会自动进行 CPU profiling，默认持续 30 s，并生成一个文件供下载，可以通过带参数 ?seconds=60 进行 60 秒的数据采集。 为了模拟请求，使用 ab 进行压测：ab -k -c 1 -t 180 -n 100000000 http://localhost:8080/hello 执行 go tool pprof http://localhost:8080/debug/pprof/profile 后，默认需要等 30 s 才会显示交互 top 指令排序展示 每一行表示一个函数的信息，列信息字段解释： flat：函数在 CPU 上运行的时间 flat%：函数在 CPU 上运行时间的百分比 sum%：是从第一行到当前行所有函数累加使用 CPU 的比例，如第二行 sum=53.85=30.77+23.08 cum：这个函数以及子函数运行所占用的时间，应该大于等于 flat cum%：这个函数以及子函数运行所占用的比例，应该大于等于 flat% 最后一列：函数的名字 web 指令生成图示 在交互模式下输入 web，会自动生成一个 svg 文件，并跳转到浏览器打开。 改功能需要安装 graphviz 后才能使用，安装方法 https://shidawuhen.github.io/2020/02/08/go-callvis/。 图中每个方框对应应用程序运行的一个函数，方框越大代表函数执行的时间越久（函数执行时间会包含它调用的子函数的执行时间，但并不是正比的关系）；方框之间的箭头代表着调用关系，箭头上的数字代表被调用函数的执行时间。 具体细节可以参考：https://github.com/google/pprof/tree/master/doc#interpreting-the-callgraph 以 runtime netpoll 为例： 箭头上的 230 ms 表示该函数总共的执行时间，包含自身和下游所有子节点； 方框中的 10 ms 表示自身的执行时间； 方框中的 2.70% 表示自身执行时间在该函数总共执行时间的占比在总时间中的占比：2.70 = (10/230)*62.16 方框中的 62.16% 表示总时间占比，即整个程序耗时占比。 list 指令分析函数 确定出哪个函数耗时之后，可以用 pprof 分析函数中的哪一行导致的耗时，使用子命令：list 函数名。 堆内存分析示例 内存分配既可以发生在堆上也可以在栈上。堆上分配的内存需要垃圾回收或者手动回收（对于没有垃圾回收的语言，例如 C++），栈上的内存则通常在函数退出后自动释放。 Go 语言通过逃逸分析会将尽可能多的对象分配到栈上，以使程序可以运行地更快。 这里说明一下，有两种内存分析策略： 一种是当前的（这一次采集）内存或对象的分配，称为 inuse； 另一种是从程序运行到现在所有的内存分配，不管是否已经被 gc 过了，称为 alloc。 As mentioned above, there are two main memory analysis strategies with pprof. One is around looking at the current allocations (bytes or object count), called inuse. The other is looking at all the allocated bytes or object count throughout the run-time of the program, called alloc. This means regardless if it was gc-ed, a summation of everything sampled. 加上 -sample_index 参数后，可以切换内存分析的类型： go tool pprof -sample_index=alloc_space http://localhost:8080/debug/pprof/heap 或者 go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap 四种标签： pprof 数据分析类型汇总 其他数据的分析和 CPU 的基本一致。下面列举所有的分类： http://localhost:8080/debug/pprof/ ：获取概况信息，即本文第一张图的信息 go tool pprof http://localhost:8080/debug/pprof/allocs : 分析内存分配 go tool pprof http://localhost:8080/debug/pprof/block : 分析堆栈跟踪导致阻塞的同步原语 go tool pprof http://localhost:8080/debug/pprof/cmdline : 分析命令行调用的程序，web 下调用报错 go tool pprof http://localhost:8080/debug/pprof/goroutine : 分析当前 goroutine 的堆栈信息 go tool pprof http://localhost:8080/debug/pprof/heap : 分析当前活动对象内存分配 go tool pprof http://localhost:8080/debug/pprof/mutex : 分析堆栈跟踪竞争状态互斥锁的持有者 go tool pprof http://localhost:8080/debug/pprof/profile : 分析一定持续时间内 CPU 的使用情况 go tool pprof http://localhost:8080/debug/pprof/threadcreate : 分析堆栈跟踪系统新线程的创建 go tool pprof http://localhost:8080/debug/pprof/trace : 分析追踪当前程序的执行状况 参考连接 一文搞懂pprof 深度解密Go语言之 pprof go性能分析工具pprof Go语言：利用pprof工具排查内存泄漏的示例 Go语言：利用pprof工具查找goroutine（协程）泄漏的示例 go 程序性能调优 pprof 的使用
Go 语言接口语法指南 | 进阶接口语法学习
概述 接口主要是用于实现多态的效果，接口是一个类型，一个抽象的类型，既然是类型，就可以定义接口类型的变量。 定义 接口好像用于定义方法的，写上方法签名列表即可 type 接口类型名 interface{ 方法名1( 参数列表1 ) 返回值列表1 // 签名 方法名2( 参数列表2 ) 返回值列表2 … } 接口名称：我们一般会在接口名称后面添加 er。 方法签名： 当方法名首字母是大写且这个接口类型名首字母也是大写时，这个方法可以被接口所在的包（package）之外的代码访问，也就是说可以被其它包所实现； 参数列表和返回值列表中的参数 变量名可以省略 type sayer interface { Say() string } 结构体变量赋值 如果一个变量定义为接口类型的变量，那么如果一个结构体实现了这个接口，并且方法的接收者都是值类型的，那么这个结构体的值类型和指针类型都可以赋予给这个接口变量。 例如下例中，结构体 dog，实现了 Mover 接口的 move 和 eat 方法，并且这个两个方法的接受者都是值类型，所以 dog{} 可以赋值给这个 Mover 接口变量，&dog{} 也可以赋值给这个 Mover 接口变量 package main import "fmt" type Mover interface { move() eat() } // dog 类型的方法的接受者都是值类型 type dog struct{} func (d dog) move() { fmt.Println("狗会动") } func (d dog) eat() { fmt.Println("吃") } func main() { var x Mover x = &dog{} // 指针类型可以赋值 x.move() var y Mover y = dog{} // 值类型可以赋值 y.move() } 但是，如果结构体方法的接收者存在指针类型，那么只能这个结构体的指针类型都可以赋予给这个接口变量，值类型不行，会报错。 如下所示 move 方法是指针类型，所以只能 &dog{} 赋予给 Mover 接口变量，用 dog{} 值类型赋予接口变量的话，将会报错 package main import "fmt" type Mover interface { move() eat() } type dog struct{} // move 方法的接受者是指针类型*dog func (d *dog) move() { fmt.Println("狗会动") } func (d dog) eat() { fmt.Println("吃") } func main() { var x Mover x = &dog{} // 指针类型可以赋值 x.move() var y Mover y = dog{} // 值类型不可以赋值了，会报错!!! y.move() } 空接口 接口是行为规范的集合，空接口没有任务行为规范，也就是接口方法为空。因为没有任何行为规范，所以任何类型都可以属于空接口类型。所以空接口可以接收任何参数。 package main import "fmt" // Mover 自定义空接口 type Mover interface { } // dog 空接口参数arg func dog(arg Mover) { } func main() { // 空接口可以接受任何参数 dog(10) dog("10") // 空接口可以接受任何参数 var a Mover a = 10 a = "10" // Println 参数类型就是空接口 fmt.Println(a) } go 里其实已经自带了一个空接口类型 interface{}，不需要我们自定义（上例的 Mover 空接口） package main import "fmt" func main() { // go里自带的空接口类型interface{} var a interface{} a = 10 a = "10" // Println 参数类型就是空接口 fmt.Println(a) } 在 go1.18 之后，官方更加建议使用 any 来替代 interface{}，这样可以少写几个字母，没有别的差别。 package main import "fmt" func main() { // go1.18之后，官方推荐用any替代interface{} var a any a = 10 a = "10" // Println 参数类型就是空接口 fmt.Println(a) } 断言 由于空接口可以存任何类型的值，有时候我们想要知道，空接口类型的值到底是什么类型，这个时候就需要对值进行断言。 package main import "fmt" func main() { // 定义一个空接口x var x interface{} x = "Hello 沙河" v, ok := x.(string) // 断言是否是字符串，ok为true或false，v为变量的值 fmt.Println(v, ok) } 断言配合switch使用 anyVal.(type)的形式必须配合switch使用 var anyVal interface{} anyVal = "123" switch specificTypeVal := anyVal.(type) { case string: fmt.Println(specificTypeVal) // 输出字符串3 } 有时候我们希望结构体必须实现某个接口，如果没有实现的话，就编译报错，启动不起来，可以这样写 package main import ( "io" ) type MyStruct struct{} func (m *MyStruct) Read(p []byte) (n int, err error) { return 0, nil } // Impl 或者写个方法，返回这个接口类型也可以 func (m *MyStruct) Impl() io.Reader { return (*MyStruct)(nil) } // 这个两种方式，方法的接受者可以是指针，也可以是值类型 var _ io.Reader = (*MyStruct)(nil) // 把nil转成*MyStruct类型 var _ io.Reader = &MyStruct{} type MyStructCaseTwo struct{} func (m MyStructCaseTwo) Read(p []byte) (n int, err error) { return 0, nil } // 方法的接受这必须是指针类型 var _ io.Reader = MyStructCaseTwo{} func main() { } 接口的嵌套 接口与接口间可以通过嵌套创造出新的接口，嵌套得到的接口的使用与普通接口一样 // Sayer 接口 type Sayer interface { say() } // Mover 接口 type Mover interface { move() } // 接口嵌套 type animal interface { Sayer Mover } 接口的实现 接口是隐式实现的，一个对象只要全部实现了接口中的方法，那么就实现了这个接口；换句话说，接口就是一个 需要实现的方法列表。 type sayer interface { Say() string } type Cat struct{} // 实现了sayer接口 func (c Cat) Say() string { return "喵喵喵" } 一个类型可以实现多个接口，只需要实现每个接口里的方法即可 type Sayer interface { say() } type Mover interface { move() } type dog struct { name string } func (d dog) say() { fmt.Printf("%s会叫汪汪汪\n", d.name) } func (d dog) move() { fmt.Printf("%s会动\n", d.name) } func main() { var x Sayer var y Mover a := dog{name: "旺财"} x = a y = a x.say() y.move() } 还可以多个类型实现同一个接口，这就是多态的实现，同一个接口类型，不同对象有不同的表现，如下所示，同一个 move 方法，dog 结构体实例和 car 结构体实例，执行的结果是不一样的 type Mover interface { move() } type dog struct { name string } type car struct { brand string } func (d dog) move() { fmt.Printf("%s会跑\n", d.name) } func (c car) move() { fmt.Printf("%s速度70迈\n", c.brand) } func main() { var x Mover x = dog{name: "旺财"} x.move() x = car{brand: "保时捷"} x.move() }
从语法的角度来讲，泛型是从接口层面演化而来的。 下面例子中，f1 是依赖于接口类型 Man，f2 是依赖于 T 类型，T 类型等价于 Man 类型。 package main type Man interface { Say() } type Student struct { } func (s Student) Say() { } // 面向接口编程，a必须是Man接口类型 func f1(a Man) { } // 泛型，中括号里可以理解为T是Man的别名 // 既然T等Man，所以f1和f2就等价了 func f2[T Man](a T) { } func main() { f1(Student{}) f2(Student{}) } 应用在函数里 跟接口的语法有点类似，就是里面的内容是数据类型，而不是方法签名，如下所示 Happy 就是联合类型，将来要使用到 Happy 的时候，只需要用到任意一个类型传递进去就可以。 // 联合类型 type Happy interface { // ~T，是go1.18新增的符号，~t表示底层是T的所有类型 int | ~int32 | int8 | string | ~bool } // a,b必须同时为int、~int32、int8、string、 ~bool // 不可以一个是int，一个是string func f3[T Happy](a, b T, c float64) { } func main() { // a,b都是int f3(1, 2, 3.1) f3(true, false, 3.1) f3("a", "b", 3.1) } 如果不想要定义个结构体，可以如下所示，当然，a, b 两个参数每次调用的时候，也要是同一个类型。 package main func f4[T int | string | bool](a, b T, c float64) { } func main() { // a,b都是int f4(1, 1, 3.1) f4(true, false, 3.1) f4("a", "b", 3.1) } 应用在结构体 在接口体名称后面新增一个中括号，在中括号里设置好别名 package main import "fmt" type Happy interface { // ~T，是go1.18新增的符号，~t表示底层是T的所有类型 int | ~int32 | int8 | string | ~bool } type Bird[T Happy, S int | string] struct { Head T Hair S } func (b *Bird[T, S]) SetHead(h T) { b.Head = h } func main() { // [int, string]，定下来[T, S]是什么 bird := Bird[int, string]{ Head: 3, Hair: "lang", } fmt.Println(bird.Head, bird.Head) }