1. 怎么控制并发数?#
分析
这个问题其实是一个略带开放设计的问题,极有可能和高并发的接口设计配合使用。比如有这样的场景:
现在有一个请求接口,QPS 达到 3w,接口用 Go 语言来实现,不考虑消息队列等中间件的情况下,你会怎么设计?
其实核心就是考虑怎么控制并发 goroutine 的数量。一个请求用 go func() 开一个协程显然不合理,会造成 goroutine 太多,反而影响程序的性能。但是串行又不合理,那要怎么用一定数量的 goroutine 来实现并发呢?
可以结合 Java 或者 C++ 语言中的池化技术,用协程池来处理。再结合之前的消息队列作用,用管道来缓冲请求量。
回答
- 有缓冲 channel:利用缓冲满时发送阻塞的特性,处理端开一定数量的处理协程来消费。
- 实现一个协程池,控制处理请求的 worker 的数量。
2. 多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 捕获?#
分析
这个题主要考察 Go 语言中的集中 error 的了解程度。
Go 语言的错误分三种:error、panic 和 fatal error。
error就是我们常说的错误,一般通过函数返回值传递,需要使用if err != nil处理。panic大家有时也会叫异常,通常对标其他语言的 exception。数组越界、空指针等都会触发panic,业务代码也可以主动触发panic。这一类错误可以使用recover捕获。fatal error是由系统触发的严重错误,这类错误一般都跟系统资源相关。典型的fatal error就是无法从系统申请内存。之所以说严重,是因为程序没法从这类错误中恢复正常。fatal error无法被recover捕获。
回答
map 会检测是否存在并发写,如果检测到并发写会触发 fatal error。fatal 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 数据读写。
回答
select可以让同一个 goroutine 监听多个 channel 的读写操作,实现单个 goroutine 的多路复用。- 配合
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 来实现协程等待。