В прошлом месяце команда, отвечающая за драйвер 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 потокам назначены сокеты. Каждый поток либо:
- Проверил свой сокет, и собирается вернуть его и сигнализировать об очереди, или
- Ждет его сокет, или попросит его в будущем, или
- Вернул свой сокет и никогда не будет просить его снова.
Чтобы заблокировать все потоки должны быть в состоянии 2.
Чтобы достичь этой точки, нам нужно N — 1 потоков в состоянии 2 и иметь переход N-го потока из 1 в 2. (По определению он не переходит из состояния 3 в 2.) Но когда N-й поток возвращает свой сокет и сигналы очередь, все сокеты теперь возвращаются, поэтому следующий пробужденный поток больше не будет ждать — его сокет доступен, поэтому он переходит в состояние 1. Таким образом, нет тупиков.
Старый код определенно не был эффективным. Легко представить случаи, когда все потоки сокета ожидали, хотя один из них мог работать. Допустим, есть 2 сокета и 4 потока:
- В потоке 1 извлечено гнездо A, в потоке 2 — гнездо B, в потоке 3 ожидание A, в потоке 4 ожидание B, и они помещены в очередь, как [3, 4].
- Поток 2 возвращает B, сигнализирует об очереди.
- Нить 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? Но я звоню, это уходит сейчас. Оставьте комментарий, если у вас есть какие-либо идеи, но я удовлетворен тем, что старый пул был расточительным, а новый — существенным улучшением.
