Счетчики ссылок на объект. Без блокировок для 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 лайк

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

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

  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 и без блокировок.

2 лайка

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

одна: ref–

другая: ref++

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

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

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

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

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

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

1 лайк

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

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

2 лайка

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

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

на ЕСП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 и атомарных блоков должно закрыть вопросы (для моего уровня программирования).

1 лайк

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

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

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

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

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

1 лайк

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

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

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

Но.

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

1 лайк

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

ну так у меня он и есть : “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. Это все происходит не мгновенно

1 лайк