Русский
Русский
English
Статистика
Реклама

Ядро linux

Bedrock Linux лего-набор для создания идеального linux-дистрибутива

22.02.2021 16:14:10 | Автор: admin


С момента появления Linux достаточно скоро возникло множество дистрибутивов: Slack, RedHat, Debian, SUSE и т. д. Тогда же возникла и проблема выбора дистрибутива, ведь каждый из них имеет свои особенности и преимущества, которые делают его особенным. RedHat и Debian наиболее стабильные и консервативные из дистрибутивов, Ubuntu заточен на удобство и имеет прекрасный пользовательский интерфейс, Gentoo свобода выбора и гибкость.

У каждого пользователя Linux были моменты, когда ему не хватало некоторых функций, реализованных в других дистрибутивах. Многим в свое время не понравилось, что Debian перешел на systemd и они создали на его основе новый дистрибутив Devuan. Некоторые перешли на Gentoo, где пользователь может создать среду с двумя системами инициализации: как с openrc, так и с systemd.

В разных дистрибутивах этот вопрос решается по-разному. Установка пакета, который отсутствует в штатном репозитории, решается с помощью docker-контейнеров, или использованием систем самодостаточных пакетов snap и flatpak. Можно даже ставить RPM пакеты на системах с пакетным менеджером DEB. В Gentoo имеется поддержка RPM и DEB пакетов. Все это работает, однако плохо масштабируется и не очень стабильно.

Создатели Bedrock Linux пошли дальше и создали полноценный мета-дистрибутив. В нем возможно использование не только пакетов, но и компонент различных Linux дистрибутивов, как кубиков Лего. В одном окружении можно создать систему из нескольких Linux OS, например установку дополнительных пакетов Ubuntu поверх базовых компонент Debian и Arch. Установочный скрипт доступен для следующих платформ.

  • aarch64;
  • armv7hl;
  • armv7l;
  • mips64el;
  • mips64;
  • mips;
  • mipsel;
  • ppc64;
  • ppc64le;
  • ppc;
  • s390;
  • x86_64;
  • x86;

Кстати, а почему установочный скрипт, а не полноценный установочный диск, или образ? Причина в том, что Bedrock Linux не имеет своего канонического дистрибутива, вместо этого имеется набор рецептов по сборке операционной системы из некоего набора ингредиентов. В этом Bedrock Linux похож на другой мета-дистрибутив Gentoo, однако в попытке объять необъятное продвинулся к самым границам здравомыслия, а возможно и перешел их.

Установка Bedrock и базовые команды


Используя уже установленный традиционный дистрибутив Linux с помощью установочного скрипта Bedrock трансформирует его в гибридную систему. Например, у вас уже установлена ОС Debian, с помощью установочного скрипта, вы получаете совмещенную среду с Ubuntu. Для начала надо запустить из под пользователя root.

sh ./bedrock-linux-<release>-<arch>.sh --hijack

Скрипт выдаст предупреждение, что это не учения.

* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * **                                                               ** Continuing will:                                              ** - Move the existing install to a temporary location           ** - Install Bedrock Linux on the root of the filesystem         ** - Add the previous install as a new Bedrock Linux stratum     **                                                               ** YOU ARE ABOUT TO REPLACE YOUR EXISTING LINUX INSTALL WITH A   ** BEDROCK LINUX INSTALL! THIS IS NOT INTENDED TO BE REVERSIBLE! **                                                               ** * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *Please type "Not reversible!" without quotes at the prompt to continue:> Not reversible!__          __             __\ \_________\ \____________\ \___ \  _ \  _\ _  \  _\ __ \ __\   /  \___/\__/\__/ \_\ \___/\__/\_\_\          Bedrock Linux 0.7.19 Poki

Самое время ознакомиться с таблицей совместимости Linux дистрибутивов. Основные популярные дистрибутивы имеют хорошую степень поддержки, кроме возможно OpenSUSE. Для Linux Mint не поддерживается выгрузка и автоматическое установка. Не надо также забывать про таблицу совместимости компонент между собой. Бинарники, например, переносятся хорошо из одного дистрибутива в другой, а шрифты плохо.

Если все проверки прошли успешно, скрипт вносит необходимые изменения в ОС, после чего нужно перезагрузить компьютер, чтобы изменения вступили в силу. С этого момента пользователь находится в окружении Bedrock Linux. Теперь можно установить дополнительную ОС в контейнер, называемый stratum нечто наподобие chroot окружения, в котором проделаны специальные дыры для коммуникации с другими strata.
Однако прежде, чем начинать желательно ознакомиться с руководством по эксплуатации, вызвав brl tutorial basics. Простейшие команды Bedrock, назначение каждой очевидно.

# brl update# brl version# brl ctatus

Просмотр списка доступных дистрибутивов и установка.

# brl fetch --list# brl fetch alpine# brl fetch void


Как взаимодействуют дистрибутивы в составе Bedrock?


В определенных ситуациях можно выполнять команды из разных strata так, как будто они часть одной привычной Linux OS. Например команды из void и alpine можно использовать в одном конвейере. Первая команда устанавливает пакет jq на alpine, вторая jo на void. Конвейер читает из второй и передает на первую, все происходит прозрачно для пользователя.

$ sudo apk add jq$ sudo xbps-install -y jo$ jo "distro=bedrock" | jq ".distro"

Первоначальная ОС Debian Linux, над которой произвели действие --hijack теперь также является всего лишь stratum. О её существовании можно догадаться, выполнив некоторые из этих команд.

$ brl which lsdebian$ brl which /debian

Более определенно, вывод этих команд будет совпадать с содержимым файла /etc/os-release, который виден из текущего процесса shell. Это логично, так как каждый stratum видит лишь свой локальный файл, иначе параллельно установленные Debian и Ubuntu споткнулись бы о содержимое файла /etc/apt/sources.list.

Однако, если бы все файлы были локальными разные дистрибутивы не смогли бы общаться между собой. Для того, чтобы это стало возможно необходимы глобальные файлы. Такие файлы действительно существуют.

$ brl which /bedrock/etc/bedrock.confglobal$ brl which /runglobal$ brl which /tmpglobal

Для тех случаев, когда процессам одного дистрибутива необходимо достучаться до локальных файлов другого, реализованы cross пути. Например чтобы из одной strata прочитать файл os-release другой нужно обращаться к ресурсам файловой системы используя путь /bedrock/strata/. Сам stratum bedrock служит лишь для cross чтения и записи файлов. Внутри crossfs файловая система FUSE, в которой запрашиваемые файлы перезаписываются на лету для обеспечения совместимости между различными strata.

$ brl which /bedrock/strata/bedrock/etc/os-release bedrock$ cat /bedrock/strata/bedrock/etc/os-releaseNAME="Bedrock Linux"ID=bedrockID_LIKE=bedrocklinuxVERSION="0.7.19 (Poki)"VERSION_ID="0.7.19"PRETTY_NAME="Bedrock Linux 0.7.19 Poki"HOME_URL="http://personeltest.ru/aways/bedrocklinux.org"$ brl which /bedrock/strata/my-alpine/etc/os-release my-alpine

Если необходимо выполнить внутреннюю команду определенной strata, следует воспользоваться соответствующим префиксом.

$ strat void sh -c 'apk --help'

Обновление Bedrock


Bedrock обновляется незатейливо и просто Как и все дистрибутивы Linux, достаточно запустить brl update из под пользователя root. Это команда обновит лишь stratum Bedrock, остальные strata обновляются своими штатными средствами: например yum update, или dnf update для Redhat и CentOS.

Удаление strata


Тоже ничего сложного сначала дистрибутив выключается, затем следует удаление.

$ sudo brl disable alpine$ sudo brl remove alpine$ sudo remove -d void

Последняя команда совмещает, операции disable и remove.

Для чего действительно нужен Bedrock Linux?


В этот момент многие читатели скорее всего задаются вопросом: для чего нужно скрещивать ежа с ужом и создавать гибридные ОС, ведь не всегда рабочая станция Linux сама по себе бывает достаточно стабильной, особенно с закрытыми драйверами графической карты, или в сессии Wayland. Попробуем перечислить некоторые сценарии использования Bedrock Linux в практике.
  • Вы предпочитаете стабильные дистрибутивы Linux, такие как RedHat и Debian, однако вам также необходима поддержка нового железа: CPU, или недавно приобретенный принтер. Чтобы получить эту поддержку необходимо установить более свежую версия ядра и пакетов cups, hplips. Такая задача может быть решена единожды, но стабильная система с нестабильными пакетами уже не то,
  • Вам нравится дистрибутив, но не его система инициализации. Скажем, systemd вы предпочитаете openrc, или runit, однако хотели бы при этом использовать Ubuntu.
  • У вас есть задача вести разработку, или сопровождать программное обеспечение для Linux, однако ваш дистрибутив отличается от целевого. Например sh скрипты написанные для bash не будут корректно выполнены в Debian, так как в нем /bin/sh не является ссылкой на /bin/bash. Для таких сценариев в Bedrock Linux достаточно добавить stratum для Debian Linux.
  • Вы пытаетесь изменить ваши представления об использовании Linux OS. Впрочем это уже не имеет отношение к практике.


Подробнее..

Перевод 30 лет Линукса. Интервью с Линусом Торвальдсом. Часть 1

04.05.2021 10:05:07 | Автор: admin


Тридцать лет назад Линусу Торвальдсу был 21 год, он был студентом Хельсинского университета. Именно тогда он впервые выпустил ядро Linux.Анонс этого события начинался так: Я делаю (свободную) операционную систему (просто в качестве хобби, большой и профессиональной она не будет). Три десятилетия спустявсе топ-500 суперкомпьютеров в мире работают под Linux, равно как и более 70% всех смартфонов. Linux явно стал и большим, и профессиональным.

В течение тридцати лет Линус Торвальдс руководил разработкой ядра Linux, вдохновив бесчисленное множество других разработчиков и опенсорсных проектов. В 2005 году Линус также создал Git, чтобы было проще управлять процессом разработки ядра, с тех пор Git превратился в популярную систему контроля версий, которой доверяют бесчисленные опенсорсные и проприетарные проекты.

Следующее интервью одна из серии бесед с лидерами опенсорса. Линус Торвальдс ответил на вопросы по электронной почте, поразмышляв о том, что он узнал за годы руководства большим опенсорсным проектом. В первой части акцент сделан на разработке ядра Linux и Git. [Linux] был личным проектом, который вырос не из какой-нибудь большой мечты создать новую операционную систему, объясняет Линус, а в буквальном смысле несколько спонтанно, ведь я изначально просто сам хотел разобраться во входах и выходах моего нового железа для ПК.

Что касается создания Git и его последующей передачи Джунио Хамано для дальнейшей доработки и поддержки, Линус отметил: Не собираюсь утверждать, что программирование это искусство, поскольку на самом деле это большей частью просто хорошая инженерия. Я горячо верю в мантру Томаса Эдисона об одном проценте таланта и девяноста девяти процентах упорного труда: почти все зависит от мелких деталей и ежедневной рутинной работы. Но есть и эта эпизодическая составляющая, называемая талант, этот хороший вкус, который сводится не только к решению какой-либо задачи, но и к стремлению решить ее чисто, аккуратно и да, даже красиво. У Джунио есть как раз такой хороший вкус.

Итак, читайте первую часть этого интервью (есть и вторая). В оригинале она выходит через неделю после первой, и во второй части Линус исследует те уроки и озарения, которые приобрел за три десятилетия во главе разработки ядра Linux.

Разработка ядра Linux


Джереми Эндрюс: Linux повсюду, он вдохновил целый мир опенсорса. Разумеется, так было не всегда. Вы прославились тем, что выпустили ядро Linux еще в 1991 году, скромно сообщив об этом в Usenet в разделе comp.os.minix. Десять лет спустя вы написали увлекательную и глубоко личную книгу под названием Ради удовольствия: Рассказ нечаянного революционера, где разобрали большую часть этой истории. В августе этого года Linux празднует тридцатилетие! Это захватывающе, поздравляем! В какой момент на вашем пути вы осознали, что Linux это уже гораздо больше, чем просто хобби?

Линус Торвальдс: возможно, прозвучит слегка потешно, но, на самом деле, это произошло очень рано. Уже к концу девяносто первого (и определенно к началу девяносто второго) Linux вырос значительно сильнее, чем я ожидал.


Да, на тот момент у Linux было, пожалуй, всего несколько сотен пользователей (и даже пользователей слишком громко сказано люди просто возились с ним), и это, возможно, звучит странно, учитывая, насколько Linux вырос впоследствии. Но во многих отношениях, для меня лично, большой поворотный момент наступил, когда я осознал, что другие люди в самом деле пользуются Linux, заинтересованы им, и операционная система начала жить своей жизнью. Люди начали присылать патчи, и система начала делать гораздо больше, чем я изначально мог представить.

Думаю, X11 была портирована на Linux где-то в апреле девяносто второго (не верьте мне на слово, когда я припоминаю даты слиииишком давно дело было), а еще один серьезный шаг свершился, когда у системы вдруг появился GUI и целый новый набор возможностей.

Чтобы дать широкий контекст скажу, что в самом деле не начинал с наполеоновских планов или больших ожиданий. Это был личный проект, который вырос не из какой-нибудь большой мечты создать новую операционную систему, а в буквальном смысле несколько спонтанно, ведь я изначально просто сам хотел разобраться во входах и выходах моего нового железа для ПК.

Поэтому, когда я выпустил ту самую первую версию, посыл в самом деле был смотрите, что у меня получилось и, разумеется, я надеялся, что другие найдут мою работу интересной, но это не была по-настоящему серьезная, практически-ориентированная ОС. Это была скорее проверка концепции и просто личный проект, который я к тому моменту разрабатывал уже несколько месяцев.

Причем, переход от этого личного проекта к продукту, который оказался по-настоящему востребован у других, по которому мне стала приходить обратная связь (и багрепорты), а также кое-какие патчи все это стало для меня большойпеременой.

Просто пример одного по-настоящему фундаментального аспекта: исходная лицензия на копирайт формулировалась примерно так: допускается распространение в виде исходников, но не за деньги.

Дело в том, что для меня одна из проблем заключалась в реальной дороговизне коммерческого Unix (да, для бедного студента, потратившего все деньги на новый ПК, так и было), поэтому для меня было серьезно и важно обеспечить доступность исходного кода (так, чтобы люди могли с ним пошаманить), и я хотел, чтобы проект оставался открытым для людей вроде меня, которые просто не могли позволить себе альтернатив.

И я изменил лицензию в конце девяносто первого (или, может быть, в самом начале девяносто второго), поскольку нашлись те, кто хотел распространять систему на дискетах в локальных группах пользователей Unix, но при этом хотя бы отбить расходы на дискеты и компенсировать себе время, потраченное на копирование. Причем, я понял, что это, очевидно, совершенно оправданно, и что важнее всего было обеспечить не полную бесплатность, а свободную доступность исходников.

К чему это привело: мало того, что люди стали распространять Linux на собраниях в группах пользователей Unix, но и в считанные месяцы появились первые дистрибутивы для дискет, например, SLS и Slackware.

По сравнению с теми первыми, по-настоящему фундаментальными изменениями, все дальнейшие можно считать пошаговыми. Разумеется, некоторые из этих шагов были весьма велики (систему взяла на вооружение IBM, под мою систему портировали Oracle DB, состоялись первичные коммерческие предложения Red Hat, Android расцвел на смартфонах, т.д.), но лично мне эти события все равно казались не столь революционными, как люди, которых я даже не знаю, уже используют Linux.

Дж. А.: Вы когда-нибудь сожалели, что выбрали именно такую лицензию, либо завидовали тому, какие деньги сделали другие люди и компании на вашем детище?


ЛТ: Ничуть.

Во-первых, мне вполне хватает. Я не баснословно богат, но я хорошо оплачиваемый программист, занимаюсь любимым делом, сам составляю себе расписание. Не бедствую.

Но не менее важно, что я на 100% уверен: именно такая лицензия во многом определила успех Linux (и Git, если уж на то пошло). Думаю, всем причастным гораздо приятнее знать, что все в равных правах, и никого такая лицензия не выделяет.

Существует немало таких проектов с двойной лицензией, где за исходным владельцем остается коммерческая лицензия (можете использовать это в вашем проприетарном продукте при условии, что будете отчислять нам роялти), но, с другой стороны, продукт также доступен по GPL-подобной лицензии для использования в опенсорсных проектах.

Думаю, в такой ситуации действительно сложно выстроить сообщество, так как те, кто занимается опенсорсом, всегда будут сознавать, что они второй сорт. Плюс такая ситуация порождает массу лицензионной бумажной волокиты, чтобы избранные всегда сохраняли свои особые права. Таким образом, в проекте возникает серьезная пробуксовка.

С другой стороны, я видел много лицензированных опенсорсных проектов от BSD (или MIT, или т.п.), которые просто фрагментируются, как только становятся достаточно крупными и приобретают коммерческую важность, а компании, причастные к таким проектам, неизбежно решают запатентовать принадлежащие им фрагменты.

Итак, я считаю, что GPLv2 дает практически идеальный баланс все работают, и правила для всех одинаковы, но при этом все равно требует от людей отдачи сообществу (долг платежом красен). Причем, каждый знает, что все остальные люди, вовлеченные в проект, подчиняются одним и тем же правилам, поэтому весь процесс очень равноправный и честный.

Разумеется, обратный эффект заключается в том, что и вы получаете что-то от проекта, в который вложили силы. Разумеется, можно попытаться запрыгнуть на хвост проекту и оставаться обычным пользователем, почему нет. Но, избрав такой путь, вы нисколько не будете контролировать проект. Это также может быть вполне нормально, если вам нужна всего лишь непритязательная операционная система, а Linux уже делает все, что вам надо. Но, если у вас есть особые требования, то единственный реальный способ влиять на проект это участвовать в нем.

При таком подходе все честны. Включая меня. Каждый может сделать форк проекта и пойти своим путем, сказать: пока, Линус, у меня есть своя версия Linux, и ее поддержку я беру на себя. Я особенный только потому что и только до тех пор, пока люди доверяют мне за хорошо сделанную работу. Именно так и должно быть.

То, что каждый может поддерживать собственную версию вызывало у некоторых беспокойство по поводу версии GPLv2, но я вижу в этом достоинство, а не недостаток этой лицензии. Несколько парадоксально, но я считаю, что именно это и уберегло Linux от фрагментации: ведь каждый может сделать собственный форк проекта, и это нормально. На самом деле, именно в этом заключается один из ключевых принципов, на основе которых спроектированGit каждый клон репозитория это самостоятельный маленький форк, и люди (а также компании) ответвляют собственные версии, именно так и совершается весь процесс разработки.

Итак, форки не проблема, если потом при слиянии вы добавите в основную ветку только то, что получилось хорошо. Именно здесь вступает в дело GPLv2. Право сделать форк и развивать собственный проект очень важное, но в равной степени важна и другая сторона медали право впоследствии снова объединиться, если форк показал себя успешным.

Другая проблема в том, что вам по-настоящему нужны только инструменты для поддержания интересующего вас потока задач, но при этом вам также нужен подходящий менталитет для поддержки этого проекта. Серьезным препятствием для возвращения форков в общий проект являются не только вопросы лицензирования, но и дурная кровь. Если форк создается из чувства глубокого противоречия родительской ветке, то объединить две ветки обратно может быть очень сложно, причем, дело не в лицензировании и не в технических причинах, а в том, что форк был столь несочетаем с исходной версией. Опять же, думаю, что в Linux такого удалось в основном избежать, поскольку мы всегда относились к созданию форков как к делу совершенно естественному. Поэтому естественно воспринимается и обратное слияние, если какая-то работа успешно прошла проверку практикой.

Итак, отвечая на вопрос, я немного ушел в сторону, но считаю, что это важное отступление. Я решительно не жалею о том, какую лицензию выбрал, поскольку действительно считаю, что в GPLv2 заключается огромная часть успешности Linux.

На самом деле, деньги не столь серьезный мотиватор. Они не сплачивают людей. Думаю, что, если работаешь над общим проектом и чувствуешь, что реально можешь быть полноправным партнером в рамках этого проекта, то именно это и мотивирует людей.

Дж.А.: В наши дни, если кто-то выпускает исходный код по лицензии GPLv2, то делает это в основном ради работы с Linux. Как вы нашли лицензию, и сколько времени и сил у вас ушло на изучение других существующих лицензий?


ЛТ: В те времена в сообществе еще бушевали серьезные флеймы по поводу BSD и GPL (думаю, отчасти они разжигались из-за того, что у rms настоящий талант бесить людей), так что я встречал разные дискуссии на тему лицензирования только в разных новостных группах usenet, которые я читал (такие источники, какcomp.arch,comp.os.minixи т.д.).

Но двумя основными поводами были, пожалуй, банальный gcc который очень и очень поспособствовал тому, чтобы Linux набрал ход, поскольку мне был абсолютно необходим компилятор для C и Ларс Виржениус (Ласу), другой шведскоязычный студент с факультета компьютерных наук, с которым мы учились в университете на одном курсе (шведскоязычное меньшинство в Финляндии очень невелико).

Ласу гораздо активнее участвовал в дискуссиях по лицензированию и т.п., чем я.

Для меня выбор GPLv2 не был огромной дипломатической проблемой, а был обусловлен в основном тем фактом, что моя изначальная лицензия была столь импровизированной, и ее требовалось обновить, а еще я чувствовал себя в долгу перед gcc, и GPLv2 соответствовала моим ожиданиям исходники нужно отдавать.

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

Дж. А.: Каков ваш обычный день? Сколько времени вы тратите на написание кода, по сравнению с ревью кода и чтением/написанием электронной почты? Как вы находите баланс между личной жизнью и разработкой ядра Linux?

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

Иными словами, основной объем кода, который я пишу, скорее сводится к посмотри, а мы это делаем вот так и в данном случае патч очень конкретный пример. Легко увязнуть в какой-нибудь теоретической высокоуровневой дискуссии о том, как решить какую-нибудь задачу, и, на мой взгляд, что наилучший способ описать решение просто привести фрагмент кода, может быть, не весь код и максимально выпятить его именно таким образом.

Вот почему вся моя реальная работа сводится к чтению и написанию электронной почты. Это в основном коммуникация, а не написание кода. На самом деле, я считаю именно такую коммуникацию с журналистами и техническими блогерами, т.д., самой настоящей частью моего рабочего дня. Возможно, приоритет у этой работы ниже, чем у технических дискуссий как таковых, но я трачу немало времени и на решение таких вопросов.

Да, я уделяю время и ревью кода, но, честно говоря, к тому времени, как я получаю пул-реквест, код, вызвавший вопросы, уже успевает просмотреть множество других людей. Поэтому, хотя я и просматриваю патчи, больше внимания я уделяю объяснениям и истории патча, как он пришел ко мне. Причем, с людьми, с которыми мы работаем достаточно давно, я обхожусь даже без этого: все они занимаются поддержкой тех подсистем, за которые отвечают, и микроменеджмент по контролю их работы не мое дело.

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

Дж.А.: Какова ваша рабочая обстановка? Например, комфортнее ли вам работать в затемненной комнате, где ничего не отвлекает, либо в комнате с видовым окном? Вы склонны работать в тишине или под музыку? Какое аппаратное обеспечение вы обычно используете? Выполняете ревью кода в vi, в окне терминала или в навороченной IDE? И есть ли такой дистрибутив Linux, который вы предпочитаете для данной работы?


ЛТ: не могу сказать, что у меня в комнате темно, но я действительно прикрываю шторами окно у рабочего места, поскольку яркий солнечный свет мне не нравится (правда, в этот сезон в Орегоне его и так не слишком много;). Так что никаких панорам, только (заваленный) стол с двумя 4k мониторами имощным ПК под столом. И еще пара ноутбуков под рукой, для тестирования и на случай, если какая-то работа прилетит мне в дороге.

И я хочу работать в тишине. Возненавидел щелканье механических винчестеров, к счастью, они давно отправлены в утиль, поскольку я давно переключился на работу исключительно с SSD, вот уже более десяти лет как. Шумные процессорные вентиляторы для меня также неприемлемы.

Вся работа делается в традиционном терминале, хотя, я и не пользуюсь 'vi'. Я работаю с этим убогим micro-emacs, который не имеет ничего общего с emacs от GNU, с той оговоркой, что некоторые привязки клавиш у них похожи. Я привык работать с этим редактором еще в Хельсинском университете, будучи юнцом, и так и не смог от него отучиться, хотя, подозреваю, вскоре мне придется это сделать. Несколько лет я сварганил для него (очень ограниченную) поддержку utf-8, но редактор уже старый, и во всех его аспектах сквозит, что написан он был в 1980-е, а та версия, которой пользуюсь я это форк, не поддерживаемый с середины 90-х.

В Хельсинском университете этот редактор использовался, поскольку он работал под DOS, VAX/VMSиUnix, почему и мне довелось с ним познакомиться. А теперь он просто вшит мне в пальцы. На самом деле, давно пора переключиться на какую-то альтернативу, которая исправно поддерживается и как следует воспринимает utf-8. Пожалуй, попробую 'nano'. Мой же наспех слепленный антикварный мусор работает на том уровне вполне приемлемо, что у меня не возникало острой нужды переучивать мои старые пальцы на новые фокусы.

Итак, моя настольная рабочая среда весьма безыскусна: открыто несколько текстовых терминалов, еще браузер с почтой (плюс еще несколько вкладок, в основном с техническими и новостными сайтами). Я хочу, чтобы значительная часть рабочего стола была свободна, поскольку привык работать с достаточно большими окнами терминалов (100x40 можно сказать, таков у меня исходный размер окна по умолчанию), и у меня бок о бок открыто несколько окон терминала. Поэтому работаю с двумя мониторами по 4k.

На всех моих машинах я использую Fedora, не потому, что этот дистрибутив для меня однозначно предпочтителен, а потому, что я к нему привык. Меня не особо волнует выбор дистрибутива, я расцениваю дистрибутив в основном как вариант установки Linux на машине, как среду, в которой настроены все мои инструменты, так, чтобы я мог просто заменить ядро и сосредоточиться на работе с ним.

Дж. А.: Публичное обсуждение разработки ядра происходит впочтовой рассылке по ядру Linux, и трафик там запредельный. Как вы успеваете разгребать столько почты? Вы исследовали другие решения для совместной работы и коммуникации вне почтовой рассылки, либо простая почтовая рассылка чем-то идеально подходит для той работы, которую вы делаете?

ЛТ: О, я не читаю саму рассылку, посвященную разработке ядра, годами не читал. Там слишком много всего.

Нет, суть рассылки по разработке ядра в том, что она ставится в копию во всех дискуссиях (ладно -некоторыхрассылок по разработке ядра это касается, ведь их много и тогда традиционная lkml используется в качестве резервного варианта, если для заданного вопроса не находится более узконаправленная рассылка). Таким образом, когда к дискуссии подключается новичок, он может посмотреть историю и контекст проблемы, просто изучив рассылку, посвященную разработке ядра.

Таким образом, я привык, что подписан на эту рассылку, но вся почта из lkml, где я не указан в копии, у меня автоматически попадает в авто-архивацию, так что по умолчанию я ее не вижу. Но, когда какую-то проблему доводят до меня, я могу развернуть всю дискуссию по ней, поскольку она лежала у меня в электронной почте, а не просто во входящих, и ждала своего часа.

В настоящее время я предпочитаю использовать функционалlore.kernel.org, так как работает он очень хорошо, и вокруг него уже выстроены некоторые другие инструменты. Поэтому дискуссии можно не автоматически упаковывать в мои собственные почтовые архивы, а сохранять вот таким образом тогда они видны. Но общий поток задач концептуально остается прежним.

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

Причем, модель с почтовой рассылкой (и сопровождающими ее инструментами) в самом деле работает очень хорошо. Я не о том, что люди не пользуются иными средствами коммуникации кроме электронной почты (имею в виду и личные переговоры, и участие в почтовых рассылках): многим очень нравятся различные чаты в режиме реального времени (традиционный вариант IRC). Причем, хотя это и никогда не было моим коньком, очевидно, что многим нравится метод мозгового штурма. Но модель с списком рассылки, используемым в качестве архива работает очень хорошо, и бесшовно сшивается как с рассылкой патчей от разработчика к разработчику в электронных сообщениях, так и с отправкой отчетов по проблемам в виде электронной почты.

Итак, электронная почта остается основным каналом связи, по ней удобно обсуждать технические проблемы, поскольку патчи встраиваются в ту же среду, что и письма. Причем, почта работает сразу во всех часовых поясах, а это очень важно, когда сообщество так сильно рассредоточено географически.


Дж.А.: Я пристально следил за разработкой ядра на протяжении примерно десяти лет,вел на эту тему блог в KernelTrap и писал о новых возможностях по мере их развития. Бросил заниматься этим примерно к моменту выхода версии ядра 3.0, выпущенной спустя 8 лет, когда выходили версии с номерами 2.6.x. Можете ли резюмировать, какие наиболее интересные события произошли с ядром после релиза версии 3.0?

ЛТ: Эх. Это было так давно, что я даже не знаю, с чего начать резюмировать. Прошло уже десять лет с момента выхода версии 3.0, и за это десятилетие мы успели внести много технических изменений. Архитектура ARM выросла, и ARM64 стала одной из наших основных архитектур. Много-много новых драйверов и новая базовая функциональность.

В любом случае, что самое интересное за последнее десятилетие как нам удалось удержать действующую модель разработки по-настоящему ровной, и что в ней не изменилось.

За десятилетия мы попробовали много разных схем версионирования, у нас были разные модели разработки, но релиз 3.0 фактически оказался именно тем, в котором окончательно оформилась модель, используемая нами с тех пор по сей день. В этой версии мы, так сказать, официально заявили, что релизы выпускаются по времени, номера версий это просто номера, и в них нет никаких зависимостей компонентов.

Мы затеяли всю историю с привязкой релизов ко времени и с окном по сведению патчей (merge window) во времена 2.6.x, поэтому сама эта инициатива не нова. Но именно в 3.0 последние реликты у номера есть значение были выброшены на свалку.

У нас была и случайная схема нумерации (в основном до версии 1.0), у нас была целая модель нечетные минорные номера соответствует версии ядра, которая находится в разработке, четные означают стабильное ядро, готовое к продакшену, после чего в версиях 2.6.x мы перешли к модели с привязкой релизов по времени. Но у людей по-прежнему оставался вопрос Что должно произойти, чтобы увеличился мажорный номер. И в версии 3.0 было официально объявлено, что четный мажорный номер версии не несет никакой семантики, и что мы всего лишь стараемся придерживаться простой нумерации, с которой было бы удобно обращаться, и которая бы чрезмерно не разрасталась.

Итак, за последние десятилетия мы внесли совершенно колоссальные изменения (в Git легко посмотреть некоторую статистику в числовом выражении: примерно три четверти миллиона коммитов, сделанных 17 тысячами участников). Но сама модель разработки остается весьма ровной и стабильной.

Так, конечно, было не всегда. Первые двадцать лет в истории разработки ядра были полны поистине болезненных перемен в модели разработки. Последнее десятилетие получилось гораздо более предсказуемым в плане выхода релизов.

Дж.А.: На настоящий момент последний релиз 5.12-rc5. Как стандартизирован процесс релизов? Например, изменения какого рода попадают в -rc1, по сравнению с -rc2 и так далее? И в какой момент вы решаете, что очередной релиз готов к официальному выходу? Что происходит, если вы ошиблись, и после финального релиза приходится серьезно отойти назад, и как часто это случается? Как этот процесс развивался с годами?

ЛТ: Выше я на это уже указывал: сам процесс в самом деле хорошо стандартизирован, и остается таким на протяжении последнего десятилетия. Перед этим произошло несколько серьезных потрясений, но с 3.0 он работает практически как часы (на самом деле, это началось еще на несколько лет ранее во многих отношениях переход на Git положил начало современным процессам, и потребовалось время, прежде, чем все к этому привыкли).

Поэтому у нас была такая каденция с двухнедельным окном по сведению патчей, за которым следует примерно 6-8 недель, затрачиваемых на изучение кандидатов для релиза; думаю, такие циклы поддерживаются уже на протяжении примерно 15 лет.

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

Затем цикл повторяется поэтому релизы у нас происходят примерно с десятинедельным интервалом.

А критерии для релиза для меня заключаются в ощущении достаточной уверенности, которая, очевидно, в свою очередь основана на том, какие сообщения о проблемах по-прежнему продолжают приходить. Если в какой-нибудь области проблемы продолжают сохраняться на поздних этапах релизного цикла, то я весьма настойчиво требую все откатить и говорю займемся этим в одном из следующих релизов, когда как следует разберемся, что к чему, но в целом прибегать к таким мерам требуется достаточно редко.

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

Все эти аспекты на протяжении последних десяти лет практически не менялись, хотя, мы действительно значительно шире стали применять автоматизацию. Автоматизация тестирования ядра дело вообще сложное, отчасти потому, что значительная часть ядра приходится на драйверы, которые, очевидно, зависят от доступности аппаратного обеспечения. Но у нас есть несколько ферм, тестирующих как загрузку, так и производительность, а еще мы выполняем различные варианты рандомизированного нагрузочного тестирования. Все это за годы работы улучшилось.

Дж.А.: В прошлом ноябре, по вашим словам, вас впечатлили новые чипсеты ARM64 от Apple, поставленные в некоторых из их новых компьютеров. Вы следите за этими разработками, чтобы поддерживать их под Linux? Вижу, workбыладобавлена в for-next. Вероятно ли, что Linux будет грузиться на оборудовании Apple MacBook уже с появлением готовящегося ядра 5.13? Станете ли вы одним из ранних пользователей? Насколько велика для вас важность ARM64?

ЛТ: я очень эпизодически проверяю, как с этим дела, но пока там все на очень раннем этапе. Как вы правильно отметили, самый ранний вариант поддержки, вероятно, будет добавлен в ядро 5.13, но учитывайте пожалуйста, что мы в самом начале пути, и аппаратное обеспечение Apple пока еще не годится дляполезной работыпод Linux.

Основную проблему представляет не сама arm64, а драйверы для аппаратного обеспечения, сопутствующего этой архитектуре (в особенности это касается SSD и GPU). На данном раннем этапе работы мы успели привести в работоспособный вид некоторые весьма низкоуровневые штуки, которые пока не приносят никакой реальной пользы кроме первичного запуска оборудования. Пройдет еще какое-то время, прежде, чем эти разработки станут реальным вариантом, который можно попробовать.

Но улучшилось не только аппаратное обеспечение Apple сама инфраструктура для arm64 значительно выросла, и ядра процессора изменились от ни о чем до вполне конкурентоспособной альтернативы для серверного пространства. Еще не так давно серверное пространство arm64 представляло собой весьма унылое зрелище, но процессоры Graviton2 от Amazon и Altra от Ampere оба основаны на значительно улучшенной версии ARM Neoverse IP гораздо лучше альтернатив, имевшихся на рынке несколько лет назад.

Я уже более десяти лет дожидался, пока появится удобная машина с ARM, и ее до сих пор нет, но сейчас до нее гораздо ближе, чем было когда-то.

На самом деле могу сказать, что хотел машину с ARM гораздо дольше, еще в подростковые годы, причем, по-настоящему желанна была Acorn Archimedes, но из соображений цены и доступности пришлось удовлетвориться Sinclair QL (процессор M68008), а затем, конечно же, через несколько лет я сменил ее на i386 PC.

Несколько десятилетий казалось, что такая машина уже не за горами, но в широкой доступности ее по-прежнему не было, а также я не мог предпочесть ее другим компьютерам по соображениям производительности/цены. Когда-нибудь она появится. Надеюсь, не в столь отдаленном будущем.

Дж.А.: есть ли в ядре какие-то аспекты, которые сделаны не лучшим образом, но, чтобы поправить их как следует, пришлось бы полностью переписывать код? Иными словами, ядру 30 лет, и за эти 30 лет значительно изменились наши знания, языки программирования аппаратное обеспечение. Если бы сейчас вы переписывали ядро с нуля, то что бы вы в нем изменили?

ЛТ: на самом деле, намвесьмахорошо удавалось даже целиком переписывать некоторые вещи, если была такая необходимость, поэтому все детали, которые казались необезвреженными бомбами, давным-давно переписаны.

Естественно, у нас есть изрядное количество слоев, которые оставлены для обеспечения совместимости, но обычно там не ужас-ужас. Причем, неясно, а исчезнут ли эти слои для совместимости, если переписать все с нуля ведь они нужны для обратной совместимости со старыми бинарными файлами (а зачастую и для обратной совместимости со старыми архитектурами, например, для запуска 32-битных приложений для x86 на x86-64). Поскольку я считаю обратную совместимость очень важной, я хотел бы сохранить их даже в переписанной версии.

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

Нет, единственная серьезная причина для переписывания могла бы возникнуть лишь в случае, если бы обнаружился некоторый практический кейс, в котором вся структура действительно не имеет смысла. Наиболее вероятным примером такого рода в реальности могла бы оказаться какая-нибудь маленькая встраиваемая система, которой не нужно ничего, что сегодня может предложить Linux, а на уровне аппаратного обеспечения ее отпечаток столь мал, что этой системе попросту нужно нечто помельче и попроще, чем операционная система, какой за годы развития стал Linux.

Ведь Linuxзначительновырос. Даже на небольших устройствах (вспомним мобильные телефоны, например) он сегодня гораздо мощнее, чем исходный Linux, который разрабатывался для машин своего времени.

Дж.А.: Как насчет хотя бы частично переписать ядро на Rust, языке, который разрабатывался именно с прицелом на производительность и безопасность? Есть ли пространство для улучшения в таком ключе? Как вы считаете, возможно ли, чтобы другой язык, например, Rust, заменил C в ядре?


ЛТ: Увидим. Не думаю, что Rustзакрепится в самой основе ядра, но писать на нем отдельные драйверы (или, может быть, целые подсистемы драйверов) не скажу, что это совершенно невероятно. Может быть, он и для файловых систем подойдет. Поэтому, скорее не заменить C, а дополнить наш код на C там, где это целесообразно .

Разумеется, на драйверы как таковые приходится примерно половина кода ядра, поэтому места для таких разработок много, но я не думаю, что кто-то в самом деле рассчитывает, что уже существующие драйверы будут переписаны на Rust целиком. Может быть, есть люди, желающие писать новые драйверы на Rust, а некоторые драйверы мы на нем действительно можем переписать, если это будет целесообразно.

Но прямо сейчас ситуация дошла только до люди пробуют его, играют с ним, не более. Легко подчеркивать преимущества, но здесь определенно есть и сложности. Поэтому я очень склонен подождать и понаблюдать, действительно ли обещанные сильные стороны Rust себя оправдают.

Дж.А.: Есть ли в ядре какие-либо конкретные элементы, которыми вы лично особенно гордитесь?

ЛТ: выдающиеся части, которые мне хочется лишний раз подчеркнуть это уровень VFS (виртуальная файловая система) (и поиск имени пути в частности) и наш код виртуальной машины. Первое просто потому, что в Linux некоторые из этих фундаментальных вещей (поиск имени файла по-настоящему базовая функциональность в операционной системе) выполнимы намного лучше, чем во многих других ОС. А второе в основном потому, что мы поддерживаем более 20 архитектур, и по-прежнему делаем это при помощи в основном унифицированного уровня виртуальной машины, что, на мой взгляд, весьма впечатляет.

Но, в то же время, все это во многом проистекает из какая из частей ядра вам наиболее интересна. Ядро достаточно велико, чтобы разные разработчики (и разные пользователи) просто придерживались разных мнений по поводу того, что в нем наиболее важно. Некоторым кажется, что планирование задач наиболее захватывающая функция ядра. Другим нравится вникать в тонкости драйверов устройств (а у нас таких много). Лично я сильнее вовлечен в работу над VM и VFS, поэтому, естественно, указываю на них.

Дж.А.: Я нашел вот такое описание поиска имени пути, и он сложнее, чем я ожидал. Почему реализация этой функции в Linux настолько лучше, чем в любых других операционных системах? И что для вас означает лучше?

ЛТ: Поиск имени пути это поистине настолько обычная и фундаментальная вещь, что почти никто вне круга разработчиков ядра не считает, что это проблема. Они просто открывают файлы и принимают это как должное.

Но на самом деле очень сложно добиться, чтобы это работало как следует. Именно потому, что поиск имени пути все время происходит буквально везде, и поэтому данная задача критически сказывается на производительности; кроме того, это именно та область, в которой требуется хорошо масштабироваться при работе в средах SMP, блокировки при выполнении таких задач сопряжены с немалой сложностью. А вы хотите свести к минимуму какие-либо операции ввода/вывода, поэтому кэширование это очень важно. На самом деле, поиск имени пути настолько важен, что его нельзя выполнять на низком уровне файловой системы, ведь у нас более 20 различных файловых систем, и реализация в каждой из них собственных механизмов кэширования и блокировок стала бы подлинной катастрофой.

Итак, одна из основных задач, решаемых на уровне VFS это обработка всего кэширования и всех блокировок, связанных с компонентами имени пути, а также с обработкой всех операций, касающихся сериализации и обхода точек монтирования, причем, все это делается в основном при помощи неблокирующих алгоритмов (RCU), а также с применением весьма умных сущностей, напоминающих блокировки (блокировка lockref, предусмотренная в Linux это очень особенная спин-блокировка с подсчетом ссылок, буквально предназначенная для кэширования dcache, и, в принципе, это специализированный механизм подсчета ссылок, учитывающий блокировки, который в определенных типичных ситуациях может выполнять исключение блокировок).

В итоге: низкоуровневые файловые системы все равно должны искать вещи, которые не кэшированы, но на их уровне не приходится беспокоиться о кэшировании и соблюдении правил согласованности и атомарности, которые должны соблюдаться при поиске имени пути. Уровень VFS все это обрабатывает за них.

Причем, в этом Linux успешнее, чем какая-либо другая операционная система, и это не мешает ему хорошо масштабироваться даже на машинах с тысячами CPU. Даже когда этим машинам приходится обращаться к одним и тем же каталогам (скажем, к корневому каталогу или домашнему каталогу проекта приходится одновременно обращаться даже в приложениях с сильно развитой многопоточностью, а такое по-поточное поведение не поддается какому-либо распределению).

Поэтому, здесь в Linux все не просто лучше, но даже Лучше с большой буквы Л. Ни одна другая система в этом и близко не сравнится с Linux. Механизм dcache просто единственный в своем роде.

Дж.А.: Прошлый год тяжело дался всему миру. Как пандемия COVID-19 повлияла на процесс разработки ядра Linux?

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

Да, в начале года ситуация повлияла на саммит по разработке ядра (и в этом году он пока также остается в подвешенном состоянии), а большинство конференций было отменено или переведено в виртуальный режим. Люди, работавшие в офисе, в основном стали работать из дома (но многие из тех, кто занимается поддержкой ядра, и раньше работали в таком режиме). Поэтому многие вещи изменились, но в основе своей процесс разработки ядра остался таким как прежде.

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



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Перевод 30 лет Линукса. Интервью с Линусом Торвальдсом. Часть 2

06.05.2021 14:05:14 | Автор: admin


Первая часть интервью.

Распределенная система контроля версий Git


Дж.А.: Linux только первая из ваших работ, глобально повлиявших на мир опенсорса. В 2005 году вы также создали Git, исключительно популярную распределенную систему контроля версий. Вы быстро перенесли дерево исходников ядра Linux из проприетарного хранилища Bitkeeper в новоиспеченный Git, который сделали опенсорсным, и в том же году передали поддержку Git Джунио Хамано. История этих событий увлекательна, расскажите, что побудило вас передать этот проект так быстро, и как вы нашли и выбрали Джунио?

ЛТ: Итак, ответ на этот вопрос состоит из двух частей.


Во-первых, я совершенно нехотел создавать новую систему контроля исходников. Linux был создан, так как мне очень интересен низкоуровневый интерфейс между аппаратным и программным обеспечением в принципе, эта работа была выполнена из любви к предмету и личного интереса. Напротив, Git был создан из необходимости: не потому, что я интересуюсь контролем исходников, а потому что большинство имевшихся на тот момент систем контроля версий вызывали у меня подлинное отвращение, а та единственная, что показалась мне наиболее терпимой и при этом действительно весьма хорошо сочеталась с моделью разработки Linux (BitKeeper) стала несостоятельной.

Итог: я занимаюсь Linux более 30 лет (до годовщины первого релизаеще остается пара месяцев, но работать над тем, что впоследствии превратилось в Linux, я стал уже более 30 лет назад), и все это время занимаюсь его поддержкой. Но Git? Я даже не думал о том, чтобы поддерживать его в долгосрочной перспективе. Он мне определенно нравится, и я, конечно, считаю, что это наилучшая из имеющихся систем управления исходниками, но она не является моей большой любовью и увлечением, если вы понимаете, о чем я.

Поэтому я всегда хотел найти кого-то, кто поддерживал бы эту систему контроля исходников за меня; на самом деле, я был бы счастлив вообще не писать ее.

Таков контекст.

Что касается Джунио на самом деле, он один из первых, кто реально занялся разработкой Git. Первые изменения от него пришли мне в пределах нескольких дней после того, как я выложил в общий доступ самую первую (и весьма сырую) версию Git. Поэтому Джунио причастен к этому проекту, можно сказать, с самых первых дней Git.

Но не подумайте, что я просто передал проект первому встречному. Я поддерживал Git несколько месяцев, и что побудило меня поинтересоваться у Джунио, не хочет ли он взять эту поддержку на себя так это трудноуловимое чувство хорошего вкуса. В самом деле, не могу описать это точнее: программирование сводится к решению технических задач, но суть в том, как вы их решаете, и это одна из тех вещей, которые начинают распознаваться со временем: определенные люди обладают хорошим вкусом, и поэтому выбирают правильное решение.

Не хочу заявлять, что программирование это искусство, поскольку на самом деле программирование это в основном хорошая инженерия. Я глубоко верю в мантру Томаса Эдисона про один процент таланта и девяносто девять процентов усердия; практически вся суть успеха заключается в мелких деталях и ежедневной рутинной работе. Но, все-таки, иногда приходится проявить вдохновение и тот самый хороший вкус, то есть, не просто решить задачу, а решить ее чисто, аккуратно и да, даже красиво.

Вот у Джунио такой хороший вкус нашелся.

Всякий раз, когда заходит речь о Git, я не забываю предельно ясно подчеркнуть следующее: пусть я и был зачинателем Git и спроектировал его ключевые идеи, зачастую я получаю за это чрезмерно много признания. Это было больше 15 лет назад, и я был по-настоящему погружен в работу над Git только в течение первого года. Джунио образцово справляется с поддержкой Git, и именно благодаря нему Git стал тем, чем является сегодня.

Кстати, вся эта история с хорошим вкусом и подыскиванием людей, которые им обладают, а также с умением доверять этим людям касается не только Git, но и в не меньшей степени всей истории Linux. В отличие от Git, Linux это продукт, чьей поддержкой я до сих пор активно занимаюсь, но, чем Linux во многом похож на Git так это вовлеченностью огромного множества людей в проект. Думаю, одно из самых замечательных достижений Linux в том, что его поддержкой занимаются буквально сотни активных участников, и все они, отвечающие за разные части ядра, обладают этим трудноопределимым чувством вкуса.

Дж.А.: Доводилось ли вам когда-либо делегировать кому-то поддержку, а потом понять, что это решение было ошибочным?

ЛТ: структура нашей работы по поддержке никогда не была настолько черно-белой или негибкой, чтобы это доставляло нам какие-либо проблемы. На самом деле, маловероятно, что мы даже когда-нибудь попытаемся тщательно документировать процедуру поддержки. Да, у нас есть файл MAINTAINERS, но он создан для того, чтобы можно былонайтинужных людей, это в самом деле не знак какого-то исключительного обладания.

Поэтому вся структура кто чем владеет в основном пластична и предназначена для ориентирования, означает этот человек активен и хорошо справляется со своей работой, а не упс, мы доверили человеку проект, а он взял и все запорол.

Ситуация пластична и в том смысле, что, может быть, вы занимаетесь поддержкой одной подсистемы, но вам что-то нужно подхватить из другой системы так вот, эти границы проницаемы. Обычно такие вещи сначала активно обсуждаютсяс людьми, а лишь потом делаются, но суть в том, что такая практика есть, и не существует жестких правил вроде вам можно прикасаться только к этому файлу.

Фактически, здесь мы вновь затрагиваем тему лицензирования, поднятую в первой части, и подчеркиваем один из принципов, по которым спроектирован Git, а именно у каждого есть собственное дерево, и технически ни одно дерево не является особенным.

Поскольку во многих других проектах использовались такие инструменты как CVS или SVN фундаментально некоторые люди действительно становятся особенными и пользуются обладанием, которое приходит вместе с этим статусом. В мире BSD этот феномен называется бит подтверждения (commit bit): это разряд, обладатель которого имеет право фиксировать код в центральном репозитории (или, как минимум, некоторых его частях).

Я всегда терпеть не мог такую модель, поскольку она неизбежно сказывается на политике и порождает в сообществе разработчиков клику, когда некоторые люди становятся привилегированными, и им по умолчанию доверяют. Проблема даже не в том, что по умолчанию доверяют, а как раз в другой стороне медали: кому-то, другим людям, не доверяют, и они по определению оказываются аутсайдерами, которым для выполнения работы нужно пройти кого-то из охранителей.

Опять же, в Git такой ситуации не возникает. Все равны. Каждый может клонировать ветку, начать собственную разработку, и, если они хорошо справятся с работой, то при объединении их ветка может вернуться в основную, а если очень хорошо то им поручается поддержка, и именно они начинают отвечать за слияние кода в тех деревьях, за которые отвечают ;).

Поэтому не приходится наделять людей особыми привилегиями, таким битом подтверждения. Это также означает, что не возникает политики, связанной с коммитами, не приходится никому по умолчанию доверять. Если оказалось, что кто-то плохо справился с работой, либо, что чаще, человек просто охладел к проекту и нашел дело поинтереснее их наработки не попадут в основную ветку при объединении, и они не будут путаться под ногами у других, кто может предложить новые, свежие идеи.

Дж.А.: Впечатляли ли вас когда-нибудь новые возможности Git, включали ли вы их в свои рабочие процессы? Можете ли назвать такие фичи, которых, на ваш взгляд, в Git до сих пор не хватает?

ЛТ: разумеется, в первую очередь были удовлетворены именно мои пожелания по функционалу, поэтому мне редко приходилось задумываться о каких-либо новых фичах.

С годами Git определенно улучшился, и некоторые такие подвижки отразились и на моих рабочих процессах. Например, Git всегда работал весьма быстро в конце концов, это была одна из целей, которые я поставил при проектировании, но значительная часть работы исходно делалась в виде шелл-скриптов, организованных вокруг некоторых базовых вспомогательных программ. С годами большая часть этих шелл-скриптов ушла, это означает, что я могу применять комплекты патчей от Эндрю Мортона даже быстрее, чем это делалось изначально. Это очень радует, поскольку именно скорость работы с патчами я использовал в качестве одного из первых контрольных показателей при тестировании производительности.

Итак, для меня Git всегда был хорош, но со временем стал только лучше.

Значительныеулучшения связаны с тем, насколько удобнее стало регулярным пользователям работать с Git. Во многом благодаря тому, что люди разобрались, как в Git устроен поток задач, и просто привыкли к нему (оноченьотличается от CVS и других аналогов, к которым люди привыкли ранее), но и сам Git стал гораздо приятнее в использовании.



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Карантин для динамической памяти ядра Linux

30.12.2020 02:06:40 | Автор: admin

2020 год. Повсюду карантин. И эта статья тоже про карантин, но он другого рода.


Я расскажу об экспериментах с карантином для динамической памяти ядра Linux. Это механизм безопасности, противодействующий использованию памяти после освобождения (use-after-free или UAF) в ядре Linux. Я также подведу итоги обсуждения моей патч-серии в списке рассылки ядра (Linux Kernel Mailing List, LKML).


image


Использование памяти после освобождения в ядре Linux


UAF в ядре Linux очень популярный для эксплуатации тип уязвимостей. Есть множество публичных прототипов ядерных эксплойтов для UAF:



Для эксплуатации UAF обычно применяется техника heap spraying. Цель данной техники разместить данные, контролируемые атакующим, в определенном участке динамической памяти, которая также называется кучей. Техника heap spraying для эксплуатации UAF в ядре Linux основана на том, что при вызове kmalloc() slab-аллокатор возвращает адрес участка памяти, который был недавно освобожден:


image


То есть создание другого ядерного объекта такого же размера с контролируемым содержимым позволяет переписать освобожденный уязвимый объект:


image


Примечание: heap spraying для эксплуатации переполнения буфера в куче отдельная техника, которая работает иначе.


Идея


В июле 2020 года у меня возникла идея, как можно противостоять технике heap spraying для эксплуатации UAF в ядре Linux. В августе я нашел время поэкспериментировать. Я выделил карантин для slab-аллокатора из функциональностиKASANи назвал его SLAB_QUARANTINE.


При активации этого механизма освобожденные аллокации размещаются в карантинной очереди, где ожидают реального освобождения. Поэтому они не могут быть мгновенно реаллоцированы и переписаны эксплойтами UAF. То есть при активацииSLAB_QUARANTINE аллокатор ядра ведет себя следующим образом:


image


13 августа я отправил первый ранний прототип карантина в LKML и начал более глубокое исследование параметров безопасности этого механизма.


Свойства безопасности SLAB_QUARANTINE


Для исследования свойств безопасности карантина для динамической памяти ядра я разработал два тестаlkdtm(опубликованы в серии патчей).


Первый тест называетсяlkdtm_HEAP_SPRAY. Он выделяет и освобождает один объект из отдельногоkmem_cache, а затем выделяет 400 000 аналогичных объектов. Другими словами, этот тест имитирует оригинальную технику heap spraying для эксплуатации UAF:


#define SPRAY_LENGTH 400000    ...    addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);    ...    kmem_cache_free(spray_cache, addr);    pr_info("Allocated and freed spray_cache object %p of size %d\n",                    addr, SPRAY_ITEM_SIZE);    ...    pr_info("Original heap spraying: allocate %d objects of size %d...\n",                    SPRAY_LENGTH, SPRAY_ITEM_SIZE);    for (i = 0; i < SPRAY_LENGTH; i++) {        spray_addrs[i] = kmem_cache_alloc(spray_cache, GFP_KERNEL);        ...        if (spray_addrs[i] == addr) {            pr_info("FAIL: attempt %lu: freed object is reallocated\n", i);            break;        }    }    if (i == SPRAY_LENGTH)        pr_info("OK: original heap spraying hasn't succeeded\n");

Если отключитьCONFIG_SLAB_QUARANTINE, освобожденный объект немедленно реаллоцируется и переписывается:


  # echo HEAP_SPRAY > /sys/kernel/debug/provoke-crash/DIRECT   lkdtm: Performing direct entry HEAP_SPRAY   lkdtm: Allocated and freed spray_cache object 000000002b5b3ad4 of size 333   lkdtm: Original heap spraying: allocate 400000 objects of size 333...   lkdtm: FAIL: attempt 0: freed object is reallocated

Если включитьCONFIG_SLAB_QUARANTINE, 400 000 новых аллокаций не переписывают освобожденный объект:


  # echo HEAP_SPRAY > /sys/kernel/debug/provoke-crash/DIRECT   lkdtm: Performing direct entry HEAP_SPRAY   lkdtm: Allocated and freed spray_cache object 000000009909e777 of size 333   lkdtm: Original heap spraying: allocate 400000 objects of size 333...   lkdtm: OK: original heap spraying hasn't succeeded

Это происходит из-за того, что для прохождения объекта через карантин требуется и выделение, и освобождения памяти. Объекты выпускаются из карантина, когда выделяется новая память, но только если карантин превысил свой предельный размер. А размер карантина увеличивается при освобождении памяти.


Поэтому я разработал второй тест под названиемlkdtm_PUSH_THROUGH_QUARANTINE. Он выделяет и освобождает один объект из отдельногоkmem_cacheи затем выполняет kmem_cache_alloc()+kmem_cache_free()для этого кэша 400000 раз.


    addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);    ...    kmem_cache_free(spray_cache, addr);    pr_info("Allocated and freed spray_cache object %p of size %d\n",                    addr, SPRAY_ITEM_SIZE);    pr_info("Push through quarantine: allocate and free %d objects of size %d...\n",                    SPRAY_LENGTH, SPRAY_ITEM_SIZE);    for (i = 0; i < SPRAY_LENGTH; i++) {        push_addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);        ...        kmem_cache_free(spray_cache, push_addr);        if (push_addr == addr) {            pr_info("Target object is reallocated at attempt %lu\n", i);            break;        }    }    if (i == SPRAY_LENGTH) {        pr_info("Target object is NOT reallocated in %d attempts\n",                    SPRAY_LENGTH);    }

При этом тесте объект проходит через карантин и реаллоцируется после своего возвращения в список свободных объектов аллокатора:


  # echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/   lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE   lkdtm: Allocated and freed spray_cache object 000000008fdb15c3 of size 333   lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...   lkdtm: Target object is reallocated at attempt 182994  # echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/   lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE   lkdtm: Allocated and freed spray_cache object 000000004e223cbe of size 333   lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...   lkdtm: Target object is reallocated at attempt 186830  # echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/   lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE   lkdtm: Allocated and freed spray_cache object 000000007663a058 of size 333   lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...   lkdtm: Target object is reallocated at attempt 182010

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


В карантине объекты хранятся в пачках. Рандомизация работает так: сначала все пачки наполняются ядерными объектами. А потом, когда карантин превысил предельный размер и должен выпустить лишние объекты, выбирается произвольная пачка, из которой освобождается примерно половина всех ядерных объектов, которые тоже выбираются произвольно. Теперь карантин отпускает освобожденный объект в непредсказуемый момент:


   lkdtm: Target object is reallocated at attempt 107884   lkdtm: Target object is reallocated at attempt 265641   lkdtm: Target object is reallocated at attempt 100030   lkdtm: Target object is NOT reallocated in 400000 attempts   lkdtm: Target object is reallocated at attempt 204731   lkdtm: Target object is reallocated at attempt 359333   lkdtm: Target object is reallocated at attempt 289349   lkdtm: Target object is reallocated at attempt 119893   lkdtm: Target object is reallocated at attempt 225202   lkdtm: Target object is reallocated at attempt 87343

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


Поэтому важно очищать объекты ядерной кучи до помещения их в карантин. Более того, заполнение их нулями в некоторых случаях позволяет обнаружить использование памяти после освобождения: происходит разыменование нулевого указателя. Такой функционал уже существует в ядре и называетсяinit_on_free.Я интегрировал его с CONFIG_SLAB_QUARANTINE.


В ходе этой работы я обнаружил ошибку в ядре: вCONFIG_SLAB функцияinit_on_freeосуществляется слишком поздно, и ядерные объекты отправляются на карантин без очистки. Я подготовил исправление в отдельном патче (принят в mainline).


Для более глубокого понимания того, как работает CONFIG_SLAB_QUARANTINE с рандомизацией, я подготовил дополнительный патч, с подробным отладочным выводом (патч не для принятия в mainline). Пример такого отладочного вывода:


quarantine: PUT 508992 to tail batch 123, whole sz 65118872, batch sz 508854quarantine: whole sz exceed max by 494552, REDUCE head batch 0 by 415392, leave 396304quarantine: data level in batches:  0 - 77%  1 - 108%  2 - 83%  3 - 21%  ...  125 - 75%  126 - 12%  127 - 108%quarantine: whole sz exceed max by 79160, REDUCE head batch 12 by 14160, leave 17608quarantine: whole sz exceed max by 65000, REDUCE head batch 75 by 218328, leave 195232quarantine: PUT 508992 to tail batch 124, whole sz 64979984, batch sz 508854...

ОперацияPUT осуществляется в ходе освобождения ядерной памяти. ОперацияREDUCEосуществляется в ходе выделения ядерной памяти, когда превышен размер карантина. Объекты ядра, выпущенные из карантина, возвращаются в список свободных объектов аллокатора. Также в этом выводе можно видеть, что при осуществлении операции REDUCE карантин отпускает часть объектов из произвольно выбранной пачки.


А что с производительностью?


Я провел несколько тестов производительности моего прототипа на реальном оборудовании и на виртуальных машинах:


  1. Тестирование пропускной способности сети с помощьюiperf:
    сервер: iperf -s -f K
    клиент: iperf -c 127.0.0.1 -t 60 -f K
  2. Нагрузочный тест ядерного планировщика задач:
    hackbench -s 4000 -l 500 -g 15 -f 25 -P
  3. Сборка ядра в конфигурации по умолчанию:
    time make -j2

Я тестировал ванильное ядро Linux в трех режимах:


  • init_on_free=off
  • init_on_free=on (механизм из официального ядра)
  • CONFIG_SLAB_QUARANTINE=y (включает в себя init_on_free)

Тестирование пропускной способности сети с помощьюiperfпоказало, что:


  • init_on_free=on дает пропускную способность на28% ниже, чем init_on_free=off.
  • CONFIG_SLAB_QUARANTINE дает пропускную способность на2%ниже, чем init_on_free=on.

Нагрузочный тест ядерного планировщика задач:


  • hackbench работает на5,3%медленнее с init_on_free=on по сравнению с init_on_free=off.
  • hackbench работает на 91,7%медленнее с CONFIG_SLAB_QUARANTINE по сравнению с init_on_free=on. При этом тестирование на виртуальной машине QEMU/KVM показало снижение производительности на 44%, что существенно отличается от результатов тестирования на реальном оборудовании (Intel Core i7-6500U CPU).

Сборка ядра в конфигурации по умолчанию:


  • При init_on_free=on сборка ядра осуществлялась на 1,7% медленнее, чем с init_on_free=off.
  • При CONFIG_SLAB_QUARANTINEсборка ядра осуществлялась на 1,1% медленнее, чем с init_on_free=on.

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


Примечание: для этой версии прототипа карантина НЕ проводилась оптимизация производительности. Моей главной задачей было исследование свойств безопасности механизма. Я решил, что оптимизацией производительности лучше заняться позже, если станет ясно, что идея удачная.


Контратака


В LKML получилось интересное обсуждение CONFIG_SLAB_QUARANTINE. Спасибо разработчикам ядра, которые уделили время и дали детальную обратную связь на мою серию патчей. Это Кейс Кук (Kees Cook), Андрей Коновалов, Александр Потапенко, Мэттью Уилкокс (Matthew Wilcox), Дэниел Майкей (Daniel Micay), Кристофер Ламетер (Christopher Lameter), Павел Мачек (Pavel Machek) и Эрик Бидерман (Eric W. Biederman).


Особенно я благодарен Яну Хорну (Jann Horn) из команды Google Project Zero. Он придумал контратаку, с помощью которой все-таки удается обойти CONFIG_SLAB_QUARANTINE и проэксплуатировать UAF в ядре Linux.


Примечательно, что наша дискуссия с Яном состоялась одновременно со стримом Кейса в Twitch, в ходе которого он тестировал мои патчи (рекомендую посмотреть запись).


Цитата из переписки с идеей контратаки:


On 06.10.2020 21:37, Jann Horn wrote:> On Tue, Oct 6, 2020 at 7:56 PM Alexander Popov wrote:>> So I think the control over the time of the use-after-free access doesn't help>> attackers, if they don't have an "infinite spray" -- unlimited ability to store>> controlled data in the kernelspace objects of the needed size without freeing them.   [...]>> Would you agree?>> But you have a single quarantine (per CPU) for all objects, right? So> for a UAF on slab A, the attacker can just spam allocations and> deallocations on slab B to almost deterministically flush everything> in slab A back to the SLUB freelists?Aaaahh! Nice shot Jann, I see.Another slab cache can be used to flush the randomized quarantine, so eventuallythe vulnerable object returns into the allocator freelist in its cache, andoriginal heap spraying can be used again.For now I think the idea of a global quarantine for all slab objects is dead.

То есть атакующий может воспользоваться другим slab-кэшем в ядерном аллокаторе, выделить и освободить в нем большое количество объектов, что приведет к вытеснению целевого объекта из карантина обратно в список свободных объектов. После этого атакующий может воспользоваться стандартной техникой heap spraying для эксплуатации UAF.


Я сразу поделился этой перепиской в чате стрима Кейса в Twitch. Он доработал мой тест PUSH_THROUGH_QUARANTINE по идее Яна и выполнил атаку. Бабах!


Очень советую прочитать эту переписку в LKML целиком. Там обсуждаются новые идеи противодействия эксплуатации UAF в ядре.


Заключение


Я исследовал свойства безопасности карантина для динамической памяти ядра Linux, провел эксперименты, показывающие его влияние на эксплуатацию уязвимостей use-after-free. Получился быстрый и интересный проект. Надежное средство защиты, примененное в mainline, создать не удалось, но мы получили полезные результаты и идеи, которые пригодятся в дальнейших работах по защите ядра Linux.


А пока что позвольте закончить небольшим стихотворением, которое пришло мне в голову перед сном:


  Quarantine patch version three  Won't appear. No need.  Let's exploit use-after-free  Like we always did ;)    -- a13xp0p0v
Подробнее..

Не простые проблемы простого устройства тачскрин

25.04.2021 00:12:13 | Автор: admin

Не простые проблемы простого устройства, ёмкостной тачскрин на ft5406.

Чистил однажды облако и нашел свои заметки по проблеме сенсорного экрана с ёмкостным тачскрином на ft5406. Вот так и появилась эта статья, а вдруг кто еще наткнётся на подобное во время разработки...

Наткнулся как-то на проблему с ёмкостным тачскрином на ft5406, сенсорный экран в один прекрасный момент просто подвисал и отказывался дальше работать. Ну, а теперь поподробней об этом.

Железо: marsboard sun7i A20.

ПО: uboot 2017, kernel 4.10, LUbuntu 16.04.

Суть проблемы.

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

Чтоб его оживить, приходилось перезагружать его виртуальный указатель через Xinput, система ввода Xorg, в консоли.

т.к. touchscreen показывал такую странную реакцию на xinput, то покопавшись в его недрах (xinput), были найдены два теста xinput test тест виртуального указателя и xinput test-xi2 - тоже такой же тест, но по протоколу XI 2.0 (The X Input Extension 2.x), просто более адаптирован под мультитач. Можно почитать про него тут (https://www.x.org/wiki/Development/Documentation/Multitouch/). В общем, если первый тест не показал никакой реакции на сенсорный экран, то второй - исправно показал перемещение координат.

Опускаемся на уровень ниже, тест работы модуля ядра evt-ft5x06 (драйвер для ft5406) evtest, немножко нервов и ожидания, показал интересные результаты. Периодически, по любой из пяти точек, поддерживаемых контроллером сенсорного экрана, проскакивает координата вне предела панели. Эта координата драйвером ввода xserver-xorg-input-evdev воспринимается как нажатие, а событие отжатия (есть такое на ft5406) не приходит и до тех пор, пока не тыкнешь в экран количеством пальцев соответствующим номеру проскочившей координаты (хорошо что их пять и хватало пятерни) сенсорная панель будет висеть, думая что событие нажатия еще не окончено.

Проба установки других драйверов экрана, таких как:

  • xserver-xorg-input-libinput

  • xserver-xorg-input-mtrack

И прописывание их в /usr/share/X11/xorg.conf.d ситуацию не изменили, если libinput полностью повторил оригинальный драйвер, то mtrack хоть и избавил от проблемы, но то, что он ориентирован только на touchpad,поставило на нем крест. Т.к. сенсорный экран становился, как большой touchpad с полным несоответствием координат, и никакая калибровка при загрузке не помогла.

т.к. вышеуказанное показало свою несостоятельность в режиме multitouch, а протокол XI 2.0 еще находится в разарботке, то решение было найдено на уровне модулей ядра.

Решено было запретить прерывания всех точек, кроме первой, а так как это для уровня devicetree (файл devicetree используется для настройки kernell при загрузке, он стал основным методом конфигурирвания после версии ядра 3.11) не реализовано, то делалось это прямо в модуле и свелось все практически к запятой...

В исходниках ядра по адресу /drivers/input/touchscreen/ находим файл модуля edt-ft5x06.c и в самом конце в структуре:

static const struct edt_i2c_chip_data edt_ft5x06_data = {      ...    .max_support_points = 5,     ...}; 

Исправляем 5 на 1. И становится у нас сенсорная панель с одновременной обработкой только одной координаты.

Дальнейшая сборка и запуск ядра показали работоспособность сенсорной панели. Ошибки, конечно, проскакивают и по первой координате, но лишний раз ткнуть в экран одним пальцем куда проще чем пятернёй одновременно.

Конечно, можно еще указать обновление координат при сбое в обработчике прерывания

static irqreturn_t edt_ft5x06_ts_isr(int irq, void *dev_id)

, но это уже не принципально.

Вместо заключения.

В связке контроллер панели и сама сенсорная панель так и не удалось выяснить кто виноват. Но основные подозрения падали на саму панель, т.к. через некоторое время после работы (считай прогрева) левые координаты переставали идти.

Подробнее..

Перевод Неблокирующие паттерны атомарные операции и частичные барьеры памяти

13.03.2021 16:07:18 | Автор: admin

В первой статье цикла мы познакомились с простыми неблокирующими алгоритмами, а также рассмотрели отношение happens before, позволяющее их формализовать. Следующим шагом мы рассмотрим понятие гонки данных (data race), а также примитивы, которые позволяют избежать гонок данных. После этого познакомимся с атомарными примитивами, барьерами памяти, а также их использованием в механизме seqcount.


С барьерами памяти некоторые разработчики ядра Linux уже давно знакомы. Первый документ, содержащий что-то похожее на спецификацию гарантий, предоставляемых ядром при одновременном доступе к памяти он так и называется: memory-barriers.txt. В этом файле описывается целый зоопарк барьеров вместе с ожидаемым поведением многопоточного кода вядре. Также там описывается понятие парных барьеров (barrier pairing), что похоже на пары release-acquire операций и тоже помогает упорядочивать работу потоков.


В этой статье мы не будем закапываться так же глубоко, как memory-barriers.txt. Вместо этого мы сравним барьеры с моделью acquire и release-операций и рассмотрим, как они упрощают (или, можно сказать, делают возможной) реализацию примитива seqcount. К сожалению, даже если ограничиться лишь наиболее популярными применениями барьеров это слишком обширная тема, поэтому о полных барьерах памяти мы поговорим в следующий раз.



Гонки данных и атомарные операции


Рассматриваемое здесь определение гонок данных впервые сформулировано в C++11 и с тех пор используется многими другими языками, в частности, C11 и Rust. Все эти языки довольно строго относятся к совместному доступу к данным без использования мьютексов: так позволяется делать только со специальными атомарными типами данных, используя атомарное чтение и атомарную запись в память.


Гонка данных возникает между двумя операциями доступа к памяти, если 1) они происходят одновременно (то есть, не упорядочены отношением A происходит перед B), 2) одна из этих операций это запись, и 3) хотя бы одна из операций не является атомарной. Врезультате гонки данных (сточки зрения C11/C++11) может произойти что угодно неопределённое поведение. Отсутствие гонок данных ещё не означает невозможность состояний гонки (race conditions) валгортимах: гонка данных это нарушение стандарта языка, а состояние гонки это ошибка вреализации алгортима, вызванная неправильным использованием мьютексов, acquire-release семантики, или и того и другого.


Избежать гонок данных и вызываемого ими неопределённого поведения очень легко. Самый простой способ это работать с данными только из одного потока. Если данные должны быть доступны другим потокам, то работа с ними должна быть упорядочена нужным вам образом с помощью acquire и release-операций. Наконец, вы можете воспользоваться атомарными операциями.


C11, C++11, Rust предоставляют целый спектр атомарных операций доступа к памяти, гарантирующих некоторый порядок доступа (memory ordering). Нас интересуют три вида: acquire (для чтения), release (для записи), и relaxed (для того и другого). Что делают acquire и release вам уже должно быть ясно, в ядре Linux это называется smp_load_acquire() и smp_store_release(). А вот relaxed-операции обеспечивают так называемый нестрогий порядок доступа к памяти. Нестрогие операции не создают никаких отношений порядка между потоками. Их единственная задача это предотвратить гонку данных и избежать нарушения строгой буквы стандарта.


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


Кроме того, компиляторы порой очень изобретательны при оптимизациях и им очень многое позволяется делать, при условии сохранении наблюдаемого поведения в одном потоке (даже ценой неожиданного поведения во всех остальных потоках). Так что идея нестрого упорядоченного нострого атомарного доступа к памяти оказывается полезна и в ядре Linux, где для этих целей существуют макросы READ_ONCE() и WRITE_ONCE(). Считается хорошим стилем использовать READ_ONCE() и WRITE_ONCE(), когда вам нужны явные операции с памятью, что мы отныне и будем делать в примерах кода.


Эти макросы уже встречались в первой части:


    поток 1                           поток 2    -----------------------------     ------------------------    a.x = 1;    smp_wmb();    WRITE_ONCE(message, &a);          datum = READ_ONCE(message);                                      smp_rmb();                                      if (datum != NULL)                                        printk("%x\n", datum->x);

Они похожи на smp_load_acquire() и smp_store_release(), но первый аргумент тут является lvalue, а неуказателем (внимание на присваивание message в потоке1). При отсутствии иных механизмов, избегающих гонок данных (вроде захваченного спинлока), настоятельно рекомендуется использовать READ_ONCE() и WRITE_ONCE() при обращении к данным, доступным другим потокам. Сами эти операции не упорядочены, но всегда используются вместе с чем-то ещё, вроде другого примитива или механизма синхронизации, который уже обладает release и acquire семантикой. Так атомарные операции оказываются в итоге упорядочены нужным образом.


Пусть, например, у вас есть struct work_struct, которые в фоне затирают ненужные массивы единичками. После запуска задачи у вас есть другие важные дела и массив вам не нужен. Когда понадобится, то вы делаете flush_work() и гарантированно получаете единички. flush_work(), как и pthread_join(), обладает acquire-семантикой и синхронизируется с завершением struct work_struct. Поэтому читать из массива можно и обычными операциями чтения, которые гарантированно произойдут после записи, выполненной задачей. Однако, если вы забиваете единичками регионы, которые могут пересекаться и обновляться из нескольких потоков, то им следует использовать WRITE_ONCE(a[x],1), а не просто a[x]=1.


Всё становится сложнее, если release и acquire-семантика обеспечивается барьерами памяти. Рассмотрим в качестве примера реальный механизм seqcount.



Seqcounts


Seqcount (sequence counter) это специализированный примитив, сообщающий вам, а не изменилась ли структура данных, пока вы с ней работали. У seqcounts довольно узкая зона применимости, где они показывают себя хорошо: защищаемых ими данных должно быть немного, при чтении не должно быть никаких побочных эффектов, а записи должны быть сравнительно быстрыми и редкими. Но зато читатели никогда не блокируют писателей и никак не влияют на их кеш. Эти довольно существенные преимущества, когда вам нужна масштабируемость.


Seqcount работает с одним писателем и множеством читателей. Обычно seqcount комбинируется смьютексом или спинлоком для того, чтобы гарантировать эксклюзивный доступ писателям; врезультате получается примитив seqlock_t, как его называют в Linux. Вне ядра слова seqlock и seqcount порой используются как синонимы.


Фактически, seqcount это счётчик поколений. Нечётный номер поколения означает, что в этот момент времени со структурой данных работает писатель. Если читатель увидел нечётный номер на входе в критическую секцию или если номер изменился на выходе из критической секции, то структура данных возможно изменялась. Читатель мог увидеть лишь несогласованную часть этих изменений, так что ему следует повторить всю свою работу с начала. Для корректного функционирования seqcount читатель должен корректно опознавать начало и конец работы писателя. По паре load-acquire и store-release операций на каждую сторону. Если раскрыть все макросы, то простая [и неправильная] реализация seqcount на уровне отдельных операций с памятью выглядит примерно так:


    поток 1 (писатель)                 поток 2 (читатель)    --------------------------------    ------------------------                                        do {    WRITE_ONCE(sc, sc + 1);                 old = smp_load_acquire(&sc) & ~1;    smp_store_release(&data.x, 123);        copy.y = READ_ONCE(data.y);    WRITE_ONCE(data.y, 456);                copy.x = smp_load_acquire(&data.x);    smp_store_release(&sc, sc + 1);     } while(READ_ONCE(sc) != old);

Этот код слегка похож на передачу сообщений из первой части. Здесь видно две пары load-acquire store-release операций: для sc и для data.x. И довольно легко показать, что они обе необходимы:


  • Когда поток 2 выполняется после потока1, то при первом чтении sc он должен увидеть значение, которое поток1 туда записал вторым присваиванием. Пара smp_store_release() и smp_load_acquire() здесь гарантирует, что чтение произойдёт после записи.
  • Когда потоки исполняются одновременно, то если поток2 уже увидел новое значение какого-либо поля пусть data.x то он должен увидеть и новое значение sc при проверке цикла. Пара smp_store_release() и smp_load_acquire() здесь гарантирует, что как минимум первый инкремент sc будет видно и поток 2 уйдёт на второй круг.

Вопрос на самопроверку: зачем читатель делает & ~1?


Но если внимательно присмотреться и подумать*, то в коде есть хитрая ошибка! Так как писатель неделает ни одной acquire-операции, то присваивание data.y в принципе может произойти ещё до первого инкремента sc. Конечно, можно психануть и делать вообще всё исключительно через load-acquire/store-release, но это пальба из пушки по воробьям и только маскирует проблему. Если подумать ещё чуть-чуть, то можно найти правильное и эффективное решение.
________
* Вот я, например, сразу не заметил этой ошибки и мне уже в комментариях подсказали.


В первой статье мы видели, что порой в Linux используют WRITE_ONCE() и smp_wmb() вместо smp_store_release(). Аналогично, smp_rmb() и READ_ONCE() вместо smp_load_acquire(). Эти частичные барьеры памяти создают особый тип отношений порядка между потоками. А именно, smp_wmb() делает все последующие неупорядоченные присваивания release-операциями, а smp_rmb(), соответственно, превращает предыдущие неупорядоченные чтения в load-acquire. (Строго говоря, это несовсем так, но примерно так о них можно думать.)


Попробуем улучшить работу с полями data:


    поток 1 (писатель)                 поток 2 (читатель)    ------------------------------      ------------------------    // write_seqcount_begin(&sc)        do {    WRITE_ONCE(sc, sc + 1);                 // read_seqcount_begin(&sc)    smp_wmb();                              old = smp_load_acquire(&sc) & ~1;    WRITE_ONCE(data.x, 123);                copy.y = READ_ONCE(data.y);    WRITE_ONCE(data.y, 456);                copy.x = READ_ONCE(data.x);    // write_seqcount_end(&sc)              // read_seqcount_retry(&sc, old)    smp_store_release(&sc, sc + 1);         smp_rmb();                                        } while(READ_ONCE(sc) != old);

Даже если не знать семантики smp_wmb() и smp_rmb(), любому программисту очевидно, что такой код гораздо проще завернуть в удобный API. С данными можно работать, используя обычные атомарные операции (а модель памяти Linux даже позволяет и неатомарные), тогда как волшебные барьеры можно спрятать за read_seqcount_retry() и write_seqcount_begin().


Добавленные барьеры разделяют READ_ONCE() и WRITE_ONCE() на группы, обеспечивая безопасность работы seqcount. Но тут есть пара нюансов:


  • Во-первых, неупорядоченные атомарные операции остаются неупорядоченными. Поток 2 может увидеть новое значение data.y вместе со старым data.x. Для seqcount это непроблема, так как последующая проверка sc приведёт к повтору цикла.
  • Во-вторых, барьеры дают меньше гарантий, чем load-acquire и store-release. Чтение через smp_load_acquire() гарантированно происходит перед чтениями и записями в память, которые следуют за ним. Аналогично, присваивание через smp_store_release() происходит не только после предыдущих записей в память, но и чтений в том числе. Тогда как smp_rmb() упорядочивает лишь чтения, а smp_wmb() только записи. Правда, взаимный порядок между чтениями и записями, наблюдаемый из других потоков редко важен на практике именно по этой причине в ядре долгое время использовались только smp_rmb() и smp_wmb().

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


Предыдущий абзац очень неформально всё описывает, но это хорошая иллюстрация того, почему важно знать паттерны неблокирующего программирования. Это знание позволяет размышлять окоде на более высоком уровне без потери точности. Вместо того, чтобы описывать каждую инструкцию отдельно, вы можете просто сказать: data.x и data.y защищены seqcount sc. Иликаквпредыдущем примере: a передаётся другому потоку через message. Мастерство неблокирующего программирования отчасти состоит в умении узнавать и использовать подобные паттерны, облегчающие понимание кода.


На этом, пожалуй, пока и остановимся. Естественно, тема барьеров далеко не исчерпана, поэтому вследующей статье мы рассмотрим полные барьеры памяти, как барьеры вообще работают, и ещё больше примеров их использования в ядре Linux.

Подробнее..

Перевод Приёмы неблокирующего программирования полные барьеры памяти

26.03.2021 04:11:58 | Автор: admin

В первых двух статьях цикла мы рассмотрели четыре способа упорядочить доступ к памяти: load-acquire и store-release операции впервой части, барьеры чтения и записи в память вовторой. Теперь пришла очередь познакомиться с полными барьерами памяти, их влиянием на производительность, и примерами использования полных барьеров в ядре Linux.


Рассмотренные ранее примитивы ограничивают возможный порядок исполнения операций спамятью четырьмя различными способами:


  • Load-acquire операции выполняются перед последующими чтениями и записями.
  • Store-release операции выполняются после предыдущих чтений и записей.
  • Барьеры чтения разделяют предыдущие и последующие чтения из памяти.
  • Барьеры записи разделяют предыдущие и последующие записи в память.

Внимательный читатель заметил, что одна из возможных комбинаций в этом списке отсутствует:

Чтение выполняется... Запись выполняется...
после чтения smp_load_acquire(), smp_rmb() smp_load_acquire(), smp_store_release()
после записи ??? smp_store_release(), smp_wmb()
Оказывается, обеспечить глобальный порядок записей и последующих чтений из памяти гораздо сложнее. Процессоры вынуждены прилагать отдельные усилия для этого. Сохранение такого порядка стоит недёшево и требует явных инструкций. Чтобы понять причину этих особенностей, нам придётся спуститься на уровнь ниже и присмотреться к тому, как процессоры работают спамятью.



Что творится внутри процессоров


В первой статье уже упоминалось, что на самом деле процессоры обмениваются сообщениями через шину вроде QPI или HyperTransport, поддерживая таким образом когерентность кешей. На уровне ассемблера, правда, этого не видно. Там есть только инструкции записи и чтения из памяти. Acquire и release-семантика отдельных операций реализуется уже конвеером исполнения инструкций конкретного процессора, с учём его архитектурных особенностей.


Например, на x86-процессорах любое чтение из памяти это load-acquire операция, а любая запись это store-release, потому что так того требует спецификация архитектуры. Тем не менее, это ещё незначит, что в коде для x86 можно никак не обозначать acquire и release-операции. Барьеры влияют не только на процессор, но и на оптимизации компилятора, которые тоже могут переупорядочивать операции с памятью.


Кроме того, архитектура x86 негарантирует, что все процессоры будут видеть операции с памятью в одинаковом порядке. Рассмотрим следующий пример, где по адресам a и b изначально расположены нули:


    CPU 1                    CPU 2    -------------------      --------------------    store 1 => a             store 1 => b    load  b => x             load  a => y

Как бы вы ни переставляли эти операции между собой, как минимум одна из записей в память будет находиться перед соответствующим чтением. Соответственно, можно было бы ожидать, что либо в x, либо в y, либо в обоих окажется единица. Но даже на x86 может случиться так, что ивx, ивy будут считаны нули.


Почему? Дело в том, у каждого процессора есть так называемый буфер записей (store buffer), находящийся между процессором и его L1-кешем. Запись в память обычно изменяет только часть кеш-линии. Если кеш-линия инвалидирована, то процессору сперва надо достать новые данные из памяти аэто медленно. Поэтому новые данные для записи складываются в буфер, который позволяет процессору продолжить работу не ожидая обновления кеша.


Буфер записей не особо мешает процессорам сохранить глобальный порядок операций с памятью, когда это чтениечтение, чтениезапись, записьзапись:


  • Если процессор переупорядочивает инструкции в конвеере для увеличения производительности (out-of-order execution), то механизм спекулятивного исполнения инструкций может сохранять порядок операций относительно чтений из памяти. Процессор начинает отслеживать кеш-линии смомента их чтения и до момента, когда инструкция чтения покидает конвеер. Если на этом промежутке кеш-линия оказывается вытеснена из кеша, то все спекулятивно исполненные и незавершённые операции, зависящие от считанного значения, требуется повторить, считав новое значение из памяти. Если же отслеживаемая кеш-линия остаётся на месте, то все последующие операции спамятью будут завершены после чтения, так как инструкции покидают конвеер и завершают исполение уже впорядке их следования впрограмме.
  • Сохранить порядок записей между собой ещё проще: каждому процессору достаточно переносить записи в кеш в порядке их поступления в буфер записей. Дальше протокол когерентности всё сделает сам.

Но вот гарантировать, что только что записанное одним процессором значение будет считано другим процессором это гораздо сложнее. Во-первых, новое значение может застрять на некоторое время в буфере записей одного процессора, и пока оно не попадёт в L1-кеш, другие процессоры его не увидят. Во-вторых, чтобы процессор всегда видел свои же записи, все чтения сперва проходят через буфер записей (механизм store forwarding). То есть, если у CPU1 или CPU2 вих буферах записей окажутся значения для b и a соответственно, то они увидят именно их предыдущие значения (нули), независимо от состояния кешей.


Единственный способ получить ожидаемое поведение это сбросить весь буфер записей вкеш после записи и перед чтением. Несамая дешёвая операция (пара-тройка десятков циклов), но именно это делает полный барьер памяти smp_mb() в Linux. Рассмотрим теперь, как это выглядит наC:


    поток 1                       поток 2    -------------------           --------------------    WRITE_ONCE(a, 1);             WRITE_ONCE(b, 1);    smp_mb();                     smp_mb();    x = READ_ONCE(b);             y = READ_ONCE(a);

Допустим, в x получается ноль. Что должно для этого произойти? Волнистой линией обозначим ситуацию, когда WRITE_ONCE(b,1) не успевает перезаписать значение, считываемое другим потоком. (Вмодели памяти ядра такое отношение называется from-reads.) Поведение потоков можно описать так:


    WRITE_ONCE(a, 1);           |      -----+----- smp_mb();           |           v    x = READ_ONCE(b);   >  WRITE_ONCE(b, 1);                                         |                                    -----+----- smp_mb();                                         |                                         v                                  y = READ_ONCE(a);

Вспомните, что атомарные операции сами по себе ничего не упорядочивают между потоками. Барьеры тоже не обладают acquire- или release-семантикой. Никаких общих гарантий порядка выполнения операций здесь нет, но есть некоторые специальные гарантии наблюдаемого поведения, на которые всё же можно рассчитывать.


Полный барьер в потоке2 гарантирует, что к моменту выполнения READ_ONCE(a) буфер записей будет сброшен в кеш. Если это произойдёт перед READ_ONCE(b), то она уже увидит запись WRITE_ONCE(b,1) и в x должна будет оказаться единица. Соответственно, если там оказался ноль, порядок выполнения должен быть другим READ_ONCE(b) должна выполниться первой:


    WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);           |                              |           |                         -----+----- smp_mb();           |                              |           v                              v    x = READ_ONCE(b); -----------> y = READ_ONCE(a);                      (если x = 0)

Благодаря транзитивности, READ_ONCE(a) втаком случае увидит эффект WRITE_ONCE(a,1) и, соответственно, y=1 когда x=0. Аналогично, если второй поток всё ещё видит ноль вa, то полный барьер в первом потоке гарантирует, что READ_ONCE(a) выполнится перед READ_ONCE(b):


    WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);           |                              |      -----+----- smp_mb();               |           |                              |           v                              v    x = READ_ONCE(b); <----------- y = READ_ONCE(a);                      (если y = 0)

То есть, если y=0, то обязательно x=1. Порядок выполнения операций негарантируется, но каким бы он ни оказался, x и y теперь немогут одновременно содержать нули. Иначе READ_ONCE(a) должна была бы выполниться перед READ_ONCE(b), а READ_ONCE(b) перед READ_ONCE(a), что невозможно.


Модель памяти Linux не считает такие ситуации отношением happens-before между потоками, ведь ни одна из операций не имеет acquire или release-семантики и порядок между ними, строго говоря, не определён. Но тем не менее, барьеры памяти всё же способны влиять на поведение потоков, что позволяет писать высокоуровневые примитивы синхронизации, пользователи которых могут рассчитывать на вполне определённое неопределённое поведение. Рассмотрим теперь, как барьеры применяются на практике.




Синхронизация сна и пробуждения


Как вы могли бы догадаться по предыдущему разделу, полные барьеры памяти это довольно запутанная и неинтуитивная штука. Ксчастью, большиство применений полных барьеров ограничивается как раз шаблоном два потока и два флажка, где каждый поток пишет в один флажок, а читает из другого.


Рассмотрим типичный пример. Пусть один поток хочет попросить другой что-то сделать. Адругой поток часто бывает занят, либо вообще устаёт работать и хочет поспать самоудаляется из планировщика потоков и отправляется на неопределённое время всон. Первому потоку надо растормошить второй. Вкоде это выглядит как-то так:


    поток 1                               поток 2    -------------------                   --------------------------    WRITE_ONCE(dont_sleep, 1);            WRITE_ONCE(wake_me, 1);    smp_mb();                             smp_mb();    if (READ_ONCE(wake_me))               if (!READ_ONCE(dont_sleep))      wake(thread2);                        sleep();

Если второй поток видит в dont_sleep ноль, то первый поток увидит в wake_me единицу иразбудит второй поток. Выглядит, как будто у первого потока release-семантика (представьте, что wake() это как mutex_unlock()). Если же первый поток увидит в wake_me ноль, то второй поток обязательно увидит единицу в dont_sleep и просто непойдёт спать. Второй поток это как бы acquire-половинка операции.


Всё это держится на предположении, что команды первого потока нетеряются. Если, например, wake() вызывается после READ_ONCE() во втором потоке, но до sleep(), то второй поток недолжен витоге заснуть. Как вариант, эти операции могут блокироваться на общем мьютексе. Вот вам ещё один пример того, как неблокирующие приёмы программирования применяются вместе с традиционной синхронизацией. Вданном случае это неявляется проблемой, так как блокировки будут очень редкими.


Приём действительно работает и применяется, например, в интерфейсах prepare_to_wait() и wake_up_process(). Они были добавлены в ядро ещё в ветке 2.5.x, что в своё время подробно разбиралось наLWN. Если раскрыть вызовы функций, то можно увидеть знакомые строки:


    поток 1                                       поток 2    -------------------                           --------------------------    WRITE_ONCE(condition, 1);                     prepare_to_wait(..., TASK_INTERRUPTIBLE) {    wake_up_process(p) {                            set_current_state(TASK_INTERRUPTIBLE) {      try_to_wake_up(p, TASK_NORMAL, 0) {             WRITE_ONCE(current->state, TASK_INTERRUPTIBLE);        smp_mb();                                     smp_mb();        if (READ_ONCE(p->state) & TASK_NORMAL)      }          ttwu_queue(p);                          }      }                                           if (!READ_ONCE(condition))    }                                               schedule();

Как и с seqcount, все барьеры спрятаны за удобным высокоуровневым API. Собственно, как раз использование барьеров или load-acquire/store-release операций и придаёт acquire- или release-семантику всему интерфейсу. Вданном случае wake_up_process() обладает release-семантикой, аset_current_state() распространяет свою acquire-семантику на вызов prepare_to_wait().


Ещё часто бывает, что флажок проверяют дважды, дабы по возможности избежать лишних вызовов wake():


    поток 1                               поток 2    -------------------                   --------------------------    WRITE_ONCE(dont_sleep, 1);            if (!READ_ONCE(dont_sleep)) {    smp_mb();                               WRITE_ONCE(wake_me, 1);    if (READ_ONCE(wake_me))                 smp_mb();      wake(thread2);                        if (!READ_ONCE(dont_sleep))                                              sleep();                                          }

В ядре подобные проверки можно найти в tcp_data_snd_check(), вызываемой из tcp_check_space() одним потоком и tcp_poll() в другом потоке. Код здесь довольно низкоуровневый, так что разберём его подробнее. Если в буфере сокета закончилось место, то надо подождать, пока оно освободится. tcp_poll() в одном потоке устанавливает флаг SOCK_NOSPACE раз места нет, то надо спать перед проверкой __sk_stream_is_writeable(), вот здесь:


    set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);    smp_mb__after_atomic();    if (__sk_stream_is_writeable(sk, 1))      mask |= EPOLLOUT | EPOLLWRNORM;

Если возвращаемая маска будет пустой, то для вызывающего кода это означает, что сокет не готов (и надо ждать, пока будет готов). smp_mb__after_atomic() это специализированная версия smp_mb() с аналогичной семантикой. Коптимизациям барьеров мы ещё вернёмся, но позже.


Другой поток, занятый отправкой данных из сокета, должен впоследствии разбудить первый поток. tcp_data_snd_check() сперва отправляет TCP-пакеты, освобождая место в буфере можно неспать, место появилось затем проверяет флажок SOCK_NOSPACE, и наконец (через указатель нафункцию sk->sk_write_space()) попадает в sk_stream_write_space(), где флажок сбрасывается и если там кто-то спит, то его будят. Вызовов функций тут немного, так что я думаю, вы сами легко разберётесь. Также обратите внимание на комментарий в tcp_check_space():


    /* pairs with tcp_poll() */    smp_mb();    if (test_bit(SOCK_NOSPACE, &sk->sk_socket->flags))      tcp_new_space(sk);

Парное использование барьеров означает, что функции составляют взаимосвязанную пару операций с acquire и release-семантикой. По барьерам чтения или записи в память легко понять, какая у чего семантика: acquire для чтения, release для записи. Сполными барьерами же для понимания семантики следует читать код вокруг них и думать. Внашем случае мы знаем, что функция, инициирующая пробуждение tcp_check_space() обладает release-семантикой. Соответственно, у tcp_poll() acquire-семантика.


Подобный приём идиому можно заметить почти везде, где в ядре используется smp_mb(). Например:


  • Workqueues таким образом решают, может ли рабочий поток отправиться поспать, если ему больше нечего делать. Будильником здесь выступает insert_work(), тогда как wq_worker_sleeping(), очевидно, хочет спать.
  • Системный вызов futex() с одной стороны имеет пользовательский поток, записывающий новое значение в память, а барьеры являются частью futex(FUTEX_WAKE). Сдругой стороны находится поток, выполняющий futex(FUTEX_WAIT) и все операции с флажком wake_me внутри ядра. futex(FUTEX_WAIT) получает через аргумент ожидаемое значение в памяти, и потом решает, надо ли спать или уже нет. См.длинный комментарий в начале kernel/futex.c, где подробно рассматривается этот механизм.
  • В контексте KVM роль сна играет переход процессора в гостевой режим, когда он отдаётся враспоряжение виртуальной машины. Для того, чтобы выбить процессор из рук гостевой ОС ивернуть его себе обратно, kvm_vcpu_kick() отправляет межпроцессорное прерывание. Вглубине стека вызовов можно найти kvm_vcpu_exiting_guest_mode(), где видно знакомые нам комментарии о парных барьерах вокруг флажка vcpu->mode.
  • В драйверах virtio можно найти два места, где smp_mb() используется похожим образом. Содной стороны находится драйвер, который иногда хочет прервать операцию и пинает занятое устройство прерыванием. Сдругой стороны есть устройство, которому иногда надо отсигналить ожидающему драйверу о завершении операции.

На этом знакомство с барьерами памяти оглашается оконченным. Вследующей части мы примемся за операции compare-and-swap, их применение для оптимизации блокировок и важную роль вреализации неблокирующих связных списков.

Подробнее..

Перевод Приёмы неблокирующего программирования введение в compare-and-swap

27.04.2021 06:17:05 | Автор: admin

В первой части этого цикла статей мы рассмотрели теорию, стоящую за одновременным доступом в моделях памяти, а также применение этой теории к простым чтениям и записям в память. Правда, этих примитивов оказывается недостаточны для построения высокоуровневых механизмов синхронизации вроде спинлоков, мьютексов и условных переменных. Хоть и полные барьеры памяти позволяют синхронизировать потоки с помощью приёмов, рассмотренных в предыдущей части (алгоритм Деккера), современные процессоры позволяют получить нужный эффект проще, быстрее и гибче да, всё сразу! с помощью операций compare-and-swap.


Для программистов ядра Linux операция обмена compare-and-swap выглядит так:


    T cmpxchg(T *ptr, T old, T new);

где T может быть либо числовым типом не больше указателя, либо указателем на что-нибудь. Так как в C нет обобщённых функций, то подобный полиморфизм реализуется макросами. cmpxchg() это очень аккуратно реализованный макрос, который ведёт себя как функция (например, вычисляет аргументы только один раз). В Linux также есть макрос cmpxchg64(), который работает с 64-битными целыми числами и недоступен на 32-битных платформах.


cmpxchg() читает значение по указателю *ptr и, если оно равно old, то заменяет его наnew. Иначе же после чтения ничего непроисходит. Считанное значение возвращается как результат операции, независимо от того, произошла ли запись. И всё это выполняется атомарно: если другой поток одновременно с cmpxchg() записывает что-то по адресу *ptr, то cmpxchg() ничего неменяет. Либо old становится new, либо текущее значение остаётся нетронутым. Поэтому cmpxchg() называют атомарной операцией read-modify-write.


В ядре Linux cmpxchg() также предоставляет окружающему коду гарантии порядка операций с памятью. Для выполнения атомарного обмена значений требуется и читать, и писать в память. С некоторыми оговорками можно считать, что чтение здесь имеет acquire-семантику, а запись это release-операция. То есть cmpxchg() будет синхронизироваться с load-acquire и store-release операциями в других потоках.



Стеки и очереди без блокировок


Статья Lockless algorithms for mere mortals рассказывает, как compare-and-swap позволяет работать со списками без мьютексов с подробным разбором и иллюстрациями. Здесь же мы ограничимся рассмотрением односвязного списка в качестве примера: как его реализовать наC, где он может пригодиться. Начнём с простого: как добавляется элемент в начало односвязного списка.


    struct thing {        struct thing *next;        ...    };    struct thing *first;    node->next = first;    first = node;

Как мы теперь знаем, присваивание first следует выполнять release-операцией, чтобы другие потоки увидели node->next после выполнения load-acquire. Хорошо, пока всё идёт по плану и шаблону из первой статьи.


Однако, такой простой код корректно работает лишь для случая, когда ровно один поток может модифицировать список. Если таких потоков-писателей может быть несколько, то уже оба присваивания должны быть защищены критической секцией. В противном случае, если какой-то другой поток изменяет значение first (добавляет элемент, например), то node->next в свежедобавленном элементе может указывать на старое значение first и один элемент списка окажется утерян. Из этого следует важный урок: корректное использование acquire и release-семантики это необходимое, но отнюдь недостаточное условие корректности неблокирующих алгоритмов. Сами по себе они незащищают вас от логических ошибок и гонок потоков.


К счастью, в данной ситуации вместо блокировки можно воспользоваться cmpxchg(), чтобы поймать ситуацию, когда какой-то другой поток изменил first первым. Следующий код будет корректным уже для любого числа потоков-писателей:


    if (cmpxchg(&first, node->next, node) == node->next)        /* ура, сработало! */    else       /* и что теперь? */

И тут возникают вопросы. Во-первых, что же делать, когда cmpxchg() заметит изменения в first? В нашем случае ответ простой: считать новое значение, обновить node->next, попробовать добавить node в список ещё раз. Это новый элемент, который пока ещё не видно из других потоков. Если нам не повезло с добавлением никто ничего не заметит.


Второй вопрос гораздо более хитрый: как именно в таком случае следует считывать first? По идее нам не нужна acquire или release-семантика, ведь нас волнует только значение first само по себе. С другой стороны, слишком умный оптимизирующий компилятор может подумать, что раз мы ничего не присваиваем first в этом потоке, то значение не могло измениться и ничего повторно читать из памяти не нужно. Хоть cmpxchg() в Linux и запрещает подобные оптимизации, хорошим тоном считается явно показывать с помощью READ_ONCE(), что мы хотим считать новое значение из памяти.


Улучшенная версия кода выглядит так:


    struct thing *old, *expected;    old = READ_ONCE(first);    do {        node->next = expected = old;        old = cmpxchg(&first, expected, node);    } while (old != expected);

Отлично, добавление в список работает. Перейдём к другой стороне проблемы: как следует забирать значения из этого списка. Зависит из того, чего мы хотим добиться, передавая значения через список, сколько потоков будут из него читать, и в каком порядке они хотят это делать: LIFO или FIFO, от новых элементов к старым или наоборот.


Самый простой случай это когда все чтения из списка гарантированно происходят после записей. Доступ к списку синхронизируется каким-нибудь другим образом, так что при чтении список уже не изменяется и обычные, не атомарные операции полностью безопасны. Например, если читатели запускаются после писателей, дождавшись завершения потоков, которые собирают список дальнейшей работы, как было в первой статье.


Если чтения выполняются редко или группами, то можно сделать так, что писатели работают совместно, не блокируя друг друга, а доступ читателей строго синхронизируется. Берём rwlock и переворачиваем его! Писатели захватывают блокировку совместно (через read_lock()), а читатели требуют эксклюзивного доступа (через write_lock()). В таком случае запись в список не может происходить одновременно с чтением, так что читателям снова не нужны никакие атомарные операции. Остаётся только надеяться, что вы пишете хорошие комментарии к своему коду, чтобы у ваших читателей не случился взрыв мозга.


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


    my_tasks = xchg_acquire(&first, NULL);

xchg() подобно cmpxchg() атомарно выполняет одновременно чтение и запись в память. В данном случае читает и возвращает старое значение first, всегда записывая вместо него NULL. То есть переносит значение first в my_tasks. Здесь используется вариант xchg_acquire(), придающий acquire-семантику чтению из памяти, но при этом запись выполняется просто атормано, без release-семантики (подобно WRITE_ONCE()). Тут достаточно лишь acquire-семантики, потому что release-половинка находится в другом месте (уписателей). Вцелом, это всё тот же приём из первой части, только расширенный на случай более чем двух потоков.


Можно ли при этом заменить cmpxchg() у писателя на cmpxchg_release() с записью через release, но обычным чтением? По идее так можно сделать, ведь писателю важно только то, чтобы его запись в node->next была видна другим потокам. Однако, acquire-семантика чтения у cmpxchg() имеет полезный побочный эффект: так каждый следующий писатель синхронизируется с предыдущими писателями. Вызовы cmpxchg() упорядочиваются благодаря парам acquire и release-операций:


    поток 1: load-acquire first (возвращает NULL)             store-release node1 --> first                  \      поток 2: load-acquire first (возвращает node1)               store-release node2 --> first                    \         поток 3: load-acquire first (возвращает node2)                  store-release node3 --> first                       \            поток 4: xchg-acquire first (возвращает node3)

Только cmpxchg() из третьего потока синхронизирована с xchg_acquire() в четвёртом потоке. Однако благодаря транзитивности, все другие cmxchg() происходят перед xchg_acquire(). Если писатели используют cmpxchg(), то после xchg_acquire() по списку можно пройти уже с помощью обычных операций чтения.


Если бы писатели использовали cmpxchg_release(), то отношения порядка между потоками выглядели бы так:


    поток 1: load first (возвращает NULL)             store-release node1 --> first      поток 2: load first (возвращает node1)               store-release node2 --> first         поток 3: load first (возвращает node2)                  store-release node3 --> first                       \            поток 4: xchg-acquire first (возвращает node3)

Четвёртый поток конечно же прочитает node2 из node3->next, потому что он уже прочитал значение first, которое записал третий поток. Но вот для последующих элементов списка наблюдаемый порядок записей других потоков уже не гарантируется при использовании обычных операций чтения поэтому четвёртому потоку требуется использовать smp_load_acquire() для прохода по списку, чтобы точно увидеть node1 при чтении node2->next.


Конечно же односвязные списки давно реализованы в ядре Linux linux/llist.h поэтому можно не переизобретать колесо и пользоваться готовыми решениями. Теперь обратим внимание на ещё пару интересных функций: llist_del_first() и llist_reverse_order().


llist_del_first() вынимает и возвращает первый элемент списка. Документация предупреждает, что эта функция безопасна только для единственного читателя. Если несколько потоков одновременно забирают элементы списка, то при определённом стечении обстоятельств возможна так называемая проблема ABA. Я не буду вдаваться здесь в детали, просто не надо так делать. Возникает ситуация, похожая на предыдущий пример с rwlock: для корректной работы алгоритма в целом необходимо пользоваться блокировками, но определённые части могут обойтись без них. llist_del_first() позволяет любому количеству писателей добавлять элементы в список с помощью llist_add() без каких-либо блокировок, но если читателей несколько, то они между собой должны пользоваться мьютексом или спинлоком.


llist_del_first() превращает список в LIFO-контейнер (стек). Если же вам требуется FIFO-порядок (очередь), то можно воспользоваться следующим приёмом с llist_reverse_order(). Если забирать элементы из списка пачками с помощью xchg() (или же llist_del_all()), то пачки сохраняют FIFO-порядок между собой, только элементы в них следуют в обратном порядке. Воу, а что если...


    struct thing *first, *current_batch;    if (current_batch == NULL) {        current_batch = xchg_acquire(&first, NULL);        /* перевернуть список current_batch */    }    node = current_batch;    current_batch = current_batch->next;

Таким образом можно забирать элементы из списка в порядке их поступления, подобно очереди. Как и в случае с llist_del_first(), это работает только если элементы current_batch обрабатываются одним потоком. Пооная реализация этого алгоритма с помощью API llist оставляется читателю в качества упражнения.


На этом у меня пока всё. В следующей части мы продолжим изучать атомарные read-modify-write операции, посмотрим, что ещё можно сделать с compare-and-swap и как её можно использовать для ускорения счётчиков ссылок.


Дейв Чиннер один из основных сопровождающих XFS и Btrfs оставил замечательный комментарий касательно производительности cmpxchg().

Есть пара достаточно важных моментов про неблокирующие алгоритмы, использующие cmpxchg. Они как бы подразумеваются в статье, но явно не рассматриваются. cmpxchg определяется как атомарная операция read-modify-write, что технически корректно, но умалчивает некоторые ограничения масштабирования, с которыми сталкиваются неблокирующие алгоритмы на основе cmpxchg.


  1. cmpxchg это блокирующая операция: на уровне железа, не программном, но всё же блокирующая. Кроме того, она инвалидирует кеш-линии, со всеми вытекающими: неизбежно наступает момент, когда межпроцессорная шина насыщается, нагрузка на процессоры начинает расти нелинейно и производительность падает.
  2. cmpxchg не очень равномерно и справедливо распределяет нагрузку между процессорами, что легко может привести к взаимным блокировкам и просадкам производительности под высокой нагрузкой. Здесь уже вылезают особенности реализации протоколов когерентности кеша (при должном опыте по задержкам в исполнении инструкций можно догадываться о том, что там происходит на уровне железа).

Поэтому большинство алгоритмов в ядре выходят из цикла cmpxchg() после некоторого числа неудач, а потом переходят к плану Б: скажем, захватывают спинлок, чтобы избежать негативных последствий высокой нагрузки на cmpxchg. Например, dentry-кеш использует механизм lockref (lockref_get/lockref_put), который прекращает крутить цикл cmpxchg() после 100 неудачных попыток.


Dentry-кеш ускоряет работу с именами файлов, чтобы ядро не бегало каждый раз в файловую систему заново. Например, если вы открываете только что созданный файл, то он наверняка будет в dentry-кеше и ядру не придётся лишний раз перепроверять, что все промежуточные директории существуют.


Чтобы вы примерно себе представляли, когда конфликты за кеш-линии становятся проблемой (при достижении протоколом когерентности кешей точки насыщения), на моей машине нагрузка на процессор перестаёт линейно расти где-то при 2,53 миллионах операций в секунду, если взять простой цикл с cmpxchg() для 64-битного значения на своей собственной кеш-линии, где больше ничего не происходит. Это в 32 потока на машине двухлетней давности с 32 ядрами. Они не прям под завязку нагружены, но если попрофайлить конкретный цикл, создавая 650000 файлов в секунду, то можно наблюдать, как суммарно этот цикл начинает занимать вполне значимое количество ресурсов: единицы процентов процессорного времени.


Если сравнить со спинлоками, то при похожей нагрузке приблизительно треть времени тратится только на то, чтобы крутить спинлоки. Конкретно, если посмотреть на спинлок, защищающий список inode в суперблоке файловой системы, то при 2,6 миллионах доступов в секунду на спинлоки уходит околе 10% процессорных ресурсов (то есть 3 из 32 процессоров). Очень похоже на cmpxchg, который начинает тупить где-то в этом же районе, и если подумать, как оба варианта обращаются с кешем, то наблюдаемое поведение вполне логично.


Другими словами, cmpxchg() масштабируется лучше, чем блокирующие алгоритмы, но это не волшебная таблетка, делающая всё быстрее. У cmpxchg() тоже есть пределы масштабируемости, так как это не вполне неблокирущая операция. Врочем, мой опыт проектирования многопоточных алгоритмов подсказывает, что масштабируемость зависит больше от того, насколько часто алгоритму требуется читать или обновлять разделяемые данные, а не от того, насколько (не)блокирующий алгоритм используется. Мьютекс это ж фактически просто атомарный флажок захвачено и немного оптимизаций. Так что неудивительно, что cmpxchg() и атомарные примитивы имеют схожие ограничения в масштабируемости с блокировками фундаментально, если посмотреть с точки зрения кеша, все эти подходы об одном и том же. Просто cmpxchg() и атомарные операции дают более тонкий контроль над памятью, уменьшая частоту одновременного доступа к данным. Отсюда и выгода: если меньше трогать общую память, то можно делать больше независимой работы.


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


На что последовал ответ автора Паоло.

Дейв, огромное спасибо за ответ. Прям в корень зришь я сомневался, стоит ли вообще рассказывать о связных списках, во многом из-за описанных тобой причин. С одной стороны, связные списки это прямо классический пример неблокирущих структур данных. С другой стороны, они не настолько широко применимы, как другие вещи, о которых я рассказываю, вот как раз из-за ограничений в масштабируемости. Нет, у связных списков есть своя ниша, но их слишком легко использовать там, где не надо. Например, в тех немногих случаях, когда мне были нужны именно llist, я принял такое решение не потому, что неблокирующее добавление в список это круто (нет, это прикольно, но у него проблемы с распределением работы между процессорами при повышенной нагрузке). Наоборот, я знал, что писателей будет немного и записи будут редкими, а вот читатель будет лишь один и его хотелось сделать как можно более быстрым. Я бы не назвал это очевидным решением задачи.


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


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


  • Если вы знаете пару-тройку широко известных приёмов (которые часто ещё и завёрнуты в удобные API), то внезапно ваш код становится проще поддерживать и масштабируемость тоже скорее всего будет получше, несмотря на то, что неблокирующее программирование сложнее мьютексов. И вот тут неблокирующие связные списки это как раз плохой пример, потому что в неопытных руках от них порой больше вреда чем пользы.
  • Следует как можно чётче разделять быструю неблокирующую часть кода которая либо реализует один из рассмотренных приёмов, либо использует какую-нибудь готовую абстракцию вроде того же lockref и медленную часть, где можно позволить себе блокировки. Гораздо проще выбрать правильный примитив, если неблокирующая часть небольшая и аккуратная.
  • На выбор очень сильно влияют обстоятельства. Если у вас только один читатель или писатель, то дело существенно упрощается, так как скорее всего ваш алгоритм попадает в какой-нибудь из привычных, готовых шаблонов.

Чтобы добиться максимальной производительности, вам необходимо знать относительную частоту записей и чтений (или распределение работы между быстрой и медленной частями алгоритма). Иначе вы ж понятия не имеете, где у вас могут возникнуть проблемы с масштабируемостью. Неблокирующие алгоритмы это всегда компромисс (моя история с llist служит примером, но пожалуй наиболее известный пример это RCU). Стоит иметь это в виду, ведь если ваш код выполняется достаточно редко, то на медленную часть можно не особо обращать внимание, добавить туда мьютекс и превратить много читателей/писателей в одного (в один момент времени), что может значительно упростить вам жизнь, расширив количество применимых шаблонов неблокирующего программирования.

Подробнее..

Перевод Rust в ядре Linux

13.06.2021 16:20:24 | Автор: admin


Вболее ранней публикации компания Google объявила, что в Android теперь поддерживается язык программирования Rust, применяемый в разработке этой ОС как таковой. В связи с этим авторы данной публикации также решили оценить, насколько язык Rust востребован в разработке ядра Linux. В этом посте на нескольких простых примерах рассмотрены технические аспекты этой работы.

На протяжении почти полувека C оставался основным языком для разработки ядер, так как C обеспечивает такую степень управляемости и такую предсказуемую производительность, какие и требуются в столь критичном компоненте. Плотность багов, связанных с безопасностью памяти, в ядре Linux обычно весьма низка, поскольку код очень качественный, ревью кода соответствует строгим стандартам, а также в нем тщательно реализуются предохранительные механизмы. Тем не менее,баги, связанные с безопасностью памяти, все равно регулярно возникают. В Android уязвимости ядра обычно считаются серьезным изъяном, так как иногда позволяют обходить модель безопасности в силу того, что ядро работает в привилегированном режиме.

Предполагается, что Rust уже дозрел до того, чтобы составить компанию C в качестве языка для практической реализации ядра. Rust помогает сократить количество потенциальных багов и уязвимостей безопасности в привилегированном коде, при этом он хорошо сочетается с основой ядра и позволяет поддерживать хорошие показатели производительности.

Поддержка Rust


Был разработанпервичный прототипдрайвера Binder, позволяющий адекватно сравнивать характеристики безопасности и производительности имеющейся версии на C и ее аналога на Rust. В ядре Linux более 30 миллионов строк кода, поэтому мы не ставим перед собой цель переписать весь его на Rust, а обеспечить возможность дописывать нужный код на Rust. Мы полагаем, что такой инкрементный подход помогает эффективно использовать имеющуюся в ядре высокопроизводительную реализацию, в то же время предоставляют разработчикам ядра новые инструменты для повышения безопасности памяти и поддержания производительности на уровне в ходе работы.

Мы присоединились к организацииRust для Linux, где сообщество уже много сделало и продолжает делать для добавления поддержки Rust в систему сборки ядра Linux. Также нам нужно проектировать системы таким образом, чтобы фрагменты кода, написанные на двух языках, могли взаимодействовать друг с другом: нас особенно интересуют безопасные абстракции без издержек, которые позволяют коду Rust использовать функционал ядра, написанный на C, а также возможность реализовывать функционал на идиоматическом Rust, который можно гладко вызывать из тех частей ядра, что написаны на C.

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

Rust славится не только своей безопасностью, но и тем, насколько он полезен и удобен для разработчиков. Далее рассмотрим несколько примеров, демонстрирующих, в чем Rust может пригодиться разработчикам ядра при написании безопасных и корректных драйверов.

Пример драйвера


Рассмотрим реализацию семафорного символьного устройства. У каждого устройства есть актуальное значение; при записи nбайт значение устройства увеличивается на n; при каждом считывании это значение понижается на 1, пока значение не достигнет 0, в случае чего это устройство блокируется, пока такая операция декремента не сможет быть выполнена на нем без ухода ниже 0.

Допустим,semaphore это файл, представляющий наше устройство. Мы можем взаимодействовать с ним из оболочки следующим образом:

> cat semaphore

Когдаsemaphore это устройство, которое только что инициализировано, команда, показанная выше, блокируется, поскольку текущее значение устройства равно 0. Оно будет разблокировано, если мы запустим следующую команду из другой оболочки, так как она увеличит значение на 1, тем самым позволив исходной операции считывания завершиться:

> echo -n a > semaphore

Мы также сможем увеличить счетчик более чем на 1, если запишем больше данных, например:

> echo -n abc > semaphore

увеличивает счетчик на 3, поэтому следующие 3 считывания не приведут к блокированию.

Чтобы продемонстрировать еще некоторые аспекты Rust, добавим к нашему драйверу следующие возможности: запомним, каково было максимальное значение, достигнутое в течение всего жизненного цикла, а также запомним, сколько операций считывания выполнил на устройстве каждый из файлов.

Теперь покажем, как такой драйвер был быреализован на Rust, сравнив этот вариант с реализацией на C. Правда, отметим, что разработка этой темы в Google только начинается, и в будущем все может измениться. Мы хотели бы подчеркнуть, как Rust может пригодиться разработчику в каждом из аспектов. Например, во время компиляции он позволяет нам устранить или сильно снизить вероятность того, что в код вкрадутся целые классы багов, притом, что код останется гиьким и будет работать с минимальными издержками.

Символьные устройства


Разработчику необходимо сделать следующее, чтобы реализовать на Rust драйвер для нового символьного устройства:

  1. Реализовать типажFileOperations: все связанные с ним функции опциональны, поэтому разработчику требуется реализовать лишь те, что релевантны для данного сценария. Они соотносятся с полями в структуре Cstruct file_operations.
  2. Реализовать типажFileOpenerэто типобезопасный эквивалент применяемого в C поляopenиз структурыstruct file_operations.
  3. Зарегистрировать для ядра новый тип устройства: так ядру будет рассказано, какие функции нужно будет вызывать в ответ на операции над файлами нового типа.

Далее показано сравнение двух первых этапов нашего первого примера на Rust и C:



Символьные устройства в Rust отличаются целым рядом достоинств по части безопасности:

  • Пофайловое управление состоянием жизненного цикла:FileOpener::openвозвращает объект, чьим временем жизни с того момента владеет вызывающая сторона. Может быть возвращен любой объект, реализующий типаж PointerWrapper, и мы предоставляем реализации дляBoxиArc, так что разработчики, реализующие идиоматические указатели Rust, выделяемые из кучи или предоставляемые путем подсчета ссылок, обеспечены всем необходимым.

    Все ассоциированные функции вFileOperationsполучают неизменяемые ссылки на self(подробнее об этом ниже), кроме функцииrelease, которая вызывается последней и получает в ответ обычный объект (а впридачу и владение им). Тогда реализация releaseможет отложить деструкцию объекта, передав владение им куда-нибудь еще, либо сразу разрушить его. При работе с объектом с применением подсчета ссылок деструкция означает декремент количества ссылок (фактическое разрушение объекта происходит, когда количество ссылок падает до нуля).

    То есть, здесь мы опираемся на принятую в Rust систему владения, взаимодействуя с кодом на C. Мы обрабатываем написанную на C часть кода, которым владеет объект Rust, разрешая ему вызывать функции, реализованные на Rust, а затем, в конце концов, возвращаем владение обратно. Таким образом, коль скоро код на C, время жизни файловых объектов Rust также обрабатывается гладко, и компилятор обязывает поддерживать правильное управление временем жизни объекта на стороне Rust. Например, open не может возвращать в стек указатели, выделенные в стеке, или объекты, выделенные в куче, ioctl/read/writeне может высвобождать (или изменять без синхронизации) содержимое объекта, сохраненное вfilp->private_data, т.д.


    Неизменяемые ссылки: все ассоциированные функции, вызываемые между openиreleaseполучают неизменяемые ссылки наself, так как они могут конкурентно вызываться сразу множеством потоков, а действующие в Rust правила псевдонимов не допускают, чтобы в любой момент времени на объект указывало более одной изменяемой ссылки.

    Если разработчику требуется изменить некоторое состояние (а это в порядке вещей), то это можно сделать при помощивнутренней изменяемости : изменяемое состояние можно обернуть в MutexилиSpinLock(илиatomics) и безопасно через них изменить.

    Во время компиляции это препятствует возникновению багов, при которых разработчику не удается приобрести нужную блокировку при доступе к полю (поле недоступно), или когда разработчику не удается обернуть поле в блокировку (поле предназначено только для чтения).


    Состояние для каждого устройства отдельно: когда экземпляры файлов должны совместно использовать состояние конкретного устройства, что при работе с драйверами бывает очень часто, в Rust это можно делать полностью безопасно. Когда устройство зарегистрировано, может быть предоставлен типизированный объект, для которого затем выдается неизменяемая ссылка, когда вызываетсяFileOperation::open. В нашем примере разделяемый объект обертывается вArc, поэтому объекты могут безопасно клонировать и удерживать ссылки на такие объекты.

    Причина, по которойFileOperation
    выделен в собственный типаж (в отличие, например, отopen, входящего в состав типажаFileOperations) так можно разрешить выполнять регистрацию конкретной реализации файла разными способами.

    Таким образом исключается, что разработчик может получить неверные данные, попытавшись извлечь разделяемое состояние. Например, когда в C зарегистрировано miscdevice, указатель на него доступен вfilp->private_data; когда зарегистрированоcdev, указатель на него доступен в inode->i_cdev. Обычно эти структуры встраиваются в объемлющую структуру, которая содержит разделяемое состояние, поэтому разработчики обычно прибегают к макросуcontainer_of, чтобы восстановить разделяемое состояние. Rust инкапсулирует все это и потенциально проблемные приведения указателей в безопасную абстракцию.


    Статическая типизация: мы пользуемся тем, что в Rust поддерживаются дженерики, поэтому реализуем все вышеупомянутые функции и типы в виде статических типов. Поэтому у разработчика просто нет возможности преобразовать нетипизированную переменную или поле в неправильный тип. В коде на C в вышеприведенной таблице есть приведения от, в сущности, нетипизированного указателя (void*) к желаемому типу в начале каждой функции: вероятно, в свеженаписанном виде это работает нормально, но также может приводить к багам по мере развития кода и изменения допущений. Rust отловит все такие ошибки еще во время компиляции.


    Операции над файлами: как упоминалось выше, разработчику требуется реализовать типажFileOperations, чтобы настраивать поведение устройства на свое усмотрение. Это делается при помощи блока, начинающегося с impl FileOperations for Device, гдеDevice это тип, реализующий поведение файла (в нашем случае это FileState). Оказавшись внутри блока, инструменты уловят, что здесь может быть определено лишь ограниченное количество функций, поэтому смогут автоматически вставить прототипы. (лично я используюneovimи LSP-серверrust-analyzer.)

    При использовании этого типажа в Rust, та часть ядра, что написана на C, все равно требует экземпляр struct file_operations. Контейнер ядра автоматически генерирует такой экземпляр из реализации типажа (а опционально также макросdeclare_file_operations): хотя, в нем и есть код, чтобы сгенерировать корректную структуру, здесь все равно всеconst, поэтому интерпретируется во время компиляции, и во время выполнения не дает никаких издержек.

    Обработка Ioctl

    Чтобы драйвер предоставлял собственный обработчикioctl, он должен реализовывать функцию ioctl, входящую в состав типажа FileOperations, как показано в следующей таблице.



    Команды Ioctl стандартизированы таким образом, что, имея команду, мы знаем, предоставляется ли под нее пользовательский буфер, как ее предполагается использовать (чтение, запись, и то, и другое, ничего из этого) и ее размер. В Rust предоставляется диспетчер(для доступа к которому требуется вызватьcmd.dispatch), который на основе этой информации автоматически создает помощников для доступа к памяти и передает их вызывающей стороне.

    Но от драйвера нетребуетсяэтим пользоваться. Если, например, он не использует стандартную кодировку ioctl, то Rust подходит к этому гибко: просто вызывает cmd.rawдля извлечения сырых аргументов и использует их для обработки ioctl (потенциально с небезопасным кодом, что требуется обосновать).

    Но, если в реализации драйвера действительно используется стандартный диспетчер, то ситуация значительно улучшается, поскольку ему вообще не придется реализовывать какой-либо небезопасный код, и:

    • Указатель на пользовательскую память никогда не является нативным, поэтому пользователь не может случайно разыменовать его.
    • Типы, позволяющие драйверу считывать из пользовательского пространства, допускают лишь однократное считывание, поэтому мы не рискуем получить баги TOCTOU (время проверки до времени использования). Ведь когда драйверу требуется обратиться к данным дважды, он должен скопировать их в память ядра, где злоумышленник не в силах ее изменить. Если исключить небезопасные блоки, то допустить баги такого класса в Rust попросту невозможно.
    • Не будет случайного переполнения пользовательского буфера: считывание или запись ни в коем случае не выйдут за пределы пользовательского буфера, которые задаются автоматически на основе размера, записанного в команде ioctl. В вышеприведенном примере реализацияIOCTL_GET_READ_COUNTобладает доступом лишь к экземпляру UserSlicePtrWriter, что ограничивает количество доступных для записи байт величиной sizeof(u64), как закодировано в команде ioctl.
    • Не смешиваются операции считывания и записи: в ioctl мы никогда не записываем буферы, предназначенные для считывания, и наоборот. Это контролируется га уровне обработчиков чтения и записи, которые получают только экземплярыUserSlicePtrWriterиUserSlicePtrReaderсоответственно.

    Все вышеперечисленное потенциально также можно сделать и в C, но разработчику ничего не стоит (скорее всего, ненамеренно) нарушить контракт и спровоцировать небезопасность; для таких случаев Rust требует блокиunsafe, которые следует использовать лишь изредка и проверяться особенно пристально. А вот что еще предлагает Rust:

    • Типы, применяемые для чтения и записи пользовательской памяти, не реализуют типажиSendиSync, и поэтому они (и указатели на них) небезопасны при использовании в другом контексте. В Rust, если бы разработчик попытался написать код, который передавал бы один из этих объектов в другой поток (где использовать их было бы небезопасно, поскольку контекст менеджера памяти там мог быть неподходящим), то получил бы ошибку компиляции.
    • ВызываяIoctlCommand::dispatch, логично предположить, что нам потребуется динамическая диспетчеризация, чтобы добраться до фактической реализации обработчика (и это повлечет дополнительные издержки, которых не было бы в C), но это не так. Благодаря использованию дженериков, компилятор сделает функцию мономорфной, что обеспечит нам статические вызовы функции. Функцию можно будет даже оформить внутристрочно, если оптимизатор сочтет это целесообразным.

    Блокировка и условные переменные

    Разработчикам разрешено использовать мьютексы и спинлоки для обеспечения внутренней изменяемости. В нашем примере мьютекс используется для защиты изменяемых данных; в приведенной ниже таблице показаны структуры данных, используемые в C и Rust соответственно, а также показано, как реализовать ожидание и дождаться, пока счет станет ненулевым, и мы сможем выполнить требуемое считывание:



    Отметим, что такие операции ожидание вполне обычны в существующем коде на C, например, когдаканал ожидаетпартнера для записи,сокет домена unix ожидаетданные, апоиск индексного дескриптора ожидает завершения удаления, либо помощник пользовательского режима ожидаетизменения состояния.

    А вот какими достоинствами обладает соответствующая реализация на Rust:

    • ПолеSemaphore::innerдоступно только во время удержания блокировки, при помощи ограничителя, возвращаемого функциейlock. Поэтому разработчики не могут случайно прочитать или записать защищенные данные, предварительно их не заблокировав. В примере на C, приведенном выше,countиmax_seenвsemaphore_stateзащищены мьютексом, но программа не обязывает держать блокировку во время доступа к ним. there is no enforcement that the lock is held while they're accessed.
    • Получение ресурса есть инициализация (RAII): блокировка снимается автоматически, когда ограничитель (innerв данном случае) выходит из области видимости. Таким образом, все блокировки всегда снимаются: если разработчику нужно, чтобы блокировка оставалась на месте, он может продлевать существование ограничителя, например, возвращая его; верно и обратное: если необходимо снять блокировку ранее, чем ограничитель выйдет из области видимости, это можно сделать явно, вызвав функцию drop.
    • Разработчики могут использовать любую блокировку, использующую типажLock, в состав которого, кстати, входят MutexиSpinLock, и, в отличие от реализации на C, это не повлечет никаких дополнительных издержек во время выполнения. Другие конструкции для синхронизации, в том числе, условные переменные, также работают прозрачно и без дополнительных издержек времени выполнения.
    • Rust реализует условные переменные при помощи очередей ожидания, предусмотренных в ядре. Благодаря этому разработчики могут пользоваться атомарным снятием блокировки и погружать поток в сон, не задумываясь о том, как это отразится на низкоуровневом планировщике функций ядра. В вышеприведенном примере на C вsemaphore_consumeвидим смесь семафорной логики и тонкого планирования в стиле Linux: например, код получится неправильным, еслиmutex_unlockбудет вызван доprepare_to_wait, поскольку таким образом можно забыть об операции пробуждения.
    • Никакого несинхронизированного доступа: как упоминалось выше, переменные, совместно используемые несколькими потоками или процессорами, должны предоставляться только для чтения, и внутренняя изменяемость пригодится для тех случаев, когда изменяемость действительно нужна. Кроме вышеприведенного примера с блокировками, есть еще пример с ioctl из предыдущего раздела, где также демонстрируется, как использовать атомарную переменную. В Rust от разработчиков также требуется указывать, как память должна синхронизироватьсяпри атомарных обращениях. В той части примера, что написана на C, нам довелось использовать atomic64_t, но компилятор не предупредит разработчика о том, что это нужно сделать.

    Обработка ошибок и поток выполнения

    В следующих примерах показано, как в нашем драйвере реализованы операцииopen,read иwrite:







    Здесь видны и еще некоторые достоинства Rust:

    • Оператор?operator: используется реализациями openиreadв Rust для неявного выполнения обработки ошибок; разработчик может сосредоточиться на семафорной логике, и код, который у него получится, будет весьма компактным и удобочитаемым. В версиях на C необходимая обработка ошибок немного замусоривает код, из-за чего читать его сложнее.
    • Обязательная инициализация: Rust требует, чтобы все поля структуры были инициализированы при ее создании, чтобы разработчик не мог где-нибудь нечаянно забыть об инициализации поля. В C такая возможность не предоставляется. В нашем примере с open, показанном выше, разработчик версии на C мог легко забыть вызвать kref_get(пусть даже все поля и были инициализированы); в Rust же пользователь обязан вызватьclone(повышающую счет ссылок на единицу), в противном случае он получит ошибку компиляции.
    • Область видимости при RAII: при реализации записи в Rust используется блок выражений, контролирующий, когда innerвыходит из области видимости и, следовательно, когда снимается блокировка.
    • Поведение при целочисленном переполнении: Rust стимулирует разработчиков всегда продумывать, как должны обрабатываться переполнения. В нашем примере с write, мы хотим обеспечить насыщение, чтобы не пришлось плюсовать к нашему семафору нулевое значение. В C приходится вручную проверять, нет ли переполнений, компилятор не оказывает дополнительной поддержки при такой операции.




    Облачные серверы от Маклауд быстрые и безопасные.

    Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru