news 2026/10/15 13:55:39

Go Channel 底层解析:锁、环形缓冲区与等待队列

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go Channel 底层解析:锁、环形缓冲区与等待队列

有一次排查线上并发服务,发现 goroutine 数量冲到几万,CPU 却不怎么涨。直觉告诉我问题出在 channel:一大票 goroutine 堵在ch <- v或v := <-ch上互相等待,活活把并发打成了串行的“等”。当晚我把 Go 运行时里 channel 的实现从头读了一遍,读完之后,以前那些零散认知——什么时候走缓冲区、什么时候直接做数据拷贝、锁到底保护哪些字段、阻塞的 goroutine 被存放在哪里——全部串了起来。

这篇文章就是那次阅读的整理。我按 runtime 中hchan结构的三个核心机制展开:锁、环形缓冲区、等待队列。会讲清ch <- v与v := <-ch背后的完整代码路径,也会解释几个非常反直觉的优化。适合已经写过一段时间 Go 并发代码、想往底层再走一步的读者。select 的完整实现不在本文范围,那是一个足够单独开一篇的话题。

1. hchan 结构体逐字段拆解:锁、缓冲、队列如何拼成一台机器

1.1 核心源码骨架

在 Go 运行时里,channel 的运行时表示是hchan。名字很直白:heap channel,因为它总是在堆上分配的。简化掉和主线无关的字段后,核心结构长这样:

type hchan struct { qcount uint // 当前环形队列中的元素个数 dataqsiz uint // 环形队列容量,也就是 make(chan T, n) 里的 n buf unsafe.Pointer // 指向环形队列内存 elemsize uint16 // 单个元素占用的字节数 closed uint32 // 是否已经被 close elemtype *_type // 元素类型,拷贝元素时要用 sendx uint // 下一个写入位置 recvx uint // 下一个读取位置 recvq waitq // 阻塞在接收操作的 goroutine 队列 sendq waitq // 阻塞在发送操作的 goroutine 队列 lock mutex // 保护上述所有字段的排他锁 }

一眼看过去,channel 的“存储”其实是一个带两个下标的循环数组:buf是内存,sendx是写指针,recvx是读指针,qcount记录当前积压的元素数量。发送、接收两个方向各有一支等待队列,队列节点不是 goroutine 本身,而是一个叫sudog的结构。最后,整个结构体用一把mutex包住,所有字段的读写都在锁内完成。

这三个组件各司其职:环形缓冲区处理的是“缓冲区还有空间”的正常收发;等待队列处理的是“缓冲区满了或空了,先睡一会”的阻塞场景;锁则保证多个 goroutine 同时操作 channel 时,共享状态不会被撕碎。

1.2 buf 与索引:chan 内部其实是个队列

很多人把 channel 理解成“带类型的管道”,但从内存布局看,它更像一个生产者消费者队列。发送方往队尾放数据,接收方从队头取数据,sendx和recvx就是队尾和队头的指针。这带来几个直接推论:

  • 元素的顺序是保序的,先发的先被收到,FIFO;
  • 容量不够时新来的发送者会排进sendq,等待时也是按顺序出队;
  • qcount用来区分“空”和“满”,因为当sendx == recvx时,既可能是空也可能是满,单靠两个下标没办法分辨。

这个设计比“动态数组 + 搬迁”简单得多。数组满了不想开新内存,直接把写指针绕回开头,只要读指针没追上它就行。Go 选择固定容量的环形缓冲区,换来的是 O(1) 的入队出队和完全可预测的内存占用,代价是容量用完之后只能阻塞。

1.3 waitq 与 lock:队列本身不是并发安全的

recvq和sendq的类型是waitq:

type waitq struct { first *sudog last *sudog }

它就是一个双向链表的头尾指针。链表里每个节点描述一个“等待中的 goroutine”。这里要注意,waitq自己没有锁,它只是数据结构;链表节点的插入、删除、遍历全部要持hchan.lock。所以 channel 的并发安全并不是来自“队列自带锁”,而是来自hchan.lock这个总闸。你读源码时会发现几乎所有函数的第一行都是lock(&c.lock),最后一行是unlock(&c.lock),就是这个原因。

既然锁这么重要,就需要搞清楚它到底锁住了什么、为什么能扛住高并发。

2. 从 makechan 开始:环形缓冲区的分配策略与两个隐藏细节

2.1 两种内存布局:GC 决定要不要单独分 buf

先看出生过程。make(chan T, n)最终会调用makechan。源码里对内存分配做了区分:元素类型不含指针和含指针,采用两种完全不同的布局。

func makechan(t *chantype, size int) *hchan { mem := hchanSize + uintptr(size)*t.elem.Size_() var c *hchan if !t.elem.HasPointers() { // 元素不含指针:hchan 和 buf 一次性分配,紧挨着放 c = (*hchan)(mallocgc(mem, nil, flagNoScan)) c.buf = add(unsafe.Pointer(c), hchanSize) } else { // 元素含指针:hchan 和 buf 分开分配,buf 需要 GC 扫描 c = new(hchan) c.buf = mallocgc(uintptr(size)*t.elem.Size_(), t.elem, flagScan) } c.elemsize = uint16(t.elem.Size_()) c.elemtype = t.elem c.dataqsiz = uint(size) return c }

第一次读到这里时,我愣了好一会:为什么无指针类型要把buf紧跟着hchan分配?原因在 GC。如果元素里没有指针,GC 扫描这个对象时不需要递归检查每个元素引用,于是整块内存可以标记为“无需扫描”,分配一次即可。如果元素含指针,buf里的内容必须参与 GC 追踪,所以要单独分配,并且把元素类型传给分配器,让它知道怎么扫描。

另一个隐藏细节:如果元素大小是 0,比如make(chan struct{}, 1000000),mem计算出来还是hchanSize,分配器也不会为这 100 万个元素真给内存。这对struct{}类型的 channel 来说很划算,它本质只承担信号同步作用。

2.2 判断空与满:为什么必须靠 qcount

环形缓冲区的容量判断是最容易被误读的地方。直觉是:sendx == recvx表示空,其实不一定。看一个容量为 4 的 channel:

操作sendxrecvxqcount状态
初始000空
连续发送 4 次004满
接收 1 次013正常
再连续接收 3 次000空

可以看到,空和满两种状态下sendx都可能等于recvx。区分它们只能看qcount:qcount == 0是空,qcount == dataqsiz是满。所以在 channel 源码里,你几乎看不到用“两个下标是否相等”来判断状态的代码,到处都是对qcount和dataqsiz的比较。

这个设计也解释了为什么环形缓冲区容量必须是固定的:如果容量可变,数据搬迁会非常麻烦,而且qcount与容量之间的关系会失去约束力。

2.3 下标回绕:一个 if 就完成了取模

环形缓冲区最常见的写法是用取模(sendx + 1) % dataqsiz来绕圈。Go 源码没有用取模,用的是更直接的判断:

func chanbuf(c *hchan, i uint) unsafe.Pointer { return add(c.buf, uintptr(i)*uintptr(c.elemsize)) } // 入队后推进发送下标 c.sendx++ if c.sendx == c.dataqsiz { c.sendx = 0 } // 出队后推进接收下标 c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 }

为什么不用取模?因为除法在 CPU 里是比较贵的指令,而if + 清零在绝大多数场景下只需要两次整数比较,成本低得多。容量不要求是 2 的幂,照样能正确回绕。你要读源码,这种“能用if就不用%”的优化思路会一直出现。

3. 锁的边界:为什么整条收发路径只有一把互斥锁

3.1 为什么不能用读写锁

channel 的读写操作看起来天然适合“读多写少”,能不能用读写锁?不能。因为在 channel 里,发送方和接收方都会修改同一个共享区域:发送方写buf、移动sendx、改qcount;接收方读buf、移动recvx、改qcount。两边都是写操作,唯一的“读”其实是拷贝元素时短暂读取elemtype、elemsize等元数据。所以这里只能是一把排他锁,不分读者和写者。

锁保护的范围也很明确:hchan的全部字段。具体到一次发送:

  • 检查c.closed,防止向已关闭的 channel 发送;
  • 检查c.recvq,看有没有正在等待的接收者;
  • 检查c.qcount,决定是写入缓冲区还是把自己挂进sendq;
  • 真正执行memmove拷贝元素;
  • 推进sendx,修改qcount。

以上每一步都必须串行,否则两个 goroutine 同时入队,sendx就会错乱,qcount就对不上了。

3.2 锁内的临界区到底有多小

读源码时你会注意到,锁的临界区通常很短:没有系统调用、没有 IO、没有用户回调。绝大多数情况下就是几十条指令的内存操作,锁持有时间在微秒级甚至纳秒级。这也是为什么 channel 在小规模并发下性能不错:高并发下锁竞争虽然存在,但临界区短,等待者很快能抢到锁。

Go 运行时的mutex是自旋与休眠混合实现的。短临界区下,竞争线程先自旋一小段时间,通常能等到锁释放,省去了进入内核态休眠的代价。如果自旋超时还没拿到,才把线程休眠。这套机制对 channel 这种“临界区极短”的场景很友好。

3.3 一个关键顺序:先解锁,再唤醒 goroutine

最容易被忽略的细节是锁与唤醒的顺序。在send和recv这两个内部函数里,我看到的模式是:

func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { if sg.elem != nil { sendDirect(c.elemtype, sg, ep) sg.elem = nil } gp := sg.g unlockf() // 先释放 hchan.lock goready(gp, skip) // 再唤醒被阻塞的 goroutine }

unlockf是对解锁函数的封装,传进来的是一个闭包,实际就是unlock(&c.lock)。这里刻意把goready放在解锁之后,非常讲究。goready需要把 goroutine 放进调度器的可运行队列,那会拿调度器自己的锁。如果还握着c.lock就跑去唤醒他人,可能形成锁顺序依赖:A goroutine 持有 channel 锁等待调度器锁,另一个 goroutine 持有调度器锁又想在某个 channel 上操作。为了避免这种潜在死锁,运行时的惯例是锁内只改状态,锁外再唤醒。

我在读旧版本代码时见过唤醒在锁内的写法,后来 Go 逐步改成锁外唤醒,不是没有原因的。你写业务代码时也可以借鉴这个顺序:在临界区内完成数据修改,然后在临界区外通知别人,能显著减少死锁面。

3.4 锁竞争的代价:单 channel 的吞吐天花板

既然锁临界区短,是不是就可以随便往一个 channel 里塞数据?不是。多核竞争下,锁的 cache line bouncing 会让性能变得很难看:每次解锁后,其他核心要重新拉取这段缓存,CPU 时间的相当一部分会耗在缓存同步上。实践中的经验是,一个 channel 的吞吐上限通常就是这把锁的上限,跟缓冲区大小关系不大。缓冲区只是降低阻塞概率,并不会减少锁竞争频率,因为每次收发都还是要拿锁。

4. sendq、recvq 与 sudog:阻塞的 goroutine 是如何被管理的

4.1 sudog:封装一次阻塞等待

当缓冲区满或空,goroutine 不能立刻完成收发,就要进入等待队列。等待队列的节点是sudog:

type sudog struct { g *g // 等待的 goroutine isSelect bool // 是否来自 select elem unsafe.Pointer // 等待中的元素指针 next *sudog prev *sudog releasetime int64 }

为什么不让 goroutine 直接排队,而要包一层sudog?因为一个 goroutine 可能同时等待多个 channel,比如在select里。sudog相当于一次“等待请求”的登记表:我在等哪个 channel,我的数据放在哪个地址,我是不是 select 的一部分。同一时刻一个 goroutine 可以被多个sudog引用,这为 select 的多个等待分支提供了支持。

elem字段也很关键。发送者被阻塞时,elem指向待发送变量地址;接收者被阻塞时,elem指向接收目标地址。后面唤醒后的数据拷贝,靠的就是这个指针。

4.2 入队出队时机:收发路径的四个分支

结合sendq和recvq,channel 的每一次收发都遵循一套固定判断顺序。

发送ch <- v时:

  1. 如果recvq里有等待的接收者,直接让对方收走,不走缓冲区;
  2. 否则,如果缓冲区没满,写入缓冲区;
  3. 否则,把自己挂进sendq,gopark休眠。

接收v := <-ch时:

  1. 如果sendq里有等待的发送者,直接从对方那里拿数据;
  2. 否则,如果缓冲区有数据,从缓冲区取;
  3. 否则,把自己挂进recvq,gopark休眠。

这套分支顺序贯穿全文,后面第 5 节会展开两个反直觉的“快速路径”。

4.3 gopark 与 goready:goroutine 的状态流转

阻塞不只是“傻等”,它有一套与调度器联动的状态机。调用gopark时,当前 goroutine 会从running状态切到waiting状态,并让出 CPU,去执行别的 goroutine。gopark还会带上一个等待原因,比如waitReasonChanSend或waitReasonChanReceive。这就是你在 goroutine dump 里看到chan send (...)、chan receive (...)的来源。

等到被唤醒时,goready会把 goroutine 从waiting状态放回可运行队列,标记为runnable,等待调度器再次选中它。所以从挂起到唤醒,数据拷贝其实已经完成了,唤醒后的 goroutine 只是回到用户代码继续往下走。

有一点值得单独说:被唤醒并不代表原操作一定成功,因为 channel 可能在等待期间被close了。sudog里有个成功标志,唤醒后如果发现 channel 已关闭,接收者会拿到零值并返回ok=false,发送者则会 panic。边界情况在chanrecv和chansend里都有专门处理。

5. 收发路径完整走读:两个反直觉的快速路径

5.1 有缓冲发送:recvq 非空时,数据根本不放缓冲区

很多人以为ch <- v一定先把数据放进缓冲区,接收方再从缓冲区取。实际上,当recvq非空时,发送方会绕过缓冲区,直接把数据交给等待中的接收者。

为什么可以绕过?因为这里有一个很重要的不变量:如果recvq非空,缓冲区一定是空的。想想看,如果缓冲区里有数据,接收者早就从缓冲区拿到数据了,根本不会阻塞到排队。所以发送方遇到等待的接收者时,缓冲区必然是空状态,直接点对点直传不会破坏 FIFO 语义。

对应源码结构:

lock(&c.lock) if sg := c.recvq.dequeue(); sg != nil { send(c, sg, ep, func() { unlock(&c.lock) }, 3) return true }

send函数里做的是栈到栈的直接拷贝:发送方的数据用sendDirect写到接收方sudog.elem指向的内存。因为接收者正处于等待状态,没有执行任何用户代码,它的栈是稳定可写的。这个设计省掉了一次“先入 buf,再出 buf”的中转,而且数据只拷贝一次。

5.2 有缓冲接收:sendq 非空时,会触发“读一个补一个”

接收方同样有反直觉的路径。当sendq非空时,说明缓冲区一定是满的——缓冲区不满,发送者早就入队了,不会躺在等待队列里。此时接收方一来,正确的做法并不是只从缓冲区拿走一个,而是走recv函数:

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { if c.dataqsiz == 0 { recvDirect(c.elemtype, sg, ep) } else { qp := chanbuf(c, c.recvx) typedmemmove(c.elemtype, ep, qp) // 1. 缓冲区队头元素交给接收方 typedmemmove(c.elemtype, qp, sg.elem) // 2. 等待发送者的数据补进队头位置 c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 } c.sendx = c.recvx c.qcount-- } ... }

这段代码非常漂亮。接收方拿走队头旧元素后,紧接着把等待发送者的元素直接写入刚刚空出来的位置,下一次接收又能立刻拿到它。sendx被设置成与recvx相同,因为缓冲区从“满”退回到“满-1+1”,而且新补进去的元素就在对头位置,发送下标应该跟随队尾移动。

为什么不能简单地从缓冲区取一个,然后让等待的发送者醒来慢慢入队?因为那样需要多一次唤醒、多一次入队,而且发送者在被唤醒后还要重新拿锁执行一次发送流程,多走一圈竞态检查。当前实现把“补位”直接塞进了接收者的临界区里,一次锁操作完成两件事,效率高很多。

这段“读一个、补一个”的代码是我觉得整个 channel 实现里最有启发的地方。看懂它,才算真正理解了 channnel 的收发不是“进缓冲、出缓冲”的简单两级流水线,而是充满直传优化。

5.3 无缓冲收发:没有中间商,纯直传

无缓冲 channel 在makechan时dataqsiz == 0,buf根本没有分配。它的收发只有一条路:接收方必须先进入等待队列,发送方到来后直接数据拷贝到接收方栈地址,然后唤醒。反过来也成立:发送方先等,接收方到来后从发送方栈上把数据拷走。

源码里send与recv对dataqsiz == 0的分支都调用了sendDirect/recvDirect这类直传函数,本质就是一次typedmemmove。所以无缓冲 channel 的天然语义就是“同步握手”:发送完成了,接收就完成了;接收没准备好,发送就永远等着。

我踩过的坑是把无缓冲 channel 当成“有缓冲但容量为 0”去理解,结果对阻塞时机的预期完全错位。无缓冲 channel 的核心价值在于同步,而不是临时存数据。

5.4 close 的边界:唤醒所有人,但发送者等来的是 panic

close的源码路径也是锁内处理等待队列、锁外唤醒。它会遍历recvq和sendq,把所有sudog的elem清零,收集到本地链表里,解锁后逐个goready。

关闭后的行为边界设计得很干脆:

  • 向已关闭的 channel 发送数据,panic;
  • 从已关闭但缓冲区还有数据的 channel 接收,先把剩余数据取完;
  • 缓冲区见底后再接收,直接返回零值,且ok标志为 false;
  • 无缓冲 channel 关闭后,接收者永远拿到零值。

很多人会误以为“关闭就是让接收者不再收到值”,其实是“先把存量发完,再返回零值”。这个语义在我的排查经历里多次出现过,比如一个消费者因为 channel 突然被 close,拿到的全是零值,又没检查ok标志,把零值当真数据处理了。看一遍源码之后,这个行为就再也忘不掉了。

6. 读完源码后的实战心得:选型、调参与避坑清单

6.1 缓冲区大小该怎么定,依据是什么

知道了内部实现,再回头选缓冲区大小就有依据了。缓冲区存在的意义是允许生产者和消费者在一段时间内速度不一致:消费者临时慢了,生产者可以继续往里放几个;生产者临时慢了,消费者可以继续取出几个。如果双方基本同步,缓冲区设成 0 就很合适,还强制了每次交互都完成一次握手,语义更清晰。

如果只有一方偶发波动,缓冲区可以设成“波动容忍量”:一次突发最多积压多少任务,就设成多大。不要拍脑袋设一个很大的数,因为过大的缓冲区只会让调度延迟变高,消费者拿到的任务不够新鲜,还会让内存长时间占用。我在源码里看到qcount与dataqsiz比较满空的逻辑后,对“容量只是缓冲队列长度”这个理解更深了一层:它不会改变 channel 的语义,只改变阻塞出现的时机。

6.2 锁竞争明显的场景,不要只盯着缓冲区

如果你已经排除了其它问题,发现压测时大量 goroutine 卡在同一个 channel 的收发上,那大概率是hchan.lock本身的竞争饱和了,而不是缓冲区不够。此时加缓冲区只会延迟阻塞,锁竞争依然存在。

缓解手段一般朝两个方向走:

  • 扇出:一个生产者的数据通过 hash 或轮询分到多个 channel,多个消费者组分别处理,天然降低单把锁的竞争;
  • 批量:把多次发送合并成一次批量发送,比如上报日志时攒一批再统一写入 channel,减少锁调用次数。

我处理过一个类似场景:高频埋点数据全部涌向一个 channel,把单个 channel 当成消息总线用。改成 4 个 channel 按用户 ID 分片后,吞吐提升非常明显。源码的知识有时候不需要真的去改 runtime,它帮你判断瓶颈在哪里。

6.3 高频踩坑点:nil channel、close 与 goroutine 泄漏

最后按源码行为列几个高频坑:

  • nil channel 收发永久阻塞。源码里chansend遇到c == nil直接gopark,不再继续。这不是 bug,是设计。忘记初始化 channel 就使用,会让 goroutine 安静地消失。
  • 发送方的 panic 不一定在 close 时立刻发生。如果你向一个已 close 的 channel 发送,源码会在获取锁后检查closed并 panic;但如果发送方已经阻塞在sendq里,close 会唤醒它并直接让它 panic。
  • 等待队列不是无限制的。无限地创建 goroutine 去等一个永远不会有东西的 channel,最终会拖垮内存。所以 goroutine dump 里如果大量停在chan send或chan receive,应该马上排查是否有消费端没起来、或者逻辑上不满足唤醒条件。
  • 不要在接收端只依赖返回值不检查 ok 标志。close 后接收会不断返回零值,除了“零值是否合法”的问题,更重要的是这可能让循环进入忙轮询,白白占满 CPU。

6.4 我排查问题时常看的三个信号

看完源码后,我排查 channel 相关问题的步骤也固定了。先抓 goroutine dump,数一数停在chan send、chan receive、select上的 goroutine 数量。如果chan send多,说明消费者瓶颈或者 buffer 太小;如果chan receive多,说明生产不足或者任务还没进来;如果两者都多且数量相近,往往是无缓冲 channel 的同步握手太多,锁竞争或唤醒开销占据了主导。

抓到信号后,再看qcount,也就是 channel 当前堆积量。业务代码里如果用了runtime或调试工具,能看到 channel 的qcount/dataqsiz,这个比值能直接告诉你 buffer 是常满还是基本空闲。常满说明消费者跟不上,空闲但 goroutine 阻塞多说明同步模式太密。这套判断方法比我以前“先加 buffer,再上日志”要快得多。

最后再分享一个小习惯:我遇到可疑阻塞问题时,先不急着改代码,而是先画一条“发送方、channel、接收方”的状态线,标出recvq、sendq分别有没有人、缓冲区空不空,再对照源码分支判断卡点在哪。多数时候问题不是调度器,也不是 Go 版本,而是我们对 channel 行为模型的想象出了问题。读完源码以后,channel 就不再是黑盒了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 13:55:39

二叉搜索树核心操作:LCA查找、插入与删除

1. 二叉搜索树基础与算法挑战概述二叉搜索树&#xff08;BST&#xff09;作为一种经典的数据结构&#xff0c;在算法面试和实际工程中都有着广泛应用。今天我们将集中解决BST相关的三个经典问题&#xff1a;最近公共祖先查找&#xff08;LCA&#xff09;、节点插入和节点删除。…

作者头像 李华
网站建设 2026/10/15 13:55:35

STM32实时音频频谱实现:从ADC采样到LED显示的完整嵌入式信号处理链

简介&#xff1a;本资源是一套基于STM32微控制器实现音乐频谱实时可视化功能的完整嵌入式项目&#xff0c;面向嵌入式初学者、电子类专业学生及STM32开发实践者&#xff0c;解决音频信号采集、FFT频域分析与LED动态频谱显示等典型嵌入式信号处理问题。压缩包共190个文件&#x…

作者头像 李华
网站建设 2026/10/15 13:54:36

分层控制在孤岛式直流微电网中的优化与应用

1. 项目背景与核心价值孤岛式直流微电网作为分布式能源系统的关键组成部分&#xff0c;其控制策略直接关系到供电可靠性和电能质量。传统集中式控制在面对复杂拓扑结构时暴露出通信依赖性强、容错性差等固有缺陷。这个项目通过分层控制架构的创新设计&#xff0c;实现了对IEEE …

作者头像 李华
网站建设 2026/10/15 13:54:34

2026年期货回测平台核心技术解析与选型指南

1. 期货回测平台的核心价值与行业现状期货交易员的一天往往从回测开始。清晨6点&#xff0c;当大多数人还在睡梦中时&#xff0c;我已经在电脑前运行着昨晚构思的新策略。回测平台输出的每一个数字&#xff0c;都决定着今天是否要实盘投入真金白银。在这个领域&#xff0c;回测…

作者头像 李华
网站建设 2026/10/15 13:54:16

随机化快排:分治、递归与随机主元的工程实践

1. 项目概述&#xff1a;为什么随机化快排不是“加个random”就完事了&#xff1f;快速排序&#xff08;QuickSort&#xff09;是算法课上绕不开的硬骨头&#xff0c;但很多人写完标准版本后&#xff0c;一遇到极端数据——比如已经排好序的数组、大量重复元素的数组&#xff0…

作者头像 李华