最近排查线上Go服务的时候,遇到一个非常隐晦的问题:某个任务处理模块的goroutine数量持续上涨,但CPU和内存看起来都很正常,日志里也没有任何报错。折腾了半天,最后定位到原因居然是——channel缓冲区设置不当导致生产者goroutine永久阻塞,而阻塞的goroutine又无法被回收,只能越积越多。这个经历让我觉得,很多人(包括当时的我)对Go Channel缓冲和无缓冲的理解只停留在“缓冲带容量、无缓冲不带容量”这种表面层次,真正遇到问题的时候根本不知道从哪入手。
这篇文章我想把Go Channel缓冲与无缓冲的区别彻底讲透。不光是语法层面的差异,更重要的是底层运行机制、阻塞时机、适用场景、性能表现以及那些只有踩过坑才能总结出来的经验。无论你是刚学Go的新手,还是写了好几年Go但没深究过并发模型的老手,这篇内容应该都能帮你补上一些盲区。
1. 从一次线上故障说起:为什么必须搞懂缓冲区
1.1 事故现场:生产者被“悄悄”卡死
先说那次线上事故。我们有一个数据采集服务,每个采集任务会启动一个goroutine去上游拉数据,拉到的数据通过channel交给下游处理模块。当初设计的时候拍脑袋写了一句:
taskCh := make(chan Task, 100)看着容量100不算小,但实际上游采集速度远快于下游处理速度。高峰期一秒能产生几千个Task,Channel很快就满了。满了之后会发生什么?生产者goroutine会阻塞在taskCh <- task这一行等待接收方腾出空间。
这里的问题在于:下游处理模块偶尔会因为依赖的第三方接口超时而变慢,一旦变慢,Channel缓冲区持续处于打满状态,大量生产者goroutine就全部堵在发送那一行。因为Channel没有超时机制,也没有任何错误返回,这些goroutine就这么悄无声息地阻塞着。
最坑的是什么?这些阻塞的goroutine不会崩溃、不会打日志、不会占用明显的内存,只会保留在goroutine栈里,慢慢把服务的goroutine数量拉到几十万。排查的时候如果只看CPU和内存,完全看不出问题。
这次故障让我重新审视了Channel缓冲区设计。缓冲区的本质是流量整形,它允许生产者和消费者暂时速率不一致,但缓冲容量一旦打满,生产者的阻塞就是必然的。这个“必然”不能靠运气,必须在设计阶段就算清楚。
1.2 Channel在Go并发模型中的定位
在讲缓冲和无缓冲之前,有必要先明确Channel在整个Go并发模型里的位置。Go的并发哲学里有句经典的话:“Do not communicate by sharing memory; instead, share memory by communicating.”(不要通过共享内存来通信,而是通过通信来共享内存。)
Channel就是这句话的核心实现。它本质上是goroutine之间的一个数据管道,发送方把数据丢进管道,接收方从管道里取数据。但这个管道不是简单的“丢进去就能走”,它有一套完整的同步和阻塞机制,而缓冲与非缓冲就是这套机制最直接的分水岭。
从源码实现角度来说,无论make(chan int)还是make(chan int, 5),底层构造出来的都是hchan结构体,唯一的区别是dataqsiz字段是0还是非0。这个字段直接决定了发送和接收走的是“直连”路线还是“环形队列”路线,进而决定了整个Channel的阻塞行为。
理解了这一点,再看缓冲和无缓冲的区别,就不是“有没有容量”这么简单了,而是两种完全不同的同步策略。
2. 无缓冲Channel:同步消息传递的核心机制
2.1 运行逻辑:发送和接收必须“同刻在场”
无缓冲Channel的创建方式很简单:ch := make(chan int),不传第二个参数,或者说第二个参数默认为0。这里的0代表缓冲区容量为0,意味着Channel内部没有暂存数据的地方,任何一次发送都必须有一个对应的接收操作同时准备好。
执行到ch <- v这一行时,Go运行时(runtime)会执行以下逻辑:
- 尝试从接收等待队列(
recvq)中找一个正在等待接收的goroutine。 - 如果找到了,直接把数据从发送方拷贝给这个接收方goroutine,然后唤醒它。
- 如果没有找到,发送方goroutine会被包装成
sudog结构体,放入发送等待队列(sendq),自身陷入阻塞,直到某个接收方出现。
反过来也一样。执行<-ch时,如果发送等待队列里有goroutine在等,就直接收下数据并唤醒对方;如果没有,接收方就进入recvq队列阻塞。
画个简洁的比喻:无缓冲Channel就像两个人约定在某个路口碰面交接一个包裹。你到路口了,发现对方还没来,你只能站在原地等;对方到了发现你还没来,他也在原地等。双方必须同时在场,交接才能完成。所以无缓冲Channel的每一次收发操作都是一次严格的同步点。
2.2 典型应用场景:goroutine之间的同步信号
无缓冲Channel最典型的场景不是传数据,而是当同步信号用。我举几个实际项目中经常出现的用法。
第一个是等待goroutine任务完成:
done := make(chan struct{}) go func() { // 执行耗时操作 doSomething() // 完成后通知主goroutine done <- struct{}{} }() // 主goroutine阻塞在这里,直到上面的goroutine执行完成 <-done fmt.Println("任务完成")这里用的是struct{}{},空结构体不占任何内存,纯粹用于信号传递。done <- struct{}{}和<-done必须同时准备好,所以主goroutine一定会等到子goroutine执行到发送那一行才继续往下走。这其实就是一种可控的、无锁的“线程join”。
第二个是确保goroutine已经准备就绪:
ready := make(chan struct{}) go func() { // 做一些初始化工作,比如连接数据库 initDB() close(ready) }() <-ready // 此时可以放心使用数据库连接通过close(ready)而不是ready <- struct{}{},还能让多个接收方同时收到“就绪”信号。
第三个是用无缓冲Channel实现互斥访问。限于篇幅不展开代码,但思路就是:初始化一个容量为1的信号量Channel,谁拿到里面的令牌谁就能进入临界区,处理完再还回去。虽然官方提供了sync.Mutex,但在某些需要跨多个操作持有“锁”的场景下,Channel方式反而更灵活。
2.3 一个容易忽略的细节:收发顺序与happens-before保证
无缓冲Channel有个非常重要的语义:发送操作完成时,接收操作已经在等待了。这意味着发送方往Channel里写数据之后,接收方拿到数据之前,中间发生的所有内存修改对接收方都是可见的。
Go的内存模型明确规定:无缓冲Channel的发送操作“happens-before”接收操作的完成。听起来挺学术,翻译成人话就是:
ch := make(chan int) var result int go func() { result = 42 ch <- 1 // 发送之前所有写操作 }() <-ch // 接收完成之后 fmt.Println(result) // 这里一定能读到42,不需要加锁因为无缓冲Channel的同步特性,发送goroutine在ch <- 1之前对result的写入,一定在接收方<-ch完成之后可见。这就是为什么无缓冲Channel能充当安全的goroutine间内存同步工具,而缓冲Channel在这方面的保证要弱一些。
缓冲Channel只保证:发送操作在第n个元素入队时,happens-before第n个元素的接收完成。也就是说,它是按元素顺序同步的,不是按操作时刻同步的。这在第4部分对比时会再展开。
2.4 实操注意事项:无缓冲不等于性能差
很多新手会觉得:无缓冲Channel每次收发都要“对接”,性能肯定比缓冲Channel差,所以能加缓冲就加缓冲。这个想法只对了一半。
无缓冲Channel的确存在发送方和接收方之间的直接握手开销,但它的优势在于延迟低、语义明确。因为无缓冲意味着数据不需要先放进队列再取出来,发送方的数据可以直接拷贝给接收方,减少了一次中间存储。在一些延迟敏感的场景下,这种直接传递反而更优。
另一个要注意的点是,无缓冲Channel在高并发下容易造成频繁的goroutine阻塞和唤醒,这个上下文切换成本是不能忽略的。如果生产者普遍快于消费者,无缓冲Channel会让生产者大部分时间都处于阻塞状态,这种场景下缓冲Channel反而更合适。
所以正确的思维方式是:先想清楚业务逻辑需要什么样的同步语义,再决定用缓冲还是无缓冲,而不是凭“性能好坏”来拍板。
3. 缓冲Channel:异步队列的容量博弈
3.1 运行逻辑:环形队列与三态判断
缓冲Channel的创建方式是ch := make(chan int, n),其中n代表缓冲区容量。内部实现是一个环形队列(ring buffer),由hchan结构体里的buf字段指向一片连续内存,通过sendx和recvx两个索引记录当前写入和读取的位置。
发送操作ch <- v的运行逻辑如下:
- 检查缓冲区是否已满(
qcount == dataqsiz)。 - 未满:把数据写入
buf中sendx指向的位置,sendx后移,qcount加1,发送方直接返回,不阻塞。 - 已满:发送方进入
sendq等待队列阻塞,直到缓冲区有空位。
接收操作<-ch的逻辑是对称的:
- 检查缓冲区是否为空(
qcount == 0)。 - 非空:从
buf中recvx指向的位置读取数据,recvx后移,qcount减1。如果此刻sendq里有等待的发送方,还会顺便唤醒一个,让它把数据写进刚腾出的空位。 - 为空:接收方进入
recvq等待队列阻塞。
这段逻辑里最容易被忽略的是状态判断的顺序。缓冲Channel不是“有没有缓冲”两个状态,而是“空、非空非满、满”三个状态,接收和发送的行为在这三种状态下完全不同。
举个例子,一个容量为1的Channel,在初始空状态下,ch <- v一定不阻塞;在已满状态下,ch <- v一定阻塞。只要记住这个三态模型,很多误解都能解开。
3.2 容量设置:拍脑袋是大忌
缓冲容量设置是实战里最容易翻车的环节。容量设小了,生产者容易被堵死;容量设大了,内存浪费,而且还会掩盖系统真实的问题(比如消费端故障),等缓冲区被意外打满时才集中爆发。
我自己的经验是,容量设置必须基于生产速率、消费速率和可接受的积压量三个参数来估算。
假设生产者的平均生产速率是p个/秒,消费者的平均消费速率是c个/秒,且p > c,那么每秒会积压p - c个数据。如果希望在消费端出现问题时最多积压T秒的数据,那么缓冲区容量N至少为:
N = (p - c) × T举个例子:生产者每秒产生1000个请求,消费者每秒处理800个请求,消费端故障时我们希望最多积压10秒的数据(10秒内要有报警和修复机会),那么容量至少是:
N = (1000 - 800) × 10 = 2000注意,这是下限,不是建议值。实际设置时最好再留20%~50%的余量,并且配合监控报警——Channel当前积压的数据量可以通过len(ch)拿到,建议定期上报到监控系统。等到len(ch)长期接近cap(ch)时,说明消费能力不足或者容量已经不够了。
反过来,如果生产速率远小于消费速率,容量反而不需要太大。容量大只会让数据在队列里“躺”得更久,增加端到端延迟——消费端明明有能力立刻处理,数据却在缓冲区里排队等着,这没有任何好处。
3.3 读空与写满:最容易误判的两个边界
实战中很多人写代码都会遇到这两种情况,但要么没意识到,要么没搞清发生时的具体行为。
第一个是读空。从一个空的缓冲Channel读取,接收方会阻塞,直到有数据到来。这个行为很好理解,但组合上for range会有个小陷阱:
for v := range ch { // 处理v }for range会持续从Channel读取,如果Channel是空的,它会阻塞在这里等待新数据。这个阻塞是channel本身的语义,但在主goroutine里使用时要格外小心——如果没有任何生产者了,这行代码就是永久阻塞。如果你的程序还有其他事情要做,这种写法等于把整个流程卡死了。正确做法是使用select加超时,或者到最后主动close(ch)来结束for range。
第二个是写满。向一个已经写满的缓冲Channel发送数据,发送方会阻塞,直到缓冲区被消费出空间。这个行为本身没问题,但要注意:阻塞期间goroutine手里持有的资源不会被释放。比如一个持有数据库连接的goroutine因为写Channel被堵住了,这个连接就一直被占着,可能导致连接池里的连接被占满,进而引发新一轮的连锁故障。
我处理过的几次线上事故,根因都是这种“间接资源泄漏”:表面上是Channel满了,实际上是下游处理慢导致缓冲区被打满,最终把上游连接的资源全拖死。
实际开发中要给“写Channel”这种操作加上可超时的保护机制,最常用的是select加time.After:
select { case taskCh <- task: // 发送成功 case <-time.After(5 * time.Second): // 发送超时,做降级处理 log.Error("task channel full, drop task") }这样即使Channel满了,生产者也只是超时放弃,不会无限期阻塞。我的习惯是,任何生产端的Channel写入都必须走这个模式,除非你能百分之百确认消费端不会成为瓶颈。
4. 两者的本质区别与选择依据
4.1 核心差异对照:不只是“有没有容量”
把无缓冲和缓冲Channel的核心差异整理成一个对照表,方便快速查阅:
| 对比维度 | 无缓冲Channel | 缓冲Channel |
|---|---|---|
| 创建方式 | make(chan T) | make(chan T, n) |
| 内部缓冲区 | 无(dataqsiz=0) | 环形队列(dataqsiz=n) |
| 发送行为 | 必须有接收方同时等待,否则发送方阻塞 | 缓冲区未满时立即返回,满时阻塞 |
| 接收行为 | 必须有发送方同时发送,否则接收方阻塞 | 缓冲区非空时立即返回,空时阻塞 |
| 同步性质 | 强同步:发送与接收是同一个操作的两个面 | 异步解耦:发送方只需写入缓冲区即可继续 |
| 典型比喻 | 面对面交接 | 快递柜寄存 |
| 内存模型保证 | 发送前操作对接收方可见性强 | 按元素顺序保证,跨元素同步较弱 |
| 适用场景 | 信号同步、goroutine协作、事件通知 | 任务队列、流水线、限流削峰 |
这张表可以拿来当面试题答案用,但实际设计系统时,要判断的远不止这几点。
4.2 场景选择:分场景对比更清晰
大概总结了三类最典型的场景选择经验。
**第一类:需要强同步语义时,用无缓冲。**比如你要确认子goroutine已经执行到某个位置,或者要等待一组goroutine全部完成,这种场景下无缓冲Channel的“必须同时在场”特性,实际上帮我们自动完成了同步,不需要额外加锁或轮询。
**第二类:生产消费速率不一致时,用缓冲。**这是缓冲Channel的主场。生产者可能突然产生一批数据,消费者处理速度又跟不上,缓冲区能起到削峰填谷的作用。工作池(worker pool)模式就是典型例子:任务提交方只管往缓冲Channel里扔任务,固定数量的worker从Channel里取任务执行,两者互不阻塞,配合得恰到好处。
**第三类:高吞吐流水线处理中间结果。**数据经过多个处理阶段,每个阶段之间用缓冲Channel连接。缓冲能让每个阶段相对独立地运行,某个阶段短暂变慢时,其他阶段不会立即被拖死。当然,代价是整体数据在管道里会有滞留,实时性会降低。
实战里我经常看到有人把make(chan int, 1)当“带锁状态位”用,这本身没问题,但要注意容量为1的Channel和普通无缓冲Channel的行为差异非常大。容量为1时,第一次发送一定不阻塞,第二次发送在第一次被接收前一定阻塞。这在实现“最多有一个任务在执行”的串行化控制时非常有用,但如果你想要的是“每次都严格同步”,那必须用无缓冲。
4.3 底层实现:hchan结构体里的真相
想真正理解缓冲和无缓冲的区别,绕不开底层。Go的Channel实现在runtime/chan.go里,核心结构体是hchan:
type hchan struct { qcount uint // 当前缓冲区中的元素数量 dataqsiz uint // 缓冲区容量,即make的第二个参数 buf unsafe.Pointer // 指向环形队列缓冲区,无缓冲时为nil elemsize uint16 // 单个元素的大小 closed uint32 // 标记Channel是否已关闭 elemtype *_type // 元素类型 sendx uint // 发送索引,指向环形队列下一个写入位置 recvx uint // 接收索引,指向环形队列下一个读取位置 recvq waitq // 接收等待队列,存储阻塞中的接收goroutine sendq waitq // 发送等待队列,存储阻塞中的发送goroutine lock mutex // 保护上述字段的互斥锁 }dataqsiz就是make时传入的容量值。无缓冲Channel创建时dataqsiz=0、buf=nil,这意味着发送方没有可以写入的内存区域,只能去找接收方;接收方没有可以读取的内存区域,只能去找发送方。两者一旦相遇,数据直接从发送方栈拷贝到接收方栈,不经过任何中间缓存。
缓冲Channel则是在堆上为buf分配了一块连续内存,容量就是dataqsiz。发送方有地方放数据了,所以只有当qcount == dataqsiz时才会阻塞。特别要注意,环形队列构造成本不高,make(chan int, 1000)并不会一次性初始化1000个元素的类型数据,buf只是一块原始内存,元素是在写入时才填充的。所以容量大带来的内存开销没有想象中那么大,真正要算的是元素类型占用的空间乘以容量。
还有一个很多人不知道的细节:接收等待队列和发送等待队列是两个双向链表。当无缓冲Channel有接收方在等待时,发送方直接走后端逻辑找接收方;当缓冲Channel满了且有发送方在等待时,接收方从缓冲区取走数据后会顺手从sendq里唤醒一个发送方。这个“顺手唤醒”的设计保证了缓冲Channel在高并发下也能保持相对稳定的吞吐,不会因为频繁的空位通知造成性能波动。
4.4 底层源码验证:用cap(ch)判断Channel类型
判断一个Channel是不是带缓冲,有个很简单的办法:
if cap(ch) == 0 { fmt.Println("这是无缓冲Channel") } else { fmt.Printf("这是缓冲Channel,容量为%d\n", cap(ch)) }cap函数返回的就是hchan.dataqsiz。这个方法在调试时特别有用,尤其是当你拿到一个从别处传入的Channel,不确定它的具体类型时。
当然,实际工作中更常见的是用len(ch)和cap(ch)配合监控Channel的积压情况。len(ch)返回的是qcount,即当前缓冲区里有多少数据。对于无缓冲Channel来说,len(ch)永远为0,因为数据要么已经被接收方取走,要么发送方还阻塞在发送队列里。这一点也再次印证了:无缓冲Channel内部是存不住数据的,这也正是它被称为“无缓冲”的原因。
5. 实战中的常见坑与排查技巧
5.1 死锁:最常见的新手事故
死锁是玩Channel最容易踩的坑,尤以无缓冲Channel为甚。看这个经典例子:
func main() { ch := make(chan int) ch <- 1 // 这里会阻塞,因为没有接收方 fmt.Println(<-ch) }这段代码运行时会直接报fatal error: all goroutines are asleep - deadlock!。原因很简单:主goroutine执行到ch <- 1时,没有接收方在等待,也无缓冲可写,只能阻塞。而阻塞之后,主goroutine已经没有其他事情可做了,整个程序就陷入死锁。
类似的还有在同一个goroutine里既发送又接收,但没有缓冲:
ch := make(chan int) ch <- 1 fmt.Println(<-ch)这两个操作在同一goroutine里顺序执行,发送时没有接收方,直接死锁。
避免死锁有几个原则。发送和接收的配对要成对出现,如果发放在A goroutine,接收要确保有另一个goroutine在正确的时间点执行;不要让一个goroutine独自完成“发送后再接收”的无缓冲操作;使用select加default分支做非阻塞尝试,防止意外阻塞在Channel操作上。
select { case ch <- v: // 发送成功 default: // Channel已满(或无缓冲且无接收方),不阻塞 }这个非阻塞模式在需要“不阻塞发送”的场景里非常实用。但要注意,这种写法如果大量出现在热路径上,会频繁触发select的调度逻辑,性能上不如直接发送。需要根据场景取舍。
5.2 内存泄露:打满后的goroutine堆积
前面提到的线上故障,本质就是缓冲区打满后的goroutine堆积。这类问题有个特征:goroutine数量缓慢上升,然后突然飙升,再然后服务响应越来越慢。
排查方法我总结了一条实用路径:
- 先跑
go tool pprof http://your-service/debug/pprof/goroutine拿到goroutine的堆栈采样。 - 在采样结果里搜索
chan send或chan receive关键字。 - 定位到具体的Channel发送/接收行,看是哪个业务逻辑卡住了大量goroutine。
拿到堆栈后,重点看卡在channel send的goroutine数量。如果数量持续增长,说明生产速度远大于消费速度,缓冲区设计有问题。这时候有几条路可以走:
- 增加缓冲区容量,但这是治标不治本,只能缓解。
- 增加消费端goroutine数量,提升消费速度。
- 在发送端加超时控制,宁可丢弃数据也不能无限制堆积goroutine。
- 排查消费端是否出现阻塞,比如消费端调用的下游接口变慢、缺乏超时设置。
从我的经验来看,大部分Channel相关的“goroutine泄露”问题,根子往往不在Channel本身,而是某个下游环节变慢又没有任何超时和熔断机制。Channel只是忠实地把“拥塞”表现了出来。
5.3 Channel关闭的三种隐患
关闭Channel比打开它更需要小心。总结一下经常遇到的三种问题,以及对应的规避方式。
向已经关闭的Channel发送数据,会触发panic:
ch := make(chan int, 1) close(ch) ch <- 1 // panic: send on closed channel这是最严重的一种。规避方式是确保发送方永远不是关闭Channel的一方,或者用sync.Once保证只close一次。生产实践中,关闭Channel的责任最好明确划分给唯一的拥有方。
重复关闭Channel也会panic:
ch := make(chan int) close(ch) close(ch) // panic: close of closed channel如果关闭操作可能在多个goroutine里触发,建议用一个sync.Once包起来。
从已关闭且为空的Channel接收会立即返回零值,不会panic:
ch := make(chan int) close(ch) v := <-ch // v = 0,不阻塞这个行为会让接收方拿到一个“假数据”,如果业务逻辑不区分零值和正常值,就会产生隐蔽的bug。规避方式是使用v, ok := <-ch的语法,当ok为false时说明Channel已经关闭且缓冲区为空,此时要做的是退出处理循环而不是继续处理数据。
for v := range ch其实内部就帮你做了这个判断,Channel关闭且缓冲区清空后,range会自动退出。这也是为什么推荐用range遍历Channel而不是手动循环接收。
5.4 性能误区:缓冲真的更快吗?
最后聊一个大家争论最多的问题:是不是所有场景下缓冲Channel性能都比无缓冲好?
从benchmark结果来看,缓冲Channel在“生产消费速率不匹配”的场景下确实有明显优势,原因很好理解:生产者大多数时候直接写入缓冲区就返回了,不需要和消费者做“对接握手”。但是要注意,这个优势是建立在消费者不会成为瓶颈的前提下的。
平衡点在于吞吐量与延迟的权衡。缓冲区大了,吞吐量提升,但数据在队列里停留的时间变长,端到端延迟就会增大。你可以做个小实验:
// 无缓冲 ch1 := make(chan int) // 缓冲1000 ch2 := make(chan int, 1000)用同样的生产消费逻辑分别跑,消费端记录从“生产完成”到“收到数据”的时延,几乎一定能看到无缓冲的时延更稳定更小,而缓冲的时延分布会更宽。
另一个容易被忽略的性能因素是GC(垃圾回收)压力。缓冲Channel在堆上分配缓冲区,容量越大,GC需要扫描和管理的对象越多。如果缓冲区里的元素是指针类型,GC的扫描成本会更高。所以不要无脑把容量设得很大,够用就好。
根据我的经验,一个比较合理的策略是:在需要同步协作的地方用无缓冲Channel,在需要解耦削峰的地方用缓冲Channel,容量按“消费端故障时可接受的最大积压时间”来算,并且永远给发送操作加上超时保护。
写在最后
Go的Channel设计得相当精巧,缓冲与无缓冲看似只是容量的区别,实际上背后是两种不同的并发控制哲学。无缓冲Channel用严格的同步换来了明确的内存模型和低延迟;缓冲Channel用异步解耦换来了高吞吐和削峰能力,但代价是需要在容量规划和超时保护上花更多心思。
我个人在实际项目中反复吃过亏之后的体会是:Channel本身不会“做错”什么,它只会忠实地把你的并发设计缺陷暴露出来。缓冲区打满不是Channel的锅,是容量规划不合理;goroutine堆积不是Channel的锅,是缺少超时和熔断机制。写并发代码之前,先花几分钟想清楚:这个Channel是拿来“同步”还是拿来“缓冲”?如果缓冲,满了之后业务上应该怎么处理?想清楚这两个问题,至少能避掉八成以上和Channel相关的线上事故。
最后再分享一个小技巧:如果你在写代码时不确定当前Channel的行为,尤其是调试别人留下的老代码时,别猜,直接写一行测试代码,fmt.Println(cap(ch)),运行一下,所有疑问都会立刻清晰。生产环境的Channel更要养成打点监测的习惯,把len(ch)和cap(ch)上报到监控系统,设置合理的告警阈值,做到“将满未满”时就能发现问题。并发编程的路很长,希望这篇文章能帮你少踩几个坑。