Ruby 3 представил революционную функцию для параллельного программирования с выпуском Fiber::SchedulerInterface. Этот мощный инструмент позволяет разработчикам управлять волокнами, упрощая переключение контекста в задачах, связанных с вводом-выводом. В этой статье мы погрузимся в мир волокон и socketry/async стека, исследуя мощные возможности, которые они предлагают, на примере процессора фоновых заданий.

Это будет довольно большой кусок, поэтому вот краткий план того, что будет описано:

  • Что такое клетчатка?
  • Что такого особенного в Fiber::SchedulerInterface?
  • Различные виды селекторов событий
  • Socketry обзор
  • Jiggler. Фоновый процессор заданий на основе оптоволокна
  • Jiggler основные компоненты
  • Упреждающее планирование оптоволокна
  • Ориентиры
  • Ограничения
  • Будущее

Что такое клетчатка?

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

Впервые представленная в Ruby 1.9 (декабрь 2007 г.), это не новая концепция. Несмотря на то, что волокна были легкими и предоставляли больший контроль, они не получили широкой популярности в сообществе, оставаясь довольно редко используемой функцией. Возможно, из-за того, что Ruby не предоставляет простого готового интерфейса планирования, или, может быть, потому, что сообщество в значительной степени ориентировано на Rails, поэтому людям просто проще придерживаться потоков как более часто используемого построить.

Тем не менее, все еще есть несколько библиотек, использующих волокно, которые раньше были пионерами и приобрели некоторую популярность:

  • Celluloid https://github.com/celluloid/celluloid — не поддерживается с 2016 года, но в то время это была одна из главных жемчужин Ruby, предоставляющая удобный API для написания параллельного кода Ruby.
  • Em-synchrony https://github.com/igrigorik/em-syncrony — файберная реализация EventMachine, последние коммиты были сделаны около 5 лет назад.

При использовании файберов в старых версиях Ruby (от 1.9 до 3) разработчикам приходилось отслеживать все файберы и вручную управлять ими, вызывая методы Fiber::yield, Fiber#resume и Fiber#transfer. Этот процесс может быть сложным, слишком многословным и подверженным ошибкам.

В настоящее время с Ruby 3 управление волокнами стало намного проще.

Что такого особенного в Fiber Scheduler?

Fiber::SchedulerInterface предоставляет набор хуков, которые вызываются, когда блокирующая операция начинается/заканчивается.

По сути, стандартные методы ввода-вывода Ruby, такие как IO#wait_readable, IO#wait_writable, IO#read, IO#write, Kernel.sleep и т. д., были исправлены, чтобы уступить место планировщику, если он определен в контексте текущего потока. А планировщик, в свою очередь, передает управление исполнением другим готовым файберам.

Это автоматически делает все стандартные вызовы Ruby удобными для планировщика. Тем не менее, все еще существует множество жемчужин Ruby, использующих C-расширения, которые могут выполнять ввод-вывод самостоятельно, минуя нативные методы Ruby. Например, различные виды DB-адаптеров. К счастью, относительно легко реализовать вызов планировщика из C-расширения, поэтому поддержка волокон в таких драгоценных камнях постепенно растет. Разработчики должны проверить, поддерживает ли данный гем со встроенным расширением C планировщик и, следовательно, имеет ли смысл использовать его с волокнами.

Различные виды селекторов событий

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

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

  • poll()/epoll() — это механизм по умолчанию для наблюдения за сокетами, чтобы увидеть, доступны ли новые данные почти во всех системах Linux.
  • io_uring — это библиотека ядра Linux, предоставляющая высокопроизводительный интерфейс для асинхронных операций ввода-вывода. Он был представлен в ядре Linux версии 5.1 и направлен на устранение некоторых ограничений и проблем с масштабируемостью существующего интерфейса асинхронного ввода-вывода.
  • kqueue() используется в OSX/FreeBSD.
  • IO Completion Ports для Windows.

Большинство производственных систем, скорее всего, будут использовать epoll(), поскольку это наиболее распространенный подход для систем Linux, однако, возможно, стоит попробовать io_uring, если он поддерживается конкретным Fiber.scheduler и доступен в ОС.

Стоит отметить, что до появления планировщика Fiber основной библиотекой, которая заботилась об обнаружении начала/окончания событий ввода-вывода, была Nio4r https://github.com/socketry/nio4r. И он до сих пор используется в Action Cable, Puma, Async v1 и многих других библиотеках, занимающихся вводом-выводом.

Обзор сокетов

На данный момент Socketry https://github.com/socketry является самой популярной коллекцией асинхронных библиотек в мире Ruby.

Async https://github.com/socketry/async — это фреймворк, предоставляющий удобные интерфейсы, которые еще больше упрощают разработку на основе оптоволокна. Он имеет встроенный Fiber.scheduler с поддержкой epoll/kqueue и io_uring. Он инкапсулирует все вызовы, связанные с планировщиком, поэтому пользователи могут легко запускать асинхронные задачи, см. пример ниже:

Async do
  resources.each do |resource|
    Async do
      result = api_client.get(resource)
      DB.connection.save(result)
    rescue => err
      logger.error(err)
    end
  end
end

Вот так просто.

Джигглер. Реализация фонового процессора заданий

Давайте еще раз оценим, когда волокна могут сиять больше всего.

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

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

Учитывая, что socketry/async предоставляет все необходимые API для построения джоб-процессора, то такие проекты просто обязаны были начать появляться.

Было очень, очень тяжело, но и легко © davie504

Джигглер https://github.com/tuwukee/jiggler

jiggler вдохновлен sidekiqи, хотя он не поддерживает столько функций, он по-прежнему реализует очень похожую парадигму, поэтому справедливо сравнить эти 2 с точки зрения производительности, чтобы ясно увидеть все преимущества, которые волокна и socketry/async могут предоставить по сравнению с чисто поточный подход.

Основные компоненты Джигглера

Концептуально jiggler состоит из двух частей: client и server.
client отвечает за отправку заданий в Redis и позволяет читать статистику, а server читает задания из Redis, обрабатывает их и записывает статистику.

Сервер состоит из 3-х частей: Manager, Poller, Monitor.

  • Manager раскручивается и обрабатывает рабочих.
  • Poller периодически извлекает данные для повторных попыток и запланированных заданий.
  • Monitor периодически загружает данные статистики в Redis.

Упреждающее планирование оптоволокна

Одна важная загвоздка здесь заключается в том, что мы хотим, чтобы Poller и Monitor гарантированно работали в свое время, даже если рабочие выполняют некоторые задачи, требующие большой нагрузки на ЦП. Мы хотим иметь актуальную статистику и стабильные опросы. С потоками нам все равно, так как ОС периодически переключает потоки без вмешательства разработчика, а Fiber.scheduler ждет команду на переключение контекста.

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

В упреждающей модели задачи могут быть принудительно приостановлены.

Есть отличная статья Вандера Хиллена, объясняющая проблематику в контексте Ruby, очень рекомендую ее прочитать:
https://www.wjwh.eu/posts/2021-02-07-ruby-preemptive-fiber .html

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

CONTEXT_SWITCHER_THRESHOLD = 0.5

def patch_scheduler
  @switcher = Thread.new(Fiber.scheduler) do |scheduler|
    loop do
      sleep(CONTEXT_SWITCHER_THRESHOLD)
      switch = scheduler.context_switch
      next if switch.nil?
      next if Process.clock_gettime(Process::CLOCK_MONOTONIC) - switch < CONTEXT_SWITCHER_THRESHOLD

      Process.kill('URG', Process.pid)
    end
  end

  Signal.trap('URG') do
    next Fiber.scheduler.context_switch!(nil) unless Async::Task.current?
    Async::Task.current.yield
  end

  Fiber.scheduler.instance_eval do
    def context_switch
      @context_switch
    end

    def context_switch!(value = Process.clock_gettime(Process::CLOCK_MONOTONIC))
      @context_switch = value
    end

    def block(...)
      context_switch!(nil)
      super
    end

    def kernel_sleep(...)
      context_switch!(nil)
      super
    end

    def resume(fiber, *args)
      context_switch!
      super
    end
  end
end

Ориентиры

Последние тесты доступны на странице README jiggler.
На данный момент он превосходит sidekiq во всех тестах, когда дело доходит до выполнения кода, учитывающего хуки планировщика Fiber.

Это зависит от полезной нагрузки, но на данных примерах обычно экономит 10–20% памяти при тех же настройках параллелизма. По скорости — показывает неплохие результаты с File IO, а вот с net/http или PG запросами разница не столь впечатляющая.

Но не забывайте, что при использовании файберов параллелизм можно выставлять на более высокие значения. Технически ожидается, что накладные расходы на нерестящихся рабочих будут низкими. Таким образом, можно протестировать его с 50, или 100, или даже большим количеством воркеров для конкретной полезной нагрузки, если действительно происходит много операций ввода-вывода. Ограничение скорее в пуле соединений (когда рабочие выполняют некоторые запросы к БД) или во внешних службах, принимающих запросы (в случае сетевых запросов).

Ограничения

Разработчики должны быть осторожны при использовании C-расширений внутри волокон. socketry/async пока не поддерживает JRuby, поэтому МРТ — единственный способ. Нет поддержки и для пользователей Windows.

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

Я пытался протестировать socketry/async напрямую с нативными потоками Ruby и с полифонией (https://github.com/digital-fabric/polyphony — еще один интересный фреймворк для волокон), и полифония на самом деле отлично работает на OSX M1. Таким образом, проблема может быть в реализации поддержки kqueue() в планировщике socketry/async Fiber, но дальше этого я не продвинулся, и это может быть ложным выводом.

Будущее

Строить jiggler весело, я многому учусь. У меня много планов и идей для этой библиотеки, но не так много свободного времени, чтобы поработать над ней. На данный момент драгоценный камень уже доступен в версии RC.

Одной из идей было бы попытаться реализовать brpoplpush для каждой очереди в выделенных классах чтения (вместо brpop для всех очередей в каждом воркере), таким образом предоставляя выполнение воркера хотя бы один раз (в настоящее время это не более одного раза, как и в бесплатной версии sidekiq). Другой идеей может быть тестирование PG в качестве бэкенда вместо Redis. Чего ждать? Да! Это добавит МНОГО накладных расходов, но такой подход также обеспечит надежность «из коробки». Перед нами открываются большие возможности!

Спасибо за прочтение!