Golang面试前一夜准备
题号 | 题目 |
14 | Go语言的栈空间管理是怎么样的 |
15 | Goroutine和Channel的作用分别是什么 |
14. Go语言的栈空间管理是怎么样的
Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。
分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?
为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。
在新分配的栈底部,还插入了一个叫做lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到lessstack。lessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。
分块式的栈让我们能够按照需求来扩展和收缩栈的大小。Go开发者不需要花精力去估计goroutine会用到多大的栈。创建一个新的goroutine的开销也不大。当 Go开发者不知道栈会扩展到多少大时,它也能很好的处理这种情况。
这一直是之前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。
这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要原因。新的方法叫做栈复制法(stack copying)。
栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。
栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。然而,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。
因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用C语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。
这也是为什么现在运行环境的开发者正在用Go语言重写运行环境的大部分代码。无法用Go语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。
这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。
另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题
首先,32位系统只能支持4G字节虚拟内存,并且应用只能用到其中的3G空间。由于同时运行百万goroutines的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个goroutine的stack只有8K。
第二,然而我们可以在64位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。
15. Goroutine和Channel的作用分别是什么
进程是内存资源管理和cpu调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但cpu调度的最小单元变成了线程。
那协程又是什么呢,以及与线程的差异性?
协程,可以看作是轻量级的线程。但与线程不同的是,线程的切换是由操作系统控制的,而协程的切换则是由用户控制的。
最早支持协程的程序语言应该是lisp方言scheme里的continuation(续延),续延允许scheme保存任意函数调用的现场,保存起来并重新执行。Lua,C#,python等语言也有自己的协程实现。
Go中的goroutinue就是协程,可以实现并行,多个协程可以在多个处理器同时跑。而协程同一时刻只能在一个处理器上跑(可以把宿主语言想象成单线程的就好了)。然而,多个goroutine之间的通信是通过channel,而协程的通信是通过yield和resume()操作。
goroutine非常简单,只需要在函数的调用前面加关键字go即可,例如:
go func(){}
我们也可以启动5个goroutines分别打印索引:
func main() { for i:=1;i<5;i++ { go func(i int) { fmt.Println(i) }(i) } // 停歇5s,保证打印全部结束 time.Sleep(5*time.Second) }
在分析goroutine执行的随机性和并发性,启动了5个goroutine,再加上main函数的主goroutine,总共有6个goroutines。由于goroutine类似于”守护线程“,异步执行的,如果主goroutine不等待片刻,可能程序就没有输出打印了。
在Golang中channel则是goroutinues之间进行通信的渠道。
可以把channel形象比喻为工厂里的传送带,一头的生产者goroutine往传输带放东西,另一头的消费者goroutinue则从输送带取东西。channel实际上是一个有类型的消息队列,遵循先进先出的特点。
- channel的操作符号
ch <- data 表示data被发送给channel ch;
data <- ch 表示从channel ch取一个值,然后赋给data。
- 阻塞式channel
channel默认是没有缓冲区的,也就是说,通信是阻塞的。send操作必须等到有消费者accept才算完成。
应用示例:
func main() { ch1 := make(chan int) go pump(ch1) // pump hangs fmt.Println(<-ch1) // prints only 1 } func pump(ch chan int) { for i:= 1; ; i++ { ch <- i } }
在函数pump()里的channel在接受到第一个元素后就被阻塞了,直到主goroutinue取走了数据。最终channel阻塞在接受第二个元素,程序只打印 1。
没有缓冲(buffer)的channel只能容纳一个元素,而带有缓冲(buffer)channel则可以非阻塞容纳N个元素。发送数据到缓冲(buffer) channel不会被阻塞,除非channel已满;同样的,从缓冲(buffer) channel取数据也不会被阻塞,除非channel空了。