刷题的时候队列是最容易掉以轻心的一个结构——enqueue和dequeue两个操作看一眼就懂写起来也就几行。但我见过不止一个项目栽在这几行上某次批量任务处理的服务消费端处理速度稍慢队列里的数据就莫名其妙被覆盖了日志里读出来的任务内容全是错位的排查了两天才定位到循环队列的判满条件写成了rear front。队列的坑几乎全部集中在边界条件和指针语义上而不是操作本身。这篇内容就把队列从底层语义到工程落地完整拆一遍数组实现怎么推导判空判满、链表实现怎么避免断链、双端队列和优先队列各自适合什么场景、BFS 和环形缓冲区里队列扮演什么角色。不管你是刚开始学数据结构还是已经在写业务代码但对队列的边界条件心里没底下面这些内容都能直接拿走用。1. 队列的第一道坎三个指针变量背后的语义约定1.1 一头进一头出队列到底约束了什么队列的定义只有一句话只允许在一端插入、在另一端删除的线性表。插入的那端叫队尾rear删除的那端叫队头front。这个约束听起来简单但它带来的行为特征是先进先出也就是常说的 FIFO。我用一个生活场景对照一下就清楚了食堂打饭的队伍新来的人只能从队尾排进去打饭的阿姨只从队头接人。你不能插队也不能从中间被叫走。栈不一样栈像往箱子里叠盘子最后放进去的最先被拿出来。这两个结构在代码里长得极像但适用场景完全相反。从操作集合上看队列对外暴露的核心接口就四个操作语义时间复杂度enqueue(x)元素 x 从队尾入队O(1)dequeue()队头元素出队并返回O(1)front() / peek()只看队头元素不出队O(1)isEmpty()判断队列是否为空O(1)这四个操作全部是 O(1)这是队列最大的价值所在。它不提供随机访问不支持查找也不保证有序除非你用的是优先队列。它只做一件事维护元素的到达顺序。凡是按到达顺序处理、且生产者与消费者速率不匹配的场景队列几乎都是第一选择因为它把到达和处理这两件事在时间上解耦了。我以前写过一个图片处理的小工具主线程负责扫描目录拿文件路径工作线程负责压缩。一开始主线程扫完一个文件就直接调压缩函数结果大图压缩要几百毫秒扫描逻辑被卡得死死的整个界面像死了一样。后来加了一个队列把路径先攒起来主线程入队工作线程出队两边各跑各的界面立刻就顺了。这就是队列最朴素也最常用的价值。1.2 顺序队列的假溢出是怎么发生的先用最直觉的方式实现开一个定长数组data[N]用一个front记队头位置一个rear记队尾位置。入队时data[rear] x出队时return data[front]。空队列的条件是front rear看起来很自然。问题出在这里front和rear只增不减。假设数组长度是 8我入队 3 个元素再出队 3 个此时front 3、rear 3队列逻辑上是空的但rear已经停在 3 的位置。再入队 5 个元素rear到达 8数组明明还有 0 到 2 这三个位置空着因为front是 3前面三个槽早已被释放可rear 8已经越界了。这个现象就是假溢出队列并没有真的满只是指针走到了数组尽头。很多初学者第一次遇到时会以为是自己下标算错了其实是顺序存储方式本身的缺陷。注意判断顺序队列是否真满不能只看rear有没有到 N还要看front前面还剩多少空间。可如果允许rear绕回去那这个结构就已经是循环队列了。解决办法有两个方向搬移元素一旦rear到边界就整体前移把front归零。代价是每次搬移 O(N)均摊下来未必划算而且正在出队的线程需要额外同步。让指针绕回来把数组在逻辑上首尾相接rear加一后对 N 取模。这就是循环队列也是几乎所有标准库和工程实现采用的方案。1.3 front 与 rear 的语义分歧指向队头元素还是指向队头前一个位置在写循环队列之前有一个必须先定死的事情front到底指向队头元素所在的下标还是队头元素前一个空位rear同理。这两种约定都能工作但混用会出大问题而且几乎所有循环队列的 bug 都源于这里。常见的有两种流派流派 Afront 指向队头元素rear 指向下一个可写入的空位。初始时front rear 0表示空。入队写data[rear]然后rear (rear 1) % N。出队读data[front]然后front (front 1) % N。这套约定最直观队头元素永远在data[front]队尾下一个空位是data[rear]。流派 Bfront 指向队头元素的前一个位置rear 指向队尾元素。入队先rear (rear 1) % N再写值出队先front (front 1) % N再读值。这套约定在读队尾元素时方便但初始状态和判空逻辑会更绕。我强烈建议统一采用流派 A。原因很实际流派 A 下队列中元素个数可以直接写成(rear - front N) % N一眼就能看懂而流派 B 容易在队列为空时 front 和 rear 指向同一位置和队列满时也指向同一位置之间打转脑子很容易乱。真正需要小心的不是选哪个流派而是同一个项目里前后端、不同模块之间必须约定一致。我曾经接手过一份代码生产侧按流派 A 写消费侧按流派 B 读单独测各自都通过联调时数据永远错一位查了半天才发现是语义没对齐。这个教训很值钱凡是涉及共享内存结构的地方把指针语义写进注释比写十行代码注释还重要。2. 循环队列的判空判满三种方案与取模下标的实测推演2.1 方案一牺牲一个存储单元循环队列最麻烦的地方是front rear这个条件既可能表示空也可能表示满。数组绕一圈之后队头队尾在同一个位置是完全合法的状态。最省事的办法是主动浪费一个槽位把数组长度设为 N但只允许存 N-1 个元素。约定front rear表示空(rear 1) % N front表示满。推导一下满的条件。当队列里已经有 N-1 个元素时rear再往前走一格就会和front重合而一旦重合我们就认定它满了不允许写入。所以满的判定就是rear 的下一个位置是否为 front// 约定front 指向队头元素rear 指向下一个可写位置 #define MAXSIZE 8 // 实际可用容量为 7 typedef struct { int data[MAXSIZE]; int front; int rear; } SqQueue; void init(SqQueue *q) { q-front 0; q-rear 0; } int isEmpty(SqQueue *q) { return q-front q-rear; } int isFull(SqQueue *q) { return (q-rear 1) % MAXSIZE q-front; } int enqueue(SqQueue *q, int x) { if (isFull(q)) return -1; // 满拒绝写入 q-data[q-rear] x; q-rear (q-rear 1) % MAXSIZE; return 0; } int dequeue(SqQueue *q, int *out) { if (isEmpty(q)) return -1; *out q-data[q-front]; q-front (q-front 1) % MAXSIZE; return 0; }这个方案的优点是代码极短、判断条件只有两个、没有额外字段非常适合嵌入式和对内存布局敏感的场景。缺点是浪费了一个槽位并且数组长度 8 只能装 7 个这件事必须写在文档里否则调用方按 8 来算容量迟早出事。我在实际项目里会额外加一个capacity()函数把MAXSIZE - 1显式暴露出去让调用方不要自己算。这种小封装看着不起眼但能挡掉很多我以为能装 8 个的争论。2.2 方案二引入 size 计数器如果不想浪费槽位最直观的做法是维护一个当前元素个数size。判空看size 0判满看size MAXSIZE两个条件互不干扰彻底绕开了指针重合含义不明的问题。typedef struct { int data[MAXSIZE]; int front; int rear; int size; // 当前元素个数 } SqQueueWithSize; int isEmpty(SqQueueWithSize *q) { return q-size 0; } int isFull(SqQueueWithSize *q) { return q-size MAXSIZE; } int enqueue(SqQueueWithSize *q, int x) { if (isFull(q)) return -1; q-data[q-rear] x; q-rear (q-rear 1) % MAXSIZE; q-size; return 0; } int dequeue(SqQueueWithSize *q, int *out) { if (isEmpty(q)) return -1; *out q-data[q-front]; q-front (q-front 1) % MAXSIZE; q-size--; return 0; }这个方案的可读性最好而且size可以直接对外暴露成当前积压任务数在监控和限流场景里非常有用。代价是多了一个整型字段入队出队时多一次自增自减。有个细节值得说size和front/rear是冗余信息理论上可以由(rear - front MAXSIZE) % MAXSIZE推出来。冗余意味着有不一致的风险——如果某处代码手动改了front却忘了改size结构就进入了一个自相矛盾的状态而且这种 bug 极难复现。所以用这套方案时我会把所有对front、rear、size的写操作都收拢到enqueue和dequeue两个函数里其他地方一律只读。这是封装的意义所在。2.3 方案三引入 tag 标记最近一次操作第三种方案更学术一点加一个tag字段记录最近一次操作是入队还是出队。入队时置tag 1出队时置tag 0。判空条件front rear tag 0。判满条件front rear tag 1。逻辑上很优雅——指针重合时看上一次操作是谁就能推断当前是满还是空。如果上一次是入队导致重合的那必然是被写满了如果是出队导致重合的那必然是出队出空了。typedef struct { int data[MAXSIZE]; int front; int rear; int tag; // 1 表示最近一次是入队0 表示最近一次是出队 } TagQueue; int isEmpty(TagQueue *q) { return q-front q-rear q-tag 0; } int isFull(TagQueue *q) { return q-front q-rear q-tag 1; }这个方案不浪费槽位也不需要额外的计数器代价是多一个判断条件而且每次入队出队都要维护tag。相比size方案它并没有带来明显收益所以在工程里用得反而少。我提它主要是为了完整性——面试和考试里会考知道它的存在能让你在讨论判满方案时说到点子上。三种方案的对照如下方案是否浪费槽位判断条件复杂度额外字段适用场景牺牲一个单元是容量 N-1低无嵌入式、内存布局敏感size 计数器否容量 N低1 个整型通用工程实现推荐tag 标记否容量 N中1 个整型考试/教学工程少用2.4 完整实现与边界测试用例判断一个循环队列写得对不对光看代码没用得跑边界。我总结了一套必测的用例无论你用哪种方案下面这几组都得过空队列出队应返回失败且front、rear不被破坏。满队列入队应返回失败且原有 N-1/N 个元素完好无损。反复入队出队直到指针绕回起点两次验证取模没有把下标算成负数。装满 → 全出空 → 再装满验证状态机没有卡死。单元素队列入队一个、出队一个检查front和rear是否都回到原位。第 3 条特别容易出问题。C 语言里负数取模的结果是实现相关的(rear - front N) % N这个写法里如果忘了那个 N当rear front时就会算出负数元素个数直接变成负值后面的for循环一次都不执行表现就是数据凭空消失。我做过的笔录里这个坑至少踩过三次每次都是在指针绕回之后才暴露。第 5 条也常被忽略。单元素时dequeue之后front前移一格如果正好等于rear队列回到空状态这是正确的但如果实现里把rear也一起重置了那么当这个队列正在被多个消费者共享时就会出现一个消费者出队导致另一个消费者的游标失效的问题。所以front和rear的更新要各管各的谁的操作谁负责别互相串联。还有一个实际调试技巧写一个dump()函数把front、rear、size和数组前 N 个槽位按顺序打出来。循环队列出问题时光看日志里入队了却没读到是完全没法定位的把内部状态打出来一看往往立刻就能发现是判满条件写错了还是取模算歪了。这个函数在开发阶段加上线后通过条件编译关掉几乎没有成本。3. 链式队列指针操作顺序决定你会不会断链3.1 带头结点与不带头结点的取舍链式队列用两个指针front和rear分别指向队头和队尾。入队在rear后面接新结点出队删掉front结点。相比数组实现它不需要预先定容量不会假溢出但每个结点多一个指针的开销且内存不连续缓存命中率差一些。第一个要决定的是要不要头结点。不带头结点的版本队列为空时front NULL第一个元素入队时front和rear都要指向这个新结点出队到最后一个元素时两个指针又都要置空。这两处两个指针同时动的分支是最容易漏写的地方一旦漏了就会出现front已空但rear还挂着野指针的情况下次入队直接往非法地址写。带头结点就没这个问题头结点永远存在front永远指向它真正的队头元素是front-next。空队列的条件变成front-next NULL或者rear front。入队和出队都不需要特判队列为空的分支代码结构统一很多。代价是多分配一个结点、多一层指针间接。对于绝大多数业务场景这点开销完全可以忽略换来的是代码正确性的显著提升。我的建议很明确除非你有明确的性能或内存约束一律带头结点。3.2 入队出队的指针改写顺序链式队列真正的坑在于指针改写顺序。以不带头结点的版本为例入队代码int enqueue(LinkQueue *q, int x) { Node *n (Node *)malloc(sizeof(Node)); if (!n) return -1; n-data x; n-next NULL; if (q-rear NULL) { // 空队列两个指针都要动 q-front n; q-rear n; } else { q-rear-next n; q-rear n; } return 0; }这里的顺序不能反必须先把新结点挂到rear-next上再让rear指向新结点。如果写成先q-rear n再q-rear-next n那就成了自己指向自己原来的队尾和新结点彻底断开前面所有元素全部丢失。这类错误编译器不会报运行时也不一定崩只是数据悄悄没了是最难查的一类。出队的顺序同样讲究int dequeue(LinkQueue *q, int *out) { if (q-front NULL) return -1; // 空队列 Node *tmp q-front; *out tmp-data; q-front tmp-next; if (q-front NULL) { // 最后一个元素被删掉了 q-rear NULL; } free(tmp); return 0; }关键点有两个。第一必须先把tmp-data取出来再free(tmp)顺序反了就是访问已释放内存。第二出队后如果front变成NULL说明队列已经空了此时必须把rear也置空否则rear变成悬垂指针。这个判断只能放在free之前或紧随其后位置错了就无效。带头结点的版本能把这两处特殊判断都消掉int enqueueWithHead(LinkQueue *q, int x) { Node *n (Node *)malloc(sizeof(Node)); if (!n) return -1; n-data x; n-next NULL; q-rear-next n; // 头结点永远存在rear 一定有效 q-rear n; return 0; } int dequeueWithHead(LinkQueue *q, int *out) { Node *first q-front-next; // 真正的队头 if (first NULL) return -1; // 空队列 *out first-data; q-front-next first-next; if (q-rear first) { // 出队的是最后一个结点 q-rear q-front; } free(first); return 0; }注意这里仍然有一个特判当出队的是最后一个元素时rear要退回到头结点否则rear会指向刚被释放的内存地址下次入队就写坏了。这个判断没法省但至少只有一个地方需要考虑比不带头结点版本的四处分支清爽太多。3.3 完整实现与两种实现的性能对照把带头结点的链式队列补全加上初始化、判空和销毁typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *front; // 指向头结点哨兵 Node *rear; // 指向队尾结点 } LinkQueue; void initQueue(LinkQueue *q) { Node *head (Node *)malloc(sizeof(Node)); head-next NULL; q-front head; q-rear head; } int isQueueEmpty(LinkQueue *q) { return q-front q-rear; } void destroyQueue(LinkQueue *q) { Node *p q-front; while (p) { Node *next p-next; free(p); p next; } q-front q-rear NULL; }isQueueEmpty直接比较front q-rear就够了因为入队永远让rear前移出队永远让front前移两者相等时只能是空队列。这个判据比判断front-next NULL更快也不需要解引用指针。两种实现的性能对比我按实际测过的量级给个参考维度数组循环队列链式队列入队/出队均摊复杂度O(1)O(1)但每次入队要 malloc内存分配一次性预分配每个元素独立分配缓存局部性好连续内存差结点分散容量上限固定仅受堆内存限制多线程环境相对容易做无锁需要自己管理内存回收适用场景高频、元素数可预估元素数波动大、单个元素较大关键差别在缓存。我在一次压测里跑过 1000 万次入队出队数组版本比链式版本快了接近三倍差距几乎全部来自 CPU 缓存未命中——链表每个结点都要跳一次指针而数组是顺序访问。所以如果你的场景是高频短消息、元素数可预估数组循环队列明显更划算如果元素大小差异很大、峰值不可预估链表更安全。4. 不止一种队列双端队列、优先队列与单调队列的适用边界4.1 双端队列两端都能进出的线性表普通队列限制了一端进一端出。双端队列deque把这个限制放开两端都可以入队和出队。它同时具备队列和栈的能力——只用一端操作就是栈一端进另一端出就是队列。双端队列的典型用途是滑动窗口类问题和撤销/重做历史。它要求两端的插入删除都是 O(1)所以底层一般用双向链表或者分段连续数组实现。C 的std::deque就是后者把内存切成若干固定大小的块再用一个索引数组管理块的顺序。这样既能像数组一样按下标随机访问又能像链表一样在两端高效插入。在 Python 里collections.deque是标准做法不要用list去模拟队列。list的pop(0)是 O(n) 的因为它要把后面所有元素往前挪而deque的popleft()是 O(1) 的。这个差别在做 BFS 或者处理十万级数据时会非常明显。from collections import deque q deque() q.append(1) # 右端入队 q.append(2) q.appendleft(0) # 左端入队 q.pop() # 右端出队 q.popleft() # 左端出队四个操作全部 O(1)这就是双端队列的完整能力。需要注意的是deque按下标随机访问是 O(n) 的虽然它做了优化比链表快所以如果你需要频繁随机访问还是用数组。4.2 优先队列出队顺序由优先级决定优先队列不保证先进先出它保证优先级最高的先出。这已经完全脱离了队列的 FIFO 语义是个独立的数据结构只是接口长得像队列。实现方式上堆是最常见的选择用数组存完全二叉树插入时上浮删除堆顶时下沉两个操作都是 O(log n)。为什么不用有序数组或者有序链表有序数组插入是 O(n)有序链表查找堆顶是 O(1) 但插入要遍历找位置也是 O(n)。堆用部分有序换来了插入和删除的平衡是所有方案里综合表现最好的。业务里的典型场景是任务调度。比如一堆待处理任务每个任务带一个优先级字段要求优先级高的先处理、同优先级按到达顺序处理。这时候用优先队列优先级作为主键到达序号作为次键就能稳定地满足需求。import heapq # 用小顶堆实现元组比大小(优先级, 到达序号, 任务内容) pq [] heapq.heappush(pq, (2, 0, 任务A)) heapq.heappush(pq, (1, 1, 任务B)) heapq.heappush(pq, (2, 2, 任务C)) while pq: pri, seq, task heapq.heappop(pq) print(pri, task) # 输出顺序1 任务B2 任务A2 任务C这里塞进元组第二位的seq很关键。Python 的堆比较元组时是按元素逐个比的如果两个任务优先级相同它会去比较任务内容。任务内容如果是字符串还好如果是不支持比较的对象就会直接抛异常。塞一个自增序号进去既避免了比较异常又保证了同优先级的稳定顺序一举两得。这个技巧我几乎每次写优先队列都会用。4.3 单调队列滑动窗口最值的线性解法单调队列是一个更精巧的变体它解决的问题是给定一个数组和一个固定长度的窗口求窗口滑动过程中每个位置的最大值或最小值。暴力解法是每滑一次遍历一遍窗口复杂度 O(n·k)。单调队列能做到 O(n)。核心思路是维护一个双端队列队列里存的是下标这些下标对应的值从队头到队尾单调递减求最大值时。每次新元素进来从队尾开始把比它小的全弹掉因为它比那些元素更晚离开窗口、值又更大那些元素永远不可能再成为答案。然后把新下标从队尾加入。同时检查队头是否已经滑出窗口滑出就弹出。from collections import deque def max_sliding_window(nums, k): dq deque() # 存下标对应值单调递减 result [] for i, v in enumerate(nums): # 队尾比当前值小的全部弹出 while dq and nums[dq[-1]] v: dq.pop() dq.append(i) # 队头滑出窗口就弹出 if dq[0] i - k: dq.popleft() # 窗口形成后开始记录答案 if i k - 1: result.append(nums[dq[0]]) return result print(max_sliding_window([1, 3, -1, -3, 5, 3, 6, 7], 3)) # [3, 3, 5, 5, 6, 7]这段代码有几个容易写错的地方。第一弹队尾用的是而不是用能把重复值也弹掉队列里保留的永远是最新且最大的那个不会因为重复值占位导致出队时机出错。第二检查队头滑出窗口要用dq[0] i - k这里的边界要仔细想窗口是[i-k1, i]所以下标小于等于i-k的都已经不在窗口里了。第三结果收集的时机必须在i k - 1之后也就是窗口第一次填满之后。单调队列我建议一定要手写几遍不要只背模板。因为它的正确性依赖三个动作的顺序先弹队尾、再加入、再弹队头、最后取答案。顺序错了在简单用例上可能看不出问题在边界用例上就会漏答案或者多答案。5. 把队列放进真实系统BFS、环形缓冲与任务调度5.1 BFS 层序遍历中的队列角色广度优先搜索是队列最经典的应用没有之一。它的逻辑是从起点出发先把起点入队然后不断出队把出队结点的所有未访问邻居入队直到队列为空。为什么必须是队列而不是栈队列保证了先被发现的结点先被扩展也就是按距离起点的层数依次推进。换成栈就变成了深度优先搜索访问顺序完全不同。这个性质在求最短路径边权相同时至关重要——BFS 第一次访问到某个结点时走过的路径就是最短路径。层序遍历二叉树是同一个套路只是多了分层的需求。标准写法是在每轮循环开始时记录当前队列长度这个长度就是这一层的结点数然后只处理这么多def level_order(root): if not root: return [] result, q [], deque([root]) while q: level_size len(q) # 当前层的结点数 level [] for _ in range(level_size): node q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) result.append(level) return result这里的level_size是必须的。如果直接写while q然后一路出队入队得到的只是一个扁平序列层的边界就丢了。很多人第一次写层序遍历会漏掉这一步输出结果和预期对不上还以为是遍历逻辑错了。另外一个细节是入队时就要判空。if node.left这个检查如果漏了空指针就会被塞进队列下一轮popleft出来访问.val直接崩。这类空指针入队的 bug 在树和图的遍历里极其常见写的时候养成入队前先判空的肌肉记忆能省很多调试时间。5.2 环形缓冲区生产消费速率不匹配时的缓冲层环形缓冲区ring buffer是循环队列在系统层的直接落地。它的典型场景是生产者产生数据的速度和消费者处理数据的速度不一致中间需要一个固定大小的缓冲区来吸收波动。和普通循环队列相比工程上的环形缓冲区多了几个考量第一是覆盖策略。队列满了之后新数据来了怎么办有两种选择丢弃新数据保留旧数据或者覆盖最旧的数据。日志采集通常选前者因为旧日志更重要实时音视频通常选后者因为最新的帧才有意义。这个策略必须在设计时定死不能含糊。第二是无锁实现。单生产者单消费者的场景下环形缓冲区可以做到完全无锁生产者只改write_index消费者只改read_index两个指针各自独立递增通过取模落回数组范围。只要保证指针的读写是原子的就不需要任何锁。// 单生产者单消费者无锁环形缓冲区的核心判断 #define BUF_SIZE 1024 // 必须是 2 的幂便于用位运算取模 // 生产者侧 int write_pos 0, read_pos 0; int next (write_pos 1) (BUF_SIZE - 1); if (next ! read_pos) { // 未满 buf[write_pos] data; write_pos next; } // 消费者侧 if (read_pos ! write_pos) { // 非空 data buf[read_pos]; read_pos (read_pos 1) (BUF_SIZE - 1); }这里用 (BUF_SIZE - 1)代替% BUF_SIZE前提是缓冲区大小必须是 2 的幂。位运算比取模快得多在每秒几百万次操作的高频场景下差距明显。这也是为什么几乎所有高性能环形缓冲区都要求容量是 2 的幂。第三是内存序。在多核环境下编译器和 CPU 都可能重排指令。生产者写完数据后更新write_pos这两步的顺序必须保证——否则消费者可能看到新的write_pos却读到旧数据。C/C 里需要用内存屏障或者原子变量的释放/获取语义来保证。这个问题在没有做压力测试时往往看不出来一上多核高并发就表现为偶发的数据错乱非常难查。5.3 任务调度与限流队列队列在任务调度里的角色是削峰。请求瞬间涌入时先全部塞进队列后端按自己的能力匀速消费。这样做的直接好处是后端不会被打垮代价是引入了延迟——排队等待的时间。用队列做限流时一个常见误区是只限制队列长度不限制消费速率。队列长度确实能防止内存被打爆但如果消费端本来就慢队列只会越堆越满最终仍然是满队拒绝用户看到的是排队很久然后失败体验极差。正确的做法是同时控制两个维度队列容量设上限保护内存消费端设并发上限保护下游。另一个经验是给队列里的任务加超时。任务在队列里待太久往往意味着它已经不重要了——用户可能早就关掉页面了。这时候再花资源去处理它纯属浪费。我通常会在任务入队时打一个时间戳出队时检查一下超过阈值直接丢弃并记一笔日志。这个策略在流量高峰期能省下相当可观的资源。还有一个容易忽略的点是队列的监控。只看得见入队和出队两个操作是远远不够的至少要把当前队列长度、入队速率、出队速率、平均等待时间这几个指标暴露出来。队列长度持续上涨就是明确的告警信号比等用户投诉要早得多。6. 性能与踩坑缓存、伪共享和那些不该用队列的地方6.1 数组队列与链式队列的缓存表现前面提过数组比链表快这里把原因说透一点。现代 CPU 读内存不是一个字节一个字节读的而是一次读一整条缓存行通常是 64 字节。数组的元素在内存里连续排列读第一个元素时后面十几个元素可能已经被顺带载入缓存了后续访问几乎不花代价。链表的结点是分散分配的每次p-next都可能是一次缓存未命中要从主存重新加载延迟可能是命中缓存的几十倍。这个差距在元素本身很小的时候尤其明显。存int的队列数组版本一次缓存行能装 16 个元素链表版本每个结点光是next指针就占了 8 字节加上数据和对齐一个缓存行顶多装两三个结点。所以在高频短消息场景数组队列的优势是压倒性的。不过链表也有它的优势场景元素大小差异极大、且峰值不可预估。数组队列必须预分配定大了浪费内存定小了溢出链表按需分配天然适应波动。另外当单个元素很大比如几 KB 的结构体时缓存行的优势被稀释链表的劣势就没那么突出了。场景特征推荐实现高频、小元素、数量可预估数组循环队列元素大小差异大、峰值波动大链式队列需要固定内存占用、可预测延迟数组循环队列需要跨线程无锁数组环形缓冲区容量取 2 的幂6.2 多线程下队列的锁与无锁单线程队列怎么写都不会有并发问题一旦多个线程同时操作情况就复杂了。最直接的做法是加一把互斥锁入队出队都先拿锁。简单可靠代价是吞吐量受限于锁竞争。优化的方向有几个层次第一层是缩小临界区。锁只包住对共享变量的读写不要把malloc、日志打印这类耗时操作放进锁里。这个改造成本最低收益往往最明显。第二层是分锁。入队和出队用两把不同的锁因为入队只改rear出队只改front。前提是队列的实现要让两个操作不互相干扰——这就回到了前面说的front 和 rear 各管各的不要在一个操作里同时改两个指针。用size计数器方案时要注意size被两边共享得用原子操作。第三层是无锁队列。用 CAS比较并交换实现典型的是基于链表的 Michael-Scott 队列。这个方案吞吐量高但实现复杂且有 ABA 问题需要处理。除非你的场景确实需要极致性能否则不建议自己写——现成的并发库已经做了大量优化和验证。提醒一句无锁不等于高性能。在竞争激烈的情况下CAS 失败重试的开销可能比一把轻量锁还大。选型前先测别凭感觉。6.3 什么时候不该用队列队列不是万能的有些场景用了反而添乱。需要随机访问的时候。队列的设计就是只允许两端操作你要是需要按索引取中间的元素说明你要的是数组或者列表不是队列。硬用队列去模拟随机访问只会写出while循环把元素倒来倒去的代码。需要按优先级频繁调整的时候。普通队列的 FIFO 顺序是固定的如果你的业务需要中途调整某个元素的处理顺序用优先队列或者干脆用带排序的列表更合适。数据量极小、且对延迟敏感的时候。队列的价值在于解耦和缓冲如果生产者消费者本来就同步执行、数据量也就几个多一层队列只是多一次内存分配和一次上下文切换纯属负优化。我见过有人给一个只有三条记录的配置加载加了个队列代码复杂了一倍一点性能收益都没有。需要严格保证处理顺序且多消费者并行的时候。多个消费者同时从队列取任务处理完成的顺序是不确定的。如果你的业务要求任务必须按入队顺序完成那要么用单消费者要么在消费端做额外的排序和对齐不能指望队列本身帮你保证。这个坑在订单处理类业务里踩过太多次了。最后分享一个我个人一直在用的习惯凡是要写队列的地方先把空队列、满队列、单元素这三个状态的用例写出来再动手实现。这三个状态能覆盖绝大多数边界 bug先想清楚它们应该表现成什么样写出来的代码正确率会高很多。比起写完再对着日志猜这个前置成本低得多。
阅读完成 · 觉得有帮助?