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

Что такое refcounter и зачем он нужен

Когда программа работает с объектами динамически (через “malloc”, “new”, всякие фабрики и т.п.), рано
или поздно возникает вопрос:

А когда, собственно, объект можно безопасно удалить?

В однозадачном коде ответ простой: удаляем там, где он больше не нужен и для решения этой задачи
часто применяют refcounter (reference counter).

Refcounter - это счётчик, связанный с объектом/структурой (обычно это член структуры (Си) или класса (Си++)) и показывающий,
сколько “пользователей” сейчас используют этот объект.

На refcounters реализованы “shared_ptr” в Си++, например, поэтому, если вы пишете на Си++, то используйте “shared_ptr”. Читать дальше можно или из академического интереса или в попытке сделать чуть быстрее, чем уже сделано :slight_smile:

Идея очень простая:

  • объект создаётся с “ref = 1”
  • каждый новый пользователь объекта делает “ref++”
  • когда пользователь закончил работу - делает “ref–”
  • когда “ref” становится равным 0, объект удаляется

Объект будет жить, пока он кому-то нужен

Примерчик (одна задача, одно ядро). Примерчик на Си, хотя на Си++ все это было бы компактнее и проще.

struct Object {
    int ref;
    int data;
};

void addref(struct Object *o) {
    o->ref++;
}

void unref(struct Object *o) {
    o->ref--;
    if (o->ref == 0)
        free(o);
}

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

Пока “ref > 0” - объект существует, а вот когда последний пользователь вызывает
“unref()” - память освобождается. И в одной задаче все прекрасно работает.

В многозадачном коде всё чуть усложняется из-за того, что объект может использоваться несколькими
задачами одновременно. Что произойдет, если одна задача вызовет ref++ тогда как вторая задача вызовет ref–?
Что, если ref-- выполнится раньше и вызовет удаление объекта, а другая задача будет делать obj->ref++ не подозревая,
что указатель на объект уже мертвенький?

Обычно тут говорят: “давайте сделаем mutex или semaphore, который и будет нас защищать”. И так и делают, и работает:

struct Object {
    int ref;
    int data;
    mutex_t mux;
};

void addref(struct Object *o) {
    mutex_acquire(o->mux);
    o->ref++;
    mutex_release(o->mux);
}

void unref(struct Object *o) {
    mutex_acquire(o->mux);
    o->ref--;
    if (o->ref == 0)
        free(o);
    mutex_release(o->mux);
}

Мутекс тут не только защищает переменную →ref, но и выполняет важную функцию - дожидается отложенных записей в память сделаных разными ядрами. Но все это скрыто от глаз программиста. Что произойдет теперь, если две задачи попытаются одновременно сделать ref– и ref++? Одна из задач успеет захватить мутекс а вторая - повиснет на этом мутексе. Шедулер операционки (FreeRTOS) поместит задачу повисшую на мутексе в список приостановленных задач и переключится на следующую. Задач будет заблокирована минимум на несколько тиков операционной системы (1 тик = 1 миллисекунда).

А можно без мутексов? Без блокировок задач, без всего вот этого?

Можно. Все с теми же нашими любимыми атомиками :slight_smile:

Если несколько задач на нескольких ядрах одновременно увеличивают и уменьшают счётчик, операции типа “++” и “–”
становятся неоднозначными. Поэтому сам счетчик сделаем атомарным:

#include <stdatomic.h>

typedef _Atomic(unsigned int) refc_t;  // в Си++ см. std::atomic и всякие atomic<unsigned int> 

// Какой-то объект со счетчиком ссылок и мембером data
struct Object {
    refc_t ref;
    int    data;
};

Теперь исправим addref() и unref() так, чтобы доступ к ->ref был атомарным:

// Увеличить счетчик ссылок r на 1.
// Возвращает true = можно работать с объектом
// Возвращает false = объект уже умер, пока вы делали addref(), объектом пользоваться нельзя
//
bool addref(refc_t *r) {


    // Загружаем текущее значение счетчика ссылок в ref
    unsigned int ref = atomic_load_explicit(r, memory_order_relaxed);

    // Проверяем, что (ref + count) <= 2^32
    if (ref == (unsigned int )(-1))
      abort();

    // Пытаемся увеличить r на единичку, достаточно упорно.
    do {

      // Если ref в какой-то момент упадет до нуля, значет объект 
      // умер, пока мы пытались прибавить единичку.
      if (ref < 1)
        return false;

    } while (!atomic_compare_exchange_weak_explicit( 
                                              r,                      // куда писать
                                              &ref,                   // ..только если значение не поменялось
                                              ref + 1,                // что писать
                                              memory_order_acquire,
                                              memory_order_relaxed));
    // Ура. Счетчик увеличен, объектом можно пользоваться
    return true;
}


// Уменьшение счетчика ссылок на единичку
// и удаление объекта, если счетчик равен нулю. Можно добавить еще один аргумент - деструктор.
// Ну или переписать на Си++
//
void unref(refc_t *r, void *object) {

    // Уменьшаем счетчик на 1. Если он и был 1 до этого, то
    if (atomic_fetch_sub_explicit(r, 1, memory_order_release) == 1) { 

      // дожидаемся всех отложенных записей в object
      atomic_thread_fence(memory_order_acquire);

      // теперь удаляем
      free(object);
    }
}

Типичный шаблон использования выглядит так:

if (addref(&obj->ref)) {
    // безопасно работаем с объектом
    ...
    unref(&obj->ref, obj);
}

или

// посылаем объект куда-то. получатель удалит объект вызовом unref()
if (addref(&obj->ref)) {
    send_object_to_task1(obj); 
}

Тадам. Получилось отложенное удаление без блокировок :). Простенькое, но кому надо - тот сам добавит фичи по вкусу.

Не уверен, что кому-то пригодится, но кто его знает. Если будете писать сетевые драйвера или ковырять tcp/ip стек - пригодится.
Там все пакеты данных со счетчиками

Раз уж тема, вроде как обучающая, решил таки спросить:

Теоретически, никак не пойму, в чём проблема?

  1. объект создаётся с “ref = 1”
  2. новый пользователь делает “свой” ++
  3. закончив работу, этот пользователь “свой” же ++ , и , удаляет(–).
  4. если пользователь не может два раза в подряд сделать --, или , сделать --, не сделав, перед этим++ - откуда тогда сбой возьмётся?

В моем коде нет мутексов и семафоров. Запомним пока этот факт.

Теперь чуток теории.

Предположим вы пишете у себя в коде следующее:

struct Passenger {
  int age;
  int weight;
  const char *passport_id;
  const char *name;
};

Структура описывает какого-то пассажира. Там есть вес, возраст и пара полей, которые будут аллокированы потом: в одном будет строчка с паспортными данными, во втором - ФИО.

И вот мы пишем что-то типа:

struct Passenger *GlobalPtr;  // Глобальная переменная-указатель на объект.
bool ready = false;           // Объект еще не готов.


// Для простоты убраны проверки на NULL поинтер

void create_passenger(const char *pid, const char *name) {


  GlobalPtr = (struct Passenger *)malloc(sizeof(*GlobalPtr));

  GlobalPtr->age = 47;
  GlobalPtr->weigh = 61;
  GlobalPtr->passport_id = strdup(pid);
  GlobalPtr->name = strdup(name);

  ready = true;
}

Так же у нас есть еще одна задача (печатает билеты для пассажиров в фоне), которая ждет флага готовности “ready“, и , как только объект “готов“, мы начинаем печатать билет:

void printer_task( void *foo) {
  // ждем готовности
  while(ready == false)
    delay(1);
  // Дождались, объект готов,
  ...
  ...    
}

Вопрос: Может ли произойти нечто такое, что задача увидит что флаг ready == true, а половина полей в объекте вообще не инициализированна?

Ответ: Да, к сожалению может. И для этого даже не нужна многопроцессорная система - достаточно двух задач на одном CPU.

Почему?

Ответ вообще-то очень длинный, но я попробую в двух словах.

Когда вы пишите

  GlobalPtr->age = 47;
  GlobalPtr->weigh = 61;
  GlobalPtr->passport_id = strdup(pid);
  GlobalPtr->name = strdup(name);

  ready = true;

то для вас очевидно, что строки программы выполняются по-порядку, сверху вниз :). Это не очевидно для компилятора. И , что еще хуже, это не очевидно для CPU. Поэтому, компилятор МОЖЕТ переставить строчки: например, он может сначала присвоить ready = true, а уж потом - все остальное. С точки зрения компилятора - ready это обычная переменная, ни с чем не связанная. Однозадачная программа не заметит разницы

Языки Си и Си++ говорят, что такое поведение компилятора - законно. Примем это как данность.

Может ли переставить операции сохранения\чтения сам процессор? Еще как. Он это делает постоянно, чтобы обеспечивать высокую производительность. Процессор вообще с памятью работает двумя очередями: у него есть load-buffer и store-buffer. Вот в примере выше процессор наполнит свой store-buffer командами: “запиши 42 туда-то. Запиши 61 туда-то …“. Операции в store buffer могут быть переставлены как попало самим процессором.

Ну и как тогда быть?

Специально для этого были придуманы специальные функции в Си и Си++, которые: запрещают компилятору производить некоторые перестановки. директивы, которые заставля.т процессор таки выполнить присвоение сначала полям, а потом - ready=true. Одна из таких конструкций - thread_fence. В данном случае команда говорит: я хочу, чтобы все отложенные (процессором, компилятором) записи в объект случились прямо сейчас.

Когда мы используем mutex или semaphore то код мутекса или семафора делает для нас эти операции. Сама операция захвата мутекса является барьером: компилятор не имеет права переставлять строки ДО мутекса - ПОСЛЕ и наоборот. Захват мутекса гарантирует, что объект полностью проинициализирован.

Ну так надо просто использовать мутексы и не мучить мозг, разве нет?

Можно. Но с мутексами есть небольшая неприятность - они блокируют выполнение задачи. Хотя бы на 1 FreeRTOS тик (1мс). Если у вас высоконагруженный код - будет заметно лагать. Надо избавляться от мутекса. Об этом, собственно, первый пост и был - как написать , чтобы работало на SMP/multithread и без блокировок.

например, две задачи одновременно делают:

одна: ref–

другая: ref++

Изначально ref = 1. Одна задача делает ref–. Значение упало до нуля, начинается удаление объекта но…

Тадам, шедулер переключил задачи. Теперь выполняется задача, которая делает ref++. То, что первая задача сделала там что-то с ref - еще не дошло от процессора до памяти, поэтому вторая задача видит, что ref все еще равен 1. И начинает делать свой ++, а потом начинает использовать поля обхекта, но..

Тадам, шедулер переключился на первую задачу. Которая как раз только-только начала удалять обхект, но ее прервали. Ну тут уж объект удаляется полностью и

Вторая задача работает с освобожденной памятью.

А теперь представьте, что это ESP32 с двумя ядрами. Там вообще честное параллельное выполнение задач будет с ++ и –. Что там будет в результате - только CPU знает, но он не скажет.

Очевидным решением является: mutex, semaphore, spinlock, critical section (все это есть во FreeRTOS на ESP32). Но это все прошлый век и медленный код.

дико извиняюсь, но я только спросить, код будет работать на всех ядрах или только на 1 ?

Как по мне, если такое возможно - это неслабый косяк в программе))
Но я лишь дилетант- любитель, чтобы серьёзно говорить на эти темы - мне надо много ещё учиться. Что и будем делать.
Спасибо за разъяснения.

хоть на всех, хоть на одном. и так и так можно.

ну, если ты это увидел - значит ты на правильном пути, и многопоточное\многопроцессорное программирование - это твое.

на ЕСП32 я запускал 150 задач. Работает :). Когда есть возможность запускать задачи - меняется подход к программированию. Например, blink с delay уже не будет проблемой - запускай свой блинк в отдельной задаче и ее delay будет работать только для нее.

Спойлер

А т.к. многозадачность и многоядерность уже просто везде, то надо потихоньку менять привычки в программировании - даешь параллельное исполнение задач! многоядерность в каждый процессор!

Во FreeRTOS все объекты со встроенной защитой. Т.е. если ты используешь их очереди, например, то там унутре сидит мутекс, который и будет отвечать за то, чтобы процессор или компилятор не наделал бед.

Можете дать ссылку на документ для ESP32, где это описано? (Пытался сам найти, не получилось).

Это в документе от авторов архитектуры Xtensa, фирма Cadence. Формально Еспрессиф не имеет права публиковать эти доки. Идиотизм.

Документ https://www.cadence.com/content/dam/cadence-www/global/en_US/documents/tools/silicon-solutions/compute-ip/isa-summary.pdf

, страница 118. Там как раз и написано про “memory order“. Это одно из мест, где проблема описана и разобрана. А так, по документу, поищи по memory order или reoder

Делают это все процессоры более-менее современные. Да и древние тоже делали, начиная с DEC Alpha. Представь, например, такой код:

char a[16000];
char b[16000];

a[0] = b[0];
a[15000] = b[15000];
a[1] = b[1];
a[15001] = b[15001];
a[2] = b[2];
a[15002] = b[15002];

При включенной оптимизации компилятор и\или ЦПУ гарантированно переставят все вот так:

a[0] = b[0];
a[1] = b[1];
a[2] = b[2];
a[15000] = b[15000];
a[15001] = b[15001];
a[15002] = b[15002];

Почему? Потому что так будет меньше ошибок кэша. Кэш на ESP32 в среде Ардуино равен 16кб (можно увеличить до 32к). Поэтому чтение сначала b[0], потом b[15000], потом опять b[1]… будет перезагружать кэш каждый раз. Это неприемлемо, поэтому команды переставлены, чтобы кэш переключился один раз. Ну, это упрощенное объяснение.

PS: плохой пример привел. лучше бы написать

char b[32000];

a = b[0];
c = b[17001];
d = b[1];
e = b[17002];



Про компилятор это понятно, и на AVR такое есть. Но чтобы проц сам “думал”, -
для меня что-то новое. Это значит, ему надо, сначала загрузить кусок программы, чтобы понять, что с ним дальше делать?
Или, я, не правильно понял, и без компилятора здесь не обходится?

P.S. По ссылке прошёл, почитал, но, не уверен, что всё правильно понял.
Если не затруднит, разъясни пжлст.

>> Процессор вообще с памятью работает двумя очередями: у него есть load-buffer и store-buffer. Вот в примере выше процессор наполнит свой store-buffer командами: “запиши 42 туда-то. Запиши 61 туда-то …“. Операции в store buffer могут быть переставлены как попало самим процессором.

Слов load-buffer и store-buffer в документе не нашел. Собственно хотел посмотреть, что это за буфер для доступа к внутреннему RAM. Из того, что прочитал около страницы 118, для себя решил, что использование volatile и атомарных блоков должно закрыть вопросы (для моего уровня программирования).

Получается это происходит онлайн, без компилятора? В железе, или загружается какой то софт, типа “драйвер” на процессор?

Для моего и подавно, но, всё же, интересно.)

Про store buffer - Это была цитата от vvb333007. И потом вопрос к этой цитате.

Пример с кешем от vvb333007, более - менее понятен, и логично, что процессор может оптимизировать обращение к кешу. Но встроенная RAM ведь работает без кеша, и зачем там буфер чтения и записи, я не понял (а где объясняется, не нашел).

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

Мне, как начинающему, был бы более интересен вариант, когда работает ровно так, как написал. Чтобы было видно, где хорошо, где криво. Именно для понимания и обучения. Не велика заслуга, когда за тебя все косяки исправляют.(Это относится и к компилятору/оптимизатору).
Как написать матерную частушку, а на выходе получить уже народную песню)

А так да, когда уже находишься “на уровне” - всё это только в помощь будет.

Да, процессор внутри имеет конвееры команд, очереди доступа к памяти и т.п.

Но.

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

не фига не понятно, но очень интересно, спс, а еще ЫЫ ругается, и говорит барьеры нужны, иначе ошибки будут…

ну так у меня он и есть : “atomic_thread_fence()“. Забор целый, не то что барьер. Ну, кстати, надо бы код этот на ревью ЫЫ отдать, посмотреть, чо скажет

Когда компилятор и процессор начинают там что-то переставлять и умничать - они руководствуются одним важным правилом: “Компилятор и ЦПУ вольны переставлять инструкции как им вздумается, вольны переупорядочивать запись и чтение из памяти, но так, чтобы однозадачная программа ничего не заметила“.

А если задач несколько? Тогда мы используем мутексы или семафоры - это простой и легкий путь. Но не самый быстрый. Мутексы устроены так (и семафоры и критические секции), что компилятору ЗАПРЕЩАЕТСЯ переставлять команды рядом сзахватом или освобождением mutexa.

Компилятор может переставлять строчки ниже как ему вздумается.

a = 10;

b = 11;

c = 12;


В коде ниже компилятору запрещено переставлять a=10 и b=11 ниже, чем мутекс. e=15 не может быть переставлена выше.


...
a = 10;
b = 11;
mutex_acquire(...);
c = 12;
d = 13;
mutex_release(...);
e = 15;

И только если нам хочется избавиться от семафоров и мутексов - только тогда нам нужно уже внимательно смотреть на код, на memory order и прочее.

Спойлер

Когда-то давно производители процессоров и компиляторов выдумали такю штуку как memory model. Она бывает слабая (dec alpha, arm, powerpc) и сильная (x86). На сильной частенько неправильный код работает правильно. Потом переносишь его на слабую архитектуру - и все ломается.

Согласно выдуманной этой модели памяти - разные задачи, в один и тот же момент времени могут видеть значения одной и той же переменной по разному. Вы ведь когда пишите A=1 в одной задаче, а в другой if (A == 1), знаете какой путь проходит эта единица? Ядро номер 1 отдает команду “store 1 at address XXXXX“. Процессор предугадывает, что скорее всего, следующая запись будет примерно куда-то туда же и загружает в кэш нужные ему адреса. Изменения данныйх происходит в кэше. Когда-то потом произойдет сброс кэша в память. Потом от ядра 1 ядру 0 будет отослано сообщение о том, что “нужно перечитать память в кэш, она обновилась“ и только тогда вторая задача увидит, что А равно 1. Это все происходит не мгновенно