GOLANG面试八股文-代码面试题整理

1. 切片底层数据结构问题

1
2
3
4
5
6
7
func main() {
    s := []int{1, 2, 3, 4, 5}
    a := s[:2]
    b := s[2:]
    a = append(a, 100)
    fmt.Println(b[0])
}

很多人脱口而出”3”。答案是100。

`a` 和 `b` 共享同一个底层数组。`a := s[:2]` 之后,a的长度是2,但容量是5——还剩三个位置。 `append(a, 100)` 不会触发扩容,直接往底层数组的第三个位置写了100,刚好是 `b[0]` 指向的位置。

这题考的是slice的底层结构:一个指针、一个长度、一个容量。只有append的时候容量不够了才会分配新数组,容量够的时候就是原地修改。

追问通常是”怎么避免这种问题”——用三下标切片 `a := s[:2:2]`,把容量也限制成2,append的时候就一定会触发扩容,分配新的底层数组。

2. goroutine泄漏问题

1
2
3
4
5
6
7
8
func fetchData(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        result := doSomething()
        ch <- result
    }()
    return ch
}

这段代码在正常路径下没问题。但如果调用方的context超时了,不再从ch里读数据,`ch <- result` 就永远阻塞了——这个goroutine泄漏了,永远不会被回收。 改法:

1
2
3
4
5
6
7
8
9
10
11
func fetchData(ctx context.Context) <-chan int {
    ch := make(chan int, 1) // 带缓冲
    go func() {
        result := doSomething()
        select {
            case <- result:
            case <-ctx.Done():
        }
    }()
    return ch
}

两个改动:channel加了1的缓冲(就算没人读也不阻塞),select里加了context取消分支。

面试官真正想听的不是这段代码怎么改,而是你怎么发现线上有goroutine泄漏。`runtime.NumGoroutine()` 可以拿到当前goroutine数量,如果这个数字一直涨不下来,大概率是泄漏了。用 `pprof` 的 goroutine profile 可以看到每个goroutine卡在哪一行:

1
go tool pprof http://localhost:6060/debug/pprof/goroutine

然后 `top` 看哪些函数创建的goroutine最多、`list` 看具体卡在哪行。

3. defer的返回值陷阱

1
2
3
4
5
6
func f() (result int) {
    defer func() {
        result++
    }()
    return 0
}

返回值是1,不是0。

Go的return不是原子操作。`return 0` 拆成两步:先把0赋给返回值 `result`,然后执行defer,defer里 `result++` 把它改成了1,最后函数才真正返回。

换一种写法:

1
2
3
4
5
6
7
func f() int {
    result := 0
    defer func() {
        result++
    }()
    return result
}

这次返回0。因为返回值是匿名的,`return result` 先把result的值复制给匿名返回值,然后defer修改的是局部变量result,匿名返回值不受影响。

这两段代码的区别就在于返回值有没有名字。命名返回值的defer能修改返回结果,匿名返回值的defer改不了。面试中能把这个讲清楚,基本说明你理解了Go的函数调用栈帧。

4. map的并发崩溃

1
2
3
4
5
6
m := make(map[int]int)
for i := 0; i &lt; 10; i++ {
    go func(n int) {
        m[n] = n
    }(i)
}

这段代码不是”可能出bug”,是直接 `fatal error: concurrent map writes`,进程挂掉,recover都救不了。

Go的map不是并发安全的,runtime里做了检测——发现并发写直接fatal,连panic都不给你,没法recover。这是Go团队的设计选择:与其让你默默写出数据竞争然后在生产环境查三天,不如直接让你挂掉。

三种解法:`sync.Mutex` 加锁、`sync.RWMutex`(读多写少的场景)、`sync.Map`(Go 1.9之后提供的并发安全map)。

`sync.Map` 不是万能的。它针对两种场景做了优化:key只写一次但读很多次,或者多个goroutine读写不同的key集合。如果你的场景是大量goroutine频繁读写相同的key,`sync.RWMutex` + 普通map反而更快。

5. sync.Pool 内存池污染问题

下面这段代码运行后会出现什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var pool = sync.Pool{New: func() interface{} { return new(bytes.Buffer) }}

func testMemAlloc() {
    go func() {
        for {
            processRequest(1 << 28) // 256MiB
        }
    }()
    for i := 0; i < 1000; i++ {
        go func() {
            for {
                processRequest(1 << 10) // 1MiB
            }
        }()
    }
    var stats runtime.MemStats
    for i := 0; ; i++ {
        runtime.ReadMemStats(&stats)
        fmt.Printf("Cycle %d: %dB\n", i, stats.Alloc)
        time.Sleep(time.Second)
        runtime.GC()
    }
}

func processRequest(size int) {
    b := pool.Get().(*bytes.Buffer)
    time.Sleep(500 * time.Millisecond)
    b.Grow(size)
    pool.Put(b)
    time.Sleep(1 * time.Millisecond)
}

实际的运行结果是,内存会持续增长,如果机器物理内存小于256GB,最终会OOM。啥?你说你内存有1TB,那没事了!
问题的本质在于sync.Pool的污染,理论上池子里面有1000个1MiB的buffer,但是上面的那个goroutine会从池子里面随机拿一个buffer进行复用,开始极大概率会拿到1MB的,然后grow到256MB,然后放回去,导致内存一直增长。

6. Channel未初始化导致的阻塞问题

下面这段代码运行后会出现什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func testChannel() {
    var ch chan int
    go func() {
        ch = make(chan int, 1)
        ch <- 1
    }()
    go func(ch chan int) {
        time.Sleep(time.Second)
        <-ch
    }(ch)
    c := time.Tick(1 * time.Second)
    for range c {
        fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine())
    }
}

运行结果为,循环输出#goroutines: 2。
这是因为对未初始化的channel进行读写操作会导致goroutine阻塞,第二个goroutine会一直阻塞,第一个goroutine在初始化之后会向ch中发送数据而正常退出。
加上主goroutine,总共是2个goroutine。
如果把ch = make(chan int, 1)修改为ch = make(chan int),则会输出#goroutines: 3。

发表回复

Your email address will not be published. Required fields are marked *.

*
*