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.
This is Part 4 of a 10-part series that takes you from “I copy-paste commands” to writing robust, production-grade shell scripts {Đâ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}. Parts 1–3 covered the shell itself, variables & quoting, and conditionals {Phần 1–3 đã nói về shell, biến & quoting, và điều kiện}. Now we repeat work without repeating yourself — loops {Giờ ta lặp công việc mà không lặp lại chính mình — vòng lặp}.
Loops let a script walk a list of files, lines in a log, or numbers in a range, running the same block for each item {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ử}. Get them right and your scripts scale; get them wrong and you silently corrupt data or miss files with spaces in their names {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}.
The loop mental model {Mô hình tư duy vòng lặp}
Every bash loop follows the same shape: test whether there is another item → run the body → go back {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}. The syntax changes (for, while, until), but the flow does not {Cú pháp đổi (for, while, until), nhưng luồng thì không}.
Three loop keywords, one job {Ba từ khóa lặp, một việc}:
for— iterate over a fixed list you already know (words, files, array elements) {duyệt danh sách cố định bạn đã biết (từ, file, phần tử mảng)}.while— keep going while a condition is true (or an exit code is0) {tiếp tục khi điều kiện còn đúng (hoặc exit code là0)}.until— keep going until a condition becomes true — the mirror ofwhile{tiếp tục đến khi điều kiện thành đúng — đối xứng vớiwhile}.
for — iterate over a list {for — duyệt một danh sách}
The classic form assigns each word in a list to a variable, one at a time {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 splits the list on whitespace (by default) {Bash tách danh sách theo khoảng trắng (mặc định)}. That is fine for simple words; it is dangerous for arbitrary file paths {Ổn với từ đơn giản; nguy hiểm với đường dẫn file tùy ý}. Always quote "$item" inside the body {Luôn quote "$item" trong thân lặp}.
Looping over an array {Lặp qua mảng}
Bash arrays (covered in depth later) work naturally with for {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[@]}" expands to each element as a separate word — the correct way to loop an array {"${servers[@]}" bung thành từng phần tử là một từ riêng — cách đúng để lặp mảng}. Never use "${servers[*]}" when you need one iteration per element {Không dùng "${servers[*]}" khi bạn cần mỗi phần tử một lần lặp}.
Looping over files — glob, not ls {Lặp qua file — glob, không phải ls}
To process every .txt file in the current directory {Để 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
The shell expands *.txt into a list of matching filenames before the loop runs {Shell bung *.txt thành danh sách tên file trước khi vòng lặp chạy}. This is called globbing {Gọi là globbing}.
Never parse
lsoutput {Không bao giờ parse output củals}.lsformats for humans — columns, escapes, unpredictable spacing with odd filenames {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 gives you real filenames, one per iteration {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 …
If no file matches, bash leaves the literal *.txt (with nullglob off) {Nếu không khớp file nào, bash giữ nguyên chuỗi *.txt (khi nullglob tắt)}. Guard against that {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
C-style for (( … )) {for kiểu C for (( … ))}
When you need a numeric counter, bash offers arithmetic loops {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
This is bash-specific (not POSIX sh) {Đây là tính năng riêng bash (không phải POSIX sh)}. Use it for index-based work — retry counts, padding, stepping through columns {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 and until — condition-driven loops {while và until — vòng lặp theo điều kiện}
while — run while the test succeeds {while — chạy khi test thành công}
count=0
while [[ $count -lt 5 ]]; do
echo "count = $count"
(( count++ ))
done
while also pairs naturally with commands whose exit code matters {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 — run until the test succeeds {until — chạy đến khi test thành công}
until is while inverted: the body runs while the condition is false {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
Useful when you want to read “keep trying until it works” {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"
Reading a file line by line — the canonical pattern {Đọc file từng dòng — mẫu chuẩn}
The production-grade pattern for reading a text file {Mẫu mức production để đọc file text}:
while IFS= read -r line; do
# process "$line"
echo "$line"
done < "$file"
Two flags matter enormously {Hai flag cực kỳ quan trọng}:
| Piece {Thành phần} | What it does {Nó làm gì} | Why you need it {Vì sao cần} |
|---|---|---|
IFS= | Clears the field separator for this read {Xóa field separator cho lần read này} | Default IFS strips leading/trailing whitespace from each line {IFS mặc định cắt khoảng trắng đầu/cuối mỗi dòng} |
read -r | Raw mode — do not interpret backslashes {Chế độ raw — không diễn giải backslash} | Without -r, \ at end of line joins the next line; other escapes mutate content {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" | Redirect stdin from the file {Chuyển stdin từ file} | Loop runs in the current shell — variables set inside persist {Vòng lặp chạy trong shell hiện tại — biến đặt bên trong còn lại} |
Demonstrating why -r matters {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]
Demonstrating why IFS= matters {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
Reading command output — pipe vs process substitution {Đọc output lệnh — pipe vs process substitution}
Often you loop over lines produced by a command, not a file {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) is process substitution — it feeds the command’s stdout into the loop’s stdin without a pipe {< <(command) là process substitution — đưa stdout của lệnh vào stdin vòng lặp không qua pipe}. The loop stays in the current shell {Vòng lặp vẫn ở shell hiện tại}.
The pipe subshell trap {Bẫy subshell của pipe}
This looks natural but has a hidden cost {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
A pipe creates a subshell for the right-hand commands {Pipe tạo subshell cho các lệnh bên phải}. Variables changed inside while read do not survive when the loop ends {Biến đổi bên trong while read không sống sót khi vòng lặp kết thúc}.
Fixes {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 and continue {break và continue}
break— exit the innermost loop immediately {thoát vòng lặp trong cùng ngay lập tức}.continue— skip the rest of this iteration and jump to the next {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 can target an outer loop (rare) {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
Generating sequences — brace expansion & seq {Sinh dãy số — brace expansion & seq}
Brace expansion {Brace expansion}
Bash expands \{1..10\} into the integers 1 through 10 before any command runs {Bash bung \{1..10\} thành số nguyên 1 đến 10 trước khi lệnh chạy}. In prose we write it without backslashes; in a script it is {1..10}:
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 is not a loop — it produces words that for then walks {Brace expansion không phải vòng lặp — nó sinh từ mà for rồi duyệt}. It happens at parse time, so you cannot use variables inside the braces directly {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}
seq prints a sequence of numbers — portable and variable-friendly {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)
Prefer for (( … )) for simple numeric ranges in bash-only scripts; use seq when the bounds are dynamic or you need POSIX-friendly shells {Ư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}.
Loop styles compared {So sánh các kiểu vòng lặp}
| Pattern {Mẫu} | Best for {Tốt cho} | Pitfall {Bẫy} |
|---|---|---|
for x in a b c | Known word list, globs, array elements {Danh sách từ, glob, phần tử mảng} | Unquoted $() splits on whitespace {$() không quote tách theo khoảng trắng} |
for (( i=0; … )) | Numeric counters, retries {Bộ đếm, retry} | Bash-only — not POSIX sh {Chỉ bash — không phải POSIX sh} |
while [[ … ]] | Unknown iteration count, polling {Số lần lặp không biết trước, polling} | Forgetting to update the condition → infinite loop {Quên cập nhật điều kiện → lặp vô hạn} |
until [[ … ]] | ”Wait until ready” semantics {Ngữ nghĩa “đợi đến khi sẵn sàng”} | Easy to invert logic by mistake {Dễ đảo logic nhầm} |
while read < file | Line-by-line file processing {Xử lý file từng dòng} | Omitting IFS= or -r {Thiếu IFS= hoặc -r} |
while read < <(cmd) | Command output + keep shell state {Output lệnh + giữ state shell} | Requires bash (process substitution) {Cần bash (process substitution)} |
cmd | while read | Quick one-off transforms {Biến đổi nhanh một lần} | Subshell — outer variables unchanged {Subshell — biến ngoài không đổi} |
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌
for line in $(cat file)—$(cat …)collapses newlines to spaces and word-splits; lines with spaces become multiple “lines” {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”}. Usewhile IFS= read -r line; do …; done < fileinstead {Dùngwhile IFS= read -r line; do …; done < filethay thế}. - ❌ Forgetting
read -r— backslashes and line continuations get mangled {Quênread -r— backslash và nối dòng bị biến dạng}. - ❌ Piping into
while readthen wondering whycount(or any outer variable) is still zero {Pipe vàowhile readrồi thắc mắc vì saocount(hay biến ngoài) vẫn bằng 0}. The right side of|runs in a subshell {Bên phải|chạy trong subshell}. - ❌
for f in $(ls *.txt)— breaks on spaces; can mis-parse special filenames {for f in $(ls *.txt)— vỡ với dấu cách; parse sai tên file đặc biệt}. Glob directly:for f in *.txt{Glob trực tiếp:for f in *.txt}. - ❌ Unquoted
"$item"inside the loop body — word-splitting and glob expansion bite again {Không quote"$item"trong thân lặp — word-splitting và glob expansion lại gây hại}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Write a script that loops over every
.shfile in the current directory and prints its line count usingwc -l{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}. - Read
access.logline by line (create a sample with 3 lines) and print only lines containing the wordERROR{Đọcaccess.logtừng dòng (tạo mẫu 3 dòng) và chỉ in dòng chứa từERROR}. - Use a
while readloop fed byfind . -name '*.tmp'to count temp files, then print the total after the loop ends {Dùng vòngwhile 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}. Avoid the pipe subshell trap {Tránh bẫy subshell của pipe}.
Solution {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)"Exercise 3 uses process substitution so count updates in the current shell and is correct after done {Bài 3 dùng process substitution để count cập nhật trong shell hiện tại và đúng sau done}. A pipe (find … | while read) would leave count at 0 {Pipe (find … | while read) sẽ để count bằng 0}.
Takeaway {Điều cốt lõi}
Use for when you have a list (words, globs, array elements); use while IFS= read -r for lines in a file or command output {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 files directly — never parse ls {Glob file trực tiếp — không parse ls}. When you need variables to survive the loop, redirect with < file or < <(cmd), not a pipe {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" every time {Quote "$item" mỗi lần}.