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

  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.

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


  1. pin2t
    17.01.2022 20:02
    +4

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

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


    1. omotezata Автор
      17.01.2022 20:41
      +6

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


      1. edo1h
        18.01.2022 05:55
        +2

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


        1. mayorovp
          18.01.2022 10:38

          То, что потоки не закрываются — это тоже не странно: создавать новый поток дорого, держать неактивным дешевле.


          1. edo1h
            18.01.2022 10:52

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


            omotezata


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

            а почему конечно-то? если эти потоки были заняты дисковым i/o, то что плохого в том, что их было много?


            1. omotezata Автор
              18.01.2022 12:19

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

              Я не думаю, что 92 потока на 16 ядрах могут помочь в скорости работы.


              1. edo1h
                18.01.2022 12:26
                +3

                Полагаю, планировщику потоков было не очень хорошо

                сотня «лишних» процессов определенно не являются проблемой для линуксового планировщика.


                Я не думаю, что 92 потока на 16 ядрах могут помочь в скорости работы.

                если они находятся в iowait, то и не повредят.


        1. omotezata Автор
          18.01.2022 12:13

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


      1. pin2t
        18.01.2022 10:57
        +1

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


        1. omotezata Автор
          18.01.2022 12:25

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


  1. bonta
    17.01.2022 20:24
    +5

    С титульной пикчей статьи конечно не замарачивались.


  1. powerman
    17.01.2022 21:54

    А не пробовали использовать библиотечки для io_uring, которые упоминаются в https://github.com/golang/go/issues/31908? Как они решают упомянутые проблемы, перечисленные в разделе "Замечания"?


    1. omotezata Автор
      17.01.2022 23:15

      Библиотеки из ишью проще, так как заточены только под io_uring. Они решают (вернее, просто не воспроизводят) только первую проблему. Линуксового aio там нет, а вот go vet пишет такой же ворнинг, как и у меня.
      Полагаю, что я слишком заморочился с дизайном сохранения состояния асинхронной операции. uintptr, unsafe.Pointer, приведение типов и вот это все. Можно упростить, чтобы рантайм с включенным детектором гонок не ругался на прямую работу с указателями. Попробую в следующей версии пофиксить.


  1. godzie
    17.01.2022 22:05
    +2

    По идее можно сделать пул потоков для файлового I/o через пул горутин + runtime.LockOsThread и raw сисколы. А так да io_uring решает проблему с асинхронными операциями на файлах, хоть в ядре всеравно будет тот же пул воркеров :)


  1. gdt
    17.01.2022 22:13
    +8

    Тут весьма приятный и простой интерфейс: надо всего лишь открыть файл с флагом FILE_FLAG_OVERLAPPED

    В первый раз вижу, чтобы кто-то называл WinAPI приятным и простым интерфейсом :) А так очень круто заморочились конечно.


    1. KotKotich
      17.01.2022 23:05
      +1

      Особенно с мутным асинхронным WinAPI. Помню, как плевался в далекие 90е и 2000е годы, когда пришлось заниматься этим. Вроде документирован был, но весьма мутно. Но, оказывается, не все там было так плохо. В линуксах еще веселее.)


      1. gdt
        17.01.2022 23:11

        Да они даже как представлять bool не определились, 1 или 4 байта. Когда дело доходит до маршалинга структур, там вообще кто на что горазд (из индусов, писавших этот код, no offense так сказать)


    1. omotezata Автор
      17.01.2022 23:34
      +3

      Мне нравится, что там можно юзать OVERLAPPED и горя не знать. Да и IOCP умеет одинаково работать как с сокетами, так и с файлами. Не надо заводить зоопарк интерфейсов и вспоминать, чем aio отличается от io_uring, почему нельзя использовать epoll на файлах и почему не получится асинхронно вызывать zero-copy запись в сокет.
      А может у меня просто специфичные вкусы)


      1. mvv-rus
        18.01.2022 21:33

        Ну, вообще-то в Winsock API были и свои асинхронные системные вызовы — аналоги системных вызовов socket API (родом BSD Unix).
        Этот API — он родом из Win3.x с ее «колхозной» (т.е. кооперативной) многозадачностью, и наличие в нем асинхронных вызовов было необходимостью: иначе блокировался единственный на всю систему (систему, не процесс!) поток управления, и пользователь мог только созерцать песочные часы: даже переключиться на другую программу он не мог.


      1. loltrol
        19.01.2022 08:39

        Меня iocp тоже соблазняет, но там нет таймеров . Приходилось сбоку прикручивать.


        1. mayorovp
          19.01.2022 10:56

          Довольно странно ожидать таймеров от IOCP...


        1. Videoman
          19.01.2022 11:37

          Там нет таймеров, но зато есть PostQueuedCompletionStatus(). Через этот механизм можно вообще всё что угодно реализовать. Главное не забывать что количество физических потоков ограничено и не блокировать их выполнение.


  1. mayorovp
    17.01.2022 23:09

    Следующий уровень — асинхронное закрытие файлов...


  1. AterCattus
    17.01.2022 23:22
    +2

    >Что мы можем с этим сделать? Скорее всего, ничего.

    Или я не так понял, или https://pkg.go.dev/runtime#KeepAlive поможет


    1. omotezata Автор
      18.01.2022 00:23
      +1

      Не совсем. RawSyscall возвращает uintptr, который является числом, не связанным с каким-либо объектом с точки зрения рантайма. KeepAlive не сможет донести до сборщика мусора, что uinptr - это не число, а указатель на что-то.


      1. MrLoki
        18.01.2022 00:29

        Если не делать return значения скастованого из uintptr то не должно быть проблем, ведь если данные снаружи, то GC их не тронет, а если изнутри, то keepalive (оригинальной структуры, а не новой из uintptr) в пределах блока сработает. С return конечно сложнее.


      1. Devoter
        18.01.2022 01:42

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


      1. SabMakc
        18.01.2022 09:16

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

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


  1. zuborg
    17.01.2022 23:22

    Если проблема именно в нагрузке при асинхронной записи многих файлов - можно попробовать ммапить файлы для записи, делать, собственно, запись данных в заммапленную память, а дальше пусть ОС разгребает dirty pages самостоятельно. Правда, все может упереться в задержки на page faults, я лично не тестил. Впрочем, для sparse файлов проблемы с пейжфолтами быть не должно. И придется ещё позаниматься тюнингом sysctl vm.dirty* и подобных, а то ОС может и не справиться самостоятельно, если прилетит большая пачка апдейтов.
    На фре есть возможность сделать ммап с флагом MAP_NOSYNC - тогда можно отдельно синкать измененные файлы на диск (ОС это делать уже не будет), контролируя загрузку дисков..


  1. sborisov
    18.01.2022 00:10
    +4

    Отчасти Linux вышел вперёд, благодаря выливаниям компаний не только в серверную но и десктопную среду, на нем были решены многие пользовательские проблемы (автомонтирование флешек, воспроизведение звука с нескольких устройств одновременно), которые судя по беглому гуглению, ещё присутствуют во FreeBSD. Разработчикам стало относительно удобно в нем, и по привычке проще поставить на сервер более знакомую среду. Тем более что основной софт (по количеству разработчиков) - это сервисы, web. Который работает на том же nginx, и поэтому не все сталкиваются с такими проблемами, так как они решаются другими людьми.

    Жаль конечно что freebsd сдала позиции, но бывают и обратные примеры.

    Старый знакомый, оказывал компаниям услуги по настройке web сайтов, решил попробовать freebsd после Linux и полностью перешёл на неё , спустя пару недель. Клиентам с его слов все равно на чем работает их сайт, а ему намного легче ставить это все и следить за этим всем. Как он говорил все есть в handbook - не надо искать как в другом дистрибутиве настраивать и устанавливать пакеты.

    За статью спасибо! Очень познавательно


  1. xonix
    18.01.2022 01:36
    +1

    Удивлён, кстати, что где-то Linux уступает BSD-системам. На моей практике было ровно наоборот. Хотя и у меня задачи совсем другие были... Около-скриптового характера. Несколько примеров из того что вспомню:

    1. BSD-шный date не поддерживает %N, а значит отсутствует простая и быстрая возможность получать нано- или хотя-бы миллисекундное разрешение времени в shell

    2. BSD-шный awk гораздо медленнее Gawk

    3. В BSD нет линуксового /dev/shm - файловая система в RAM - удобно для быстрого создания временных файлов

    4. В BSD нет /dev/stderr, /dev/stdin, /dev/stdout

    5. Коробочный bash в macOS гораздо древнее актуального, а во Free он даже не установлен по дефолту

    6. Добавьте своё или поправьте меня по пунктам выше????

    Короче, для себя сделал вывод, что для разработчика Linux поприятнее, чем macOS/FreeBSD. Да и вообще GNU-окружение по сравнению с BSD-аналогами.


    1. kibb
      18.01.2022 05:19
      +7

      1. Нет 'bsd-ного' awk, это one-true-awk от собственно авторов языка. Если нужен gawk, то его и поставьте.

      2. это называется tmpfs, обычно монтируется в /tmp

      3. конечно есть /dev/std{in,out,err}, как и /dev/fd/

      4. опять-таки, поставьте bash. Только не пишите #!/bin/bash, хотя бы #!/usr/bin/env bash


      1. xonix
        18.01.2022 16:09
        -1

        1. Все так, но это не меняет факта. В системе по умолчанию стоит самый каноничный, но самый медленный awk, а не быстрые gawk или mawk

        2. Но по умолчанию я наблюдаю, что у меня в macOS и фре /tmp это диск

        3. Да, тут моя неправда. Сейчас перепроверил и всё работает. Значит, это была другая ошибка. Благодарю.

        4. Все так, но это не менят факта.

        Еще вспомнил момент. Во Фре по дефолту нету wget и curl, но есть некий fetch.


        1. simpleadmin
          18.01.2022 16:58

          Во Фре по дефолту нету wget и curl, но есть некий fetch.

          А ещё некие sysrc, sockstat... ee вместо nano, ifconfig вместо brctl / ethtool / ip / iwconfig, ipfw вместо iptables, jail вмеcто lxc/lxcd/docker, md5 вместо md5sum, mpd5 вместо... а time вообще выводит данные в одну строку...


          1. xonix
            18.01.2022 17:22

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

            А аналоги - они, разумеется, есть. Но, видимо, гораздо менее популярные и используемые. Потому, видимо, и сама Фря так малопопулярна теперь.


  1. edo1h
    18.01.2022 05:52

    я бы назвал статью «как было плохо с асинхронным i/o в linux до io_uring» )


    На сегодняшний день асинхронного sendfile в Линуксе нет

    ЕМНИП рекомендуемый сейчас подход — звать splice через io_uring


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

    в какой момент самую популярную? что-то я такого не припоминаю.


  1. constb
    18.01.2022 10:36
    +4

    я одного не мог понять с самого начала статьи и так и не увидел ответа – зачем вы вообще пишете в файлы? если у вас прямой эфир, формируйте чанки и плейлисты прямо в памяти и из неё же и отдавайте – на 100 потоков это же не так много памяти нужно. когда чанк ушёл из плейлиста, из памяти его сборщик мусора выкинет, а для архива поток пусть пишет отдельно стоящая машинка с ffmpeg. зачем вообще дисковое IO на медиасервере?


    1. omotezata Автор
      18.01.2022 12:43

      Плейлист может содержать N чанков, в зависимости от настроек сорца. N чанков - это X мб, а если там еще адаптивный стриминг, то X*Y. Данные одного плейлиста могут несколько десятков мегабайт весить.100 потоков - это норм, но не всегда будет 100 потоков. Сейчас демка держит несколько сотен паблишеров - даже если в текущей сырой версии все держать в памяти, то счет пойдет на гигабайты, которые будут расходоваться только на вот этот кэш. В будущих версиях я надеюсь добиться производительности 1000+, и там уже о десятках гигов пойдет речь. Словом, все держать в памяти - это не вариант.

      Вообще в настройках есть возможность управлять этим, например, потоки с большим количеством клиентов-плееров можно будет кэшировать, но это все надо будет аккуратно отслеживать и тюнить. Держать в памяти плейлист, у которого полтора зрителя - такое себе решение.
      Кроме того, дисковое I/O нужно для VoD трансляций.

      Асинхронная запись, переиспользование памяти, zero-copy операции, быстрые ssd - наши лучшие друзья.


      1. constb
        18.01.2022 14:05

        ну собственно "десятки гигабайт" – это не так уж много для сервера. кроме того с 1000+ стримов на одном сервере у вас скорее всего начнёт заканчиваться пропускная способность сетевого интерфейса, и именно сеть а не память будет ограничивать capacity сервера. а вы CDN подключаете или напрямую с сервера чанки раздаёте? если напрямую, то сеть закончится гораздо раньше, если конечно в стримах не по 1-2 зрителя…

        что касается VoD, то у него вообще другие задачи и там уже чанки лучше раздавать с какого-нибудь S3-подобного хранилища, а писать их отдельным сервисом на отдельной машине, зачем всё в один процесс пытаться "утрамбовать"?


  1. pyra
    18.01.2022 12:48
    +3

    Асинхронные апи позволяют сократить количество thread и стеков, но жесткий диск не станет быстрей писать в файлы. Вы похоже достигли предела IO сервера


    1. omotezata Автор
      18.01.2022 13:44
      +1

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


      1. edo1h
        18.01.2022 18:00
        +2

        я думаю, что тесты были бы хорошим дополнением к статье )


  1. aml
    18.01.2022 13:00
    +3

    Классная статья, автор явно хорошо разобрался с внутренностями Go. Прочитал с удовольствием.

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


    1. omotezata Автор
      18.01.2022 13:42
      +3

      Спасибо)
      На плюсах не хочу. Я 7 лет работал программистом на С++, у меня все до сих пор болит от этих плюсов.

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


      1. aml
        18.01.2022 15:45

        В терапевтических дозах не должно сильно болеть. Go - отличный язык для бэка. Весь control plane, все апишечки на Go писать куда приятнее. А с data plane можно потерпеть и написать и на чем-то более низкоуровневом. Обычно там не так много кода. Раз написал, и оно всю жизнь работает.


  1. leotsarev
    18.01.2022 14:39

    Интересное чтение в ту же тему про разницу file API в Windows и Linux https://devblogs.microsoft.com/dotnet/file-io-improvements-in-dotnet-6/


    1. mvv-rus
      18.01.2022 21:07

      Да, это действительно было интересно — узнать, что разработчики .NET Core наконец-то прочитали документацию по Win32 API:

      we have very carefully studied the profiles and WriteFile docs and got to the conclusion that we don’t need to extend the file before performing every async write operation. WriteFile() extends the file if needed.

      In the past, we were doing that because we were thinking that SetFilePointer (Windows sys-call used to set file position) could not be pointing to a non-existing offset (offset > endOfFile). Docs helped us to invalidate that assumption:


  1. szt_1980
    18.01.2022 16:08

    Год назад io_uring валил ядро в панику. В одном из релизов после 5.4 это починили, потом бэкпортировали


  1. szt_1980
    18.01.2022 16:16

    POSIX AIO в glibc, помнится, был реализован на тредпуле - возможно, так и осталось. С Native AIO там были связаны относительно удобные векторные системные вызовы (что-то наподобие io_pwritev, io_preadv, на которых было достаточно удобно делать 0-copy DMA, но там была куча багов в обработке kiocb