Вопрос или проблема
В системе с двумя ядрами ядру 0 необходимо прерывать ядро 1 по нескольким причинам. Для передачи как причины, так и данных прерывания используется перечисление, связанное с объединением. Код написан на C.
#include <stdatomic.h>
typedef enum { // причина прерывания, также указывает, какой член действителен в объединении
kData0,
// ...
} InterruptId;
typedef struct {
_Atomic InterruptId interruptId;
union { // данные, связанные с interruptId
int data0;
// ...
};
} SharedMemory;
// ядро 0
void InterruptCore1(); // записывает некоторые регистры в ARM GIC для прерывания ядра 1
void PassData0ToCore1(SharedMemory *mem, int data0) {
mem->data0 = data0;
atomic_store_explicit(&mem->interruptId, kData0, memory_order_release);
InterruptCore1();
}
// ядро 1
void ProcessData0(int data0);
void OnCore0Interrupt(SharedMemory *mem) {
InterruptId id =
atomic_load_explicit(&mem->interruptId, memory_order_acquire);
switch (id) {
case kData0:
ProcessData0(mem->data0);
break;
}
}
Указанный выше код кажется неправильным, так как ядро 1 может увидеть устаревшее значение interruptId. Насколько я понимаю, порядок Release-Acquire не гарантирует, что загрузка может считать самое последнее значение. Может быть, atomic_thread_fence — это решение в данной ситуации.
```c
typedef struct {
InterruptId interruptId;
union {
int data0;
// ...
};
} SharedMemory;
// ядро 0
void InterruptCore1(); // записывает некоторые регистры в ARM GIC для прерывания ядра 1
void PassData0ToCore1(SharedMemory *mem, int data0) {
mem->data0 = data0;
mem->interruptId = kData0;
atomic_thread_fence(memory_order_release);
InterruptCore1();
}
// ядро 1
void ProcessData0(int data0);
void OnCore0Interrupt(SharedMemory *mem) {
atomic_thread_fence(memory_order_acquire);
InterruptId id = mem->interruptId;
switch (id) {
case kData0:
ProcessData0(mem->data0);
break;
}
}
И версия с release, и с acquire atomic_thread_fence компилируются в dmb ish, что кажется правильным на машинном уровне. Но потом я читаю версию C++ atomic_thread_fence и обнаруживаю, что она также требует использования атомарных переменных, и синхронизация работает только в том случае, если загрузка действительно считывает высвобожденное значение. Значит ли это, что второе решение имеет ту же проблему, что и первое (ядро 1 может считать устаревшее значение)? Как гарантировать, чтобы ядро 1 могло считать самое последнее значение? Большое спасибо.
Интересно. Когда одно ядро прерывает другое, это редкий случай, когда действительно имеет смысл говорить о "последнем значении" вместо просто порядка (см. "Гарантирует ли атомарное чтение считывание последнего значения?"). Но, подумав еще раз, мы можем говорить об этом в терминах порядка. Вы хотите создать отношение happens-before между InterruptCore1(); в одном ядре и обработчиком прерываний в другом ядре, который будет запускать OnCore0Interrupt. Поскольку межпроцессорные прерывания не являются операциями с памятью (не говоря уже о том, что они не являются частью std::atomic), вы не можете получить то, что хотите, с помощью std::atomic_thread_fence или других барьеров памяти. Я думаю, ключевым моментом является ожидание, пока запись станет глобально видимой (зафиксированной в кэше) перед тем, как прервать другое ядро. Если вы этого не сделаете, никакой барьер на стороне чтения не сможет знать, что он должен ждать появления какой-то конкретной записи. Барьеры упорядочивают операции в одном потоке относительно друг друга (ожидая завершения более ранних операций перед разрешением более поздних операций). Это не помогает, когда запись, которую вы ожидаете, все еще находится в процессе (в буфере хранения) на другом ядре. Поэтому вам нужен dsb ish или dsb sy на ядре писателе. Возможно, также и dmb ish? Я не так хорошо знаю барьеры ARM, я просто гуглил и нашел "Реальные примеры использования барьеров (DSB, DMB, ISB) в ARM", которое подводит итог: "DSB — когда доступ к памяти должен завершиться перед тем, как программа будет продолжать выполняться". Вероятно, эксперт по ARM может опубликовать ответ (или отредактировать это с новым разделом) о деталях и если asm volatile("dsb sy" ::: "memory") после записи, перед InterruptCore1() достаточно на бумаге. Используя только функции std::atomic стандартов ISO C/C++, вы можете заставить читателя ожидать последовательный номер, который вы храните в писателе в отдельной переменной, но это хуже, чем заставить писателя использовать другой барьер, чтобы убедиться, что запись видима для загрузок на других ядрах.
И синхронизация работает только в том случае, если загрузка действительно считывает высвобожденное значение.
Это одинаково в C и C++, разницы между ними нет.
Насколько я понимаю, порядок Release-Acquire не гарантирует, что загрузка может считать самое последнее значение.
Все порядки памяти гарантируют, что все потоки видят "самое последнее" записанное значение для всех атомарных объектов. Различия возникают из-за того, какое состояние вы можете видеть в других объектах, т.е. можно ли переупорядочить записи в другие объекты относительно атомарных чтений или записей.
Ответ или решение
Вам необходимо гарантировать, что при прерываниях одного ядра другим в многопроцессорной системе значения, которые были записаны до прерывания, будут корректно переданы между ядрами. Проблема заключается в том, что стандартные механизмы управления памятью и порядок операций могут не обеспечить видимость обновлений в глобальной памяти в нужное время.
Проблема
В приведенном вами коде используется stdatomic
для управления доступом к разделяемой памяти между ядрами. Однако, просто использование атомарных операций с использованием порядка памяти release/acquire
может не гарантировать, что второе ядро (core1) всегда увидит актуальные данные, записанные перед тем, как произошло прерывание.
Решение
Для решения этой проблемы необходимо учесть, что прерывание одного ядра другим не является операцией над памятью, и следовательно стандартные механизмы синхронизации, такие как atomic_thread_fence
, могут не обеспечивать правильную видимость данных.
Вместо этого предлагаю использовать низкоуровневые механизмы архитектуры ARM для обеспечения правильного порядка выполнения операций:
Обновленный код
#include <stdatomic.h>
typedef enum {
kData0,
// ...
} InterruptId;
typedef struct {
_Atomic InterruptId interruptId;
union {
int data0;
// ...
};
} SharedMemory;
// core0
void InterruptCore1(); // write some registers in ARM GIC to interrupt core1
void PassData0ToCore1(SharedMemory *mem, int data0) {
mem->data0 = data0;
atomic_store_explicit(&mem->interruptId, kData0, memory_order_release);
// Убедимся, что все записи видны перед вызовом прерывания
__asm__ volatile ("dmb ish" ::: "memory");
InterruptCore1();
}
// core1
void ProcessData0(int data0);
void OnCore0Interrupt(SharedMemory *mem) {
// Убедимся, что загрузка данных происходит после всех записей
__asm__ volatile ("dmb ish" ::: "memory");
InterruptId id = atomic_load_explicit(&mem->interruptId, memory_order_acquire);
switch (id) {
case kData0:
ProcessData0(mem->data0);
break;
}
}
Комментарий к обновленному решению
-
Механизм
dmb ish
: Этот низкоуровневый барьер гарантирует, что все предыдущие операции записи (в данном случае запись значения в структуруSharedMemory
) завершены и видимы другим ядрам. С помощью такого барьера вы обеспечиваете видимость сохраненных данных в момент прерывания. -
Порядок операций: Использование
memory_order_release
на записьinterruptId
иmemory_order_acquire
на его чтение гарантирует, что все операции, выполненные до вызоваInterruptCore1()
, будут завершены до того, как core1 увидит обновленныйinterruptId
.
Заключение
Ваша задача состоит в том, чтобы установить "связь состояний" между двумя ядрами. Применение низкоуровневых барьеров памяти, совместно с атомарными операциями из <stdatomic.h>
, может быть наиболее надежным способом обеспечить, что core1 всегда будет видеть актуальную информацию, переданную из core0 через механизм прерываний.