Делаем асинхронность асинхронной, разбираемся в планировщике Go, ругаем Linux

от автора

В айтишном мире есть две весьма обсуждаемые темы:

  1. Что является главным недостатком в Go;

  2. Linux vs <что угодно>;

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

Предполагаю, что читатель слышал про netpoller и общий принцип планирования горутин.

Главный недостаток в Go

Кто-то считает, что главным недостатком является обработка ошибок, а кто-то видит в нем отсутствие дженериков. Я же полагаю, что главный недостаток Go — это его рантайм. Рантайм у Go большой, но далее я буду подразумевать три вещи: планировщик горутин, сборщик мусора и netpoller. Мне не нравится, что у меня нет способов настраивать и тюнить их, даже если я на 100% уверен в необходимости этих настроек. Еще меньше мне нравятся некоторые особенности реализации оных в Go.

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

С чего все началось

Проблема была обнаружена при нагрузочном тестировании медиа-сервера, который пишется на Go. Медиа-сервера делают много полезных вещей, среди которых есть ремуксирование мультимедиа-потоков, например, когда поток публикуется по протоколу RTMP, а плеер хочет смотреть его по протоколу HLS. В этом случае бэкенд конвертирует входящие RTMP пакеты в пакеты Mpegts/fMP4 и записывает их в файл, который затем будет раздаваться по обычному HTTP. Там есть еще много особенностей работы, но сейчас нас интересует конкретно работа с файлами.

Тесты были простые: несколько сотен входящих RTMP-потоков и несколько тысяч клиентов, которые подключаются к этим потокам по HLS. То есть несколько сотен входящих клиентов постоянно писали данные в файлы, которые затем раздавались по HTTP. Все это было развернуто на свежем CentOS, и все было в порядке, но почему-то через десять минут htop показал, что сервер, который изначально был запущен с GOMAXPROCS=16, создал 92(!) потока. Конечно, производительность сразу снизилась, а клиенты стали падать с ошибками.

Кто виноват?

Виноват рантайм. Все оказалось довольно просто: планировщик горутин умеет работать только в пространстве самого Go. То есть он понимает какая горутина блокирована на канале, на мьютексе или waitGroup, какую пора приостановить, а какую запустить. Однако чистые горутины в вакууме мало чем полезны: бОльшую часть времени программа все-таки общается с внешним миром и с ОС через системные вызовы.

Рассмотрим операции ввода-вывода. Если горутина читает или пишет в файл, то она должна вызвать системный вызов read или write. В Go есть два вида обработчиков системных вызовов:

Отличия между ними состоят в том, что RawSyscall — это нативные вызовы, вроде того, что происходит, например, в Си. А просто Syscall — это еще и вызов двух функций: entersyscall и exitsyscall. Что они делают? Первая говорит планировщику, что сейчас будет осуществлен системный вызов, но планировщик не знает сколько он будет продолжаться. Если долго — все горутины в очереди на данном контексте планировщика будут заблокированы, пока ОС не отдаст нам результат. Поэтому планировщик пытается найти свободный системный поток, на который перебросит горутину, которая осуществляет этот системный вызов. А если свободного потока нету — создает его. Так что если в программе есть активная работа с долгими системными вызовами — вы получаете неконтролируемое создание системных потоков, даже если они вам совсем не нужны. Вторая функция, очевидно, говорит что системный вызов закончился, и горутину можно вернуть обратно в контекст Go. При этом новый поток, если он был создан, не завершается, а остается висеть.

По этим причинам несколько сотен входящих мультимедиа-стримов, которые регулярно вызывали системный вызов write на файлах, создали мне 76 лишних системных потоков.
Вы спросите, а как же тогда работают сокеты? Там ведь тоже происходят системные вызовы. С сокетами есть хитрость: там используется netpoller и RawSyscall, то есть системный вызов дергается без участия рантайма. Если сокет не готов для I/O, то netpoller заблокирует горутину, но не даст заблокироваться текущему системному потоку через epoll/kqueue. Unix-интерфейсы неблокирующего I/O можно использовать для сокетов — но не для файлов

Что делать?

Просмотр документации и исходников некоторых проектов, которые должны более-менее активно работать с дисковым I/O, привел к неутешительному выводу: все они различным образом используют пул потоков, в который отправляют работу с файловым вводом-выводом. Подопытными были nginx, libtorrent и libuv.

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

Медиа-сервер тестировался на Windows, Linux, Macos, Android и FreeBSD. Давайте посмотрим, что эти операционные системы могут предложить.

  • Windows

Windows полностью реализует ядерную асинхронность. Тут весьма приятный и простой интерфейс: надо всего лишь открыть файл с флагом FILE_FLAG_OVERLAPPED, а в последнем аргументе read/write передать указатель на структуру OVERLAPPED, в которой указаны необходимые аргументы: сдвиги, размер буфера и прочее.

  • FreeBSD, (MacOS)

Есть интерфейс, который называется aio. Используя его, можно асинхронно выполнять операции дискового I/O.

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

MacOS в скобках, потому что это не серверная ОС. aio там присутствует, но вместе с aio идет огромное количество граблей.

  • Linux, Android

В ядро Linux версии 2.6 тоже был добавлен aio. Но он был настолько плохой, что, например, nginx использовал вместо него пул потоков. Основное ограничение — это необходимость использовать флаг O_DIRECT при открытии файла, который требует выравнивания по памяти буферов с данными и отключает кэш страниц при работе с диском. Использовать такой aio можно, но не очень удобно.

В 2019 году произошло великое событие: инженеры из Facebook написали новый механизм асинхронной работы с дисковым IO, который получил название io_uring. Он был добавлен в ядро версии 5.1. Вполне неплохо — Linux 2019 года уже может полноценно работать с файлами.
io_uring достаточно прост: по большому счету это два кольцевых буфера, которые сммаплены в юзер-спейс. Первый буфер нужен для отправки запросов на IO операции и называется SQ. Второй — для приема результатов и называется CQ. Нам достаточно отправлять и принимать объекты из этих кольцевых буферов, причем делать это можно напрямую из Go.

Реализация

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

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

Первая операция делается достаточно просто, так как нам достаточно просто взять адрес:

var mySlice []uint8 var byteArray *uint8 = &mySlice[0]

Превращение байтового массива обратно в слайс немного сложнее:

var b []uint8 hdr := (*reflect.SliceHeader)(unsafe.Pointer(&b)) hdr.Data = uintptr(unsafe.Pointer(byteArray)) hdr.Len = int(sliceLen) hdr.Cap = int(sliceCap) return b

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

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

Возникает более высокоуровневый вопрос: каким образом нам дожидаться окончания этих операций? Конечно, идеальным решением была бы асинхронная нотификация, например, через сигналы. К сожалению, из-за реализации aio в Linux этот вариант затруднителен или невозможен, поэтому первая версия библиотеки не имеет нотификаций, а результаты получает путем опроса о готовности. Пользователю необходимо вызывать метод LastOp() (int, bool, error), который вернет количество прочитанных или записанных байт, флаг, обозначающий закончилась ли последняя асинхронная операция, и ошибку, если она есть. Под капотом эта функция проверяет, не заполнились ли еще результаты последней асинхронной операции на данном файле, и, если нет, то выполняет системный вызов, который заполняет результаты всех законченных операций над всеми файлами. Исключением является io_uring: там достаточно пробежаться по сммапленному кольцевому буферу CQ и забрать готовые объекты.

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

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

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

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

API напоминает стандартное: такие же вызовы Read([]byte) и Write([]byte). Исключением является открытие файла, где необходимо указать режим работы ModeAsync, и новый метод LastOp() (int, bool, error). В принципе, библиотеку можно даже использовать в стандартных гошных интерфейсах вроде Reader, правда, где-то могут быть ошибки и/или неожиданные результаты, так как асинхронные операции чтения/записи всегда возвращают 0 в первом результате. И есть еще одно ограничение: так как линуксовый aio требует выравнивания рабочих буферов по 512 байт, то вместо обычного make([]byte, sz) надо вызывать функцию AllocBuf(sz), которая вернет слайс с нужным смещением внутреннего массива.

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

Замечания

  • Библиотека не работает с включенным детектором гонок. Там сразу возникает паника «fatal error: checkptr: pointer arithmetic result points to invalid allocation». Почему-то в этом случае рантайму не нравится прямая работа со структурой слайса. Баг этот известный, его обещали починить в Go 1.15, но не починили. Сделать мы с этим ничего не можем, потому что для системных вызовов приходится выковыривать из слайса непосредственно байтовый массив, а для заполнения результатов операций — восстанавливать слайс из массива.

  • В коде используются конструкции вроде
    var x uintptr = syscall.Syscall(...)
    var p unsafe.Pointer = unsafe.Pointer(x)

    Это может смутить. Опасность uintptr состоит в том, что uintptr — это просто число с точки зрения рантайма Go, даже если это число представляет собой указатель. Другими словами, если вы присвоили переменной типа uintptr адрес какого-либо объекта, и дальше по коду нет прямого обращения к этому объекту, то Go считает, что он больше не используется, и его можно удалить сборщиком мусора. Такой код опасен:
    type myStruct struct { i []int}
    s := myStruct{i: []int{42}}
    p := unsafe.Pointer(&s)
    u := uintptr(p)
    // s и p больше не используются, на объект больше нет ссылок
    s1 := *((*myStruct)(unsafe.Pointer(u)))
    s1.i[0] = 43 // неопределенное поведение

    go vet честно предупреждает об этом. Что мы можем с этим сделать? Скорее всего, ничего. Мы не можем гарантировать, что между преобразованием из uintptr и работой с данными не будет вызван сборщик мусора. К счастью, такие конструкции используются только при работе с mmap, и сборщик мусора ничего не сможет сделать. Адрес сммапаленной памяти будем валидным.

  • aio в Linux требует выровненного размера буфера. На старых линуксах нельзя асинхронно считать/записать рандомное число байт — только кратное 512.

  • Сами системные вызовы для работы с асинхронным I/O являются блокирующими и могут работать достаточно долго. Например, aio в Linux может заблокироваться если задидосить его очень частыми операциями и/или большими данными. Если попытаться записать за раз слишком много данных (вызвать Write с очень большим слайсом), то ядро может начать разбивать входящий массив на части, и будет делать это в блокирующем режиме. Если попытаться выполнить за раз больше операций, чем указано в /sys/block/…/queue/nr_requests, то также произойдет блокировка. Вообще у aio в Linux довольно непредсказуемое поведение, которое иногда может стать синхронным. Хотя думаю, что это касается не только aio.

Асинхронный sendfile

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

Медиа-сервер пытается использовать sendfile если это возможно, и это работает, но сам sendfile — синхронный. Мы не тратим время на копирование данных во временной буфер, но тратим его на запись из одного дескриптора в другой. Конечно, sendfile быстрее, чем считать-записать, но все равно блокирует выполнение пока не перекинет данные. Неплохо было бы иметь асинхронный sendfile.
Но нет.

  • Windows

Есть функция TransmitFile, которая может принимать указатель на структуру OVERLAPPED. Ее можно было бы использовать.

  • FreeBSD

Netflix и Nginx написали реализацию асинхронного sendfile для FreeBSD. Ее можно было бы использовать.

  • Linux

На сегодняшний день асинхронного sendfile в Линуксе нет. Я нашел два варианта эмуляции:

  1. Использовать пул потоков; (не подходит, так как у нас Go)

  2. Mmap файла и использование существующих механизм асинхронного I/O; (не подходит, так как у нас может быть много (МНОГО) файлов, у которых бывает существенный размер. Ммапить каждый файл — это такое себе решение)

Скорее всего, клиенты будут использовать именно Linux, у которого асинхронного sendfile нет, так что пока библиотека тоже не поддерживает асинхронный sendfile. И это приводит нас ко второй обсуждаемой теме.

Что случилось, почему все вдруг забыли про FreeBSD — некогда самую популярную серверную ОС — и начали переходить на Linux?

Что было в Linux такого нужного, чего не было во FreeBSD? Это точно не производительность и не надежность:

  • В 2007 году в рассылке Nginx обсуждалось обслуживание 100-200к соединений под FreeBSD — это на железе 2007 года;

  • На FreeBSD работал (или до сих пор работает, не знаю) бэкенд Whatsapp, который обслуживал миллион клиентов;

  • Netflix, который обеспечивает 15% всего мирового интернет-трафика, использует FreeBSD.

  • Сервер NetWare с аптайм в 16 лет бесперебойно работал под управлением FreeBSD, пока не был погашен из-за аппаратных ошибок жесткого диска.

Я не буду сейчас останавливаться на стандартных холиварах по теме ZFS, лицензирования, целостности операционной системы, утилит, портов, документации, стабильности и качества кода. Остановлюсь только на той проблеме, с которой я столкнулся, и которую было почти невозможно решить на Linux до версии 5.1. Пусть меня испепелят апологеты Linux, но в нем до 2019 года не было полноценной работы с асинхронным дисковым I/O (то есть не было полноценной работы с дисковым I/O). Любая программа, которая более-менее плотно работала с файлами, рано или поздно проседала бы в производительности из-за блокировок. Разработчикам приходилось искать обходные пути и на уровне приложения пытаться обойти кривую реализацию в ядре.

Aio добавили ПОСЛЕ аналога в FreeBSD, epoll добавили ПОСЛЕ kqueue. Причем изначально epoll уступал по функционалу: например, в рассылке 2003 года Игорь Сысоев пишет о проблемах с epoll (), а некоторые говорят о них и спустя 15 лет.

Асинхронного sendfile там до сих пор нет.

Скажем так, для разработки некоторых демонов бэкенда это не самая удобная в мире операционная система. Была. Конечно, в 2021 году Linux уже почти все умеет, а еще может в докер, оркестровку контейнеров и поддерживает целое множество железа всех сортов и расцветок. Но это в 2021, в то время как FreeBSD ушла в небытие гораздо раньше. А почему?

Пример использования и исходники библиотеки asyncfs лежат на гитхабе. Лицензия — BSD.


ссылка на оригинал статьи https://habr.com/ru/post/646073/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *