jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}.

next item? do … body … use "$item" done? loop back for each of: file1 file2 file3 …
Each iteration: pick the next item, run the body, then loop back until the list is exhausted or the condition fails

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 is 0) {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 of while {tiếp tục đến khi điều kiện thành đúng — đối xứng với while}.

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 ls output {Không bao giờ parse output của ls}. ls formats 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 {whileuntil — 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 {untilwhile đả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 -rRaw 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)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 {breakcontinue}

  • 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 cKnown 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 < fileLine-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 readQuick 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”}. Use while IFS= read -r line; do …; done < file instead {Dùng while IFS= read -r line; do …; done < file thay thế}.
  • ❌ Forgetting read -r — backslashes and line continuations get mangled {Quên read -r — backslash và nối dòng bị biến dạng}.
  • ❌ Piping into while read then wondering why count (or any outer variable) is still zero {Pipe vào while read rồi thắc mắc vì sao count (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}.

  1. Write a script that loops over every .sh file in the current directory and prints its line count using wc -l {Viết script lặp qua mọi file .sh trong thư mục hiện tại và in số dòng bằng wc -l}.
  2. Read access.log line by line (create a sample with 3 lines) and print only lines containing the word ERROR {Đọc access.log từng dòng (tạo mẫu 3 dòng) và chỉ in dòng chứa từ ERROR}.
  3. Use a while read loop fed by find . -name '*.tmp' to count temp files, then print the total after the loop ends {Dùng vòng while read nhậ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}.