OS·V Операционная система Глава 39 из 65
Гонки
Летом 1997 года компьютер марсианского Pathfinder раз за разом перезагружался сам. Инженеры повторили сбой на земной копии аппарата и починили его с расстояния около двухсот миллионов километров. Теперь ремонт ведёте вы: ловите гонки, ищете порядки, при которых код ломается, сажаете за стол философов и устраняете инверсию приоритетов.
Операционная система
- 36 ОС
- 37 Планировщик
- 38 Виртуальная память
- 39 Конкурентность вы здесь
- 40 Хранение
Опирается на: 37 · Центр управления полётом
Что вы унесёте из главы
- находить гонку в коде с общими данными: перебирать порядки шагов и видеть, где окно между чтением и записью
- закрывать общие данные блокировкой, брать несколько замков без взаимной блокировки и передавать работу через очередь
- объяснять, как инверсия приоритетов остановила Pathfinder, и зачем в Python замок интерпретатора
5Как сотня программ работает на двух ядрах и не ломает друг другу данные?
Прошлая глава закончилась опытом на C: два потока по миллиону раз прибавляют единицу к общему счётчику, а выходит то миллион с небольшим, то почти два — и каждый раз по-разному. Виртуальная память развела процессы по разным адресным пространствам, и чужой процесс наших чисел не испортит. Но потоки одного процесса живут в одной памяти. Пересылать друг другу данные им не нужно, зато двое могут взяться за одно число, и каждый будет думать, что он один. Такие ошибки не видны в тексте программы и пропадают, стоит начать их искать. Учиться их ловить мы будем на ремонте, причём на другой планете.
Марс, июль 1997. Компьютер перезагружается
Что они увидели в трассе, мы узнаем в конце главы тем же путём, что и они: повторим сбой на земной копии. Но прочесть трассу без потоков, блокировок и приоритетов не выйдет, а сбой прячется как раз в их неудобном сочетании. По дороге будут головоломки одного вида: короткий код выглядит правильным, а вы ищете порядок шагов, при котором он ломается.
Потоки: одна память на всех
Процесс из главы 36 — это программа со своей памятью, открытыми файлами и хотя бы одной нитью исполнения: счётчиком команд, регистрами и стеком вызовов. Нитей может быть несколько. Каждую из них называют потоком. Планировщик из главы 37 раздаёт ядра именно потокам: у каждого своё место в программе и свои локальные переменные, а всё остальное — глобальные переменные, списки, словари, файлы — у потоков одного процесса общее. Создать поток в Python можно модулем threading.
Оба потока дописывают в один и тот же список, и записи ложатся вперемешку, по мере того как потоки просыпаются: ['A0', 'B0', 'A1', 'B1', 'A2', 'B2']. Список общий, пересылать ничего не пришлось. start запускает поток, join ждёт его конца. Теперь повторим на Python опыт прошлой главы: два потока по миллиону раз прибавляют единицу к общему счётчику.
Ни одной потерянной прибавки, хотя на C терялись сотни тысяч. Можно подумать, что Python от гонок защищён. Проверим это на коде, который мог бы стоять на любом сайте, — на счётчике просмотров страниц. Каждый посетитель обслуживается своим потоком, и каждый поток прибавляет единицу к числу просмотров своей страницы.
Вот и потери. Когда писалась глава, песочница теряла от 80 до 800 тысяч просмотров из двух миллионов — каждый раз по-разному. Запустите ещё раз: числа будут другими. Если программа на одних и тех же данных выдаёт разные ответы, ищите потоки, которые делят эти данные.
Окно между чтением и записью
Чтобы понять, почему одна прибавка теряет, а другая нет, заглянем в байт-код обеих строк. Читать его мы учились в главе 33.
Каждая строка распадается на несколько действий. counter += 1 — это четыре команды: прочитать counter (LOAD_GLOBAL), положить единицу, сложить, записать (STORE_GLOBAL). Прибавка к просмотрам — около десятка: прочитать словарь, вызвать его метод get (CALL), сложить, записать результат в словарь (STORE_SUBSCR). Между чтением старого значения и записью нового всегда есть промежуток. Если в этот промежуток другой поток успеет прочитать то же старое значение, оба запишут одно и то же число, и одна прибавка пропадёт.
Остаётся вопрос, когда интерпретатор переключает потоки. Python делает это сам: раз в несколько миллисекунд он просит текущий поток уступить место. Срок можно узнать — sys.getswitchinterval() в песочнице возвращает 0,005 секунды. Но выполнить просьбу поток может только там, где интерпретатор её проверяет. Начиная с версии 3.10 таких мест немного. В нашей 3.13 это вход в функцию, переход на новый виток цикла и возврат из вызова встроенной функции. Внутри четырёх команд counter += 1 ни одной проверки нет, и потому в нашей версии Python эта строка ни разу не прервалась посередине. А в count_view проверка стоит сразу после CALL: поток прочитал старое число, вернулся из get, увидел просьбу и уступил ядро — с прочитанным числом в руках.
Это везение, а не защита. На Python 3.9 тот же счётчик.py теряет прибавки: мы запускали его и получали от 1,1 до 1,8 миллиона вместо двух. В Python без замка интерпретатора (о нём речь в конце главы) прибавка тоже не защищена, а в C, Java и Go — тем более: опыт прошлой главы на C это показал. Любая следующая версия Python может поставить проверку в другое место. Полагаться на то, где именно интерпретатор переключает потоки, нельзя.
Ошибку, при которой результат зависит от того, в каком порядке потоки выполнили свои шаги, называют гонкой: потоки как будто бегут наперегонки, и от того, кто прибежит первым, зависит ответ. А свойство, которого не хватило нашей прибавке, называют атомарностью: операция атомарна, если другие потоки видят состояние до неё или после неё, но никогда не середину. Чтение-изменение-запись не атомарно, пока мы не сделаем его таким сами.
Гонка возникает, когда выполнены три условия: данные общие, их кто-то меняет и между чтением и записью есть промежуток, в который может вклиниться другой поток. Уберите любое из трёх — гонки нет. Вся глава — это способы убрать одно из них.
Головоломка: найдите порядок
Ловить гонку запусками неудобно. В ячейке выше она проявляется сотни тысяч раз за секунду, потому что потоки только и делают, что бьют в одно место. В рабочей программе окно открывается редко, и ошибка может прятаться месяцами, а потом случиться на Марсе. Надёжнее рассуждать. Представим каждый поток списком шагов и будем сами решать, чей шаг следующий, — как планировщик. Порядок, в котором перемешаны шаги потоков, называют чередованием. Гонка — это чередование, при котором ответ неверен. Найдите его.
Первые две задачи решаются за несколько нажатий, и решение у них одно: второй поток должен прочитать или проверить общее значение, пока первый ещё не записал своё. В задаче о последнем месте это особенно наглядно. Проверка «мест больше нуля» верна в момент проверки, но к моменту продажи мир успел измениться. Такую гонку называют «проверил, потом действуй», и она живёт везде, где решение принимают по прочитанному раньше: «файла нет — создам», «логин свободен — займу», «денег хватает — спишу».
Прежде чем браться за третью задачу, сделайте ставку.
Два потока, в каждом цикл из десяти витков: v = counter, потом counter = v + 1. Счётчик начинается с нуля. Какой самый маленький итог возможен при каком-нибудь чередовании?
Два. Поток A читает 0 и засыпает. B без помех проходит девять витков: счётчик 9. A просыпается и пишет 1 — девять прибавок B стёрты. Теперь B читает эту единицу и засыпает. A доделывает свои девять витков, счётчик 10. B просыпается и пишет 2. Меньше двух не получить: записанное значение никогда не бывает нулём, а последнее чтение последнего потока происходит после его же первой записи, — значит, он читает хотя бы единицу и пишет хотя бы двойку.
Чередований у двух коротких потоков немного, и их можно перебрать программой. Шаги потока — список, а все чередования строятся рекурсией, как перестановки в главе 9: первым идёт шаг либо одного потока, либо другого, а дальше — все чередования того, что осталось.
Шесть чередований, и только два дают правильную двойку — те, где один поток закончил прежде, чем начал другой. Так же, только в большом масштабе, работают программы проверки моделей: перебирают все чередования и ищут плохое. Но чередований становится очень много, и очень быстро.
У двух потоков по $k$ шагов чередований $\binom{2k}{k}$: из $2k$ мест в общем порядке надо выбрать те, где идут шаги первого потока. Для двадцати шагов это уже больше ста миллиардов, для трёх потоков по двадцать — $5{,}8 \cdot 10^{26}$. Рост экспоненциальный, как у перебора в главе 13. Тесты пробуют ничтожную долю порядков, причём одни и те же: в каком порядке потоки пойдут на вашем ноутбуке, решает планировщик, а он предсказуем, пока нагрузка не изменится. Поэтому гонки так часто проходят все испытания и всплывают у пользователей.
Первое правило защиты и самое дешёвое: не делите то, что можно не делить. Пусть каждый поток считает просмотры в собственном словаре, а в конце главный поток сложит словари. Общих данных нет, и гонке негде случиться. Так устроены и большие системы подсчёта: каждый сервер копит свои числа и время от времени отдаёт их в общий итог. Но бывают данные, которые общие по смыслу: остаток на счёте, свободные места в зале, список открытых файлов. Для них нужен другой инструмент.
Примерочная на одного
Если общее менять приходится, надо сделать так, чтобы между чтением и записью никто не вклинился. Проще всего пускать в этот участок кода потоки по одному, как в примерочную с замком на двери: вошёл — запер, вышел — отпер, а следующий ждёт у двери. Участок, где поток работает с общими данными и где второму потоку быть нельзя, называют критической секцией, а сам замок — блокировкой, или мьютексом: от английского mutual exclusion, «взаимное исключение». В Python это threading.Lock. Метод acquire берёт замок или ждёт, пока его отпустят; release отпускает. Удобнее писать with lock: — замок отпустится сам, даже если внутри случится исключение.
Ровно два миллиона, три раза подряд. Правильность стоит времени: когда писалась глава, без замка те же два миллиона вызовов шли в песочнице около 0,2 секунды, с замком — в два-три раза дольше, от 0,4 до 0,7 секунды в разных запусках. Замок — это работа: взять, отпустить, а при споре ещё и разбудить ждущего. Поэтому в критической секции держат только то, без чего нельзя, а печать, чтение файла и ожидание сети выносят наружу.
Сам замок хочется устроить как флаг «занято»: если флаг опущен, поднять его и войти. Но «если опущен — поднять» — та же гонка «проверил, потом действуй»: два потока одновременно увидят опущенный флаг и войдут оба. Из обычных чтений и записей замок так легко не построить, нужна помощь железа. Процессоры дают особые команды, которые читают ячейку памяти и записывают в неё новое значение за один неделимый шаг, даже когда ядер много: например, «обменять» или «сравнить и обменять». Пока такая команда работает, другое ядро не может вклиниться в эту ячейку: кэши ядер договариваются между собой, и строка кэша из главы 34, где лежит ячейка, на это время достаётся одному ядру. А чтобы поток, ждущий замок, не крутился вхолостую, ядро операционной системы усыпляет его и будит, когда замок освободится; в Linux для этого есть системный вызов futex.
У замка три правила. Под одним замком должны быть все обращения к общим данным, и чтения тоже: поток, который читает без замка, увидит середину чужой записи. Замок у этих данных должен быть один и тот же: with threading.Lock(): внутри функции создаёт новый замок при каждом вызове, и каждый поток запирает свою собственную дверь. И держать его надо недолго, иначе остальные будут стоять в очереди. Последнее правило касается не только скорости — скоро мы увидим, как долгое удержание замка остановило марсианский аппарат.
Therac-25
Потерянный просмотр страницы никому не повредит. Но гонки случаются и в программах, которые управляют машинами, и одна из них стоила людям жизни. Эту историю рассказывают на занятиях по надёжности программ больше тридцати лет.
Причины подробно разобрали Нэнси Левесон и Кларк Тёрнер в статье 1993 года в журнале IEEE Computer. Одна из найденных ошибок — гонка. Ввод оператора на экране обрабатывала одна задача программы, а настройку оборудования, которая занимала несколько секунд, — другая. Если оператор по ошибке выбирал рентгеновский режим, тут же исправлял букву на электронный и подтверждал, и всё это укладывалось в восемь секунд от первого нажатия, то исправление не доходило до оборудования. На экране было одно, в машине — другое. Оператор, проведший сотни сеансов, легко укладывался в восемь секунд. На испытаниях так быстро не печатал никто.
Вторая ошибка — переполнение, знакомое по главе 11 и главе 28. Флаг, который означал «нужна проверка», программа не ставила в единицу, а прибавляла к нему единицу. Время от времени переменная переполнялась и становилась нулём, и проверка в этот момент пропускалась.
Левесон и Тёрнер настаивали, что одной строкой кода эти аварии не объяснить: если искать только отдельные ошибки в программе, безопасной системы не получится. Главной причиной они называли то, как программу проектировали, проверяли и сопровождали. Защиту жизни доверили одной программе, без независимой проверки и без аппаратной страховки. К этой главе прямо относятся два урока. Гонку нельзя исключить испытаниями: она живёт в порядках, которые испытания не пробуют. И если ошибка может убить, одного правильного кода мало — нужна защита, которая сработает, даже когда код ошибётся.
Тупик
Вернёмся в проводник: теперь у потоков есть замки. В первой задаче попробуйте сломать счётчик, который защищён замком. Во второй — у каждого потока по два замка, и цель другая: остановить оба потока навсегда.
Первую задачу решить нельзя: пока A держит замок, кнопка B гаснет на шаге lock.acquire(), и между чтением и записью никто не вклинится. У двух потоков по четыре шага семьдесят чередований, а замок оставляет из них возможными только два: сначала весь A, потом весь B, или наоборот. Оба дают двойку. Зато вторая решается за два нажатия, и это новая беда. Поток A запер счёт a и ждёт счёт b. Поток B запер b и ждёт a. Ни один не отпустит своё, пока не получит чужое, и оба будут ждать вечно. Такую остановку называют взаимной блокировкой, или тупиком. На живых потоках он выглядит так: два перевода между одними и теми же счетами идут навстречу друг другу. Чтобы ячейка не простояла до предела песочницы, второй замок ждут не дольше секунды.
Секунду ячейка молчит: оба перевода держат по замку и ждут второй. Потом у одного кончается терпение, он сдаётся и отпускает свой замок, и тогда второй перевод проходит. Какой из двух сдастся, решает случай. Уберите timeout=1, и ячейка простоит до предела песочницы в десять секунд.
Четыре условия
В 1971 году Эдвард Коффман с соавторами перечислили условия, без которых тупика не бывает. Все четыре видны в нашей ячейке.
- Взаимное исключение. Ресурсом в каждый момент владеет один поток: замок держит кто-то один.
- Удержание и ожидание. Поток держит одно и ждёт другое, не отпуская первого.
- Нет отъёма. Отобрать ресурс силой нельзя: замок отпускает только хозяин.
- Круговое ожидание. Есть цикл: A ждёт B, B ждёт A — или длиннее, A ждёт B, B ждёт C, C ждёт A.
Если нарисовать граф, где стрелка ведёт от потока к тому, кто держит нужный ему замок, тупик — это цикл в таком графе. Искать циклы мы умеем с главы 19, и так тупики находят базы данных: они строят граф ожиданий и, найдя цикл, прерывают одну из транзакций. Но лучше тупиков не допускать, и для этого хватит сломать любое из четырёх условий. Больше всего пользы от последнего. Договоримся брать замки всегда в одном и том же порядке — скажем, по возрастанию номера счёта. Тогда цикл невозможен: стрелки ожидания идут только от меньших номеров к большим, а по такой лестнице не вернёшься туда, откуда начал. Как это сделать в переводах, вы выясните в задаче «Перевод без тупика» — там есть и подвох.
Пять философов
Самую знаменитую задачу о тупиках придумал Эдсгер Дейкстра в 1965 году как экзаменационное упражнение для студентов. У него пять компьютеров спорили за ленточные накопители. Вскоре он пересказал её как обед, а нынешнее имя, «обедающие философы», ей дал Тони Хоар. За круглым столом сидят пять философов, перед каждым тарелка, между соседями по одной вилке — всего пять. Философ то думает, то ест, а есть он может только двумя вилками: левой и правой. Каждый поступает разумно: берёт левую вилку, потом правую, ест, кладёт обе.
Рано или поздно все пятеро проголодаются почти одновременно, каждый возьмёт левую вилку, и правой не останется ни у кого. Это круговое ожидание в чистом виде. Правила, которые его лечат, ломают каждое своё условие. «Сначала вилку с меньшим номером» — это порядок замков: философ 4 тянется сначала к вилке 0, и круг размыкается. «Обе сразу или ни одной» убирает удержание и ожидание: голодный философ не держит одну вилку, пока ждёт вторую. Официант, который пускает к столу не больше четырёх голодных, не даёт замкнуться кругу: пятеро не могут ждать друг друга, если за столом только четверо, а у четверых на пяти вилках кто-нибудь да соберёт пару.
Счётчики трапез показывают ещё одну беду. Правило «обе или ни одной» тупика не допускает, но философ, оба соседа которого едят по очереди, может долго ждать, пока обе вилки окажутся свободны одновременно. Это голодание из главы 18 и главы 37, только теперь в буквальном смысле.
Семафор
Официанту нужен счётчик свободных мест за столом. Такой инструмент придумал тот же Дейкстра, ещё раньше философов: в 1962–1963 годах, когда его группа писала операционную систему для голландского компьютера Electrologica X8. Семафор — это целое число и две неделимые операции. Операция P уменьшает его на единицу, а если оно уже ноль, ждёт, пока кто-нибудь его увеличит. Операция V увеличивает и будит одного из ждущих. Буквы — от нидерландских слов; что именно они значили, Дейкстра объяснял по-разному. Само название взято у железнодорожного семафора: крыло вытянуто горизонтально — стой, наклонено — проезжай.
На стоянке никогда не больше двух машин: третья ждёт на въезде, пока кто-нибудь не уедет. Замок — это семафор на одно место. Турникет asyncio.Semaphore(3) из главы 37 — тот же семафор для сопрограмм, а официант за столом философов — семафор на четыре места. В задаче «Ужин без тупика» его можно поставить у стола самому.
Из рук в руки
Часто потоки делят работу по цепочке: один получает заказы, другой их выполняет; один читает файл, другой разбирает строки. Можно завести общий список под замком, но тогда придётся решать ещё две задачи. Когда список пуст, исполнитель должен ждать, не крутясь вхолостую. Когда исполнитель не успевает и список растёт, ждать должен уже поставщик, иначе кончится память. Это задача о производителе и потребителе, и Дейкстра решал её двумя семафорами: один считает свободные места, другой — готовые предметы, а замок охраняет сам список.
В Python всё это уже собрано в queue.Queue. Это очередь сообщений: put кладёт предмет и ждёт, если очередь полна, get забирает и ждёт, если пуста, а все блокировки спрятаны внутри.
Пекарь сразу кладёт четыре пирога: первый упаковщик тут же забрал, и на ленте осталось три — она полна. Дальше пекарь кладёт следующий пирог, только когда упаковщик освобождает место, раз в 0,2 секунды. Медленный потребитель сам притормаживает быстрого производителя, и никто не держит в памяти больше трёх пирогов. Последний предмет None — договорённость «конец работы»: без неё упаковщик ждал бы вечно.
Похожую ленту вы уже встречали. Труба из главы 36 устроена так же, только живёт в ядре и возит байты: в песочнице в неё помещается 65 536 байт, и пишущий процесс засыпает, пока читающий не освободит место. Свою ленту из семафоров вы соберёте в задаче «Своя лента».
Очередь меняет сам способ думать о потоках. Пока данные общие, каждый поток должен помнить про замки, и одна забытая строка ломает всё. Когда потоки только передают друг другу сообщения, у каждого предмета в каждый момент один хозяин, и делить нечего. В документации языка Go это правило записано так: «Не общайтесь через общую память; делитесь памятью, общаясь». На этой идее построены целые языки и системы: процессы Erlang, каналы Go, акторы. Ею же пользуется песочница курса: ваша программа и сервер ничего не делят, они обмениваются строками по трубе.
Земная копия
Теперь у нас есть всё, чтобы разобрать сбой Pathfinder. По рассказу, который в декабре 1997 года разошёлся по сети, — Майк Джонс из Microsoft пересказал доклад Дэвида Уилнера, технического директора Wind River, — части аппарата обменивались данными через «информационную шину»: общую область памяти, доступ к которой охранял мьютекс. Нам понадобятся три участника.
- Шина — задача высокого приоритета, раз в восьмую долю секунды раздаёт данные по шине. Ей нужен мьютекс, и она обязана успеть до следующего цикла.
- Метео — задача низкого приоритета, изредка записывает на шину погоду. Ей тоже нужен мьютекс.
- Связь — задачи среднего приоритета, шины не трогают, но работают подолгу.
Планировщик VxWorks всегда отдаёт процессор готовой задаче с самым высоким приоритетом. Если задача высокого приоритета не закончила свой цикл вовремя, бортовая программа считает, что случилось что-то серьёзное, и перезагружает компьютер. Такого сторожа называют сторожевым таймером: сам он ничего не чинит, только возвращает систему в известное состояние. Вот земная копия — повторите сбой.
Сброс случается, когда совпадают три события. Метео взяло мьютекс. Пока оно записывает погоду, просыпается связь: её приоритет выше, и она отбирает процессор у метео — вместе с мьютексом, который метео так и не отпустило. Просыпается шина, ей нужен мьютекс, и она ждёт метео. Метео ждёт процессора, который занят связью. Задача с самым высоким приоритетом стоит позади задачи со средним, хотя общего мьютекса у них нет. Это называют инверсией приоритетов: приоритеты перевернулись, и самая важная задача фактически работает как самая неважная.
Ту же историю можно рассказать короче, программой. Время здесь — такты, у каждой задачи — приоритет, момент прихода и список шагов; шаги с буквой «Ш» требуют мьютекса шины. На каждом такте планировщик берёт задачу с наибольшим приоритетом из тех, что не ждут мьютекса.
В строке «ЦП» — кто работал на каждом такте. Шина отработала на такте 0, метео на такте 1 взяло мьютекс, на такте 2 пришла связь и заняла процессор на четырнадцать тактов. На такте 8 шина проснулась и встала ждать мьютекса — её буквы в строке нет до самого конца. На такте 16 сторож видит, что шина не закончила прошлый цикл, и перезагружает компьютер. Каждый шаг по отдельности правилен: планировщик выбирает самую важную готовую задачу, мьютекс не пускает двоих. Ломается только их сочетание.
Лекарство придумали за несколько лет до полёта. В 1990 году Луи Ша, Рагунатан Раджкумар и Джон Лехоцки описали наследование приоритетов. Пока мьютекс, который держит задача, ждёт кто-то важнее её, она работает с приоритетом этого важного. Метео на время становится такой же важной, как шина, и связь её больше не вытесняет. За такт метео дописывает погоду, отпускает мьютекс и тут же возвращается к своему скромному приоритету. Поменяйте в ячейке INHERIT на True: на такте 8 в строке появится «м», на такте 9 — «ш», и все сроки соблюдены.
Заплатка летит на Марс
Сбой Pathfinder похож на остальные ошибки этой главы. Читая код по строчке, его не увидеть: каждая строка верна. Ошибка живёт в порядке, в котором три задачи сделали свои шаги, и проявляется редко. Найти её помогли две вещи, которые стоит запомнить. Первая — трасса событий, которую оставили в уже летящей программе: без неё инженерам пришлось бы гадать. Вторая — копия системы, на которой сбой можно повторять сколько угодно. Тот же приём используют тесты задачи «Счётчик просмотров»: они нарочно расширяют окно гонки, чтобы она случалась каждый раз.
Процессы, потоки и замок интерпретатора
Работу можно делить между потоками одного процесса или между процессами. Процессы разделены виртуальной памятью из главы 38: общих переменных у них нет, гонке за них взяться неоткуда, а упавший процесс не роняет соседей. Зато данные между процессами надо пересылать — по трубам, через очереди, через файлы, — и это стоит времени, а сам процесс заводить дороже, чем поток. Потоки дёшевы и видят всё сразу, но за общими данными нужно следить. Многие браузеры, например, держат вкладки в разных процессах, чтобы зависшая страница не уронила остальные.
В Python к этому выбору добавляется своё обстоятельство. Обычный CPython держит глобальный замок интерпретатора, GIL: байт-код в каждый момент выполняет только один поток процесса. Каждый объект Python хранит счётчик ссылок на себя — мы видели его в главе 38, — и счётчики меняются при каждом присваивании. Без общего замка каждую такую перемену пришлось бы защищать отдельно, и однопоточные программы стали бы медленнее. Один замок на всё сделал интерпретатор простым и быстрым для одного потока — и бесполезным для того, чтобы считать на нескольких ядрах сразу. Ждать он не мешает: поток, который спит или ждёт файл и сеть, отпускает GIL.
Четыре потока, поделившие счёт на четыре части, справляются не быстрее одного, а то и медленнее: замок у них один на всех, и они передают его друг другу. Четыре ожидания по полсекунды укладываются в полсекунды: пока один поток спит, GIL ему не нужен. Правило для обычного Python такое: потоки — для программ, которые много ждут (сеть, диск, пользователь), процессы — для счёта. В песочнице курса процессы тоже не ускорят счёт, но по другой причине: как мы выяснили в главе 35, ей выделено время только одного ядра.
Замок интерпретатора пробуют убрать давно. В 2023 году приняли предложение Сэма Гросса (PEP 703) сделать его необязательным. В Python 3.13, вышедшем в октябре 2024 года, появилась отдельная экспериментальная сборка без GIL, python3.13t. В 3.14, вышедшем через год, её объявили официально поддерживаемой, но она по-прежнему отдельная: обычный Python работает с замком. В такой сборке четыре потока считают на четырёх ядрах параллельно, а однопоточный код, по документации 3.14, медленнее примерно на 5–10 %. Песочница курса работает на обычной сборке, поэтому показать её здесь мы не можем.
Одно надо понимать твёрдо: GIL никогда не защищал ваши данные. Он охраняет внутренности интерпретатора — счётчики ссылок, словари изнутри, — но не ваше «прочитать, прибавить, записать». Счётчик просмотров терял данные при включённом GIL. Без GIL окно гонки только шире: потоки работают на разных ядрах действительно одновременно, и counter += 1 теряет прибавки, как на Python 3.9. Код, написанный по правилам этой главы, — с замками на общих данных или с очередями вместо них, — правилен с замком интерпретатора и без него.
Сотня программ уживается на двух ядрах благодаря нескольким слоям, и каждый мы разобрали. Ядро операционной системы из главы 36 стоит между программами и железом: программы работают в пользовательском режиме, а к дискам и сети обращаются и новую память получают только через системные вызовы. Планировщик из главы 37 раздаёт ядра процессора квантами в несколько миллисекунд; прерывание таймера отбирает процессор у любой программы, и каждой кажется, что она работает без перерыва. Виртуальная память из главы 38 даёт каждому процессу своё адресное пространство: один и тот же адрес у двух процессов ведёт в разные ячейки, а дороги в чужие страницы у процесса нет вовсе, поэтому испортить чужие данные он не может. Остаётся то, что программы делят по своей воле, — общая память потоков, файлы, очереди. Здесь порядок наводят сами программы: блокировки делают чтение-изменение-запись неделимым, единый порядок захвата замков исключает тупики, очереди передают работу без общих данных, а наследование приоритетов не даёт скромной задаче задержать важную. Где этими правилами пренебрегают, программы портят друг другу данные. Чаще всего незаметно, но на Pathfinder это раз за разом перезагружало компьютер, а в Therac-25 стоило людям жизни.
Задачи
Четыре задачи, и у каждой тесты устроены как земная копия Pathfinder. Гонки и тупики случаются редко, поэтому тесты не надеются на удачу: они гоняют ваш код много раз, заставляют интерпретатор переключать потоки как можно чаще и подменяют данные и замки своими, которые чуть медлят в опасном месте. Если в коде есть окно, тест в него попадёт.
Функцию count_view(page) из раздела о потоках вызывают одновременно многие потоки сервера, и она теряет просмотры. Сделайте её безопасной: при любом числе потоков каждый вызов должен прибавить ровно единицу. Словарь views оставьте глобальной переменной — тесты очищают его, читают и на время подменяют словарём того же типа, у которого чтение чуть медленнее, чтобы окно гонки стало шире. Тесты запускают до восьми потоков по три тысячи вызовов, двенадцать раундов подряд, а 200 000 вызовов в одном потоке должны уложиться в две секунды.
Заведите один замок на уровне модуля, рядом со словарём, и выполняйте чтение и запись под ним: with lock:.
Частая ошибка — with threading.Lock(): внутри функции. Такой замок новый при каждом вызове, никто другой его не ждёт, и защиты нет. Другая ошибка — заменить строку на views[page] += 1 в надежде, что так она «атомарна». В нашей версии Python она действительно не прерывается посередине, но только пока словарь обычный, а тесты подставляют словарь, у которого чтение — вызов функции.
Под замком чтение и запись идут подряд, и никакой другой поток не может войти между ними: он ждёт у with. Замок один на все страницы — для счётчика просмотров этого достаточно. Если страниц много, а потоков сотни, такой замок становится узким местом, и тогда заводят по замку на группу страниц или, как советовал раздел о порядках, дают каждому потоку свой словарь и складывают их в конце.
У каждого счёта есть номер number (у разных счетов разный), остаток balance и свой замок lock. Напишите transfer(src, dst, amount): если на src хватает денег, перевести amount на dst и вернуть True, иначе ничего не менять и вернуть False. Остатки меняйте, только держа замки обоих счетов. Переводы идут из многих потоков одновременно и в любых направлениях — навстречу друг другу, по кругу, — и тупиков быть не должно. Тесты пользуются своими счетами с теми же тремя атрибутами; их замки медлят, взяв замок, чтобы тупик, если он возможен, случился наверняка.
Тупик требует кругового ожидания. Если все потоки берут замки в одном и том же порядке — например, сначала счёт с меньшим номером, — круга не получится.
Порядок взятия замков не зависит от направления перевода: first, second = sorted([src, dst], key=lambda acc: acc.number). А проверять остаток и списывать по-прежнему надо с src.
Подвох: перевод самому себе, transfer(a, a, 10). Обычный замок Python нельзя взять второй раз, даже тому же потоку, — поток будет ждать сам себя вечно. Этот случай разберите отдельно.
Стрелки ожидания теперь идут только от меньших номеров к большим, и цикл из них не составить: в цикле хотя бы одна стрелка вела бы назад. Перевод самому себе ничего не меняет и не берёт замок дважды. Можно было бы взять threading.RLock — замок, который тот же поток может брать повторно, — но явная проверка понятнее. Одна общая блокировка на весь банк тоже убрала бы тупики, но тогда переводы между разными парами счетов стояли бы друг за другом; тесты требуют замков самих счетов.
Соберите сами то, что делает queue.Queue: класс BoundedQueue(capacity) — очередь не больше чем на capacity предметов. put(item) кладёт предмет в конец, а если очередь полна, ждёт, пока место освободится. get() забирает предмет из начала, а если очередь пуста, ждёт, пока предмет появится. len(q) — сколько предметов сейчас в очереди. Ждущий поток должен засыпать: крутиться в цикле нельзя. Тесты проверяют порядок, ожидание на пустой и на полной очереди и прогоняют три производителя и три потребителя по две тысячи предметов. Готовыми очередями из модуля queue пользоваться нельзя, collections.deque — можно.
Решение Дейкстры — два семафора и замок. Семафор свободных мест начинается с capacity, семафор готовых предметов — с нуля. put сначала берёт свободное место (acquire), кладёт предмет и прибавляет готовый предмет (release). get — наоборот.
Сам список меняйте под замком: семафоры следят за количеством, но два потока могут одновременно оказаться внутри put, если мест больше одного. Порядок важен: сначала семафор, потом замок. Если взять замок и ждать семафора, держа замок, никто другой не сможет освободить место — тупик.
Другой путь — threading.Condition: замок, рядом с которым можно уснуть. with cond:, потом while очередь полна: cond.wait() — поток отпускает замок и спит, пока кто-нибудь не вызовет cond.notify_all(). Проверять условие нужно в цикле while, а не в if: пока поток просыпался, место мог занять другой.
Семафоры считают то, чего ждут: свободные места и готовые предметы. Сумма их значений всегда равна вместимости, если не считать предметов, которые как раз кладут или забирают. Ни один поток не ждёт, держа замок, поэтому тупика нет. deque вместо списка — чтобы popleft работал за $O(1)$, как в главе 15. Решение с занятым ожиданием — while len(self.items) >= self.capacity: pass — формально ждёт, но жжёт процессор: с GIL крутящийся поток отнимает время у того, кто мог бы освободить место, и тесты на тысячи предметов не укладываются в срок.
Напишите dinner(forks, meals, eat) — ужин философов. forks — список из $n \ge 2$ вилок-замков; философ номер i ест вилками forks[i] и forks[(i + 1) % n]. Каждый философ — отдельный поток; он должен meals раз взять обе свои вилки, вызвать eat(i) и положить вилки. dinner возвращается, когда поели все. Тупика быть не должно, и стол не должен превращаться в очередь из одного места: пятеро должны хотя бы иногда есть по двое. Вилки в тестах медлят, взяв замок, так что наивный ужин застревает сразу.
Сломайте одно из четырёх условий. Проще всего — круговое ожидание. Порядок вилок вы уже применяли в переводах; здесь попробуйте официанта: семафор на $n - 1$ мест, который философ берёт перед вилками и отпускает после еды.
Почему $n - 1$: чтобы застряли все, каждый должен держать одну вилку и ждать соседа, а это круг из всех $n$ философов. Если к вилкам допущены только $n - 1$, круг не замкнётся: у крайнего в цепочке ожидающих вторая вилка окажется свободной.
Официант пускает к вилкам не больше $n - 1$ голодных, и круг ожидания из $n$ философов собраться не может. Работает и порядок вилок: a, b = sorted([i, (i + 1) % n]), сначала forks[a], потом forks[b]. А одна общая блокировка на весь стол тупика не допустит, но и есть будут строго по одному — этот вариант тесты отвергают.
Куда дальше
Счётчик просмотров теперь точен, переводы не теряют денег и не стоят в тупике, а Pathfinder больше не перезагружается. Но всё это живёт в памяти процесса. Перезапустите сервер — и два миллиона аккуратно сосчитанных просмотров исчезнут. Pathfinder после сброса собранного не терял, но только потому, что питание при перезагрузке не пропадало: данные в памяти, писал Ривз, уцелеют, пока его не отключат. Память, которую мы строили из триггеров в главе 31, помнит, только пока есть ток.
Чтобы данные пережили перезагрузку, их записывают на диск. И здесь ждёт гонка нового рода — с электричеством. Программа пишет файл, и посередине записи гаснет свет. Что окажется на диске: старый файл, новый, половина того и другого или ничего? Бывает и хуже: ломается сам диск, или кто-то по ошибке набирает команду, которая стирает всё. Как спасать данные в каждом из этих случаев, покажет следующая глава, устроенная как учения спасателей.