找回密码
 立即注册
首页 业界区 安全 Go 1.19 相比 Go 1.18 有哪些值得注意的改动? ...

Go 1.19 相比 Go 1.18 有哪些值得注意的改动?

映各 6 天前
本系列旨在梳理 Go 的 release notes 与发展史,来更加深入地理解 Go 语言设计的思路。
https://go.dev/doc/go1.19
Go 1.19 值得关注的改动:

  • 内存模型与原子操作 : Go 的 内存模型(memory model) 已更新,与 C、C++、Java 等语言的 模型 对齐,以确保持续一致性。同时,sync/atomic 包引入了新的 原子类型(atomic types),如 atomic.Int64 和 atomic.Pointer[T],简化了原子操作并提高了类型安全性。
  • go 命令 : 改进了构建信息的包含(-trimpath)、go generate/test 的环境一致性、go env 的输出处理,并增强了 go list -json 的灵活性和性能,同时缓存了部分模块加载信息以加速 go list。
  • vet 工具 : 新增检查,用于发现 errors.As 的第二个参数误用 *error 类型的常见错误。
  • Runtime : 引入了 软内存限制(soft memory limit),可通过 GOMEMLIMIT 环境变量或 runtime/debug.SetMemoryLimit 函数进行设置,允许程序在接近内存上限时更有效地利用资源,并与 GOGC 协同工作。
  • 编译器、汇编器与链接器 : 编译器通过 跳转表(jump table) 优化了大型 switch 语句(在 amd64 和 arm64 架构下提速约 20%);编译器和汇编器现在强制要求 -p=importpath 标志来构建可链接的对象文件;链接器在 ELF 平台使用标准的压缩 DWARF 格式。
下面是一些值得展开的讨论:
Go 1.19 修订内存模型并引入新的原子类型

Go 1.19 对其内存模型进行了修订,主要目标是与 C, C++, Java, JavaScript, Rust, 和 Swift 等主流语言使用的内存模型保持一致。需要注意的是,Go 仅提供 顺序一致(sequentially consistent) 的原子操作,而不支持其他语言中可能存在的更宽松的内存排序形式。
伴随着内存模型的更新,sync/atomic 包引入了一系列新的原子类型,包括 atomic.Bool, atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Uintptr, 和 atomic.Pointer[T]。
这些新类型的主要优势在于:

  • 类型安全 :它们封装了底层的值,强制所有访问都必须通过原子 API 进行,避免了意外的非原子读写操作。
  • 简化指针操作 :atomic.Pointer[T] 泛型类型避免了在调用点将指针转换为 unsafe.Pointer 的需要,使得代码更清晰、更安全。
  • 自动对齐 :atomic.Int64 和 atomic.Uint64 类型在结构体中或分配内存时,即使在 32 位系统上也会自动保证 64 位对齐。这对于保证原子操作的正确性至关重要。
旧方式(Go 1.18 及之前)
通常需要直接使用 sync/atomic 包提供的函数,并对基本类型进行操作。例如,对一个共享的 int64 变量进行原子更新:
  1. package main
  2. import (
  3.     "fmt"
  4.     "sync"
  5.     "sync/atomic"
  6. )
  7. func main() {
  8.     var counter int64
  9.     var wg sync.WaitGroup
  10.     // 启动多个 goroutine 并发增加 counter
  11.     for i := 0; i < 100; i++ {
  12.         wg.Add(1)
  13.         go func() {
  14.             defer wg.Done()
  15.             // 使用 atomic 函数进行原子增操作
  16.             atomic.AddInt64(&counter, 1)
  17.         }()
  18.     }
  19.     wg.Wait()
  20.     // 使用 atomic 函数进行原子读操作
  21.     finalValue := atomic.LoadInt64(&counter)
  22.     fmt.Println("Final counter:", finalValue) // 输出: Final counter: 100
  23. }
复制代码
对于指针类型,之前可能需要 atomic.Value 或者结合 unsafe.Pointer 使用 atomic.LoadPointer/StorePointer。
新方式(Go 1.19 及之后)
使用新的原子类型,代码更简洁,类型约束更强。
  1. package main
  2. import (
  3.     "fmt"
  4.     "sync"
  5.     "sync/atomic"
  6. )
  7. func main() {
  8.     // 使用新的 atomic.Int64 类型
  9.     var counter atomic.Int64
  10.     var wg sync.WaitGroup
  11.     // 启动多个 goroutine 并发增加 counter
  12.     for i := 0; i < 100; i++ {
  13.         wg.Add(1)
  14.         go func() {
  15.             defer wg.Done()
  16.             // 直接调用类型的方法进行原子增操作
  17.             counter.Add(1)
  18.         }()
  19.     }
  20.     wg.Wait()
  21.     // 直接调用类型的方法进行原子读操作
  22.     finalValue := counter.Load()
  23.     fmt.Println("Final counter:", finalValue) // 输出: Final counter: 100
  24. }
复制代码
对于指针,atomic.Pointer[T] 提供了类型安全的原子读写能力:
  1. package main
  2. import (
  3.     "fmt"
  4.     "sync/atomic"
  5. )
  6. type Config struct {
  7.     Version string
  8.     Data    map[string]string
  9. }
  10. func main() {
  11.     var currentConfig atomic.Pointer[Config]
  12.     // 初始化配置
  13.     initialConfig := &Config{Version: "v1", Data: map[string]string{"key": "value1"}}
  14.     currentConfig.Store(initialConfig)
  15.     // 读取配置
  16.     cfg1 := currentConfig.Load()
  17.     fmt.Printf("Config v%s: %v\n", cfg1.Version, cfg1.Data)
  18.     // 原子地更新配置
  19.     newConfig := &Config{Version: "v2", Data: map[string]string{"key": "value2", "new": "added"}}
  20.     currentConfig.Store(newConfig)
  21.     cfg2 := currentConfig.Load()
  22.     fmt.Printf("Config v%s: %v\n", cfg2.Version, cfg2.Data)
  23. }
复制代码
  1. Config vv1: map[key:value1]
  2. Config vv2: map[key:value2 new:added]
复制代码
这种方式相比旧的 atomic.Value 或 unsafe.Pointer 操作,类型更安全,意图更明确。
go 命令的改进

Go 1.19 对 go 命令进行了多项增强和调整,主要涉及构建过程、环境设置、信息查询等方面。

  • -trimpath 标志信息嵌入 :如果在 go build 时设置了 -trimpath 标志(用于从编译出的二进制文件中移除本地构建路径信息),这个设置现在会被记录在二进制文件的构建信息中。可以通过 go version -m  或 debug.ReadBuildInfo 来查看。
  1. # 编译时加入 -trimpath
  2. go build -trimpath -o myapp main.go
  3. # 查看二进制文件的构建信息
  4. go version -m ./myapp
  5. # 输出会包含类似 build -trimpath=true 的信息
复制代码

  • go generate 明确设置 GOROOT :现在 go generate 会在其执行子进程的环境变量中明确设置 GOROOT。这确保了即使代码生成器本身是使用 -trimpath 构建的(可能导致其无法自动找到 GOROOT),它也能定位到正确的 Go 安装根目录。
  • go test 和 go generate 的 PATH 调整 :这两个命令现在会将 $GOROOT/bin 放在子进程 PATH 环境变量的开头。这样做可以保证当测试代码或代码生成器需要执行 go 命令时,它们会调用到与父进程相同版本的 go 工具链,避免潜在的版本冲突。
  • go env 输出的空格处理 :对于包含空格的环境变量值(如 CGO_CFLAGS, CGO_CPPFLAGS, CGO_CXXFLAGS, CGO_FFLAGS, CGO_LDFLAGS, GOGCCFLAGS),go env 现在会在输出时给它们加上引号,使得输出结果更易于被脚本等其他工具解析。
  • go list -json 支持字段选择 :go list -json 命令现在可以接受一个逗号分隔的字段列表,用于指定需要输出的 JSON 字段。如果指定了列表,go list 只会计算和输出这些字段,这在某些情况下可以显著提高性能,因为它避免了计算不需要的字段。这也可能抑制某些只在计算未请求字段时才会出现的错误。
  1. # 仅获取当前模块的导入路径和直接依赖
  2. go list -json=ImportPath,Deps .
复制代码

  • go list 模块加载缓存 :go 命令现在会缓存加载某些模块所需的信息,这有望加速部分 go list 命令的调用。
vet 工具新增 errors.As 使用检查

vet 工具的 errorsas 检查器现在增加了一项功能:检测 errors.As 函数的第二个参数是否被错误地传递了 *error 类型。这是一个常见的错误。
errors.As 函数用于检查错误链中是否存在特定类型的错误,并将其赋值给一个变量。其签名如下:
  1. func As(err error, target interface{}) bool
复制代码
正确的用法是,target 参数必须是一个指向 具体错误类型 的指针,或者是一个指向实现了 error 接口的任意类型的指针。errors.As 会遍历 err 的错误链,如果找到一个错误可以赋值给 target 指向的变量,就进行赋值并返回 true。
常见的错误用法
开发者有时可能会错误地传递一个 *error (指向 error 接口的指针)给 target:
  1. package main
  2. import (
  3.     "errors"
  4.     "fmt"
  5.     "os"
  6. )
  7. func main() {
  8.     // 示例错误,包装了 *os.PathError
  9.     err := fmt.Errorf("wrapping a file error: %w", &os.PathError{Op: "open", Path: "/no/such/file", Err: errors.New("file not found")})
  10.     var genericErr error // 声明一个 error 接口类型的变量
  11.     // 错误用法:将 *error 类型的指针传递给 errors.As
  12.     // 这几乎永远不是你想要的,因为它只会检查错误链中是否有某个值可以赋给一个 error 接口
  13.     // 这通常没有意义,因为链中的任何错误都可以赋值给 error 接口
  14.     if errors.As(err, &genericErr) {
  15.         // 这段代码几乎总会执行 (只要 err != nil),但 genericErr 会被赋值为链中的第一个错误
  16.         // 这并不是 errors.As 的设计意图
  17.         fmt.Printf("Found an error (incorrectly): %v\n", genericErr)
  18.     } else {
  19.         fmt.Println("No error found (incorrectly)")
  20.     }
  21. }
复制代码
  1. piperliu@go-x86:~/code/playground$ go run main.go
  2. Found an error (incorrectly): wrapping a file error: open /no/such/file: file not found
  3. piperliu@go-x86:~/code/playground$ go vet main.go
  4. # command-line-arguments
  5. ./main.go:17:8: second argument to errors.As should not be *error
复制代码
Go 1.19 的 vet 会对上述错误用法发出警告。
正确的用法
应该传递一个指向 具体错误类型 变量的指针。
  1. package main
  2. import (
  3.     "errors"
  4.     "fmt"
  5.     "os"
  6. )
  7. func main() {
  8.     // 示例错误,包装了 *os.PathError
  9.     err := fmt.Errorf("wrapping a file error: %w", &os.PathError{Op: "open", Path: "/no/such/file", Err: errors.New("file not found")})
  10.     var pathErr *os.PathError // 声明一个具体错误类型的指针变量
  11.     // 正确用法:传递 *os.PathError 类型的指针
  12.     if errors.As(err, &pathErr) {
  13.         // 如果错误链中存在 *os.PathError 类型的错误,pathErr 会被赋值
  14.         fmt.Printf("Successfully found PathError: Op=%s, Path=%s\n", pathErr.Op, pathErr.Path)
  15.     } else {
  16.         fmt.Println("PathError not found in chain")
  17.     }
  18. }
复制代码
  1. go run main.go
  2. Successfully found PathError: Op=open, Path=/no/such/file
  3. piperliu@go-x86:~/code/playground$ go vet main.go
复制代码
这个新的 vet 检查有助于开发者及早发现并修正这种对 errors.As 的误用。
Runtime 的软内存限制及其他改进

Go 1.19 在 Runtime 层面引入了重要的 软内存限制(soft memory limit) 功能,并包含其他多项优化。
软内存限制

  • 目的 :提供一种机制来限制 Go 程序使用的总内存量,以提高在容器化环境等资源受限场景下的资源利用率。
  • 范围 :这个限制覆盖 Go 堆(heap)以及所有由 Runtime 管理的内存(例如 goroutine 栈、GC 元数据等)。它 不包括 程序二进制文件本身的内存映射、其他语言(如 C)管理的内存,以及操作系统为 Go 程序保留的内存(如某些内核缓冲区)。
  • 配置 :可以通过设置 GOMEMLIMIT 环境变量(例如 GOMEMLIMIT=1024MiB)或在代码中调用 runtime/debug.SetMemoryLimit(limit int64) 来管理。
  • 与 GOGC 的关系 :软内存限制与 GOGC(或 runtime/debug.SetGCPercent)协同工作。即使设置 GOGC=off,只要设置了 GOMEMLIMIT,Runtime 仍然会尝试遵守这个内存限制,通过更频繁地触发 GC 来控制内存增长。这使得程序能够始终最大限度地利用其被分配的内存限额。
  • GC CPU 限制器 :当程序的活动堆大小接近软内存限制时,为了防止 GC 过于频繁(称为 GC 抖动/thrashing)而严重影响程序性能,Runtime 会尝试将 GC 的 CPU 利用率限制在 50% 以内(不包括空闲时间)。这意味着 Runtime 宁愿稍微超出内存限制,也不愿完全阻止应用程序的进展。可以通过新的 运行指标(runtime metric) /gc/limiter/last-enabled:gc-cycle 查看该限制器最后一次生效的 GC 周期。
  • 稳定性与限制 :对于较大的内存限制(数百 MB 或更多),该功能是稳定且生产就绪的。但对于非常小的限制(几十 MB 或更少),由于外部延迟因素(如操作系统调度)的影响,限制可能不那么精确(详见 issue 52433)。
其他 Runtime 改进

  • 空闲状态下的 GC 工作线程 :当应用程序足够空闲以至于触发周期性 GC 时,Runtime 现在会调度更少的 GC 工作 goroutine 在空闲的操作系统线程上运行,以减少不必要的资源消耗。
  • 初始 Goroutine 栈大小 :Runtime 现在会根据 goroutine 历史平均栈使用量来分配初始栈大小。这旨在减少平均情况下早期栈增长和复制的开销,代价是对于栈使用量远低于平均值的 goroutine 可能会浪费最多 2 倍的空间。
  • Unix 文件描述符限制(RLIMIT_NOFILE) :在 Unix 操作系统上,导入了 os 包的 Go 程序现在会自动将进程的打开文件描述符软限制(soft limit)提高到硬限制(hard limit)允许的最大值。这是为了解决某些系统上为了兼容旧的 C 程序(使用 select 系统调用)而设置的过低的人为限制。Go 程序(尤其是并发处理大量文件时,如 gofmt)经常因此耗尽文件描述符。此更改的一个潜在影响是,如果 Go 程序再启动旧的 C 程序作为子进程,这些子进程可能会以过高的文件描述符限制运行。这可以通过在启动 Go 程序之前设置较低的硬限制来解决。
  • 简化不可恢复错误的回溯信息 :对于不可恢复的致命错误(如并发 map 写入、解锁未锁定的互斥锁),现在默认打印更简洁的回溯信息,不包含 Runtime 的元数据(类似于 panic 的致命错误)。除非设置了 GOTRACEBACK=system 或 crash,才会打印包含完整元数据的详细回溯信息。Runtime 内部的致命错误总是包含完整元数据。
  • 调试器注入函数调用(ARM64) :在 ARM64 架构上增加了对调试器注入函数调用的支持。这使得开发者在使用支持此功能的更新版调试器时,可以在交互式调试会话中调用程序中的函数。
  • 地址消毒器(Address Sanitizer)改进 :Go 1.18 中引入的 地址消毒器(address sanitizer) 支持现在能更精确地处理函数参数和全局变量。
编译器、汇编器与链接器的更新

Go 1.19 在构建工具链的底层组件方面也有一些重要的变化。
编译器 (Compiler)

  • 大型 switch 语句优化 :编译器现在使用 跳转表(jump table) 来实现包含大量 case 的整数和字符串 switch 语句。这可以带来显著的性能提升,根据具体情况,速度可能提高约 20%。此优化目前仅适用于 GOARCH=amd64 和 GOARCH=arm64 架构。
例如,一个有许多字符串 case 的 switch:
  1. func handleCommand(cmd string) {
  2.     switch cmd {
  3.     case "START":
  4.         // ...
  5.     case "STOP":
  6.         // ...
  7.     case "RESTART":
  8.         // ...
  9.     // ... 很多其他 case ...
  10.     case "STATUS":
  11.         // ...
  12.     default:
  13.         // ...
  14.     }
  15. }
复制代码
在 Go 1.19 中,如果这个 switch 足够大,编译器(在支持的架构上)会生成更高效的跳转表代码,而不是一系列的比较和跳转。

  • 强制要求 -p=importpath 标志 :Go 编译器现在要求必须提供 -p=importpath 标志才能构建一个可链接的对象文件 (.o 文件)。go build 命令和 Bazel 构建系统已经会自动提供这个标志。如果你有自定义的构建系统直接调用 Go 编译器(compile),你需要确保传递了这个标志。importpath 通常是包的导入路径。
  • 移除 -importmap 标志 :Go 编译器不再接受 -importmap 标志。直接调用编译器的构建系统必须改为使用 -importcfg 标志来提供导入路径到实际文件路径的映射。go build 会自动处理这个。
汇编器 (Assembler)

  • 强制要求 -p=importpath 标志 :与编译器类似,Go 汇编器(asm)现在也要求必须提供 -p=importpath 标志才能构建可链接的对象文件。同样,go build 会处理好,但直接调用汇编器的系统需要自行添加。
链接器 (Linker)

  • ELF 平台使用标准压缩 DWARF 格式 :在 ELF 格式的目标平台(如 Linux)上,链接器现在默认使用标准的 gABI 格式(SHF_COMPRESSED)来压缩 DWARF 调试信息段,取代了之前使用的非标准的 .zdebug 格式。这有助于提高与其他工具链(如 GDB、objdump 等)的兼容性。
这些改变主要影响性能(switch 优化)、构建系统的维护者(需要调整对编译器/汇编器的直接调用)以及调试信息格式的标准化。对于大多数使用 go build 的开发者来说,后两项更改是透明的。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册