ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Go面试题精讲:并发编程10道经典题

Go面试题精讲:并发编程10道经典题 Go面试题精讲:并发编程10道经典题摘要: 本篇精讲Go并发编程面试10道经典题涵盖goroutine调度、channel原理、context取消传播、sync.Pool重用、atomic操作、race检测、并发安全Map、WaitGroup错误处理、worker pool、fan-out/fan-in模式每道题配代码示例和考点分析分享面试中channel方向误用导致编译错误的踩坑经验对比Go并发与其他语言并发的面试差异。开篇故事去年帮团队招Go后端面了四十多个人。并发编程是必考环节发现一个规律能写出goroutine和channel基础代码的人不少但问到调度器原理、context取消传播、race检测细节大部分人卡壳。有个候选人写了个worker pool代码逻辑没问题但channel声明用成了双向channel传给只接受单向channel的函数编译器报错他改了十分钟没改对。这个知识点其实不难但面试紧张时容易懵。我把高频面试题整理成这篇每道题配最小可运行代码和考点解析。一、goroutine调度GMP模型考点: G(goroutine)、M(线程)、P(处理器)三者关系P的数量等于GOMAXPROCS。packagemainimport(fmtruntime)funcmain(){// GOMAXPROCS控制P的数量, P持有本地G队列// 默认等于CPU逻辑核数, 可手动调整fmt.Println(GOMAXPROCS:,runtime.GOMAXPROCS(0))// NumGoroutine 返回当前活跃goroutine总数// 含main、runtime监控线程等fmt.Println(Goroutines:,runtime.NumGoroutine())// WorkStealing: P的本地队列空时偷别的P的G// 这就是GMP的work stealing调度策略gofunc(){fmt.Println(G运行在某个P上, 由M(线程)执行)}()}考点解析: G是用户代码执行的单元M是OS线程P是调度上下文持有本地G队列。P空闲时从全局队列或其他P偷G执行(work stealing)。二、channel底层结构考点: channel是hchan结构体底层是环形数组(有缓冲)或链表(等待队列)。packagemainimportfmt// channel底层是runtime.hchan结构(简化)// type hchan struct {// qcount uint // 当前队列元素数// dataqsiz uint // 缓冲区大小// buf unsafe.Pointer // 环形数组指针// sendx uint // 发送索引// recvx uint // 接收索引// sendq waitq // 等待发送的goroutine队列// recvq waitq // 等待接收的goroutine队列// lock mutex // 保护channel的锁// }funcmain(){// 无缓冲channel: 发送和接收必须同时就绪// 底层buf为nil, 发送方直接拷贝数据给接收方的栈ch:make(chanint)gofunc(){ch-42// 发送方阻塞直到接收方就绪}()fmt.Println(-ch)// 接收方就绪, 数据直接拷贝// 有缓冲channel: 先写入buf, 满了再阻塞ch2:make(chanint,3)ch2-1// 写入环形数组, 不阻塞ch2-2fmt.Println(-ch2)// 从环形数组读出}考点解析: 有缓冲channel底层是环形数组无缓冲channel底层直接在发送方和接收方栈间拷贝数据不经过buf。channel操作全程加锁。三、context取消传播考点: context树形传播取消信号底层用channel实现。packagemainimport(contextfmttime)funcworker(ctx context.Context,idint){// Done返回一个channel, context取消时关闭它// 所有case ctx.Done()的goroutine都会被唤醒select{case-ctx.Done():// Err返回取消原因(Canceled或DeadlineExceeded)fmt.Printf(worker %d cancelled: %v\n,id,ctx.Err())returncase-time.After(2*time.Second):fmt.Printf(worker %d done\n,id)}}funcmain(){// WithTimeout创建带超时的context, 自动取消ctx,cancel:context.WithTimeout(context.Background(),500*time.Millisecond,)defercancel()// 确保释放资源, 防止context泄漏goworker(ctx,1)goworker(ctx,2)time.Sleep(1*time.Second)}考点解析: context构建树形结构父节点取消时所有子节点的Done channel被关闭所有等待的goroutine同时唤醒。cancel必须调用否则timer不被回收。四、sync.Pool对象重用考点: Pool利用P的本地缓存减少锁竞争GC时会清空。packagemainimport(bytesfmtsync)funcmain(){// New函数在Pool为空时创建新对象// Get优先从当前P的private缓存取, 没有则偷别的P的bufferPool:sync.Pool{New:func()interface{}{// 返回新创建的bytes.Bufferreturnnew(bytes.Buffer)},}// Get从Pool借一个Buffer, 避免每次分配buf:bufferPool.Get().(*bytes.Buffer)buf.WriteString(hello)fmt.Println(buf.String())// Put归还, 下次Get可重用, Reset清空内容buf.Reset()bufferPool.Put(buf)}考点解析: Pool每个P有private和shared缓存Get先取private无锁再取shared需加锁。GC STW阶段会清空所有PoolPool不适合存长生命周期对象。五、atomic原子操作考点: atomic底层用CPU指令(LOCK CMPXCHG)实现比Mutex轻量。packagemainimport(fmtsync/atomic)funcmain(){varcountint64// AddInt64 原子加, 底层LOCK XADD指令atomic.AddInt64(count,1)atomic.AddInt64(count,2)fmt.Println(count)// 3// CompareAndSwap 原子比较交换, 实现自旋锁// 期望0, 改为1, 成功返回trueok:atomic.CompareAndSwapInt64(count,3,10)fmt.Println(ok,count)// true 10// LoadInt64 原子读, 防止读到半写值v:atomic.LoadInt64(count)fmt.Println(v)// 10}考点解析: atomic适用于单个变量的原子操作CAS是乐观锁基础。Mutex适用于多变量复合操作。atomic操作不需要进入runtime调度。六、race检测器考点:go build -race在编译期插入检测代码运行期检测数据竞争。packagemainimport(fmtsync)// 运行: go run -race main.gofuncmain(){varwg sync.WaitGroup data:make(map[int]int)// 多个goroutine并发写同一个map, 没有加锁// -race会检测到数据竞争并报告具体位置fori:0;i5;i{wg.Add(1)gofunc(iint){deferwg.Done()data[i]i*i// 这里会产生race}(i)}wg.Wait()fmt.Println(data)}考点解析: race detector基于Thread Sanitizer每个内存访问插入检查指令能检测到运行时发生的数据竞争。它只能检测实际执行路径上的竞争未覆盖到的代码路径检测不到。七、并发安全Map考点:sync.Map用读写分离atomic实现读多写少场景高性能。packagemainimport(fmtsync)funcmain(){varm sync.Map// Store 原子存入键值对m.Store(name,Go)m.Store(version,1.21)// Load 原子读取, 返回值和是否存在v,ok:m.Load(name)fmt.Println(v,ok)// Go true// LoadOrStore 存在则返回, 不存在则存入// 适合做单例缓存actual,loaded:m.LoadOrStore(name,Rust)fmt.Println(actual,loaded)// Go true(已存在)// Range 遍历, 回调返回false停止m.Range(func(k,vinterface{})bool{fmt.Printf(%v%v\n,k,v)returntrue})}考点解析: sync.Map内部有read和dirty两个map。读优先走read(atomic无锁)read未命中加锁查dirty。适合读远多于写、key稳定(写入一次反复读)的场景。写多读少不如普通map加Mutex。八、WaitGroup错误处理考点: WaitGroup本身不传error配合errgroup或channel传递第一个错误。packagemainimport(errorsfmtsync)funcmain(){varwg sync.WaitGroup// 用once保证只记录第一个errorvarfirstErrerrorvaronce sync.Oncefori:0;i3;i{wg.Add(1)gofunc(iint){deferwg.Done()ifi1{// 第二个goroutine出错err:errors.New(task 1 failed)// 只存第一个erroronce.Do(func(){firstErrerr})}}(i)}wg.Wait()fmt.Println(first error:,firstErr)}考点解析: WaitGroup的Add必须在goroutine外部调用Done在内部defer。错误传递用sync.Once保存第一个error或用golang.org/x/sync/errgroup封装。Add在goroutine内调用是经典bug主goroutine可能在Add前就Wait了。九、worker pool模式考点: 固定数量goroutine消费任务channel控制并发度。packagemainimport(fmtsync)funcmain(){constworkers3// 任务channel, worker从这里取任务jobs:make(chanint,10)// 结果channel, worker往这里写结果results:make(chanint,10)varwg sync.WaitGroup// 启动固定数量的worker goroutineforw:0;wworkers;w{wg.Add(1)gofunc(idint){deferwg.Done()// 循环从jobs取任务, channel关闭后退出forjob:rangejobs{// 处理任务, 写入结果results-job*job}}(w)}// 发送任务fori:1;i5;i{jobs-i}close(jobs)// 关闭jobs, worker处理完自动退出// 等待worker完成后关闭resultsgofunc(){wg.Wait()close(results)}()// 收集结果forr:rangeresults{fmt.Println(r)}}考点解析: worker pool核心是固定goroutine数量消费有界channel防 goroutine无限增长。close(jobs)通知worker退出range自动感知channel关闭。close(results)必须等所有worker完成(Wait之后)。十、fan-out/fan-in模式考点: fan-out分发任务到多个goroutinefan-in合并多个结果channel。packagemainimport(fmtmath/randsynctime)// fan-out: 把一个输入channel分发给多个workerfuncsplit(in-chanint,nint)[]-chanint{outs:make([]-chanint,n)fori:0;in;i{// 每个worker读取同一个in, 竞争消费outs[i]worker(in)}returnouts}// worker 处理单个输入channel的元素funcworker(in-chanint)-chanint{out:make(chanint)gofunc(){deferclose(out)forv:rangein{// 模拟处理耗时time.Sleep(time.Duration(rand.Intn(100))*time.Millisecond)out-v*2}}()returnout}// fan-in: 合并多个channel为一个funcmerge(chs...-chanint)-chanint{varwg sync.WaitGroup out:make(chanint)// 为每个输入channel启动一个转发goroutinewg.Add(len(chs))for_,ch:rangechs{gofunc(c-chanint){deferwg.Done()forv:rangec{out-v// 转发到统一输出}}(ch)}// 所有转发完成后关闭输出gofunc(){wg.Wait()close(out)}()returnout}funcmain(){// 生成输入数据in:make(chanint,10)gofunc(){deferclose(in)fori:1;i5;i{in-i}}()// fan-out到3个worker, fan-in合并outs:split(in,3)merged:merge(outs...)forv:rangemerged{fmt.Println(v)}}考点解析: fan-out是分发模式提高吞吐量fan-in是合并模式简化下游消费。关键是每个worker有自己的输出channelmerge用WaitGroup等待全部完成后close输出channel。这种模式在pipeline并发中非常常见。踩坑经验坑1: channel方向误用导致编译错误面试时手写fan-in把双向channel传给只接收单向channel参数的函数。代码:// Bad: 声明双向channel, 但函数参数是只发送单向channelfuncsendData(chchan-int){// 只能发送ch-1}funcmain(){ch:make(chanint)// 双向channelsendData(ch)// 双向可以隐式转为单向, 这里OK// 反过来才有坑ch2:make(-chanint)// 只接收的channel// sendData(ch2) // 编译错误: cannot use ch2 (type -chan int) as type chan- int}Go中双向channel可以隐式转换为单向channel但单向不能转为双向也不能反方向使用。面试时容易把chan- int(只发)和-chan int(只收)记混。箭头方向就是数据流方向chan-表示往channel里写-chan表示从channel读。面试时在纸上画箭头方向最不容易搞混。对比分析维度Go并发面试Java并发面试Rust并发面试核心考点channel/CSP模型synchronized/Lock/AQS所有权/Borrow Checker数据竞争检测-race运行时检测需手动分析编译期拒绝线程模型M:N(goroutine映射线程)1:1(Thread映射OS线程)1:1(std::thread)同步原语channel优先锁优先Mutex/Channel均可典型手写题worker pool/fan-in生产者消费者Send/Sync traitGo面试侧重CSP理念(channel通信共享内存)Java侧重锁机制和内存模型Rust侧重编译期安全保证。Go面试的手写代码题占比高要求现场写出无deadlock的并发模式。总结Go并发面试10道题覆盖了从调度原理到设计模式的完整路径。GMP模型是理论基础channel和context是日常最常用的两个原语sync.Map和sync.Pool在特定场景比手写加锁方案更优。worker pool和fan-out/fan-in是两个必考设计模式手写时注意channel方向、close时机和WaitGroup的Add位置。race检测器是开发期的安全网CI流水线必加-race标志。
返回列表