Счетчики ссылок на объект. Без блокировок для multitask/SMP (ESP32, ESP32-S3)

Спсб за разъяснения

Я так понял, это имеет место быть только когда есть более чем одно ядро?

1 лайк

А как же прерывания? Думаю это имеется ввиду в отношении однопоточного цпу.

1 лайк

Вроде как и на одноядерном будет полегче (но гарантий нет) - задачи переключаются поочереди и прямо так, чтобы параллельно - исполняться не будут.

Возможный сценарий: например, одна задача делает А=1, а потом происходит переключение на другую, то там А==1 не гарантировано. Т.е. оно конечно станет единичкой, но не мгновенно. Поэтому какое-то количество времени точка зрения задачи 1 и точка зрения задачи 2 на переменную A будут различаться.А может и не случиться. На x86 не случится. На PowerPC - может. Лучше не рисковать :slight_smile:

На одноядерном многие проблемы решаются просто объявлением переменной volatile. На многоядерном этого недостаточно.

Спойлер

Я сам так делаю и всем рекомендую: когда пишите какую-нибудь функцию в многозадачном коде, всегда задавайте себе вопрос: “а что произойдет, если моя задача будет прервана в строке 123 и процессор переключится на другую задачу?“. А когда многие задачи на многоядерном: “а что произойдет, если такой-то код будет выполнятся одновременно на двух ядрах с небольшой задержкой относительно друг-друга?“ Если возникли сомнения - используйте мутекс.

Если у вас, допустим, есть счетчик (число. 8,16 или 32 бита) , который инкрементируется в трех разных задачах, а читается другими задачами, то нужно обложить чтение\запись этого счетчика мутексами. Вроде как полагается - доступ к shared ресурсу (счетчику) должен быть синхронизирован. Но вот в случае со счетчиками всякими можно опять же обойтись и без мутексов: использовать атомики (atomic_load, atomic_store, atomic_fetch_sub, atomic_fetch_add)

1 лайк

Я всегда думал, что на одноядерном проце, в один момент времени может “видеть” только что-то одно

я не смог навскидку придумать ситуацию, в которой можно продемонстрировать. Так что будем считать, что на одноядерном - попроще :). На ESP32 (двухъядерном) шедулер по умолчанию шедулит задачи на все доступные ядра. Т.е. задача может исполняться то на одном, то на другом ядре. Можно, конечно, при создании задачи жестко ее пришпилить к ядру конкретному и токльо там она и будет исполняться.

Да, @BOOM прав, может не успеть стать 1, как прерывание случится…

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

P.S. Хотя совместить прерывания и многозадачность это ещё уметь надо…

1 лайк

Вот, нашел в ESP-IDF для ESP32-S3 такие строки:

#define XCHAL_NUM_LOADSTORE_UNITS 1 /* load/store units */              <--- один LS блок
#define XCHAL_NUM_WRITEBUFFER_ENTRIES 4 /* size of write buffer */      <--- store buffer на 4 записи
#define XCHAL_INST_FETCH_WIDTH    4 /* instr-fetch width in bytes */
#define XCHAL_DATA_WIDTH    16  /* data width in bytes */
#define XCHAL_DATA_PIPE_DELAY   1 /* d-side pipeline delay(1 = 5-stage, 2 = 7-stage) */

#include <Arduino.h>
#include <atomic>

// Простейший тест на разделяемую память
std::atomic<int> shared{0};
std::atomic<bool> running{true};

void core1_task(void* p) {
while(running) {
shared.store(1, std::memory_order_relaxed);
delayMicroseconds(10);
}
vTaskDelete(NULL);
}

void setup() {
Serial.begin(115200);
delay(3000);
Serial.println("Минимальный тест visibility");
xTaskCreatePinnedToCore(core1_task, "Core1", 1024, NULL, 1, NULL, 1);
int stale_reads = 0;
for(int i = 0; i < 1000; i++) {
int val = shared.load(std::memory_order_relaxed);
if(val == 0) { stale_reads++;
Serial.print("Core0 прочитал УСТАРЕВШЕЕ значение! #");
Serial.println(stale_reads); } delay(1); }
running = false; delay(100);
Serial.print("Итог: "); Serial.print(stale_reads);
Serial.println(" устаревших чтений из-за отсутствия барьеров");
}

void loop() {}

ЫЫ говорит этот пример все покажет, но можно ли ему верить ?)))

Один интересный пример приводится самой Интел, в их описании архитектуры x86-64:

Рассматривают следующий код (две переменные, X и Y изначально равны 0):

Ядро #0      | Ядро #1
-------------+----------------
mov [X], 1   |  mov [Y], 1
mov eax, [Y] |  mov ebx, [X]

Для тех, кто не знаком с ассемблером Intel, операция [X] означает “содержимое памяти по адресу X“ или, иначе, “значение переменной X“; EAX и EBX - регистры общего назначения.

Компилятор сгенерировал код приведенный выше и сгенерировал его так, как нам и хотелось. Вопрос: что будет в регистрах EAX и EBX после того как обе задачи закончили выполнение? Логично предположить, что хотя бы один из регистров (eax или ebx) будет содержать 1. А может быть и оба.

Но на самом деле, из миллиона итераций, пару сотен раз происходит странное: оба регистра EAX и EBX равны нулю (!). Как это вообще возможно? Возможно, и есть даже код, который демонстрирует эту странность на Intel: Win и POSIX версии.

Происходит так потому, что иногда процессор исполняет код так:

Ядро #0      | Ядро #1
-------------+----------------
mov eax, [Y] |  mov ebx, [X]
mov [X], 1   |  mov [Y], 1

Переставляет запись и чтение из памяти.

С точки зрения CPU операция чтения и следующая за най операция записи никак не связаны между собой, поэтому процессор “имеет право переставлять LOAD после STORE, если они не зависят друг от друга“.

Что процессор иногда и делает.

В результате получаем ноль в обоих регистрах. В коде-тесте по ссылке выше, на Win32, средние значения были около 80 перестановок на 500000 итераций.

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

К сожалению, именно для ESP32 эта информация клнфиденциальная. Что-то утекло в сеть, конечно, код кое-какой тоже утек. Espressif не имеет права публиковать ничего из этого, кроме своих расширений. Свои расширения (TIE/PIE Instructions, векторыне и прочие) они опубликовали, со скрипом.

Для интела - навалом. Да и для других процессоров тоже. ESP32 уникален тем, что ежегодно продается 1 млрд этих чипов, а документацию приходится искать с факелами. И ИИ тут не помошник - сплошные галлюцинации на тему ESP32

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

Ну а если буквально “ровно так, как написал” - отключай оптимизатор и будет тебе счастье.

Насчет задач - не скажу, а отдельные инструкции именно параллельно и выполняются. Называется конвейер. Вот именно для конвейера бывает целесообразно изменить порядок выполнения инструкций.

Мне кажется, для Интела последним процессором, для которого существовала открытая подробная документация, был Pentium. Хотя, возможно, я и отстал от жизни.

Так он до сих пор и остался (честных 66 МHz), всё остальное маркетинг про их гигагерцы с каруселью рециклинга. Пока матери медные, как бы не были они правильно разведены, выше 33MHz по периферии физика не позволяет. А переходить на оптику производители пока не торопятся. А зачем, пока покупают, вкладывать ни кто не собирается в разработку.

Так иногда и делаю, на время, для диагностики. Т.е. если сбой после отключения оптимизатора пропадает, значит я где-то невнятно написал, и, оптимизатор “исправил”.
Хорошо бы, если так же можно и в процессоре вкл./ выкл. эту опцию, но , пока, не нашёл об этом.

Ну, может, конечно, и остается…
Только во что такое “честные 66 МГц”? Этот тот, который 5-вольтовая “печка”?
И вообще, какое отношение имеет частота внешней шины к частоте “камня”? И при чем здесь “честность”.
Кстати, архитектура Pentium II существенно отличается от Pentium. Причем, изменение порядка выполнения инструкций начинается как раз со “второго”, в “первом” этого не было.

Вот тут есть немножко. Последний апдейт от 2025 года. Про все кишки написано, про конвееры и прочее. Глава номер 2 как раз on-topic

Занимательное чтиво, жалко у меня его не было в 2007 году :))

1 лайк

Есть такая старая книжка, The Art of Multiprocessor Programming, которая будет интересна, может быть, ЕвгениюП и @MMM . Первому - за академичность и широту охвата материала а второму - за Си++.

Вот перевод одной из глав, вернее, начала главы.

B.7.1 “Расслабленная :)” согласованность памяти (Relaxed memory consistency)

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

На большинстве современных процессоров запросы на запись не применяются к памяти сразу в момент их выполнения. Вместо этого они помещаются в аппаратную очередь, называемую буфером записи (write buffer или store buffer), и затем применяются к памяти совместно, позднее.

Буфер записи даёт два преимущества. Во-первых, часто эффективнее выполнять сразу несколько запросов вместе (т.н. batching). Во-вторых, если поток записывает данные по одному и тому же адресу несколько раз, более ранние запросы могут быть отброшены, что позволяет избежать лишнего обращения к памяти (т.н. write absorption).

Использование буферов записи имеет очень важное следствие: порядок, в котором операции чтения и записи реально выполняются в памяти, не обязательно совпадает с порядком, в котором они записаны в программе.

2 лайка

Всё это хорошо, конечно, жаль только, что не предусматривается команда, (возможно я не нашёл ) чтобы можно было отменить, по желанию программиста, эти “навороты”, если, и, когда это надо.))

1 лайк

Это нельзя отменить, это способ работы процессора. То есть это не “поверх”, это “внутри”.

Попытался придумать аналогию:
Как бы при проезде из точки А в точку В отменить навороты с проездом через мост С с целью сокращения маршрута.

1 лайк