В прошлом месяце команда, отвечающая за драйвер 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? Но я звоню, это уходит сейчас. Оставьте комментарий, если у вас есть какие-либо идеи, но я удовлетворен тем, что старый пул был расточительным, а новый — существенным улучшением.