标签 GC 下的文章

Go 1.14中值得关注的几个变化

可能是得益于2020年2月26日Go 1.14的发布,在2020年3月份的TIOBE编程语言排行榜上,Go重新进入TOP 10,而去年同期Go仅排行在第18位。虽然Go语言以及其他主流语言在榜单上的“上蹿下跳”让这个榜单的权威性饱受质疑:),但Go在这样的一个时间节点能进入TOP 10,对于Gopher和Go社区来说,总还是一个不错的结果。并且在一定层度上说明:Go在努力耕耘十年后,已经在世界主流编程语言之林中牢牢占据了自己的一个位置。

img{512x368}

图:TIOBE编程语言排行榜2020.3月榜单,Go语言重入TOP10

Go自从宣布Go1 Compatible后,直到这次的Go 1.14发布,Go的语法和核心库都没有做出不兼容的变化。这让很多其他主流语言的拥趸们觉得Go很“无趣”。但这种承诺恰恰是Go团队背后努力付出的结果,因此Go的每个发布版本都值得广大gopher尊重,每个发布版本都是Go团队能拿出的最好版本

下面我们就来解读一下Go 1.14的变化,看看这个新版本中有哪些值得我们重点关注的变化。

一. 语言规范

和其他主流语言相比,Go语言的语法规范的变化那是极其少的(广大Gopher们已经习惯了这个节奏:)),偶尔发布一个变化,那自然是要引起广大Gopher严重关注的:)。不过事先说明:只要Go版本依然是1.x,那么这个规范变化也是backward-compitable的

Go 1.14新增的语法变化是:嵌入接口的方法集可重叠。这个变化背后的朴素思想是这样的。看下面代码(来自这里):

type I interface { f(); String() string }
type J interface { g(); String() string }

type IJ interface { I; J }  ----- (1)
type IJ interface { f(); g(); String() string }  ---- (2)

代码中已知定义的I和J两个接口的方法集中都包含有String() string这个方法。在这样的情况下,我们如果想定义一个方法集合为Union(I, J)的新接口IJ,我们在Go 1.13及之前的版本中只能使用第(2)种方式,即只能在新接口IJ中重新书写一遍所有的方法原型,而无法像第(1)种方式那样使用嵌入接口的简洁方式进行。

Go 1.14通过支持嵌入接口的方法集可重叠解决了这个问题:

// go1.14-examples/overlapping_interface.go
package foo

type I interface {
    f()
    String() string
}
type J interface {
    g()
    String() string
}

type IJ interface {
    I
    J
}

在go 1.13.6上运行:

$go build overlapping_interface.go
# command-line-arguments
./overlapping_interface.go:14:2: duplicate method String

但在go 1.14上运行:

$go build overlapping_interface.go

// 一切ok,无报错

不过对overlapping interface的支持仅限于接口定义中,如果你要在struct定义中嵌入interface,比如像下面这样:

// go1.14-examples/overlapping_interface1.go
package main

type I interface {
    f()
    String() string
}

type implOfI struct{}

func (implOfI) f() {}
func (implOfI) String() string {
    return "implOfI"
}

type J interface {
    g()
    String() string
}

type implOfJ struct{}

func (implOfJ) g() {}
func (implOfJ) String() string {
    return "implOfJ"
}

type Foo struct {
    I
    J
}

func main() {
    f := Foo{
        I: implOfI{},
        J: implOfJ{},
    }
    println(f.String())
}

虽然Go编译器没有直接指出结构体Foo中嵌入的两个接口I和J存在方法的重叠,但在使用Foo结构体时,下面的编译器错误肯定还是会给出的:

$ go run overlapping_interface1.go
# command-line-arguments
./overlapping_interface1.go:37:11: ambiguous selector f.String

对于结构体中嵌入的接口的方法集是否存在overlap,go编译器似乎并没有严格做“实时”检查,这个检查被延迟到为结构体实例选择method的执行者环节了,就像上面例子那样。如果我们此时让Foo结构体 override一个String方法,那么即便I和J的方法集存在overlap也是无关紧要的,因为编译器不会再模棱两可,可以正确的为Foo实例选出究竟执行哪个String方法:

// go1.14-examples/overlapping_interface2.go

.... ....

func (Foo) String() string {
        return "Foo"
}

func main() {
        f := Foo{
                I: implOfI{},
                J: implOfJ{},
        }
        println(f.String())
}

运行该代码:

$go run overlapping_interface2.go
Foo

二. Go运行时

1. 支持异步抢占式调度

《Goroutine调度实例简要分析》一文中,我曾提到过这样一个例子:

// go1.14-examples/preemption_scheduler.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func deadloop() {
    for {
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

在只有一个P的情况下,上面的代码中deadloop所在goroutine将持续占据该P,使得main goroutine中的代码得不到调度(GOMAXPROCS=1的情况下),因此我们无法看到I got scheduled!字样输出。这是因为Go 1.13及以前的版本的抢占是”协作式“的,只在有函数调用的地方才能插入“抢占”代码(埋点),而deadloop没有给编译器插入抢占代码的机会。这会导致GC在等待所有goroutine停止时等待时间过长,从而导致GC延迟;甚至在一些特殊情况下,导致在STW(stop the world)时死锁。

Go 1.14采用了基于系统信号的异步抢占调度,这样上面的deadloop所在的goroutine也可以被抢占了:

// 使用Go 1.14版本编译器运行上述代码

$go run preemption_scheduler.go
I got scheduled!
I got scheduled!
I got scheduled!

不过由于系统信号可能在代码执行到任意地方发生,在Go runtime能cover到的地方,Go runtime自然会处理好这些系统信号。但是如果你是通过syscall包或golang.org/x/sys/unix在Unix/Linux/Mac上直接进行系统调用,那么一旦在系统调用执行过程中进程收到系统中断信号,这些系统调用就会失败,并以EINTR错误返回,尤其是低速系统调用,包括:读写特定类型文件(管道、终端设备、网络设备)、进程间通信等。在这样的情况下,我们就需要自己处理EINTR错误。一个最常见的错误处理方式就是重试。对于可重入的系统调用来说,在收到EINTR信号后的重试是安全的。如果你没有自己调用syscall包,那么异步抢占调度对你已有的代码几乎无影响。

Go 1.14的异步抢占调度在windows/arm, darwin/arm, js/wasm, and plan9/*上依然尚未支持,Go团队计划在Go 1.15中解决掉这些问题

2. defer性能得以继续优化

Go 1.13中,defer性能得到理论上30%的提升。我们还用那个例子来看看go 1.14与go 1.13版本相比defer性能又有多少提升,同时再看看使用defer和不使用defer的对比:

// go1.14-examples/defer_benchmark_test.go
package defer_test

import "testing"

func sum(max int) int {
    total := 0
    for i := 0; i < max; i++ {
        total += i
    }

    return total
}

func foo() {
    defer func() {
        sum(10)
    }()

    sum(100)
}

func Bar() {
    sum(100)
    sum(10)
}

func BenchmarkDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo()
    }
}
func BenchmarkWithoutDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Bar()
    }
}

我们分别用Go 1.13和Go 1.14运行上面的基准测试代码:

Go 1.13:

$go test -bench . defer_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkDefer-8              17873574            66.7 ns/op
BenchmarkWithoutDefer-8       26935401            43.7 ns/op
PASS
ok      command-line-arguments    2.491s

Go 1.14:

$go test -bench . defer_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkDefer-8              26179819            45.1 ns/op
BenchmarkWithoutDefer-8       26116602            43.5 ns/op
PASS
ok      command-line-arguments    2.418s

我们看到,Go 1.14的defer性能照比Go 1.13还有大幅提升,并且已经与不使用defer的性能相差无几了,这也是Go官方鼓励大家在性能敏感的代码执行路径上也大胆使用defer的原因。

img{512x368}

图:各个Go版本defer性能对比(图来自于https://twitter.com/janiszt/status/1215601972281253888)

3. internal timer的重新实现

鉴于go timer长期以来性能不能令人满意,Go 1.14几乎重新实现了runtime层的timer。其实现思路遵循了Dmitry Vyukov几年前提出的实现逻辑:将timer分配到每个P上,降低锁竞争;去掉timer thread,减少上下文切换开销;使用netpoll的timeout实现timer机制。

// $GOROOT/src/runtime/time.go

type timer struct {
        // If this timer is on a heap, which P's heap it is on.
        // puintptr rather than *p to match uintptr in the versions
        // of this struct defined in other packages.
        pp puintptr

}

// addtimer adds a timer to the current P.
// This should only be called with a newly created timer.
// That avoids the risk of changing the when field of a timer in some P's heap,
// which could cause the heap to become unsorted.

func addtimer(t *timer) {
        // when must never be negative; otherwise runtimer will overflow
        // during its delta calculation and never expire other runtime timers.
        if t.when < 0 {
                t.when = maxWhen
        }
        if t.status != timerNoStatus {
                badTimer()
        }
        t.status = timerWaiting

        addInitializedTimer(t)
}

// addInitializedTimer adds an initialized timer to the current P.
func addInitializedTimer(t *timer) {
        when := t.when

        pp := getg().m.p.ptr()
        lock(&pp.timersLock)
        ok := cleantimers(pp) && doaddtimer(pp, t)
        unlock(&pp.timersLock)
        if !ok {
                badTimer()
        }

        wakeNetPoller(when)
}
... ...

这样你的程序中如果大量使用time.After、time.Tick或者在处理网络连接时大量使用SetDeadline,使用Go 1.14编译后,你的应用将得到timer性能的自然提升

img{512x368}

图:切换到新timer实现后的各Benchmark数据

三. Go module已经production ready了

Go 1.14中带来的关于go module的最大惊喜就是Go module已经production ready了,这意味着关于go module的运作机制,go tool的各种命令和其参数形式、行为特征已趋稳定了。笔者从Go 1.11引入go module以来就一直关注和使用Go module,尤其是Go 1.13中增加go module proxy的支持,使得中国大陆的gopher再也不用为获取类似golang.org/x/xxx路径下的module而苦恼了。

Go 1.14中go module的主要变动如下:

a) module-aware模式下对vendor的处理:如果go.mod中go version是go 1.14及以上,且当前repo顶层目录下有vendor目录,那么go工具链将默认使用vendor(即-mod=vendor)中的package,而不是module cache中的($GOPATH/pkg/mod下)。同时在这种模式下,go 工具会校验vendor/modules.txt与go.mod文件,它们需要保持同步,否则报错。

在上述前提下,如要非要使用module cache构建,则需要为go工具链显式传入-mod=mod ,比如:go build -mod=mod ./...

b) 增加GOINSECURE,可以不再要求非得以https获取module,或者即便使用https,也不再对server证书进行校验。

c) 在module-aware模式下,如果没有建立go.mod或go工具链无法找到go.mod,那么你必须显式传入要处理的go源文件列表,否则go tools将需要你明确go.mod。比如:在一个没有go.mod的目录下,要编译一个hello.go,我们需要使用go build hello.go(hello.go需要显式放在命令后面),如果你执行go build .就会得到类似如下错误信息:

$go build .
go: cannot find main module, but found .git/config in /Users/tonybai
    to create a module there, run:
    cd .. && go mod init

也就是说在没有go.mod的情况下,go工具链的功能是受限的。

d) go module支持subversion仓库了,不过subversion使用应该很“小众”了。

要系统全面的了解go module的当前行为机制,建议还是通读一遍Go command手册中关于module的说明以及官方go module wiki

四. 编译器

Go 1.14 go编译器在-race和-msan的情况下,默认会执行-d=checkptr,即对unsafe.Pointer的使用进行合法性检查,主要检查两项内容:

  • 当将unsafe.Pointer转型为*T时,T的内存对齐系数不能高于原地址的

比如下面代码:

// go1.14-examples/compiler_checkptr1.go
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var byteArray = [10]byte{'a', 'b', 'c'}
    var p *int64 = (*int64)(unsafe.Pointer(&byteArray[1]))
    fmt.Println(*p)
}

以-race运行上述代码:

$go run -race compiler_checkptr1.go
fatal error: checkptr: unsafe pointer conversion

goroutine 1 [running]:
runtime.throw(0x11646fd, 0x23)
    /Users/tonybai/.bin/go1.14/src/runtime/panic.go:1112 +0x72 fp=0xc00004cee8 sp=0xc00004ceb8 pc=0x106d152
runtime.checkptrAlignment(0xc00004cf5f, 0x1136880, 0x1)
    /Users/tonybai/.bin/go1.14/src/runtime/checkptr.go:13 +0xd0 fp=0xc00004cf18 sp=0xc00004cee8 pc=0x1043b70
main.main()
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.14-examples/compiler_checkptr1.go:10 +0x70 fp=0xc00004cf88 sp=0xc00004cf18 pc=0x11283b0
runtime.main()
    /Users/tonybai/.bin/go1.14/src/runtime/proc.go:203 +0x212 fp=0xc00004cfe0 sp=0xc00004cf88 pc=0x106f7a2
runtime.goexit()
    /Users/tonybai/.bin/go1.14/src/runtime/asm_amd64.s:1373 +0x1 fp=0xc00004cfe8 sp=0xc00004cfe0 pc=0x109b801
exit status 2

checkptr检测到:转换后的int64类型的内存对齐系数严格程度要高于转化前的原地址(一个byte变量的地址)。int64对齐系数为8,而一个byte变量地址对齐系数仅为1。

  • 做完指针算术后,转换后的unsafe.Pointer仍应指向原先Go堆对象
compiler_checkptr2.go
package main

import (
    "unsafe"
)

func main() {
    var n = 5
    b := make([]byte, n)
    end := unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n+10))
    _ = end
}

运行上述代码:

$go run  -race compiler_checkptr2.go
fatal error: checkptr: unsafe pointer arithmetic

goroutine 1 [running]:
runtime.throw(0x10b618b, 0x23)
    /Users/tonybai/.bin/go1.14/src/runtime/panic.go:1112 +0x72 fp=0xc00003e720 sp=0xc00003e6f0 pc=0x1067192
runtime.checkptrArithmetic(0xc0000180b7, 0xc00003e770, 0x1, 0x1)
    /Users/tonybai/.bin/go1.14/src/runtime/checkptr.go:41 +0xb5 fp=0xc00003e750 sp=0xc00003e720 pc=0x1043055
main.main()
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.14-examples/compiler_checkptr2.go:10 +0x8d fp=0xc00003e788 sp=0xc00003e750 pc=0x1096ced
runtime.main()
    /Users/tonybai/.bin/go1.14/src/runtime/proc.go:203 +0x212 fp=0xc00003e7e0 sp=0xc00003e788 pc=0x10697e2
runtime.goexit()
    /Users/tonybai/.bin/go1.14/src/runtime/asm_amd64.s:1373 +0x1 fp=0xc00003e7e8 sp=0xc00003e7e0 pc=0x1092581
exit status 2

checkptr检测到转换后的unsafe.Pointer已经超出原先heap object: b的范围了,于是报错。

不过目前Go标准库依然尚未能完全通过checkptr的检查,因为有些库代码显然违反了unsafe.Pointer的使用规则

Go 1.13引入了新的Escape Analysis,Go 1.14中我们可以通过-m=2查看详细的逃逸分析过程日志,比如:

$go run  -gcflags '-m=2' compiler_checkptr2.go
# command-line-arguments
./compiler_checkptr2.go:7:6: can inline main as: func() { var n int; n = 5; b := make([]byte, n); end := unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n + 100)); _ = end }
./compiler_checkptr2.go:9:11: make([]byte, n) escapes to heap:
./compiler_checkptr2.go:9:11:   flow: {heap} = &{storage for make([]byte, n)}:
./compiler_checkptr2.go:9:11:     from make([]byte, n) (non-constant size) at ./compiler_checkptr2.go:9:11
./compiler_checkptr2.go:9:11: make([]byte, n) escapes to heap

五. 标准库

每个Go版本,变化最多的就是标准库,这里我们挑一个可能影响后续我们编写单元测试行为方式的变化说说,那就是testing包的T和B类型都增加了自己的Cleanup方法。我们通过代码来看一下Cleanup方法的作用:

// go1.14-examples/testing_cleanup_test.go
package main

import "testing"

func TestCase1(t *testing.T) {

    t.Run("A=1", func(t *testing.T) {
        t.Logf("subtest1 in testcase1")

    })
    t.Run("A=2", func(t *testing.T) {
        t.Logf("subtest2 in testcase1")
    })
    t.Cleanup(func() {
        t.Logf("cleanup1 in testcase1")
    })
    t.Cleanup(func() {
        t.Logf("cleanup2 in testcase1")
    })
}

func TestCase2(t *testing.T) {
    t.Cleanup(func() {
        t.Logf("cleanup1 in testcase2")
    })
    t.Cleanup(func() {
        t.Logf("cleanup2 in testcase2")
    })
}

运行上面测试:

$go test -v testing_cleanup_test.go
=== RUN   TestCase1
=== RUN   TestCase1/A=1
    TestCase1/A=1: testing_cleanup_test.go:8: subtest1 in testcase1
=== RUN   TestCase1/A=2
    TestCase1/A=2: testing_cleanup_test.go:12: subtest2 in testcase1
    TestCase1: testing_cleanup_test.go:18: cleanup2 in testcase1
    TestCase1: testing_cleanup_test.go:15: cleanup1 in testcase1
--- PASS: TestCase1 (0.00s)
    --- PASS: TestCase1/A=1 (0.00s)
    --- PASS: TestCase1/A=2 (0.00s)
=== RUN   TestCase2
    TestCase2: testing_cleanup_test.go:27: cleanup2 in testcase2
    TestCase2: testing_cleanup_test.go:24: cleanup1 in testcase2
--- PASS: TestCase2 (0.00s)
PASS
ok      command-line-arguments    0.005s

我们看到:

  • Cleanup方法运行于所有测试以及其子测试完成之后。

  • Cleanup方法类似于defer,先注册的cleanup函数后执行(比如上面例子中各个case的cleanup1和cleanup2)。

在拥有Cleanup方法前,我们经常像下面这样做:

// go1.14-examples/old_testing_cleanup_test.go
package main

import "testing"

func setup(t *testing.T) func() {
    t.Logf("setup before test")
    return func() {
        t.Logf("teardown/cleanup after test")
    }
}

func TestCase1(t *testing.T) {
    f := setup(t)
    defer f()
    t.Logf("test the testcase")
}

运行上面测试:

$go test -v old_testing_cleanup_test.go
=== RUN   TestCase1
    TestCase1: old_testing_cleanup_test.go:6: setup before test
    TestCase1: old_testing_cleanup_test.go:15: test the testcase
    TestCase1: old_testing_cleanup_test.go:8: teardown/cleanup after test
--- PASS: TestCase1 (0.00s)
PASS
ok      command-line-arguments    0.005s

有了Cleanup方法后,我们就不需要再像上面那样单独编写一个返回cleanup函数的setup函数了。

此次Go 1.14还将对unicode标准的支持从unicode 11 升级到 unicode 12 ,共增加了554个新字符。

六. 其他

超强的可移植性是Go的一个知名标签,在新平台支持方面,Go向来是“急先锋”。Go 1.14为64bit RISC-V提供了在linux上的实验性支持(GOOS=linux, GOARCH=riscv64)。

rust语言已经通过cargo-fuzz从工具层面为fuzz test提供了基础支持。Go 1.14也在这方面做出了努力,并且Go已经在向将fuzz test变成Go test的一等公民而努力。

七. 小结

Go 1.14的详细变更说明在这里可以查看。整个版本的milestone对应的issue集合在这里

不过目前Go 1.14在特定版本linux内核上会出现crash的问题,当然这个问题源于这些内核的一个已知bug。在这个issue中有关于这个问题的详细说明,涉及到的Linux内核版本包括:5.2.x, 5.3.0-5.3.14, 5.4.0-5.4.1。
本篇博客涉及的代码在这里可以下载。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

图解Go内存分配器

本文翻译自《A visual guide to Go Memory Allocator from scratch (Golang)》

当我刚开始尝试了解Go的内存分配器时,我发现这真是一件可以令人发疯的事情,因为所有事情似乎都像一个神秘的黑盒(让我无从下手)。由于几乎所有技术魔法都隐藏在抽象之下,因此您需要逐一剥离这些抽象层才能理解它们。

在这篇文章中,我们就来这么做(剥离抽象层去了解隐藏在其下面的技术魔法)。如果您想了解有关Go内存分配器的知识,那么本篇文章正适合您。

一. 物理内存(Physical Memory)和虚拟内存(Virtual Memory)

每个内存分配器都需要使用由底层操作系统管理的虚拟内存空间(Virtual Memory Space)。让我们看看它是如何工作的吧。

img{512x368}

物理存储单元的简单图示(不精确的表示)

单个存储单元(工作流程)的简要介绍:

  1. 地址线(address line, 作为开关的晶体管)提供了访问电容器的入口(数据到数据线(data line))。
  2. 当地址线中有电流流动时(显示为红色),数据线可能会写入电容器,因此电容器已充电,并且存储的逻辑值为“1”。
  3. 当地址线没有电流流动(显示为绿色)时,数据线可能不会写入电容器,因此电容器未充电,并且存储的逻辑值为“0”。
  4. 当处理器(CPU)需要从内存(RAM)中“读取”一个值时,会沿着“地址线”发送电流(关闭开关)。如果电容器保持电荷,则电流流经“ DATA LINE”(数据线)得到的值为1;否则,没有电流流过数据线,电容器将保持未充电状态,得到的值为0。

img{512x368}

物理内存单元如何与CPU交互的简单说明

数据总线(Data Bus):用于在CPU和物理内存之间传输数据。

让我们讨论一下地址线(Address Line)和可寻址字节(Addressable Bytes)。

img{512x368}

CPU和物理内存之间的地址线的表示

  1. DRAM中的每个“字节(BYTE)”都被分配有唯一的数字标识符(地址)。 但“物理字节的表示 != 地址线的数量”。(例如:16位Intel 8088,PAE)
  2. 每条“地址线”都可以发送1bit值,因此它可以表示给定字节地址中指定“bit”。
  3. 在图中,我们有32条地址线。因此,每个可寻址字节都将拥有一个“32bit”的地址。
[ 00000000000000000000000000000000 ] — 低内存地址
[ 11111111111111111111111111111111 ] — 高内存地址

4.由于每个字节都有一个32bit地址,所以我们的地址空间由2的32次方个可寻址字节(即4GB)组成。

因此,可寻址字节取决于地址线的总量,对于64位地址线(x86–64 CPU),其可寻址字节为2的64次方个,但是大多数使用64位指针的体系结构实际上使用48位地址线(AMD64 )和42位地址线(英特尔),理论上支持256TB的物理RAM(Linux 在x86–64上每个进程支持128TB以及4级页表(page table)和Windows每个进程则支持192TB)

由于实际物理内存的限制,因此每个进程都在其自己的内存沙箱中运行-“虚拟地址空间”,即虚拟内存

该虚拟地址空间中字节的地址不再与处理器在地址总线上放置的地址相同。因此,必须建立转换数据结构和系统,以将虚拟地址空间中的字节映射到物理内存地址上的字节。

虚拟地址长什么样呢?

img{512x368}

虚拟地址空间表示

因此,当CPU执行引用内存地址的指令时。第一步是将VMA(virtual memory address)中的逻辑地址转换为线性地址(liner address)。这个翻译工作由内存管理单元MMU(Memory Management Unit)完成。

img{512x368}

这不是物理图,仅是描述。为了简化,不包括地址翻译过程

由于此逻辑地址太大而无法单独管理(取决于各种因素),因此将通过页(page)对其进行管理。当必要的分页构造被激活后,虚拟内存空间将被划分为称为页的较小区域(大多数OS上页大小为4KB,可以更改)。它是虚拟内存中用于内存管理的最小单位。虚拟内存不存储任何内容,仅简单地将程序的地址空间映射到真实的物理内存空间上。

单个进程仅将VMA(虚拟内存地址)视为其地址。这样,当我们的程序请求更多“堆内存(heap memory)”时会发生什么呢?

img{512x368}

一段简单的用户请求更多堆内存的汇编代码

img{512x368}

增加堆内存

程序通过brk(sbrk/mmap等)系统调用请求更多内存。但内核实际上仅是更新了堆的VMA。

注意:此时,实际上并没有分配任何页帧,并且新页面也没有在物理内存存在。这也是VSZ与RSS之间的差异点。

二. 内存分配器

有了“虚拟地址空间”的基本概述以及堆内存增加的理解之后,内存分配器现在变得更容易说明了。

如果堆中有足够的空间来满足我们代码中的内存请求,则内存分配器可以在内核不参与的情况下满足该请求,否则它会通过系统调用brk扩大堆,通常会请求大量内存。(默认情况下,对于malloc而言,大量的意思是 > MMAP_THRESHOLD字节-128kB)。

但是,内存分配器的责任不仅仅是更新brk地址。其中一个主要的工作则是如何的降低内外部的内存碎片以及如何快速分配内存块。考虑按p1~p4的顺序,先使用函数malloc在程序中请求连续内存块,然后使用函数free(pointer)释放内存。

img{512x368}

外部内存碎片演示

在第4步,即使我们有足够的内存块,我们也无法满足对6个连续内存块分配的请求,从而导致内存碎片。

那么如何减少内存碎片呢?这个问题的答案取决于底层库使用的特定的内存分配算法。

我们将研究TCMalloc内存分配器,Go内存分配器采用的就是该内存分配器模型。

三. TCMalloc

TCMalloc(thread cache malloc)的核心思想是将内存划分为多个级别,以减少锁的粒度。在TCMalloc内部,内存管理分为两部分:线程内存和页堆(page heap)。

线程内存(thread memory)

每个内存页分为多级固定大小的“空闲列表”,这有助于减少碎片。因此,每个线程都会有一个无锁的小对象缓存,这使得在并行程序下分配小对象(<= 32k)非常高效。

img{512x368}

线程缓存(每个线程拥有此线程本地线程缓存)

页堆(page heap)

TCMalloc管理的堆由页集合组成,其中一组连续页的集合可以用span表示。当分配的对象大于32K时,将使用页堆进行分配。

img{512x368}

页堆(用于span管理)

如果没有足够的内存来分配小对象,内存分配器就会转到页堆以获取内存。如果还没有足够的内存,页堆将从操作系统中请求更多内存。

由于这种分配模型维护了一个用户空间的内存池,因此极大地提高了内存分配和释放的效率。

注意:尽管go内存分配器最初是基于tcmalloc的,但是现在已经有了很大的不同。

四. Go内存分配器

我们知道Go运行时会将Goroutines(G)调度到逻辑处理器(P)上执行。同样,基于TCMalloc模型的Go还将内存页分为67个不同大小级别。

如果您不熟悉Go调度程序,则可以在这里获取关于Go调度程序的相关知识。

img{512x368}

Go中的内存块的大小级别

Go默认采用8192B大小的页。如果这个页被分成大小为1KB的块,我们一共将拿到8块这样的页:

img{512x368}

将8 KB页面划分为1KB的大小等级(在Go中,页的粒度保持为8KB)

Go中的这些页面运行也通过称为mspan的结构进行管理。

选择要分配给每个尺寸级别的尺寸类别和页面计数(将页面数分成给定尺寸的对象),以便将分配请求圆整(四舍五入)到下一个尺寸级别最多浪费12.5%

mspan

简而言之,它是一个双向链表对象,其中包含页面的起始地址,它具有的页面的span类以及它包含的页面数。

img{512x368}

Go内存分配器中mspan的表示形式

mcache

与TCMalloc一样,Go为每个逻辑处理器(P)提供了一个称为mcache的本地内存线程缓存,因此,如果Goroutine需要内存,它可以直接从mcache中获取它而无需任何锁,因为在任何时间点只有一个Goroutine在逻辑处理器(P)上运行。

mcache包含所有级别大小的mspan作为缓存。

img{512x368}

Go中P,mcache和mspan之间的关系

由于每个P拥有一个mcache,因此从mcache进行分配时无需加锁。

对于每个级别,都有两种类型。
* scan —包含指针的对象。
* noscan —不包含指针的对象。

这种方法的好处之一是在进行垃圾收集时,GC无需遍历noscan对象。

什么Go mcache?

对象大小<= 32K字节的分配将直接交给mcache,后者将使用对应大小级别的mspan应对

当mcache没有可用插槽(slot)时会发生什么?

从mcentral mspan list中获取一个对应大小级别的新的mspan。

mcentral

mcentral对象集合了所有给定大小级别的span,每个mcentral是两个mspan列表。

  1. 空的mspanList — 没有空闲内存的mspan或缓存在mcache中的mspan的列表
  2. 非空mspanList – 仍有空闲内存的span列表。

当从mcentral请求新的Span时,它将从非空mspanList列表中获取(如果可用)。这两个列表之间的关系如下:当请求新的span时,该请求从非空列表中得到满足,并且该span被放入空列表中。释放span后,将根据span中空闲对象的数量将其放回非空列表。

img{512x368}

mcentral表示

每个mcentral结构都在mheap中维护。

mheap

mheap是在Go中管理堆的对象,且只有一个全局mheap对象。它拥有虚拟地址空间。

img{512x368}

mheap的表示

从上图可以看出,mheap具有一个mcentral数组。此数组包含每个大小级别span的mcentral。

central [numSpanClasses]struct {
      mcentral mcentral
        pad      [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}

由于我们对每个级别的span都有mcentral,因此当mcache从mcentral请求一个mspan时,仅涉及单个mcentral级别的锁,因此其他mache的不同级别mspan的请求也可以同时被处理。

padding确保将MCentrals以CacheLineSize字节间隔开,以便每个MCentral.lock获得自己的缓存行,以避免错误的共享问题。

那么,当该mcentral列表为空时会发生什么?mcentral将从mheap获取页以用于所需大小级别span的分配。

  • free [_MaxMHeapList]mSpanList:这是一个spanList数组。每个spanList中的mspan由1〜127(_MaxMHeapList-1)页组成。例如,free[3]是包含3个页面的mspan的链接列表。Free表示空闲列表,即尚未进行对象分配。它对应于忙碌列表(busy list)。

  • freelarge mSpanList:mspans列表。每个mspan的页数大于127。Go内存分配器以mtreap数据结构来维护它。对应busyLarge。

大小> 32k的对象是一个大对象,直接从mheap分配。这些较大的请求需要中央锁(central lock),因此在任何给定的时间点只能满足一个P的请求

五. 对象分配流程

  • 大小> 32k是一个大对象,直接从mheap分配。
  • 大小<16B,使用mcache的tiny分配器分配
  • 大小在16B〜32k之间,计算要使用的sizeClass,然后在mcache中使用相应的sizeClass的块分配
  • 如果与mcache对应的sizeClass没有可用的块,则向mcentral发起请求。
  • 如果mcentral也没有可用的块,则向mheap请求。mheap使用BestFit查找最合适的mspan。如果超出了申请的大小,则会根据需要进行划分,以返回用户所需的页面数。其余页面构成一个新的mspan,并返回mheap空闲列表。
  • 如果mheap没有可用的span,请向操作系统申请一组新的页(至少1MB)。

但是Go在OS级别分配的页面甚至更大(称为arena)。分配大量页面将分摊与操作系统进行对话的成本。

所有请求的堆内存都来自于arena。让我们看看arena是什么。

六. Go虚拟内存

让我们看一个简单go程序的内存。

func main(){
    for {}
}

img{512x368}

程序的进程状态

因此,即使是简单的go程序,占用的虚拟空间也是大约100MB而RSS只有696kB。让我们尝试首先找出这种差异的原因。

img{512x368}

map和smap统计信息

因此,内存区域的大小约为〜2MB, 64MB and 32MB。这些是什么?

Arena

原来,Go中的虚拟内存布局由一组arena组成。初始堆映射是一个arena,即64MB(基于go 1.11.5)。

img{512x368}

当前在不同系统上的arena大小。

因此,当前根据程序需要,内存以较小的增量进行映射,并且它以一个arena(〜64MB)开始。

这是可变的。早期的go保留连续的虚拟地址,在64位系统上,arena大小为512 GB。(如果分配足够大并且被mmap拒绝,会发生什么?)

这个arena集合是我们所谓的堆。Go以8192B大小粒度的页面管理每个arena。

img{512x368}

单个arena(64 MB)。

Go还有两个span和bitmap块。它们都在堆外分配,并存储着每个arena的元数据。它主要在垃圾收集期间使用(因此我们现在将其保留)。

我们刚刚讨论过的Go中的内存分配策略,但这些也仅是奇妙多样的内存分配的一些皮毛。

但是,Go内存管理的总体思路是使用不同的内存结构为不同大小的对象使用不同的缓存级别内存来分配内存。将从操作系统接收的单个连续地址块分割为多级缓存以减少锁的使用,从而提高内存分配效率,然后根据指定的大小分配内存分配,从而减少内存碎片,并在内存释放houhou有利于更快的GC。

现在,我将向您提供此Go Memory Allocator的全景图。

img{512x368}

运行时内存分配器的可视化全景图。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats