Привет, Хабр! Сегодня поговорим про один из самых незаметных, но любопытных моментов языка C. Если вы пишете на C, скорее всего, вы никогда не использовали ключевое слово restrict. А зря — этот квалификатор указателя может дать вашему коду неплохой прирост производительности. Правда, для этого придётся дать компилятору честное слово насчёт своих указателей.
Зачем нужен restrict и что такое aliasing
Указатели — штука удобная. Но есть проблема, компилятор живёт в постоянной паранойе. А вдруг два разных указателя ссылаются на одну и ту же область памяти?
Это явление называется aliasing. И именно из‑за него компилятор вынужден перестраховываться, генерируя менее эффективный код. Он просто не уверен, не повлияет ли изменение через один указатель на данные, которые читаются через другой.
restrict, появившийся в стандарте C99, помечает указатель как restrict, мы обещаем компилятору, что никакой другой указатель не будет обращаться к этой области памяти на протяжении всего времени жизни нашего restrict‑указателя. Компилятор верит и начинает оптимизировать агрессивнее.
Но если вы солгали...если два restrict‑указателя всё‑таки указывают на одну память, ждите неопределённого поведения. Компилятор вам доверял, а вы его подставили!
Как aliasing тормозит код
Посмотрим на простой пример. Вот функция, которая увеличивает два значения на одно и то же число:
void updatePtrs(size_t *ptrA, size_t *ptrB, size_t *val) {
*ptrA += *val;
*ptrB += *val;
}На первый взгляд всё элементарно, но компилятор вам не доверяет. Теоретически ptrA, ptrB и val могут указывать на одно место в памяти. Представьте, что кто‑то вызвал функцию так: updatePtrs(&x, &y, &x). В этом случае ptrA и val указывают на один адрес. Когда мы выполняем ptrA += val, мы одновременно модифицируем и читаем одну переменную двумя разными указателями.
Компилятор перестраховывается. Он генерирует код, который каждый раз перечитывает значение по указателю val перед использованием. Не может же он сохранить *val в регистре на всю функцию, вдруг между операциями значение изменилось через ptrA или ptrB? Получается примерно такой код:
// Загружаем значение *val в регистр R1
R1 = *val
// Обновляем *ptrA
R2 = *ptrA
R2 = R2 + R1
*ptrA = R2
// Компилятор снова загружает *val — вдруг оно изменилось через ptrA?
R1 = *val
// Обновляем *ptrB
R2 = *ptrB
R2 = R2 + R1
*ptrB = R2Значение по адресу val прочитано дважды, перед обновлением ptrA и перед обновлением ptrB. А повторное чтение из памяти дорого. Компилятор просто не может рисковать, без подсказок он должен предполагать, что ptrA и val могут указывать на один блок памяти.
Используем restrict
Теперь перепишем функцию с restrict:
void updatePtrs(size_t *restrict ptrA,
size_t *restrict ptrB,
size_t *restrict val) {
*ptrA += *val;
*ptrB += *val;
}Видите слово restrict между звёздочкой и именем каждого указателя? Это наш контракт с компилятором. Мы клянёмся, что ptrA, ptrB и val указывают на разные области памяти. И компилятор верит.
Теперь он понимает,значение *val можно спокойно загрузить один раз и переиспользовать для обеих операций. Никаких лишних чтений из памяти.
Классический пример использования restrict — стандартная функция memcpy. Её прототип в C99 выглядит так:
void *memcpy(void *restrict dest, const void *restrict src, size_t n);И источник, и назначение помечены как restrict. Это неспроста, самый быстрый копирующий код возможен только если области памяти не перекрываются. Нарушите это правило и получите неопределённое поведение. Компилятор может копировать данные крупными блоками или в необычном порядке, и при перекрытии областей случится что‑нибудь неприятное.
Нюансы restrict
Звучит здорово. Но прежде чем кидаться расставлять restrict повсюду, давайте разберёмся с правилами игры.
Только указатели. Квалификатор restrict применим только к указателям. Целочисленную переменную или ссылку в C++ им не пометишь.
Один участок памяти — один restrict‑указатель. Объявили два restrict‑указателя в одном блоке и направили их на одну память? Вы нарушили обещание.
Не присваивайте restrict‑указатель другому restrict‑указателю. По крайней мере в пределах одного блока — внутренности компилятора могут запутаться. Зато присвоить restrict‑указатель обычному указателю можно. Такой обычный указатель считается порождённым от restrict, и компилятор понимает, что это не независимый конкурент.
Разные области видимости — разные правила. Если очень нужно, можно во вложенном блоке объявить второй restrict‑указатель на ту же память — это допустимо. Внутри внутреннего блока компилятор будет считать, что на этот участок памяти есть единственный указатель.
Действует только внутри области видимости. Обычно это тело функции, если restrict применён к параметрам. Гарантия неaliasing действует только внутри функции и никак не влияет на внешний код. Сохранили где‑то снаружи копию адреса и потом внезапно через неё поменяли данные? Для компилятора внутри функции это неожиданный подвох. Впрочем, это общая проблема оптимизаций в C, нарушать контракты опасно.
Если вы пишете обычные программы, то специально оптимизировать aliasing через restrict почти никогда не потребуется. Компиляторы за последние годы поумнели и сами многое оптимизируют.
В общем, используйте restrict разумно, помечайте параметры функций, когда уверены, что указатели не пересекаются. И не забывайте, что это обещание, которое нужно держать.
Если хочется глубже в ту же сторону — курс «Программист C» разбирает C как инструмент контроля памяти и взаимодействия с ОС: архитектура процессора, указатели, системные вызовы, отладка и практические задачи уровня middle. Отдельный блок — встраивание C в проекты и интеграции с PostgreSQL/MySQL/SQLite. Пройдите входной тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
15 января, 20:00. «Работа с памятью на языке C». Записаться
22 января, 20:00. «Сетевые приложения: Путешествие в мир сокетов». Записаться
