跳过正文
  1. 面试题库/

09|并发相关

·1619 字·4 分钟
目录
Golang面试题库 - 这篇文章属于一个选集。
§ 9: 本文

1. 怎么控制并发数?
#

分析

这个问题其实是一个略带开放设计的问题,极有可能和高并发的接口设计配合使用。比如有这样的场景:

现在有一个请求接口,QPS 达到 3w,接口用 Go 语言来实现,不考虑消息队列等中间件的情况下,你会怎么设计?

其实核心就是考虑怎么控制并发 goroutine 的数量。一个请求用 go func() 开一个协程显然不合理,会造成 goroutine 太多,反而影响程序的性能。但是串行又不合理,那要怎么用一定数量的 goroutine 来实现并发呢?

可以结合 Java 或者 C++ 语言中的池化技术,用协程池来处理。再结合之前的消息队列作用,用管道来缓冲请求量。

回答

  • 有缓冲 channel:利用缓冲满时发送阻塞的特性,处理端开一定数量的处理协程来消费。
  • 实现一个协程池,控制处理请求的 worker 的数量。

2. 多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 捕获?
#

分析

这个题主要考察 Go 语言中的集中 error 的了解程度。

Go 语言的错误分三种:errorpanicfatal error

  • error 就是我们常说的错误,一般通过函数返回值传递,需要使用 if err != nil 处理。
  • panic 大家有时也会叫异常,通常对标其他语言的 exception。数组越界、空指针等都会触发 panic,业务代码也可以主动触发 panic。这一类错误可以使用 recover 捕获。
  • fatal error 是由系统触发的严重错误,这类错误一般都跟系统资源相关。典型的 fatal error 就是无法从系统申请内存。之所以说严重,是因为程序没法从这类错误中恢复正常。fatal error 无法被 recover 捕获。

回答

map 会检测是否存在并发写,如果检测到并发写会触发 fatal errorfatal error 属于系统抛出的严重错误,无法被 recover() 捕获,程序会直接退出。

3. 如何优雅地实现一个 goroutine 池?
#

分析

首先要明确协程池的作用,应该有哪些角色,怎么添加任务,怎么获取任务,以及 worker 的大小怎么限制。

一个简单的 goroutine 池通常包含:

  • 任务队列:用于接收外部提交的任务。
  • worker:固定数量的 goroutine,从任务队列中取任务执行。
  • 关闭机制:停止接收任务,并等待 worker 退出。

示例:

package main

import (
    "fmt"
    "sync"
)

type Pool struct {
    tasks chan func()
    wg    sync.WaitGroup
}

func NewPool(workerNum int, queueSize int) *Pool {
    p := &Pool{
        tasks: make(chan func(), queueSize),
    }

    p.wg.Add(workerNum)
    for i := 0; i < workerNum; i++ {
        go func() {
            defer p.wg.Done()
            for task := range p.tasks {
                task()
            }
        }()
    }

    return p
}

func (p *Pool) Submit(task func()) {
    p.tasks <- task
}

func (p *Pool) Close() {
    close(p.tasks)
    p.wg.Wait()
}

func main() {
    pool := NewPool(3, 10)
    for i := 0; i < 10; i++ {
        num := i
        pool.Submit(func() {
            fmt.Println(num)
        })
    }
    pool.Close()
}

回答

优雅实现 goroutine 池的关键是:固定 worker 数量、使用 channel 作为任务队列、支持关闭任务队列,并通过 sync.WaitGroup 等待 worker 完成退出。

4. select 可以用于什么?
#

分析

像这类题目,问有什么用,首先我们得清楚具体用法,然后根据这个用法去思考解决了什么问题。

首先看一下 select 是怎么用的:

select {
case <-ch1:
    // 如果从 ch1 信道成功接收数据,则执行该分支代码
case ch2 <- 1:
    // 如果成功向 ch2 信道成功发送数据,则执行该分支代码
default:
    // 如果上面都没有成功,则进入 default 分支处理流程
}

select 主要是配合 channel 来使用,当前 goroutine 可以从多个 channel 中获取数据或者向多个 channel 发送数据,类似于 IO 多路复用。

当 channel 数据没有准备好的时候,若有 default 的话,就会走 default 语句,不会阻塞在这里,这种方式就实现了非阻塞的 channel 数据读写。

回答

  1. select 可以让同一个 goroutine 监听多个 channel 的读写操作,实现单个 goroutine 的多路复用。
  2. 配合 default 实现 goroutine 的非阻塞读写,当 channel 的数据没有准备好或者不能写入时,执行 default,并不会阻塞。

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

分析

这题主要考察常见的协程等待实现,一般回答的时候都会想到用 sync.WaitGroup,这个只是一个基本的回答。这个时候面试官可能会问还有其他的实现方式吗?这里就考察对 channel 的熟练程度了。channel 的作用非常广泛,要是能灵活运用的话,可以实现很多内置的功能,比如锁、信号,都可以用 channel 来实现。

这道题很可能面试官要求手写代码,所以对 sync.WaitGroup 和 channel 的使用一定要熟悉。

下面看两个例子,分别用 sync.WaitGroup 和 channel 来实现协程等待。

sync.WaitGroup 实现协程等待
#

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func(num int) {
            fmt.Println(num)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

程序输出:

0
1
6
4
5
2
7
8

channel 实现协程等待
#

package main

import (
    "fmt"
)

func main() {
    c := make(chan struct{}, 10)
    for i := 0; i < 10; i++ {
        go func(num int) {
            fmt.Println(num)
            c <- struct{}{}
        }(i)
    }

    for i := 0; i < 10; i++ {
        <-c
    }
}

程序输出:

9
1
0
3
4
2
7
8
6
5

回答

可以用 sync.WaitGroup 或 channel 来实现协程等待。

Golang面试题库 - 这篇文章属于一个选集。
§ 9: 本文