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

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

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

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

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

Возможный сценарий: например, одна задача делает А=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)

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

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

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

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

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

Вот, нашел в 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 году :))

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

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

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

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

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

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

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

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

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

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