Blog

heapArena结构

heapArena结构 #

heapArena 的结构相当于 Go 的一个内存块,在 x86-64 架构下的 Linux 系统上,一个 heapArena 维护的内存空间大小是 64MB。该结构中存放了 ArenaSize/PageSize 长度的 mspan 数组,heapArena 结构的 spans 变量,用来精确管理每一个内存页。而整个 arena 内存空间的基址则存放在 zeroedBase 中。 heapArena 结构的部分定义如下:

type heapArena struct {
    ...
    spans [pagesPerArena]*mspan
    zeroedBase uintptr
}

Viewpoint #

From #

24 | GC实例:Python和Go的内存管理机制是怎样的?

mspan内存管理单元

mspan内存管理单元

mspan内存管理单元 #

Go 的内存管理基本单元是 mspan,每个 mspan 中会维护着一块连续的虚拟内存空间,内存的起始地址由 startAddr 来记录。每个 mspan 存储的内存空间大小都是内存页的整数倍,由 npages 来保存。不过你需要注意的是,这里内存页并非是操作系统的物理页大小,Go 的内存页大小设置的是 8KB。mspan 结构的部分定义如下:

//snap/go/current/src/runtime/mheap.go
type mspan struct {
    next *mspan     // next span in list, or nil if none
    prev *mspan     // previous span in list, or nil if none
    startAddr uintptr // address of first byte of span aka s.base()
    npages    uintptr // number of pages in span
    ...
    spanclass   spanClass     // size class and noscan (uint8)
    ...
}

Viewpoint #

From #

24 | GC实例:Python和Go的内存管理机制是怎样的?

...

流水线冒泡(Pipeline Bubble)

流水线冒泡(Pipeline Bubble) #

对于同一个寄存器或者内存地址的操作,都有明确强制的顺序要求。而这个顺序操作的要求,也为我们使用流水线带来了很大的挑战。因为流水线架构的核心,就是在前一个指令还没有结束的时候,后面的指令就要开始执行。

所以,我们需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个办法,就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。

流水线停顿的办法很容易理解。如果我们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“再等等”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险,我们就可以决定,让整个流水线停顿一个或者多个周期。

时钟信号会不停地在 0 和 1 之前自动切换。其实,我们并没有办法真的停顿下来。流水线的每一个操作步骤必须要干点儿事情。所以,在实践过程中,我们并不是让流水线停下来,而是在执行后面的操作步骤前面,插入一个 NOP 操作,也就是执行一个其实什么都不干的操作。

这个插入的指令,就好像一个水管(Pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送水到下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,我们的流水线停顿,又被叫作流水线冒泡(Pipeline Bubble)的原因。

Viewpoint #

From #

22 | 冒险和预测(一):hazard是“危”也是“机”

TCMalloc的分配思想

TCMalloc的分配思想 #

在 TCMalloc 中,“TC”是 Thread Cache 的意思,其核心思想是: TCMalloc 会给每个线程分配一个 Thread-Local Cache,对于每个线程的分配请求,就可以从自己的 Thread-Local Cache 区间来进行分配。此时因为不会涉及多线程操作,所以并不需要进行加锁,从而减少了因为锁竞争而引起的性能损耗。

而当 Thread-Local Cache 空间不足的时候,才向下一级的内存管理器请求新的空间。TCMalloc 引入了 Thread cache、Central cache 以及 Page heap 三个级别的管理器来管理内存,可以充分利用不同级别下的性能优势。TCMalloc 的多级管理机制非常类似计算机系统结构的内存多级缓存机制。

Viewpoint #

From #

24 | GC实例:Python和Go的内存管理机制是怎样的?

alias类型继承的方法集合

Content #

基于类型别名(type alias)定义的新类型有没有“继承”原类型的方法集合呢?我们还是来看一个例子:

type T struct{}
func (T) M1()  {}
func (*T) M2() {}
type T1 = T
func main() {
    var t T
    var pt *T
    var t1 T1
    var pt1 *T1
    dumpMethodSet(t)
    dumpMethodSet(t1)
    dumpMethodSet(pt)
    dumpMethodSet(pt1)
}

我们看一下这个例子的输出结果:

main.T's method set:
- M1
main.T's method set:
- M1
*main.T's method set:
- M1
- M2
*main.T's method set:
- M1
- M2

我们看到, dumpMethodSet 函数甚至都无法识别出“类型别名”,无论类型别名还是原类型,输出的都是原类型的方法集合。

无论原类型是接口类型还是非接口类型,类型别名都与原类型拥有完全相同的方法集合。

Viewpoint #

From #

defined类型继承的方法集合

defined类型继承的方法集合 #

Go 语言中,凡通过类型声明语法声明的类型都被称为 defined 类型:

type I interface {
    M1()
    M2()
}
type T int
type NT T // 基于已存在的类型T创建新的defined类型NT
type NI I // 基于已存在的接口类型I创建新defined接口类型NI

新定义的 defined 类型与原 defined 类型是不同的类型,那么它们的方法集合上又会有什么关系呢?新类型是否“继承”原 defined 类型的方法集合呢?

  1. 基于接口类型创建的 defined 接口类型,新类型的方法集合与原接口类型的方法集合是一致的。
  2. 基于非接口类型创建 defined 非接口类型,新类型不会“继承”原类型的任何方法。

对于第2点,我们通过下面例子来看一下:

package main
type T struct{}
func (T) M1()  {}
func (*T) M2() {}
type T1 T
func main() {
  var t T
  var pt *T
  var t1 T1
  var pt1 *T1
  dumpMethodSet(t)
  dumpMethodSet(t1)
  dumpMethodSet(pt)
  dumpMethodSet(pt1)
}

在这个例子中,我们基于一个 defined 的非接口类型 T 创建了新 defined 类型 T1,并且分别输出 T1 和 *T1 的方法集合来确认它们是否“继承”了 T 的方法集合。运行这个示例程序,我们得到如下结果:

main.T's method set:
- M1
main.T1's method set is empty!
*main.T's method set:
- M1
- M2
*main.T1's method set is empty!

从输出结果上看,新类型 T1 并没有“继承”原 defined 类型 T 的任何一个方法。从逻辑上来说,这也符合 T1 与 T 是两个不同类型的语义。

...

结构体中嵌入结构体后的方法集合

结构体中嵌入结构体后的方法集合 #

在结构体类型中嵌入结构体类型,为 Gopher 们提供了一种“实现继承”的手段,外部的结构体类型 T 可以“继承”嵌入的结构体类型的所有方法的实现。并且,无论是 T 类型的变量实例还是 *T 类型变量实例,都可以调用所有“继承”的方法。但这种情况下,带有嵌入类型的新类型究竟“继承”了哪些方法,我们还要通过下面这个具体的示例来看一下。

type T1 struct{}
func (T1) T1M1()   { println("T1's M1") }
func (*T1) PT1M2() { println("PT1's M2") }
type T2 struct{}
func (T2) T2M1()   { println("T2's M1") }
func (*T2) PT2M2() { println("PT2's M2") }
type T struct {
    T1
    *T2
}
func main() {
    t := T{
        T1: T1{},
        T2: &T2{},
    }
    dumpMethodSet(t)
    dumpMethodSet(&t)
}

结构体类型 T 有两个嵌入字段,分别是 T1 和 *T2,我们知道 T1 与 *T1、T2 与 *T2 的方法集合是不同的:

  1. T1 的方法集合包含:T1M1;
  2. *T1 的方法集合包含:T1M1、PT1M2;
  3. T2 的方法集合包含:T2M1;
  4. *T2 的方法集合包含:T2M1、PT2M2。

它们作为嵌入字段嵌入到 T 中后,对 T 和 *T 的方法集合的影响也是不同的。我们运行一下这个示例,看一下输出结果:

...

用结构体嵌入接口简化单元测试的编写

用结构体嵌入接口简化单元测试的编写 #

结构体类型嵌入接口类型在日常编码中有一个妙用,就是可以简化单元测试的编写。由于嵌入某接口类型的结构体类型的方法集合包含了这个接口类型的方法集合,这就意味着,这个结构体类型也是它嵌入的接口类型的一个实现。即便结构体类型自身并没有实现这个接口类型的任意一个方法,也没有关系。我们来看一个直观的例子:

package employee
type Result struct {
    Count int
}
func (r Result) Int() int { return r.Count }
type Rows []struct{}
type Stmt interface {
    Close() error
    NumInput() int
    Exec(stmt string, args ...string) (Result, error)
    Query(args []string) (Rows, error)
}
// 返回男性员工总数
func MaleCount(s Stmt) (int, error) {
    result, err := s.Exec("select count(*) from employee_tab where gender=?", "1")
    if err != nil {
        return 0, err
    }
    return result.Int(), nil
}

在这个例子中,我们有一个 employee 包,这个包中的方法 MaleCount,通过传入的 Stmt 接口的实现从数据库获取男性员工的数量。

现在我们的任务是要对 MaleCount 方法编写单元测试代码。对于这种依赖外部数据库操作的方法,我们的惯例是使用“伪对象(fake object)”来冒充真实的 Stmt 接口实现。

不过现在有一个问题,那就是 Stmt 接口类型的方法集合中有四个方法,而 MaleCount 函数只使用了 Stmt 接口的一个方法 Exec。如果我们针对每个测试用例所用的伪对象都实现这四个方法,那么这个工作量有些大。那么这个时候,我们怎样快速建立伪对象呢?结构体类型嵌入接口类型便可以帮助我们,下面是我们的解决方案:

package employee
import "testing"
type fakeStmtForMaleCount struct {
    Stmt
}
func (fakeStmtForMaleCount) Exec(stmt string, args ...string) (Result, error) {
    return Result{Count: 5}, nil
}
func TestEmployeeMaleCount(t *testing.T) {
    f := fakeStmtForMaleCount{}
    c, _ := MaleCount(f)
    if c != 5 {
        t.Errorf("want: %d, actual: %d", 5, c)
        return
    }
}

我们为 TestEmployeeMaleCount 测试用例建立了一个 fakeStmtForMaleCount 的伪对象类型,然后在这个类型中嵌入了 Stmt 接口类型。这样 fakeStmtForMaleCount 就实现了 Stmt 接口,我们也实现了快速建立伪对象的目的。接下来我们只需要为 fakeStmtForMaleCount 实现 MaleCount 所需的 Exec 方法,就可以满足这个测试的要求了。

...

实现继承的原理

实现继承的原理 #

我们将 嵌入字段的用法的例子代码做一下细微改动:

var sl = make([]byte, len("hello, go"))
s.Read(sl)
fmt.Println(string(sl))
s.Add(5)
fmt.Println(*(s.MyInt))

Read 方法与 Add 方法看起来就是类型 S 方法集合中的方法。但是,这里类型 S 明明没有显式实现这两个方法。

这两个方法来自结构体类型 S 的两个嵌入字段 Reader 和 MyInt。结构体类型 S“继承”了 Reader 字段的方法 Read 的实现,也“继承”了 *MyInt 的 Add 方法的实现。注意,我这里的“继承”用了引号,说明这并不是真正的继承,它只是 Go 语言的一种“障眼法”。

这种“障眼法”的工作机制是这样的,当我们通过结构体类型 S 的变量 s 调用 Read 方法时,Go 发现结构体类型 S 自身并没有定义 Read 方法,于是 Go 会查看 S 的嵌入字段对应的类型是否定义了 Read 方法。这个时候,Reader 字段就被找了出来,之后 s.Read 的调用就被转换为 s.Reader.Read 调用。

这样一来,嵌入字段 Reader 的 Read 方法就被提升为 S 的方法,放入了类型 S 的方法集合。同理 *MyInt 的 Add 方法也被提升为 S 的方法而放入 S 的方法集合。从外部来看,这种嵌入字段的方法的提升就给了我们一种结构体类型 S“继承”了 io.Reader 类型 Read 方法的实现,以及 *MyInt 类型 Add 方法的实现的错觉。

...

嵌入字段的用法

嵌入字段的用法 #

以某个类型名、类型的指针类型名或接口类型名,直接作为结构体字段的方式就叫做结构体的类型嵌入,这些字段也被叫做嵌入字段(Embedded Field)。

我们结合具体的例子,简单说一下嵌入字段的用法:

type MyInt int
func (n *MyInt) Add(m int) {
    *n = *n + MyInt(m)
}
type t struct {
    a int
    b int
}
type S struct {
    *MyInt
    t
    io.Reader
    s string
    n int
}
func main() {
    m := MyInt(17)
    r := strings.NewReader("hello, go")
    s := S{
        MyInt: &m,
        t: t{
            a: 1,
            b: 2,
        },
        Reader: r,
        s:      "demo",
    }
    var sl = make([]byte, len("hello, go"))
    s.Reader.Read(sl)
    fmt.Println(string(sl)) // hello, go
    s.MyInt.Add(5)
    fmt.Println(*(s.MyInt)) // 22
}

结构体中的嵌入的标识符,既代表字段的名字,也代表字段的类型。

为什么第三个嵌入字段的名字为 Reader 而不是 io.Reader?这是因为,Go 语言规定如果结构体使用从其他包导入的类型作为嵌入字段,比如 pkg.T,那么这个嵌入字段的字段名就是 T,代表的类型为 pkg.T。

嵌入字段的用法和普通字段很相似。不过,Go 对嵌入字段有一些约束的。比如,和 Go 方法的 receiver 的基类型一样,嵌入字段类型的底层类型不能为指针类型。而且,嵌入字段的名字在结构体定义也必须是唯一的,这也意味这如果两个类型的名字相同,它们无法同时作为嵌入字段放到同一个结构体定义中。

...