Bash & Shell Scripting · Part 4 — Loops: for, while, until & Reading Input
Master bash loops: for-in over lists and files, C-style for, while/until, safe line-by-line reading with IFS= and read -r, process substitution vs pipes, break/continue, and brace expansion — bilingual, with exercises.
Đây là Phần 4 của series 10 bài đưa bạn từ “copy-paste lệnh” đến viết shell script chắc chắn, mức production. Phần 1–3 đã nói về shell, biến & quoting, và điều kiện. Giờ ta lặp công việc mà không lặp lại chính mình — vòng lặp.
Vòng lặp cho script duyệt danh sách file, dòng trong log, hoặc dãy số, chạy cùng một khối lệnh cho mỗi phần tử. Làm đúng thì script mở rộng được; làm sai thì bạn âm thầm hỏng dữ liệu hoặc bỏ sót file có dấu cách trong tên.
Mô hình tư duy vòng lặp
Mọi vòng lặp bash cùng một dạng: kiểm tra còn phần tử không → chạy thân lặp → quay lại. Cú pháp đổi (for, while, until), nhưng luồng thì không.
Ba từ khóa lặp, một việc:
- duyệt danh sách cố định bạn đã biết (từ, file, phần tử mảng).
- tiếp tục khi điều kiện còn đúng (hoặc exit code là
0). - tiếp tục đến khi điều kiện thành đúng — đối xứng với
while.
for — duyệt một danh sách
Dạng cổ điển gán từng từ trong danh sách vào một biến, lần lượt:
for item in apple banana cherry; do
echo "Fruit: $item"
done
Bash tách danh sách theo khoảng trắng (mặc định). Ổn với từ đơn giản; nguy hiểm với đường dẫn file tùy ý. Luôn quote "$item" trong thân lặp.
Lặp qua mảng
Mảng bash (sẽ đi sâu ở phần sau) hợp với for tự nhiên:
servers=(web-01 web-02 db-01)
for host in "${servers[@]}"; do
echo "Pinging $host …"
ping -c 1 "$host" &>/dev/null && echo " up" || echo " down"
done
"${servers[@]}" bung thành từng phần tử là một từ riêng — cách đúng để lặp mảng. Không dùng "${servers[*]}" khi bạn cần mỗi phần tử một lần lặp.
Lặp qua file — glob, không phải ls
Để xử lý mọi file .txt trong thư mục hiện tại:
for f in *.txt; do
echo "Processing: $f"
wc -l < "$f"
done
Shell bung *.txt thành danh sách tên file trước khi vòng lặp chạy. Gọi là globbing.
Không bao giờ parse output của
ls.lsđịnh dạng cho người — cột, escape, khoảng cách không dự đoán được với tên file lạ. Globbing cho bạn tên file thật, mỗi lần lặp một tên.
# ❌ fragile — breaks on spaces, hides -rf if a file is named "-rf"
for f in $(ls *.txt); do …
# ✅ robust — one filename per iteration, handles spaces
for f in *.txt; do …
Nếu không khớp file nào, bash giữ nguyên chuỗi *.txt (khi nullglob tắt). Phòng trường hợp đó:
shopt -s nullglob # unmatched globs expand to nothing
for f in *.txt; do
[[ -f "$f" ]] || continue
echo "$f"
done
for kiểu C for (( … ))
Khi cần bộ đếm số, bash có vòng lặp số học:
for (( i = 0; i < 5; i++ )); do
echo "i = $i"
done
Đây là tính năng riêng bash (không phải POSIX sh). Dùng cho việc theo chỉ số — đếm retry, padding, duyệt cột:
for (( attempt = 1; attempt <= 3; attempt++ )); do
curl -sf https://api.example.com/health && break
echo "attempt $attempt failed, retrying …"
sleep 2
done
while và until — vòng lặp theo điều kiện
while — chạy khi test thành công
count=0
while [[ $count -lt 5 ]]; do
echo "count = $count"
(( count++ ))
done
while cũng hợp với lệnh mà exit code quan trọng:
# read one line at a time until EOF (more on this below)
while IFS= read -r line; do
echo ">> $line"
done < input.txt
until — chạy đến khi test thành công
until là while đảo ngược: thân lặp chạy khi điều kiện còn sai:
n=0
until [[ $n -ge 3 ]]; do
echo "n = $n"
(( n++ ))
done
Hữu ích khi bạn muốn đọc “cứ thử đến khi được”:
until ping -c 1 -W 1 gateway.local &>/dev/null; do
echo "waiting for network …"
sleep 1
done
echo "network is up"
Đọc file từng dòng — mẫu chuẩn
Mẫu mức production để đọc file text:
while IFS= read -r line; do
# process "$line"
echo "$line"
done < "$file"
Hai flag cực kỳ quan trọng:
| Thành phần | Nó làm gì | Vì sao cần |
|---|---|---|
IFS= | Xóa field separator cho lần read này | IFS mặc định cắt khoảng trắng đầu/cuối mỗi dòng |
read -r | Chế độ raw — không diễn giải backslash | Không có -r, \ ở cuối dòng nối dòng sau; escape khác làm biến dạng nội dung |
< "$file" | Chuyển stdin từ file | Vòng lặp chạy trong shell hiện tại — biến đặt bên trong còn lại |
Minh họa vì sao -r quan trọng:
printf 'line one\\\nline two\n' > weird.txt
# without -r: backslash-newline becomes a space-joined line
while IFS= read line; do echo "[$line]"; done < weird.txt
# [line one line two]
# with -r: each physical line preserved
while IFS= read -r line; do echo "[$line]"; done < weird.txt
# [line one\]
# [line two]
Minh họa vì sao IFS= quan trọng:
printf ' padded line \n' > padded.txt
while read line; do echo "[$line]"; done < padded.txt
# [padded line] ← leading/trailing spaces eaten
while IFS= read -r line; do echo "[$line]"; done < padded.txt
# [ padded line ] ← preserved exactly
Đọc output lệnh — pipe vs process substitution
Thường bạn lặp qua dòng do lệnh sinh ra, không phải file:
# process lines from `find`
while IFS= read -r path; do
echo "found: $path"
done < <(find . -name '*.log' -type f)
< <(command) là process substitution — đưa stdout của lệnh vào stdin vòng lặp không qua pipe. Vòng lặp vẫn ở shell hiện tại.
Bẫy subshell của pipe
Trông tự nhiên nhưng có chi phí ẩn:
count=0
find . -name '*.log' | while IFS= read -r path; do
(( count++ )) # runs in a SUBSHELL
done
echo "total: $count" # always 0 — parent never saw the increments
Pipe tạo subshell cho các lệnh bên phải. Biến đổi bên trong while read không sống sót khi vòng lặp kết thúc.
Cách sửa:
# 1) process substitution (preferred for accumulating state)
count=0
while IFS= read -r path; do
(( count++ ))
done < <(find . -name '*.log' -type f)
echo "total: $count" # correct
# 2) redirect from a temp file or here-string when output is small
mapfile -t paths < <(find . -name '*.log' -type f)
count=${#paths[@]}
break và continue
- thoát vòng lặp trong cùng ngay lập tức.
- bỏ phần còn lại của lần lặp này, nhảy sang lần sau.
for f in *.log; do
[[ -f "$f" ]] || continue # skip if glob didn't match a real file
if grep -q 'CRITICAL' "$f"; then
echo "ALERT in $f"
break # stop at first critical hit
fi
done
break n / continue n có thể nhắm vòng lặp ngoài (hiếm):
for tier in frontend backend; do
for host in host-{1..3}; do
ping -c 1 "${tier}-${host}" &>/dev/null || { echo "down: ${tier}-${host}"; break 2; }
done
done
Sinh dãy số — brace expansion & seq
Brace expansion
Bash bung \{1..10\} thành số nguyên 1 đến 10 trước khi lệnh chạy.
for i in {1..10}; do
echo "tick $i"
done
for letter in {a..e}; do
echo "$letter"
done
# zero-padded (bash 4+)
for day in {01..31}; do
echo "day-$day"
done
Brace expansion không phải vòng lặp — nó sinh từ mà for rồi duyệt. Xảy ra lúc parse, nên không dùng biến trực tiếp trong ngoặc nhọn:
start=1; end=5
for i in {$start..$end}; do echo "$i"; done # prints literal "{1..5}" — NOT 1 2 3 4 5
for i in $(seq "$start" "$end"); do echo "$i"; done # ✅ works
seq
seq in dãy số — portable và dùng biến được:
for i in $(seq 1 5); do echo "$i"; done # 1 2 3 4 5
for i in $(seq 2 2 10); do echo "$i"; done # 2 4 6 8 10 (start step end)
Ưu tiên for (( … )) cho dãy số đơn giản trong script bash-only; dùng seq khi biên động hoặc cần shell gần POSIX.
So sánh các kiểu vòng lặp
| Mẫu | Tốt cho | Bẫy |
|---|---|---|
for x in a b c | Danh sách từ, glob, phần tử mảng | $() không quote tách theo khoảng trắng |
for (( i=0; … )) | Bộ đếm, retry | Chỉ bash — không phải POSIX sh |
while [[ … ]] | Số lần lặp không biết trước, polling | Quên cập nhật điều kiện → lặp vô hạn |
until [[ … ]] | ”Ngữ nghĩa “đợi đến khi sẵn sàng” | Dễ đảo logic nhầm |
while read < file | Xử lý file từng dòng | Thiếu IFS= hoặc -r |
while read < <(cmd) | Output lệnh + giữ state shell | Cần bash (process substitution) |
cmd | while read | Biến đổi nhanh một lần | Subshell — biến ngoài không đổi |
Lỗi người mới hay mắc
for line in $(cat file)—$(cat …)gộp newline thành space và word-split; dòng có dấu cách thành nhiều “dòng”. Dùngwhile IFS= read -r line; do …; done < filethay thế.- Quên
read -r— backslash và nối dòng bị biến dạng. - Pipe vào
while readrồi thắc mắc vì saocount(hay biến ngoài) vẫn bằng 0. Bên phải|chạy trong subshell. for f in $(ls *.txt)— vỡ với dấu cách; parse sai tên file đặc biệt. Glob trực tiếp:for f in *.txt.- Không quote
"$item"trong thân lặp — word-splitting và glob expansion lại gây hại.
Bài tập
Thử từng bài trước khi mở lời giải.
- Viết script lặp qua mọi file
.shtrong thư mục hiện tại và in số dòng bằngwc -l. - Đọc
access.logtừng dòng (tạo mẫu 3 dòng) và chỉ in dòng chứa từERROR. - Dùng vòng
while readnhận input từfind . -name '*.tmp'để đếm file tạm, rồi in tổng sau khi vòng lặp kết thúc. Tránh bẫy subshell của pipe.
Lời giải
#!/usr/bin/env bash
# exercise-1.sh
shopt -s nullglob
for f in *.sh; do
lines=$(wc -l < "$f")
printf '%6s %s\n' "$lines" "$f"
done#!/usr/bin/env bash
# exercise-2.sh — create sample first
cat > access.log <<'EOF'
200 GET /api OK
500 POST /api ERROR timeout
200 GET /health OK
EOF
while IFS= read -r line; do
[[ "$line" == *ERROR* ]] && echo "$line"
done < access.log#!/usr/bin/env bash
# exercise-3.sh
touch a.tmp b.tmp sub/nested.tmp 2>/dev/null || true
count=0
while IFS= read -r path; do
(( count++ ))
done < <(find . -name '*.tmp' -type f)
echo "found $count temp file(s)"Bài 3 dùng process substitution để count cập nhật trong shell hiện tại và đúng sau done. Pipe (find … | while read) sẽ để count bằng 0.
Điều cốt lõi
Dùng for khi có danh sách (từ, glob, phần tử mảng); dùng while IFS= read -r cho dòng trong file hoặc output lệnh. Glob file trực tiếp — không parse ls. Khi cần biến sống sót sau vòng lặp, redirect bằng < file hoặc < <(cmd), không dùng pipe. Quote "$item" mỗi lần.