5.4.5 小结
分析程序的崩溃和恢复过程比较棘手,代码不是特别容易理解。我们在本节最后还是简单总结一下程序崩溃和回复的过程:
1.编译器会负责做转换关键字的工作;
(1)将panic
和recover
分别转换成runtime.gopanic
和runtime.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
会根据传入的pc
和sp
跳转回deferproc
;
(3)编译器自动生成的代码会发现deferproc
的返回值不为0,这时会跳回deferreturn
并回复正常的执行流程;
4.如果没有遇到gorecover
就会依次遍历所有的_defer
结构,并在最后调用fatalpanic
中止程序、打印panic
的参数并返回错误码2
;
分析的过程设计了很多语言底层的知识,源代码阅读起来也比较晦涩,其中充斥着反常规的控制流程,通过程序计数器来回跳转,不过对于我们理解程序的执行流程还是很有帮助的。
5.5 make和new
当我们想要在Go语言中初始化一个结构时,可能会用到两个不同的关键字——make
和new
。因为它们的功能相似,所以初学者可能会对这两个关键字的作用感到困惑,它们两者的确有较大的不同。
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
是一个包含data
、cap
、len
的私有结构体internal/reflectlite.sliceHeader
;
2.hash
是一个指向runtime.hmap
结构体的指针;
3.ch
是一个指向runtime.hchan
结构体的指针;
相比复杂的make
关键字,new
的功能就很简单了,它只能接收一个类型作为参数,然后返回一个指向该类型的指针:
i := new(int)
var v int
i := &v
上述代码片段中的两种不同初始化方法是等价的,它们都会创建一个指向int
零值的指针。
接下来我们将分别介绍make
和new
在初始化不同数据结构时的过程,我们会从编译期间和运行时两个不同阶段理解这两个关键字的原理,不过由于前面的章节已经详细分析过make
的原理,所以这里会将重点放在另一个关键字new
上。
5.5.1 make
在前面的章节中我们已经谈到过make
在创建切片、哈希表、Channel的具体过程,所以在这一小节,我们只会简单提及make
相关的数据结构的初始化原理。
在编译期间的类型检查阶段,Go语言就将代表make
关键字的OMAKE
节点根据参数类型的不同转换成了OMAKESLICE
、OMAKEMAP
、OMAKECHAN
三种不同类型的节点,这些节点会调用不同的运行时函数来初始化相应的数据结构。
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())
}
}
}
不过这也不是绝对的,如果通过var
或new
创建的变量不需要在当前作用域外生存,例如不用作为返回值返回给调用方,那么就不需要初始化在堆上。
runtime.newobject
函数会获取传入类型占用空间的大小,调用runtime.mallocgc
在堆上申请一片内存空间并返回指向这片内存空间的指针:
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}
runtime.mallocgc
函数的实现大概有200多行代码,我们会在后面的章节中详细分析Go语言的内存管理机制。
5.5.3 小结
到了最后,简单总结一下Go语言中make
和new
关键字的实现原理,make
关键字的作用是创建切片、哈希表、Channel等内置的数据结构,而new
的作用是为类型申请一片内存空间,并返回指向这片内存的指针。