最近处理一个内部小工具的时候,我发现一个特别有意思的现象:团队里很多人写循环就是while(true)一挂,然后用break或者某个标志位跳出去,等到线上出问题——内存飙高、界面卡死、重复请求把后端打到告警——才回过头来骂自己昨天写的代码。其实这就是典型的“会写循环,但没工程化地对待循环”。
所谓 Loop Engineering,说的并不是什么高深莫测的冷门学科。它讲的是把每一次循环都当成一个真正的工程对象来对待:管住它的启动、运行、退出、资源回收和可观测性。今天这篇保姆级教程,我结合自己最近做的一个跨平台媒体演示项目,把 Loop Engineering 从概念到实战完整拆一遍。文章很啰嗦,但每一步都是我实际踩过的,适合正在折腾播放器、自动化任务、后台轮询或者任何“重复执行”场景的朋友。
1. 别被名字唬住:Loop Engineering 到底在解决什么问题
先讲个让我印象很深的翻车案例。之前某新媒体工具组丢给我一个需求:要做一个自动拉取远程配置的模块,每 30 秒同步一次。有个同事很快写完了,代码逻辑很简单,一个while循环套上sleep(30)从接口取数据,更新到本地变量就算完成。当时功能验证没问题,结果放到线上跑了两天,内存从 400MB 一路涨到 2GB,最后整个服务 OOM 被系统杀掉。
为什么?因为他在循环体里每次同步都创建了新的请求对象、日志对象、响应体缓存,却没有任何资源回收机制;循环也没有退出条件,进程一启动就只能靠杀进程来停。你可能会说“这种错误很低级”,可是在真实项目里,这种低级错误出现的频率远比想象中高。我们太习惯把循环当成“让代码重复跑几遍”的工具,却忽略了循环本质上是一个长期的、持续运行的过程。
1.1 循环不是“重复执行”那么简单
很多人刚接触编程时,老师都会说“循环就是重复执行某段代码”。这话没错,但它在工程上具有误导性——因为它把循环的“复杂度”给掩盖了。
一个循环真正在做的事情有三件:进入循环、维持循环、退出循环。进入阶段要决定初始状态是什么、首次运行的条件怎么判断;维持阶段要处理每次迭代的状态更新、资源申请与释放、异常情况;退出阶段则要确保不再满足条件时干干净净地停下来,把定时器、连接、临时文件、监听器全部收掉。
用生活化的例子来类比的话,循环就像一个 24 小时营业的便利店。你不仅要决定几点开门(初始化)、营业期间如何补货和结账(迭代状态)、几点关门(退出条件),还要考虑停电了怎么办(异常处理)、交班时怎么清点库存(资源回收)。如果你只关心“一直营业”,从来不设计下班流程,那这家店迟早要出大事。
1.2 五类你每天都在用的 Loop 形态
Loop Engineering 之所以是一门“工程”而不是一个语法点,是因为循环在不同的技术栈里有完全不同的存在形态。
| Loop 形态 | 常见场景 | 失控后的典型后果 | 工程化要解决的问题 |
|---|---|---|---|
| 事件循环(Event Loop) | 浏览器、GUI 框架、Node.js 运行时 | 界面卡死、事件积压、回调地狱 | 队列优先级、阻塞任务拆分、异常传播 |
| 消息循环(Message Loop) | 桌面应用窗口消息处理 | 窗口无响应、消息丢失 | 消息泵节奏、长任务切片、防重入 |
| 自动操作循环(Automation Loop) | 定时同步、爬虫、告警巡检 | 重复请求打爆接口、死循环占满 CPU | 节流退避、幂等控制、熔断 |
| 数据消费循环(Consumer Loop) | 消息队列、流处理、迭代器 | 消息堆积、内存泄漏 | 拉取与确认机制、背压控制 |
| 媒体与动画循环(Media/Render Loop) | 播放器、游戏、动画 | 音画不同步、帧率暴跌、内存只涨不跌 | 帧率控制、资源池复用、时钟同步 |
这张表不是让你背下来,而是想说明一个问题:只要你处理的场景是持续运行、反复触发的,你就已经在做 Loop Engineering 了。很多人根本没意识到自己写的定时器、动画帧回调、消息队列消费者,本质上都是循环的一种变体。
1.3 为什么“工程化”这三个字很关键
我见过太多人写循环的时候脑子里只有“我要重复执行 N 次”,结果代码能跑,但没办法维护、没办法定位问题、没办法安全停掉。Loop Engineering 的核心就是把这几个“没办法”变成“有办法”:
第一,可控制。循环必须能被外部安全地终止。就算业务上确实需要“永远跑”,也至少要留一个优雅停机的入口。
第二,可观测。当循环跑了很久之后,你必须能回答:当前循环执行到第几轮了、这一轮处理了什么、上一轮用了多久、最近有没有异常。
第三,可恢复。循环过程中出现偶发错误(网络抖动、资源暂时不可用),不能整个循环崩溃退出,也不能无限原地重试把系统拖垮。
第四,可回收。每一次迭代产生的临时资源,在迭代结束时要归还或销毁。这一点在长生命周期循环里尤其致命,内存泄漏九成都是在这里埋下的。
2. 拆开揉碎:循环的“三要素”和“生命周期”
很多教程都会直接扔给你一个循环模板,告诉你“照着抄就行”。但如果你不理解循环为什么这样设计,换一个场景你就又不会了。我把循环拆成最核心的两层结构,第一层是三要素——状态、条件、更新;第二层是生命周期——进入、运行、退场。这两层搞明白,你就能自己设计出贴合业务的循环。
2.1 循环的“三要素”:状态、条件、更新
任何循环,不管用for、while、do...while还是forEach,都逃不开三样东西:
- 状态(State):循环当前处在什么位置、已经处理到了哪一步。状态可以是计数器
i,可以是游标cursor,也可以是队列里取到的一条消息。 - 条件(Condition):继续执行还是停下。条件判断的是“状态是否还满足预期”。条件一旦写错,最常见的两种极端就是:一次都不执行,或者永远执行不完。
- 更新(Update):每次迭代后状态如何变化。更新让循环“动”起来,状态不变就意味着条件永远一样,如果条件恒为真就会死循环。
举一个很直接的例子。假设你要从一个消息队列里消费数据:
let cursor = 0; // 状态 const batchSize = 10; while (cursor < totalMessages) { // 条件 const batch = fetchMessages(cursor, batchSize); // 读取 process(batch); // 本轮业务 cursor += batch.length; // 更新状态 }你可能会说“这不是很简单吗”。但真正的工程问题从来不出在这三行的正常路径上,而是出在:fetchMessages失败返回空数组时,cursor不再更新,条件永远成立,循环空转;process抛异常时,整个循环直接退出,积压的消息再也没有人处理;进程可能被信号打断,等等。这些才是 Loop Engineering 要去解决的。
2.2 循环的“生命周期”:进入、运行、退场
比三要素更高一层的视角,是把循环看成一个有生命周期的对象。一个工程化良好的循环,应该可以拆成四个阶段,且每个阶段都有对应的代码位置和处理策略:
| 阶段 | 你要确保的事情 | 常见错误 |
|---|---|---|
| 进入(Init) | 初始状态正确,前置资源就绪 | 没做空值判断就硬闯循环 |
| 运行(Run) | 每次迭代独立、可重复、可跟踪 | 迭代之间共享可变状态导致污染 |
| 退出(Exit) | 条件满足后及时停止,或者收到外部信号后优雅退出 | break 写得太深、退出后还有资源在跑 |
| 回收(Cleanup) | 定时器关闭、连接释放、文件句柄关闭 | 只退出循环没清理外部资源 |
我之前在某跨平台系统的开发里,就吃过“退出循环但没回收定时器”的亏。当时做一个 UI 自动刷新模块,循环里用setInterval每隔 3 秒刷新一下界面上的状态文字。业务方要求“页面关闭后停止刷新”,我直接把标志位置成false,让循环体跳出去,但setInterval本身没有清除。结果页面对象已经被销毁了,定时器还在继续触发,每次触发都去访问一个已经释放的 DOM 引用,控制台里全是错误,内存也没释放。
后来统一改成“生命周期式”的写法,把循环对象化:
class RefreshLoop { constructor(onTick) { this._onTick = onTick; this._timer = null; this._running = false; } start(intervalMs) { if (this._running) return; // 防止重复启动 this._running = true; this._timer = setInterval(() => { this._onTick(); }, intervalMs); } stop() { this._running = false; if (this._timer) { clearInterval(this._timer); // 回收定时器资源 this._timer = null; } } }Change 之后,循环的进入、运行、退出、回收清清楚楚,外界只需要调用start和stop,不用关心内部细节。
2.3 快速判断一个循环写得好不好的四个问题
在代码评审的时候,我通常不急着看循环体逻辑,而是先问四个问题:
- 它能停下来吗?除了业务中断,是否有一个明确、可验证的退出条件?如果退不出来,外面能不能强制终止它?
- 它出错了会怎样?某一次迭代抛异常,是中断整个循环,还是记录后继续下一轮?有没有重试和退避策略?
- 它跑很久会怎样?每一次迭代是不是都在申请新资源?资源有没有释放?状态有没有越积越多?
- 它现在跑到哪了?运行时能不能通过日志、指标、调试端口看到当前循环的进度?还是说黑盒一个,出了问题只能靠猜?
这四个问题不是评审专用,而是你自己写完循环后,在提交代码前应该自问一遍的。我敢说,绝大多数写while(true)的人,四个问题里至少两个是回答不上来的。
3. 保姆级实战:做一个自带多种循环的跨平台媒体演示项目
理论说了半天,现在进入项目实战。前段时间我在某实验室做了一款跨平台媒体演示工具,功能不复杂:能循环播放一段视频、在播放的同时做节拍同步的音频采样、再在界面上实时刷新状态看板。这个项目特别适合当 Loop Engineering 的练习场,因为它几乎把前文提到的几种 Loop 形态都囊括了。
以下实战步骤我按当时实际开发的路线来写,包含代码、配置和取舍理由。
3.1 项目需求与整体目录设计
项目叫“某跨平台媒体演示工具”,需求拆成三块:
- Loop A——媒体回环播放:视频播放到结尾后自动从头开始,形成循环,同时要把循环切换之间的间隙压到最低。
- Loop B——节拍锁定循环:在音频播放过程中,每隔一个固定节拍间隔采样一次音量,用于驱动一条音量柱状动画。
- Loop C——状态看板循环:界面上的 CPU 占用、播放帧率、当前循环次数等指标,每秒钟自动刷新一次。
技术选型上,我用了跨平台 UI 框架做界面层,核心逻辑用 TypeScript 编写,音频处理走框架的媒体接口,看板数据用内部发布订阅事件来驱动。整个项目目录结构大致这样:
media-loop-demo/ ├─ src/ │ ├─ core/ │ │ ├─ LoopRunner.ts // 通用循环运行器,承载生命周期 │ │ ├─ MediaLoop.ts // Loop A:媒体回环播放 │ │ ├─ BeatSyncLoop.ts // Loop B:节拍锁定采样 │ │ └─ DashboardLoop.ts // Loop C:UI 状态看板刷新 │ ├─ events/ │ │ └─ EventBus.ts // 简单发布订阅,用于解耦 │ └─ ui/ │ └─ MainPanel.tsx // 界面层 └─ package.json之所以先把LoopRunner抽出来,是因为三个 Loop 的“生命周期管理”是相同的,只有“循环体逻辑”不同。把共同的东西下沉成基类,是 Loop Engineering 里很推荐的做法——循环的框架只写一次,业务子类只关心自己的业务。
3.2 LoopRunner 基类:所有循环的“骨架”
这里给出一个我在项目里实际用过的精简版。它解决的问题就是前文说的“生命周期”:让循环有明确的启动、停止、暂停、恢复,以及每次迭代的异常隔离。
export type LoopTask = () => Promise<void> | void; export interface LoopOptions { intervalMs?: number; // 固定间隔模式:每多少毫秒跑一次 immediate?: boolean; // 是否启动后立即执行第一轮 maxRuns?: number; // 最大迭代次数,防止失控 onError?: (error: unknown, runCount: number) => void; // 单轮异常处理 } export class LoopRunner { private _stopFlag = false; private _running = false; private _runCount = 0; private _timer: ReturnType<typeof setTimeout> | null = null; private readonly _task: LoopTask; private readonly _options: Required<Pick<LoopOptions, 'intervalMs' | 'immediate' | 'maxRuns'>> & LoopOptions; constructor(task: LoopTask, options: LoopOptions = {}) { this._task = task; this._options = { intervalMs: options.intervalMs ?? 1000, immediate: options.immediate ?? false, maxRuns: options.maxRuns ?? Number.MAX_SAFE_INTEGER, ...options, }; } get runCount(): number { return this._runCount; } get running(): boolean { return this._running; } start(): void { if (this._running) return; this._running = true; this._stopFlag = false; if (this._options.immediate) { this._tick(); } else { this._schedule(); } } stop(): void { this._stopFlag = true; this._running = false; this._clearTimer(); } private _schedule(): void { if (!this._running || this._stopFlag) return; this._clearTimer(); this._timer = setTimeout(() => this._tick(), this._options.intervalMs); } private async _tick(): Promise<void> { if (this._stopFlag || !this._running) return; this._runCount += 1; // 达到最大迭代次数,自动退出 if (this._runCount >= this._options.maxRuns) { this._running = false; this._options.onError?.(new Error(`LoopRunner: maxRuns reached (${this._runCount})`), this._runCount); return; } try { await this._task(); } catch (error) { // 单轮异常不拖垮整个循环 this._options.onError?.(error, this._runCount); } this._schedule(); } private _clearTimer(): void { if (this._timer) { clearTimeout(this._timer); this._timer = null; } } }这里面有几个设计细节值得展开说一说。
第一,用setTimeout而不是setInterval。很多人在写轮询循环时习惯用setInterval,如果任务本身执行时间超过间隔时间,容易出现上一轮还没跑完、下一轮又开始的“重叠执行”问题。setTimeout模式下,任务完成后再排下一轮,天然避免了重入。
第二,maxRuns这个看似简单的参数,其实是一个保险丝。我见过太多循环因为条件判断写错而失控,有了最大迭代次数之后,就算控制逻辑完全失效,循环也会在跑到指定次数后自己停下来,不会无限烧 CPU。
第三,单轮异常用onError隔离。网络抖动、文件读取失败这类偶发错误不应该让整个循环崩溃。但也要注意,不是所有异常都适合吞掉后继续跑,所以我在onError里额外传了runCount,方便外部判断“是不是已经连续出错很多次了”,配合报警策略来使用。
3.3 Loop A:媒体播放器的回环播放
Loop A 的需求看起来最简单:播放到结尾,从头再播。但真正工程化之后,你会发现“无缝循环”远比想象中复杂。如果处理不当,视频在循环点会有一个明显的黑屏或者卡顿,用户体验很差。
我当时的实现方案是这样:
import { LoopRunner } from './LoopRunner'; export class MediaLoop { private readonly _player: HTMLMediaElement; private readonly _loop: LoopRunner; private _lastTime = 0; private _loopCount = 0; constructor(player: HTMLMediaElement) { this._player = player; this._loop = new LoopRunner( () => this._checkEnd(), { intervalMs: 250, // 每 250ms 检查一次播放进度 immediate: true, onError: (err, count) => { console.error(`[MediaLoop] tick error #${count}:`, err); }, } ); // 进度事件的频率比定时器更准,优先用它推进状态 this._player.addEventListener('timeupdate', this._onTimeUpdate); } start(): void { this._loop.start(); this._player.play(); } stop(): void { this._loop.stop(); this._player.pause(); } private _onTimeUpdate = (): void => { this._lastTime = this._player.currentTime; }; private async _checkEnd(): Promise<void> { const duration = this._player.duration; if (!Number.isFinite(duration) || duration <= 0) return; // 剩余不足 0.3 秒时,主动跳回起点,弱化循环间隙感 if (duration - this._lastTime < 0.3) { this._player.currentTime = 0; this._loopCount += 1; EventBus.publish('media:loop', { count: this._loopCount }); } } get loopCount(): number { return this._loopCount; } }这里我踩过一个特别典型的坑:最初我只依赖timeupdate事件来判断是否播完,但某些播放器在真实播放到结尾时,timeupdate事件触发并不稳定,而且触发粒度不均匀。最终我选择“事件推进状态 + 定时器兜底判断”的双轨策略:timeupdate负责高频更新_lastTime,LoopRunner负责每 250ms 做一次兜底判断。
另外一个细节是duration - this._lastTime < 0.3这个“提前量”。如果等到视频完全播完再从头加载,播放器内部会有缓冲和准备时间,循环间隙会很明显。提前 0.3 秒跳回去,观众基本感知不到切换。
3.4 Loop B:节拍锁定循环的音频采样
Loop B 做的是节奏同步的音频采样。简单说,就是在音频播放时,每隔一个固定节拍(比如每 200ms)采集一次当前音量的瞬时值,用这个值去驱动界面上一条音量柱的跳动。这本身是个setInterval就能完成的活,但在工程化的时候有几个关键点。
完整实现:
export class BeatSyncLoop { private readonly _audioContext: AudioContext; private readonly _analyser: AnalyserNode; private readonly _dataArray: Uint8Array; private readonly _loop: LoopRunner; private _currentLevel = 0; constructor(audioContext: AudioContext, analyser: AnalyserNode) { this._audioContext = audioContext; this._analyser = analyser; this._dataArray = new Uint8Array(analyser.fftSize); this._loop = new LoopRunner( () => this._sampleLevel(), { intervalMs: 200, immediate: false, onError: (err) => { console.warn('[BeatSyncLoop] sampling failed:', err); }, } ); } start(): void { // 音频上下文需要从暂停状态恢复 this._audioContext.resume().then(() => { this._loop.start(); }); } stop(): void { this._loop.stop(); } get level(): number { return this._currentLevel; } private _sampleLevel(): void { this._analyser.getByteFrequencyData(this._dataArray); // 取前 64 个频段的平均值作为音量柱高度 let sum = 0; const bins = Math.min(this._dataArray.length, 64); for (let i = 0; i < bins; i++) { sum += this._dataArray[i]; } this._currentLevel = sum / bins / 255; // 归一化到 0~1 } }这段代码里最关键的工程点,是_dataArray在构造函数里创建了一次,之后每次采样都是复用同一个数组,而不是在_sampleLevel里new Uint8Array。按每 200ms 一次采样来算,一天要跑 432000 次,如果每次都新建数组,就会产生大量短生命周期对象,加重垃圾回收压力,导致音频采样抖动甚至出现卡顿。
另一个容易忽略的问题是音频上下文的resume()。很多浏览器出于自动播放策略限制,音频上下文默认是挂起状态,如果你直接开始采样循环,getByteFrequencyData拿到的数据全是 0。我当时先调了resume()再启动循环,让采样循环严格晚于音频的“可播放状态”,避免了拿到空数据的尴尬。
3.5 Loop C:状态看板的防抖刷新
Loop C 是用来驱动界面看板的,每秒钟刷新一次 CPU 占用、帧率、循环次数等信息。这个 Loop 和前面两个不一样的地方在于:它刷新的不是核心业务数据,而是展示数据,因此需要“轻”和“无感”。
我当时的实现里做了两个重要取舍。
第一,看板刷新不直接读底层状态,而是订阅事件总线。比如 Loop A 每完成一次回环就publish('media:loop', ...),看板 Loop 再去把最新数值渲染出来。这样两个循环之间完全解耦,不会出现 A 正在写数据、C 正好读半个数据的并发问题(在单线程 JS 里这个问题不严重,但拿到多线程环境就要小心)。
第二,看板循环自己不做耗时计算。所有指标的计算放在各个业务循环内部,看板这里只做“读取-渲染”:
export class DashboardLoop { private readonly _loop: LoopRunner; private readonly _render: (metrics: { mediaLoops: number; cpuUsage: number; frameRate: number; beatLevel: number; }) => void; constructor(onRender: (metrics: { mediaLoops: number; cpuUsage: number; frameRate: number; beatLevel: number }) => void) { this._render = onRender; this._loop = new LoopRunner( () => this._collectAndRender(), { intervalMs: 1000, immediate: true, onError: (err) => { console.warn('[DashboardLoop] refresh error:', err); }, } ); } start() { this._loop.start(); } stop() { this._loop.stop(); } private _collectAndRender(): void { // 指标都从事件总线的最新值里读取,不做任何耗时聚合 const metrics = { mediaLoops: EventBus.value<number>('media:loop', 0), cpuUsage: EventBus.value<number>('system:cpu', 0), frameRate: EventBus.value<number>('media:fps', 0), beatLevel: EventBus.value<number>('audio:level', 0), }; this._render(metrics); } }这里EventBus.value是我在事件总线上加的一个便捷方法,用于读取某个主题最近一次发布的快照值。它的存在意义是:数据产生方和消费方之间没有调用关系,只通过事件总线做松耦合。后面就算你要加新的指标,只需要在 Loop A、B 里多 publish 一条,看板代码基本不用动。
4. 实战中踩过的坑:死循环、内存爆炸、界面卡死
这个项目开发过程中遇到过的坑,比代码本身更有分享价值。我挑三个印象最深的,按照“问题表现 -> 排查过程 -> 根因分析 -> 修复方案”的顺序写,你可以直接拿来当排错参考。
4.1 坑一:播放器循环之后内存只涨不跌
Loop A 初版上线测试时,我把一个约 2 分钟的视频循环播放了一下午,结果内存占用从 300MB 一路涨到 1.2GB。最开始我以为是播放器 SDK 自己的问题,差点去提工单。
排查过程:我先把 LoopRunner 的日志打开,每隔 50 次循环打印一次内存快照(performance.memory.usedJSHeapSize)。很快发现一个规律——内存增长总是发生在视频循环切换之后,而且每次涨的量差不多。这说明问题不在“播放”本身,而在“循环切换”那一步。
继续往下追,我怀疑currentTime = 0跳转这种方式会触发播放器内部的缓冲重建。为了验证,我把_checkEnd里的跳转逻辑注释掉,让它自然播完,反复拉动播放进度条来模拟切换,结果内存稳定。接着我在浏览器 DevTools 的 Memory 面板里抓了一次 heap snapshot,对比循环前后的对象分布,发现大量新增的ArrayBuffer,而且全是几百 KB 到几 MB 的大块内存。
根因找到了:某些播放器内核在currentTime跳转时,会重新申请解码缓冲和帧缓冲,但旧缓冲的释放是异步的。如果跳转执行太快(我们的提前量是 0.3 秒),播放器还没完成旧缓冲释放就开始申请新缓冲,内存峰值就会持续叠加。
修复方案不是不用跳转,而是给跳转加一个“缓冲收敛等待”。伪代码如下:
private async _checkEnd(): Promise<void> { // 如果播放器已经处于跳转中,跳过本轮 if (this._seeking) return; if (duration - this._lastTime < 0.3) { this._seeking = true; this._player.currentTime = 0; await this._once('seeked'); // 等跳转真正完成 this._seeking = false; this._loopCount += 1; } }加了_seeking标志位之后,循环切换不会在短时间内被重复触发;等待seeked事件则保证了旧缓冲已经清理完毕、新缓冲已经就绪,内存增长问题基本消失。
4.2 坑二:轮询循环把后端接口打挂了
这个坑来自 Loop C 的早期版本。当时为了“实时刷新状态看板”,我把看板数据完全依赖后端接口轮询,每 1 秒请求一次。开发环境只有我一个人,后端毫无压力。等到连上内网测试环境,十几个客户端同时跑起来,后端接口响应时间从 20ms 一路涨到 5 秒,最终触发了网关限流。
根因其实不复杂:请求频率和请求成本之间没有做平衡。看板上的数据大多数不是秒级变化的,CPU 占用 1 秒前是 30%,1 秒后大概率还是 30%。每 1 秒拉一次后端,对用户来说毫无感知差别,但服务端的压力却是实打实翻了几倍。
修复方向有两个。第一个是把轮询间隔从 1 秒拉长到 15 秒;第二个是把轮询改为“条件触发”——只有当本地检测到某些业务事件(比如视频循环次数变化、音频开始播放)时,才去拉一次看板数据。最终我两个方向都做了:基础间隔 15 秒,业务事件触发时额外刷新一次。
另外,我把后端接口补上了If-None-Match相关的缓存校验,数据没变化时直接返回 304,少量多次轮询的代价也大幅降低。这里要提醒一句:轮询不是原罪,不控制频率和成本的轮询才是原罪。不要一听说轮询就想着改成 WebSocket,先算清楚你的数据变化频率、客户端数量、服务端容量,很多时候轮询依然是性价比最高的方案。
4.3 坑三:界面卡死,循环阻塞了 UI 渲染线程
还有一次卡死问题让我印象更深刻。Loop B 的采样逻辑刚写完的时候,我在_sampleLevel里顺手加了一个控制台日志console.log(this._currentLevel),想着看一眼数值合不合理。结果界面直接卡成幻灯片,CPU 占用 100%。
按理说 200ms 打一次日志,不至于卡成这样。但我忽略了日志本身也是走 UI 线程的:DevTools 打开时,控制台输出会触发完整的渲染管线,在循环里高频打印,每一行日志都会强制刷新界面。这个问题的本质不是死循环,而是高频、有副作用、与 UI 线程耦合的操作。
修复很简单,把日志从循环体里挪走,改成累计 N 次打印一次;更彻底的方案是使用独立的后台线程(比如 Web Worker)做音频采样,把结果 postMessage 回主页线程。我在实践里把采样计算挪到了 Worker,UI 线程只负责接收线程消息然后更新音量柱,整个项目在老旧设备上也能保持 60 帧。
通过这三个坑,你会发现一个共同点:循环出问题,很少是逻辑跑不通,而是长期运行之后资源、频率、阻塞这些“工程侧面”出问题。这也是我把 Loop Engineering 单独拎出来讲的原因——语法上它简单,工程上它深远。
5. 从写循环到设计循环:把 Loop Engineering 沉淀成习惯
项目做完之后,我重新审视了自己平时写过的所有循环代码,发现真正需要长期运行的循环,无论多简单,都值得用统一的框架来套。这不是过度设计,而是为了在出问题时能快速定位、安全处置。
5.1 一个标准化的循环安全检查清单
我自己现在写任何循环(不管是一次性脚本还是长期服务)都会过一遍这个清单。你可以直接抄走,贴在自己的项目模板里。
| 检查项 | 通过标准 |
|---|---|
| 退出条件 | 明确且可达;极端情况下有兜底退出(如 maxRuns) |
| 启动控制 | 有防止重复启动的机制;未初始化状态不允许进入 |
| 异常隔离 | 单轮错误不会拖垮整个循环;错误被记录且有统计 |
| 资源回收 | 定时器、监听器、连接、文件句柄都有释放入口 |
| 频率控制 | 轮询间隔与数据变化频率匹配;没有高频无效请求 |
| 可观测性 | 循环有当前轮次、上次执行耗时、累计错误数的日志或指标 |
| 停止语义 | 外部可以安全、优雅地停止;停止后所有副作用被清理 |
这份清单我打印出来贴在工位上。你可能觉得“循环而已,至于吗”——但说实话,线上故障里,循环造成的资源泄漏、接口打爆、UI 卡死,真是最常见的几类,没有之一。
5.2 几个我实际用得很顺的小技巧
分享几个在实战中让我少加班的技巧,都是那种“不写到文档里,但真的有用”的东西:
技巧一:给循环加一个“心跳日志”。不是每一轮都打日志,而是每执行 N 轮或者每隔固定时间打一条摘要:第几轮、本轮耗时、累计错误数。这个日志在你排查“循环是不是停了/卡了/失控了”时,价值巨大。
技巧二:把循环的停止做成幂等操作。stop()被调用两次,第二次什么也不做。如果stop()里还做了资源清理,重复调用会导致二次释放报错。检查_running标志是最简单的实现。
技巧三:循环内尽量不直接写 UI。数据刷新和渲染分离。循环里面产生数据、发布事件;UI 组件订阅事件再更新。这样可以保证将来即使循环移到 Worker 线程,UI 代码也不需要大改。
技巧四:统一处理“持续运行中的失败风暴”。当单轮错误连续出现 5 次以上,就不要无脑重试了,进入退避模式:间隔拉长、上报告警、必要时自动熔断。这个逻辑每个业务循环都要有。
5.3 Loop 思维能迁移到哪些场景
最后说点更宏观的。Loop Engineering 不只是处理代码里的while,它本质上是一种“周期性任务的工程治理思维”。我今天用的LoopRunner可以迁移到很多地方:
写后端定时任务调度时,用它来管理每个 cron 任务的启停和异常;写爬虫翻页的时候,用它控制每页请求之间的频率和重试;写数据处理管线时,用它约束每次消费一批消息后的提交偏移量;写前端动画的时候,更不用说,它就是动画循环的骨架。
你会发现这些场景的共同模式都一样:一个任务被反复执行,执行要有节奏、要可控、要能停、要能观察。只要你用这套思维去设计,很多线上疑难杂症都能在写代码阶段就提前消灭。
我现在回头再看最初那个用while(true)写同步模块的同事,想跟他说的话其实只有一句:把循环当成一个小型服务来对待,给它入口、出口、守门员和监控探头。这套思路,比任何语法本身都值钱。