纯Bash实现终端进度条task任务处理的常用方式(无外部依赖)

纯Bash实现终端进度条task任务处理的常用方式(无外部依赖)

在编写Bash脚本时,我们经常会遇到耗时较长的任务(如批量下载、大文件处理、数据库迁移等)。如果终端只是静静地“假死”,用户往往会感到焦虑,甚至误以为脚本卡死而强制中断,这时候引入必要的loading跟踪进度能最大限度帮助开发者了解任务的执行情况。

一个优雅的进度条不仅能缓解用户的等待焦虑,还能提升脚本的专业感。虽然市面上有 pv、dialog 等强大的外部工具,但在很多受限环境(如精简版Docker镜像、嵌入式设备、跨平台CI/CD)中,不依赖任何外部扩展库,纯靠Bash原生特性实现进度条,对开发者来说都是一项非常实用且简单的技能,所谓大道至简,善用任务处理机制和状态跟踪,事半功倍。

通过逐步实现从最简单的“旋转菊花”到复杂的“多任务并行进度条”,既帮助理解也可以日常工作中使用。


一、 核心原理:终端控制字符

在开始写代码前,我们需要了解Bash控制终端输出的两个核心“魔法”:

  1. 回车符 \r:将光标移动到当前行的行首,但不换行。配合 printf 或 echo -n,可以实现原地覆盖更新文本。
  2. ANSI 转义序列:如 \e[nA(光标上移n行)、\e[0G(光标移动到行首)。用于实现多行内容的动态刷新。

二、 基础篇:最简单的“旋转菊花” (Spinner)

当你不知道任务具体需要多久,只知道它还在运行时,一个简单的加载动画是最好的选择。

实现思路

利用一个字符数组(如 - \ | /),在后台任务运行时,循环打印并覆盖当前行首字符。

封装函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/bash

# 函数:显示旋转加载动画
# 参数:$1 = 后台任务的PID
show_spinner() {
local pid=$1
local delay=0.1
local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' # 使用Braille字符更美观,若终端不支持可改为 '-\|/'

# 隐藏光标
tput civis 2>/dev/null

while kill -0 "$pid" 2>/dev/null; do
local temp=${spinstr#?}
printf " [%c] Processing..." "$spinstr"
local spinstr=$temp${spinstr%"$temp"}
sleep $delay
printf "\r" # 回到行首
done

# 任务结束,清除当前行并打印完成状态
printf "\r[✓] Done! \n"
# 恢复光标
tput cnorm 2>/dev/null
}

# === 测试代码 ===
(
# 模拟一个耗时3秒的后台任务
sleep 3
) &
show_spinner $!

三、 进阶篇:基于百分比的线性进度条

当我们知道任务的总步数(如下载100个文件,处理1000行数据)时,百分比进度条能提供更精确的预期。

实现思路

  1. 计算当前百分比:percent = current * 100 / total。
  2. 计算进度条填充长度:利用Bash的字符串重复特性或 printf 的宽度控制。
  3. 使用 \r 原地刷新。

封装函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 函数:绘制单行百分比进度条
# 参数:$1 = 当前进度, $2 = 总进度, $3 = 进度条宽度(可选,默认40)
draw_progress_bar() {
local current=$1
local total=$2
local width=${3:-40}

# 防止除零错误
if [ "$total" -eq 0 ]; then return; fi

# 计算百分比和填充字符数 (使用纯Bash整数运算)
local percent=$((current * 100 / total))
local filled=$((current * width / total))
local empty=$((width - filled))

# 生成进度条字符串 (利用printf的补空格特性,再用tr替换为=)
local bar=$(printf "%${filled}s" | tr ' ' '=')
local space=$(printf "%${empty}s")

# 输出并回车覆盖
printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$percent" "$current" "$total"

# 如果完成,换行
if [ "$current" -eq "$total" ]; then
echo ""
fi
}

# === 测试代码 ===
total_steps=50
for ((i=0; i<=total_steps; i++)); do
draw_progress_bar $i $total_steps
sleep 0.05 # 模拟任务耗时
done

四、 高级篇:带时间估算 (ETA) 的动态进度条

在复杂的脚本中,我们希望能看到“预计剩余时间”。

实现思路

记录任务开始时间,通过已用时间和当前进度,推算出总时间和剩余时间。为了保持代码纯粹,我们使用 date +%s 获取时间戳,并用 awk 进行简单的浮点运算(awk 是POSIX标准工具,不算外部扩展库)。

注意事项

虽然所有主流发行版本都自带相关 COMMAND 命令,但像 AWK 可能需要现安装才可以,这点注意即可,避免环境兼容性问题,做好检查⚠️。

1
2
3
4
5
6
# 检查awk是否存在
if command -v awk >/dev/null 2>&1; then
echo "✅ 发现 awk,可以执行浮点运算。"
else
echo "❌ 未找到 awk,将降级使用纯 Bash 整数运算。"
fi

封装函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 函数:带ETA的高级进度条
# 参数:$1 = 当前进度, $2 = 总进度, $3 = 开始时间戳
draw_advanced_bar() {
local current=$1
local total=$2
local start_time=$3
local width=30

if [ "$total" -eq 0 ] || [ "$current" -eq 0 ]; then
printf "\r[%*s] %3d%% ETA: --:--" "$width" "" 0
return
fi

local now=$(date +%s)
local elapsed=$((now - start_time))

# 计算ETA (使用awk避免Bash不支持浮点数的尴尬)
local eta=$(awk "BEGIN {print int(($elapsed / $current) * ($total - $current))}")

# 格式化时间为 MM:SS
local eta_min=$((eta / 60))
local eta_sec=$((eta % 60))
local eta_str=$(printf "%02d:%02d" $eta_min $eta_sec)

local percent=$((current * 100 / total))
local filled=$((current * width / total))
local bar=$(printf "%${filled}s" | tr ' ' '█') # 使用实心方块更现代
local space=$(printf "%$((width - filled))s")

printf "\r[%s%s] %3d%% ETA: %s" "$bar" "$space" "$percent" "$eta_str"

if [ "$current" -eq "$total" ]; then echo ""; fi
}

# === 测试代码 ===
start_ts=$(date +%s)
for i in {1..100}; do
draw_advanced_bar $i 100 $start_ts
sleep 0.08
done

五、 终极篇:并行任务的进度条

这是相对最复杂的场景。假设我们要同时下载10个文件,每个文件有自己的进度。(但也要注意任务的拆分和终端文本本身的承载能力)

痛点:子进程在后台运行,无法直接控制父进程的终端输出,如果多个子进程同时 printf,终端会乱成一锅粥。

实现思路(进程间通信 + 光标控制)

  1. 状态分离:子进程不直接输出UI,而是将自己的当前进度写入独立的临时文件(如 progress_1.tmp)。
  2. 主进程聚合:父进程开启一个 while 循环,每隔0.2秒读取所有临时文件,计算总进度和各个子任务进度。
  3. 多行刷新:利用ANSI转义码 \e[nA(光标上移n行),实现多行UI的整体刷新。

封装函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#!/bin/bash

# 模拟后台任务:将进度写入临时文件
worker_task() {
local id=$1
local steps=$2
local tmp_file="progress_${id}.tmp"

for ((i=0; i<=steps; i++)); do
echo "$i" > "$tmp_file"
sleep 0.1 # 模拟网络延迟或处理耗时
done
}

# 函数:并行任务进度条
# 参数:$1 = 任务总数, $2 = 每个任务的步数
parallel_progress_bar() {
local total_tasks=$1
local steps_per_task=$2
local bar_width=20

# 1. 清理旧的临时文件
rm -f progress_*.tmp

# 2. 启动后台任务,并记录PID
local pids=()
for ((t=1; t<=total_tasks; t++)); do
worker_task $t $steps_per_task &
pids+=($!)
done

# 3. 隐藏光标
tput civis 2>/dev/null

# 预先打印空行占位,方便后续光标上移覆盖
for ((i=0; i<=total_tasks; i++)); do echo ""; done

# 4. 主循环:轮询临时文件并刷新UI
while true; do
local all_done=true
local total_current=0
local total_all=$((total_tasks * steps_per_task))
local ui_output=""

# 收集各个子任务的进度
for ((t=1; t<=total_tasks; t++)); do
local tmp_file="progress_${t}.tmp"
local current=0
if [ -f "$tmp_file" ]; then
current=$(cat "$tmp_file")
fi
total_current=$((total_current + current))

# 构建单任务进度条UI
local filled=$((current * bar_width / steps_per_task))
local bar=$(printf "%${filled}s" | tr ' ' '#')
local space=$(printf "%$((bar_width - filled))s")
ui_output+="Task ${t}: [${bar}${space}] ${current}/${steps_per_task}\n"

if [ "$current" -lt "$steps_per_task" ]; then
all_done=false
fi
done

# 构建总进度条UI
local total_percent=$((total_current * 100 / total_all))
local total_filled=$((total_current * (bar_width + 10) / total_all)) # 稍微宽一点
local total_bar=$(printf "%${total_filled}s" | tr ' ' '=')
local total_space=$(printf "%$((bar_width + 10 - total_filled))s")

# 5. 核心:利用ANSI转义码刷新多行
# \e[{total_tasks + 1}A 表示光标上移 (任务数 + 1) 行
printf "\e[${total_tasks}A\e[0G"
printf "Total: [%s%s] %3d%%\n" "$total_bar" "$total_space" "$total_percent"
printf "$ui_output"

# 检查所有后台进程是否结束 (通过PID检查更可靠)
local running=false
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
running=true
break
fi
done

if ! $running && $all_done; then
break
fi

sleep 0.2
done

# 6. 清理与恢复
echo "" # 最后换行
tput cnorm 2>/dev/null
rm -f progress_*.tmp
}

# === 测试代码 ===
echo "Starting parallel downloads..."
parallel_progress_bar 5 30 # 5个并行任务,每个任务30步
echo "All tasks completed!"

六、 总结与最佳实践

  1. 兼容性第一:尽量使用 printf 代替 echo -ne,因为不同系统(如 macOS 的 BSD echo 和 Linux 的 GNU echo)对 -e 的解析存在差异。
  2. 优雅降级:如果脚本可能在非交互式环境(如 cron job 或 CI 管道)中运行,建议检测 tty,如果不是终端则禁用进度条,直接输出纯文本日志:
    1
    2
    3
    4
    if [ ! -t 1 ]; then
    # 非终端环境,禁用进度条动画
    echo "Processing $current/$total..."
    fi
  3. 性能考量:在并行进度条的主循环中,sleep 0.2 是必要的。如果轮询太频繁(如 sleep 0.01),会大量消耗CPU去读取文件和刷新终端,反而拖慢实际任务的执行。
  4. 临时文件清理:使用 trap 'rm -f progress_*.tmp; tput cnorm' EXIT 可以在脚本被 Ctrl+C 中断时,确保临时文件被清理且光标恢复正常。

纯Bash实现终端进度条task任务处理的常用方式(无外部依赖)

https://www.wdft.com/d272461c.html

Author

Jaco Liu

Posted on

2025-12-17

Updated on

2026-10-15

Licensed under