Что такое регистры ЦП и как они используются, особенно многопоточность WRT?

StackOverflow https://stackoverflow.com/questions/2384578

Вопрос

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

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

Это было полезно?

Решение

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

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

Много дополнительной документации на все это, конечно, повсюду. Википедия на регистрах. Википедия на контексте Переключение. для начинающих. Редактировать: или прочитайте ответ STEVE314. :)

Другие советы

Регистры — это «рабочая память» процессора.Они очень быстрые, но очень ограниченный ресурс.Обычно ЦП имеет небольшой фиксированный набор именованных регистров, причем имена являются частью соглашения языка ассемблера для машинного кода этого ЦП.Например, 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 лет.Но главное в принципе.

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

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

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

Обычно предполагается, что локальные переменные в функции находятся в стеке.Это общее правило для «авто» переменных в 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» не требуется, и на первый взгляд, к переменной можно получить доступ из любого потока, работающего на любом ядре (или даже на отдельном CPU).

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

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

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

Частичное решение — пометить переменную как «изменчивую»...

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», это всего лишь вызов функции.Он не знает, что сначала ему необходимо сохранить регистры обратно в память.

Чтобы решить эту проблему, мы можем вернуть «изменчивость».

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

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

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

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

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top