Что такое ybc?
Ybc — это легковесная низкоуровневая библиотека на C, реализующая API для кэширования произвольных blob’ов. На текущий момент работает только под Linux, но может быть легко портирована на любую платформу благодаря тому, что весь платформозависимый код спрятан за платформонезависимым интерфейсом, реализация которого находится в строго отведенном месте.
С чего все началось?
Пару лет назад мне попалась на глаза статья Notes from the Architect, где автор программы Varnishрассказывает о том, как нужно писать современный софт под современные платформы (операционные системы + железо). После этого я прочел еще одну статью того же автора — You’re Doing It Wrong, где он рассказывает про то, насколько важно понимание locality of referenceпри создании высокопроизводительного кода под современные платформы. Для тех, кому влом читать оригиналы статей, привожу две основные мысли:
- Современное железо напичкано кучей многоуровневых кэшей. Поэтому циклическое обращение к одной и той же области памяти может работать в 100500 раз быстрее, чем циклическое обращение к случайным областям памяти.
- Память, с которой работают ваши программы на современных операционных системах, имеет мало общего с физической RAM. Скорость доступа по разным адресам этой памяти может отличаться в миллионы раз. Причем скорость доступа по одному и тому же адресу памяти в разные интервалы времени может также отличаться в миллионы раз.
Эти «сокровенные знания» могут сильно помочь при оптимизации алгоритмов по производительности. Для любителей матана есть замечательная серия статей — «What every programmer should know about memory», а также Cache-oblivious algroithm.
Начитавшись вышеупомянутой «ереси», я решил испытать свои силы. Вначале покопался в исходниках Varnish’а и обнаружил там следующие «фатальные недостатки»:
- Добавление новой записи в переполненный кэш либо устаревание уже существующей записи может приводить к большому количеству операций ввода-вывода по случайным адресам памяти. Как известно любителям файлов подкачки на HDD, такие операции могут сильно тормозить.
- Обращение к существующей записи также может приводить к нескольким операциям ввода-вывода, если все записи в кэше не умещаются в RAM.
- Реализация кэша в Varnish была подвержена DoS-атаке на хэш-коллизии.
- При крэше Varnish’а все закэшированные записи терялись.
- Код написан не мной.
Для «решения» этих недостатков вначале были написано несколькопатчейдляVarnish, затем была созадана реализация B-heapи
Дизайн ybc
Отслеживание и удаление записей из кэша в Varnish’а реализовано с помощью двух механизмов, присутствующих в большинстве классических реализаций кэша:
- LRU-списка, в котором хранятся указатели на все записи, отсортированные по времени последнего обращения к этим записям. При каждом обращении к записи ее указатель переносится в начало LRU-списка. При переполнении кэша Varnish проходится по этому списку с конца, удаляя записи, к которым очень давно не было обращений.
- Heap’а, в котором хранятся указатели на все записи, упорядоченные по expiration time. В «голове» heap’а всегда находится запись, которая должна устареть раньше всех. При каждом добавлении новой записи ее указатель заносится в heap. Периодически Varnish удаляет устаревшие записи с помощью этого heap’а.
Как можно видеть, основной недостаток классической реализации кэша — повышенного количество обращений к памяти по произвольным адресам, которое выливается в операции ввода-вывода при больших объемах данных.
Главной целью при проектировании ybc было избавление от этого недостатка. Эта цель была достигнута с помощью радикального способа — полного отказа от LRU с heap’ом в пользу «принципиально нового» механизма — циклического буферанаподобие того, который используется в Log-structured filesystem. Кэшируемые данные записываются по очереди в циклический буфер. При переполнении буфера старые данные затираются новыми. При этом структура кэша реализована таким образом, что отслеживать затираемые записи или инициализировать циклический буфер при создании нет необходимости. При попытке обращения к затертой или поврежденной записи ybc легко обнаружит это и вернет результат «запись не найдена». Хранение данных в виде циклического буфера, спроецированного в mmap’edфайл, дало следующие «бесплатные» фичи:
- записи не теряются при крэше или принудительном рестарте программы, использующей библиотеку ybc.
- Размер кэша может превышать объем физической RAM в сотни раз.
- Закэшированные данные никогда не попадут в файл подкачки. Это хорошо с точки зрения безопасности и производительности — при нехвтке памяти операционная система просто «забудет» про cold cacheстраницы памяти из за-mmap’ленного файла вместо того, чтобы выгружать их в файл подкачки.
- Не нужно городить огород с балансировкой закэшированных данных между оперативной памятью и файлом — об этом позаботится механизм виртуальной памятив операционной системе.
Каким же образом ybc находит необходимую запись по ключу? Для этого служит модифицированная open addressing хэш-таблица, адаптированная под нужды кэша. Модификация заключается в том, что при переполнении хэш-таблицы ybc может спокойно затирать старые записи новыми, не беспокоясь о memory leak’ах. Также модифицированная хэш-таблица «бесплатно» обеспечила защиту от DoS-атаки на хэш-коллизиивследствие ограниченного размера bucket’ов.
Подсчитаем количество необходимых обращений к произвольным участкам памяти (которые, как известно, на больших объемах данных превращаются в очень медленныеоперации ввода-вывода) при чтении чтении и записи для классической реализации кэша и для кэша ybc.
- Чтение
- Для классического кэша — минимум две при кэш-промахе (одна для обращения к хэш-бакету по ключу, последующие — для просмотра каждой записи в хэш-бакете), минимум пять при кэш-попадании (плюс две для переноса указателя в начало LRU-списка и одна — для чтения закэшированных данных).
- Для ybc — одна при кэш-промахе (для обнаружения факта того, что в хэш-таблице отсутствует соответствующая запись), две при кэш-попадании — одна для обращения в хэш-таблицу, вторая — для чтения данных из циклического буфера.
- Запись в переполненный кэш
- Для классического хэша — много (динамическое выделение памяти для данных и для хэш-записи, вставка указателя на данные в хэш-бакет, произвольное количество обращений к элементам LRU-списка для освобождения необходимой памяти под новую запись, произвольное количество обращений к элементам expiration heap’а для удаления устаревших записей, произвольное количество вызовов функции освобождения динамически выделенной памяти под удаляемые записи и вспомогательные элементы в LRU-списке, expiration heap’е и хэш-таблице).
- Для ybc — одна (для добавления записи в хэш-таблицу). Обращение к памяти при добавлении данных в циклический буфер не учитывается, т.к. эта область памяти с большой долей вероятности будет находиться в кэшах процессора, если недавно другой элемент был записан в кэш.
Полезные и не очень оптимизации, используемые в ybc
В ybc также было применено множество мелких оптимизаций, нацеленных на минимизацию working set size. Вот некоторые из них:
- Мелкие объекты, размер которых не превышает размера страницы памяти, могут перемещаться с определенной долей вероятности в начало циклического буфера. Это необходимо для того, чтобы «запаковать» мелкие часто запрашиваемые объекты в минимальную область памяти. Также это позволяет продлить время жизни таких объектов — ведь при переполнении циклического буфера они не будут затерты.
- Кэш хэш-таблицы для хранения часто запрашиваемых записей. При обнаружении записи в этом кэше удается избежать потенциально медленного обращения в основную хэш-таблицу, которая может не умещаться в RAM.
- Минимизация динамического выделения памяти. Обычно алгоритмы динамического выделения памяти приводят к произвольному количеству обращений по произвольным адресам памяти. В ybc динамическое выделение памяти используется только в одном очень специфическом месте — для реализации dogpile effect handling.
- «Упаковка» структур друг в друга вместо повсеместно используемой практики — использования указателей на вложенные структуры. Это минимизирует количество разыменований указателей (которые могут быть медленными на определенных платформах) при работе с данными структурами.
- Минимизация копирования больших объемов данных из одной области памяти в другую. Ybc API реализовано таким образом, чтобы пользователи могли избегать большинства операций копирования данных. Это может дать существенный выигрыш в скорости при работе с большими blob’ами наподобие многогигабайтных фильмов.
- Использование skiplist-овдля ускорения отслеживания, какие записи в данный момент читаются/пишутся. Данное отслеживание необходимо для того, чтобы при переполнении цилклического буфера не произошло нарушения целостности читающейся/пишущейся в данный момент записи. Skiplist’ы оказались наилучшим решением, позволившим избежать динамического выделения памяти в наиболее часто исполняемом коде.
- Минимизация количества и длительности блокировокв часто исполняемом коде. Это необходимо для того, чтобы несколько процессоров могли параллельно выполнять полезную работу, а не ожидать, пока другие процессоры освободят блокировку. В ybc даже есть специальный режим, отключающий все блокировки. Он позволяет линейно наращивать производительность ybc для операций чтения путем увеличения количества задействованных CPU в системе. Но за скорость нужно платить — в этом режиме сильно возрастает нагрузка на CPU для больших blob’ов, поэтому он подходит только для работы со сравнительно маленькими записями.
Что дальше?
После завершения основных работ над ybc я начал думать, где бы его применить. Первое, что пришло на ум, — реализация memcached сервера. Программировать что-нибудь сложнее hello world на C мне не очень хотелось, поэтому решил создать binding’идля ybc на каком-нибудь более дружелюбном для application programming языке. Выбор пал на Go. В итоге на Go с помощью ybc были реализованы два PoC-приложения:
- go-memcached — memcached сервер с «фишками» вроде cache persistence, произвольного объема кэшируемых данных (может превышать объем RAM), произвольного размера отдельно кэшируемых записей.
- go-cdn-booster — простой кэширующий http-прокси для статического контента. В отличие от nginx, хранит весь закэшированный контент в двух, а не в миллионе файлов. Также не нуждается в cleaner-процессе, ответственным за удаление файлов с устаревшим контентом.
В планах есть желание использовать ybc для создания squid killer’а — прозрачного кэширующего http-проксис возможностью кэширования range request’овот основных видеохостингов вроде ютуба и иксхамстера. Такой прокси мог бы экономить входящий трафик для интернет-провайдеров, а также отдавать юзерам закэшированный контент на скорости 1Гбит/с. Пока до него не дошли руки.
Заключение
Несколько советов:
- Изучайте детали реализации современных платформ (железо, операционная система, программное окружение). Это позволит вам писать более эффективный код на любом языке программирования.
- Пишите больше разного кода (желательно применимого на практике) на разных языках программирования, не стесняйтесь изобретать «велосипеды». Это позволит вам делать осознанный выбор в пользу программных архитектур и языков программирования, ориентированных на практическое, а не теоретическое применение.
Если после прочтения данной статьи вы вдруг решите помочь в разработке и продвижении ybc, то мне нужна помощь по следующим направлениям:
- Портирование ybc на другие платформы.
- Создание binding’ов для ваших любимых языков программирования.
- Создание высокоуровневых API, более удобных для использования по сравнению с API ybc.
- Написание различных приложений, использующих ybc.
С удовольствием отвечу на любые ваши вопросы к комментариям к этой статье.