Go语言设计与实现 学习笔记 第五章 常用关键字(2)

发布于:2024-07-11 ⋅ 阅读:(26) ⋅ 点赞:(0)

5.4.5 小结

分析程序的崩溃和恢复过程比较棘手,代码不是特别容易理解。我们在本节最后还是简单总结一下程序崩溃和回复的过程:
1.编译器会负责做转换关键字的工作;
(1)将panicrecover分别转换成runtime.gopanicruntime.gorecover

(2)将defer转换成deferproc函数;

(3)在调用defer的函数末尾插入deferreturn函数调用;

2.在运行过程中遇到gopanic方法时,会从Goroutine的链表依次取出_defer结构体并执行;

3.如果调用延迟执行函数时遇到了gorecover就会将_panic.recovered标记成true并返回panic的参数(panic的参数类型为interface{});
(1)在这次调用结束后,gopanic会从_defer结构体中取出程序计数器pc和栈指针sp并调用recovery函数进行程序恢复;

(2)recovery会根据传入的pcsp跳转回deferproc

(3)编译器自动生成的代码会发现deferproc的返回值不为0,这时会跳回deferreturn并回复正常的执行流程;

4.如果没有遇到gorecover就会依次遍历所有的_defer结构,并在最后调用fatalpanic中止程序、打印panic的参数并返回错误码2

分析的过程设计了很多语言底层的知识,源代码阅读起来也比较晦涩,其中充斥着反常规的控制流程,通过程序计数器来回跳转,不过对于我们理解程序的执行流程还是很有帮助的。

5.5 make和new

当我们想要在Go语言中初始化一个结构时,可能会用到两个不同的关键字——makenew。因为它们的功能相似,所以初学者可能会对这两个关键字的作用感到困惑,它们两者的确有较大的不同。

1.make的作用是初始化内置的数据结构,也就是我们在前面提到的切片、哈希表、Channel;

2.new的作用是根据传入的类型在堆上分配一片内存空间并返回指向这片内存空间的指针(作者说new会在堆上分配空间,其实这是不对的,go的new比较特殊,其他语言中的new常常是在堆上分配内存,但go会根据逃逸分析的结果决定是在栈上还是堆上分配内存);

我们在代码中往往都会使用如下所示语句初始化这三类基本类型,这三个语句分别返回了不同类型的数据结构:

slice := make([]int, 0, 100)
hash := make(map[int]bool, 10)
ch := make(chan int, 5)

1.slice是一个包含datacaplen的私有结构体internal/reflectlite.sliceHeader

2.hash是一个指向runtime.hmap结构体的指针;

3.ch是一个指向runtime.hchan结构体的指针;

相比复杂的make关键字,new的功能就很简单了,它只能接收一个类型作为参数,然后返回一个指向该类型的指针:

i := new(int)

var v int
i := &v

上述代码片段中的两种不同初始化方法是等价的,它们都会创建一个指向int零值的指针。
在这里插入图片描述
接下来我们将分别介绍makenew在初始化不同数据结构时的过程,我们会从编译期间和运行时两个不同阶段理解这两个关键字的原理,不过由于前面的章节已经详细分析过make的原理,所以这里会将重点放在另一个关键字new上。

5.5.1 make

在前面的章节中我们已经谈到过make在创建切片、哈希表、Channel的具体过程,所以在这一小节,我们只会简单提及make相关的数据结构的初始化原理。
在这里插入图片描述
在编译期间的类型检查阶段,Go语言就将代表make关键字的OMAKE节点根据参数类型的不同转换成了OMAKESLICEOMAKEMAPOMAKECHAN三种不同类型的节点,这些节点会调用不同的运行时函数来初始化相应的数据结构。

5.5.2 new

编译器会在中间代码生成阶段通过以下两个函数处理该关键字:
1.cmd/compile/internal/gc.callnew函数会将关键字转换成ONEWOBJ类型的节点;

2.cmd/compile/internal/gc.state.expr函数会根据申请空间的大小分两种情况处理:
(1)如果申请的空间为0,就会返回一个表示空指针的zerobase变量;

(2)在遇到其他情况时会将关键字转换成runtime.newobject函数:

func callnew(t *types.Type) *Node {
    // ...
    // 创建一个节点n
    // ONEWOBJ类型节点表示分配一个新对象
    // typename(t)函数返回类型t的名称
    // nil表示没有子节点
    n := nod(ONEWOBJ, typename(t), nil)
    // ...
    // 返回新创建的节点n
    return n
}

func (s *state) expr(n *Node) *ssa.Value {
    switch n.Op {
    case ONEWOBJ:
        if n.Type.Elem().Size() == 0 {
            return s.newValue1A(ssa.OpAddr, n.Type, zerobaseSym, s.sb)
        }
        typ := s.expr(n.Left)
        // 调用newobject分配新对象
        vv := s.rtcall(newobject, true, []*types.Type{n.Type}, typ)
        return vv[0]
    }
}

需要注意的是,使用new在编译器看来是ONEW节点,使用var在编译器看来是ODCL节点。这两个节点在这一阶段(应该是中间代码生成阶段)都会被cmd/compile/internal/gc.walkstmt转换成通过runtime.newobject函数在堆上(作者说是在堆上,其实不一定是在堆上申请)申请内存:

func walkstmt(n *Node) *Node {
    switch n.Op {
    // 如果是变量声明节点
    case ODCL:
        // 从左子节点中取出要声明的变量节点
        v := n.Left
        // 如果变量v的类型是堆上存储
        if v.Class() == PAUTOHEAP {
            // 如果prealloc中还没有为v分配空间
            if prealloc[v] == nil {
                // 为变量v分配内存,并将分配的结果存在prealloc中
                prealloc[v] = callnew(v.Type)
            }
            // 创建一个赋值节点,将prealloc[v]赋值给v.Name.Param.Heapaddr
            nn := nod(OAS, v.Name.Param.Heapaddr, prealloc[v])
            // 设置节点的colas属性为true,表示这是:=语法的一部分
            nn.SetColas(true)
            // 检查节点类型
            nn = typecheck(nn, ctxStmt)
            // 递归调用walkstmt处理新创建的赋值节点
            return walkstmt(nn)
        }
    // 如果是分配新对象的节点
    case ONEW:
        // 如果逃逸分析的结果是不需要逃逸到堆中
        if n.Esc == EscNone {
            // 创建一个类型为n.Type.Elem()临时变量
            r := temp(n.Type.Elem())
            // 初始化临时变量r
            r = nod(OAS, r, nil)
            // 对赋值节点进行类型检查
            r = typecheck(r, ctxStmt)
            // 将初始化表达式加入init列表中
            init.Append(r)
            // 获取临时变量的地址
            r = nod(OADDR, r.Left, nil)
            // 对地址表达式进行类型检查
            r = typecheck(r, ctxExpr)
            // 将原节点替换为地址表达式节点
            n = r
        // 如果对象逃逸到堆上
        } else {
            // 调用callnew在堆上分配内存
            n = callnew(n.Type.Elem())
        }
    }
}

不过这也不是绝对的,如果通过varnew创建的变量不需要在当前作用域外生存,例如不用作为返回值返回给调用方,那么就不需要初始化在堆上。

runtime.newobject函数会获取传入类型占用空间的大小,调用runtime.mallocgc在堆上申请一片内存空间并返回指向这片内存空间的指针:

func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

runtime.mallocgc函数的实现大概有200多行代码,我们会在后面的章节中详细分析Go语言的内存管理机制。

5.5.3 小结

到了最后,简单总结一下Go语言中makenew关键字的实现原理,make关键字的作用是创建切片、哈希表、Channel等内置的数据结构,而new的作用是为类型申请一片内存空间,并返回指向这片内存的指针。