Files
eduplan-cpp-eltex/lessons/D5_bash.md
T

388 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D5, часть 3. Bash, awk/sed, /proc (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Пайплайны и код возврата
`cmd1 | cmd2 | cmd3` запускает три отдельных процесса одновременно, соединённых анонимными
каналами (pipe) — `cmd2` начинает читать данные, ещё пока `cmd1` их пишет, ничего не
буферизуется целиком в памяти между шагами. `$?` после пайплайна — это код возврата
**последней** команды (`cmd3`), а не всего конвейера: если `cmd1` упал с ошибкой, а `cmd3`
завершилась успешно, `$?` покажет 0 — ошибка `cmd1` потеряется молча.
Чтобы увидеть код возврата каждой команды пайплайна отдельно, есть массив `PIPESTATUS`:
`echo "${PIPESTATUS[@]}"` после `cmd1 | cmd2` печатает, например, `1 0` — `cmd1` упал,
`cmd2` отработал нормально, хотя `$?` был бы 0.
**Факты для карточек**
- base | Чей код возврата хранит `$?` после `cmd1 | cmd2 | cmd3`? — только последней команды (`cmd3`)
- core | Как узнать код возврата каждой команды пайплайна отдельно? — массив `PIPESTATUS` (`${PIPESTATUS[@]}`)
Почему дальше: раз `$?` по умолчанию скрывает ошибки середины пайплайна, логично разобрать
режимы `set`, которые меняют это поведение по умолчанию.
## 2. `set -euo pipefail`: что каждая буква включает и где ломается
- **`-e`** — скрипт немедленно завершается, если любая команда вернула ненулевой код.
- **`-u`** — обращение к неопределённой переменной — ошибка, а не пустая строка.
- **`-o pipefail`** — код возврата пайплайна становится кодом **первой** упавшей команды в
нём, а не только последней (закрывает дыру из раздела 1).
`-e` не так надёжен, как кажется, — есть места, где он **не срабатывает**:
- Команда внутри условия (`if cmd; then`, `while cmd; do`, после `&&`/`||`) — код возврата там
ожидаем и проверяется явно, `-e` эту команду не трогает даже при ошибке.
- Команда в подстановке `$(cmd)`, присвоенной переменной без немедленного использования
результата в условии — ошибка внутри подстановки сама по себе не всегда останавливает
внешний скрипт, поведение зависит от контекста использования результата.
- Любая команда как часть пайплайна, кроме последней, — без `pipefail` `-e` реагирует только
на код последней команды пайплайна (та же дыра, что и в разделе 1).
**Ловушки**
- Полагаться на `-e` внутри `if some_check; then ... fi` для остановки скрипта при ошибке
`some_check` → скрипт не остановится, потому что команда — часть условия → ошибка
проглатывается и обнаруживается заметно позже, в неожиданном месте.
- Забыть `pipefail` → `grep pattern file | sort` при отсутствующем `file` вернёт 0 (потому что
`sort` пустого потока отрабатывает успешно) → скрипт продолжит работу, как будто файл найден.
**Факты для карточек**
- base | Что делает `-e` в `set -euo pipefail`? — скрипт завершается сразу при ненулевом коде возврата любой команды
- base | Что делает `-u`? — обращение к неопределённой переменной — ошибка вместо пустой строки
- core | Что делает `pipefail` и какую проблему из раздела 1 это закрывает? — код возврата пайплайна = код первой упавшей команды, а не только последней; закрывает потерю ошибок середины пайплайна
- core | В каком месте `-e` не остановит скрипт при ошибке команды? — если команда — часть условия (`if`, `while`, после `&&`/`||`) или не последняя команда пайплайна без `pipefail`
Почему дальше: `set -u` ловит неопределённые переменные, но не спасает от другой частой
причины сюрпризов — как bash разбивает строку на слова при подстановке без кавычек.
## 3. Кавычки, word splitting, IFS, массивы
Без двойных кавычек bash после подстановки переменной делает **word splitting** — разбивает
результат на отдельные слова по символам из `IFS` (по умолчанию пробел, таб, перевод строки) и
затем **globbing** — раскрывает символы `*`, `?`, `[...]` как маски файлов. `"$var"` в двойных
кавычках отключает оба шага — переменная передаётся как одна строка целиком, что почти всегда
и нужно.
```bash
file="my file.txt"
rm $file # word splitting: rm воспринимает это как rm "my" "file.txt" — два аргумента
rm "$file" # правильно: один аргумент "my file.txt"
```
`IFS` можно временно переопределить, чтобы разбить строку по нужному разделителю:
```bash
IFS=',' read -ra parts <<< "a,b,c" # parts=(a b c)
```
Массивы: `arr=(a b c)`, обращение по индексу `${arr[1]}`, все элементы — `${arr[@]}` (каждый
элемент — отдельное слово при развёртывании в кавычках, `"${arr[@]}"`), длина — `${#arr[@]}`.
**Ловушки**
- Забыть кавычки вокруг переменной с путём, содержащим пробел, → word splitting режет путь на
несколько аргументов → «No such file or directory» на файле, который на самом деле есть.
- `${arr[@]}` без кавычек при переборе `for x in ${arr[@]}` → элементы с пробелами внутри сами
разбиваются на несколько слов → цикл обрабатывает не те элементы, что были в массиве.
**Факты для карточек**
- base | Что по умолчанию входит в IFS? — пробел, таб, перевод строки
- core | Что отключают двойные кавычки вокруг `"$var"`? — word splitting и globbing (`*`/`?`/`[...]` не раскрываются)
- core | Как правильно перебрать массив с элементами, содержащими пробелы? — `for x in "${arr[@]}"` (с кавычками)
Почему дальше: помимо кода возврата команды и переменных окружения, у самого запущенного
скрипта и его процессов есть ещё несколько специальных переменных и способ отреагировать на
сигнал — `trap`.
## 4. `$?`, `$!`, `$$`, `trap`
- **`$?`** — код возврата последней выполненной команды (см. раздел 1 про пайплайны).
- **`$!`** — PID последнего запущенного в фоне процесса (`cmd &` затем `pid=$!`), нужен,
чтобы потом сделать `wait $pid` или `kill $pid`.
- **`$$`** — PID самого текущего скрипта/шелла, часто используется для уникальных временных
файлов: `tmpfile="/tmp/out.$$"`.
- **`trap`** — регистрирует обработчик на сигнал или на выход из скрипта:
`trap 'rm -f "$tmpfile"' EXIT` гарантированно удалит временный файл при любом завершении
скрипта — обычном, по ошибке (`-e`) или по сигналу (если сигнал перечислен), без ручного
дублирования `rm` в каждой точке выхода.
**Факты для карточек**
- base | Что хранит `$!`? — PID последнего фонового процесса
- base | Что хранит `$$`? — PID текущего скрипта/шелла
- core | Зачем `trap ... EXIT` используют для временных файлов? — гарантирует очистку при любом пути завершения скрипта (успех, ошибка, сигнал), без дублирования кода в каждой точке выхода
Почему дальше: `$$` в имени временного файла — это про один процесс; когда данные для команды
нужно передать через аргументы, а не через имя файла, и данных много (список файлов, список
PID), на сцену выходит `xargs`.
## 5. `xargs -0`
`xargs` берёт поток строк со стандартного ввода и подставляет их как аргументы в конец
указанной команды, разбивая по частям, если список слишком длинный для одного вызова
(ограничение ОС на длину аргументов). По умолчанию `xargs`, как и bash, разбивает вход по
пробельным символам — имена файлов с пробелами или переводами строк внутри (редко, но
бывает) ломают такой разбор.
`-0` — вход разделён не пробелами/переводами строк, а нулевыми байтами (`\0`), которые не
могут встретиться внутри легального имени файла в Unix. Источник таких нулевых разделителей —
`find ... -print0`:
```bash
find . -name '*.tmp' -print0 | xargs -0 rm -f
```
Это единственная безопасная пара для случая, когда имена файлов заранее не гарантированы
«простыми» (без пробелов, кавычек, переводов строк).
**Ловушки**
- `find ... | xargs rm` без `-print0`/`-0` на именах файлов с пробелами → имя разбивается на
несколько «аргументов» → `rm` пытается удалить несуществующие файлы или удаляет не то.
**Факты для карточек**
- base | Чем `-0` в `xargs -0` отличается от поведения по умолчанию? — вход разделяется нулевыми байтами `\0` вместо пробелов/переводов строк
- core | С какой опцией `find` обычно комбинируют `xargs -0`? — `-print0`
Почему дальше: `xargs` передаёт список аргументов другой команде; сами команды для поиска и
агрегации данных в потоке — отдельный набор утилит с конкретными флагами.
## 6. Поиск и агрегация: grep, sort, uniq
- **`grep -c PATTERN`** — печатает не совпавшие строки, а их количество.
- **`grep -o PATTERN`** — печатает только совпавшую часть строки, а не строку целиком (удобно
вместе с `wc -l`, чтобы посчитать число вхождений, а не строк с хотя бы одним вхождением).
- **`grep -v PATTERN`** — инвертирует фильтр, печатает строки, которые **не** совпали.
- **`sort -k N`** — сортирует по N-му полю (по умолчанию разделитель — пробел), а не по всей
строке целиком.
- **`sort -n`** — числовая сортировка (`10` идёт после `9`, а не перед `2`, как при
лексикографической сортировке строк по умолчанию).
- **`sort -u`** — сортировка с одновременным удалением дубликатов (эквивалент `sort | uniq`
в один проход).
- **`uniq -c`** — схлопывает соседние повторяющиеся строки, добавляя счётчик повторов перед
каждой строкой; работает только на **уже отсортированном** входе — несмежные повторы
`uniq` не видит.
```bash
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -3 # топ-3 IP по числу запросов
```
**Ловушки**
- Вызвать `uniq -c` без предварительного `sort` → повторы, не идущие подряд в исходном файле,
не схлопнутся → счётчики занижены относительно реального числа повторов.
- `sort` без `-n` на числовых полях (порты, счётчики) → `10 < 9` лексикографически → строки
идут не в том порядке, который ожидался.
**Факты для карточек**
- base | Что печатает `grep -c`? — количество совпавших строк, а не сами строки
- base | Почему `uniq -c` нужно применять после `sort`? — `uniq` схлопывает только соседние одинаковые строки, несмежные повторы не видит
- core | Чем `sort -u` эквивалентен по результату? — `sort | uniq`, но за один проход
Почему дальше: `grep`/`sort`/`uniq` работают со строками целиком или по одному полю через
внешний ключ; когда нужна произвольная логика по колонкам с накоплением состояния (суммы,
счётчики по ключу), нужен `awk`.
## 7. awk: поля, NR/NF, ассоциативные массивы
`awk` читает вход построчно и автоматически разбивает каждую строку на поля по разделителю
(`FS`, по умолчанию — пробел/таб): `$1`, `$2`, … — поля, `$0` — строка целиком, `NR` — номер
текущей строки от начала потока, `NF` — число полей в текущей строке.
```bash
awk '{sum[$1] += $2} END {for (k in sum) print k, sum[k]}' data.txt
```
`sum` здесь — ассоциативный массив: ключ `$1` (первое поле), значение — накапливаемая сумма
второго поля. `awk` — полноценный язык с переменными, циклами и такими массивами, а не просто
фильтр, поэтому в нём можно агрегировать данные по ключу за один проход, без промежуточных
файлов.
Почему `awk` на файле в 10⁶ строк быстрее bash-цикла построчного чтения с внешними командами
внутри: `awk` — один процесс, который читает поток и делает линейный проход по строкам целиком
внутри себя. Bash-цикл вида `while read -r line; do grep ... <<< "$line"; done < file`
порождает (форкает) отдельный новый процесс **на каждую строку** — на миллионе строк это
миллион `fork`+`exec`, каждый из которых на порядки дороже одной итерации внутреннего цикла
`awk`. Разница — не в разы, а на порядки по времени выполнения.
**Ловушки**
- Обрабатывать большой лог циклом `for`/`while` с построчным чтением и вызовом `grep`/`awk`
внутри цикла → минуты вместо секунд на файле в миллионы строк → видно по `time` на запуске
скрипта; правильный подход — один проход `awk`/`sort` на весь поток целиком.
**Факты для карточек**
- base | Что означает `$1` в awk? — первое поле текущей строки
- base | Что содержит `NR`? — номер текущей строки от начала потока
- core | Почему awk на 1e6 строк быстрее bash-цикла с grep внутри? — awk — один процесс с линейным проходом по строкам, bash-цикл форкает новый процесс на каждую итерацию (миллион fork+exec против одного процесса)
Почему дальше: `awk` агрегирует и печатает; когда нужно не посчитать, а точечно
отредактировать текст по шаблону прямо в потоке, для этого — `sed`.
## 8. sed
`sed` применяет команды редактирования к каждой строке потока и печатает результат — потоковый
редактор, не интерактивный. Самая частая команда — замена по шаблону:
`sed 's/OLD/NEW/'` (заменяет первое вхождение в строке), `sed 's/OLD/NEW/g'` (все вхождения в
строке, флаг `g` = global). Без флага `-i` `sed` не меняет исходный файл, а печатает результат
в stdout — `sed -i 's/OLD/NEW/g' file` редактирует файл на месте.
```bash
sed -n '5,10p' file # напечатать только строки с 5 по 10 (-n подавляет вывод по умолчанию)
sed '/^#/d' file # удалить строки, начинающиеся с #
```
**Факты для карточек**
- base | Что делает флаг `g` в `s/OLD/NEW/g`? — заменяет все вхождения в строке, а не только первое
- core | Чем `sed -i` отличается от `sed` без флага? — редактирует файл на месте вместо печати результата в stdout
Почему дальше: `grep`/`awk`/`sed` работают с текстовыми потоками из файлов и команд; отдельный
источник текстовых данных в Linux — это `/proc`, где ядро публикует состояние процессов и
ресурсов в виде текстовых файлов.
## 9. `/proc`: что там реально лежит
`/proc` — виртуальная файловая система: файлы в ней не лежат на диске, ядро генерирует их
содержимое в реальном времени по запросу чтения. Ключевые пути:
- **`/proc/<pid>/status`** — читаемая человеком сводка о процессе: состояние, использование
памяти (VmRSS и другие), UID/GID, число потоков.
- **`/proc/<pid>/cmdline`** — команда запуска процесса с аргументами, разделёнными нулевыми
байтами `\0` (не пробелами — тот же принцип, что у `find -print0`, аргумент с пробелом
внутри не спутать с границей между аргументами).
- **`/proc/<pid>/fd/`** — каталог, где каждый файл — символическая ссылка на открытый этим
процессом файловый дескриптор (обычный файл, сокет, pipe); число ссылок в этом каталоге —
реальное число открытых дескрипторов процесса прямо сейчас.
- **`/proc/<pid>/maps`** — карта отображений памяти процесса: диапазоны адресов, права доступа
(r/w/x), какому файлу или сегменту (куча, стек, конкретная библиотека) принадлежит каждый
диапазон.
- **`/proc/cpuinfo`** — параметры процессора (модель, число ядер, флаги поддерживаемых
инструкций).
- **`/proc/meminfo`** — распределение оперативной памяти: занято, свободно, в кэше, в буферах
(источник данных для `free -h`).
- **`/proc/<pid>/stat`** — машинно-читаемая (не для человека) строка с числовыми полями
состояния процесса, включая, например, время в user/kernel-режиме; источник данных для
`ps`/`top`.
Утилиты вроде `ps`, `top`, `free`, `iostat` — это не отдельный механизм сбора данных, а тонкая
обёртка над чтением этих же файлов `/proc`; ядро уже держит эти данные готовыми, специально
«собирать» их не нужно.
**Ловушки**
- Читать `/proc/<pid>/cmdline` как обычную строку с пробелами в качестве разделителей
аргументов → аргумент вида `"two words"` неотличим от двух отдельных аргументов → нужен
именно разбор по `\0`.
**Факты для карточек**
- base | Что такое /proc с точки зрения хранения данных? — виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске
- base | Чем разделены аргументы в /proc/<pid>/cmdline? — нулевыми байтами `\0`
- core | Что можно узнать из /proc/<pid>/maps? — диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон
- core | Откуда `free -h` берёт данные о памяти? — из /proc/meminfo
Почему дальше: `/proc/<pid>/fd/` показывает текущее число открытых дескрипторов процесса;
верхнюю границу на это число задаёт `ulimit`.
## 10. `ulimit`: дескрипторы, core, стек
`ulimit` задаёт лимиты ресурсов для текущего шелла и процессов, запущенных из него:
- **`ulimit -n`** — максимальное число одновременно открытых файловых дескрипторов на
процесс; упереться в этот лимит на нагруженном сервере — типичная причина ошибки
«Too many open files».
- **`ulimit -c`** — максимальный размер core dump файла; по умолчанию часто `0` (core dump
отключён), поэтому перед тем как ловить редкий краш, выставляют `ulimit -c unlimited` —
иначе программа упадёт, а файла с состоянием памяти на момент падения просто не будет.
- **`ulimit -s`** — размер стека потока; именно этот лимит определяет, на какой глубине
рекурсии процесс упадёт с переполнением стека (см. риск глубокой рекурсии при мемоизации в
теме ДП).
**Ловушки**
- Ждать редкий краш под нагрузкой без `ulimit -c unlimited` заранее → программа падает, core
dump не создаётся (лимит 0 по умолчанию) → единственный шанс поймать причину падения
упущен, воспроизвести заново может быть нечем.
**Факты для карточек**
- base | Что ограничивает `ulimit -n`? — максимальное число открытых файловых дескрипторов на процесс
- core | Почему перед отладкой редкого краша выставляют `ulimit -c unlimited`? — по умолчанию размер core dump часто ограничен нулём, без этого файл с состоянием памяти на момент падения не создастся
Почему дальше: лимиты `ulimit` — это про ресурсы процесса; отдельная система ограничений — про
то, кому вообще разрешено читать, писать и исполнять конкретный файл.
## 11. Права: `chmod`/`chown`, `umask`
Права файла — три триады бит (владелец/группа/остальные) × (чтение r / запись w / выполнение
x), каждая триада кодируется одной восьмеричной цифрой: r=4, w=2, x=1, сумма даёт цифру для
триады. `755` = `rwxr-xr-x`: владелец — 7 (4+2+1, полный доступ), группа — 5 (4+1, чтение и
выполнение без записи), остальные — 5 (то же самое). `chmod 755 file` выставляет эти права
явно; `chown user:group file` меняет владельца и группу файла.
`umask` — маска, которая **вычитается** из прав по умолчанию при создании нового файла/каталога
(обычно каталоги создаются с базовым 777, файлы — с 666, дальше действует `umask`): при
`umask 022` новый файл получает `666 & ~022 = 644` (`rw-r--r--`), новый каталог — `777 & ~022 =
755`. `umask` не меняет права существующих файлов — только права по умолчанию для новых.
**Факты для карточек**
- base | Расшифровка прав 755? — rwxr-xr-x (владелец: полный доступ, группа и остальные: чтение+выполнение)
- base | Какие числа кодируют r, w, x? — r=4, w=2, x=1
- core | Что делает `umask 022` с правами нового файла по умолчанию (666)? — вычитает 022, получается 644 (rw-r--r--)
Почему дальше: права управляют доступом к файлам статически; когда нужно понять, что процесс
делает с файлами/сетью прямо сейчас, в динамике, нужны отдельные диагностические утилиты.
## 12. `strace`, `lsof`, `ss` как инструменты
- **`strace -p PID`** (или `strace command`) — трассирует системные вызовы процесса построчно:
какой syscall, с какими аргументами, что вернул. Способ увидеть, например, что процесс
зависает именно на `read()` конкретного файлового дескриптора, а не где-то в логике
приложения.
- **`lsof -p PID`** — список файлов (в широком unix-смысле — включая сокеты, pipe), открытых
процессом; тот же смысл, что и `/proc/<pid>/fd/`, но в удобном для чтения формате с
дополнительной информацией.
- **`ss -tan`** — состояние TCP-сокетов (замена устаревшему `netstat`): адреса, порты,
состояние соединения (`ESTABLISHED`, `TIME_WAIT` и так далее — см. состояния TCP).
**Факты для карточек**
- base | Что показывает `strace`? — системные вызовы процесса с аргументами и результатом, построчно
- core | Чем `lsof -p PID` и `/proc/<pid>/fd/` пересекаются по смыслу? — оба показывают список файловых дескрипторов, открытых процессом
Ссылки на задачи этого дня: `tasks/08_bash` — разбор access-лога (`TOTAL`/`TOP`/`5XX`) на
файле до миллиона строк; построчный bash-цикл не проходит по времени (раздел 7), нужен
`awk`/`sort` за один-два прохода.
<details>
<summary>Проверь себя</summary>
1. Скрипт с `set -e` содержит `if grep -q pattern file; then echo found; fi`, и `grep` не
находит `pattern` (возвращает 1). Остановится ли скрипт на этой строке?
<details><summary>Ответ</summary>Нет — команда внутри условия `if` не подчиняется `-e`,
даже если она вернула ненулевой код; это одно из исключений `-e`, скрипт продолжит
выполнение дальше.</details>
2. `grep pattern missing_file.txt | wc -l` при отсутствующем файле напечатает `0`, а `$?`
пайплайна без `pipefail` будет `0`. Как обнаружить, что реальная проблема — отсутствующий
файл, а не отсутствие совпадений?
<details><summary>Ответ</summary>Включить `set -o pipefail` — тогда код возврата пайплайна
станет кодом первой упавшей команды (`grep` на несуществующем файле), а не только `wc -l`;
либо проверить `${PIPESTATUS[0]}` явно после пайплайна.</details>
3. На файле в миллион строк нужно посчитать число запросов на каждый IP-адрес. Почему решение
через `while read -r line; do ...; done < access.log` с вызовом `awk`/`grep` внутри цикла
будет на порядки медленнее, чем `awk '{print $1}' access.log | sort | uniq -c`?
<details><summary>Ответ</summary>Цикл на bash с внешней командой внутри порождает (fork+exec)
новый процесс на каждую из миллиона строк; связка `awk | sort | uniq -c` — это фиксированное
малое число процессов (по одному на каждый элемент конвейера), каждый из которых делает
один линейный проход по всему потоку целиком.</details>
4. Процесс с `ulimit -c 0` неожиданно падает по SIGSEGV под нагрузкой, воспроизвести падение
по требованию не получается. Что нужно было сделать заранее, чтобы разобрать причину
постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited` до запуска процесса —
тогда при падении ядро сохранит core dump со снимком памяти на момент сбоя, и его можно
будет открыть позже (`gdb prog core`), не дожидаясь повторного воспроизведения
краша.</details>
</details>
## Материалы
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html
- man7.org, `getrlimit(2)` — https://man7.org/linux/man-pages/man2/getrlimit.2.html
- man7.org, `ptrace(2)` — https://man7.org/linux/man-pages/man2/ptrace.2.html
- man7.org, `chmod(2)` — https://man7.org/linux/man-pages/man2/chmod.2.html
- man7.org, `umask(2)` — https://man7.org/linux/man-pages/man2/umask.2.html
- man7.org, `signal(7)` — https://man7.org/linux/man-pages/man7/signal.7.html