并发编程面试题¶
以对话形式回答,模拟真实面试场景
11. 如果若干个Goroutine,其中有一个panic,会发生什么?¶
面试官:如果有多个 Goroutine 在运行,其中一个发生了 panic,会怎么样?
回答:这是 Go 并发编程中一个很重要的特性。
一般情况下,单个 Goroutine 的 panic 只会影响它自己,其他 Goroutine 会继续正常运行。这是因为每个 Goroutine 都有自己独立的栈空间。
但是有一个特殊情况,如果是 main goroutine 发生 panic 而且没有被 recover,那整个程序就会退出。
面试官:能举个例子吗?
回答:可以的。比如我启动了 5 个 worker goroutine,如果其中第 3 个 worker panic 了,只要我在这个 goroutine 里用 defer 和 recover 处理了,其他 4 个 worker 还是会正常完成它们的工作。
这种设计的好处是故障隔离,一个任务的失败不会拖垮整个系统。但同时也要求我们在每个 Goroutine 里都要做好错误处理,特别是那些长期运行的 Goroutine。
在实际项目中,我通常会在每个 Goroutine 的入口就加上 recover,记录错误日志,然后决定是重试还是退出。
12. defer可以捕获到其Goroutine的子Goroutine的panic吗?¶
面试官:defer 能捕获到子 Goroutine 的 panic 吗?
回答:不能。这是很多人容易搞错的地方。
defer 只能捕获同一个 Goroutine 内的 panic,无法跨 Goroutine 捕获。如果父 Goroutine 里有个 defer recover,然后启动了一个子 Goroutine,这个子 Goroutine 发生 panic,父 Goroutine 的 defer 是捕获不到的。
面试官:那应该怎么处理?
回答:正确的做法是在每个 Goroutine 内部都要有自己的 recover 机制。
比如在子 Goroutine 的开头就加上 defer recover,这样可以捕获这个 Goroutine 内的 panic。如果需要把错误信息传递给父 Goroutine,可以通过 channel 或者其他同步机制来实现。
这其实也体现了 Go 的设计理念,每个 Goroutine 都是独立的执行单元,错误处理也应该在各自的作用域内完成。
在实际开发中,我会为每个长期运行的 Goroutine 都包装一个统一的错误处理函数,确保不会有未处理的 panic。
13. golang的锁机制了解过吗?Mutex的锁有哪几种模式,分别介绍一下?Mutex锁底层如何实现了解过吗?¶
面试官:Go 的锁机制你了解吗?Mutex 有哪几种模式?
回答:了解的。Go 提供了几种同步机制:Mutex 互斥锁、RWMutex 读写锁、atomic 原子操作,还有 channel 也可以用来同步。
Mutex 有三种工作模式:
正常模式下,等待的 goroutine 按 FIFO 顺序排队,但新来的 goroutine 可以和队列头部的 goroutine 竞争,这样吞吐量比较高,但可能导致队列尾部的 goroutine 等待时间过长。
当某个 goroutine 等待超过 1 毫秒时,锁就会进入饥饿模式。在饥饿模式下,锁会直接交给队列头部的 goroutine,保证公平性,防止饥饿。
还有个自旋模式,在多核系统上,如果锁很快就能释放,goroutine 会短时间自旋等待,而不是立即阻塞,这样可以避免 goroutine 切换的开销。
面试官:底层实现你了解吗?
回答:Mutex 结构体很简单,就两个字段:state 和 sema。state 是个 int32,用位来表示不同的状态信息;sema 是信号量,用于阻塞和唤醒 goroutine。
加锁时先尝试快速路径,如果没有竞争就直接用 CAS 操作获取锁;如果有竞争,就进入慢速路径,可能需要自旋或者阻塞等待。
面试官:这种设计有什么好处?
回答:这种设计在无竞争的情况下性能很高,只需要一个原子操作;在有竞争的情况下,通过自旋和饥饿模式的切换,既保证了吞吐量又保证了公平性。整体来说是个很精巧的设计。
14. channel、channel使用中需要注意的地方?¶
面试官:Channel 你用过吗?使用中有什么需要注意的地方?
回答:用过的,Channel 是 Go 并发编程的核心。
Channel 主要有三种类型:无缓冲 channel 用于同步通信,有缓冲 channel 用于异步通信,还有单向 channel 可以限制只能发送或只能接收。
面试官:使用中容易踩哪些坑?
回答:主要有几个地方要注意:
首先是死锁问题。如果在同一个 goroutine 里往无缓冲 channel 发送数据,但没有其他 goroutine 接收,就会死锁。正确的做法是用另一个 goroutine 来发送或接收。
然后是关闭 channel 的规则。只有发送方才能关闭 channel,接收方不应该关闭。而且要检查 channel 是否关闭,可以用 value, ok := <-ch 这种方式。
select 语句也很有用,可以同时监听多个 channel,还可以设置超时或者用 default 来实现非阻塞操作。
面试官:还有其他需要注意的吗?
回答:要避免 goroutine 泄漏。如果创建了一个 goroutine 往 channel 发送数据,但没有对应的接收者,这个 goroutine 就会永久阻塞,造成内存泄漏。
所以在设计的时候要确保每个 channel 都有对应的发送者和接收者,或者设置合理的超时机制。
总的来说,channel 的设计哲学就是"不要通过共享内存来通信,而要通过通信来共享内存"。
15. golang中解析tag是怎么实现的?反射原理是什么?通过反射调用函数¶
面试官:Go 中的 tag 解析是怎么实现的?反射的原理你了解吗?
回答:Tag 解析是通过反射来实现的。
我们定义结构体时可以在字段后面加 tag,比如 json、db、validate 这些。解析的时候,先用 reflect.TypeOf 获取类型信息,然后通过 FieldByName 或者遍历字段来获取具体字段,每个字段都有个 Tag 属性,可以用 Tag.Get 方法来获取指定的 tag 值。
面试官:那反射的底层原理是什么?
回答:Go 的反射基于 interface{} 的内部结构。interface{} 在运行时实际上是一个 eface 结构体,包含两个部分:_type 指针指向类型信息,data 指针指向实际数据。
反射就是通过这些类型信息来动态地检查和操作对象。reflect.TypeOf 返回类型信息,reflect.ValueOf 返回值信息,通过这两个可以获取到对象的所有元数据。
面试官:能通过反射调用函数吗?
回答:可以的。首先用 reflect.ValueOf 获取函数的反射对象,然后准备参数,每个参数也要用 reflect.ValueOf 包装,最后调用 Call 方法执行函数,返回值也是 reflect.Value 类型的切片。
对于结构体方法也类似,先获取结构体的反射对象,然后用 MethodByName 找到方法,再调用。
面试官:反射的性能怎么样?
回答:反射的性能相对较低,因为涉及大量的类型检查和动态调用。在性能敏感的场景下要慎用,通常用在框架、序列化这些对灵活性要求比较高的地方。