OS·V Операционная система Глава 37 из 65

Центр управления полётом

20 июля 1969 года бортовой компьютер «Орла» на спуске к Луне пять раз поднимал тревогу «перегружен» — и каждый раз сам решал, что бросить, чтобы посадка продолжилась. Здесь его работу делаете вы: делите один процессор между задачами, сравниваете правила очереди на диаграммах, сажаете модуль с перегрузкой и пишете свою многозадачность — на генераторах и на asyncio.

Университет 55 минут Операционные системы История
OS·V

Операционная система

  1. 36 ОС
  2. 37 Планировщик вы здесь
  3. 38 Виртуальная память
  4. 39 Конкурентность
  5. 40 Хранение

Опирается на: 36 · Экскурсия по живой системе 18 · Кто следующий

Что вы унесёте из главы

  • как операционная система делит процессор: кванты, прерывание по таймеру, переключение контекста и его цена
  • сравнивать правила очереди — по приходу, кратчайшую первой, круговую, приоритеты, многоуровневую — по ожиданию и отклику и понимать, откуда берётся голодание
  • писать кооперативные программы на генераторах и asyncio и не замораживать их одним блокирующим вызовом

Прошлая глава закончилась вопросом: процессов сто, ядер два — кто следующий? Откройте диспетчер задач на своём компьютере. Процессов там несколько сотен, ядер — четыре, восемь, может быть, шестнадцать. Почти все процессы спят: ждут письма, нажатия клавиши, ответа сервера. Но десяток-другой готов работать в любую секунду, и каждый ведёт себя так, будто процессор принадлежит ему одному. Кто-то каждые несколько миллисекунд решает, кому из них достанется ядро. В операционной системе этого кого-то зовут планировщиком.

На ближайший час планировщик — вы, и смена у вас в центре управления полётом. Пульт достаётся от самого знаменитого перегруженного компьютера в истории, бортового компьютера лунного модуля «Аполлона-11». Сначала услышим его тревогу. Потом научимся тому, что умел он, — делить процессор и успевать к сроку, — разберём тревогу и посадим модуль сами. Под конец напишем свою многозадачность, ту же, что крутится в каждом веб-сервере на Python.

102:38. Программная тревога

Код 1202 значил: компьютеру дали больше работы, чем он успевает сделать. Звучит как приговор: корабль ведёт машина, которая не успевает. Но компьютер сам выбрал, какую часть работы бросить, бросил её и продолжил вести модуль к Луне. Как программа решает «это сейчас неважно», станет ясно, когда мы увидим, как один процессор вообще делится между многими делами.

Пульт: одно ядро, много дел

Ядро процессора в каждый момент выполняет одну команду одной программы — так устроена «Искра-8» из главы 32, и так же устроено каждое ядро вашего ноутбука. Программ же много. Операционная система держит каждую в одном из трёх состояний. Работает — её команды прямо сейчас выполняет ядро. Готова — могла бы работать, но все ядра заняты, и она стоит в очереди. Ждёт — процессор ей сейчас ни к чему: она ждёт диска, сети, нажатия клавиши или пока пройдёт заданное время. Из сотен процессов почти все ждут. Готовых обычно единицы, но их всё равно бывает больше, чем ядер, и каждый раз, когда ядро освобождается, кто-то выбирает из очереди готовых следующего. Выбирает часть ядра операционной системы — планировщик.

Почувствуйте его на себе. Программа ниже крутится в пустом цикле и всё время смотрит на часы. Если между двумя взглядами прошло больше миллисекунды, значит, её не было: ядро отдали кому-то другому. Сначала крутимся одни, потом запускаем одного, двух и трёх соперников — таких же пустых циклов в отдельных процессах. Функция os.sched_setaffinity привязывает программу к ядру номер 0, и соперники, которых она запускает, наследуют эту привязку: всем приходится делить одно ядро.

Функция cpu_seconds читает запись о процессе в /proc, знакомую по прошлой главе: сколько тиков процессорного времени он получил. Сравнив, кто сколько получил за одни и те же шесть десятых секунды, видим, как планировщик делит ядро: с одним соперником нам достаётся около половины, с двумя — около трети, с тремя — около четверти. Почти поровну. Дыры длятся от миллисекунд до десятых долей секунды, смотря по тому, насколько занята машина (песочница на сервере курса делит процессор ещё и с другими читателями), и числа меняются от запуска к запуску. Картина при этом держится: дыр десятки за доли секунды, и никто не владеет ядром подолгу.

Странно здесь другое. Пустой цикл while True: pass никого ни о чём не просит: он не читает файлов, не печатает, не вызывает систему, и всё же ядро у него отбирают. Помогает железо. В процессоре есть таймер, и каждые несколько миллисекунд он посылает прерывание. По этому сигналу процессор бросает очередную команду программы, запоминает, где остановился, — как при вызове подпрограммы в главе 32, — и прыгает в обработчик внутри ядра операционной системы. Обработчик зовёт планировщик, а тот решает: вернуть ядро той же программе или отдать другой. Отрезок времени, который программе дают без перерыва, называют квантом, а отнятие ядра у программы, которая и не думала его отдавать, — вытеснением. Прерывания шлют и устройства: клавиатура — когда нажали клавишу, сетевая карта — когда пришёл пакет. Поэтому спящая программа просыпается сразу, как только для неё что-то случилось.

Планировщик — не отдельная программа, которая «следит» за остальными. Он просыпается только по прерыванию или когда программа сама входит в ядро — читает файл, ждёт сети, засыпает. Всё остальное время ядро процессора выполняет чужие команды, и планировщика как будто нет.

Кембридж, 1961. Разделение времени

Так появилось разделение времени: процессор нарезан на кванты, и кванты раздаются по очереди. Если квантов в секунду много, а каждый короткий, человек за терминалом не замечает, что машина занята кем-то ещё. Ваш ноутбук делает то же самое, только пользователь у него один, а программ — сотни.

Каждая смена программы на ядре — это переключение контекста. Программа ничего не должна заметить: когда она снова получит ядро, все регистры должны быть такими же, какими были в момент прерывания. Поэтому ядро операционной системы сохраняет всё, что процессор знает о прерванной программе, в запись о процессе, а из записи следующего процесса загружает его состояние. У «Искры-8» контекст — семь байт: регистры R0–R3, счётчик команд, указатель стека и флаги. У процессора x86-64 — шестнадцать регистров общего назначения, счётчик команд, флаги и широкие регистры векторных команд из главы 35, вместе до нескольких килобайт. Сколько это стоит по времени, проще измерить. Программа на C запускает второй процесс, и они перебрасываются одним байтом через пару каналов, как мячиком: каждый ждёт, пока байт придёт к нему, и сразу отправляет обратно. Оба процесса привязаны к одному ядру, так что каждая подача — это переключение.

fork и pipe — из прошлой главы. Получается около микросекунды на переключение — тысячи тактов процессора, и сюда вошли ещё запись и чтение байта. В песочнице на сервере курса число выйдет больше: там между программой и ядром машины стоит дополнительная защитная прослойка, и каждый вход в ядро дороже. Само по себе это немного: при кванте в несколько миллисекунд на переключения уходит доля процента. Дороже то, чего ячейка не показывает. Новая программа приходит к остывшим кэшам (глава 34): там лежат данные предыдущей, и первые тысячи обращений идут в медленную память. Поэтому квант нельзя делать сколь угодно коротким: переключаясь слишком часто, процессор будет заниматься в основном переездами.

Кто следующий: правила очереди

Главный вопрос пульта: ядро освободилось — кого из готовых позвать? Правил много, и чтобы их сравнивать, нужны мерки. Пусть задача пришла в момент $a$, требует $b$ миллисекунд процессора, впервые получила ядро в момент $s$ и закончила в момент $f$. Тогда:

  • время оборота $f - a$ — сколько прошло от прихода задачи до готового результата;
  • время ожидания $f - a - b$ — сколько из этого задача стояла в очереди, хотя могла бы работать;
  • время отклика $s - a$ — через сколько задача впервые получила процессор.

Кто запустил расчёт на ночь, смотрит на время оборота. Человек за клавиатурой чувствует отклик: если буква появляется на экране через десятую долю секунды после нажатия, это ещё «сразу», а через секунду — уже «тормозит». Возьмём четыре задачи и раздадим им ядро двумя способами: по приходу и по кругу, квантами по 2 мс.

По приходу «отчёт» занимает ядро на восемь миллисекунд, и три короткие задачи ждут его все вместе: среднее ожидание 5,75 мс, и отклик такой же — ведь каждая задача, раз начав, работает до конца. По кругу отчёт режется на куски, «почта» и «поиск» проскакивают между ними, и средний отклик падает до 1,75 мс. Зато сам отчёт теперь готов не в 8, а в 15: за удобство коротких платит длинная. Расписание удобно рисовать полосками на оси времени, как прорабы рисуют графики работ. Такую картинку называют диаграммой Ганта, по имени инженера Генри Ганта, который рисовал так загрузку цехов в 1910-х годах. Ниже она построена для всех правил главы сразу.

Диаграмма Ганта. Выберите правило и набор задач: полоски — кто когда работал, под ними — ожидание и отклик каждой задачи и средние. Задачи можно менять: нажмите на строку и двигайте приход, длину и приоритет. «Цена переключения» добавляет серую щель на каждую смену задачи; самое интересное с ней происходит в круговой очереди при маленьком кванте.

По приходу

Первое правило — то, с которого начинают все очереди мира: кто раньше пришёл, тот раньше работает, и работает до конца. Его называют FIFO (first in, first out) или FCFS (first come, first served). Ему не нужно ничего знать о задачах, и спорить с ним трудно. А беда его известна каждому, кто стоял в кассе супермаркета с одной бутылкой воды за человеком с полной тележкой: одна длинная задача впереди, и все короткие за ней ждут, пока она кончится. Это называют эффектом конвоя: колонна идёт со скоростью самого медленного грузовика. Выберите в диаграмме набор «конвой» и правило «по приходу»: шесть коротких задач по одной-две миллисекунды стоят за одной длинной на 24, и среднее ожидание больше двадцати миллисекунд, хотя почти все задачи — мелочь.

Кратчайшая первой

Раз короткие страдают за длинными, пустим короткие вперёд. Правило «кратчайшая первой» (SJF, shortest job first) из всех готовых задач зовёт ту, которой осталось меньше всего работы. В наборе «конвой» оно мгновенно убирает очередь: шесть коротких проскакивают, длинная ждёт лишние восемь миллисекунд, и никто не замечает. Можно доказать, что лучше по среднему ожиданию не бывает.

Пусть $n$ задач пришли одновременно и каждая работает без перерыва. Порядок по возрастанию длины даёт наименьшее среднее время ожидания среди всех порядков.

Возьмём любой порядок, в котором где-то подряд стоят длинная задача $L$ и за ней короткая $S$, причём $L > S$. Поменяем их местами. Задачи до этой пары не заметят ничего. Задачи после пары тоже: они ждут конца обеих, а он не сдвинулся. Изменилось ожидание только у двух: $S$ теперь ждёт на $L$ меньше, а $L$ ждёт на $S$ больше. Суммарное ожидание уменьшилось на $L - S > 0$. Значит, в наилучшем порядке нет ни одной соседней пары «длинная перед короткой» — нет соседних инверсий, а последовательность без соседних инверсий отсортирована.

Это тот же аргумент обмена, что доказывал жадный выбор заявок в главе 23. Им же видно, почему порядок так важен: в сумме ожиданий длина первой задачи учитывается $n - 1$ раз — её ждут все остальные, — длина второй $n - 2$ раза, и так далее, а последнюю не ждёт никто. Длинные надо ставить туда, где их ждут меньше.

Если задача может прийти, пока работает другая, у правила есть вытесняющий вариант: пришла задача короче, чем остаток текущей, — текущую прерываем. Его называют SRTF (shortest remaining time first), в диаграмме он тоже есть. Но оба варианта упираются в один вопрос: откуда планировщику знать, сколько задаче осталось работать? Программа этого не сообщает и сама обычно не знает. Выход тот же, что у предсказателя переходов из главы 35: судить о будущем по прошлому. Программы, как правило, работают порциями: посчитали, обратились к диску или сети, подождали, снова посчитали. Длину следующей порции предсказывают по прошлым — например, средним, в котором свежие порции весят больше старых.

Каждый новый прогноз — половина последнего наблюдения плюс половина прежнего прогноза. Вес наблюдения, сделанного $k$ порций назад, — $2^{-(k+1)}$: прошлое забывается по показательному закону, поэтому такое среднее называют экспоненциальным. Когда программа сменила привычки — редактор начал проверять орфографию, и порции выросли с 2 до 13 мс, — прогноз догоняет её за две-три порции. Параметр $\alpha$ решает, кому верить больше: при $\alpha$ ближе к единице прогноз дёрганый, но быстрый, ближе к нулю — спокойный, но медленный.

По кругу

Кратчайшая первой хороша для ожидания, но не для отклика: длинная задача может не получить ядро ни разу, пока идут короткие. Разделение времени устроено иначе — по кругу (round robin): каждая готовая задача получает квант, и если не успела, встаёт в хвост очереди. Никто не ждёт дольше, чем $(n - 1)$ квантов, где $n$ — число готовых. Отклик отличный, а время оборота у длинных задач хуже: их работу размазали по всему расписанию.

У круговой очереди одна настройка — длина кванта. Подвигайте ползунок кванта в диаграмме с включённой ценой переключения. Квант длиннее любой задачи превращает круг в FIFO: каждый успевает закончить за один подход. При очень коротком кванте ядро делится идеально поровну, но расписание покрывается серыми щелями, и процессор больше переключается, чем работает. В рабочих системах квант — от единиц до десятков миллисекунд: намного длиннее переключения и намного короче, чем человек замечает задержку.

Приоритеты и голодание

Не все задачи равны. Музыка должна играть без заиканий, интерфейс — откликаться, а резервное копирование может и подождать. Поэтому у задач бывают приоритеты: планировщик зовёт самую важную из готовых, а среди равных — по кругу. В Unix приоритет задают командой nice: чем больше число «вежливости» от −20 до 19, тем охотнее программа уступает другим. Но вы уже знаете, чем это кончается под нагрузкой: в приёмном покое из главы 18 при правиле «по тяжести» несрочного больного не звали, пока приходили срочные, и он ждал врача часами. Это то же голодание. Есть легенда, которую пересказывают учебники операционных систем: когда в 1973 году выключали IBM 7094 в MIT, нашли задание с низким приоритетом, поставленное в очередь в 1967 году и так ни разу и не запущенное. Может быть, это выдумка, но механизм описан верно.

Лекарство тоже знакомо по главе 18. Там правило «по сроку» ставило несрочного больного впереди только что пришедшего более срочного, если тот ждал слишком долго. В планировщиках это называют старением: пока задача ждёт, её приоритет понемногу растёт, и рано или поздно она обгоняет всех. В диаграмме у правила «приоритеты» есть флажок «старение». Включите его на наборе «голодание», и задача с низшим приоритетом получит ядро, не дожидаясь, пока кончатся срочные.

Очередь, которая учится

Корбато хотел от CTSS двух вещей сразу: чтобы человек за терминалом получал отклик мгновенно и чтобы долгие расчёты всё-таки шли вперёд. Знать заранее, какая задача интерактивная, а какая считает, планировщик не может, зато может наблюдать. Так появилась многоуровневая очередь с обратной связью (MLFQ, multilevel feedback queue) — главное изобретение CTSS. Корбато описал её в статье 1962 года, а современные учебники излагают её пятью правилами; в CTSS кое-что было устроено иначе, но мысль та же.

  1. Очередей несколько, у каждой свой приоритет. Работает задача из самой высокой непустой очереди; внутри очереди — по кругу.
  2. Новая задача начинает с самой верхней очереди: пока она ничем себя не показала, будем считать её интерактивной.
  3. Если задача истратила весь квант своего уровня, она опускается на уровень ниже. Внизу кванты длиннее: раз уж задача любит считать, пусть считает подольше и пореже.
  4. Если задача отдала ядро сама, раньше конца кванта, — ушла ждать клавишу или диск, — она остаётся на своём уровне.
  5. Время от времени все задачи поднимаются наверх. Иначе длинные внизу будут голодать, а программа, которая сменила характер — посчитала и стала интерактивной, — навсегда останется внизу.

Очередь сама разбирается, кто есть кто. Редактор, который просыпается на нажатие клавиши, работает микросекунды и снова засыпает, никогда не дотягивает до конца кванта и живёт наверху: его зовут сразу. Расчёт за пару квантов опускается вниз и получает ядро, когда интерактивным ничего не нужно. Так было и в CTSS: чем ниже уровень, тем длиннее квант. В диаграмме у правила MLFQ три уровня с квантами 1, 2 и 4 мс; цвет полоски показывает уровень, на котором задача работала.

У правила 4 есть лазейка, и найти её нетрудно. Программа, которая хочет жить наверху, может работать почти весь квант, а за миг до его конца на мгновение уйти в ожидание — например, прочитать байт из файла. Формально квант не истрачен, уровень сохраняется, и программа, которая на деле только считает, получает приоритет интерактивной. Лекарство — считать всё время, истраченное на уровне: кто израсходовал свою норму, хоть по кусочку, тот опускается. Современные системы ушли от строгих уровней, но мысль осталась. Windows временно повышает приоритет потоку, который дождался клавиатуры или диска. Linux с 2007 по 2023 год использовал планировщик CFS: он звал задачу, которая получила меньше всех процессорного времени с поправкой на её вес, и держал задачи в сбалансированном дереве поиска — родственнике АВЛ-деревьев. С версии 6.6 его сменил планировщик EEVDF, который учитывает ещё и то, насколько срочно задаче нужен отклик.

Разбор тревоги 1202

Слов у нас уже хватит, чтобы понять, что случилось на борту «Орла». Операционную систему его компьютера придумал Хэл Лэнинг из Приборной лаборатории MIT, а программу посадки писала большая команда той же лаборатории; разработкой бортовых программ руководила Маргарет Гамильтон. Система делила работу на два сорта. Короткие задания по часам (tasks) — например, «через две секунды снова прочитать акселерометры» — держал список ожидания Waitlist, и запускало их прерывание таймера, вытесняя всё остальное. Длинные работы (jobs) — навигацию, наведение, вывод чисел на табло — запускал диспетчер Executive, и у каждой работы был приоритет: процессор получала готовая работа с самым высоким.

Главная работа спуска называлась SERVICER. Раз в две секунды задание по часам читало акселерометры и ставило в очередь очередной SERVICER: вычислить, где модуль и куда летит, решить, как направить двигатель и какую тягу дать, и обновить числа на табло. Работа это была самая длинная, и приоритет ей дали самый низкий: короткие работы вклинивались в неё, и SERVICER получал то время, что оставалось после них. Каждой работе на время жизни нужен был свой уголок памяти, а память у компьютера была крошечная — 2048 слов, которые можно перезаписывать. Поэтому Лэнинг заранее нарезал её: восемь наборов ячеек для работ (core sets) и пять областей побольше для векторных вычислений (VAC areas). Пришла работа — заняла набор; кончилась — освободила. Если свободных наборов нет, Executive поднимает тревогу 1202, если нет свободной векторной области — 1201.

Обычно наборов хватало с запасом: работа успевала закончиться задолго до прихода следующей. Программисты считали, сколько процессорного времени остаётся свободным на каждом участке спуска. По воспоминаниям Дона Айлза, писавшего программы посадки, до того как посадочный радар поймал поверхность, запас был больше 15 %, после — около 13 %, а если экипаж выводил на табло дополнительные данные, как Олдрин свой режим 16/68, запас падал до 10 % и меньше. И тут кто-то начал воровать такты. Счётчики углов антенны радара сближения, который смотрел вверх, на командный модуль, были устроены так: каждое изменение угла присылало импульс, и процессор, не спрашивая программу, тратил такт памяти на то, чтобы прибавить или отнять единицу в ячейке. Импульсов на каждый из углов приходило до 6400 в секунду, и в худшем случае вместе они съедали около 13 % времени процессора. Запас стал отрицательным.

Дальше всё по законам очереди. SERVICER получает процессор последним и не успевает закончить, а через две секунды задание по часам ставит в очередь следующий. Недоделанный SERVICER не отдаёт ни свой набор ячеек, ни векторную область, и новый получает свои. Новый тоже не успевает. Недоделки копятся, наборы и области кончаются. Executive вызывает аварийный выход, ставит код 1202 или 1201 и перезапускает программу. Перезапуск устроен хитро. Важные вычисления по ходу дела отмечали свои контрольные точки, и после перезапуска каждое продолжалось с последней отметки. Неважные, которых не жалко, исчезали без следа. Остался самый свежий SERVICER, а исчезли его недоделанные предшественники и режим 16/68: после двух первых тревог табло само вернулось к обычным данным спуска. Нагрузка упала, и наведение продолжалось. Айлз позже писал, что у них получилась система управления в реальном времени, которая при определённых условиях была отказоустойчивой.

За пульт садитесь вы. Модель ниже упрощённая: числа нагрузки взяты по воспоминаниям Айлза и округлены, а спуск сжат в минуту с небольшим. Включите радар сближения и режим 16/68 и попробуйте посадить модуль с каждым из правил. В режиме «вручную» при каждой тревоге модель останавливается и ждёт, что бросите вы.

Посадка с перегрузкой. Сверху — табло: время полёта, высота, лампа программной тревоги. Полоса загрузки показывает, на что уходит процессор: кража тактов радаром, задания по часам, навигация SERVICER, табло. Восемь клеток — наборы ячеек для работ. Перезапуск, как на борту, выключает режим 16/68. Если навигация отстаёт больше чем на шесть секунд — пропущено два цикла подряд, — посадку прерывают. В режиме «вручную» нажмите на работы, которые хотите бросить, и продолжите.

«По приоритетам» компьютер встаёт первым: SERVICER с самым низким приоритетом не успевает, его недоделанные копии занимают векторные области, и при первой же тревоге новую работу некуда поставить, а бросать лишнее компьютер не умеет. «По приходу» держится дольше, но навигация стоит в общей очереди за выводом на табло, отстаёт всё больше, и модуль уходит на прерывание посадки. Сажает модуль только третье правило — приоритеты вместе с перезапуском, который оставляет свежий SERVICER, а недоделки и режим 16/68 выбрасывает. Так и был устроен компьютер «Орла». Включите 16/68 снова, как Олдрин, — и тревога вернётся. А в ручном режиме видно, что бросать: старые копии SERVICER своё наведение уже выдали, а пожалеете их — новому SERVICER не достанется места, и модуль ослепнет.

Перегрузка случается со всякой системой, у которой бывают пики: с сервером в чёрную пятницу, с телефонной сетью в новогоднюю ночь, с компьютером на спуске. Выдерживает её та система, которая заранее знает, что можно бросить, и в пик бросает это сама. Система, которая пытается сделать всё, не успевает ничего.

Реальное время: успеть к сроку

У компьютера «Орла» было требование, которого нет у ноутбука: ответ, полученный поздно, — неправильный ответ. Команду двигателю ориентации, вычисленную через полсекунды после нужного момента, лучше не выполнять вовсе. Такие системы называют системами реального времени. В жёстком реальном времени опоздание недопустимо никогда — тормоза, подушка безопасности, кардиостимулятор. В мягком оно неприятно, но терпимо — видеозвонок, у которого изредка пропадает кадр.

Задачи реального времени обычно периодические: каждые 4 мс подправить двигатель, каждые 10 мс пересчитать навигацию, каждые 14 мс обновить табло. Каждый экземпляр должен успеть до прихода следующего. Классических правил раздачи два. Первое — постоянные приоритеты по частоте: кто приходит чаще, тот важнее. Второе — по сроку: работает тот, у кого срок раньше. Второе правило вы уже встречали в приёмном покое главы 18 — там больные выбирались по моменту, когда истекает их норма ожидания. Его называют EDF (earliest deadline first). Проверим оба на трёх задачах: сначала при загрузке 91 %, потом при перегрузке в 105 %.

При загрузке 91 % правило по сроку успевает всё, а по частоте табло опаздывает 15 раз за две секунды. В 1973 году Ч. Л. Лю и Джеймс Лейланд доказали, что на одном ядре EDF успевает любой набор периодических задач, если их суммарная загрузка не больше 100 %, — лучше не может никакое правило. Для постоянных приоритетов гарантия скромнее: $n$ задач заведомо успевают, если загрузка не больше $n(2^{1/n} - 1)$. Для трёх задач это около 78 %, а с ростом $n$ граница спускается к $\ln 2 \approx 69\,\%$. Выше границы может повезти, а может и нет — нам не повезло.

Вторая строка переворачивает вывод. При перегрузке правило по частоте жертвует одним табло: двигатель и навигация не опоздали ни разу. А EDF опаздывает везде — двигатель 42 раза, навигация 28. Всякий экземпляр, у которого срок подходит, получает ядро первым, даже если всё равно не успеет, и тянет за собой следующих: опоздания идут волной, как падающие костяшки домино. Поэтому в системах, где перегрузка возможна, правило по сроку дополняют защитой, а важное отделяют от неважного заранее — постоянными приоритетами или, как в компьютере «Орла», списком того, что переживёт перезапуск. У постоянных приоритетов своя ловушка: из-за неё в 1997 году раз за разом перезагружался марсианский Pathfinder — задача низкого приоритета держала то, что было нужно задаче высокого. Это история главы 39.

Отдать управление самому

До сих пор планировщик отбирал ядро силой — прерыванием таймера. Есть и другой путь: программы отдают ядро сами, когда им удобно. Это кооперативная многозадачность. Работы компьютера «Орла» жили именно так: Executive не отнимал процессор посреди вычисления, работа сама регулярно проверяла, не ждёт ли кто-то важнее, и уступала. По тем же правилам жили Windows 3.1 и классическая Mac OS: программа получала управление, обрабатывала очередное событие — щелчок, нажатие клавиши — и возвращала управление системе. Пока все вежливы, это прекрасно: переключение дешёвое, и программу никогда не прерывают в неудобный момент — посередине обновления общей таблицы, например. Но одна программа, которая задумалась и не уступает, замораживает всё: курсор, окна, музыку.

Кооперативную многозадачность легко написать самим на генераторах из главы 10. Генератор умеет остановиться на yield и потом продолжить с того же места — готовая задача, которая отдаёт ядро: yield значит «я пока всё, зовите следующего». Диспетчер — очередь генераторов и цикл. Нажмите «Шаги» и проследите, как управление прыгает из генератора в диспетчер и обратно.

Девять строк диспетчера — и у нас круговая очередь, как в CTSS, только без таймера. Функцию, которая умеет много раз останавливаться и продолжаться с места остановки, называют сопрограммой, в отличие от подпрограммы, которая работает от начала до конца за один вызов. По свидетельству Дональда Кнута, слово придумал Мелвин Конвей в 1958 году. Испытаем вежливость. Рядом с курсором, который хочет мигать каждые несколько миллисекунд, работает архиватор и уступает то часто, то редко, то почти никогда.

Пока архиватор уступает раз в тысячу шагов, курсор не замечает соседа: десятые доли миллисекунды. Раз в сто тысяч шагов — замирания порядка десяти миллисекунд, уже на грани заметного. А если архиватор не уступает до конца работы, курсор стоит десятые доли секунды — столько, сколько идёт весь расчёт; будь архив побольше, стоял бы минуты. Так и выглядело «зависание» компьютера с кооперативной многозадачностью: виновата одна программа, а замирает всё. Сравните оба мира в виджете.

Кооперативная и вытесняющая многозадачность. Три задачи: музыка должна получать ядро каждые 10 мс, интерфейс — откликаться на нажатия, архиватор считает без конца. Полоса внизу — кто владел ядром. Сделайте архиватор невежливым и нажимайте «клавишу»: при кооперативной многозадачности отклик пропадает, при вытесняющей таймер возвращает ядро планировщику.

asyncio: ждать, не простаивая

Веб-сервер, чат-бот, программа, которая скачивает сотню страниц, почти всё время ждут: ответа сети, базы данных, диска. Процессор им нужен на миллисекунды, а ждут они секундами. Запускать по процессу на каждое ожидание расточительно. Разумнее одна программа с сотнями сопрограмм, которые уступают ядро каждый раз, когда начинают ждать. Внутри одной программы кооперативная многозадачность хороша: программа не враг сама себе, а переключение между сопрограммами стоит как вызов функции и обходится без входа в ядро ОС. В Python для этого есть модуль asyncio: он появился в версии 3.4 в 2014 году, а слова async и await — в версии 3.5. Сопрограмму объявляют как async def, а await пишут там, где она готова уступить, — обычно там, где она чего-то ждёт. Вместо нашего run работает цикл событий: он держит очередь готовых сопрограмм и список ждущих — кто ждёт времени, кто сети, — а когда готовых нет, засыпает до ближайшего события.

Песочница курса не пускает в сеть, поэтому ответ сервера изображает asyncio.sleep — для цикла событий разницы нет: и то и другое — ожидание. asyncio.gather запускает сопрограммы вместе и ждёт всех, а asyncio.run заводит цикл событий и крутит его, пока главная сопрограмма не закончится. Три запроса ушли разом, ответы пришли через 0,5, 1 и 1,5 секунды, и всё заняло полторы секунды, а не три. Тысяча секундных ожиданий заняла секунду с небольшим — на одном ядре, без единого потока. Так работают веб-серверы на Python и на JavaScript, где цикл событий встроен в среду выполнения Node.js: десятки тысяч соединений, почти все ждут, а ядро обслуживает тех, кому пришли данные.

Ограничение у этого мира то же, что у Windows 3.1: цикл событий не умеет отнимать ядро. Если сопрограмма вызвала что-то, что ждёт, не уступая, — time.sleep, обычный requests.get, долгий расчёт, — стоят все.

Одно слово в одной строке — и индикатор замирает на полсекунды. Это самая частая ошибка в асинхронном коде, и выглядит она именно как «сервер иногда подвисает»: все клиенты ждут, пока один обработчик спит или считает. Запомните одно правило: внутри async def всё, что ждёт, должно ждать через await. Для сети есть асинхронные библиотеки, для неизбежно блокирующего вызова — asyncio.to_thread: он отправляет вызов в отдельный поток, и цикл событий продолжает работать. Долгий расчёт лучше вынести в отдельный процесс, и тогда его будет вытеснять планировщик операционной системы.

Ещё один инструмент понадобится в задачах. Тысяча одновременных запросов к одному серверу для него неотличима от атаки, и вежливый клиент ограничивает себя сам. asyncio.Semaphore(3) — турникет на три места: async with gate пускает внутрь, только если там меньше трёх сопрограмм, а остальных ставит в очередь.

Двенадцать страниц по 0,2 секунды тройками — четыре захода, 0,8 секунды. Счётчик stats["now"] меняют двенадцать сопрограмм, и ни одно изменение не потерялось: между await сопрограмму никто не прерывает, и строка stats["now"] += 1 выполняется за один присест. С потоками, которые вытесняет планировщик ОС, так не будет; к этому мы придём через главу.

Задачи

Три задачи — три смены за пультом: составить расписание круговой очереди, посчитать ожидание при правиле «кратчайшая первой», где задачи приходят не сразу, и обойти сайт сотней одновременных запросов, не обидев сервер.

Напишите round_robin(jobs, quantum). Задачи — список кортежей (имя, приход, работа): имена разные, моменты прихода — целые от нуля, работа — целое, не меньше единицы. Верните расписание — список отрезков (имя, начало, конец) по времени. Правила:

  • готовые задачи стоят в очереди; ядро получает первая, работает квант или меньше, если ей осталось меньше, и, если не закончила, встаёт в хвост;
  • задачи, пришедшие в тот момент, когда у другой кончился квант, встают в очередь раньше неё; пришедшие одновременно — в порядке списка jobs;
  • если очередь пуста, ядро простаивает до прихода следующей задачи;
  • если задача получает квант сразу после своего же (больше звать некого), два отрезка склеиваются в один.

Например, для четырёх задач из ячейки «очередь.py» при кванте 2 ответ — восемь отрезков от ("отчёт", 0, 2) до ("отчёт", 13, 15), а одна задача ("A", 5, 7) при кванте 3 даёт [("A", 5, 12)]. Тесты дают до двадцати тысяч задач.

Возьмите за основу round_robin из ячейки «очередь.py»: очередь готовых — deque, остатки работы — словарь, указатель i — на следующую ещё не пришедшую задачу в отсортированном по приходу списке. sorted устойчива, поэтому пришедшие одновременно останутся в порядке списка.

Склеивать удобно в момент записи: если последний отрезок в plan принадлежит той же задаче и кончается ровно там, где начинается новый, вместо нового отрезка продлите последний.

Не забудьте про простой: если очередь пуста, а задачи ещё будут, перенесите часы на момент прихода следующей — t = max(t, jobs[i][1]).

Правило про одновременный приход записано порядком двух строк в конце цикла: сначала в очередь попадают пришедшие к моменту t, потом прерванная задача. В учебниках встречаются оба соглашения, лишь бы планировщик держался одного. Каждая задача проходит через очередь столько раз, сколько у неё квантов, и каждое действие с deque стоит $O(1)$, так что всё расписание строится за время, пропорциональное числу отрезков, плюс сортировка. Со списком и pop(0) вместо deque каждый вызов сдвигал бы всю очередь — глава 14 объясняет почему.

Задачи приходят в разное время: список пар (приход, работа), целые числа. Планировщик работает по правилу «кратчайшая первой» без вытеснения: когда ядро свободно, из уже пришедших задач он зовёт ту, у которой меньше работы, и она работает до конца. Если не пришёл никто, ядро простаивает до ближайшего прихода. Напишите sjf_average_wait(jobs) — среднее время ожидания (момент начала минус момент прихода); для пустого списка — 0.0. Например, для [(0, 7), (1, 4), (2, 1)] первой работает задача, пришедшая в 0 (других ещё нет), потом самая короткая, потом оставшаяся: ожидания по порядку запуска 0, 5 и 7, среднее 4. Задач в тестах бывает сто тысяч.

Запустите заготовку на примере из условия. Она ставит первой задачу длины 1 и ждёт её прихода в момент 2, а задача длины 7 к этому времени могла бы уже работать. Ответ выходит меньше правильного — заготовка жульничает. Выбирать можно только из пришедших.

Отсортируйте задачи по приходу и ведите два указателя: часы t и номер первой ещё не пришедшей задачи. Всех пришедших к моменту t кладите в кучу (heapq, глава 18) по длине работы — и доставайте из неё самую короткую.

Если куча пуста, а задачи ещё будут, перенесите часы на приход следующей. Каждая задача один раз входит в кучу и один раз выходит — $O(n \log n)$ на всё.

Заготовка решала задачу, в которой все пришли одновременно, — ту, для которой мы доказали теорему. Но теорема ничего не обещает, если задачи приходят по очереди: заготовка нарушает причинность и «запускает» задачу, которой ещё нет, а пока она ждёт её, ядро простаивает. Правильное решение — та же стойка диспетчера из главы 18: куча пришедших и часы, которые прыгают от события к событию. Кстати, без вытеснения «кратчайшая первой» уже не обязательно лучшая: если длинная задача пришла чуть раньше коротких, она займёт ядро, а их ждать заставит. Чтобы этого избежать, нужно вытеснение — правило SRTF из диаграммы.

Напишите сопрограмму crawl с параметрами start, fetch и max_parallel — паука, который обходит сайт. fetch — сопрограмма, её даст тест: await fetch(url) ждёт ответа сервера и возвращает список ссылок со страницы или None, если такой страницы нет. Верните множество адресов всех существующих страниц, до которых можно дойти по ссылкам от start, включая её саму. Условия сервера:

  • каждую страницу запрашивать не больше одного раза — даже если на неё ведут десять ссылок или две ссылки с одной страницы;
  • одновременно не больше max_parallel запросов — тест следит за этим;
  • быстро: ответ сервера идёт десятые доли секунды, страниц бывает больше сотни, а время на весь обход — пара секунд, так что по одной странице за раз не успеть.

Тест запускает паука через asyncio.run, например с start="/" и max_parallel=10.

Заготовка верна, но каждый await fetch ждёт ответа, прежде чем послать следующий запрос. Нужно, чтобы запросы к разным страницам шли одновременно: asyncio.gather из ячейки «три-запроса.py».

Удобно написать сопрограмму visit(url): получить ссылки страницы, отобрать новые и запустить visit для всех новых сразу через gather. Предел одновременных запросов держит семафор Semaphore из ячейки «турникет.py»: под async with поставьте только сам fetch, иначе страница будет держать место, пока обходятся все её потомки, и паук застрянет.

Отмечайте страницу в seen до того, как запустили её обход, а не после ответа: иначе две страницы, ссылающиеся на третью, обе успеют её запросить. И помните, что на одной странице ссылка может встретиться дважды.

Всё держится на том, что между двумя await сопрограмму никто не прерывает. Проверка link not in seen и seen.add(link) идут подряд без await между ними, поэтому две сопрограммы не могут обе увидеть страницу новой. С потоками это было бы гонкой — глава 39. Ловушек две. Если держать семафор, пока обходятся потомки, все места займут страницы, которые ждут детей, а детям места не останется — паук встанет навсегда. Это первая встреча с взаимной блокировкой, о ней тоже в главе 39. А если добавлять в seen после ответа, популярные страницы будут скачаны по нескольку раз. Так же устроены пауки поисковых систем, только они вежливее: делают паузы между запросами к одному сайту и читают его файл robots.txt.

Куда дальше

Квант за квантом сотня программ получает ядро, и каждая уверена, что работает одна. Но делят они не только процессор. Вернёмся к опыту с fork. Программа на C заводит переменную x, раздваивается, и ребёнок меняет свою x. Оба процесса печатают значение и адрес переменной.

Адрес один и тот же, а значения разные: у ребёнка 99, у родителя 10. Если адрес — это номер ячейки памяти, как мы считали с главы 14, в одной ячейке не могут одновременно лежать два числа. Значит, адрес, который видит программа, — не номер ячейки в микросхеме. Каждой программе кажется, что вся память принадлежит ей одной: она пишет по любому адресу, и никто другой её числа не видит и не портит. Как устроен этот обман, расскажет следующая глава. Он похож на гостиницу, где номер на ключе не совпадает с номером комнаты.