Многие приходят в Tarantool с ожиданием, что это очередная многопоточная СУБД, где десятки рабочих потоков параллельно лезут в одни и те же таблицы под защитой блокировок. На деле всё иначе. Tarantool - это один ОС-процесс, внутри которого работает небольшой набор узкоспециализированных потоков, а вся бизнес-логика и все изменения данных живут в единственном потоке. Понять этот факт - значит понять, почему Tarantool такой быстрый, почему в нём почти нет гонок данных и почему один тяжёлый цикл на Lua способен застопорить весь инстанс.
В этом уроке разбираемся, какие потоки крутятся внутри процесса tarantool, кто за что отвечает, что такое event-loop и кооперативная многозадачность на файберах.
Механика: потоки одного процесса
Запущенный инстанс - это один процесс. Внутри него ОС-потоки (в коде они называются cord - cord = core + thread) с чёткими ролями. Каждый cord несёт собственный event-loop на базе библиотеки libev. Между собой потоки не делят данные напрямую и не берут мьютексы на каждый чих - они обмениваются сообщениями через внутреннюю шину cbus (пара однонаправленных каналов cpipe). Это и есть ключ к производительности: нет общего изменяемого состояния под локами.
TX (transaction) thread - сердце инстанса. Это единственный поток, который имеет право трогать данные: индексы memtx и vinyl, кортежи, схему. Здесь же исполняется весь ваш Lua-код, хранимые функции, триггеры, SQL. Раз поток один и переключение задач в нём кооперативное, две транзакции физически не могут одновременно писать в один индекс. Отсюда отсутствие классических race condition внутри одного инстанса.
IPROTO thread(s) - сетевой фронт. Этот поток (а с версии 2.x их может быть несколько, см. ниже) читает байты из сокетов, разбирает бинарный протокол iproto поверх MessagePack, складывает разобранный запрос в сообщение и отправляет его по cpipe в TX-поток. Получив ответ обратно, он кодирует его в MessagePack и пишет в сокет. Смысл прост: грязную и медленную работу с сетью снимают с TX-потока, чтобы тот занимался только данными.
WAL thread - дисковый писарь. Когда транзакция готова к коммиту, TX формирует сообщение и отдаёт его WAL-потоку, который добавляет запись в .xlog (write-ahead log) и делает write/fsync на диск. Пока WAL пишет, TX-поток не простаивает - он принимает новые транзакции и готовит следующий пакет. Несколько коммитов от разных файберов объединяются в один батч и сбрасываются на диск одной операцией (group commit) - это резко поднимает пропускную способность записи.
Кроме этой тройки в процессе есть и другие потоки: на каждую подключённую реплику создаётся отдельный relay-поток (он читает WAL и шлёт его реплике, у каждой свой темп и своя позиция в логе), есть рабочие потоки vinyl для компакции и фоновые потоки для тяжёлых операций. Но базовая модель, которую надо держать в голове - именно IPROTO -> TX -> WAL.

Потоки процесса и event-loop с файберами
Event-loop и кооперативная многозадачность
Внутри TX-потока нет потоков ОС на каждый запрос. Вместо них - файберы (fibers): легковесные корутины с собственным стеком, которыми управляет планировщик внутри Tarantool, а не ядро ОС. Файберов могут быть тысячи, переключение между ними стоит копейки.
Главное слово - кооперативная. В любой момент времени бежит ровно один файбер. Он работает, пока сам не уступит управление (yield). Планировщик не вытесняет файбер принудительно. Когда активный файбер делает yield, управление уходит планировщику, тот отдаёт его другому готовому файберу, а если готовых нет - возвращает в event-loop ev_run(), который ждёт событий (готовность сокета, срабатывание таймера, ответ от WAL) и будит соответствующие файберы.
Yield бывает двух видов:
- Явный - вы сами вызвали fiber.yield() или fiber.sleep().
- Неявный - любая операция, которая ждёт внешнего события: запись в WAL при коммите, сетевой запрос через net.box, чтение/запись сокета, файловые операции fio, передача через fiber.channel. Под капотом такая операция ставит файбер в ожидание и уступает управление.
Ключевые команды и кодКооперативность - обоюдоострый меч. Если файбер крутит долгий цикл без единого yield (например, обходит миллион кортежей чистым Lua без обращений к сети и диску), он монополизирует TX-поток. Весь инстанс встаёт: новые запросы не обрабатываются, репликация отстаёт, heartbeat не уходит. Tarantool не отнимет управление силой - это ответственность автора кода.
Создание файбера и явный yield:
Код: Выделить всё
local fiber = require('fiber')
local f = fiber.create(function()
for i = 1, 5 do
print('tick', i)
fiber.sleep(0.1) -- явный yield: уступаем управление на 0.1 c
end
end)
print('fiber id:', f:id(), 'status:', f:status())
Код: Выделить всё
box.cfg{}
box.schema.space.create('s', {if_not_exists = true})
box.space.s:create_index('pk', {if_not_exists = true})
box.begin()
box.space.s:replace{1, 'a'}
box.commit() -- здесь файбер неявно yield-ит, пока WAL не подтвердит запись
Код: Выделить всё
require('fiber').info() -- список файберов, их память и CSW (переключения)
require('fiber').top() -- доля CPU по файберам (нужно fiber.top_enable())
box.stat() -- счётчики запросов по типам
box.info.replication -- лаг реплик: растёт, если TX залип
Код: Выделить всё
iproto:
threads: 4 # сколько сетевых потоков обслуживают сокеты
net_msg_max: 768 # лимит одновременно обрабатываемых сообщений
readahead: 16320 # размер буфера предчтения на соединение
Код: Выделить всё
box.cfg{
iproto_threads = 4,
net_msg_max = 768,
readahead = 16320,
}
- Больше IPROTO-потоков ускорят сами запросы. Нет. Параметр iproto_threads масштабирует только разбор сети и кодирование ответов. Сама обработка данных всегда в одном TX-потоке - это узкое место по логике, и его потоками не расширить.
- Файберы дают параллелизм на ядрах. Нет. Файберы - это конкурентность, не параллелизм. Все файберы TX-потока делят одно ядро. Несколько ядер CPU грузят IPROTO/WAL/relay/vinyl-потоки, но не пользовательскую логику.
- Внутри файбера нужны блокировки. Обычно нет. Пока в критическом участке нет ни одного yield, прерывание невозможно. Но стоит вставить туда сетевой вызов или fiber.sleep - и инвариант рвётся: после yield состояние могло измениться.
- Тяжёлый цикл - это нормально. Это главная причина деградации. Долгий проход без yield замораживает инстанс. Вставляйте fiber.yield() в длинные циклы или дробите работу.
- os.execute / блокирующий C - безвредны. Нет. Блокирующий системный вызов не делает yield и останавливает весь поток - вместе с ним стоят все остальные файберы.
Запустите интерактивный Tarantool (tt start или просто tarantool) и выполните box.cfg{}. Создайте два файбера: первый печатает числа от 1 до 5 с fiber.sleep(0.2) между ними, второй - буквы a..e тоже с fiber.sleep(0.2). Запустите оба подряд через fiber.create и посмотрите, что вывод чередуется - это и есть кооперативное переключение на неявных yield. Затем уберите sleep из первого файбера, заменив его на пустой цикл for j=1,1e7 do end, и убедитесь, что второй файбер теперь ждёт, пока первый не закончит, - монополизация потока в действии.
Контрольные вопросы
- Какой поток инстанса и только он имеет право изменять данные и исполнять Lua-код? Почему из-за этого внутри инстанса почти нет гонок данных?
- Чем занят IPROTO-поток и как он передаёт работу в TX? Ускорит ли увеличение iproto_threads обработку самих запросов?
- В чём разница между явным и неявным yield? Приведите по два примера каждого.
- Почему длинный цикл без yield опасен для всего инстанса и как этого избежать?