Как переписать горячий путь аудио в KeyEcho на Rust: ускорение в 27 раз, zero-copy, отказ от мьютекса. Читайте и применяйте.
KeyEcho — десктопное приложение, которое воспроизводит звук механической клавиатуры при каждом нажатии. В версии 1.0 горячий путь аудио был переписан, что дало ускорение в 27 раз на среднем сэмпле и в 38 раз на самом большом. В этой статье я разберу, как именно это было сделано: преддекодирование, zero-copy, отказ от глобального мьютекса. Вы узнаете, как Rust сделал эти изменения безопасными, и какую ошибку предложил AI-агент, которая выглядела чисто, но сломала бы функциональность.
Горячий путь состоит из нескольких шагов: глобальный хук клавиатуры срабатывает на каждое нажатие; первое нажатие клавиши помещается в очередь с ограничением; аудиопоток забирает из очереди, отображает клавишу на фрагмент звукового пакета и воспроизводит локально. Любая операция внутри этого пути — декодирование, выделение памяти, копирование сэмплов, блокировка — платится на каждое нажатие. Цель — вынести всё это из пути.
Предыдущая версия уже была быстрой: Tauri + Rust, нативное прослушивание клавиатуры, WAV без декомпрессии, LRU-кэш декодированного аудио. Но даже при попадании в кэш каждое нажатие копировало сэмплы (66.84 KiB) и проходило через глобальный мьютекс, разделяемый с переключением пакетов и громкостью. Промах кэша декодировал на месте. Версия 1.0 убирает именно это: копирование, блокировку и промахи кэша.
Звуковой пакет — папка с файлами sound.ogg и config.json. Конфиг отображает клавишу на фрагмент аудио: key -> [start_ms, duration_ms]. При выборе пакета каждый фрагмент декодируется один раз заранее. Никакого декодирования во время нажатия.
Многие клавиши указывают на один и тот же фрагмент, поэтому декодировать на каждую клавишу — тратить ресурсы. Строитель пакета использует карту по паре (start_ms, duration_ms), декодирует каждый уникальный фрагмент один раз и делит его через Arc:
// Декодируем каждый уникальный фрагмент один раз; одинаковые фрагменты разделяют один буфер
let mut slice_sources: HashMap<[u64; 2], AudioSource> = HashMap::new();
let mut key_sources: HashMap<Key, AudioSource> = HashMap::new();
for (key, slice) in defines {
let source = slice_sources.entry(slice)
.or_insert_with(|| {
let [start_ms, duration_ms] = slice;
let samples = decode(start_ms, duration_ms); // Vec<f32>, один раз
AudioSource::new(Arc::from(samples), channels, sample_rate)
})
.clone(); // клонирование Arc, не копирование сэмплов
key_sources.insert(key, source);
}Декодированные сэмплы хранятся в Arc<[f32]>. Поиск по клавише возвращает клон этого Arc — увеличение счётчика ссылок, а не копирование сэмплов. Стоимость на нажатие — 0 байт скопировано.
Старый путь защищал текущий звук и громкость мьютексом. Новый хендлер воспроизведения хранит текущий звук в ArcSwapOption<KeySound> и громкость в AtomicU32 (через f32::to_bits). Поиск клавиши — lock-free загрузка:
struct PlaybackSoundpack {
current_sound: Arc<ArcSwapOption<KeySound>>,
volume_bits: Arc<AtomicU32>,
}
fn source_for_key(&self, key: Key) -> Option<(AudioSource, f32)> {
let sound = self.current_sound.load(); // lock-free
let source = sound.as_ref()?.key_source(key)?; // map get + Arc clone
let volume = f32::from_bits(self.volume_bits.load(Ordering::Relaxed));
Some((source, volume))
}Преддекодирование увеличивает потребление RAM, поэтому введены ограничения: очередь событий с границей (backpressure) и бюджет декодированных сэмплов в 10 MiB. Пакет, превышающий бюджет, не загружается.
| Сценарий | v0.0.5 | v1.0 | Ускорение |
|---|---|---|---|
| Попадание в кэш, средний фрагмент | 1184.07 ns/op; 66.84 KiB скопировано | 43.50 ns/op; 0 байт скопировано | 27.2x |
| Попадание в кэш, самый большой фрагмент | 1638.57 ns/op; 98.88 KiB скопировано | 43.10 ns/op; 0 байт скопировано | 38.0x |
| Шлюз нажатия/отпускания | 58.82 ns/тап; 2 сообщения | 54.69 ns/тап; 1 сообщение | половина сообщений |
Это микро-бенчмарки пути поиска, не сквозная задержка. Методология и бюджеты в docs/performance.md.
Компилятор Rust — первый ревьюер для кода, написанного AI. Ошибки с заимствованием, псевдонимами или Send/Sync отлавливаются на этапе cargo check. Переход на Arc<[f32]> и отказ от мьютекса — именно те изменения, где ошибка была бы ошибкой алиасинга, которую Rust не пропустит.
CI для Linux armv7 падал под QEMU: libgit2 не мог загрузить git-зависимость. Агент предложил отказаться от git-пина на крейт аудио и использовать версию с crates.io. CI позеленел. Я отклонил. Пин существует, чтобы удерживать cpal на версии 0.18.1, которая имеет событие DeviceChanged. Без него звук не следует за переключением наушников или Bluetooth. Возврат к crates.io версии тихо даунгрейдит cpal до 0.17.3 и ломает эту фичу. Два issue (#41 и #20) были именно об этом. Реальный фикс — cargo vendor и офлайн-сборка.
Два правила:
Сборка armv7 занимала ~2 часа под эмуляцией, а пользователей 32-битного ARM Linux не было видно. Я убрал её из 1.0. Агент не чувствует sunk cost; решать, когда остановиться — человеческая работа.
Прямо сейчас: откройте свой проект и найдите горячий путь — место, где выполняется самая частая операция. Измерьте, сколько байт копируется, какие блокировки происходят. Попробуйте применить предварительное выделение и zero-copy через Arc. Используйте lock-free структуры, если позволяют гарантии Rust. И обязательно документируйте причины каждого пина — это спасёт вас и вашего AI-агента от регрессий.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →