编程技术文章分享与教程

网站首页 > 技术文章 正文

一文带你看懂Golang最新特性

hmc789 2024-11-19 04:57:11 技术文章 1 ℃

作者:腾讯PCG代码委员会

经过十余年的迭代,Go语言逐渐成为云计算时代主流的编程语言。下到云计算基础设施,上到微服务,越来越多的流行产品使用Go语言编写。可见其影响力已经非常强大。

一、Go语言发展历史介绍

Go语言起源于2007年的Google;创始人有三位,分别是Ken Thompson、Rob Pike、Robert Griesemer;他们可谓是大佬中的大佬。最初的构想是新语言能够匹配未来硬件发展趋势、适合开发大规模网络服务程序、程序员能够更加专注业务逻辑的开发。Ian Lance Tyalor 和 Russ Cox是Go核心开发团队的第四、五位成员。

Go语言2009年正式对外公布,2012年1.0版本正式发布。2012年到2015年是Go语言的筑底成长期;从实现自引导的Go1.5版本开始,到Go 1.9版本,业界对Go的期望先是提升到峰值接着又开始跌落;随着Go 1.11版本引入Go Module,包依赖问题得到很好的解决,Go进入稳步爬升阶段。

Go语言每年发布两次升级版本,二月和八月。1.11版本之后Go发布的版本如下:

二、Go1.22新特性

1.22是24年的第一个版本。主要变化分布在工具链、运行时以及标准库。坚持“Go 1兼容性”承诺:只要符合Go 1语言规范的代码,Go编译器保证向前兼容。

2.1 语言改进

1.循环变量不再共享

Go1.22 之前版本 for 循环声明的变量只创建一次,并在每次迭代中进行更新,这会导致遍历时访问 value 时实际上都是访问的同一个地址的值。循环体内编写并行代码容易踩坑。比如:

● Go1.22 之前版本的踩坑代码

//  Go1.22之前版本的踩坑代码,Go1.22版本可以正常运行。
package main

import (
    "fmt"
    "sync"
)

func main() {
    group := sync.WaitGroup{}
    list := []string{"a", "b", "c", "d"}
    for i, s := range list {
        group.Add(1)
        go func() {
            defer group.Done()
            fmt.Println(i, s) // 这里访问的都是同一个地址
        }()
    }
    group.Wait()
}

Go1.22之前版本的运行结果如下所示,不符合预期:

// Go1.22之前版本的运行结果,不符合预期
3 d
3 d
3 d
3 d

可以看到,上面代码输出都是同样。为了避免Go1.22之前版本的这个坑点,需要每次重新给for变量赋值,比如下面两种解决方法:

● 方法一:

// Go1.22之前版本的解决方法一
package main

import (
    "fmt"
    "sync"
)

func main() {
    group := sync.WaitGroup{}
    list := []string{"a", "b", "c", "d"}
    for i, s := range list {
        group.Add(1)
        i, s := i, s // 重新赋值
        go func() {
            defer group.Done()
            fmt.Println(i, s)
        }()
    }
    group.Wait()
}

● 方法二:

// Go1.22之前版本的解决方法二
package main

import (
    "fmt"
    "sync"
)

func main() {
    group := sync.WaitGroup{}
    list := []string{"a", "b", "c", "d"}
    for i, s := range list {
        group.Add(1)
        go func(i int, s string) {
            defer group.Done()
            fmt.Println(i, s)
        }(i, s) // 作为参数传入
    }
    group.Wait()
}

这两种方式本质都是对 for 循环变量重新赋值。

而对于 Go1.22 及之后的版本,for 循环的每次迭代都会创建新变量,每次循环迭代各自的变量。从此再也不用担心循环内并发导致的问题。对于最初的踩坑代码,在 Go1.22 及之后版本运行的结果如下:

// Go1.22版本的运行结果
3 d
1 b
0 a
2 c

2.支持对整数进行循环迭代

在 Go1.22 之前版本,for range 仅支持对 array、slice、string、map 、以及 channel 类型进行迭代。如果希望循环 N 次执行,可能需要如下编写代码:

for i := 0; i < 10; i++ {
    // do something
}

Go1.22 及之后版本,新增了对整数类型的迭代。如下示例,代码写起来更简洁。这里需要注意的是 range 的范围前闭后开 [0, N)。

for i := range 10 {
    // do something
}

3.支持函数类型范围遍历

在 for range 支持整型表达式的时候,Go 团队也考虑了增加函数迭代器(iterator),不过前者语义清晰,实现简单。后者展现形式、语义和实现都非常复杂,于是在 Go1.22 中,函数迭代器以试验特性提供,通过 GOEXPERIMENT=rangefunc 可以体验该功能特性。而在最新的 Go 1.23 版本中,已经正式支持此功能。目前支持的函数类型为:

type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

在我们开发代码过程中,经常会对自定义的 container 进行变量,比如下面的 Set:

// Set 保存一组元素。
type Set[E comparable] struct {
    m map[E]struct{}
}

而在 Go1.22 之前的版本,我们要对 Set 中元素就行某种操作时,可能需要写如下函数:

func (s *Set[E]) Handle(f func(E) bool) {
    for v := range s.m {
        if !f(v) {
            return
        }
    }
}

当我们需要打印 Set 中所有元素时,可能需要如下调用 Handle 函数:

s := Set[int]{m: make(map[int]struct{})}
s.Handle(func(e int) bool {
             fmt.Println(e)
             return true
})

而在 Go1.22 打开实验特性及 Go1.23 版本,可以支持如下写法:

s := Set[int]{m: make(map[int]struct{})}
for e := range s.Handle {
    fmt.Println(e)
}

从实现层面看,其实 range func 是一个语法糖。对于新版的 Go,for range的迭代体在编译的时候会被改写成 func(e int) bool 形式,同时在函数的最后一行返回 true。

2.2 标准库

1.第一个v2标准库:math/rand/v2

变动原因:

● 标准库里math/rand存在较多的问题,包括:生成器版本过旧、算法性能不高,以及与 crypto/rand.Read 存在冲突等问题;

● Go 1要求保障兼容性,要解决上述问题无法直接对原库进行变更,需要把标准库升级到v2版本;

● 通过用 math/rand试水,为标准库升级到V2积累经验,例如:解决工具生态的问题(gopls、goimports 等工具对 v2 包的支持), 后续再对风险更高的包(如:sync/v2 或 encoding/json/v2)进行新版本迭代;

重要变更:

● 删除 Rand.Read 和顶层的 Read: 原因是由于math库和crypto的Read相近,导致本来该使用crypto/rand.Read的地方被误用了math/rand.Read,引入安全问题;

● 移除 Source.Seed、Rand.Seed 和顶层 Seed:它们假设底层随机数生成器(Source)采用 int64作为种子,这个假设不具有普适性,不适合定义为一个通用接口;

● 随机数生成器接口增加Uint64方法,替换Int63方法,这个变更更符合新的随机数生成器, 同时移除顶层Source64函数,原因是随机数生成器提供了Uint64方法;

● Float32和 Float64使用了更直接的实现方式:以 Float64 为例,之前版本的实现是 float64(r.Int63()) / (1<<63),它偶尔会出现四舍五入到 1.0的问题,现在的实现改成了 float64(r.Int63n(1<<53)) / (1<<53)来解决上面的问题;

● 使用 Rand.Shuffle 实现 Rand.Perm。Shuffle实现效率更高,这样可以确保只有一个实现;

● 将 Int31、Int31n、Int63、Int64n 函数更名为 Int32、Int32n、Int64、Int64n;

● 新增Uint32、Uint32N、Uint64、Uint64N、Uint、UintN顶层函数以及Rand方法;

● 在 N、IntN、UintN 等中使用 Lemire 算法。初步基准测试显示,与 v1 Int31n 相比节省了 40%,与 v1 Int63n 相比节省了 75%。

● 新增 PCG-DXSM(Permuted Congruential Generator) 和 ChaCha8 两种随机数生成器。删除 Mitchell & Reeds LFSR 生成器。

示例:

// 列举了部分math/rand库的使用
package main

import (
    "fmt"
    "math/rand/v2"
    "os"
    "strings"
    "text/tabwriter"
    "time"
)

func main() {
    // 创建并设置生成器的种子。
    // 通常应使用非固定的种子,例如 Uint64(), Uint64()。
    // 使用固定种子会在每次运行时产生相同的输出。
    r := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))

    // Float32 和 Float64 的值在 [0, 1) 范围内。
    w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
    defer w.Flush()
    show := func(name string, v1, v2, v3 any) {
        fmt.Fprintf(w, "%s\t%v\t%v\t%v\n", name, v1, v2, v3)
    }

    // Float32 和 Float64 的值在 [0, 1) 范围内。
    show("Float32", r.Float32(), r.Float32(), r.Float32())
    show("Float64", r.Float64(), r.Float64(), r.Float64())

    // ExpFloat64 值的平均值为 1,但呈指数衰减。
    show("ExpFloat64", r.ExpFloat64(), r.ExpFloat64(), r.ExpFloat64())

    // NormFloat64 值的平均值为 0,标准差为 1。
    show("NormFloat64", r.NormFloat64(), r.NormFloat64(), r.NormFloat64())

    // Int32、Int64 和 Uint32 生成给定宽度的值。
    show("Int32", r.Int32(), r.Int32(), r.Int32())
    show("Int64", r.Int64(), r.Int64(), r.Int64())
    show("Uint32", r.Uint32(), r.Uint32(), r.Uint32())

    // IntN、Int32N 和 Int64N 将它们的输出限制为 < n。
    // 它们比使用 r.Int()%n 更加小心。
    show("IntN(10)", r.IntN(10), r.IntN(10), r.IntN(10))
    show("Int32N(10)", r.Int32N(10), r.Int32N(10), r.Int32N(10))
    show("Int64N(10)", r.Int64N(10), r.Int64N(10), r.Int64N(10))

    // Perm 生成 [0, n) 范围内的随机排列。
    show("Perm", r.Perm(5), r.Perm(5), r.Perm(5))

    // 打印一个位于半开区间 [0, 100) 内的 int64。
    fmt.Println("rand.N(): ", rand.N(int64(100)))

    // 打印一个位于半开区间 [0, 100) 内的 uint32
    fmt.Println("rand.N(): ", rand.N(uint32(100)))

    // 睡眠一个在 0 到 100 毫秒之间的随机时间。
    time.Sleep(rand.N(100 * time.Millisecond))

    // Shuffle 使用默认的随机源对元素的顺序进行伪随机化
    words := strings.Fields("ink runs from the corners of my mouth")
    rand.Shuffle(len(words), func(i, j int) {
        words[i], words[j] = words[j], words[i]
    })
    fmt.Println(words)
}

2.增强http.ServerMux路由能力

现有的多路复用器(http.ServeMux)只能提供基本的路径匹配,很多时候要借助于第三方库来完成实际需求的功能。Go 1.22基于提案《net/http: enhanced ServeMux routing》,增强了 http.ServerMux 的路由匹配能力,增加了对模式匹配、路径变量的支持。

匹配方法

模式匹配将支持以 HTTP 方法开头,后跟空格,如 GET /eddycjy 或 GET eddycjy.com/ 中。带有方法的模式仅用于匹配具有该方法的请求。对照到代码中,也就是 Go1.22 起,代码可以这么写:

mux.HandleFunc("POST /eddycjy/create", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "hello world")
})

mux.HandleFunc("GET /eddycjy/update", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "hello world")
})

通配符

模式匹配将支持 {name} 或 {name...},例如:/b/{bucket}/o/{objectname...}。该名称必须是有效的 Go 标识符和符合完整路径元素的标准。它们前面必须有斜杠,后面必须有斜杠或字符串末尾。例如:/b_{bucket} 不是有效的通配模式。Go1.22 起,http.ServeMux 可以这么写:

mux.HandleFunc("/items/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "id 值为 %s", id)
})

mux.HandleFunc("/items/{path...}", func(w http.ResponseWriter, r *http.Request) {
    path := r.PathValue("path")
    fmt.Fprintf(w, "path 值为 %s", path)
})

通常,通配符仅匹配单个路径元素,以请求URL中的下一个斜杠结束。如果...存在,则通配符与URL路径的其余部分匹配,包括斜杠。

○ /items/{id}: 正常情况下一个通配符只匹配一个路径段,比如匹配/items/123,但是不匹配/items/123/456。

○ /items/{path...}: 但是如果通配符后面跟着...,那么它就会匹配多个路径段,比如/items/123、/items/123/456都会匹配这个模式。

○ /items/{$}: 正常情况下以/结尾的模式会匹配所有以它为前缀的路径,比如/items/、/items/123、/items/123/456都会匹配这个模式, 但是如果以/{$}为后缀,那么表示严格匹配路径,不会匹配带后缀的路径,比如这个例子只会匹配/items/,不会匹配/items/123、/items/123/456。

优先级

单一的优先规则:

○ 如果两个模式重叠(有一些共同的请求),那么更具体的模式优先:如果 P1 符合 P2 请求的一个(严格)子集,也就是说:如果 P2 符合 P1 的所有请求及更多请求,那么 P1 就比 P2 更具体

○ 如果两者都不更具体,那么模式就会发生冲突。 这条规则有一个例外:如果两个模式发生冲突,而其中一个有 HOST ,另一个没有,那么有 HOST 的模式优先

具体的例子:

  1. example.com/ 比 / 更具体,因为第一个仅匹配主机 example.com 的请求,而第二个匹配任何请求。
  2. GET / 比 / 更具体,因为第一个仅匹配 GET 和 HEAD 请求,而第二个匹配任何请求。
  3. /b/{bucket}/o/default 比 /b/{bucket}/o/{noun} 更具体,因为第一个仅匹配第四个元素是文字 “default” 的路径,而在第二个中,第四个元素可以是任何内容。

3.新增go/version库

新增go/version库用于识别、校验go version的正确性,以及比较版本大小。功能比较简单,可直接参考函数签名:

// 返回 version x的go语言版本,比如x=go1.21rc2返回go版本为go1.21
func Lang(x string) string

// 校验版本的正确性
func IsValid(x string) bool

// 比较版本的大小: -1,0,1 分别代表 x < y, x == y, or x > y
// x,y必须是已go为前缀,比如go1.22,不能使用1.22
func Compare(x, y string) int

4.其他小变化

1.22中还包含了许多小更新,此处列举其中一些(小标题是库名)

archive/tar、archive/zip

两个库都新增了Writer.AddFS函数,允许将fs.FS对象打包,内部会对fs.FS进行遍历,打包时保持树状结构不变。

bufio

Scanner的SplitFunc函数在返回err为ErrFinalToken时,如果token为nil,之前会返回最后一个空的token再结束,现在会直接结束。如果需要返回空token再结束可以在返回ErrFinalToken时同时返回[]byte{}数组,而不是nil。举个例子:

package main

import (
    "bufio"
    "bytes"
    "fmt"
)

// 自定义的SplitFunc函数,按空格分割输入,并在遇到"STOP"时停止扫描; 另外,为了和标准库函数对齐,采用了命名返回值
func splitBySpaceAndStop(data []byte, atEOF bool) (advance int, token []byte, err error) {
    if i := bytes.IndexByte(data, ' '); i >= 0 {
        // 找到空格,返回前面的数据作为token
        token = data[:i]
        advance = i + 1
        // 如果token是"STOP",返回ErrFinalToken
        if string(token) == "STOP" {
            return 0, nil, bufio.ErrFinalToken
        }
        return advance, token, nil
    }
    // 如果到达输入末尾且有剩余数据,返回剩余数据作为token
    if atEOF && len(data) > 0 {
        return len(data), data, nil
    }
    return 0, nil, nil
}

func main() {
    input := "apple banana cherry STOP date"
    scanner := bufio.NewScanner(bytes.NewReader([]byte(input)))
    scanner.Split(splitBySpaceAndStop) // 设置自定义的SplitFunc

    // 扫描并打印每个token
    for scanner.Scan() {
        fmt.Printf("Token: %s\n", scanner.Text())
    }
    // 1.22以前会打印以下4个token,其中第4个为空字符串。而1.22则只会打印前3个token,如果也想打印4个token,则把return 0, nil, bufio.ErrFinalToken改为return 0, []byte{}, bufio.ErrFinalToken即可
    // Token: apple
    // Token: banana
    // Token: cherry
    // Token: 
}

cmp

新增了Or函数,入参是comparable类型的一组参数,返回第一个非零值,如果入参全部是零值则返回零值。这个函数可以简化一些写法,例如:

xxxConfigValue := cmp.Or(remoteConfig.Get("config_key"), "default_config_value")

Or函数源码如下:

// Or returns the first of its arguments that is not equal to the zero value.
// If no argument is non-zero, it returns the zero value.
func Or[T comparable](vals ...T) T {
    var zero T
    for _, val := range vals {
        if val != zero {
            return val
        }
    }
    return zero
}

database/sql

在支持泛型之前,sql库定义了NullInt64、NullBool、NullString等结构体用于表示各种类型的null值。在泛型得到支持后,Null[T]也就应运而生了,不过目前原有的各NullXxx结构体还没有标为deprecated。

encoding

在base32、base64、hex包里,原有的Encode和Decode函数在使用时需要提前初始化适当长度的dst数组,如下:

    src := []byte("abc")
    dst := make([]byte, base64.StdEncoding.EncodedLen(len(src)))
    base64.StdEncoding.Encode(dst, src)
    fmt.Printf("dst:%s\n", string(dst))

现在新增了AppendEncode和AppendDecode函数,可将dst追加到给定数组之后,给定数组无需提前初始化,也可以方便地进行拼接:

    src := []byte("abc")
    dst := base64.StdEncoding.AppendEncode([]byte{}, src)
    fmt.Printf("dst:%s\n", string(dst))

go/types

新增了Alias类型,用于表示类型别名。之前类型别名并不会有单独的类型,它本质上还是对应的原类型,只是代码看起来会有些差异,在运行期类型别名信息其实是不存在的。新增了Alias类型后,在错误上报时,我们可以看到别名信息而不是原类型,有助于我们定位问题。

但Alias类型可能会破坏已有的类型判断代码,所以在GODEBUG环境变量下新增了一个参数gotypesalias用于开启或关闭Alias类型。默认情况下是不开启的,以兼容旧代码。但在未来可能会默认为开启,所以我们已有的类型判断相关代码建议还是考虑到类型别名的情况,否则将来升级更新的Go版本可能会出现问题。举个例子:

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "go/types"
)

func main() {
    // 要解析和检查的Go代码
    src := `
package main

type intAlias = int

func main() {
 var x intAlias
 println(x)
}
`

    // 创建文件集
    fset := token.NewFileSet()

    // 解析Go代码
    file, err := parser.ParseFile(fset, "example.go", src, 0)
    if err != nil {
        fmt.Println("解析错误:", err)
        return
    }

    // 创建类型检查器
    conf := types.Config{Importer: nil}
    info := &types.Info{
        Types: make(map[ast.Expr]types.TypeAndValue),
        Defs: make(map[*ast.Ident]types.Object),
    }

    // 进行类型检查
    _, err = conf.Check("example", fset, []*ast.File{file}, info)
    if err != nil {
    fmt.Println("类型检查错误:", err)
        return
    }

    // 打印变量x的类型
    for ident, obj := range info.Defs {
        if ident.Name == "x" {
            // 如果环境变量配置为GODEBUG=gotypesalias=0,则这里打印的类型是int,和1.22以前的版本一样,目前gotypesalias=0是默认值
            // 如果环境变量配置为GODEBUG=gotypesalias=1,则这里打印的类型是example.intAlias,未来gotypesalias=1可能会变成默认值,因此判断类型别名的类型时需要额外注意
            fmt.Printf("变量x的类型是: %s\n", obj.Type())
        }
    }
}

net

Go DNS解析器在使用“-tags=netgo”构建时,会先在windows的hosts文件中先查找是否匹配,即%SystemRoot%\System32\drivers\etc\hosts文件。

net/http

请求或响应头中如果包含空的Content-Length头,则HTTP server和client会拒绝请求或响应。GODEBUG的httplaxcontentlength参数设置为1可关闭这一特性,即不拒绝请求或响应。

net/netip

增加了AddrPort.Compare函数用于对比两个AddrPorts。

reflect

● Value.IsZero方法现在对于浮点零和复数零都会返回true,如果结构体的空字段(即_命名的字段)有非零值,Value.IsZero方法也会返回true。这些改动使得IsZero和==比较符在判零方面表现一致。

● PtrTo函数标为deprecated,应该改用PointerTo。

● 增加了新函数TypeFor用于获取类型,之前获取反射类型通常是reflect.TypeOf((*T)(nil)).Elem(),比较麻烦,现在直接调用TypeFor就可以了,其实内部实现还是一样的:

// TypeFor returns the [Type] that represents the type argument T.
func TypeFor[T any]() Type {
    return TypeOf((*T)(nil)).Elem()
}

runtime/metrics

● 新增了四个关于stop-the-world(下称stw)的histogram metrics:

○ /sched/pauses/stopping/gc:seconds

○ /sched/pauses/stopping/other:seconds

○ /sched/pauses/total/gc:seconds

○ /sched/pauses/total/other:seconds

其中前两个用于上报从决定stw到所有协程全部停止的时间,后两个用于上报从决定stw到协程恢复运行的时间。

● /gc/pauses:seconds标记为deprecated,被/sched/pauses/total/gc:second取代了。

● /sync/mutex/wait/total:seconds之前仅包含sync.Mutex和sync.RWMutex的竞争时间,现在还包含了runtime-internal锁的竞争时间。

runtime/pprof

● mutex的profile现在会根据阻塞的协程数来计算竞争情况,更容易找出瓶颈mutex了。举个例子,有100个协程在一个mutex上阻塞了10毫秒,那这个mutex的profile会显示有1s延迟,以前是显示10毫秒延迟。

● mutex的profile除了包含sync.Mutex和sync.RWMutex的竞争时间,现在也会包含runtime-internal锁的竞争时间。

● 在Darwin系统上,CPU的profile现在会包含进程的内存映射,使得在pprof中可以启用反汇编视图。

runtime/trace

1.22的trace改动很大,详细内容可参考官方的blog:More powerful Go execution traces

slices

● 新增了Concat函数,可将多个slice连接成一个。在此之前需要多次append实现同样的效果,性能也差一些。

array1 := []int{1, 2, 3}
array2 := []int{4, 5, 6}
array3 := []int{7, 8, 9}
fmt.Println(append(append(array1, array2...), array3...)) // 1.22之前的写法,要拼接的slice越多写起来越麻烦,可能还需要for循环
fmt.Println(slices.Concat(array1, array2, array3))        // 1.22新增的Concat函数,使用起来更便利,性能也更好

● 会减少slice长度的函数会将新长度和原长度之间的元素值置为零值,包括Delete、DeleteFunc、Compact、CompactFunc、Replace,来看个例子:

array1 := []int{1, 2, 3}
array2 := slices.Delete(array1, 1, 2) // 移除[1, 2)区间的元素,即元素2,slice内还剩下元素1和3
fmt.Println(array1) // 以前是[1 3 3],现在是[1 3 0]
fmt.Println(array2) // 始终是[1 3]

● Insert函数以前如果插入的位置超出slice范围但没有真的插入任何值的时候不会panic,现在会panic了,也就是无论如何不能传入越界的插入位置。

array := []int{1, 2, 3}
slices.Insert(array, 100, 3) // 向下标100的位置插入元素3,因为越界,任何版本都会panic
slices.Insert(array, 100) // 向下标100的位置宣称要插入但没有传待插入的元素参数。在1.22版本之前因为没有实际插入任何元素所以没有问题,从1.22版本开始这样做会被认为是越界而导致panic

2.3编译器

Go 在1.20版本中,首次引入了PGO。Go 1.22对这一特性进行了优化,支持将更多的接口方法转换为直接调用,从而提高性能。大多数启用 PGO 的程序将会有2-14%的性能提升。我们以一个示例说明优化算法原理:

type Animal interface {
    Speak() string
}

type Dog struct{}

func (d Dog) Speak() string {
    return "Woof!"
}

func main() {
    var a Animal = Dog{}
    fmt.Println(a.Speak())
}

在这个例子中,a.Speak() 是一个接口方法调用,Go 运行时需要查找 Dog 类型的 Speak 方法并调用它。通过开启 PGO 编译优化,编译器可以在某些情况下将这种动态调用转换为直接调用,从而减少运行时的开销,提高性能。

此外,1.22 版本还添加了一个编译器的内联算法优化,当前为预览版本,需要在编译时通过 GOEXPERIMENT=newinliner 参数来启用。内联是编译器优化的一种技术,它将函数调用展开为函数体,从而消除函数调用的开销,提高性能。具体优化点是,在重要调用点(比如循环场景)通过启发式算法提高内联能力,同时在不重要(比如,panic调用链)的调用点上不阻止内联。

2.4 链接器

Go 在1.22 中的改进点主要如下:

1. 改进 -s 和 -w 标志:

● -w 标志:抑制 DWARF 调试信息的生成。

● -s 标志:抑制符号表的生成,并且隐含 -w 标志(即同时抑制 DWARF 调试信息的生成)。

● -w=0:可以用来取消 -s 标志隐含的 -w 标志。例如,-s -w=0 将生成一个包含 DWARF 调试信息但没有符号表的二进制文件。

2. 新增 ELF 平台上的 -B 标志:

-B gobuildid:链接器将生成一个基于 Go build ID 的 GNU build ID(ELF NT_GNU_BUILD_ID 注释)。这意味着最终的 ELF 文件将包含一个基于 Go build ID 的 GNU build ID(存储在 ELF 文件的 .note.gnu.build-id 部分中)。

3. 改进 Windows 平台上的 -linkmode=internal 标志:

当使用 -linkmode=internal 构建时,链接器现在会保留 C 对象文件中的 SEH(结构化异常处理)信息,通过将 .pdata 和 .xdata 部分复制到最终的二进制文件中。这有助于使用本地工具(如 WinDbg)进行调试和分析。

需要留意的是,在 Go1.22 以前,C 函数的 SEH 异常不会被 go 程序处理。所以,升级新版本后可能因为错误被正确处理而导致程序出现一些与之前不同的行为。

总结来说,这些改进使得链接器在不同平台上的行为更加一致,并且在 Windows 平台上改进了程序调试和分析能力。

2.5 工具链

1.Go command

Go 1.22 版本对 Go command 工具链进行了一些改造升级,包括 go work vendor、go mod init、go test 覆盖率汇总等,总体上通过 go work vendor 功能完善了对 go work 的支持,而 go mod init 与 go get 等工具上的调整则逐渐停止了对 GOPATH 模式的支持,符合工具的迭代变化演进规律。

● 新增 go work vendor 功能

我们都知道 go module 是 Go 1.11 版本之后官方推出的版本管理工具,并且从 Go 1.13 版本开始,go module 是 Go 语言默认的依赖管理工具,但是当本地有很多 module,且这些 module 存在相互依赖,本地开发就会存在诸多不便,因此 Go 1.18 引入了 workspace工作区模式,可以让开发者不用修改 go module 的 go.mod,就能同时对多个有依赖的 go module 进行本地开发。不过当时可能为了简化 workspace 模式的实现,并没有支持 vendor 模式,而 vendor 模式对于需要依赖隔离、离线部署、可重复等场景还是很有必要。

因而 Go 1.22 版本,支持了 go work vendor 功能,可以将 workspace 模式中的依赖放到 vendor ?录下,使用方法上与 go mod vendor 一致,只是将 vendor 能力支持到了 workspace 工作区模式下,详情可通过 go help work vendor 查看帮忙文档。

● go test 调整了单测覆盖率摘要展示

Go 1.22 版本之前,当某个包下没有单测时,显示如下:

? mymod/mypack [no test files]

Go 1.22 版本调整了单测覆盖率摘要展示,当某个包下没有单测时,会被当作是无覆盖:

mymod/mypack coverage: 0.0% of statements

● 其它一些变化

在传统的 GOPATH 模式下(即 GO111MODULE=off 时),不再支持在 Go Module 之外使用 go get。不过其它的构建编译命令,例如:go build 和 go test,则可以继续适用于传统的 GOPATH 程序。

此外,初始化命令 go mod init 将不再尝试从其他依赖工具(如 Gopkg.lock)的配置文件中导入模块依赖,从中也可以看出,GOPATH 时代的工具正逐渐退出并成为历史。

2.Trace

Go 1.5 版本推出了 trace 工具,用于支持对整个周期内发生的事件进行性能分析,在之后的几个版本中逐渐完善成熟,不过也一直存在追踪的开销很大及追踪的扩展性不强等问题。

Go 1.21 版本 runtime/trace 基于 Frame Pointer Unwinding 技术大幅降低了收集运行时 trace 的 CPU 开销,需要注意的是,目前该项技术仅在 GOARCH 架构为 amd64 和 arm64 时生效,在 1.21 版本之前,许多应用开销大约在 10-20% 的 CPU 之间,而该版本只需要 1-2%。

得益于 1.21 版本中 runtime/trace 收集效率的大幅改进,Go 1.22 版本中的 trace 工具也进行了适应性地调整以支持新的 trace 收集工具,解决了一些问题并且提高了各个子页面的可读性,以进一步提升开发者们基于 trace 定位问题的效率与便捷性,如下基于 trace 报告中的 goroutines 追踪页面可以感受到可读性上的变化:

旧页面:

新页面:

此外 Web UI 新增支持了面向线程的 trace 追踪展示:

随着 trace 收集开销的大幅降低,trace 工具的应用场景可能也会越来越广,催生更多的生态工具,例如 Flight recording,值得有兴趣的同学进一步关注。

3.Vet

go vet 工具是 Go 代码静态诊断器,可以用于检查代码中可能存在的各种问题,例如无法访问的代码、错误的锁使用、不必要的赋值、布尔运算错误等。工具检查选择的规则集主要考量正确性、频率和准确性等:

● 正确性:工具定位于检查代码潜在的编译或执行问题,而不是代码风格问题

● 频率:工具定位于希望开发人员频繁地执行,因此纳入的检查规则是需要能真正发现问题的规则

● 准确性:工具定位于极高的准确性,尽可能地不漏报也不错报,因此纳入的检查规则需要极高的准确性

因此,建议未使用的开发人员安排上该工具,纳入日常 CI 流程或者 precommit 时进行检查,详情可以通过 go tool vet help 查看已注册的检查器及其用法

Go1.22 版本的 go vet 工具,总的来说延续既有风格,并未做出特别大的调整,主要是基于 Go1.22 自身语言的一些变化,适应性地调整了对相应问题的检查:

● 增加了对 slice 进行 append 操作却未追加额外值时的告警

示例:

// 错误代码
func testAppend() {
    var s []string
    s = append(s)
}

当使用 go vet 检查时,将会报告类似如下告警:

# [command-line-arguments]
./main.go:3:6: append with no values

● 增加了当对 time.Since 进行错误 defer 时的告警

示例:

func testTimeSince() {
    t := time.Now()
    defer log.Println(time.Since(t)) // non-deferred call to time.Since
    tmp := time.Since(t)
    defer log.Println(tmp) // equivalent to the previous defer

    defer func() {
        log.Println(time.Since(t)) // a correctly deferred call to time.Since
    }()
}

当使用 go vet 检查时,将会报告类似如下告警:

# [command-line-arguments]
./main.go:3:20: call to time.Since is not deferred

● 增加了当使用 slog 打印日志却未正确传入键值对时的告警

func testSlog() {
    slog.Info("hello", 3, 3)
}

当使用 go vet 检查时,将会报告类似如下告警:

./main.go:2:21: slog.Info arg "3" should be a string or a slog.Attr (possible missing key or value)

2.6 运行时

运行时的变化主要在 GC 算法。在 Go 1.22 中,运行时会将基于类型的垃圾回收元数据保持在每个堆对象附近,从而可以将 Go 程序的 CPU 性能提升1~3%。此外,通过减少重复的元数据信息,大多数程序的内存开销也会下降约1%。

这一改进涉及到对象地址对齐问题,以前部分对象的地址是按16(甚至更大)字节边界对齐,现在只与8字节对齐。内存的分配更为精细化,大多数应用程序的内存开销也因此而变小。但是,如果某些对象的大小刚好在新的大小类边界上,那么这些对象可能会被移动到更大的内存块中,这些对象可能会占用更多的内存。由于这种应用程序占比较少,总体而言,内存开销是有小幅优化下降的。

此外,一些使用汇编指令的程序,由于要求内存地址对齐超过8字节,且依赖之前内存分配器的地址对齐功能,可能会在这个版本遇到兼容性问题。Go 团队给出了构建参数 GOEXPERIMENT=noallocheaders ,通过这个参数可以关闭新变化,回退到之前版本的对齐特性。这个参数仅用于临时过渡,后续 Go 版本会将其移除,因此如果有依赖的话需要尽快兼容适配。


三、小结

Go 1.22版本对语言、编译器、工具连、运行时、标准库都有一定程度的优化。既有代码通过本版本重新编译后性能上能有一定的提升。8月份“稳重求变”的 Go 语言如期发布了 1.23 版本,相关的新特性报告敬请期待。

参考资料

Go 1.22 is released

Profile-guided optimization - The Go Programming Language

math/rand/v2: revised API for math/rand · Issue #61716 · golang/go · GitHub

Range Over Function Types

More powerful Go execution traces

Tags:

标签列表
最新留言