内存

1. 什么是逃逸分析

  • golang程序变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配。
  • 所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。 函数中申请一个新的对象如果分配在栈中,则函数执行结束可自动将内存回收;如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;
  • 逃逸分析通常有四种情况:
    • 指针逃逸
    • 栈空间不足逃逸
    • 动态类型逃逸
    • 闭包引用对象逃逸
  • 逃逸总结
    • 栈上分配内存比在堆中分配内存有更高的效率.
    • 栈上分配的内存不需要GC处理.
    • 堆上分配的内存使用完毕会交给GC处理.
    • 逃逸分析目的是决定内分配地址是栈还是堆.
    • 逃逸分析在编译阶段完成.

3. 发生逃逸的三种情况
  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

4. 必然不会逃逸
  • 指针被未发生逃逸的变量引用;
  • 仅仅在函数内对变量做取址操作,而未将指针传出;

5. 可能发生逃逸,也可能不会发生逃逸:
  • 将指针作为入参传给别的函数;这里还是要看指针在被传入的函数中的处理过程,如果发生了上边的三种情况,则会逃逸;否则不会逃逸;

2. Go 语言局部变量分配在栈还是堆?

  • 视逃逸分析结果而定,Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析,当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。
  • 注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。(堆栈如何分配内存的)
  • 每当函数中申请新的对象,编译器会跟据该对象是否被函数外部引用来决定是否逃逸:
    • 如果函数外部没有引用,则优先放到栈中;
    • 如果函数外部存在引用,则必定放到堆中.


3 Golang 减小gc 压力、避免内存泄漏小tips

  • 减少内存分配
    • Golang的内存模型,小对象(小于32k,因为在go的runtime里面写死了数组,数组的最大值就是32768,换算下来就是32k)多了会造成gc压力。
    • 通常小对象过多会导致GC三色法消耗过多的GPU。优化思路是,减少对象分配.
  • 避免 string 与 []byte 转化
  • 指定 slice 长度

4 避免内存泄漏的两个原则

  • 绝对不能由消费者关 channel,因为向关闭的 channel 写数据会 panic,正确的姿势是生产者写完所有数据后,关闭 channel,消费者负责消费完 channel 里面的全部数据:
  • 利用关闭 channel 来广播取消动作。向关闭的 channel 读数据永远不会阻塞

5 Go内存管理方式

  • Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法,全称Thread-Caching Malloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
  • Go在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。
  • arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan

bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB

6 什么是内存泄露

  • 程序运行过程中已不再使用的内存,没有被释放掉,导致这些内存无法被使用,直到程序结束这些内存才被释放的问题。


7 内存泄露场景


  1. channel的读或者写:
    1. 无缓冲channel的阻塞通常是写操作因为没有读而阻塞
    2. 有缓冲的channel因为缓冲区满了,写操作阻塞
    3. 期待从channel读数据,结果没有goroutine写
  2. select操作,select里也是channel操作,如果所有case上的操作阻塞,goroutine也无法继续执行。

8 线上内存泄露了怎么处理

  • pprof
    1. block:goroutine的阻塞信息,本例就截取自一个goroutine阻塞的demo,但block为0,没掌握block的用法
    2. goroutine:所有goroutine的信息,下面的full goroutine stack dump是输出所有goroutine的调用栈,是goroutine的debug=2,后面会详细介绍。
    3. heap:堆内存的信息
    4. mutex:锁的信息
    5. threadcreate:线程信息
  • go tool pprof url可以获取指定的profile文件


数组

1. 如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上

  • 在栈上初始化需要对变量一个一个赋值,静态区的话可以直接把整片内存复制过去,在元素多的时候会节省很多时间。


反射

1 json包里使用的时候,结构体里的变量不加tag能不能正常转成json里的字段?

  • 如果变量首字母小写,则为private。无论如何不能转,因为取不到反射信息
  • 如果变量首字母大写,则为public
    • 不加tag,可以正常转为json里的字段,json内字段名跟结构体内字段原名一致
    • 加了tag,从structjson的时候,json的字段名就是tag里的字段名,原字段名已经没用。

2 uintptr和unsafe.Pointer的区别

  • unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
  • 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
  • unsafe.Pointer 可以和 普通指针 进行相互转换;
  • unsafe.Pointer 可以和 uintptr 进行相互转换

基础

1. byte、rune、string区别

  • byte 表示一个字节(8个比特),rune 表示四个字节(对中文转码就有区别,对字符串串码无使用上的区别)
  • byte 等同于int8,常用来处理ascii字符
  • rune 等同于int32,常用来处理unicode或utf-8字符(处理中文)
  • 在go中string字符串会分配到只读内存中去

    2. map为什么是不安全的

  • 在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。

    3. 怎么实现高并发map

  • 通过读写锁sync.RWMutex实现对map的并发访问控制(会导致一定的性能问题,不过能保证程序的安全运行)

    4. map是如何实现的

  • Go 语言使用拉链法(大部分语言也都是使用了这种方法)来解决哈希碰撞的问题实现了哈希表

  • 哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash 就成为可以帮助哈希快速遍历桶中元素的缓存。
  • 哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。

    5. map的扩容机制

  • 条件

    • 装载因子已经超过 6.5;
      • 翻倍扩容
    • 哈希使用了太多溢出桶;
      • 等量扩容 sameSizeGrow,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移
  • 具体

    • map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。

      6 map为什么是无序的

  • 在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始

  • 遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。

    7 sync.Map 为什么是线程安全的

  • 空间换时间。通过冗余的两个数据结构(只读的 read 字段、可写的 dirty),来减少加锁对性能的影响。对只读字段(read)的操作不需要加锁。

  • 优先从 read 字段读取、更新、删除,因为对 read 字段的读取不需要锁。动态调整。miss 次数多了之后,将 dirty 数据提升为 read,避免总是从 dirty 中加锁读取。
  • double-checking。加锁之后先还要再检查 read 字段,确定真的不存在才操作 dirty 字段。
  • 延迟删除。删除一个键值只是打标记,只有在提升 dirty 字段为 read 字段的时候才清理删除的数据。

8 如果你来设计线程安全的map,你会怎么设计

  • 加读写锁:扩展map,支持并发读写
  • 分片加锁:更高效的并发map

    4 defer

  • defer的执行顺序在return之后,但是在返回值返回给调用方之前,所以使用defer可以达到修改返回值的目的。

切片

1. Go中对nil的Slice和空Slice的处理是一致的吗

  • Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致.
  • 隐藏在结构体中 处理会不一致,比如结构体里的 values []int
  • 和nil的比较不同 var s1 []int 与nil相等 var s2 = []int{} 与nil不等
  • 空切片指向的地址不是nil,指向的是一个内存地址,但是它没有分配任何内存空间,即底层元素包含0个元素。

2. slice是如何扩容的

分三步

  1. 预估容量

image.png

  1. 分配内存 = 预估容量 * 元素类型大小
  2. newCap = 申请分配内存 / 元素类型大小

context包的作用

  • goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。


操作系统

1. 协程,线程,进程的区别

  • 进程
    • 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
  • 线程
    • 线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
  • 协程
    • 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
    • 创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存
    • Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
    • 当 threads 切换时,需要保存各种寄存器,以便将来恢复:
    • 而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。
    • 一般而言,线程切换会消耗 1000-1500 纳秒,Goroutine 的切换约为 200 ns
  • 区别
    • 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
    • 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。
    • 协程和线程一样共享堆,不共享栈,协程由程序开发者在协程的代码里显示调度。

GC

1. Golang GC 时会发生什么?

  • Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。golang 中的 gc 基本上是标记清除的过程:
  • gc的过程一共分为四个阶段:
    • 栈扫描(开始时STW)
    • 第一次标记(并发)
    • 第二次标记(STW)
    • 清除(并发)
  • 整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。

    • 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理
    • 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列
    • 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象;
    • 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录

      2 GC触发时机

  • 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。

  • 被动触发,分为两种方式:
    • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
    • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。

协程

1. 为什么协程比线程轻量?

  • go协程调用跟切换比线程效率高
    • 线程并发执行流程
      • 线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换。线程在等待IO操作时线程变为unrunnable状态会触发上下文切换。现代操作系统一般都采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换ESP指向目标线程内核堆栈,将EIP指向目标线程上次被调度出时的指令地址。
    • go协程并发执行流程
      • 不依赖操作系统和其提供的线程,golang自己实现的CSP并发模型实现:M, P, G .go协程也叫用户态线程,协程之间的切换发生在用户态。在用户态没有时钟中断,系统调用等机制,因此效率高
  • go协程占用内存少
    • 执行go协程只需要极少的栈内存(大概是4~5KB),默认情况下,线程栈的大小为1MB。goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行。

因此协程和线程一样共享堆,不共享栈,协程由用户态下面的轻量级线程。

2. Goroutine 泄漏

  • 如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.可以通过Go自带的工具pprof或者使用Gops去检测诊断当前在系统上运行的Go进程的占用的资源.
  • 常见的导致协程泄露的场景有以下几种:
    • 缺少接收器,导致发送阻塞
      • 这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。 ```go func query() int { ch := make(chan int) for i := 0; i < 1000; i++ { go func() { ch <- 0 }() } return <-ch }

func main() { for i := 0; i < 4; i++ { query() fmt.Printf(“goroutines: %d\n”, runtime.NumGoroutine()) } } // goroutines: 1001 // goroutines: 2000 // goroutines: 2999 // goroutines: 3998

  1. - 那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。
  2. - 两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。
  3. - 这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。
  4. ```go
  5. func request(url string, wg sync.WaitGroup) {
  6. for {
  7. if _, err := http.Get(url); err == nil {
  8. // write to db
  9. break
  10. }
  11. time.Sleep(time.Second)
  12. }
  13. wg.Done()
  14. }
  15. func main() {
  16. var wg sync.WaitGroup
  17. for i := 0; i < 1000; i++ {
  18. wg.Add(1)
  19. go request(fmt.Sprintf("exampe.com/%d", i), wg)
  20. }
  21. wg.Wait()
  22. }

3. goroutine的优雅退出方法

  • 使用for-range退出
    • for-range是使用频率很高的结构,常用它来遍历数据,range能够感知channel的关闭,当channel被发送数据的协程关闭时,range就会结束,接着退出for循环。
    • 它在并发中的使用场景是:当协程只从1个channel读取数据,然后进行处理,处理后协程退出。
  • 使用select case ,ok退出
    • 待补充

4. Go 可以限制运行时操作系统线程的数量吗?

  • runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1
  • GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

5. Goroutine和Channel的作用分别是什么?

  • 进程是内存资源管理和cpu调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但cpu调度的最小单元变成了线程。
  • 协程,可以看作是轻量级的线程。但与线程不同的是,线程的切换是由操作系统控制的,而协程的切换则是由用户控制的。
  • Go中的goroutinue就是协程,可以实现并行,多个协程可以在多个处理器同时跑。而协程同一时刻只能在一个处理器上跑(可以把宿主语言想象成单线程的就好了)。 然而,多个goroutine之间的通信是通过channel,而协程的通信是通过yield和resume()操作。
  • 在Golang中channel则是goroutinues之间进行通信的渠道。
  • 可以把channel形象比喻为工厂里的传送带,一头的生产者goroutine往传输带放东西,另一头的消费者goroutinue则从输送带取东西。channel实际上是一个有类型的消息队列,遵循先进先出的特点。

    6. Golang 中 Goroutine 如何调度?

  • 协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。 因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。

  • groutine能拥有强大的并发实现是通过GPM调度模型实现.

    • Go的调度器内部有四个重要的结构:M,P,S,Sched
    • M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息
    • G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
    • P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine
    • Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。

      7. goroutine调度时机有哪些

  • 使用go关键字

    • go 创建一个新的 goroutine,Go scheduler 会考虑调度
  • GC
    • 由于进行 GC 的 goroutine 也需要在 M 上运行,因此肯定会发生调度。当然,Go scheduler 还会做很多其他的调度,例如调度不涉及堆访问的 goroutine 来运行。GC 不管栈上的内存,只会回收堆上的内存
  • 系统调度
    • 当 goroutine 进行系统调用时,会阻塞 M,所以它会被调度走,同时一个新的 goroutine 会被调度上来
  • 内存同步访问

    • atomic,mutex,channel 操作等会使 goroutine 阻塞,因此会被调度走。等条件满足后(例如其他 goroutine 解锁了)还会被调度上来继续运行

      8. 什么是M:N模型

    • Go runtime 会负责 goroutine 的生老病死,从创建到销毁,都一手包办。Runtime 会在程序启动的时候,创建 M 个线程(CPU 执行调度的单位),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型:

    • 在同一时刻,一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞(例如向一个 channel 发送数据,被阻塞)时,runtime 会把当前 goroutine 调度走,让其他 goroutine 来执行。目的就是不让一个线程闲着


9 我们如何知道一个Goroutine已经死亡?

10 GMP 模型,为什么要有 P?

早期

  • 调用 schedlock 方法来获取全局锁。
  • 获取全局锁成功后,将当前 Goroutine 状态从 Running(正在被调度) 状态修改为 Runnable(可以被调度)状态。
  • 调用 gput 方法来保存当前 Goroutine 的运行状态等信息,以便于后续的使用。
  • 调用 nextgandunlock 方法来寻找下一个可运行 Goroutine,并且释放全局锁给其他调度使用。
  • 获取到下一个待运行的 Goroutine 后,将其运行状态修改为 Running。
  • 调用 runtime·gogo 方法,将刚刚所获取到的下一个待执行的 Goroutine 运行起来,进入下一轮调度。

11 goroutine的生命状态控制

  • 如何主动退出goroutine

channel

1. Go中的channel的实现

  • 在Go中最常见的就是通信顺序进程(Communicating sequential processes,CSP)的并发模型,通过共享通信,来实现共享内存,这里就提到了channel.
  • Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Go 语言中的 Goroutine 会通过 Channel 传递数据。
  • Goroutine通过使用channel传递数据,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。
  • Channel 收发操作均遵循了先入先出(FIFO)的设计,具体规则如下:
    • 先从 Channel 读取数据的 Goroutine 会先接收到数据;
    • 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;
  • Channel 通常会有以下三种类型:
    • 同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方;
    • 异步 Channel — 基于环形缓存的传统生产者消费者模型;
    • chan struct{} 类型的异步 Channelstruct{} 类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义;

Channel 在运行时使用 runtime.hchan 结构体表示:

2. 无缓冲的 channel 和 有缓冲的 channel 的区别?

  • 对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
  • 对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。
  • 例子

    3. 什么是channel,为什么它可以做到线程安全?

  • 结构体里面的lock 用来保证每个读 channel 或写 channel 的操作都是原子的。

  • Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信。Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的。

    4. 无缓冲Chan的发送和接收是否同步

    1. ch := make(chan int) 无缓冲的channel由于没有缓冲发送和接收需要同步.
    2. ch := make(chan int, 2) 有缓冲channel不要求发送和接收操作同步.
  • channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。

  • channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。

5. select可以用于什么?

  • golang 的 select 机制是,监听多个channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置default,它的作用是:当监听的多个事件都阻塞住会执行default的逻辑。

6. 发生 panic 的情况有三种:

向一个关闭的 channel 进行写操作;关闭一个 nil 的 channel;重复关闭一个 channel。
读、写一个 nil channel 都会被阻塞。

7. 从一个关闭的channel能读出数据吗

从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。

8. channel在什么情况下会引起资源泄露

泄漏的原因是 goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中,不见天日。
另外,程序运行过程中,对于一个 channel,如果没有任何 goroutine 引用了,gc 会对其进行回收操作,不会引起内存泄漏。

9 Goroutine上下文切换的时候会发生什么

  • 读取当前g的状态,将状态从 _Grunning 切换成 _Grunnable
  • 解除当前g和m的关系
  • 锁定全局调度器
  • 将这个g丢到全局g队列去
  • 解锁全局调度器
  • 调用 schedule 去寻找可执行的g

10 g切换的时候,要做哪些事情?

  • schedule找到了g之后,会执行 execute 函数:

11 对未初始化的的 chan 进行读写,会怎么样?

  • 读写未初始化chan 都会阻塞

12 对已经关闭的的 chan 进行读写,会怎么样

  • 已经关闭chan 能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
  • 如果 chan 关闭前,buffer 内有元素还未读 , 会正确读到 chan 内的值,且返回的第二个 bool 值(是否读成功)为 true
  • 如果 chan 关闭前,buffer 内有元素已经被读完chan 内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个 bool 值一直为 false
  • 已经关闭chanpanic

13 channel分配在 堆 上

网络

1. Go中的http包的实现原理

  • Golang中http包中处理 HTTP 请求主要跟两个东西相关:ServeMux 和 Handler。ServeMux 本质上是一个 HTTP 请求路由器(或者叫多路复用器,Multiplexor)。它把收到的请求与一组预先定义的 URL 路径列表做对比,然后在匹配到路径的时候调用关联的处理器(Handler)。处理器(Handler)负责输出HTTP响应的头和正文。任何满足了http.Handler接口的对象都可作为一个处理器。通俗的说,对象只要有个如下签名的ServeHTTP方法即可:
  • Go 语言的 HTTP 包自带了几个函数用作常用处理器,比如FileServerNotFoundHandlerRedirectHandler

1. 互斥锁如何实现公平

  • 互斥锁有两种状态:正常状态和饥饿状态。
  • 在正常状态下,所有等待锁的 goroutine 按照FIFO顺序等待。唤醒的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁的拥有。新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入到等待队列的前面。 如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。
  • 在饥饿模式下,锁的所有权将从 unlock 的 goroutine 直接交给交给等待队列中的第一个。新来的 goroutine 将不会尝试去获得锁,即使锁看起来是 unlock 状态, 也不会去尝试自旋操作,而是放在等待队列的尾部。如果一个等待的 goroutine 获取了锁,并且满足一以下其中的任何一个条件:(1)它是队列中的最后一个;(2)它等待的时候小于1ms。它会将锁的状态转换为正常状态。正常状态有很好的性能表现,饥饿模式也是非常重要的,因为它能阻止尾部延迟的现象。

    2. Golang中除了加Mutex锁以外还有哪些方式安全读写共享变量

  • Golang中Goroutine 可以通过 Channel 进行安全读写共享变量。

Mutex和RWMutex使用过程中如何规避死锁问题,go可以检测吗?

  • 加锁解锁成对出现
  • 都是不可重入的
    • 如果想改为可重入,有什么解决思路
    • 不可重入原因之一是go的锁是没有记录持有锁的goroutine id,将锁进行改造,记录goroutine id或者传入token
  • 避免环路等待
  • go vet工具可以检测死锁

other

1. struct结构体 能比较吗

  • 如果结构体的所有成员变量都是可比较的,那么结构体就可比较


2. Go函数返回局部变量的指针是否安全

  • 在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上


3. make和new的区别

  • 二者都是用来做内存分配的。
  • make只用于slice、map以及channel的初始化,返回的还是这三个引用类型本身;
  • 而new用于类型的内存分配,并且内存对应的值为类型零值,返回的是指向类型的指针。


4. golang context包的用法

  • goroutine之间的传值
  • goroutine之间的控制

5. 空 struct{} 的用途

  • 可以节省内存,一般作为占位符使用,表明这里并不需要一个值
  • unsafe.Sizeof(struct{}{})) // 0

——————————— 分割线

https://www.ulovecode.com/2020/04/02/%E9%9D%A2%E8%AF%95/Golang%E9%9D%A2%E8%AF%95%E9%A2%98/

https://www.ulovecode.com/2020/05/18/%E9%9D%A2%E8%AF%95/%E4%BA%94%E6%9C%88%E9%9D%A2%E8%AF%95%E6%8C%87%E5%8D%97/


9. 互斥锁,读写锁,死锁问题是怎么解决。

  • 互斥锁
    • 互斥锁就是互斥变量mutex,用来锁住临界区的.条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了。当资源被分配到了,条件锁打开,进程继续运行;读写锁,也类似,用于缓冲区等临界资源能互斥访问的。
  • 读写锁
    • 通常有些公共数据修改的机会很少,但其读的机会很多。并且在读的过程中会伴随着查找,给这种代码加锁会降低我们的程序效率。读写锁可以解决这个问题。
  • 死锁
    • 一般情况下,如果同一个线程先后两次调用lock,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而锁正是被自己占用着的,该线程又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。 另外一种情况是:若线程A获得了锁1,线程B获得了锁2,这时线程A调用lock试图获得锁2,结果是需要挂起等待线程B释放锁2,而这时线程B也调用lock试图获得锁1,结果是需要挂起等待线程A释放锁1,于是线程A和B都永远处于挂起状态了。
  • 死锁产生的四个必要条件:
    • 互斥条件:一个资源每次只能被一个进程使用
    • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    • 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。
    • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
  • 预防死锁
    • 可以把资源一次性分配:(破坏请求和保持条件)然后剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件)资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
  • 避免死锁
    • 预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。
  • 检测死锁

    • 首先为每个进程和每个资源指定一个唯一的号码,然后建立资源分配表和进程等待表.

      11. Go语言的栈空间管理是怎么样的?

  • Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。

  • 分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?
  • 为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。
  • 在新分配的栈底部,还插入了一个叫做lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到lessstacklessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(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位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。

    13. 说一下异步和非阻塞的区别?

  • 异步和非阻塞的区别:

    • 步:调用在发出之后,这个调用就直接返回,不管有无结果;异步是过程。
    • 非阻塞:关注的是程序在等待调用结果(消息,返回值)时的状态,指在不能立刻得到结果之前,该调用不会阻塞当前线程。
  • 阻塞与非阻塞的区别:
    • 阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务,函数只有在得到结果之后才会返回。
    • 非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
  • 阻塞与非阻塞是对同一个线程来说的,在某个时刻,线程要么处于阻塞,要么处于非阻塞。阻塞是使用同步机制的结果,非阻塞则是使用异步机制的结果。

14. Goroutine和线程的区别?

  • OS的线程由OS内核调度,每隔几毫秒,一个硬件时钟中断发到CPU,CPU调用一个调度器内核函数。这个函数暂停当前正在运行的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运行哪一个线程,再从内存中恢复线程的注册表信息,最后继续执行选中的线程。这种线程切换需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的。
  • Go运行的时候包涵一个自己的调度器,这个调度器使用一个称为一个M:N调度技术,m个goroutine到n个os线程(可以用GOMAXPROCS来控制n的数量),Go的调度器不是由硬件时钟来定期触发的,而是由特定的go语言结构来触发的,他不需要切换到内核语境,所以调度一个goroutine比调度一个线程的成本低很多。
  • 从栈空间上,goroutine的栈空间更加动态灵活。
  • 每个OS的线程都有一个固定大小的栈内存,通常是2MB,栈内存用于保存在其他函数调用期间哪些正在执行或者临时暂停的函数的局部变量。这个固定的栈大小,如果对于goroutine来说,可能是一种巨大的浪费。作为对比goroutine在生命周期开始只有一个很小的栈,典型情况是2KB, 在go程序中,一次创建十万左右的goroutine也不罕见(2KB*100,000=200MB)。而且goroutine的栈不是固定大小,它可以按需增大和缩小,最大限制可以到1GB。
  • goroutine没有一个特定的标识。
  • 在大部分支持多线程的操作系统和编程语言中,线程有一个独特的标识,通常是一个整数或者指针,这个特性可以让我们构建一个线程的局部存储,本质是一个全局的map,以线程的标识作为键,这样每个线程可以独立使用这个map存储和获取值,不受其他线程干扰。
  • goroutine中没有可供程序员访问的标识,原因是一种纯函数的理念,不希望滥用线程局部存储导致一个不健康的超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。

17. 主协程如何等其余协程完再操作?

  • Go提供了更简单的方法——使用sync.WaitGroup。WaitGroup,就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数,它提供了三个方法,Add()用来添加计数。Done()用来在操作结束时调用,使计数减一。Wait()用来等待所有的操作结束,即计数变为0,该函数会在计数不为0时等待,在计数为0时立即返回。 ```go package main

import ( “fmt” “sync” )

func main() {

  1. var wg sync.WaitGroup
  2. wg.Add(2) // 因为有两个动作,所以增加2个计数
  3. go func() {
  4. fmt.Println("Goroutine 1")
  5. wg.Done() // 操作完成,减少一个计数
  6. }()
  7. go func() {
  8. fmt.Println("Goroutine 2")
  9. wg.Done() // 操作完成,减少一个计数
  10. }()
  11. wg.Wait() // 等待,直到计数为0

}

// 输出 // Goroutine 2 // Goroutine 1 ```

19. Go的堆栈

  • 数据结构的堆栈:
    • 堆:堆可以被看成是一棵树,如:堆排序。在队列中,调度程序反复提取队列中第一个作业并运行,因为实际情况中某些时间较短的任务将等待很长时间才能结束,或者某些不短小,但具有重要性的作业,同样应当具有优先权。堆即为解决此类问题设计的一种数据结构。
  • 在内存分配中的堆和栈:
    • 栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
    • 堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
  • Go的堆栈分配

    • 每个goroutine维护着一个栈空间,默认最大为4KB.
    • 当goroutine的栈空间不足时,golang会调用runtime.morestack(汇编实现:asm_xxx.s)来进行动态扩容.
    • 连续栈是当栈空间不足的时候申请一个2倍于当前大小的新栈,并把所有数据拷贝到新栈,接下来的所有调用执行都发生在新栈上.
    • 每个function维护着各自的栈帧(stack frame),当function退出时会释放栈帧.

      20. Golang 中的defer性能提升

  • 在Golang 1.14中新加入了开放编码(Open-coded)defer类型,编译器在ssa过程中会把被延迟的方法直接插入到函数的尾部,避免了运行时的deferproc及deferprocStack操作。

  • 避免了在没有运行时判断下的deferreturn调用。如有运行时判断的逻辑,则 deferreturn 也进一步优化,开放编码下的 deferreturn 不会进行jmpdefer的尾递归调用,而直接在一个循环里遍历执行。
  • 在1.14中defer的实现原理,共有三种defer模式类型,编译后一个函数里只会一种defer模式。
  • 堆上分配
    • 在 Golang 1.13 之前的版本中,所有 defer 都是在堆上分配 (deferProc),该机制在编译时会进行两个步骤:
    • 在 defer 语句的位置插入 runtime.deferproc,当被执行时,延迟调用会被保存为一个 _defer 记录,并将被延迟调用的入口地址及其参数复制保存,存入 Goroutine 的调用链表中。
    • 在函数返回之前的位置插入 runtime.deferreturn,当被执行时,会将延迟调用从 Goroutine 链表中取出并执行,多个延迟调用则以 jmpdefer 尾递归调用方式连续执行。
    • 这种机制的主要性能问题存在于每个 defer 语句产生记录时的内存分配,以及记录参数和完成调用时参数移动的系统调用开销。
  • 栈上分配
    • 在Golang 1.13 版本中新加入 deferprocStack 实现了在栈上分配的形式来取代 deferproc,相比后者,栈上分配在函数返回后 _defer 便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer 的链表即可。
    • 编译器可以去选择使用deferproc 还是 deferprocStack,通常情况下都会使用deferprocStack,性能会提升约 30%。不过在 defer 语句出现在了循环语句里,或者无法执行更高阶的编译器优化时,亦或者同一个函数中使用了过多的 defer 时,依然会使用 deferproc
    • 栈上分配 (deferprocStack),基本跟堆上差不多,只是分配方式改为在栈上分配,压入的函数调用栈存有_defer记录,另外编译器在ssa过程中会预留defer空间。
    • SSA 代表 static single-assignment,是一种IR(中间表示代码),要保证每个变量只被赋值一次。这个能帮助简化编译器的优化算法。简单来说,使用ssa可以使二进制文件大小减少了30%,性能提升5%-35%等.
  • 开放编码
    • Golang 1.14 版本继续加入了开发编码(open coded),该机制会将延迟调用直接插入函数返回之前,省去了运行时的 deferprocdeferprocStack 操作,在运行时的 deferreturn 也不会进行尾递归调用,而是直接在一个循环中遍历所有延迟函数执行。
    • 这种机制使得 defer 的开销几乎可以忽略,唯一的运行时成本就是存储参与延迟调用的相关信息,不过使用这个机制还需要三个条件:
      • 没有禁用编译器优化,即没有设置 -gcflags “-N”.
      • 函数内 defer 的数量不超过 8 个,且返回语句与延迟语句个数的乘积不超过 15.
      • defer 不是在循环语句中。
  • 此外该机制还引入了一种元素 —— 延迟比特(defer bit),用于运行时记录每个 defer 是否被执行(尤其是在条件判断分支中的 defer),从而便于判断最后的延迟调用该执行哪些函数。
  • 延迟比特的原理:
    • 同一个函数内每出现一个 defer 都会为其分配 1个比特,如果被执行到则设为 1,否则设为 0,当到达函数返回之前需要判断延迟调用时,则用掩码判断每个位置的比特,若为 1 则调用延迟函数,否则跳过。
    • 为了轻量,官方将延迟比特限制为 1 个字节,即 8 个比特,这就是为什么不能超过 8 个 defer 的原因,若超过依然会选择堆栈分配,但显然大部分情况不会超过 8 个。

21. 哪些情况下会panic

  • 使用未声明的引用类型的变量
  • 向一个关闭的 channel 进行写操作;
  • 关闭一个 nil 的 channel;
  • 重复关闭一个 channel。
  • 读、写一个 nil channel 都会被阻塞。

22 对字符串len() 0 和 “” 在使用上,性能测试上,汇编上基本没啥区别

参考
https://github.com/KeKe-Li/data-structures-questions