Статьи

Любопытный случай параллелизма

В прошлом месяце команда, отвечающая за драйвер Ruby 10gen для MongoDB, столкнулась с несколькими ошибками параллелизма, о которых сообщил клиент, запустивший драйвер в JRuby с большим количеством потоков и соединений. Я едва написал в своей жизни строчку о Ruby, но я все равно попытался помочь на неделю .

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

Пул драйвера Ruby назначает сокет потоку при первом вызове потока checkout, и этот поток остается прикрепленным к своему сокету на всю жизнь. Пока пул не достигнет своего настроенного состояния max_size, для каждого нового потока создается специальный сокет. Дополнительным потокам назначаются случайные существующие сокеты. При следующем вызове потока checkout, если его сокет используется (другим потоком), запрашивающий поток ожидает в очереди.

Вот упрощенная версия пула:

class Pool
  def initialize(max_size)
    @max_size       = max_size
    @sockets        = []
    @checked_out    = []
    @thread_to_sock = {}
    @lock           = Mutex.new
    @queue          = ConditionVariable.new
  end

  # Check out an existing socket or create a
  # new socket if max_size not exceeded.
  # Otherwise, wait for the next socket.
  def checkout
    tid = Thread.current.object_id
    loop do
      @lock.synchronize do
        if sock = @thread_to_sock[tid]

          # Thread wants its prior socket
          if ! @checked_out.include?(sock)
            # Acquire the socket
            @checked_out << sock
            return sock
          end

        else

          if @sockets.size < @max_size

            # Assign new socket to thread
            sock = create_connection
            @thread_to_sock[tid] = sock
            return sock

          elsif @checked_out.size < @sockets.size

            # Assign random socket to thread
            sock = available[rand(available.length)]
            @thread_to_sock[tid] = sock
            return sock

          end

        end

        # Release lock, wait to try again
        @queue.wait(@lock)
      end
    end
  end

  # Return a socket to the pool.
  def checkin(socket)
    @lock.synchronize do
      @checked_out.delete(socket)
      @queue.signal
    end
  end
end

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

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

Я написал скрипт на Python для имитации пула Ruby и запустил его для нескольких тысяч тиков с различным количеством потоков и сокетов. Это никогда не зашло в тупик.

Поэтому мне пришлось перестать кодировать и начать думать.


Допустим, есть N потоков и S сокетов. N может быть больше, меньше или равно S. Не имеет значения. Предположим, что пул уже создал все S сокетов, и всем N потокам назначены сокеты. Каждый поток либо:

  1. Проверил свой сокет, и собирается вернуть его и сигнализировать об очереди, или
  2. Ждет его сокет, или попросит его в будущем, или
  3. Вернул свой сокет и никогда не будет просить его снова.

Чтобы заблокировать все потоки должны быть в состоянии 2.

Чтобы достичь этой точки, нам нужно N — 1 потоков в состоянии 2 и иметь переход N-го потока из 1 в 2. (По определению он не переходит из состояния 3 в 2.) Но когда N-й поток возвращает свой сокет и сигналы очередь, все сокеты теперь возвращаются, поэтому следующий пробужденный поток больше не будет ждать — его сокет доступен, поэтому он переходит в состояние 1. Таким образом, нет тупиков.

Старый код определенно не был эффективным. Легко представить случаи, когда все потоки сокета ожидали, хотя один из них мог работать. Допустим, есть 2 сокета и 4 потока:

  1. В потоке 1 извлечено гнездо A, в потоке 2 — гнездо B, в потоке 3 ожидание A, в потоке 4 ожидание B, и они помещены в очередь, как [3, 4].
  2. Поток 2 возвращает B, сигнализирует об очереди.
  3. Нить 3 просыпается, не может получить A, ждет снова.

На этом этапе поток 4 должен быть запущен, поскольку его сокет B доступен, но он ошибочно ждет, пока поток 1 вернет A, прежде чем он проснется.

Таким образом, мы изменили код, чтобы сделать queue.broadcastвместо signal, поэтому checkinпробуждает все потоки, и мы выпустили исправленный драйвер . В будущем, даже более качественный код может вообще помешать нескольким потокам бороться за один и тот же сокет.

Исправление было очевидным. Гораздо сложнее точно определить, насколько серьезной была ошибка — насколько часто сокет не используется?


В моем смоделированном пуле есть 10 розеток. Каждый поток использует свой сокет в течение 1–20 секунд, спит одну секунду и снова запрашивает свой сокет. Я посчитал, сколько сокетов использовалось каждую секунду, и вычел это из S * total_time, чтобы получить коэффициент неэффективности:

Процент неиспользованных розеток

Если N = S = 10, потоки никогда не ждут, но есть некоторая ложная «неэффективность» из-за 1-секундного сна. Для большего числа потоков время ожидания становится неактуальным (потому что всегда есть другой поток, готовый использовать сокет), но signalдобавляет неэффективность, которая очень медленно снижается с 8% по мере увеличения числа потоков. broadcastНапротив, пул, который использует насыщенные сокеты, может содержать более 30 потоков.

Я часами (в основном на самолетах) пытался выяснить, почему фактор неэффективности действует именно так — почему 8%? Разве это не должно быть хуже? И почему он медленно падает с ростом N? Но я звоню, это уходит сейчас. Оставьте комментарий, если у вас есть какие-либо идеи, но я удовлетворен тем, что старый пул был расточительным, а новый — существенным улучшением.