SkillGridИзучай computer science на практике

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

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

16 мин · 50 XP

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

CPU register: ~0.3 ns
L1 cache:     ~1 ns
RAM:          ~30 ns
SSD:          ~100 000 ns
HDD:          ~8 000 000 ns

SSD / RAM = 3000xРазрыв, который и существует, чтобы его прятала подсистема ввода-вывода.

Самая простая модель — блокирующий ввод-вывод. Процесс вызывает read(), ядро отправляет запрос устройству, а вызвавший поток снимается с очереди готовых и засыпает. Он не крутится в цикле и не получает процессор: он не стоит ничего, пока устройство не закончит. Затем устройство поднимает прерывание, ядро будит поток, и read() возвращает данные. С точки зрения программы блокирующий read — это одна строка кода. С точки зрения системы это переключение контекста туда и ещё одно обратно плюс всё то время, которое заняло устройство.

ProcessKernelDiskread(fd, buf, 4096)submit requestblocked, off the run queueDMA transfer doneraise interruptwake up, put back on the run queuereturn 4096

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

polling:    10 000 000 spins/s, 1 core busy
interrupt:  no cost while the device is idle

slow device, 0.1 ms  -> interrupts win
fast device, 40 000 iops -> interrupts cost 4 ms/sКомпромисс в числах. Ни один ответ не верен вообще: решает масштаб времени.

А теперь стоимость, которая удивляет. Вызов read() — это не вызов функции. Это контролируемый переход в ядро: процессор переключается в режим ядра, регистры сохраняются, аргументы проверяются, права проверяются, а на возврате выполняется проверка отложенных сигналов и, возможно, перепланирование. Всё это происходит раньше, чем ядро прочитало хоть один байт откуда бы то ни было. На современном процессоре пустой системный вызов стоит от 100 до 300 наносекунд против примерно 2 наносекунд для вызова внутри того же процесса. Вот число, которое объясняет немалую долю реальных проблем производительности.

size_t n = read(fd, buf, 4096);

call: ~2 ns
read(4096): ~200 ns
read(1): ~1000 ns

4096 calls instead of 1 -> 4000xОдни и те же байты, доставленные одним вызовом и четырьмя тысячами вызовов.

Сложите эти два числа — и знакомый совет перестаёт быть вопросом стиля. Программа, читающая файл в 1 МиБ по одному байту, делает 1048576 системных вызовов. По 200 наносекунд каждый это около 0,21 секунды чистых накладных расходов на вызовы, ещё до того, как тронут хоть один байт данных, — и это оптимистичный случай, когда каждый байт уже лежит в кэше страниц. Тот же файл, прочитанный блоками по 4 КиБ, требует 256 вызовов и около 51 микросекунды накладных расходов. Чтение блоками по 64 КиБ требует 16 вызовов. Отношение между версией «по байту» и блочной измеряется тысячами, и к скорости диска это не имеет никакого отношения.

Когда одному потоку надо ждать много устройств сразу, блокирующее чтение не работает: первый же вызов блокируется, и остальные десять сокетов никто не посмотрит. Старый ответ — select() и poll(): они передают ядру список дескрипторов и спрашивают, у каких есть данные. Оба линейны по числу дескрипторов, поэтому при десяти тысячах соединений каждый вызов обходит весь список. epoll стал лечением: дескрипторы регистрируются один раз, а ядро держит интересные из них в списке готовых. Программа блокируется один раз в epoll_wait и получает обратно только те сокеты, у которых действительно что-то есть.

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

/* readiness model */
for (;;) {
  n = epoll_wait(ep, evs, 128, -1);
  for (i = 0; i < n; i++) read(evs[i].data.fd, buf, 4096);
}

/* completion model */
for (;;) {
  sqe = io_uring_get_sqe(&ring);
  io_uring_prep_read(sqe, fd, buf, 4096, 0);
  io_uring_submit(&ring);
}Две модели рядом. Первая спрашивает, можно ли читать; вторая заказывает чтение и забирает ответ.

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

epoll: 1 submit per batch
io_uring: 1 submit for N reads

queue depth 1: 1 000 000 iops
queue depth 32: 3 000 000 iopsОдин и тот же интерфейс на разной глубине очереди. Именно глубокие очереди делают быстрые устройства полезными.

Буферизация — общее имя для всех мест, где данные копируются. Ядро держит кэш страниц, поэтому повторное чтение того же файла вообще не доходит до диска. Библиотека C держит буфер в пространстве пользователя для stdio, поэтому fgetc() обычно вовсе не вызывает ядро. Это одна и та же идея: собрать медленную операцию в пачку и размазать её стоимость. У обеих есть и одна и та же цена: то, что вы записали, не обязательно уже лежит на устройстве.

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

Практика

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

    1. 1ядро проверяет аргументы и права и ищет страницу в кэше страниц
    2. 2обработчик прерывания будит ожидающий поток
    3. 3устройство передаёт данные и поднимает прерывание
    4. 4процесс выполняет системный вызов, и процессор переключается в режим ядра
    5. 5системный вызов возвращается, и поток продолжает работу с заполненным буфером
    6. 6запрос отправляется устройству, а вызвавший поток засыпает
    Подсказки
    • Устройство никогда не вызывает процесс напрямую: оно лишь сигнализирует процессору, а процессор выполняет процедуру, принадлежащую ядру.
    • Процесс не теряет своё место: его откладывают до передачи и подхватывают снова только после того, как данные легли в его буфер.
  2. Две программы читают один и тот же файл в 1 МиБ, уже лежащий в кэше страниц. Первая вызывает read() на каждый байт, вторая — на каждые 4096 байт. Перепишите первую так, чтобы она читала блоками по 4096 байт.

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

    Подсказки
    • Сравните две программы оператор за оператором: разница только в том, сколько раз тело цикла уходит в ядро.
    • Здесь доминирует стоимость одного вызова, а не одного байта, поэтому сначала посчитайте вызовы и только потом смотрите на количество байтов.
  3. Сопоставьте каждый механизм ввода-вывода с тем, что он на самом деле делает.

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

Вопросы по этому уроку

Что такое операционная система на самом деле?

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

Зачем это, если я хочу просто писать код?

Потому что когда программа тормозит, зависает или съедает память, причина почти всегда здесь. Без этих знаний остаётся гадать, с ними — измерять.

Это бесплатно?

Да. Первый уровень каждого предмета бесплатный и не требует аккаунта. Дальше уровни и старшие грейды платные — именно это позволяет проекту существовать.

На какой возраст рассчитано?

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

Нужно ли уже уметь программировать?

Нет. Логика, физика и сети начинаются с нуля. Код ты пишешь в разделе языков — и он тоже начинается с самого начала.

Нужно что-то устанавливать?

Нет. Всё работает в браузере: теория, практика и интерактивные симуляции. Скачивать нечего и настраивать нечего.

Сколько времени занимает один предмет?

Урок — от 10 до 15 минут. Грейд «джун» это примерно 12–20 уроков, то есть несколько недель регулярных занятий, а не один вечер.

Обязательно идти по порядку?

Внутри грейда уровни открываются по очереди: каждый опирается на предыдущий. А между предметами ты свободен — начинай с логики, физики или сетей.