Многозадачность, мутексы и lockless доступ

Представим себе скетч, многозадачный, в котором мы оперируем с какой-нибудь структурой, назовем ее

struct UserSettings {
  ...
  int ref;
  ...
};

Или, для любителей Си++, пусть вместо структуры будет какой-нибудь класс, это не важно.

В структуре у нас есть член ref - счетчик активных “пользователей” данного экземпляра структуры, который используется для удаления (когда число пользователей становится равным нулю):

// Уменьшаем счетчик пользователей на единицу. Если счетчик равен нулю - то 
// удаляем структуру
void 
delete_user_settings(struct UserSettings *p) {

  if (--p->ref == 0) {
    free(p);
  }
}

Так, вратце, работает логика со счетчиками ссылок (reference counters, refcounters): удаляем объект, когда на него нет ссылок, чтобы сохранить объект, вместо копирования мы просто увеличиваем счетчик ссылок.

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

Почему?

Ну если две задачи одновременно сделают ref++, то значение ref будет непредсказуемым: оно может увеличится на 1, на 2 или на 0. (А на некоторых архитектурах значение ref может быть вообще произвольным)

Что делать?

Ну, обычно применяют какой-нибудь объект синхронизации, например, мутекс или бинарный семафор:

// Уменьшаем счетчик пользователей на единицу. Если счетчик равен нулю - то 
// удаляем структуру, исправленная версия:
void 
delete_user_settings(struct UserSettings *p) {

  mutex_acquire(p->mutex);
  if (--p->ref == 0) {
    mutex_release(p->mutex);
    free(p);
    return;
  }
  mutex_release(p->mutex);
}

И все прекрасно начинает работать (считаем, что p->mutex инициализирован где-то), но есть несколько проблем:

  1. На мутексе задача ,блокируется и ничего не делает, пока мутекс не разблокируется. Создается задержка.
  2. С мутексами существует проблема нарваться на deadlock.
  3. Если удалить задачу, которая захватила и не отпустила мутекс, то всё, привет.
  4. А еще - мутекс надо создавать и удалять. Ну, или хотя-бы создавать, бог с ним , с удалением.

Слава богу, на этих наших ардуинах многозадачных построенных на ESP32, например) компилятором поддерживается стандарт C11 с ключевым словом _Atomic и атомными операциями.

Такой подход называется “lockless programming”, про это довольно много написано в интернете. Современные ядра операционных систем так или иначе пользуются этим подходом, хотя, конечно, не весь код с мутексами может быть
сконвертирован в lockless код.

Код выше может быть переписан совсем без задержек* и использования объектов синхронизации следующим
образом:

#include <stdatomic.h>

struct UserSettings {
  ...
  _Atomic int ref;
  ...
};

// Уменьшаем счетчик пользователей на единицу. Если счетчик равен нулю - то 
// удаляем структуру, lockless версия:
void 
delete_user_settings(struct UserSettings *p) {
  if (atomic_fetch_sub(&p->ref, 1) == 1) { // atomic read-modify-store цикл
    free(p);
    return;
  }
}

Ключевое слово _Atomic говорит компилятору о том, что наша переменная - особенная :).

В принципе, если теперь делать ref++ и ref–, то эти операции будут атомарными, с гарантированным результатом. Но так как нам еще нужно и атомарное сравнение, то мы используем atomic_fetch_sub(&p->ref,1). Эта конструкция вычитает единицу из счетчика и возвращает его значение до вычета.

C11 содержит множество атомарных операций, наподобие описанной выше: atomic_compare_exchange, atomic_fetch_add, atomic_load и тому подобное, тысячи их. В Си++ можно использовать std::atomic.

Happy lockless coding!

Эт ты счас открыл Critical Section

Не, critical section еще и прерывания выключает, т.е. это тяжеловесненько. А во вторых, в critical section, обычно, вот такой код

while (variable > 0) {}

Т.е. крутится на месте, блокируя все, вообще все.

Я же написал про безблокировочный подход

Интересненько… Вообще, в С и С++ “есть многое, что и не снилось…”
Разве библиотека std.atomic доступна “в этих наших ардуинах” ? Для конкретики - скажем на обычной АВР Атмега328 ?

Справедливости ради - на них и истинная многозадачность-то излишняя ))

Кто ж его знает. Надо компилятор изучать. На ESP32 - работает :).

Продолжаем разговор.

Добавление в список, lockless версия.

Есть у вас фабрика, которая умеет создавать и утилизировать структуры. Назовем нашу структуру как-нибудь, пусть будет

struct helper_arg {
  struct helper_arg *next;   
  int                data;
  float              data2;
  char              *data3;
};

И, как и положено на простенькой фабрике, у нас есть методы get() и put(): первый создает новую структуру, put() - удаляет. Причем удаления, как такового, не происходит - удаляемая структура просто помещается в список удаленных, откуда может быть извлечена на свет божий при очередном get() запросе.

Это для скорости и устойчивости (при out-of-memory событиях) такое требование.

(Чтобы пример не казался надуманным - так работает сетевой стек FreeBSD и Linux, выделяя и “освобождая” память под сетевые пакеты., см mbuf и socket buf во FreeBSD и Linux cоответственно).

Для начала создадим список для “удаленных” структур

#include <stdatomic.h>

static _Atomic(struct helper_arg *) ha_unused = NULL; //пока тут пусто

Метод get(). Выделить память под структуру helper_arg или переиспользовать недавно освобожденную

static struct helper_arg *ha_get() {

  struct helper_arg *ret;

  do {
    if ((ret = atomic_load(&ha_unused)) == NULL) // есть чего переиспользовать?
      break;
  } while(!atomic_compare_exchange_strong( &ha_unused, &ret, ret->next)); // другими словами: ha_unused = ret->next

  return ret ? ret 
             : (struct helper_arg *)malloc(sizeof(struct helper_arg ));
}

Метод put(); Вернуть память в систему

static void ha_put(struct helper_arg *ha) {
  if (ha != NULL) {
    do {
      ha->next = atomic_load(&ha_unused);
    } while(!atomic_compare_exchange_strong( &ha_unused, &ha->next, ha));
  }
}
  1. В коде не используются объекты синхронизации вовсе, и , тем не менее - код многозадачный, put() и get()
    можно вызывать одновременно из нескольких задач\процессов, на многоядерной системе.
    код абсолютно портабелен (достаточно поддержки стандарта C11 компилятором) и не требует операционки (для мутексов\семафоров уже нужен какой-нибудь FreeRTOS)

  2. Максимальная задержка, которая создается циклом while() не превышает сумму времени выполнения
    2*atomic_load + atomic_compare_exchange_strong, что на ESP32 примерно 20 тактов процессора, т.е. на 2-3 порядка быстрее, чем мутекс или семафор.

  3. Этот код одинаково работает при вызове из прерывания или из пользовательской задачи: классический подход потребовал бы еще и прерывания отключать, в некоторых случаях.

Happy coding! :slight_smile: