资讯动态

手写 Python 进度条:从 print 原理到工程实战

发布时间:2026/10/14 23:32:00 来源:尧图企业网站定制
开头你肯定遇到过这种场景脚本跑一个批处理任务循环几百上千次屏幕上一片死寂光标在那闪啊闪你也不知道它到底是在干活还是卡死了。按个回车没反应CtrlC 又怕中断在关键节点只能干瞪眼。等到终于跑完时间也浪费了大半。其实Python 里用 print 就能轻松做一个进度条不需要安装任何第三方库效果还挺像那么回事。今天就把我自己从最简单到进阶的写法完整拆一遍包括原理、实现、踩过的坑以及在什么场景下该自己写、什么场景下应该去用现成的库。我最早是在一个数据清洗脚本里需要进度条。那个脚本要处理十几万行数据跑一次将近二十分钟没有进度反馈实在太煎熬了。一开始我直接用了 tqdm几行代码就搞定了但后来有个部署环境装不了第三方包才被迫认真研究用 print 自己实现。结果发现搞懂了底层原理之后反而觉得很多场景自己写更顺手——轻量、可控、没有任何依赖。这篇文章适合谁看如果你是刚接触 Python 没多久的新手想搞懂终端输出和刷新机制这篇文章可以带你把 print 玩明白如果你已经在写数据处理、爬虫、批量文件操作、模型训练这类需要长时间运行的任务这篇文章的几个方案可以直接抄走用。1. 从 print 到进度条先搞懂终端输出是怎么回事1.1 print 的默认行为其实一直在“换行”很多人写 Python 的第一行代码就是print(Hello World)然后就没深想过 print 到底做了什么。其实 print 默认的end参数是\n也就是说它每次打印完都在末尾加了一个换行符光标跳到下一行开头。这个设计在日常输出日志时没什么问题但要做进度条就成了最大障碍——你不可能让进度条每次都往下一行跑那样屏幕上就会堆满数字根本不算“进度条”。关键转折点在于把end参数改成\r。\r是回车符Carriage Return意思是让光标回到当前行的开头但不换行。这样一来下一轮 print 的内容就会从同一行开头重新覆盖输出。我拿个最简单的例子来说明import time for i in range(101): print(f\r进度: {i}%, end) time.sleep(0.05)这个代码跑起来屏幕同一行会依次显示“进度: 0%”一直到“进度: 100%”因为每次打印都回到了行首覆盖上一次的内容。提示\r只让光标回到行首不改动本行已有内容。所以新内容如果比旧内容短末尾会残留旧字符后面我会讲怎么处理这个问题。1.2 终端、缓冲和刷新进度条“不刷新”的真相理解了\r之后另一个高频踩坑点浮出水面有时候你写了同样的代码结果进度条根本没动所有输出一口气全出来了。这个问题涉及到 stdout标准输出的缓冲机制。当 Python 的输出目标是终端时默认是行缓冲遇到换行符就冲刷缓冲但如果你把输出重定向到文件或管道就变成块缓冲要攒够一定大小的数据才会真正写入。在进度条场景下我们恰恰去掉了换行符只靠\r控制位置这就会导致内容滞留在缓冲里出不来。解决方法是import time import sys for i in range(101): print(f\r进度: {i}%, end, flushTrue) time.sleep(0.05)flushTrue是强制把缓冲区里的内容立即推送到终端上。如果想全局设置也可以sys.stdout.reconfigure(line_bufferingTrue)或者在运行时加python -u参数但最省事的还是每次 print 都带上 flush。说实话我现在写这类代码的习惯是凡是进度条相关的 print一律手动加 flush。这跟文件写入里 close 干净内存的道理类似小细节不留意后果就是整套方案看起来完全失效。2. 手写进度条基础版从一行数字到可视化横条2.1 等宽列布局数字跳动之前先解决覆盖残留真正能用的进度条起码要包含三个信息当前进度百分比、已处理数量/总数、一个不断增长的可视化横条。先处理覆盖残留问题。假设你上一次打印的是“进度: 100%”下一次如果只打印“进度: 5%”行尾会留下“0%”的残影。处理方式很简单固定整个反引号的宽度或者先打印足够多空格清空行。我自己比较常用的写法是给整个进度条区域设置一个固定宽度比如import time total 100 for i in range(total 1): percent i / total * 100 print(f\r进度{percent:6.1f}% {i}/{total}, end, flushTrue) time.sleep(0.05)注意{percent:6.1f}里的宽度 6意思是整个数字至少占 6 个字符宽。当 0.0% 变成 100.0% 时宽度变化正好被这一设置吸收掉就不会出现尾部残留。配合固定数量的斜杠和总数位宽整行长度就稳定了。2.2 带横条的完整实现每写一行代码都说明白为什么接下来加可视化横条就是那种[####------]的样子。横条本质上是按比例把当前进度换算成若干个填充字符import time def custom_progress_bar(total, bar_length40): for i in range(total 1): percent i / total filled int(bar_length * percent) bar # * filled - * (bar_length - filled) print(f\r|{bar}| {percent:6.1%} ({i}/{total}), end, flushTrue) time.sleep(0.02) print() # 结束后换行收尾 custom_progress_bar(200)具体解释一下几个关键点bar_length控制横条在屏幕上占多少字符一般终端宽度 120 的情况下40 个字符比较协调。filled用整数乘法和 int 转型计算当前应填充的字符数。比如总宽 40、进度 35% 时int(40 * 0.35) 14表示有 14 个#。{percent:6.1%}是 Python 格式化百分比的写法把小数直接渲染成带一位小数的百分数字符串好用。最后结束循环后加一个普通print()作用是换行——否则下一行任何输出都会接在进度条后面把画面搞乱。注意int()直接截断小数进度 99.9% 时会显示 39 个#加 1 个-数值上没错但在极个别情况下最后一帧可能还没来得及显示满格循环就结束了。介意的话可以在最后强制打印一次满格的条或者用round()代替int()。2.3 加剩余时间和速度让进度条会“说话”全功能进度条最关键的一点是——它不只是展示“现在到哪了”还要能估算“还要多久”。原理极其简单记录任务启动时间用已用时间除以当前进度乘上剩余百分比就是预估剩余时间。import time def timed_progress_bar(total): start_time time.monotonic() for i in range(total 1): percent i / total elapsed time.monotonic() - start_time if percent 0: eta (elapsed / percent) * (1 - percent) else: eta 0 bar # * int(40 * percent) - * int(40 * (1 - percent)) print( f\r|{bar}| {percent:5.1%} f已用时 {elapsed:5.1f}s 预计剩余 {eta:5.1f}s, end, flushTrue, ) # 模拟耗时任务 time.sleep(0.05) print() timed_progress_bar(100)这里我用了time.monotonic()而不是更常见的time.time()原因是monotonic保证单调递增不会因为系统时间调整或者 NTP 校时出现倒跳。跑长任务的时候这个细节很重要你总不希望在等待 4 小时后看到进度条显示“预计剩余-15秒”。第一帧 percent 是 0直接除会抛 ZeroDivisionError所以我判了下if percent 0给个默认值 0。逻辑上第一帧也确实是“刚开始跑没法估算”合理的做法就是显示剩余 0。3. 进阶玩法把 print 进度条用到真正的干活场景里3.1 从模拟循环到真实任务用生成器抽象进度回调前面例子里的time.sleep只是模拟耗时真实场景里循环体是各类数据操作、网络请求、文件读写。一个很自然的封装思路是把进度条逻辑抽出来任务函数只负责报告进度。我比较推荐生成器的写法因为不需要侵入任务内部逻辑import time def track_progress(iterable, totalNone, bar_length40): total total if total is not None else len(iterable) start_time time.monotonic() for idx, item in enumerate(iterable, start1): yield item percent idx / total elapsed time.monotonic() - start_time eta (elapsed / percent) * (1 - percent) if percent 0 else 0 bar # * int(bar_length * percent) - * (bar_length - int(bar_length * percent)) print( f\r|{bar}| {percent:6.1%} 已用时 {elapsed:5.1f}s f预计剩余 {eta:5.1f}s, end, flushTrue, ) print() return # 实际使用 for item in track_progress(range(120)): time.sleep(0.03)这样任务代码和进度显示就解耦了。你只要把任意可迭代对象包进track_progress()任务逻辑原封不动进度条自动出现。而且生成器天然支持边遍历边显示不需要把所有数据提前加载进来。我后来很多脚本都是让读取数据、循环处理的地方直接套一层生成器代码侵入性极低。尤其是那些“任务本身会提前返回”的情况生成器里的yield item执行完下一次进入循环前正好更新进度——顺序非常自然。3.2 多行信息联动除了横条还能显示当前状态有时进度条旁边要跟一行当前正在处理的对象名、错误数量等。我的处理方案是进度条负责同一行覆盖附加信息在进度条下方另起一行但同样用\r覆盖自己那一行。import time def multi_line_progress(iterable): total_len len(iterable) start time.monotonic() for idx, item in enumerate(iterable, 1): # 处理任务 time.sleep(0.05) percent idx / total_len bar # * int(36 * percent) - * (36 - int(36 * percent)) # 第一行进度条 print(f\r进度: |{bar}| {percent:6.1%} ({idx}/{total_len}), end, flushTrue) # 第二行当前详情在同一行内覆盖 print(f\n当前处理: {item}累计用时 {time.monotonic() - start:.1f}s, end, flushTrue) # 回到第二行的开头等待下一次覆盖 print(\r, end, flushTrue) print()这个写法里有个细节第二行的print(\r, end, flushTrue)是让光标回到第二行行首下一次循环就可以直接覆盖第二行内容。而第一行还在因为进度条的 print 又回到了第一行行首。不过我一般比较少用多行方案因为多数终端高度有限行数多了之后用户体验并没有提升太多。更常用的是把附加信息直接塞进进度条所在行用竖线分隔print(f\r|{bar}| {percent:6.1%} | 当前: {item} | 错误数: {err_count}, end, flushTrue)一行信息如果控制在 80 字符以内不折行不闪烁终端里效果是最干净的。3.3 花式样式与颜色在用之前先想清楚你的运行环境如果终端支持 ANSI 转义序列可以让进度条有颜色、有动态动画。比如转圈动画- \ | /轮播、彩色背景填充等。但这里我必须说一句经验之谈终端颜色的兼容性一直是老大难问题。Windows 的经典 cmd 和 PowerShell 对 ANSI 的支持程度不一样在一些远程执行环境、容器日志里颜色转义码甚至会被原样打印成一堆乱码\x1b[31m。所以我的原则是——普通脚本默认不带颜色需要强调的地方用纯文本符号表示确定运行环境是 Linux 终端或 Windows Terminal 时才加颜色。用颜色库比如colorama可以省去跨平台的烦恼但它们也是第三方依赖权衡下来并不一定比手写 ANSI 简单。我自己的实际经验大部分做自动化脚本的时候根本不需要颜色反而是那些面向最终用户跑批任务的工具才值得加颜色区分成功/失败状态。再好看的进度条如果日志系统没法捕捉信息照样会丢。4. 真实项目中的实战案例进度条在批量文件处理中的完整应用4.1 需求描述和方案选型去年虚构我参与了一个内部数据迁移项目需要把一个旧的存储目录下约 3 万个文件分批迁移到新的存储结构。每批大约处理 500 个文件涉及校验、复制、索引更新三个步骤。整个任务预计跑 4 到 6 小时。当时的需求是运维人员要能实时看到当前批次、当前文件、总进度迁移脚本不允许安装任何第三方库进度信息要能同时显示在终端也要能留一份到日志文件里因为不允许第三方库tqdm 直接用不了所以我选择了自写生成器方案并在生成器内部把进度信息同时写到终端和一个 rotating log 文件。4.2 实现细节文件遍历、分批和进度挂钩核心代码如下做了脱敏和简化import os import shutil import time from pathlib import Path def migrate_batch(file_list, target_root): total len(file_list) start_time time.monotonic() success_count 0 fail_list [] def show_progress(index, current_file): percent index / total elapsed time.monotonic() - start_time eta (elapsed / percent) * (1 - percent) if percent 0 else 0 bar_len 40 filled int(bar_len * percent) bar # * filled - * (bar_len - filled) msg ( f[{index}/{total}] |{bar}| {percent:6.1%} f耗时 {elapsed:.1f}s 剩余 {eta:.1f}s f当前: {Path(current_file).name} ) print(f\r{msg}, end, flushTrue) with open(migrate.log, a, encodingutf-8) as log: log.write(f{index} {total} {percent:.2f} {current_file}\n) for idx, src in enumerate(file_list, start1): dest target_root / Path(src).name try: shutil.copy2(src, dest) # 验证文件大小一致性 if os.path.getsize(src) ! os.path.getsize(dest): raise RuntimeError(size mismatch) success_count 1 except Exception as exc: fail_list.append((src, str(exc))) show_progress(idx, src) print(\n迁移完成) if fail_list: print(f失败 {len(fail_list)} 个) for src, err in fail_list[:10]: print(f {src}: {err}) # 模拟调用 fake_files [fsource_dir/file_{i:05d}.dat for i in range(30000)] migrate_batch(fake_files, Path(target_dir))其实这个实现里最有价值的是两点一是把“失败文件列表”和“进度提示”都保留下来保证中断或异常时能精确重启二是进度行打印在终端的同时每一帧都追加一行到日志。这样即使终端窗口关闭事后也能复查整个执行过程的时间点和文件状态。4.3 处理极端情况任务中断后的续跑方案在实际执行中唯一一次出状况是大约第 2 万 4 千多个文件时脚本所在的机器因为内存不足直接崩了。重启后怎么办重新跑一遍显然浪费时间。这时我利用日志文件里记录的“当前文件”和文件系统里已经存在的目标目录做了个简单的跳过机制existing set(os.listdir(target_root)) remaining [ src for src in fake_files if Path(src).name not in existing ]这样已经成功迁移的文件直接跳过剩余文件继续。日志文件成了断点续跑的依据而进度条跟普通从头跑没有任何区别。真到这种时候你才会意识到——进度条不仅仅是个 UI 组件其实是整个任务的节拍器它把运行状态、时间、文件信息都编织在一起了。提示任务中断后再次执行之前一定要先确认现有文件是真完整拷贝而不是半截子的。我那次是shutil.copy2假设目标文件存在且大小一致就视为成功其实还有校验和未做的风险。生产环境建议加上哈希校验至少用文件大小 修改时间做二次确认。5. 常见问题与排查技巧实录5.1 进度条不刷新或者一次全部输出上面已经解释过最直接的原因就是缓冲区没刷新。检查自己代码里是不是少了flushTrue。另外重定向场景下比如python script.py run.log终端里的行缓冲不再生效必须 flush。有时候你会在 IDE比如 PyCharm 的 Run 窗口看到进度条以每行递增的方式输出而不是覆盖同一行这其实是\r被 IDE 解释成了换行。遇到这种情况我一般建议直接改用完整打印每一行的日志方案而不是死磕进度条。5.2 Windows 下出现奇怪的乱码或换行Windows 传统 cmd 下\r的覆盖行为其实是可以工作的但前提是没有设置不兼容的控制台编码。如果你的脚本里有中文且终端编码不是 UTF-8打印本身就会出乱码。遇到这个优先在脚本头部加import sys sys.stdout.reconfigure(encodingutf-8)或者直接在终端里先执行chcp 65001切换代码页。Windows Terminal 基本没这个问题经典 cmd 就需要格外小心。5.3 嵌入其他日志输出时进度条被搞得乱七八糟这是最快遇到也最烦躁的问题。一个脚本里既有logging输出又有进度条日志一旦换行进度条就毁了。我的解法和大家分享一下要么所有日志都走同一个“带行首控制”的模块要么干脆把进度条和日志错开。最简单的方案是进度条在终端的最下面日志往上面滚动。实现方式是把日志输出封装成一个函数每次打印日志前先清空进度条当前行打完整段日志再重新打印进度条。伪代码如下def log_and_show(log_text, progress_text): clear_line() print(log_text) print(progress_text, end, flushTrue)但说实话这个方案很脆多线程一介入就乱了。更稳妥的是给日志加上“定向”终端只显示进度条日志统一写到日志文件。终端交付给用户看就够了不需要同时承担信息流角色。5.4 性能损耗和刷新频率控制进度条也是一条 print如果循环体每次都非常快比如每次迭代只有 1 毫秒那么每次迭代都刷新进度条极可能导致程序变慢。原因是终端 I/O 是相对重的操作刷屏本身就是成本。实测下来如果每秒刷 60 帧以上进度条在终端里还会有闪烁感观感反而差。解决方案是“节流刷帧”最简单的做法是规定时间比如每 0.1 秒最多刷新一次last_refresh 0 refresh_interval 0.1 for idx, item in enumerate(iterable, 1): process(item) now time.monotonic() if now - last_refresh refresh_interval: update_progress(idx) last_refresh now这个技巧在 tqdm 内部也有类似实现它是按miniters和mininterval控制刷帧频率还要靠dynamic_ncols动态适配终端宽度。我自己血泪教训就是一次爬虫任务120 万个请求不加限流的进度条让整个爬虫速度下降了将近 30%。加了这个节流之后终端 I/O 基本可以忽略。5.5 终端宽度过窄导致的折行进度条内容过长终端宽度不够会发生自动折行——后果就是进度条“多出一截”覆盖逻辑全乱。解决办法有两个方向一是把进度条内容压缩到 50 个字符以内二是查询终端宽度动态适配用shutil.get_terminal_size()获取宽度再计算 bar 长度。我一般推荐方案一进度条信息越短越好。剩余时间和百分比其实已经传递了关键信息当前处理文件名这种完全可以放到日志文件里不必挤在进度条那一行。6. 到底要不要用 tqdm选型建议和个人体会6.1 tqdm 的优势与劣势既然玩明白了 print 手写进度条还是得正视 tqdm 的存在。tqdm 是第三方库但几乎是 Python 生态里最被广泛使用的进度条工具。它有几点做得非常好自动适配终端宽度、自动节流、兼容 jupyter notebook、支持嵌套进度条自动缩进丰富的参数desc前置描述、unit单位、initial初始进度、bar_format自定义在 notebooks 里会渲染成 HTML 控件在终端里是覆盖式的无缝切换后台多线程环境下也能多实例共存它的劣势也很明显第三方依赖。离线环境装不上就是装不上嵌套和定制化选项多反而增加了学习成本有的内部行为不够可控比如自动探测总长、自动调用len()等少数场景需要额外参数才能达到你想要的效果6.2 什么时候该手写什么时候该选库我现在的选择标准大致是这样场景推荐方案部署环境不能装第三方包手写 print 方案脚本非常简短几十行手写 print 方案零依赖需要精确定制显示逻辑手写 print 方案数据科学、模型训练等快速原型验证tqdm能少写多少行就少写多少行多进程并行任务需要独立进度条tqdm 的嵌套/多实例更成熟Jupyter Notebook 环境tqdm 的 notebook 版本长期运行的高可用任务脚本手写方案 日志记录更稳因为你可以控制每一帧落盘坦白讲现在我自己日常脚本大部分还是直接用 tqdm 的一条for item in tqdm(iterable):干净利落。但一旦遇到“不允许装包”或“需要精确控制输出内容”的场景手写方案就能派上用场而且因为理解了底层原理用 tqdm 的时候遇到异常也能快速定位问题根源。7. 最后再分享一点实际的体会手写进度条这件事难点从来不是打印本身而是对输出控制的整体理解。当你搞清楚了print的end、flush、\r这三个知识点之后再回头去看任何进度条库的实现都会觉得很多地方似曾相识。这不是死记 API 的功劳是真的抓住了本质。我自己现在写这类脚本的习惯是先把任务逻辑写好确认无性能问题最后再在关键循环外套一层进度显示。进度条本身不要过度设计——一个横条、一个百分比、一个剩余时间足够覆盖绝大多数场景。最后给一个实操小技巧如果你觉得手写的进度条回调嵌入业务代码很麻烦就把它做成上下文管理器。用with progress_bar(total100) as bar:包住业务代码在循环里调用bar.update(1)这样代码结构最清晰也不会误把进度代码漏在业务逻辑外面。我给团队留下的某内部工具现在还在用这个模式跑着隔几个月打开日志看一眼那种“每一帧都在、时间记录完整”的感觉就是这类工具该有的样子。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑