Рассмотрим многоядерный процессор ARM. Один поток модифицирует блок машинного кода, который может выполняться одновременно другим потоком. Модифицирующий поток выполняет следующие виды изменений:
Для потока, пишущего код, я понимаю, что достаточно сделать окончательную запись с std::memory_order_release
в C ++ 11.
Однако не ясно, что делать на стороне потока исполнителя (это выходит из-под контроля, мы просто контролируем блок машинного кода, который пишем). Должны ли мы написать некоторый барьер инструкций перед первой инструкцией изменяемого блока кода?
Я не думаю, что ваша процедура обновления безопасна. В отличие от x86, кэши инструкций ARM не согласуются с кэшем данных, согласно этому запись блога с самоизменяющимся кодом.
Первую инструкцию без перехода все еще можно кэшировать, чтобы другой блок мог войти в блок. Когда выполнение достигает 2-й строки i-кеша блока, возможно, он перезагружается и видит частично измененное состояние.
Есть и другая проблема: прерывание (или переключение контекста) может привести к выселению / перезагрузке строки кэша в потоке, который все еще находится в середине выполнения старой версии. Перезапись блока инструкций на месте требует, чтобы вы были уверены, что выполнение во всех других потоках вышло из блока после того, как вы изменили что-то, чтобы новые потоки не входили в него. Это проблема даже для связного I-кэша (например, x86), и даже если блок кода помещается в одну строку кэша.
Я не думаю, что есть какой-то способ сделать переписывание на месте одновременно безопасным и эффективным на ARM.
Без согласованных I-кешей вы также не можете гарантировать, что другие потоки будут видеть изменения кода быстро с этой конструкцией, без смехотворно дорогих вещей, таких как очистка блоков из кэша L1I перед каждым их запуском.
С когерентным I-кешем (стиль x86) вы можете просто ждать достаточно долго для любой возможной задержки в другом потоке, заканчивающем выполнение старой версии. Даже если блок не выполняет никаких операций ввода-вывода или системных вызовов, возможны ошибки кэширования и переключения контекста. Если он работает с приоритетом в реальном времени, особенно с отключенными прерываниями, то худший кеш — это просто пропуски кеша, то есть не очень длинные. В противном случае я бы не стал делать ставку на что-то меньшее, чем один или два таймслея (может быть, 10 мс), которые были бы в безопасности
На этих слайдах представлен хороший обзор ARM-кешей, в основном с ARMv8..
Я на самом деле собираюсь процитировать еще один слайд (о виртуализации ARM) для этого краткого описания, но я бы рекомендовал читать слайды ELC2016, а не слайды виртуализации.
Программное обеспечение должно знать о кеше в некоторых случаях: загрузка / генерация исполняемого кода
- Требуется очистка D-кэша до точки объединения + аннулирование I-кэша
- Возможно из пространства пользователя на ARMv8
- Требуется системный вызов на ARMv7
D-кеш может быть аннулирован с обратной записью или без нее (поэтому убедитесь, что вы очищаете / очищаете, а не сбрасываете!). Вы можете и должны вызывать это по виртуальному адресу (вместо очистки всего кэша за раз, и, безусловно, не используйте для этого очистку с помощью set / way).
Если вы не очистили свой D-кэш до аннулирования I-кэша, выборка кода может извлекаться непосредственно из основной памяти в некогерентный I-кэш после пропуска в L2. (Без выделения устаревшей строки в любых унифицированных кешах, которые МЭСИ будет предотвращать, потому что L1D имеет линию в измененном состоянии). В любом случае, очистка L1D для PoU архитектурно необходима, и в любом случае происходит в потоке писателя, не являющегося критичным, поэтому, вероятно, лучше просто сделать это, а не пытаться понять, безопасно ли это делать для конкретной микроархитектуры ARM. Смотрите комментарии к попыткам @ Notlikethat прояснить мою путаницу в этом.
Подробнее об очистке I-кэша из пространства пользователя см. Как очистить и аннулировать кэш процессора ARM v7 из режима пользователя в Linux 2.6.35. ССЗ __clear_cache()
функция и Linux sys_cacheflush
работать только с областями памяти, которые были mmap
пед с PROT_EXEC
,
Там, где вы планировали иметь целые блоки кода инструментов, поместите один косвенный переход (или сохранение / восстановление lr
и вызов функции, если у вас все равно будет филиал). Каждый блок имеет свою собственную целевую переменную перехода, которая может быть обновлена атомарно. Ключевым моментом здесь является то, что пункт назначения для косвенного прыжка данные, так что это связно с магазинами из пишущей ветки.
Поскольку вы обновляете указатель атомарно, потребительские потоки либо переходят к старому или новому блоку кода.
Теперь ваша проблема заключается в том, чтобы убедиться, что ни одно ядро не имеет устаревшей копии нового местоположения в своем i-кэше. Учитывая возможности переключений контекста, это включает текущее ядро, если переключатели контекста не полностью очищают i-кеш.
Если вы используете достаточно большой кольцевой буфер локаций для новых блоков, чтобы они оставались неиспользованными достаточно долго, чтобы их можно было выселить, на практике это может оказаться невозможным. Это звучит невероятно трудно доказать, хотя.
Если обновления происходят редко по сравнению с тем, как часто другие потоки запускают эти динамически модифицированные блоки, вероятно, это достаточно дешево, чтобы иметь поток публикации триггеров кеша в других потоках после написания нового блока, но до обновление указателя косвенного перехода для указания на него.
Заставить другие потоки очистить кеш:
Linux 4.3 и выше имеет membarrier()
системный вызов он будет запускать барьер памяти на всех других ядрах системы (обычно с межпроцессорным прерыванием) до его возврата (таким образом, перекрывая все потоки всех процессов). Смотрите также этот блог описание некоторых сценариев использования (например, пользовательское пространство RCU) и mprotect()
как альтернатива.
Однако он не поддерживает очистку кэшей инструкций. Если вы собираете собственное ядро, вы можете рассмотреть возможность добавления поддержки нового cmd
или же flag
значение, которое означает очистку кэшей инструкций вместо (или также) запуска барьера памяти. Возможно, flag
значение может быть виртуальным адресом? Это будет работать только на архитектурах, где адрес вписывается в int
, если вы не настроите API системного вызова для просмотра полной ширины регистра flag
для вашего нового CMD, но только int
значение для существующего MEMBARRIER_CMD_SHARED
,
Помимо взлома membarrier (), вы могли бы посылать сигналы потокам потребителя, а их обработчики сигналов сбрасывали соответствующую область i-cache. Это асинхронно, поэтому поток производителя не знает, когда безопасно повторно использовать старый блок.
ИДК, если munmap()
Это будет работать, но, вероятно, дороже, чем необходимо (потому что он должен изменить таблицы страниц и сделать недействительными соответствующие записи TLB).
Вы могли бы что-то сделать, опубликовав монотонно увеличивающийся порядковый номер в разделяемой переменной (с семантикой выпуска, поэтому она упорядочена по сравнению с записью инструкций). Затем потребительские потоки проверяют порядковый номер на соответствие локальному потоку и делают недействительным i-cache, если есть новые вещи. Это может быть для каждого блока или глобального.
Это напрямую не решает проблему определения того, когда последний поток, выполняющий старый блок, покинул его, если только эти наиболее часто встречающиеся счетчики не являются локальными для потока: все еще для потока, но поток производителя может посмотреть на их. Он может сканировать их на предмет наименьшего порядкового номера в любом потоке, и, если он превышает порядковый номер, когда на блок не ссылались, теперь он может быть использован повторно. Будьте осторожны с ложный обмен: не используйте глобальный массив unsigned long
для этого, потому что вы хотите, чтобы приватная переменная каждого потока находилась в отдельной строке кэша с другими компонентами локального потока.
Другой возможный метод: если есть только один потребительский поток, производитель устанавливает указатель цели перехода так, чтобы он указывал на блок, который не изменяется (поэтому не нужно очищать i-cache). Этот блок (который выполняется в потоке-получателе) выполняет очистку кеша для соответствующей строки i-cache, а затем снова модифицирует указатель цели перехода, на этот раз, чтобы указать на блок, который должен выполняться каждый раз.
С несколькими потоками потребителей это становится немного неуклюжим: возможно, у каждого потребителя есть свой собственный частный указатель цели перехода, и производитель обновляет их все?
Других решений пока нет …