Что такое регистры процессора и как они используются, особенно многопоточность WRT? - PullRequest
4 голосов
/ 05 марта 2010

Этот вопрос и мой ответ ниже в основном являются ответом на вопрос о путанице в другом вопросе.

В конце ответа есть некоторые проблемы WRT "volatile" и синхронизации потоков, в которых я не совсем уверен - я приветствую комментарии и альтернативные ответы. Однако вопрос в основном относится к регистрам ЦП и тому, как они используются.

Ответы [ 2 ]

14 голосов
/ 05 марта 2010

Регистры являются «рабочим хранилищем» в CPU. Они очень быстрые, но очень ограниченный ресурс. Как правило, ЦП имеет небольшой фиксированный набор именованных регистров, имена которых являются частью соглашения о языке ассемблера для машинного кода этого ЦП. Например, 32-разрядные процессоры Intel x86 имеют четыре основных регистра данных с именами eax, ebx, ecx и edx, а также ряд индексных и других более специализированных регистров.

Строго говоря, в наши дни это не совсем так - например, переименование регистров является обычным делом. У некоторых процессоров достаточно регистров, чтобы они нумеровали их, а не называли их и т. Д. Однако это остается хорошей базовой моделью для работы. Например, переименование регистра используется, чтобы сохранить иллюзию этой базовой модели, несмотря на неупорядоченное выполнение.

Использование регистров в написанном вручную ассемблере, как правило, имеет простую схему использования регистров. Несколько переменных будут храниться исключительно в регистрах на время подпрограммы или какой-либо существенной ее части. Другие регистры используются в схеме чтения-изменения-записи. Например ...

mov eax, [var1]
add eax, [var2]
mov [var1], eax

IIRC, это допустимый (хотя, вероятно, неэффективный) код ассемблера x86. На Motorola 68000 я мог бы написать ...

move.l [var1], d0
add.l  [var2], d0
move.l d0, [var1]

На этот раз источником обычно является левый параметр, а адрес назначения - справа. У 68000 было 8 регистров данных (d0..d7) и 8 адресных регистров (a0..a7), причем a7 IIRC также служит указателем стека.

На 6510 (обратно на старый добрый Commodore 64) я мог бы написать ...

lda    var1
adc    var2
sta    var1

Регистры здесь в основном неявны в инструкциях - прежде всего используется регистр A (аккумулятор).

Прошу прощения за любые глупые ошибки в этих примерах - я не писал сколько-нибудь значительного количества «настоящих» (а не виртуальных) ассемблеров как минимум 15 лет. Принцип суть, однако.

Использование регистров зависит от конкретного фрагмента кода. То, что содержит регистр, в основном независимо от того, какая последняя инструкция осталась в нем. Программист обязан следить за тем, что находится в каждом регистре в каждой точке кода.

При вызове подпрограммы вызывающий или вызываемый должен нести ответственность за отсутствие конфликта, что обычно означает, что регистры сохраняются в стеке в начале вызова, а затем считываются в конце. Подобные проблемы возникают с прерываниями. Такие вещи, как то, кто отвечает за сохранение регистров (вызывающий или вызываемый), обычно являются частью документации каждой подпрограммы.

Компилятор обычно решает, как использовать регистры гораздо более изощренным способом, чем программист, но он работает на тех же принципах. Отображение из регистров в конкретные переменные является динамическим и резко меняется в зависимости от того, какой фрагмент кода вы просматриваете. Сохранение и восстановление регистров в основном обрабатываются в соответствии со стандартными соглашениями, хотя компилятор может импровизировать «пользовательские соглашения о вызовах» в некоторых случаях.

Как правило, локальные переменные в функции предполагаются для использования в стеке. Это общее правило с переменными «auto» в C. Поскольку «auto» является значением по умолчанию, это обычные локальные переменные. Например ...

void myfunc ()
{
  int i;  //  normal (auto) local variable
  //...
  nested_call ();
  //...
}

В приведенном выше коде «i» вполне может содержаться в основном в регистре. Он может даже перемещаться из одного регистра в другой и обратно по мере выполнения функции. Однако, когда вызывается nested_call, значение из этого регистра почти наверняка будет в стеке - либо потому, что переменная является переменной стека (не регистром), либо потому, что содержимое регистра сохраняется, чтобы nested_call могла использовать свое собственное рабочее хранилище .

В многопоточном приложении обычные локальные переменные являются локальными для определенного потока. Каждый поток получает свой собственный стек и, пока он работает, исключительное использование регистров процессора. При переключении контекста эти регистры сохраняются. В регистрах или в стеке локальные переменные не являются общими для потоков.

Эта базовая ситуация сохраняется в многоядерном приложении, даже если два или более потоков могут быть активными одновременно. Каждое ядро ​​имеет свой стек и свои регистры.

Данные, хранящиеся в общей памяти, требуют большей осторожности. Это включает в себя глобальные переменные, статические переменные внутри классов и функций, а также объекты, выделенные в куче. Например ...

void myfunc ()
{
  static int i;  //  static variable
  //...
  nested_call ();
  //...
}

В этом случае значение «i» сохраняется между вызовами функций. Статическая область основной памяти зарезервирована для хранения этого значения (отсюда и название «статический»). В принципе, нет необходимости в каких-либо специальных действиях для сохранения «i» во время вызова «nested_call», и на первый взгляд, переменная может быть доступна из любого потока, работающего на любом ядре (или даже на отдельном процессоре).

Тем не менее, компилятор все еще усердно работает над оптимизацией скорости и размера вашего кода. Повторное чтение и запись в основную память намного медленнее, чем доступ к регистру. Компилятор почти наверняка выберет , а не , чтобы следовать простой схеме чтения-изменения-записи, описанной выше, но вместо этого сохранит значение в регистре в течение относительно длительного периода, избегая повторных операций чтения и записи в одну и ту же память. .

Это означает, что изменения, сделанные в одном потоке, могут не просматриваться другим потоком в течение некоторого времени. Два потока могут в конечном итоге иметь совершенно разные представления о значении «i» выше.

Для этого не существует волшебного аппаратного решения. Например, нет механизма синхронизации регистра между потоками. Для процессора переменная и регистр - это совершенно разные сущности - он не знает, что их нужно синхронизировать. Безусловно, синхронизация между регистрами в разных потоках или на разных ядрах отсутствует - нет оснований полагать, что другой поток использует один и тот же регистр для той же цели в любое конкретное время.

Частичное решение - пометить переменную как "volatile" ...

void myfunc ()
{
  volatile static int i;
  //...
  nested_call ();
  //...
}

Это говорит компилятору не оптимизировать чтение и запись в переменную. У процессора нет понятия волатильности. Это ключевое слово говорит компилятору генерировать другой код, делая немедленные чтения и записи в память, как указано в присваиваниях, вместо того, чтобы избегать такого доступа с помощью регистра.

Это не решение для многопоточной синхронизации, однако - по крайней мере, само по себе. Одним из подходящих многопоточных решений является использование какой-либо блокировки для управления доступом к этому «общему ресурсу». Например ...

void myfunc ()
{
  static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Здесь происходит больше, чем сразу очевидно. В принципе, вместо того, чтобы записывать значение «i» обратно в его переменную, готовое к вызову «release_lock_on_i», его можно сохранить в стеке. Что касается компилятора, это не лишено смысла. В любом случае он осуществляет доступ к стеку (например, сохраняет адрес возврата), поэтому сохранение регистра в стеке может быть более эффективным, чем запись его обратно в «i» - более дружественный к кэшу, чем доступ к совершенно отдельному блоку памяти.

К сожалению, однако, функция блокировки освобождения не знает, что переменная еще не записана обратно в память, поэтому ничего не может сделать, чтобы это исправить. В конце концов, эта функция - просто вызов библиотеки (настоящий релиз блокировки может быть скрыт в более глубоко вложенном вызове), и эта библиотека могла быть скомпилирована за годы до вашего приложения - она ​​не знает как его вызывающие используют регистры или стек. Это большая часть того, почему мы используем стек и почему соглашения о вызовах должны быть стандартизированы (например, кто сохраняет регистры). Функция блокировки разблокировки не может заставить абонентов «синхронизировать» регистры.

Точно так же вы можете связать старое приложение с новой библиотекой - вызывающая сторона не знает, что и как делает release_lock_on_i, это просто вызов функции. Он не знает, что сначала нужно сохранить регистры обратно в память.

Чтобы решить эту проблему, мы можем вернуть "volatile".

void myfunc ()
{
  volatile static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Мы можем временно использовать обычную локальную переменную, когда блокировка активна, чтобы дать компилятору возможность использовать регистр для этого краткого периода. В принципе, однако, блокировка должна быть снята как можно скорее, поэтому там не должно быть так много кода. Если мы это сделаем, мы записываем нашу временную переменную обратно в «i» перед снятием блокировки, а изменчивость «i» гарантирует, что она записывается обратно в основную память.

В принципе этого недостаточно. Запись в основную память не означает, что вы записали в основную память - между слоями прослеживается кеш-слой, и ваши данные могут некоторое время находиться в любом из этих слоев. Здесь есть проблема с «барьером памяти», и я не очень разбираюсь в этом - но, к счастью, эта проблема связана с вызовами синхронизации потоков, такими как вызовы блокировки и освобождения, указанные выше.

Однако эта проблема с барьером памяти не устраняет необходимость в ключевом слове volatile.

12 голосов
/ 05 марта 2010

Регистры ЦП - это небольшие области хранения данных на кремнии ЦП. Для большинства архитектур они являются основным местом, где выполняются все операции (данные загружаются из памяти, обрабатываются и выталкиваются обратно).

Независимо от того, какой поток запущен, он использует регистры и владеет указателем инструкций (который говорит, какая инструкция последует). Когда операционная система переходит в другой поток, все состояние процессора, включая регистры и указатель инструкций, где-то сохраняются, эффективно замораживая состояние потока, когда он снова возвращается к жизни.

Множество документации по всему этому, конечно же, повсюду. Википедия по регистрам. Википедия по переключению контекста. для начинающих. Изменить: или прочитать ответ Steve314. :)

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...