На этой неделе я получила по почте новую книгу: Программный интерфейс Linux (The Linux Programming Interface). Моя замечательная коллега Аршия (Arshia) порекомендовала мне, и я купила ее! Она написана мейнтейнером проекта Linux man-pages Майклом Керриском (Michael Kerrisk). В ней рассказывается об программном интерфейсе Linux, начиная с ядра версии 2.6.x.

Вот обложка.

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

Вы должны достаточно хорошо разбираться в теме или быть готовым уделить время (например, чтению исходного кода, написанию тестовых программ), чтобы добиться такого понимания. Написание тестовых программ очень важно: довольно много ошибок в ядре и glibc (GNU C Library (GNU библиотека)) было обнаружено при написании тестовых программ во время подготовки man-страниц.

Я подумала, что это отличное напоминание о том, как можно многому научиться, документируя что-либо и составляя небольшие тестовые программы!

Но сегодня мы поговорим о том, что я узнала из этой книги: о системных вызовах select, poll и epoll.

Глава 63: Альтернативные модели ввода-вывода

Эта книга огромна: 1400 страниц. Я начала ее с главы 63 ("Альтернативные модели ввода/вывода"), потому что уже давно хотела понять, что происходит с select, poll и epoll. Когда я подробно описываю то, что узнаю, это помогает мне лучше разобраться!

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

Например, если вы пишете веб-сервер в node.js на Linux, под капотом он фактически использует системный вызов epoll Linux. Давайте поговорим о том, чем epoll отличается от poll и select, и как он работает!

Серверы должны просматривать большое количество файловых дескрипторов

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

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

Вы можете создать цикл, который, по сути, делает следующее:

for x in open_connections:
    if has_new_input(x):
        process_input(x)

Проблема в том, что это может приводить к большим затратам процессорного времени. Вместо того чтобы постоянно запрашивать "есть ли обновления? а как насчет сейчас? что теперь?", мы лучше просто спросим ядро Linux "эй, вот 100 файловых дескрипторов. Скажи мне, когда один из них будет обновлен!".

Три системных вызова, которые позволяют вам попросить Linux отслеживать множество файловых дескрипторов, это poll, epoll и select. Давайте начнем с poll и select, потому что именно с них началась эта глава.

Первый способ: select & poll

Эти два системных вызова доступны в любой Unix-системе, в то время как epoll специфичен для Linux. Вот как они работают, в общих чертах:

  1. Дайте им список дескрипторов файлов, о которых нужно получить информацию.

  2. Они сообщают вам, в каких из них есть данные для чтения/записи.

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

Я обратилась к определению poll и select в исходнике ядра Linux, чтобы подтвердить это, и оказалась права!

Они оба вызывают много одинаковых функций. В частности, в книге упоминается то, что poll возвращает больший набор возможных результатов для таких дескрипторов файлов, как POLLRDNORM | POLLRDBAND | POLLIN | POLLHUP | POLLERR, в то время как select просто сообщает вам "есть ввод / есть вывод / есть ошибка".

select преобразовывает из более подробных результатов poll (например, POLLWRBAND) в общие "вы можете написать". Вы можете посмотреть код, где это делается в Linux 4.10 здесь.

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

Чтобы убедиться в этом, вы можете просто посмотреть на сигнатуры для poll и select!

int ppoll(struct pollfd *fds, nfds_t nfds,
          const struct timespec *tmo_p, const sigset_t
          *sigmask)`
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
            fd_set *exceptfds, const struct timespec *timeout,
            const sigset_t *sigmask);

С помощью poll вы говорите "вот файловые дескрипторы, которые я хочу отслеживать: 1, 3, 8, 19 и т.д." (это аргумент pollfd). При использовании select сообщаете: "Я хочу отслеживать 19 файловых дескрипторов. Вот 3 битсета, которые нужно отслеживать на предмет чтения/записи/исключений". Таким образом, когда программа запускается, она перебирает от 0 до 19 файловых дескрипторов, даже если на самом деле вас интересовали только 4 из них.

В главе есть еще много специфических подробностей о том, чем отличаются poll и select, но это были 2 основные вещи, которые я узнала!

Почему бы нам не использовать poll и select?

Ладно, но в Linux мы сказали, что ваш сервер node.js не будет использовать poll или select, он будет использовать epoll. Почему?

Из книги:

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

По сути: каждый раз, когда вы вызываете select или poll, ядру необходимо заново проверить, доступны ли ваши файловые дескрипторы для записи. Ядро не помнит список файловых дескрипторов, которые оно должно отслеживать!

Ввод-вывод управляемый сигналом (используют ли его люди?)

В книге описаны два способа попросить ядро запомнить список файловых дескрипторов, которые оно должно отслеживать: ввод-вывод с управлением по сигналу и epoll. Управляемый сигналом ввод-вывод - это способ заставить ядро посылать вам сигнал, когда файловый дескриптор обновляется, вызывая fcntl. Я никогда не слышала, чтобы кто-то использовал этот способ, и книга говорит о том, что epoll будет лучше. Поэтому мы пока проигнорируем его и поговорим об epoll.

срабатывание по уровню (level-triggered) в сравнении со срабатыванием по фронту (edge-triggered) 

Прежде чем заговорить об epoll, необходимо обсудить "level-triggered" и "edge-triggered" уведомления о файловых дескрипторах. Я никогда раньше не слышала этой терминологии (думаю, она пришла из электротехники?). В принципе, есть 2 способа получения уведомлений

  • получать список всех интересующих вас файловых дескрипторов, которые доступны для чтения ("level-triggered")

  • получать уведомления каждый раз, когда дескриптор файла становится доступным для чтения ("edge-triggered").

Что такое epoll?

Итак, мы готовы поговорить об epoll. Это очень интересно, потому что я часто встречала epoll_wait при отслеживании (stracing) программ и часто ощущала какую-то неопределенность относительно того, что именно он означает.

Группа системных вызовов epoll (epoll_create, epoll_ctl, epoll_wait) предоставляет ядру Linux список файловых дескрипторов для отслеживания и запрашивает обновления об активности на них.

Ниже описаны шаги для использования epoll:

  1. Вызовите epoll_create, чтобы сообщить ядру, что вы собираетесь использовать epoll. Оно вернет вам идентификатор.

  2. Вызовите epoll_ctl, чтобы сообщить ядру о файловых дескрипторах, обновления которых вас интересуют. Интересно, что вы можете передать ему множество различных типов файловых дескрипторов (конвейеры, FIFO (First In First Out  — первый вошел - первый вышел), сокеты, очереди сообщений POSIX (portable operating system interface for Unix — переносимый интерфейс операционных систем Unix), экземпляры inotify, устройства и многое другое), но не обычные файлы. Я думаю, что это вполне логично - конвейеры и сокеты обладают довольно простым API (один процесс записал в конвейер, а другой - считал), поэтому вполне очевидно будет сказать: "В этом конвейере есть новые данные для чтения". Но с файлами все не так просто. Вы можете выполнить запись в середину файла. Так что на самом деле не имеет смысла говорить: “в этом файле есть новые данные, доступные для чтения”.

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

Производительность: select и poll в сравнении с epoll

В книге есть таблица, в которой сравнивается производительность для 100 000 операций мониторинга:

# operations  |  poll  |  select   | epoll
10            |   0.61 |    0.73   | 0.41
100           |   2.9  |    3.0    | 0.42
1000          |  35    |   35      | 0.53
10000         | 990    |  930      | 0.66

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

Кто использует epoll?

Иногда я замечаю epoll_wait, когда отслеживаю (strace) программу. Почему? Есть вроде бы очевидный, но бесполезный ответ "это мониторинг некоторых файловых дескрипторов", но мы можем сделать лучше!

Во-первых, если вы используете зеленые потоки (green threads) или цикл событий, вы, скорее всего, используете epoll для выполнения всех сетевых и конвейерных операций ввода-вывода.

Например, вот программа на golang, которая использует epoll в Linux.

package main

import "net/http"
import "io/ioutil"

func main() {
    resp, err := http.Get("http://example.com/")
        if err != nil {
            // handle error
        }
    defer resp.Body.Close()
    _, err = ioutil.ReadAll(resp.Body)
}

Здесь вы видите, как рантайм golang использует epoll для поиска DNS:

16016 connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("127.0.1.1")}, 16 <unfinished ...>
16020 socket(PF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP
16016 epoll_create1(EPOLL_CLOEXEC <unfinished ...>
16016 epoll_ctl(5, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=334042824, u64=139818699396808}}
16020 connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("127.0.1.1")}, 16 <unfinished ...>
16020 epoll_ctl(5, EPOLL_CTL_ADD, 4, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=334042632, u64=139818699396616}}

В основном это делается для подключения 2 сокетов (в файловых дескрипторах 3 и 4) для выполнения DNS-запросов (к 127.0.1.1: 53), а затем с помощью epoll_ctl попросить epoll предоставить нам обновления о них.

Затем он делает 2 DNS-запроса для example.com (почему 2? nelhage предполагает, что один из них запрашивает запись A, а другой - запись AAAA!), и использует epoll_wait для ожидания ответов

# these are DNS queries for example.com!
16016 write(3, "\3048\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\34\0\1", 29
16020 write(4, ";\251\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\1\0\1", 29
# here it tries to read a response but I guess there's no response
# available yet
16016 read(3,  <unfinished ...>
16020 read(4,  <unfinished ...>
16016 <... read resumed> 0xc8200f4000, 512) = -1 EAGAIN (Resource temporarily unavailable)
16020 <... read resumed> 0xc8200f6000, 512) = -1 EAGAIN (Resource temporarily unavailable)
# then it uses epoll to wait for responses
16016 epoll_wait(5,  <unfinished ...>
16020 epoll_wait(5,  <unfinished ...>

Итак, одна из причин, по которой ваша программа может использовать epoll - "она написана на Go / node.js / Python с gevent и работает в сети".

Какие библиотеки применяются в go/node.js/Python для использования epoll?

Веб-серверы также имплементируют epoll - например, вот код epoll в nginx.

Другие материалы по select и epoll

Мне понравились эти 3 поста Марека (Marek):

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

И это:

Ну все, хватит

Я узнала довольно много нового о select и epoll, написав этот пост! Сейчас у нас около 1800 слов, так что я думаю, этого достаточно. С нетерпением жду возможности прочитать больше из этой книги по программному интерфейсу Linux и сделать новые открытия.

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

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


Приглашаем всех желающих на открытое занятие «Паттерн Entity-Component-System в играх на C». На этом занятии мы познакомимся с часто применяемым в игровых приложениях архитектурным шаблоном Entity/Component/System и рассмотрим его реализацию на языке C на примере опенсорсной библиотеки flecs. Также мы изучим код несложной игры, использующей flecs на практике. Регистрация — по ссылке.

Также приходите на урок «Инструментарий UNIX-разработчика: исправляем утечку памяти в curl», который состоится уже сегодня в 20:00. Рассмотрим важные элементы инструментария разработчика под UNIX-подобными ОС и с их помощью продиагностируем и исправим утечку памяти в библиотеке для работы с HTTP/2 libcurl. Регистрация на урок

Комментарии (6)


  1. dlinyj
    08.09.2022 18:05
    +3

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

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


  1. Tujh
    08.09.2022 18:25

    Вместо того чтобы постоянно запрашивать "есть ли обновления? а как насчет сейчас? что теперь?", мы лучше просто спросим ядро Linux "эй, вот 100 файловых дескрипторов. Скажи мне, когда один из них будет обновлен!".

    Но...но...но ведь все три механизма именно что и спрашивают ядро - есть ли обновления в списке дескрипторов.

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

    Единственный действительно асинхронный механизм - это микрософтовский IOCP.


    1. prefrontalCortex
      10.09.2022 08:27

      Не совсем верно, все эти три механизма просят ядро вернуть управление в юзерспейс, когда произойдёт обновление. Это выгодно отличается от того, что мы с некоторой периодичностью долбим ядро системными вызовами для проверки состояния дескриптора (или дескрипторов), впустую тратя циклы CPU на "нырки" из юзерспейса в кернелспейс и обратно.
      Ближайший аналог IOCP, насколько я понимаю, - новый линуксячий механизм io_uring.


      1. Tujh
        10.09.2022 17:37

        Не совсем верно, все эти три механизма просят ядро вернуть управление в юзерспейс

        Всё верно, только это не совсем асинхронный ввод-вывод

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

        При вызове всех трёх происходит блокировка потока вызова до наступления события. Возможны трюки с NO_WAIT конечно, но в этом случае

        мы с некоторой периодичностью долбим ядро системными вызовами для проверки состояния дескриптора (или дескрипторов), впустую тратя циклы CPU на "нырки" из юзерспейса в кернелспейс и обратно

        io_uring

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


        1. prefrontalCortex
          11.09.2022 09:03
          +1

          Всё верно, только это не совсем асинхронный ввод-вывод

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

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

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


    1. prefrontalCortex
      10.09.2022 09:52

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