Циклы в Bash: for, while и until с примерами

Циклы в Bash: for, while и until с примерами Полезное

Цикл в Bash — это конструкция, которая повторяет блок команд между do и done: для каждого элемента списка (for), пока команда-условие успешна (while) или пока она неуспешна (until). Ниже — рабочие примеры каждого вида, чтение файла построчно, break и continue, а также типовые ошибки с пробелами в именах.

Примеры проверены в Bash 5.2 (Ubuntu 24.04) 23 сентября 2026 года. В macOS по умолчанию стоит Bash 3.2 (а оболочка по умолчанию — zsh): там часть приемов не работает, это отмечено отдельно.

Три термина, которые путают

  • Условие в Bash — это не логическое выражение, а команда. Цикл смотрит на ее код возврата: 0 — «истина», любой другой — «ложь». Поэтому проверка в двойных квадратных скобках [[, арифметика (( ... )), grep -q и даже ping -c1 одинаково годятся как условие.
  • Итерация — один проход тела цикла.
  • Список слов в for — то, что осталось после подстановок: переменных, {1..5}, масок вроде *.txt. Именно на этом шаге теряются пробелы в именах, если забыть кавычки.

Минимальный полный пример

Скрипт складывает размеры и печатает промежуточную сумму:

#!/usr/bin/env bash
total=0
for size in 120 45 300; do
  total=$((total + size))
  echo "добавил $size, всего $total"
done
echo "итого: $total МБ"

Сохраните как sum.sh и запустите bash sum.sh. В консоли будет:

добавил 120, всего 120
добавил 45, всего 165
добавил 300, всего 465
итого: 465 МБ

Разбор: size по очереди принимает значения из списка после in, тело между do и done выполняется трижды, $((...)) считает целочисленную арифметику. Переменная total после цикла сохраняет значение — цикл выполняется в той же оболочке.

Какой цикл выбрать

Задача Цикл Шаблон
Перебрать известный список, файлы по маске, элементы массива for ... in for f in *.log; do ...; done
Счетчик с шагом или границей из переменной for ((...)) for ((i = 0; i < n; i++))
Повторять, пока условие истинно; читать файл построчно while while IFS= read -r line
Ждать, пока что-то станет истинным (файл, сервис) until until test -e flag

Важная деталь: и while, и until проверяют условие до каждой итерации. Если условие сразу не выполнено, тело не выполнится ни разу. Цикла с постусловием (как do ... while в C) в Bash нет, until — это просто while с обратным условием.

Цикл for: список, диапазон, массив

Диапазон задается фигурными скобками, шаг — третьим числом:

for i in {1..5}; do printf '%s ' "$i"; done; echo
for i in {0..10..3}; do printf '%s ' "$i"; done; echo
1 2 3 4 5 
0 3 6 9 

Шаг {0..10..3} появился в Bash 4. В Bash 3.2 на macOS вторая строка выведет {0..10..3} как обычный текст.

Ошибка: граница диапазона в переменной. Скобки раскрываются раньше, чем подставляются переменные:

n=3
for i in {1..$n}; do echo "i=$i"; done
i={1..3}

Одна итерация с буквальной строкой. Исправление — арифметическая форма, как в C:

n=3
for ((i = 1; i <= n; i++)); do echo "i=$i"; done
i=1
i=2
i=3

Внутри ((...)) переменные пишут без $. Сначала выполняется инициализация i = 1, перед каждой итерацией проверяется i <= n, после тела выполняется i++.

Элементы массива перебирают только в кавычках — "${servers[@]}":

servers=("web 01" "db 01")
for s in ${servers[@]}; do echo "[$s]"; done
for s in "${servers[@]}"; do echo "[$s]"; done

Первый цикл без кавычек выдал [web], [01], [db], [01] — четыре итерации вместо двух. Второй — [web 01] и [db 01], как задумано.

Файлы и пробелы в именах

Частая ошибка — перебирать вывод ls. В каталоге лежат notes.txt и report 2026.txt:

for f in $(ls *.txt); do echo "файл: $f"; done
файл: notes.txt
файл: report
файл: 2026.txt

Результат команды разбивается по пробелам, и одно имя превращается в два. Исправление — маска напрямую и переменная в кавычках при использовании:

for f in *.txt; do echo "файл: $f"; done
файл: notes.txt
файл: report 2026.txt

Вторая ловушка: если файлов по маске нет, for f in *.csv выполнится один раз со строкой *.csv. Команда shopt -s nullglob перед циклом делает ноль итераций.

Цикл, который меняет файлы, сначала запускайте в режиме «только показать». Пример переименования IMG 001.JPG в IMG_001.jpg с защитой от перезаписи:

#!/usr/bin/env bash
# rename.sh: "IMG 001.JPG" -> "IMG_001.jpg" в текущем каталоге
shopt -s nullglob            # нет .JPG - ноль итераций, а не строка "*.JPG"
dry_run=${DRY_RUN:-1}        # по умолчанию только показываем план

for f in *.JPG; do
  new="${f// /_}"            # пробелы -> подчеркивания
  new="${new%.JPG}.jpg"      # меняем расширение
  if [[ -e $new ]]; then
    echo "пропуск: $new уже есть" >&2
    continue
  fi
  if [[ $dry_run != 0 ]]; then   # реальный запуск только при DRY_RUN=0
    printf 'план: %q -> %q\n' "$f" "$new"
  else
    mv -- "$f" "$new"
  fi
done

В каталоге с IMG 001.JPG, IMG 002.JPG и уже существующим IMG_002.jpg команда bash rename.sh выводит:

план: IMG\ 001.JPG -> IMG_001.jpg
пропуск: IMG_002.jpg уже есть

Если план верный, запускайте DRY_RUN=0 bash rename.sh: переименуется только первый файл, второй останется нетронутым. Любое другое значение (DRY_RUN=yes, DRY_RUN=true) оставляет пробный режим: проверка идет строкой, а не через (( dry_run )), где слово yes считалось бы именем пустой переменной, то есть нулем, и файлы переименовались бы по-настоящему. -- в mv защищает от имен, начинающихся с дефиса.

Цикл while и чтение файла построчно

while повторяет тело, пока команда-условие возвращает 0:

count=1
while (( count <= 3 )); do
  echo "шаг $count"
  count=$((count + 1))
done

Выведет шаг 1, шаг 2, шаг 3. Если забыть увеличить счетчик, цикл станет бесконечным — остановить его можно Ctrl+C.

Главный практический сценарий while — построчное чтение. Файл lines.txt содержит строку с отступом, путь с обратной косой чертой и последнюю строку без перевода строки. Наивный вариант:

while read line; do
  echo "[$line]"
done < lines.txt
[отступ]
[путь C:temp]

Потерян отступ, съеден \, последняя строка пропала. Правильный шаблон:

while IFS= read -r line || [[ -n $line ]]; do
  echo "[$line]"
done < lines.txt
[  отступ]
[путь C:\temp]
[последняя без перевода строки]

IFS= сохраняет пробелы по краям, -r не трактует \ как экранирование, а добавка || с проверкой -n $line обрабатывает последнюю строку без \n. Если указать несколько переменных, read разобьет строку на поля: while read -r user role для строки alice admin даст user=alice, role=admin. Для CSV разделитель задают там же: while IFS=, read -r user role для строки alice,admin даст те же поля. Пустой IFS= при нескольких переменных, наоборот, отключает разбиение: вся строка попадет в первую переменную.

Ошибка: счетчик после конвейера. Считаем строки через cat ... |:

n=0
cat lines.txt | while IFS= read -r line || [[ -n $line ]]; do
  n=$((n + 1))
done
echo "строк: $n"

Выведет строк: 0. В Bash каждая часть конвейера по умолчанию выполняется в отдельной подоболочке, и изменения n в нее «уходят». Исправление — перенаправление вместо конвейера: done < lines.txt. Тогда получится строк: 3.

Цикл until: ждать с ограничением

until удобен, когда нужно дождаться события. Бесконечное ожидание в скрипте опасно, поэтому число попыток ограничиваем и после цикла проверяем, чем он закончился:

attempt=1
max=5
until [[ -e /tmp/ready.flag ]] || (( attempt > max )); do
  echo "жду файл, попытка $attempt из $max"
  sleep 1
  attempt=$((attempt + 1))
done
if [[ -e /tmp/ready.flag ]]; then
  echo "готово"
else
  echo "не дождался за $max попыток" >&2
  exit 1
fi

Когда файл появился через 2 секунды, скрипт напечатал две-три строки «жду файл» (зависит от того, на какую проверку пришлось появление) и готово. Когда файла не было, он вывел «не дождался» и завершился с кодом 1. Проверка после цикла обязательна: выход из until по лимиту попыток не значит, что событие наступило.

break и continue

continue пропускает остаток текущей итерации, break завершает цикл:

for i in {1..6}; do
  if (( i == 2 )); then continue; fi
  if (( i == 5 )); then break; fi
  echo "i=$i"
done

Выведет i=1, i=3, i=4: двойка пропущена, на пятерке цикл остановился. Во вложенных циклах break 2 выходит сразу из двух уровней — например, из перебора портов и хостов, когда нужная пара найдена.

Если не получилось

Симптом Причина Что сделать
i={1..3} вместо чисел переменная в {1..$n} for ((i = 1; i <= n; i++))
syntax error near unexpected token с echo или done пропущено do или ; перед ним for i in 1 2 3; do
Syntax error: Bad for loop variable скрипт запущен через sh (в Ubuntu это dash) запускать bash script.sh, первая строка #!/usr/bin/env bash
Имя файла разбито на части $(ls) или переменная без кавычек маска *.txt и "$f"
После цикла переменная пустая или 0 цикл стоит после | done < file вместо конвейера
Цикл не завершается условие не меняется в теле проверить счетчик, добавить лимит попыток, Ctrl+C

Большую часть этих ошибок до запуска находит линтер ShellCheck (shellcheck script.sh): на примерах из статьи он предупредил о {1..$n} (SC2051), массиве без кавычек (SC2068), переборе $(ls) (SC2045), read без -r (SC2162) и счетчике в конвейере (SC2030/SC2031).

Выводы

  • В Bash три цикла: for перебирает список или считает в форме ((...)), while работает, пока условие успешно, until — пока неуспешно; оба последних проверяют условие до итерации.
  • Условие цикла — код возврата команды: 0 означает «истина».
  • Файлы перебирайте маской, а не $(ls), и всегда берите переменные и массивы в кавычки; shopt -s nullglob убирает итерацию с пустой маской.
  • Файл читают шаблоном while IFS= read -r line с перенаправлением done < file, без конвейера перед while; последнюю строку без перевода строки подхватывает проверка -n $line.
  • Циклы, меняющие файлы или ждущие событий, запускайте с пробным режимом и лимитом попыток.

Где применяется / связь с практикой

Освойте тему на практике

Циклы — основа почти любого скрипта администратора: обойти серверы из списка, пережать логи за неделю, дождаться старта сервиса после деплоя, разобрать CSV-выгрузку построчно. Если хочется системно освоить командную строку, права, процессы и скрипты в Linux, посмотрите курс Administrator Linux. Basic. Разобрать отдельные темы с преподавателями можно на бесплатных открытых уроках Otus.

FAQ

Можно ли записать цикл в одну строку прямо в терминале?
Да, через точки с запятой: for f in *.log; do gzip -- "$f"; done. Перед do и перед done нужен ; или перевод строки.

Чем seq отличается от {1..10}?
{1..10} раскрывает сама оболочка, границы должны быть литералами. seq 1 $n — внешняя программа, принимает переменные, но для счетчика внутри Bash проще форма for ((...)).

Почему команда внутри while read съедает строки файла?
Команды вроде ssh читают стандартный ввод и забирают оставшиеся строки. Для ssh помогает флаг -n, общий способ — читать файл через отдельный дескриптор: while IFS= read -r line <&3; do ...; done 3< file.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)