Подтвердить что ты не робот

Блокировка ссылок и интеллектуальные указатели С++

В общем, наиболее широко известные реализации ссылок на классы smart ptr в С++, включая стандартный std::shared_ptr, используют подсчет атомных ссылок, но не обеспечивают атомный доступ к одному и тому же экземпляру smart ptr. Другими словами, несколько потоков могут безопасно работать с отдельными экземплярами shared_ptr, которые указывают на один и тот же общий объект, но несколько потоков не могут безопасно читать/записывать экземпляры одного и того же экземпляра shared_ptr без предоставления какой-либо синхронизации, такой как мьютекс или что угодно.

Атомная версия shared_ptr, называемая "atomic_shared_ptr", была предложена, а предварительная уже существуют. Предположительно, atomic_shared_ptr может быть легко реализован с помощью блокировки спина или мьютекса, но также возможна блокировка.

После изучения некоторых из этих реализаций одно очевидно: реализация блокировки std::shared_ptr очень сложна и, по-видимому, требует так много операций compare_and_exchange, чтобы я поставил вопрос, достигнет ли простая блокировка спина производительность.

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

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

Таким образом, в целом для создания незакрепленных версий используются различные сложные стратегии. Реализация здесь выглядит так: она использует стратегию подсчета двойных ссылок, где есть "локальные" ссылки, которые подсчитывают количество потоков, одновременно обращающихся к экземпляру shared_ptr, и затем "общие" или "глобальные" ссылки, которые подсчитывают количество экземпляров shared_ptr, указывающих на общий объект.

Учитывая всю эту сложность, я был очень удивлен, обнаружив статью доктора Доббса, начиная с 2004 года, не менее (путь до С++ 11 atomics), который, как представляется, небрежно решает всю эту проблему:

http://www.drdobbs.com/atomic-reference-counting-pointers/184401888

Похоже, автор утверждает, что может:

"... [читать] указатель на счетчик, увеличивает счетчик и возвращает указатель-все таким образом, чтобы никакие другие потоки не могли вызвать неправильный результат"

Но я не совсем понимаю, как он это реализует. Он использует (не переносные) инструкции PowerPC (примитивы LL/SC lwarx и stwcx), чтобы отключить это.

Соответствующий код, который делает это, он называет "aIandF" (атомный приращение и выборка), который он определяет как:

addr aIandF(addr r1){
  addr tmp;int c;
  do{
    do{
      tmp = *r1;
      if(!tmp)break;
      c = lwarx(tmp);
    }while(tmp != *r1);
  }while(tmp && !stwcx(tmp,c+1));
  return tmp;
};

По-видимому, addr - это тип указателя, указывающий на общий объект, которому принадлежит переменная счетчика ссылок.

Мой вопрос (ы):, можно ли это сделать только с архитектурой, поддерживающей операции LL/SC? Кажется, с cmpxchg это невозможно сделать. А во-вторых, как именно это работает? Я читал этот код несколько раз, и я не могу понять, что происходит. Я понимаю, что LL/SC примитивы, я просто не могу понять смысл кода.

Лучшее, что я могу понять, это то, что addr r1 - это адрес указателя на общий объект, а также адрес указателя на счетчик ссылок (который, я думаю, означает, что переменная счетчика ссылок должна быть первым членом struct, который определяет общий объект). Затем он разыгрывает addr (получение фактического адреса общего объекта). Затем он связал загружает значение, хранящееся по адресу в tmp, и сохраняет результат в c. Это значение счетчика. Затем он условно сохраняет это значение, увеличивающееся (которое не сработает, если tmp изменилось) обратно в tmp.

Я не понимаю, как это работает. Адрес общего объекта может никогда не измениться, и LL/SC может быть успешным, но как это нам поможет, если другой поток освободил общий объект за время?

4b9b3361

Ответ 1

addr aIandF(addr r1) {
  addr tmp;
  int c;
  do {
    do {
      // r1 holds the address of the address
      // of the refcount
      tmp = *r1;       // grab the address of the refcount
      if (!tmp) break; // if it null, bail

      // read current refcount
      // and acquire reservation
      c = lwarx(tmp);

      // now we hold the reservation,
      // check to see if another thread
      // has changed the shared block address
    } while (tmp != *r1); // if so, start over

    // if the store succeeds we know we held
    // the reservation throughout
  } while (tmp && !stwcx(tmp, c+1));
  return tmp;
};

Обратите внимание, что aIandF используется специально при построении копии существующего общего указателя, требуя ссылки для копии.

Статья доктора Доббса описывает операцию освобождения ссылки в качестве первой атомарной замены адреса общего счетчика в исходном объекте с общим указателем с нулевым указателем, локальным для функции; затем атомарно уменьшая счетчик; затем тестирование, чтобы проверить, равен ли результат декремента нулю. Этот порядок операций важен: вы говорите: "Адрес общего объекта никогда не может измениться, и LL/SC может быть успешным, но как это помогает нам, если другой поток освободил общий объект за время?" - но этого никогда не может произойти, поскольку объект никогда не будет освобожден без предварительного обмена, давая нам возможность наблюдать за изменением адреса.

aIandF проверяет, что адрес счетчика является нулевым при записи.

Он может определить, что адрес становится нулевым, если это происходит до lwarx, потому что он явно проверяет это, как только он имеет резервирование.

Если swap в декрементирующем потоке происходит после lwarx, нам на самом деле все равно: если stwcx in aIandF преуспевает, мы знаем, что декрементирующий поток увидит новый счетчик ссылок, а не уничтожит объект, и мы может продолжаться, зная, что мы ссылались на него; тогда как если другому потоку удастся сначала уменьшить счетчик, мы потеряем наше резервирование, магазин потерпит неудачу, и мы обнаружим уничтожение объекта на следующей итерации цикла.

Этот алгоритм предполагает сильную согласованную модель памяти (все потоки всегда видят, что эффекты друг друга читают и записывают в программном порядке) - это не обязательно так, даже на тех современных архитектурах, которые поддерживают ll/sc.

EDIT: думая об этом, алгоритм также, по-видимому, делает предположение, что всегда безопасно читать из адреса памяти, который был когда-то действительным (например, MMU/защита или алгоритм не работает):

if (!tmp) break;

// another thread could, at this point, do its swap, 
// decrement *and* destroy the object tmp points to
// before we get to do anything else

c = lwarx(tmp); 

// if that happened, we'll detect this fact and do nothing with c
// but ONLY if the lwarx doesn't trap 
// (due to the memory tmp points to 
// getting unmapped when the other thread frees the object)