Api linux что это

Обновлено: 04.07.2024

Наличие в Unix-системах простых и эффективных средств взаимодействия между процессами оказало программирование в Unix не менее важное влияние, чем представление объектов системы в виде файлов. Благодаря межпроцессному взаимодействию (Inter-Process Communication, IPC) разработчик (и пользователь) может разбить решение сложной задачи на несколько простых операций, каждая из которых доверяется отдельной небольшой программе. Последовательная обработка одной задачи несколькими простыми программами очень похожа на конвейерное производство (среди многих значений английского pipeline есть и «конвейер», но в этой статье мы для перевода слова pipe будем пользоваться принятым в отечественной литературе термином «канал» [3]. Альтернативой конвейерному подходу являются большие монолитные пакеты, построенные по принципу «все в одном». Использование набора простых утилит для решения одной сложной задачи требует несколько большей квалификации со стороны пользователя, но взамен предоставляет гибкость, не достижимую при использовании монолитных «монстров». Наборы утилит, использующих открытые протоколы IPC, легко наращивать и модифицировать. Разбиение сложных задач на сравнительно небольшие подзадачи также позволяет снизить количество ошибок, допускаемых программистами (см. врезку). Помимо всего этого у IPC есть еще одно важное преимущество. Программы, использующие IPC, могут «общаться» друг с другом практически также эффективно, как и с пользователем, в результате чего появляется возможность автоматизировать выполнение сложных задач. Могущество скриптовых языков Unix и Linux во многом основано на возможностях IPC.

Фредерик Брукс, автор книги «Мифический человеко-месяц», высказал предположение (известное как «закон Брукса»), что количество ошибок в проекте должно быть пропорционально квадрату числа участников проекта, тогда как объем полезной работы при увеличении числа участников проекта растет линейно. Закон Брукса означал, что на определенном этапе развития проекта любая попытка привлечь к разработке новых программистов приводит лавинообразному росту числа ошибок (а значит все больше и больше работы требуется на их выявление и исправление). Если бы закон Брукса выполнялся, то для всех проектов существовал бы верхний порог сложности, при попытке превысить который КПД разработки начинал бы стремительно падать. Что же касается открытой модели разработки ПО, то она, с точки зрения закона Брукса, была бы невозможна в принципе. Для того, чтобы понять, в чем Ф. Брукс ошибался, следует рассмотреть исходные посылки его рассуждений. Закон Брукса основан двух предположениях (а) ошибки чаще возникают на стыке элементов проекта, выполняемых разными разработчиками (соответственно, чем больше таких «швов», тем больше ошибок); (б) модель взаимодействия разработчиков представляет собой полный граф (то есть, каждый разработчик взаимодействует со всеми остальными участниками проекта), число ребер которого пропорционально квадрату числа вершин. Ни то, ни другое утверждение, вообще говоря, неверно. В частности, при программировании «канальных» утилит всем участникам проекта нет надобности непосредственно контактировать между собой. Каждая группа разработчиков должна следовать только фиксированному протоколу обмена данными между программами, так что в этом случае число ошибок подчиняется линейной, а не квадратичной, зависимости.

Неименованные каналы

Самый распространенный вариант внутри-программного использования каналов: программа запускает другую программу и считывает данные, которые та выводит в свой стандартный поток вывода. С помощью этого трюка разработчик может использовать в своей программе функциональность другой программы, не вмешиваясь во внутренние детали ее работы. Для решения этой задачи мы воспользуемся функциями popen(3) и pclose(3). Формально эти функции подобны функциям fopen(3) и fclose(3). Функция popen() запускает внешнюю программу и возвращает вызвавшему ее приложению указатель на структуру FILE, связанный либо со стандартным потоком ввода, либо со стандартным потоком вывода запущенного процесса. Первый параметр функции popen() - строка, содержащая команду, запускающую внешнюю программу. Второй параметр определяет, какой из стандартных потоков (вывода или ввода) будет возвращен. Аргумент “w” соответствует потоку ввода запускаемой программы, в этом случае приложение, вызвавшее popen(), записывает данные в поток. Аргумент “r” соответствует потоку вывода. Функция pclose() служит для завершения работы с внешним приложением и закрытием канала. Для демонстрации работы с функциями popen()/pclose() мы напишем небольшую программу makelog (полный текст программы можно найти здесь в файле makelog.c) Программа makelog выполняет команду оболочки, переданную ей в качестве параметра и записывает данные, выводимые этой командой, одновременно на стандартный терминал и в файл log.txt (аналогичными функциями обладает стандартная команда tee). Например, если скомпилировать программу: на экране терминала будут распечатаны данные, выводимые командой оболочки ls -al, а в рабочей директории программы makelog будет создан файл log.txt, содержащий те же данные. Кавычки вокруг команды оболочки нужны для того, чтобы программа makelog получала строку вызова команды как один параметр командной строки.

Как читатель наверняка уже догадался, изюминка программы makelog заключается в использовании функции popen(). Рассмотрим фрагмент исходного текста программы:

Эта операция очень похожа на открытие обычного файла для чтения. Переменная f имеет тип FILE *, но в параметре argv[1] функции popen передается не имя файла, а команда на запуск программы или команды оболочки, например, "ls -al". Если вызов popen() был успешен, мы можем считывать данные, выводимые запущенной командой, с помощью обычной функции fread(3):

Особенность функции popen() заключается в том, что эта функция не возвращает NULL, даже если переданная ей команда не является корректной. Самый простой способ обнаружить ошибку в этой ситуации - попытаться прочесть данные из потока вывода. Если в потоке вывода нет данных (fread() возвращает значение 0), значит произошла ошибка. Для вывода данных, прочитанных с помощью fread(), на терминал мы используем функцию write() с указанием дескриптора стандартного потока вывода:

Параллельно эти же данные записываются в файл на диске. По окончании чтения данных открытый канал нужно закрыть:

Следует иметь в виду, что pclose() вернет управление вызывающему потоку только после того как запущенное с помощью popen() приложение завершит свою работу.

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

Для обмена данными с внешним приложением функция popen() использует каналы неявным образом. В своих программах мы можем использовать каналы и непосредственно. Наиболее распространенный тип каналов, - неименованные однонаправленные каналы (anonymous pipes), создаваемые функцией pipe(2). На уровне интерфейса программирования такой канал представляется двумя дескрипторами файлов, один из которых служит для чтения данных, а другой - для записи. Каналы не поддерживают произвольный доступ, т. е. данные могут считываться только в том же порядке, в котором они записывались. Неименованные каналы используются преимущественно вместе с функцией fork(2) и служат для обмена данными между родительским и дочерним процессами. Для организации подобного обмена данными, сначала, с помощью функции pipe(), создается канал. Функции pipe() передается единственный параметр - массив типа int, состоящий из двух элементов. В первом элементе массива функция возвращает дескриптор файла, служащий для чтения данных из канала (выход канала), а во втором - дескриптор для записи (вход). Затем, с помощью функции fork() процесс «раздваивается». Дочерний процесс наследует от родительского процесса оба дескриптора, открытых с помощью pipe(), но, также как и родительский процесс, он должен использовать только один из дескрипторов. Направление передачи данных между родительским и дочерним процессом определяется тем, какой дескриптор будет использоваться родительским процессом, а какой - дочерним. Продемонстрируем изложенное на простом примере программы pipes.c, использующей функции pipe() и fork().

Оба дескриптора канала хранятся в переменной pipedes. После вызова fork() процесс раздваивается и родительский процесс (тот, в котором fork() вернула ненулевое значение, равное, кстати, PID дочернего процесса) закрывает дескриптор, открытый для чтения, и записывает данные в канал, используя дескриптор, открытый для записи (pipedes[1]). Дочерний процесс (в котором fork() вернула 0) первым делом закрывает дескриптор, открытый для записи, и затем считывает данные из канала, используя дескриптор, открытый для чтения (pipedes[0]). Назначение дескрипторов легко запомнить, сопоставив их с аббревиатурой I/O (первый дескриптор - для чтения (input), второй - для записи (output)). Стандарт POSIX предписывает, чтобы каждый процесс, получивший оба канальных дескриптора, закрывал тот дескриптор, который ему не нужен, перед тем, как начать работу с другим дескриптором, и хотя в системе Linux этим требованием можно пренебречь, лучше все же придерживаться строгих правил. В нашем примере нам не нужно беспокоиться о синхронизации передачи данных, поскольку ядро системы выполнит всю трудную работу за нас. Но в жизни встречаются и не столь тривиальные случаи. Например, ничто не мешает нам создать несколько дочерних процессов с помощью нескольких вызовов fork(). Все эти процессы могут использовать один и тот же канал, при условии, что каждый процесс использует только один из дескрипторов pipdes, согласно его назначению. В этой ситуации нам пришлось бы выполнять синхронизацию передачи данных явным образом.

Как канал передает данные

Для передачи данных по каналу используются специальные объекты ядра системы, называемые буферами каналов (pipe buffers). Даже если предыдущая запись заполнила буфер не полностью, повторная запись данных в буфер становится возможной только после того, как прежде записанные данные будут прочитаны. Это означает, что если разные процессы, пишущие данные в один и тот же канал, передают данные блоками, размеры которых не превышают объем буферов, данные из блоков разных процессов не будут перемешиваться между собой. Использование этой особенности каналов существенно упрощает синхронизацию передачи данных. Узнать размер буфера можно с помощью вызова функции где pipedes - дескриптор канала. На архитектуре IA32 размер буфера составляет 4 килобайта. Начиная с ядра 2.6.11, каждый канал может использовать до 16 буферов, что существенно повышает производительность каналов.

Познакомившись с неименованными каналами, мы можем самостоятельно реализовать аналог функции popen() без «дополнительных расходов» (то есть, без запуска процесса оболочки). Напишем небольшую программу, которая запускает утилиту netstat, читает данные, выводимые этой утилитой, и выводит их на экран. Если бы мы использовали для этой цели функцию popen(), то получили бы доступ к потоку вывода netstat с помощью и скопировали данные на экран. Этот способ прост, но не эффективен. Мы напишем другую программу (файл printns.c). Структура этой программы та же, что и в предыдущем примере, только теперь родительский процесс читает данные с помощью канала. Самое интересное происходит в дочернем процессе, где выполняется последовательность функций:

С помощью функции dup2(2) мы перенаправляем стандартный поток вывода дочернего процесса (дескриптор стандартного потока вывода равен 1) в канал, используя дескриптор pipdes[1], открытый для записи. Далее с помощью функции execve(2) мы заменяем образ дочернего процесса процессом netstat (обратите внимание, что поскольку в нашем распоряжении нет оболочки с ее переменной окружения PATH, путь к исполнимому файлу netstat нужно указывать полностью). В результате родительский процесс может читать стандартный вывод netstat через поток, связанный с дескриптором pipdes[0] (и никакой оболочки!). Именованные каналы

Хотя в приведенном выше примере неименованные каналы используются только для передачи данных между процессами, связанными «родственными узами», существует возможность использовать их и для передачи данных между совершенно разными процессами. Для этого нужно организовать передачу дескрипторов канала между неродственными процессами, как это описано, например, в [2]. Однако, передача дескрипторов стороннему процессу носит скорее характер трюка (или «хака»), и мы на ней останавливаться не будем. Для передачи данных между неродственными процессами мы воспользуемся механизмом именованных каналов (named pipes), который позволяет каждому процессу получить свой, «законный» дескриптор канала. Передача данных в этих каналах (как, впрочем, и в однонаправленных неименованных каналах) подчиняется принципу FIFO (первым записан - первым прочитан), поэтому в англоязычной литературе иногда можно встретить названия FIFO pipes или просто FIFOs. Именованные каналы отличаются от неименованных наличием имени (странно, не правда ли?), то есть идентификатора канала, потенциально видимого всем процессам системы. Для идентификации именованного канала создается файл специального типа pipe. Это еще один представитель семейства виртуальных файлов Unix, не предназначенных для хранения данных (размер файла канала всегда равен нулю). Файлы именованных каналов являются элементами VFS, как и обычные файлы Linux, и для них действуют те же правила контроля доступа. Файлы именованных каналов создаются функцией mkfifo(3). Первый параметр этой функции - строка, в которой передается имя файла, идентифицирующего канал, второй параметр - маска прав доступа к файлу. Функции mkfifo() создает канал и файл соответствующего типа. Если указанный файл канала уже существует, mkfifo() возвращает -1, (переменная errno принимает значение EEXIST). После создания файла канала процессы, участвующие в обмене данными, должны открыть этот файл либо для записи, любо для чтения. После закрытия файла канала, файл (и канал) продолжают существовать. Для того, чтобы закрыть сам канал, нужно удалить его файл, например с помощью последовательных вызовов unlink(2).

В качестве маски доступа мы используем восьмеричное значение 0600, разрешающее процессу с аналогичными реквизитами пользователя чтение и запись (можно было бы использовать маску 0666, но на мы на всякий случай воздержимся от упоминания Числа Зверя, пусть даже восьмеричного, в нашей программе). Для краткости мы не проверяем значение, возвращенное mkfifo(), на предмет ошибок. В результате вызова mkfifo() с заданными параметрами в рабочей директории программы должен появиться специальный файл fifofile. Файл- менеджер KDE отображает файлы канала с помощью красивой пиктограммы, изображающей приоткрытый водопроводный кран. Далее в программе-сервере мы просто открываем созданный файл для записи:

Считывание данных, вводимых пользователем, выполняется с помощью getchar(), а с помощью функции fputc() данные передаются в канал. Работа сервера завершается, когда пользователь вводит символ “q”. Исходный текст программы-клиента можно найти в файле typeclient.c. Клиент открывает файл fifofile для чтения как обычный файл:

Символы, передаваемые по каналу, считываются с помощью функции fgetc() и выводятся на экран терминала с помощью putchar(). Каждый раз, когда пользователь сервера наживает ввод, функция fflush(), вызываемая сервером (см. файл typeserver.c), выполняет принудительную очистку буферов канала, в результате чего клиент считывает все переданные символы. Получение символа “q” завершает работу клиента.

Скомпилируйте программы typeserver.c и typeclient.c в одной директории. Запустите сначала сервер, потом клиент в разных окнах терминала. Печатайте текст в окне сервера. После каждого нажатия клавиши [Enter] клиент должен отображать строку, напечатанную на сервере.

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

Одной из сильных сторон Unix/Linux IPC является возможность организовывать взаимодействие между программами, которые не только ничего не знают друг о друге, но и используют разные механизмы ввода/вывода. Сравним нашу программу typeclient и команду ls. Казалось бы, между ними нет ничего общего - typeclient получает данные, используя именованный канал, а ls выводит содержимое директории в стандартный поток вывода. Однако, мы можем организовать передачу данных от ls к typeclient с помощью всего лишь пары команд bash! В директории программы typeclient дайте команду:

Эта команда создаст файл канала fifofile также, как это сделала бы программа typeserver. Запустите программу typeclient, а затем в другом окне терминала дайте команду, наподобие где /path/fifofile - путь к файлу FIFO. В результате, программа typeclient распечатает содержимое соответствующей директории. Главное, чтобы в потоке данных не встретился символ “q”, завершающий ее работу.

Я, исследуя устойчивость хранения данных в облачных системах, решил проверить себя, убедиться в том, что понимаю базовые вещи. Я начал с чтения спецификации NVMe для того чтобы разобраться с тем, какие гарантии, касающиеся устойчивого хранения данных (то есть — гарантии того, что данные будут доступны после сбоя системы), дают нам NMVe-диски. Я сделал следующие основные выводы: нужно считать данные повреждёнными с того момента, как отдана команда записи данных, и до того момента, как завершится их запись на носитель информации. Однако в большинстве программ для записи данных совершенно спокойно используются системные вызовы.

В этом материале я исследую механизмы устойчивого хранения данных, предоставляемые файловыми API Linux. Кажется, что тут всё должно быть просто: программа вызывает команду write() , а после того, как работа этой команды завершится, данные будут надёжно сохранены на диске. Но write() лишь копирует данные приложения в кеш ядра, расположенный в оперативной памяти. Для того чтобы принудить систему к записи данных на диск, нужно использовать некоторые дополнительные механизмы.


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

Особенности использования функции write()

Системный вызов write() определён в стандарте IEEE POSIX как попытка записи данных в файловый дескриптор. После успешного завершения работы write() операции чтения данных должны возвращать именно те байты, которые были до этого записаны, делая это даже в том случае, если к данным обращаются из других процессов или потоков (вот соответствующий раздел стандарта POSIX). Здесь, в разделе, посвящённом взаимодействию потоков с обычными файловыми операциями, имеется примечание, в котором говорится, что если каждый из двух потоков вызывает эти функции, то каждый вызов должен видеть либо все обозначенные последствия, к которым приводит выполнение другого вызова, либо не видеть вообще никаких последствий. Это позволяет сделать вывод о том, что все файловые операции ввода/вывода должны удерживать блокировку ресурса, с которым работают.

Означает ли это, что операция write() является атомарной? С технической точки зрения — да. Операции чтения данных должны возвращать либо всё, либо ничего из того, что было записано с помощью write() . Но операция write() , в соответствии со стандартом, не обязательно должна завершаться, записав всё то, что ей предложено было записать. Ей позволено выполнить запись лишь части данных. Например, у нас может быть два потока, каждый из которых присоединяет 1024 байта к файлу, описываемому одним и тем же файловым дескриптором. С точки зрения стандарта приемлемым будет результат, когда каждая из операций записи сможет присоединить к файлу лишь по одному байту. Операции эти останутся атомарными, но после того, как они завершатся, данные, записанные ими в файл, окажутся перемешанными. Вот очень интересная дискуссия на эту тему на Stack Overflow.

Функции fsync() и fdatasync()

Самый простой способ сброса данных на диск заключается в вызове функции fsync(). Эта функция запрашивает у операционной системы перенос всех модифицированных блоков из кеша на диск. Сюда входя и все метаданные файла (время доступа, время модификации файла и так далее). Я полагаю, что необходимость в этих метаданных возникает редко, поэтому, если вы знаете о том, что для вас они не важны, вы можете пользоваться функцией fdatasync() . В справке по fdatasync() говорится, что в ходе работы этой функции производится сохранение на диск такого объёма метаданных, который «необходим для корректного выполнения следующих операций чтения данных». А это — именно то, что заботит большинство приложений.

Одна из проблем, которая тут может возникнуть, заключается в том, что эти механизмы не гарантируют того, что файл можно будет обнаружить после возможного сбоя. В частности, когда создают новый файл, нужно вызвать fsync() для директории, которая его содержит. Иначе после сбоя может оказаться так, что этого файла не существует. Причина этого заключается в том, что в UNIX, из-за применения жёстких ссылок, файл может существовать в нескольких директориях. Поэтому при вызове fsync() для файла нет способа узнать о том, данные какой именно директории тоже надо сбросить на диск (тут об этом можно почитать подробнее). Похоже, что файловая система ext4 способна автоматически применять fsync() к директориям, содержащим соответствующие файлы, но в случае с другими файловыми системами это может быть не так.

Этот механизм может быть по-разному реализован в разных файловых системах. Я использовал blktrace для того чтобы узнать о том, какие дисковые операции используются в файловых системах ext4 и XFS. И та и другая выдают обычные команды записи на диск и для содержимого файлов, и для журнала файловой системы, сбрасывают кеш и завершают работу, выполняя FUA-запись (Force Unit Access, запись данных прямо на диск, минуя кеш) в журнал. Вероятно, они поступают именно так для того чтобы подтвердить факт совершения операции. На дисках, которые не поддерживают FUA, это вызывает два сброса кеша. Мои эксперименты показали, что fdatasync() немного быстрее fsync() . Утилита blktrace указывает на то, что fdatasync() обычно пишет на диск меньше данных (в ext4 fsync() записывает 20 КиБ, а fdatasync() — 16 КиБ). Кроме того, я выяснил, что XFS немного быстрее, чем ext4. И тут с помощью blktrace удалось узнать о том, что fdatasync() сбрасывает на диск меньше данных (4 КиБ в XFS).

Неоднозначные ситуации, возникающие при использовании fsync()

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

Первый такой случай произошёл в 2008 году. Тогда интерфейс Firefox 3 «подвисал» в том случае, если выполнялась запись на диск большого количества файлов. Проблема заключалась в том, что в реализации интерфейса для хранения сведений о его состоянии использовалась база данных SQLite. После каждого изменения, произошедшего в интерфейсе, вызывалась функция fsync() , что давало хорошие гарантии устойчивого хранения данных. В используемой тогда файловой системе ext3 функция fsync() сбрасывала на диск все «грязные» страницы в системе, а не только те, которые имели отношение к соответствующему файлу. Это означало, что щелчок по кнопке в Firefox мог инициировать запись мегабайтов данных на магнитный диск, что могло занять многие секунды. Решение проблемы, насколько я понял из этого материала, заключалось в том, чтобы перенести работу с базой данных в асинхронные фоновые задачи. Это означает, что раньше в Firefox были реализованы более жёсткие требования к устойчивости хранения данных, чем это было реально нужно, а особенности файловой системы ext3 лишь усугубили эту проблему.

Вторая неувязка случилась в 2009 году. Тогда, после сбоя системы, пользователи новой файловой системы ext4 столкнулись с тем, что многие недавно созданные файлы имеют нулевую длину, а вот с более старой файловой системой ext3 подобного не произошло. В предыдущем абзаце я говорил о том, что ext3 сбрасывала на диск слишком много данных, что сильно замедляло работу fsync() . Для того чтобы улучшить ситуацию, в ext4 на диск сбрасываются только те «грязные» страницы, которые имеют отношение к конкретному файлу. А данные других файлов остаются в памяти в течение гораздо более длительного времени, чем при применении ext3. Это было сделано ради улучшения производительности (по умолчанию данные пребывают в таком состоянии 30 секунд, настраивать это можно с помощью dirty_expire_centisecs; тут можно найти дополнительные материалы об этом). Это означает, что большой объём данных может быть безвозвратно утерян после сбоя. Решение этой проблемы заключается в использовании fsync() в приложениях, которым нужно обеспечить устойчивое хранение данных и максимально обезопасить их от последствий сбоев. Функция fsync() работает при применении ext4 гораздо эффективнее, чем при применении ext3. Минус такого подхода заключается в том, что его применение, как и прежде, замедляет выполнение некоторых операций, вроде установки программ. Подробности об этом смотрите здесь и здесь.

Третья проблема, касающаяся fsync() , возникла в 2018 году. Тогда, в рамках проекта PostgreSQL, было выяснено, что если функция fsync() сталкивается с ошибкой, она помечает «грязные» страницы как «чистые». В результате следующие вызовы fsync() ничего с такими страницами не делают. Из-за этого модифицированные страницы хранятся в памяти и никогда не записываются на диск. Это — настоящая катастрофа, так как приложение будет считать, что какие-то данные записаны на диск, а на самом деле это будет не так. Такие сбои fsync() бывают редко, приложение в таких ситуациях почти ничего не может сделать для борьбы с проблемой. В наши дни, когда это происходит, PostgreSQL и другие приложения аварийно завершают работу. Здесь, в материале «Can Applications Recover from fsync Failures?», эта проблема исследуется во всех деталях. В настоящее время лучшим решением этой проблемы является использование Direct I/O с флагом O_SYNC или с флагом O_DSYNC . При таком подходе система сообщит об ошибках, которые могут возникнуть при выполнении конкретных операций записи данных, но этот подход требует того, чтобы приложение управляло бы буферами самостоятельно. Подробности об этом читайте здесь и здесь.

Открытие файлов с использованием флагов O_SYNC и O_DSYNC

Вернёмся к обсуждению механизмов Linux, обеспечивающих устойчивое хранение данных. А именно, речь идёт об использовании флага O_SYNC или флага O_DSYNC при открытии файлов с использованием системного вызова open(). При таком подходе каждая операция записи данных выполняется так, как будто после каждой команды write() системе дают, соответственно, команды fsync() и fdatasync() . В спецификации POSIX это называется «Synchronized I/O File Integrity Completion» и « Data Integrity Completion». Главное преимущество такого подхода заключается в том, что для обеспечения целостности данных нужно выполнить лишь один системный вызов, а не два (например — write() и fdatasync() ). Главный недостаток этого подхода в том, что все операции записи, использующие соответствующий файловый дескриптор, будут синхронизированы, что может ограничить возможности по структурированию кода приложения.

Использование Direct I/O с флагом O_DIRECT

Системный вызов open() поддерживает флаг O_DIRECT , который предназначен для того, чтобы, обходя кеш операционной системы, выполнять операции ввода-вывода, взаимодействуя непосредственно с диском. Это, во многих случаях, означает, что команды записи, выдаваемые программой, будут напрямую транслироваться в команды, направленные на работу с диском. Но, в общем случае, этот механизм не является заменой функций fsync() или fdatasync() . Дело в том, что сам диск может отложить или кешировать соответствующие команды записи данных. И, что ещё хуже, в некоторых особых случаях операции ввода-вывода, выполняемые при использовании флага O_DIRECT , транслируются в традиционные буферизованные операции. Легче всего решить эту проблему можно, используя для открытия файлов ещё и флаг O_DSYNC , что будет означать, что за каждой операцией записи будет идти вызов fdatasync() .

Оказалось, что в файловой системе XFS недавно был добавлен «быстрый путь» для O_DIRECT|O_DSYNC -записи данных. Если перезаписывают блок с использованием O_DIRECT|O_DSYNC , то XFS, вместо сброса кеша, выполнит команду FUA-записи в том случае, если устройство это поддерживает. Я в этом убедился, пользуясь утилитой blktrace в системе Linux 5.4/Ubuntu 20.04. Такой подход должен быть эффективнее, так как при его использовании на диск записывается минимальное количество данных и при этом применяется одна операция, а не две (запись и сброс кеша). Я нашёл ссылку на патч ядра 2018 года, в котором реализован этот механизм. Там есть обсуждение, касающееся применения этой оптимизации и в других файловых системах, но, насколько мне известно, XFS — это пока единственная файловая система, которая это поддерживает.

Функция sync_file_range()

В Linux есть системный вызов sync_file_range(), который позволяет сбросить на диск лишь часть файла, а не весь файл. Этот вызов инициирует асинхронный сброс данных и не ожидает его завершения. Но в справке к sync_file_range() говорится, что эта команда «очень опасна». Пользоваться ей не рекомендуется. Особенности и опасности sync_file_range() очень хорошо описаны в этом материале. В частности, по видимому, этот вызов использует RocksDB для управления тем, когда ядро сбрасывает «грязные» данные на диск. Но при этом там, для обеспечения устойчивого хранения данных, используется и fdatasync() . В коде RocksDB есть интересные комментарии на эту тему. Например, похоже, что вызов sync_file_range() при использовании ZFS не приводит к сбросу данных на диск. Опыт подсказывает мне, что код, которым пользуются редко, возможно, содержит ошибки. Поэтому я посоветовал бы не пользоваться этим системным вызовом без крайней необходимости.

Системные вызовы, помогающие обеспечить устойчивое хранение данных

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

  1. Вызов функции fdatasync() или fsync() после функции write() (лучше пользоваться fdatasync() ).
  2. Работа с файловым дескриптором, открытым с флагом O_DSYNC или O_SYNC (лучше — с флагом O_DSYNC ).
  3. Использование команды pwritev2() с флагом RWF_DSYNC или RWF_SYNC (предпочтительнее — с флагом RWF_DSYNC ).

Заметки о производительности

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

Вызовы конкретных функций ядра (системные вызовы) являются естественной частью разработки приложений на GNU / Linux. Но что относительно другого направления, когда вызов осуществляется из пространства ядра в пользовательское пространство? Оказывается, что есть ряд приложений, использующих эту возможность, которыми вы, вероятно, пользуетесь каждый день. Например, когда ядро обнаруживает устройство, для которого необходимо загрузить модуль, то как происходит этот процесс? Происходит динамическая загрузка модуля, которая выполняется из ядра в рамках процесса, называемого usermode-helper.

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

Версия ядра

В настоящей статье изучается usermode-helper API для ядра 2.6.27.

Предупреждение

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

Usermode-helper API

Интерфейс usermode-helper API представляет собой простое API с хорошо известным набором возможностей. Например, чтобы создать процесс из пользовательского пространства, вы обычно указываете имя исполняемого модуля и набор переменных среды (смотрите справочную страницу man execve ). То же самое происходит при создании процесса из ядра. Но, поскольку вы запускаете процесс из пространства ядра, есть ряд дополнительных возможностей.

В таблице 1 приведен базовый набор функций ядра, которые есть в usermode-helper API.

Таблица 1: Базовые функции в usermode-helper API

Функция APIОписание
call_usermodehelper_setup Подготовка обработчика handler для вызова функции пользовательского пространства
call_usermodehelper_setkeys Установка сессионных ключей для helper
call_usermodehelper_setcleanup Установка функции очистки cleanup для helper
call_usermodehelper_stdinpipe Создание конвейера stdin для helper
call_usermodehelper_exec Вызов функции пользовательского пространства

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

Таблица 2: Сокращенные функции usermode-helper API

Функция APIОписание
call_usermodehelper Осуществляет вызов функции пользовательского пространства
call_usermodehelper_pipe Осуществляет вызов функции пользовательского пространства с использованием конвейера stdin
call_usermodehelper_keys Осуществляет вызов функции пользовательского пространства с использованием сессионных ключей

Давайте сначала пройдемся по базовым функциям, а затем изучим возможности, которые предлагают сокращенные функции. Базовое API в своей работе использует ссылку на обработчик (handler), который является структурой subprocess_info . В этой структуре (которую можно найти в ./kernel/kmod.c) собраны все элементы, необходимые для данного экземпляра usermode-helper. Ссылка на структуру возвращается в вызове call_usermodehelper_setup . Дальнейшее конфигурирование структуры (и последующих вызовов) осуществляется с помощью вызовов call_usermodehelper_setkeys (работа с учетными данными), call_usermodehelper_setcleanup и call_usermodehelper_stdinpipe . Наконец, после того, как конфигурирование будет завершено, вы можете с помощью функции call_usermodehelper_exec вызвать сконфигурированное приложение, работающее в пользовательском режиме.

Базовые функции предоставят вам максимальную возможность управления, причем функции helper выполнят большую часть работы за один вызов. Вызовы, использующие конвейеры ( call_usermodehelper_stdinpipe и функция call_usermodehelper_pipe из helper), создают соответствующий конвейер для использования его helper-ом. В частности, создается конвейер pipe (файловая структура в ядре). Приложение пользовательского пространства читает данные из pipe, а со стороны ядра осуществляется запись в pipe. А что касается записи, то выдача дампа памяти является единственным приложением, которое может использовать конвейер совместно с usermode-helper. В этом приложении ( do_coredump() в ./fs/exec.c ), выдача дампа памяти выполняет запись данных через конвейер из пространства ядра в пользовательское пространство.

Соотношение между этими функциями и sub_processinfo вместе с деталями структуры subprocess_info показано на рис. 1.

Рис.1. Элементы интерфейса usermode-helper API

Сокращенные функции из Таблицы 2 внутри себя вызывают функцию call_usermodehelper_setup и функцию call_usermodehelper_exec . Последние два вызова, указанные в таблице 2, вызывают, соответственно, call_usermodehelper_setkeys и call_usermodehelper_stdinpipe . Исходный код для call_usermodehelper_pipe вы можете найти в файле ./kernel/kmod.c, а исходный код для call_usermodehelper и call_usermodhelper_keys — в файле ./include/linux/kmod.h.

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

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

Таблица 3. Приложения, использующие usermode-helper API для вызовов из ядра

ApplicationSource location
Загрузка модулей ядра./kernel/kmod.c
Управление питаниемt./kernel/sys.c
Управление группами./kernel/cgroup.c
Генерация ключей./security/keys/request_key.c
Передача событий в ядро./lib/kobject_uevent.c

Одним из наиболее ожидаемых применений usermode-helper API является загрузка модулей из пространства ядра. В функции request_module инкапсулирована функциональность usermode-helper API и предоставлен простой интерфейс. В обычных случаях ядро идентифицирует устройство или необходимую службу и делает вызов request_module , который осуществляет загрузку модуля. В случае использования usermode-helper API модуль загружается в ядро через modprobe (в пользовательском пространстве приложение вызывается с помощью request_module ).

Аналогичной ситуацией, в которой загружаются модули, является "горячее" подключение устройств (добавление или удаление устройств во время работы системы). Эта возможность реализована с помощью usermode-helper API, вызывающего утилиту /sbin/hotplug, исполняемую в пользовательском пространстве.

Интересным приложением, использующим usermode-helper API (через request_module ), является textsearch API (./lib/textsearch.c). Это приложение обеспечивает в ядре реализацию настраиваемой инфраструктуры, предназначенной для текстового поиска. Это приложение использует usermode-helper API для динамической загрузки поисковых алгоритмов, реализованных в виде загружаемых модулей. В релизе ядра 2.6.30 поддерживается три алгоритма, в том число алгоритм Бойера-Мура (Boyer-Moor, смотрите в ./lib/ts_bm.c), нативного подхода с использованием автомата с конечным числом состояний (./lib/ts_fsm.c) и, наконец, алгоритм Кнута-Морриса-Пратта (Knuth-Morris-Pratt, смотрите в ./lib/ts_kmp.cc).

Интерфейс usermode-helper API также используется в Linux для упорядоченного завершения работы системы. Когда необходимо отключить питание системы, ядро вызывает в пользовательском пространстве команду /sbin/poweroff, которая выполняет соответствующие действия. В таблице 3 приведен список других приложений и указывается место, где можно найти их исходный код.

Внутренняя организация usermode-helper API

Исходный код API для usermode-helper API можно найти в файле kernel/kmod.c (где проиллюстрировано его первоочередное использование в качестве используемого в ядре загрузчика модулей в пространство ядра). Основной объем работы в этой реализации выполняет функция kernel_execve . Обратите внимание, что kernel_execve является функцией, которая используется для запуска процесса init при загрузке системы и не использует usermode-helper API.

Реализация usermode-helper API довольно проста и очевидна (см. рис 2). Работа usermode-helper начинается с вызова функции call_usermodehelper_exec (которая используется для вызова приложения, предназначенного для работы в пользовательском пространсте, взятого из предварительно сконфигурированной структуры subprocess_info ). Эта функция имеет два аргумента: ссылку на структуру subprocess_info и аргумент перечисляемого типа (определяющего ждать или не ждать, когда процесс будет запущен, и ждать или не ждать, когда процесс полностью завершится). Затем структура subprocess_info (или, точнее, элемент work_struct этой структуры) ставится в очередь в рабочую структуру ( khelper_wq ), которая выполняет асинхронный вызов.

Рис.2. Внутренняя организация usermode-helper API

Когда элемент помещается в khelper_wq, вызывается функция обработчика для очереди работ (в данном случае, __call_usermodehelper ), которая исполняется внутри потока khelper . Эта функция начинается с удаления из очереди структуры subprocess_info , в которой содержится вся информация, необходимая для вызова из пользовательского пространства. Далее весь процесс зависит от значения переменной wait , имеющий перечисляемый тип. Если запрашиваемый процесс хочет ждать, пока весь данный процесс закончится, в том числе и вызовы, сделанные в пользовательском пространстве ( UMH_WAIT_PROC ) или вообще ничего не захочет ждать ( UMH_NO_WAIT ), то поток ядра создается из функции wait_for_helper . В противном случае, запрашиваемый процесс просто будет ждать, пока закончится вызов приложения в пользовательском пространстве ( UMH_WAIT_EXEC ), но не завершение приложения. В этом случае поток ядра создается для ____call_usermodehelper() .

В потоке wait_for_helper установлен обработчик сигнала SIGCHLD, а для ____call_usermodehelper создается другой поток ядра. Но в потоке wait_for_helper делается вызов sys_wait4 , который дожидается окончания потока ядра ____call_usermodehelper (указывается сигналом SIGCHLD). Затем поток выполняет всю необходимую очистку (либо освобождая структуры для UMH_NO_WAIT , либо просто посылая уведомление о завершении в call_usermodehelper_exec() ).

Функция ____call_usermodehelper является именно тем местом, где выполняется фактическая работа по запуску приложения в пользовательском пространстве. Эта функция начинается с разблокирования всех сигналов и настройки службы хранения ключей. Она также устанавливает стандартный конвейер stdin (если это требуется). Затем, после еще некоторой минимальной инициализации приложение пользовательского пространства запускается с помощью вызова функции kernel_execve (из kernel/syscall.c), который включает в себя заранее заданный список path, argv (в том числе указывающий название приложения пользовательского пространства), а также настройку среды окружения. Когда этот процесс завершится, произойдет выход из потока с помощью обращения к функции do_exit() .

Этот процесс также используется при завершении Linux, что похоже на операцию с использованием семафоров. Когда вызывается функция call_usermodehelper_exec , делается объявление о завершении работы системы. После того, как структура subprocess_info помещается в khelper_wq , делается вызов функции wait_for_completion (использующую переменную completion, указывающую о завершении, в качестве своего единственного аргумента). Обратите внимание, что эта переменная, также хранится в структуре subprocess_info как поле complete . Когда дочерние потоки захотят обратиться к функции call_usermodehelper_exec , они вызовут метод ядра complete , возвращающий переменную completion в структуре subprocess_info . Этот вызов разблокирует функцию, так что она может быть продолжена. Вы можете узнать подробности реализации этого API в файле include/linux/completion.h.

Дальнейшую информацию о usermode-helper API можно найти в разделе "Ресурсы" оригинала статьи.

Приложение - пример

Теперь давайте взглянем на простой пример использования usermode-helper API. Сначала рассмотрим стандартное API, а затем узнаем, как с помощью helper-функций это сделать еще проще.

Для этой демонстрации мы разработаем простой загружаемый модуль ядра, который вызывает API. В листинге 1 представлены функции модуля - заготовки, определяющие функции входа в модуль и выхода из модуля. Эти две функции вызываются по modprobe и insmod для модуля (функция входа в модуль) и по rmmod для модуля (выход из модуля).

Листинг 1. Функции модуля - заготовки

Использование usermode-helper API приведено в листинге 2, который мы изучим детально. Функция начинается с объявления целого ряда необходимых переменных и структур. Начинаем со структуры subprocess_info , которая содержит всю информацию, необходимую для выполнения вызова приложения пользовательского пространства. Этот вызов инициализируется, когда вы вызываете функцию call_usermodehelper_setup . Далее, определяем ваш список аргументов, называемый argv . Этот список аналогичен списку argv , используемый в обычных программах на языке C, в нем указывается приложение (первый элемент массива) и список аргументов. Для того, чтобы указать конец списка, требуется завершающий элемент NULL. Обратите внимание, что неявно подразумевается использование переменной argc (счетчик аргументов), поскольку известна длина списка argv . В этом примере именем приложения будет /usr/bin/logger, а его аргументом — help! , за которым следует завершающий NULL. Следующей необходимой переменной является массив, описывающий среду окружения ( envp ). Этот массив имеет список параметров, которые определяют среду, в которой будет выполняться приложение пользовательского пространства. В этом примере, мы определяем несколько типичных параметров, которые задаются для шелл оболочки, а в конце ставим завершающий NULL.

Листинг 2. Простой тест usermode helper API

Вы можете еще упростить этот процесс, если используете функцию call_usermodehelper , которая одновременно выполняет функцию call_usermodehelper_setup и функцию call_usermodehelper_exec . Как видно из листинга 3, в результате не только удаляется функция, но и пропадает необходимость управлять структурой subprocess_info .

Листинг 3. Еще более простой тест usermode-helper API

Заметьте, что в листинге 3 выполнены все те же требования по настройке и осуществлению вызова (такие как инициализация массивов argv и envp ). Единственная разница состоит в том, что функция helper выполняет функции setup и exec .

Двигаемся дальше

Интерфейс usermode-helper API является важной особенностью ядра, если учитывать его повсеместное и разностороннее применение (от загрузки модулей ядра, "горячего" подключения устройств и даже работу с событиями udev). Хотя важно проверять приложения на соответствие API, понятно, что это важная особенность и, следовательно, будет полезным дополнением к вашему инструментальным средствам ядра Linux.

Ядро Linux предоставляет несколько интерфейсов для приложений пользовательского пространства, которые используются для разных целей и имеют разные свойства по своей конструкции. В ядре Linux есть два типа интерфейса прикладного программирования (API) , которые не следует путать: API "ядро – пользовательское пространство" и API "внутреннее ядро".

СОДЕРЖАНИЕ

Linux API

Linux API - это API-интерфейс ядра и пользовательского пространства, который позволяет программам в пользовательском пространстве получать доступ к системным ресурсам и службам ядра Linux. Он состоит из интерфейса системных вызовов ядра Linux и подпрограмм библиотеки GNU C (glibc). Основное внимание при разработке Linux API было направлено на обеспечение полезных функций спецификаций, определенных в POSIX, в разумно совместимом, надежном и производительном виде, а также на предоставление дополнительных полезных функций, не определенных в POSIX, как и ядро ​​- API пользовательского пространства других систем, реализующих POSIX API, также предоставляют дополнительные функции, не определенные в POSIX.

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

Для POSIX API написано много доступного бесплатного программного обеспечения с открытым исходным кодом . Поскольку в ядро ​​Linux идет гораздо больше разработки по сравнению с другими POSIX-совместимыми комбинациями ядра и стандартной библиотеки C, ядро ​​Linux и его API были дополнены дополнительными функциями. Поскольку эти дополнительные функции обеспечивают техническое преимущество, программирование для Linux API предпочтительнее, чем для POSIX-API. Хорошо известными текущими примерами являются udev , systemd и Weston . Такие люди, как Леннарт Поеттеринг, открыто выступают за предпочтение Linux API перед POSIX API, где это дает преимущества.

Интерфейс системного вызова ядра Linux

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

Публично обсуждаются различные вопросы, связанные с организацией системных вызовов ядра Linux. На проблемы указывали Энди Лютомирски, Майкл Керриск и другие.

Стандартная библиотека C

Библиотека GNU C - это оболочка интерфейса системных вызовов ядра Linux.

Стандартная библиотека C обертка вокруг системных вызовов ядра Linux; Комбинация интерфейса системных вызовов ядра Linux и стандартной библиотеки C - вот что строит Linux API.

Дополнения к POSIX

Как и в других Unix-подобных системах, существуют дополнительные возможности ядра Linux, которые не являются частью POSIX:

  • Подсистема cgroups , системные вызовы, которые она представляет, и libcgroup
  • Системные вызовы Direct Rendering Manager , особенно частные ioctl драйвера для отправки команд, не являются частью спецификаций POSIX.
  • Расширенная звуковая архитектура Linuxможет устанавливать системные вызовы, которые не являются частью спецификаций POSIX.
  • Системные вызовы futex (быстрый мьютекса) в пользовательском пространстве, epoll , splice , dnotify , fanotify , и inotify был эксклюзивом для ядра Linux до сих пор.
  • Системный вызов getrandom был представлен в версии 3.17 основной ветки ядра Linux.
  • memfd был предложен разработчиками kdbus
    • memfd_create был объединен с основной веткой ядра Linux в версии ядра 3.17

    Другие библиотеки

    • libdrm (для диспетчера прямого рендеринга )
    • libnl (набор libnl представляет собой набор библиотек, предоставляющих API для интерфейсов ядра Linux на основе протокола netlink.)
    • libevdev (для evdev )
    • libasound ( Расширенная звуковая архитектура Linux )

    Linux ABI

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

    Он должен иметь возможность компилировать программное обеспечение с разными компиляторами в соответствии с определениями, указанными в ABI, и обеспечивать полную двоичную совместимость. Бесплатными компиляторами и программным обеспечением с открытым исходным кодом являются, например, GNU Compiler Collection , LLVM / Clang .

    На самом деле конечных пользователей интересует не Linux API (или Windows API), а ABI.

    Внутриядерные API

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

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

    Внутриядерные ABI

    Поскольку в ядре нет стабильных API-интерфейсов, не может быть стабильных встроенных в ядро ​​ABI.

    Читайте также: