OS·V Операционная система Глава 36 из 65
Экскурсия по живой системе
Каждая ячейка этого курса запускается в настоящем Linux. Эта глава — путеводитель по нему: заглянем в таблицу процессов, проследим, из каких просьб к ядру состоит print("hi"), соединим программы трубами, как предлагал в 1964 году Дуг Макилрой, и выясним, кто в этой системе хозяин и почему нам здесь можно так мало.
Операционная система
- 36 ОС вы здесь
- 37 Планировщик
- 38 Виртуальная память
- 39 Конкурентность
- 40 Хранение
Опирается на: 32 · Ты — процессор
Что вы унесёте из главы
- понимать, что такое ядро, системный вызов, процесс и файловый дескриптор, и находить их следы в /proc
- собирать конвейеры из команд оболочки и повторять их на Python
- читать права доступа и переменные окружения и понимать, почему программе что-то запрещено
Прошлая глава оставила вопрос: на одном процессоре работает сотня программ — кто решает, кому из них работать, и кто защищает их друг от друга? Там же мы поймали этого кого-то за руку. Песочница видела много ядер, а занять давали одно, и раз за разом кто-то останавливал наши процессы, когда они выбирали свою долю. Этот кто-то — операционная система. Знакомиться с ней можно по книгам, но у нас есть способ лучше: мы давно внутри неё. Каждая ячейка курса выполняется на сервере под Linux, и эта глава — путеводитель по нему.
Маршрут такой. У входа осмотримся и прочтём табличку с историей. В первом зале познакомимся с ядром и подслушаем, о чём программа его просит. Во втором увидим процессы — и как один процесс рождает другой. Третий зал — о том, почему в Unix всё есть файл, четвёртый — о трубах, которые соединяют программы, пятый — об оболочке, языке, на котором эти трубы прокладывают. В шестом выясним, кто мы здесь такие и что нам можно. А на выходе, как положено, сувенирная лавка: правила, по которым строили всё увиденное. Экспонаты трогать можно и нужно, все ячейки живые.
Вход: где мы
Сначала спросим у системы, кто мы и где находимся.
Система — Linux. Версия ядра может оказаться любой, на сервере курса — 4.19.0-gvisor или, у старых версий, 4.4.0: так представляется gVisor, о нём ниже. Мы — процесс номер 1, то есть первый и, как скоро выяснится, почти единственный процесс в своём мире. Пользователь номер 65 534 — это nobody, «никто», самый бесправный пользователь в системе. А в корне лежат каталоги, знакомые любому, кто заглядывал в Linux или macOS: bin — программы, etc — настройки, tmp — временные файлы, proc — окно в ядро, к нему мы ещё придём. Есть и наш: data — данные курса, землетрясения и «Война и мир» из прошлых глав.
Зал первый: ядро
Ваша программа не может сама нарисовать букву на экране, прочитать байт с диска или отправить пакет в сеть. Не из вежливости — ей это физически запрещено. У процессора есть два режима. В пользовательском режиме работают обычные программы: им доступна только своя память, а команды, управляющие устройствами и памятью, запрещены — попытка выполнить такую команду кончается аварийной остановкой программы. В режиме ядра разрешено всё. В нём работает ядро — сердце операционной системы (не путать с ядрами процессора из прошлой главы), единственная программа, которая сама трогает железо.
Когда программе что-то нужно от мира, она просит ядро. Кладёт в регистры номер просьбы и её аргументы и выполняет особую команду процессора — на x86-64 она так и называется, syscall. Процессор переключается в режим ядра и прыгает в заранее назначенное ядром место — сама программа выбрать, куда прыгнуть, не может. Ядро проверяет, имеет ли программа право на эту просьбу, выполняет её, кладёт ответ в регистр и возвращает управление. Такая просьба называется системным вызовом. Через эту стену между режимами и протекла Meltdown из прошлой главы.
Сколько системных вызовов в самой короткой программе? Строка print("hi"), запущенная отдельным Python.
Сколько системных вызовов делает команда python3 -c 'print("hi")' от запуска до выхода?
351 в нашей записи (Python запускали с ключом -I, о нём — в шестом зале). Печать — только один из них, write(1, "hi\n", 3), почти в самом конце. Все остальные — подготовка: загрузить сам Python и его библиотеки, найти стандартную библиотеку, прочитать и скомпилировать модули, настроить сигналы — и выход.
Подслушать системные вызовы программы можно утилитой strace. В песочнице её нет, поэтому трассу мы записали заранее на такой же системе и положили в /data/os. Пройдитесь по ней.
print("hi"): 351 системный вызов, каждая чёрточка — один вызов, цвет — его род. Нажмите на чёрточку или ведите ползунок, чтобы прочесть строку трассы с пояснением; фильтр оставляет вызовы одного рода. Красной рамкой отмечен единственный вызов, ради которого всё затевалось.Трасса — обычный текстовый файл, по строке на вызов: имя, аргументы в скобках, после знака равенства — ответ ядра. Посчитаем её так же, как считали слова в главе 8.
Самый частый вызов — rt_sigaction: Python опрашивает, как настроен каждый из шестидесяти с лишним сигналов, и ставит свой обработчик на Ctrl+C, чтобы превращать его в KeyboardInterrupt. Дальше идут newfstatat и openat — «есть ли такой файл?» и «открой файл». В 43 случаях ядро отказало, но ничего страшного в этом нет. Чаще всего это ENOENT, «нет такого файла»: Python заглядывает во все места, где могла бы лежать стандартная библиотека и её модули, и находит не с первого раза. ENOTTY — ответ на вопрос «не терминал ли это?»: Python выясняет, куда подключён его вывод, чтобы решить, копить ли текст в буфере.
Есть в трассе и странность: пять вызовов write, а не один. Четыре пишут в файлы с расширением .pyc. Это байт-код из главы 33: Python скомпилировал модули encodings и linecache и сохранил результат на диск, чтобы в следующий раз не компилировать. В следующий раз — это сводка strace -c в той же папке, снятая на повторном запуске: кэш уже лежал на диске, и вызовов понадобилось 292, а write — один. Кэш из главы 34 работает и здесь, на уровне файлов.
Системный вызов можно сделать и самому, минуя print. Модуль os — тонкая обёртка над вызовами ядра: os.write(1, байты) делает тот же write, что мы видели в трассе.
Ядро ответило числом записанных байтов: 50, хотя символов в строке 30, — кириллица в UTF-8 занимает по два байта на букву, а длинное тире три, как мы видели в главе 28. Второй опыт показывает цену стены: даже простейший системный вызов в несколько раз дороже обычного вызова функции — на сервере курса, как сейчас выяснится, и того больше, — потому что процессор переключает режим, а ядро проверяет права. Поэтому программы стараются звать ядро пореже: print копит текст в буфере и отдаёт его одним write.
На сервере курса стена ещё толще. Между вашей программой и ядром сервера стоит gVisor — программа, которую Google открыла в 2018 году. Она перехватывает системные вызовы песочницы и выполняет их сама, а ядру сервера передаёт лишь малую их часть, тщательно проверенную. Получается ядро внутри ядра: даже если программа читателя найдёт ошибку в реализации какого-нибудь вызова, ошибка окажется в gVisor, и Linux сервера останется цел. У gVisor есть и свой режим, похожий на strace: он записывает все системные вызовы песочницы, но включает его только хозяин сервера.
Зал второй: процессы
Программа — это файл на диске, как /usr/local/bin/python3. А процесс — программа во время работы: её код, своя память, состояние регистров, открытые файлы, текущий каталог и номер, по которому ядро её знает, — PID. Одна программа может работать десятью процессами сразу, как десять открытых окон браузера.
В Unix всё есть файл, и процессы тоже. Каталога /proc на диске нет: это окно, через которое ядро показывает свои таблицы в виде файлов. Каждому процессу там отведён каталог с его номером. Придумал это Том Киллиан из Bell Labs, в восьмой редакции Unix, а в 1984 году описал в докладе «Процессы как файлы»; Linux завёл свой /proc в 1992-м.
В своём мире мы одни: виден только процесс номер 1. На ноутбуке в этом каталоге были бы сотни номеров, но песочница живёт в отдельном пространстве процессов, и чужих ей не показывают. Процесс 1 — это программа python3, запущенная с файлом /opt/cs/harness.py: служебная программа курса, которая принимает ваш код и выполняет его у себя внутри. Поэтому ваш код и «есть» процесс номер 1. Строка Threads говорит, что у процесса несколько потоков: служебные потоки пересылают ваш вывод на страницу. State: R — «работает»: процесс читает свой статус как раз тогда, когда работает. VmRSS — сколько памяти он сейчас занимает.
Как рождается процесс
Новые процессы в Unix рождаются делением. Вызов fork делает точную копию процесса: та же программа, та же память, те же открытые файлы, то же место в коде. Из fork возвращаются уже два процесса, и различаются они только ответом: родитель получает номер ребёнка, ребёнок — ноль. По этому ответу каждый понимает, кто он, и идёт своей дорогой. Чтобы ребёнок занялся другим делом, есть второй вызов — exec: он заменяет программу процесса другой программой, сохраняя номер и открытые файлы. Родитель тем временем ждёт ребёнка вызовом wait и узнаёт, чем тот закончил.
Число, с которым процесс заканчивается, — его код возврата. Договорились так: 0 — всё в порядке, любое другое число — что-то не так, а что именно, решает программа. Оболочка, о которой речь впереди, так и запускает каждую команду: fork, в ребёнке — exec, в родителе — wait. Разделение на два вызова выглядит странно — почему бы не сделать один «запусти программу»? Но именно в промежутке между ними ребёнок успевает подготовиться: сменить каталог, переподключить свои файлы, понизить себе права, — и всё это обычным кодом, без особых параметров.
Раз каждый процесс, кроме первого, рождён другим, процессы складываются в дерево. В схеме ниже песочница запускает команду и, пока та работает, читает /proc: кто чей родитель, в каком состоянии и куда подключены стандартные входы и выходы.
/proc на сервере курса. Выберите команду или впишите свою: песочница запустит её через sh, через полсекунды сделает снимок и остановит. Под каждым процессом — его открытые дескрипторы. Одинаковые буквы у дескрипторов разных процессов — два конца одного канала, о каналах будет четвёртый зал. Посчитайте, сколько процессов рождают два вызова fork подряд.Выберите конвейер sleep 2 | sort | uniq -c. Процесс 1, наша программа, родил оболочку sh, а она — по процессу на каждую команду. Все три работают одновременно и почти всё время спят — состояние S: sort ждёт, пока sleep что-нибудь напишет, а uniq ждёт sort. Спящий процесс не тратит процессор: ядро разбудит его, когда появятся данные. В этом часть ответа на вопрос из начала главы: из сотни процессов на компьютере работать в каждый момент хотят немногие, остальные чего-то ждут.
Зал третий: всё есть файл
С файлами процесс тоже работает через ядро. Он просит открыть файл по имени, и ядро возвращает маленькое число — номер в таблице открытых файлов этого процесса. Дальше программа говорит не «прочитай из /data/os/README.md», а «прочитай из номера 7». Этот номер называют файловым дескриптором. Таблица дескрипторов процесса тоже видна в /proc.
Первые три номера особые, и так договорились ещё в первых версиях Unix. Ноль — стандартный ввод, откуда программа читает, единица — стандартный вывод, куда пишет print, двойка — вывод ошибок, куда Python пишет трейсбек. Программа не знает и не должна знать, что к ним подключено: терминал, файл, другая программа. У нас все три ведут в pipe:[…] — каналы. Всё, что попадает в дескриптор 1, — скажем, из os.write или из запущенной команды, — уходит в трубу, а на другом её конце служебный поток песочницы упаковывает текст в сообщения и отправляет на эту страницу. Номера 3–6 — служебные копии и каналы той же песочницы, а файл, который мы открыли, получил первый свободный номер.
Подключить стандартный вывод к файлу или стандартный ввод к другому файлу — значит до запуска программы подменить, на что указывают номера 0, 1 и 2. Это умеет оболочка: команда > файл отправляет вывод в файл, команда < файл берёт ввод из файла, 2>&1 отправляет ошибки туда же, куда вывод. Делает она это в том промежутке между fork и exec: ребёнок переподключает свои дескрипторы, а потом превращается в нужную программу, которая ничего не подозревает.
«Всё есть файл» — не метафора. Файлами в Unix выглядят и устройства: /dev/null — чёрная дыра, которая проглатывает всё записанное, /dev/urandom — бесконечный источник случайных байтов. Файлами выглядят и таблицы ядра в /proc, и каналы между программами. Всё это читается одними и теми же read и пишется одними write, и поэтому одни и те же программы годятся для всего.
Зал четвёртый: трубы
Канал, или труба, — это буфер в ядре с двумя концами, у каждого свой дескриптор. Что один процесс пишет в один конец, другой читает из другого, в том же порядке: первым пришёл — первым ушёл. Это очередь из главы 15, и устроена она как кольцевой буфер оттуда же. Какого размера кольцо? Проверим: будем писать в трубу, никого не читая, пока ядро не скажет «больше некуда».
В трубу влезло 65 536 байтов — 64 КиБ, размер по умолчанию в Linux. Обычно писатель не получает отказа, как у нас: он засыпает, пока читатель не освободит место, и так же засыпает читатель, когда труба пуста. Ещё на двух правилах держатся все конвейеры. Когда все писатели закрыли свой конец, читатель, дочитав, получает пустой ответ — конец файла, — и понимает, что пора заканчивать. А когда закрыт конец для чтения, писателя, который пытается писать, ядро останавливает сигналом SIGPIPE: читать всё равно некому. Python этот сигнал гасит — помните SIG_IGN в трассе? — и превращает в исключение BrokenPipeError. Поиграйте с трубой сами.
SIGPIPE?Ни одна программа здесь не управляет другой. Писатель не знает, кто его читает, читатель — кто ему пишет, скорость у них сама выравнивается через сон, а конец работы сам передаётся по цепочке. Поэтому трубы так легко соединять: каждая программа знает только свои ввод и вывод.
Зал пятый: оболочка
Чтобы соединять программы трубами, нужен язык, на котором это удобно говорить. Это язык оболочки — командного интерпретатора, той программы, что отвечает в окне терминала. В Linux это обычно bash, в macOS — zsh, в нашей песочнице — sh, маленький и быстрый dash. Оболочка — обычная программа: читает строку, разбирает её, делает fork и exec для каждой команды, соединяет их трубами и ждёт. Терминала у песочницы нет, поэтому в курсе есть модуль cs.shell: функция sh передаёт строку оболочке и печатает её со знаком $, а под ней — всё, что напечатали команды.
Строк в трассе 352: 351 вызов и последняя строка о выходе. Последние две команды — наши первые конвейеры. cut -d'(' -f1 режет каждую строку по скобке и оставляет первый кусок — имя вызова. uniq -c склеивает одинаковые строки и пишет, сколько их было, но только соседние: в предпоследней команде вызовы идут вразнобой, и в первых строках каждый посчитан по единице. Поэтому перед uniq всегда стоит sort — он собирает одинаковые строки вместе. sort -rn сортирует по числу в начале строки, от большего к меньшему, head -5 оставляет пять первых строк. Получилось то же, что у Counter в первом зале, — пятью маленькими программами, ни одна из которых не знает про системные вызовы.
Такую цепочку называют конвейером команд, а программы, которые читают стандартный ввод, что-то делают с данными и пишут результат в стандартный вывод, — фильтрами. Слово появилось сразу после труб: оказалось, что почти любую утилиту стоит научить читать стандартный ввод, если ей не дали файла. А вот правило про конец работы в действии.
yes печатает букву y бесконечно, head -3 берёт три строки и заканчивает. Писать yes больше некому, ядро присылает ему SIGPIPE, и бесконечная программа заканчивается сама. Bash хранит коды возврата всех звеньев: у head — 0, у yes — 141, то есть 128 плюс 13, номер сигнала SIGPIPE. Так по коду возврата узнают, что процесс убит сигналом и каким.
Шесть строк против десяти страниц
В 1986 году Джон Бентли, который вёл в журнале Communications of the ACM колонку «Жемчужины программирования», попросил Дональда Кнута показать на примере своё грамотное программирование — способ писать программу как книгу. Задача: найти в тексте $k$ самых частых слов. Кнут написал на десяток с лишним страниц прекрасно объяснённую программу на Паскале с хитрой структурой данных. Рецензию Бентли заказал Макилрою. Тот похвалил изложение, а потом показал решение из шести команд:
Попробуем его на «Войне и мире». Буквы в нём, правда, русские, и первая попытка заменить диапазон A-Za-z на А-Яа-я кончается так:
Первая команда печатает мусор. tr родом из 1970-х и работает с байтами, а русская буква в UTF-8 — два байта, как мы выяснили в главе 28. tr режет буквы пополам и меняет половинки. Вторая команда делает то же самое средствами, которые понимают кодировку: grep -oE '[[:alpha:]]+' печатает каждое слово из букв отдельной строкой, sed 's/.*/\L&/' переводит строку в нижний регистр. Шесть программ, около секунды — и готов частотный словарь романа, который в главе 8 мы строили словарём Python.
Рецензию Макилрой закончил жёстко: Кнут показал, как программировать понятно, но не мудро, и сделал что-то вроде яйца Фаберже — тонкая, восхитительная работа, музейная вещь с самого начала. Мудрое инженерное решение, по его словам, собирается из готовых деталей, а простой конвейер даёт ответ сейчас, а не через неделю или месяц.
В Python у труб тоже есть родственник: генераторы из главы 10. Цепочка генераторов передаёт данные по одному элементу, как труба, и ни одно звено не держит в памяти весь поток. А sort — звено, которое обязано дочитать всё до конца, прежде чем выдать первую строку: отсортировать, не видя всего, нельзя. Повторить sort | uniq -c на Python — первая задача главы. А пока — терминал. Он запускает каждую команду в свежей песочнице, так что файлы, созданные в /tmp, до следующей команды не доживают; цепочки пишите через ; или &&.
sh. Стрелки ↑ и ↓ листают историю, cd запоминается. Подсказка: ls /proc/self/, cat /proc/self/status, env, ls -l /.Зал шестой: кто мы здесь
Ядро не только выполняет просьбы, но и решает, кому что можно. В Unix у каждого процесса есть пользователь, а у каждого файла — хозяин, группа и девять битов прав. Посмотрим на них.
Строку -rw-r--r-- читают тройками. Первый символ — тип: - — обычный файл, d — каталог. Дальше три тройки прав доступа: для хозяина, для его группы и для всех остальных; в каждой — r (читать), w (писать) и x (исполнять, а для каталога — входить в него). /etc/passwd принадлежит root, читать его могут все, писать — только хозяин. Права — это биты, и chmod 600 записывает их восьмеричным числом: 6 = 110 — чтение и запись хозяину, 0 — ничего остальным. После chmod 000 даже хозяин не может прочитать собственный файл: ядро смотрит на биты прав, и поблажек хозяину нет.
Отказов было два, и разных. Дописать в /etc/passwd не дали права: файл чужой — «Permission denied». А создать /hello не дала другая стена: весь корень песочницы подключён только для чтения — «Read-only file system», — и тут не помогли бы никакие права. Даже пользователю root (номер 0), которому ядро разрешает почти всё, здесь писать было бы некуда. А мы даже не root, мы nobody. Писать можно только в /tmp, и у этого каталога в правах особая буква t на конце: в такой каталог пишут все, но удалить файл может только его хозяин. Ещё у нашего процесса отобраны все особые привилегии ядра и нет сети. Песочница, где читатель запускает какой угодно код, обязана быть такой: каждая стена здесь — системный вызов, который ядро отклонит.
Окружение
Ещё одно, что процесс получает от родителя при рождении, — переменные окружения: пары «имя = значение», которые настраивают программы, не меняя их кода. Самая нужная — PATH: список каталогов через двоеточие, где оболочка ищет программу, когда вы пишете ls без пути. HOME — домашний каталог. Окружение копируется в ребёнка при fork и сохраняется при exec, но это именно копия: что ребёнок поменял у себя, родитель не увидит.
Запись ИМЯ=значение команда кладёт переменную в окружение одной команды: ребёнок sh её видит, а у родителя её нет. Так же в главе 16 мы выключали соль хешей переменной PYTHONHASHSEED: с ней два разных запуска Python дают один и тот же хеш. А последняя строка возвращает нас к файлам .pyc из первого зала. Песочница задаёт переменную PYTHONDONTWRITEBYTECODE, которая запрещает Python сохранять кэш байт-кода, — писать на диск всё равно некуда. Её слушается каждый Python, запущенный из оболочки. Но с ключом -I Python на переменные окружения не смотрит. С этим ключом запущен процесс 1 — вспомните его командную строку в зале процессов, — и ему сохранить кэш не дают уже права и корень, подключённый только для чтения. С этим же ключом мы снимали трассу на обычной системе, и там Python сохранил четыре файла .pyc.
На выходе: философия Unix
Всё, что мы видели в залах, построено по нескольким правилам, и записал их тот же Макилрой. В 1978 году в предисловии к специальному выпуску Bell System Technical Journal, посвящённому Unix, он и его соавторы сформулировали стиль, который сложился у создателей системы. Первое правило: пусть каждая программа делает одно дело хорошо, а для нового дела пишите новую программу, вместо того чтобы усложнять старые. Второе: рассчитывайте, что вывод вашей программы станет вводом для другой, ещё неизвестной. В 1994 году историк Unix Питер Салус пересказал это тремя фразами, которые цитируют чаще оригинала: пишите программы, которые делают одно дело и делают его хорошо; пишите программы, которые работают вместе; пишите программы, которые работают с текстовыми потоками, потому что это универсальный интерфейс.
Эти правила видны и в самой песочнице курса. Ваша ячейка печатает текст. Служебная часть песочницы делает одно дело — превращает его в события, по одному JSON-объекту на строку, и пишет их в трубу. Сервер делает другое — передаёт эти строки странице по мере появления, а страница — третье: рисует их под ячейкой. Ни одна часть не знает, как устроены остальные, и каждую можно заменить. Тот же конвейер, только трубы в нём тянутся через сеть.
У философии есть и цена, мы её тоже видели. Текстовый поток — интерфейс универсальный, но нестрогий: tr резал буквы пополам, потому что «текст» для него — байты, а имя файла с пробелом ломает половину однострочников. Поэтому рядом с трубами живут и программы на Python, и форматы вроде JSON. Но привычка разбивать задачу на маленькие части с простыми соединениями пригодится вам в любом языке.
Задачи
Три задачи: повторить конвейер на Python, сосчитать файл так же придирчиво, как wc, и написать несколько однострочников. Тесты сверяют ваши ответы с утилитами самой песочницы.
Напишите две функции. uniq_c(lines) делает то же, что uniq -c: склеивает подряд идущие одинаковые строки и для каждой группы возвращает строку вида f"{count:7d} {line}" — число шириной семь знаков, пробел, строка. sort_uniq_c(lines) делает то же, что sort | uniq -c с порядком байтов (LC_ALL=C): для строк Python это обычный sorted. На вход — список строк без символов перевода строки, на выход — список строк. Тесты сравнивают ответы с выводом sort и uniq песочницы, в том числе на двухстах тысячах строк.
Заготовка считает словарём все одинаковые строки, где бы они ни стояли. А uniq видит только соседей: на ["a", "b", "a"] он выдаст три группы по одной строке. Идите по списку и сравнивайте строку с предыдущей.
Не забудьте последнюю группу: когда список кончился, она ещё не выдана. И пустой список — пустой ответ.
sort_uniq_c — это uniq_c от отсортированного списка, так же как в конвейере.
Словарь считал бы быстрее и «правильнее», но это была бы другая программа: uniq нарочно знает только про соседей. Зато он не держит в памяти ничего, кроме одной предыдущей строки, и может обработать поток любой длины — вся работа по сближению одинаковых строк отдана sort. Каждая программа делает одно дело. Порядок у sorted совпадает с LC_ALL=C sort, потому что UTF-8 устроен так, что сравнение байтов даёт тот же порядок, что сравнение кодов символов; поэтому «ё» оказывается после «я». В обычной русской локали sort поставил бы её иначе.
Утилита wc печатает для файла три числа: строки, слова и байты. Напишите wc(path), который возвращает кортеж (строки, слова, байты) в точности как утилита wc. Правила у него придирчивые: строки — это число символов перевода строки \n; слово — непрерывная цепочка непробельных символов, а пробельные — это пробел, табуляция, переводы строки и возврат каретки (других, вроде неразрывного пробела, в тестах нет); байты — именно байты, а не символы. Тесты создают файлы с подвохами в /tmp и сверяют ответ с wc, а ещё считают «Войну и мир» — пять мегабайтов — с ограничением по времени.
Запустите тесты и найдите, на чём падает заготовка. Файл hello без перевода строки в конце для wc — ноль строк, а splitlines насчитает одну.
Байты считаются в байтах: «привет» — шесть символов, но двенадцать байтов. Откройте файл в двоичном режиме, open(path, "rb"), и работайте с bytes: у них тоже есть count и split.
splitlines считает переводом строки не только \n, но и \r и ещё несколько символов. Считайте только \n.
Три подвоха и три ответа. Строки wc считает по символам \n, поэтому последняя строка без перевода не считается — так записан стандарт POSIX: строка — это то, что кончается переводом строки. Байты — это размер файла, и кириллица весит вдвое больше латиницы. А bytes.split() без аргументов режет по тем же пробельным символам ASCII, что и wc, и не путается в кодировке. Пробелы Юникода, вроде неразрывного, wc в UTF-8 тоже считает разделителями, а bytes.split() — нет; в тестах их нет. Файл читается целиком, этого хватает на пять мегабайтов; для гигабайтных файлов читают кусками, и тогда надо следить за словом, разрезанным границей куска.
Заполните словарь COMMANDS: для каждой задачи — команда оболочки, которая читает данные со стандартного ввода и печатает ответ. Тесты запускают каждую команду через sh на нескольких разных входах и сравнивают вывод. Задачи: "errors" — напечатать строки, в которых встречается ERROR (даже внутри другого слова, как в ERRORS); "count" — напечатать число строк; "second" — напечатать второе поле каждой строки, где поля разделены запятыми; "unique" — напечатать разные строки по одному разу в порядке sort; "top" — напечатать самую частую строку в формате uniq -c (в тестах она всегда одна).
Нужные программы встречались в главе: wc -l считает строки, cut -d, -f2 вырезает второе поле по запятой, sort -u сортирует и убирает повторы.
Самая частая строка — это конвейер Макилроя без первых двух звеньев и с head -1 вместо последнего.
Каждая команда — фильтр: читает поток, пишет поток и ничего не знает о том, откуда он пришёл. Поэтому тесты могут подать на вход что угодно, а вы — проверить команду на своём примере функцией sh с аргументом input. Кстати, wc -l без имени файла печатает одно число, а с именем — ещё и имя: программа подстраивает вывод под то, как её запустили.
Куда дальше
Экскурсия окончена, а один вопрос из начала главы так и остался открытым. Спящие процессы процессор не тратят, но как быть с теми, что хотят работать? В конвейере sort | uniq -c на большом файле работают оба звена. На ноутбуке в эту минуту хотят работать браузер, музыка и компилятор, а ядер, допустим, два. В прошлой главе ядро системы раз за разом останавливало наши процессы и потом пускало их снова. По какому правилу оно выбирает, кого остановить и кого пустить? Процессов сто, ядер два. Кто следующий? Об этом — следующая глава, и начнётся она 20 июля 1969 года, за несколько минут до посадки на Луну, когда бортовой компьютер «Аполлона-11» оказался перегружен.