资讯详情

资讯详情

手写print进度条:从回车符到ETA的底层原理与完整实现

说个很常见的场景脚本跑起来了终端一片空白光标一闪一闪你不知道它是卡死了还是在努力工作。等十分钟是常态等十分钟还是白屏就想 CtrlC 了。这个时候一个能用 print 打出来的进度条比任何日志都管用。今天这篇文章不打算讲花哨的框架就围绕最基础的print函数手把手把打印进度条这件事拆开揉碎从原理到完整实现再到我实际运行项目时踩过的那些坑一次性说清楚。这不是一篇教你偷懒的文章恰恰相反是教你别急着偷懒。很多场景下你根本不需要引入第三方库几个字符加一个回车符就能搞定一个相当合格的进度条。而且把 print 进度条的底层机制搞懂了以后用任何现成库都能明白它背后的逻辑出问题也能自己排查。1. 为什么我不直接推荐tqdm——print进度条的适用边界1.1 进度条的本质给机器循环一个人味先想一个问题为什么需要进度条说到底进度条解决的是不确定性带来的焦虑。程序跑多快、还剩多久、当前卡在哪一步这三个问题是人在面对长耗时任务时最想知道的信息。日志能回答卡在哪但回答不了还剩多久而进度条恰恰是专门为解决这个问题设计的。我见过不少新手一上来就装 tqdm然后for i in tqdm(range(100)):一行搞定。这当然没问题但如果你只停留在这一步就永远不理解进度条背后的两个核心机制行内刷新和光标控制。光会用工具而不懂原理工具一旦不满足需求就束手无策。比如你需要在进度条里额外打印一条业务日志、需要在某些条件下改变进度条颜色、需要把一个耗时函数的内部状态实时显示出来这时候 tqdm 的默认行为可能就不是你想要的了。另一个更现实的原因是环境限制。我在某公司维护过一套部署在离线内网的批处理脚本那台机器的 Python 环境里没有 tqdm也没法直接联网安装。要在这种环境里做出一个体面的进度条除了手写没有别的路。print 是 Python 内建函数任何环境都跑得起来这是它最大的优势。1.2 print方案 vs 现成库什么时候必须自己写我个人的判断标准很简单三条满足任意一条就手写任务本身很简单只是一个大循环加条进度显示而已运行环境受限装不了第三方库进度条需要高度定制比如要和服务端接口联动、要输出成文件、要嵌在别的 UI 系统里。反过来如果项目本身已经引用了 tqdm或者任务涉及复杂的多阶段进度、嵌套迭代、需要自动适配终端宽度那直接用现成库更省心。这不是非此即彼的选择而是看工作量。手写一个基础进度条大概 10 分钟但要做到 tqdm 那个程度——自动切换单位、百分比、耗时、ETA、嵌套支持——就要花不少功夫了。先判断需求复杂度再决定要不要自己造轮子这才是务实的做法。2. 从\r说起最朴素的进度条是怎么一点点变好用的2.1 回车符不是换行是回到行首重写很多刚接触这个写法的人会把\r和\n搞混。\n是换行光标移到下一行\r是回车光标回到当前行的行首但不换行。这个区别就是打印进度条的命根子。想想老式打字机\r是滚筒回到最左边\n是纸往上滚一行。单独用\r你在同一行反复写、反复覆盖视觉上就是这条线在动。配合print的end参数把默认的换行去掉就能实现原地更新的效果。最小实现长这样import time for i in range(101): time.sleep(0.05) print(f\r进度: {i}%, end) print()跑一下就知道终端同一行会依次显示进度: 0%到进度: 100%最后跳出一行新的。这里有两个细节值得注意第一end必须加否则每个 print 自带换行\r就失去意义了。第二循环结束后加一个空print()把光标挪到下一行否则你后续要打的日志会直接覆盖在进度条这一行上糊成一团。这个空 print 我一开始老忘记每次程序跑完终端最后一行都是残缺的进度条逼得我总要多敲一个回车才能看清楚输出。2.2 加个容器中括号进度条的最小实现光有百分比数字其实已经算进度条了但视觉冲击力不够。我们加一个容器让用户直观看到任务完成的覆盖程度import time total 100 bar_length 30 for i in range(total 1): time.sleep(0.05) percent i / total filled int(bar_length * percent) bar █ * filled - * (bar_length - filled) print(f\r|{bar}| {i}/{total} ({percent:.0%}), end) print()这里的关键是filled int(bar_length * percent)。它把百分比映射到固定长度的容器上进度 30%30 个字符宽度的条里就有 9 个实心字符、21 个占位字符。实心字符我用的是█占位字符用-或者空格都可以。实际项目里我建议用半角字符如和-因为有些终端对█这种全角字符的处理宽度不一致会导致进度条越长越歪。还有个细节total 100配合range(total 1)是为了让循环能覆盖到 0% 和 100% 两个端点。如果你用range(total)最终的 100% 状态永远到不了进度条会停在 99%对强迫症患者不太友好。2.3 百分比的显示细节百分比格式化我常用三种写法按场景选格式示例输出适用场景{percent:.0%}37%简单展示整数百分比{percent:6.1%}37.5%需要小数点注意6是字段宽度{i}/{total}37/100需要看绝对进度{percent:6.1%}里的6是保留宽度作用是把 0.5%、99.9% 这类不同长度的数字对齐避免百分比的位数变化导致进度条整体左右晃动。你可以在一个进度条里同时显示百分比和i/total前者给直观感受后者给精确位置很多正式工具就是这么干的。3. 让进度条会说话百分比、耗时、速度与ETA的完整演变3.1 计时从什么时候开始更合理一个合格的进度条不能只告诉你完成多少还得告诉你还要等多久。这就要引入时间统计。第一步是在循环外记录开始时间start time.time()这里有个很多人忽略的问题start应该放在循环开始之前还是在第一个耗时任务执行完之后从语义上讲用户看到的耗时应该是总耗时也就是从任务开始跑到现在所以放在循环前没问题。但如果你在循环之前还做了一大段数据加载、模型初始化之类的工作那这段准备时间也应该算进去否则 ETA 会对不上。反过来如果准备阶段和循环阶段的耗时特征完全不同比如准备阶段固定 30 秒循环阶段每步 0.1 秒那也可以把计时点放在循环开始处只估算循环阶段的剩余时间。我做过一个图像处理项目数据加载就用了 40 秒循环体总共才 10 秒这时候如果你从加载前开始计时速度估算会被严重拉低ETA 会显得不靠谱。所以计时点的选择本质上是你希望进度条反映哪一部分任务的时间。3.2 ETA的三种估算方式与取舍ETA预计剩余时间看起来简单其实有三种常见算法平均速度法remaining (total - i) / (i / elapsed)。就是拿已经完成的平均耗时去乘剩余数量。适合每一步耗时比较均匀的任务。最近窗口法只统计最近 N 步的平均速度。适合耗时波动大的任务反应更灵敏但可能抖得很厉害。加权移动平均法给最近的数据更高权重介于前两者之间。实现略复杂但效果最平滑。实际手写我建议先用平均速度法。它代码最少而且对大多数任务来说——遍历文件、逐行处理、批量请求——每步耗时差距不会太离谱。下面是一个完整例子import time total 200 bar_length 30 start time.time() for i in range(total 1): time.sleep(0.02) elapsed time.time() - start percent i / total if i 0: speed i / elapsed eta (total - i) / speed else: speed 0 eta 0 filled int(bar_length * percent) bar █ * filled · * (bar_length - filled) print( f\r|{bar}| {percent:6.1%} | f已用 {elapsed:5.1f}s | 剩余 {eta:5.1f}s | f{speed:5.1f} 次/s, end, ) print()这里我特意在i 0时做了保护否则第一轮计算i / elapsed会除以零。这个细节在循环第一步往往会被忽略等sleep(0)或者任务特别快时程序直接崩掉。先把边界条件想清楚比事后调试省心得多。3.3 速度、剩余时间的格式化技巧格式化这些时间字段时我推荐使用固定字段宽度比如{elapsed:5.1f}。为什么要固定宽度因为如果不固定elapsed从0.1s变成12.3s再变成123.4s字符串长度从 3 个字符变成 5 个字符再变成 6 个字符会导致整行输出长度不停变化。终端上\r回到行首只覆盖当前行内容如果新内容比旧内容短末尾就会残留上一帧的字符。这就是残影问题。你明明打印了剩余 12.3s但上一帧是剩余 123.4s等宽度不一致时行尾会留下没被覆盖的残缺字符。解决办法有两个要么所有字段固定宽度要么在每次打印时在末尾加足够多的空格清尾。两招可以一起用我在生产脚本里就是宽度对齐加尾部空格双保险确保任何时刻输出的字节数都不小于上一帧。ETA 的显示还有一个实用技巧当剩余时间超过 60 秒时自动切换成x分x秒的格式超过一小时切换成x小时x分。这个逻辑用divmod很容易实现能让输出更符合人的阅读习惯而不是甩给你一串 873.5 秒让用户自己去心算。4. 嵌套循环和并发环境进度条最容易翻车的地方4.1 内层循环导致的刷屏问题单层循环的进度条写得很顺一遇到嵌套循环就容易翻车。新手最常见的写法是for i in range(5): for j in range(100): time.sleep(0.01) print(f\r外层 {i1}/5 内层 {j1}/100, end) print() # 想在内层结束时换行结果终端刷出一堆外层 x/5 内层 y/100而且进度条文字完全对不齐。问题根源在于内层循环每次刷新都试图占据同一行但一旦某个print()把光标推到了下一行整个行内刷新机制就乱套了。解决思路有两个方向。第一个方向是只刷新外层内层不打印只在外层每完成一步时输出一个整体进度这样终端永远只有一行干净利落。第二个方向是引入多行刷新用 ANSI 转义序列把光标移到指定行再刷新。这个后面详细说。我自己写嵌套循环时如果内层每步耗时都在几十毫秒以上会倾向于只显示外层进度然后在内层行里附带最近一次内层进度。比如for i in range(5): for j in range(100): time.sleep(0.01) print(f\r外层 {i1}/5 | 当前内层 {j1}/100, end) print()这其实还是单行方案只是当前内层被当成附加信息。它的好处是简单一个\r从头管到尾无论嵌套多深都能保证不刷屏。4.2 多线程输出混乱的根源多线程环境下打进度条比嵌套循环更麻烦。两个线程同时往终端写进度条文字会互相穿插变成一行里面掺杂两个线程的进度信息。根源在于print本身不是原子的。虽然 GIL 保证了解释器层面的很多操作是线程安全的但print涉及格式化字符串 写缓冲区 刷新终端多个步骤多个线程交替执行这些步骤时输出就乱了。简单的解决方案是给进度条输出加一把锁import threading print_lock threading.Lock() def update_progress(thread_id, percent): with print_lock: print(f\r线程 {thread_id}: {percent:.0%}, end)锁保证了同一时刻只有一个线程在占着这行写内容。但请注意如果有多个线程都各自维护一条进度条单行方案就彻底行不通了因为两个进度条会互相覆盖。这时候必须上多行刷新方案或者干脆换个思路——只在主线程汇总打印全局进度子线程只负责干活、上报状态。4.3 一个能同时显示两层的写法多行刷新其实不难核心是 ANSI 转义序列。\033[A是光标上移一行\033[2K是清空当前行。思路是先把多行进度内容按行准备好然后从当前位置逐行上移并覆盖。下面是一个两层进度条的简版实现import time import shutil def clear_lines(n): for _ in range(n): print(\033[A\033[2K, end) outer_total, inner_total 5, 100 for i in range(outer_total): for j in range(inner_total): time.sleep(0.005) inner_percent (j 1) / inner_total outer_percent (i inner_percent) / outer_total line1 f外层: {i 1}/{outer_total} ({outer_percent:.1%}) line2 f内层: {j 1}/{inner_total} ({inner_percent:.1%}) # 先输出两行再把光标移回去覆盖 print(line1) print(line2) clear_lines(2)每轮循环先打印两行然后光标上移两行并清空下一轮再原地覆盖。视觉上就是两行固定的位置在实时更新。这个方案对三层甚至更多层也都适用只要clear_lines的参数和输出行数保持一致。不过这种写法有个明显问题对终端宽度不敏感万一某一行内容超过终端宽度发生了自动换行\033[A就不知道光标到底上一行是什么了。所以多行方案一定要配合终端宽度检测来控制单行长度这个我在下一节展开。5. 实测中绕不开的坑缓冲区、终端宽度与ANSI颜色5.1 print不显示的缓冲区陷阱我最开始写进度条时遇到一个诡异现象进度条确实在更新但屏幕上什么都不动直到程序快结束才突然一次性刷出来几行。原因在于 Python 的print默认走的是行缓冲而在重定向输出比如管道、文件或某些终端环境下缓冲区可能不会按行刷新。解决方法是显式刷新。print函数自带flush参数print(f\r进度: {i}%, end, flushTrue)或者用sys.stdout.flush()手动刷新。还有一种办法是启动脚本时加-u参数强制 Python 的 stdout 和 stderr 不缓冲python -u your_script.py这个坑在交互式终端里通常不明显但只要一重定向到文件或者放进 CI 系统跑立刻暴露。我的经验是最好在所有进度条打印里都加上flushTrue成本为零但少一个隐蔽故障源。顺带一提如果进度条同时要写进日志文件别用print往文件里怼进度信息。进度条是给人看的动态画面写进日志里只会留下一堆垃圾行。正确做法是进度条只管终端日志文件只记录业务节点和最终结果。5.2 终端宽度变化导致的错位进度条的本质是一行固定宽度的输出而固定宽度的前提是你知道终端有多宽。终端窗口可以随时被用户拖窄一旦实际宽度小于进度条设计宽度终端会自动换行整个进度条就崩了。用shutil.get_terminal_size()可以拿到当前终端大小import shutil size shutil.get_terminal_size() bar_length min(30, size.columns - 30)我在这类场景里常用的做法是把进度条总宽度设计成终端宽度减去一个固定余量。余量留给百分比、耗时、ETA 这些文字部分。如果计算出来的 bar_length 小于某个下限比如 10就直接不画条只画百分比保证在任何尺寸下都不炸。另外还要考虑screen或tmux这类终端复用工具它们会动态改变终端尺寸报告所以最稳妥的是每一帧都实时获取终端宽度而不是在循环开始前取一次。取一次的问题在于用户中途拖了窗口你的进度条仍然按旧宽度输出照样错位。5.3 颜色代码让进度条漂移的解法给进度条加点颜色比如完成部分绿色、未完成部分灰色看起来高级很多。但这里有一个非常隐蔽的坑ANSI 颜色转义序列比如\033[32m本身不占显示宽度却占字符串长度。如果你用len()去算进度条长度、做填充对齐颜色代码会干扰计算导致进度条越画越歪。解法是计算宽度时排除 ANSI 序列。最简单粗暴的方式是先把颜色代码加在整段文字的开头而不是混在进度条的每个字符之间GREEN \033[32m RESET \033[0m bar █ * filled · * (bar_length - filled) print(f\r{GREEN}{bar}{RESET} {percent:6.1%}, end, flushTrue)这样颜色代码只出现在整体字符串的前后不影响中间字符宽度的均匀性。如果你非要在条的不同分段用不同颜色那就要自己写一个忽略 ANSI 序列的 len 函数专门用来计算实际显示宽度。我把这个函数贴出来你可以直接抄import re ansi_pattern re.compile(r\033\[[0-9;]*m) def display_width(text): return len(ansi_pattern.sub(, text))有了这个函数颜色随便加对齐计算全用它不会再出现进度条走着走着左边多了一段空白这种玄学问题。6. 什么时候该换tqdm以及自研方案的保留价值6.1 一张表看懂print方案与tqdm的差距既然前面花了这么大力气手写那也得客观说清楚tqdm 这类成熟库在哪些维度上确实碾压手写方案。拿我常用的几个维度做个对比对比维度手写print方案tqdm依赖零依赖任何环境可跑需要安装第三方包单位自适应需要自己写自动 B、KB、MB 切换嵌套进度条要自己写多行控制原生支持手动更新需要自己维护计数器update()随意控制自定义样式完全自由需要了解其配置项学习成本懂了 \r 就上手一行代码起步在管道/CI中的行为需自己处理自动检测并降级tqdm 最厉害的地方在于自适应它检测到终端不支持动态刷新时会自动降级成逐行打印检测到嵌套场景时自动缩进对齐数据显示到 KB、MB 自动切换单位。这些东西手写起来非常繁琐属于能做但没必要重复造的范畴。6.2 tqdm解决不了的问题但 tqdm 也不是万能。我遇到过一个场景需要把多个任务的进度实时汇总成一张监控表固定刷新率地输出到终端顶部同时底部还要留一块区域给操作员输入命令。这种不是简单行内刷新而是整块终端区域的动态布局的需求tqdm 的 API 就很难优雅地表达手写 ANSI 控制反而更直接。还有一类场景是进度条只是执行过程的可视化的一部分整个程序涉及多个阶段每个阶段有自己的状态、颜色、日志输出。我自己写过一个数据处理流水线的控制台界面第一行显示当前阶段第二行是阶段内进度第三行是最近一条错误信息这三行各自独立刷新。tqdm 处理单条进度条很顺手但要做成这种仪表盘它的抽象层级反而碍事。另外在性能极致敏感的任务里每一帧进度刷新本身也要花时间。虽然\r加 flush 的开销微乎其微但如果你在一个每步耗时只有几十微秒的热循环里刷新进度条刷新本身可能占到总时间的一两成。这时候就要降低刷新频率比如每 100 步才刷新一次别每一步都去写终端。tqdm 也有类似机制但手写时你更能体会到这个取舍的份量。6.3 最终建议按场景决定用哪套我的习惯是这么分的一次性脚本、环境受限、或需要精细控制布局时手写 print 进度条长期维护的工具项目、嵌套迭代多、对显示细节要求不苛刻时直接用 tqdm省下来的时间拿去做业务逻辑。说实话写这篇并不是让大家放弃 tqdm而是把print这个最基础的能力用透。你掌握\r、flush、宽度对齐、ANSI 控制这些底层知识之后再回去看 tqdm 的文档很多选项会变得非常直观因为你已经知道它内部大概在做什么了。这种知其所以然带来的踏实感是直接抄一行tqdm(range(...))永远体会不到的。最后再分享一个小技巧如果你手写的进度条想留个后门可以在类里封装一个set_description()模拟 tqdm 的用法。这样将来项目复杂度上来了你可以在不改变调用方代码的前提下把内部实现悄悄换成 tqdm。我做过一次这样的迁移整个过程只改了工具类本身的代码业务模块完全没动划算得很。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →