Информация об изменениях

Сообщение Re: COW устарел, осторожнее с его использованием? от 23.08.2022 23:14

Изменено 23.08.2022 23:44 watchmaker

Re: COW устарел, осторожнее с его использованием?
Здравствуйте, pax123, Вы писали:


P>В чем проблема с COW?


Ну написано же:
1. COW-строки не удовлетворяют новым требованиям, добавленными в C++11
В С++03 явно было сказано, что такой код некорректный и, к примеру, может ронять программу:
{
  std::string s1("s");
  const char* p1s1 = s1.data();
  {
      std::string s2(s);
      const char* p2s1 = s1.data();
      printf("%c", *p2s1);
  }
  printf("%c", *p1s1);
}

В С++11 требования изменили, и сказали, что такой код падать не должен, даже если из-за этого он будет работать медленнее.


2. В COW-строках всё плохо с неконстантными методами
Это скорее проблема всего языка С++, что в нём нельзя заранее узнать как будет использоваться результат функции.
Например, operator[] возвращает ссылку на char. Но он не знает что с ней будут делать: только читать или ещё и записывать. Если записывать, то COW (что переводится как copy-on-write, напомню) вынуждает реализацию склонировать строку. В результате иногда COW-строки работают не так эффективно как могли бы: в них есть ложные копирования из-за того, что программист вызывал неконстантную перегрузку вместо константной.
void foo(std::string& s) {
  printf("%c", s[0]);  // медленно
  printf("%c", std::as_const(s)[0]);  // всегда быстро
}

Но расставлять const или писать везде as_const утомительно...

3.В COW-строках есть atomic, которые не очень работают на многоядерных системах

На самом деле полная фигня и упереться в это в реально программе довольно сложно. Обычно либо строки разные, либо для них достаточно быстро вызывается метод clone/detach, либо они константные и атомики не трогаются.

Упереться разве что можно, если у тебя в реализации пустая строка сделана синглтоном и существует в единственном экземпляре. Тогда все потоки с пустыми строками будут теребить этот несчастный счётчик. Впрочем в практичных реализациях COW-строк этот случай учтён: например, введением строк с глобальным временем жизни, у которых не нужно трогать счётчик, так как строка заведомо живёт дольше остальной программы. И пустая строка как раз попадает в эту категорию.



Примерно такой список причин по убыванию важности.

И да, у не-COW строк тоже свой список недостатков: нельзя сказать, что они в любом сценарии лучше.



P>Там еще перед этим есть string: COW MT fixes

P>

P>Например, есть два потока, в них по строке, которые ссылаются на общий динамический объект. Если потоки работают со строками и одновременно решают их удалить, получается, что мы из двух потоков будем пытаться одновременно менять динамический счетчик ссылок use_count. Это приведет к тому, что либо возникнет утечка памяти, либо приложение аварийно завершится.


Ты же понимаешь, что это стиль написания статьи такой: сначала дать заведомо неправильную реализацию, а потом показать как этот фрагмент кода исправить?
То что написано в этом абзаце относится только к первой версии реализации строки из статьи, а не к реализациям std::string в популярных версиях STL.

P>Не совсем понял, а когда это строки стали thread safe?

Во-первых, до С++11 в языке не было вообще ничего про многопоточность. Реализации поддерживали многопоточность, все ей пользовались, но в стандарте её не было. Поэтому и не было требования thread-safe у контейнеров.
Во-вторых, у других стандартных контейнеров вполне себе есть подмножество операций, которые обязаны быть потокобезопасными (например, но не только). И со строками аналогично. Так что надо уточнять какие операции ты имеешь ввиду: некоторые потокобезопасные, другие — нет.
В-третьих, в этом фрагменте статьи идёт речь о совсем другой ситуации: не когда два потока меняют одну и ту же переменную строкового типа, а когда каждый поток меняет свою переменную, которая находится у него в эксклюзивном владении:
const int threads = 2;
std::vector<std::string> v{threads, "long shared string "};

for (int i = 0; i < threads ; ++i) {
  auto fn = [i, &v]() {
    v[i] += ('0' + i);
    puts(v[i].c_str());
  };
  std::thread(fn).detach();
}

Тут каждый поток меняет только свою строку: v[0] либо v[1]. Это разные переменные к которым доступ идёт из разных потоков. Поэтому даже если бы было сказано, что ни одна операция с std::string не является thread-safe, то такой код всё равно должен работать правильно.

И в нормальных реализация COW-строк он действительно работает всегда правильно, даже не смотря на то, что все строки ссылаются изначально на один и тот же общий буфер.
Вот как например std::string устроена в libstdc++ до С++11: bits/cow_string.h — вообще никаких проблем с этим сценарием. В других версиях STL для С++03 — аналогично.

И в статье не говорится, что описанная ситуация — это какой-то фатальный сценарий для правильной работы COW-строк, в ней скорее говорится, что если ты реализуешь COW-контейнер вручную, то об этой ситуации просто нужно не забыть.
Re: COW устарел, осторожнее с его использованием?
Здравствуйте, pax123, Вы писали:


P>В чем проблема с COW?


Ну написано же:
1. COW-строки не удовлетворяют новым требованиям, добавленными в C++11
В С++03 явно было сказано, что такой код некорректный и, к примеру, может ронять программу:
{
  std::string s1("s");
  const char* p1s1 = s1.data();
  {
      std::string s2(s);
      const char* p2s1 = s1.data();
      printf("%c", *p2s1);
  }
  printf("%c", *p1s1);
}

В С++11 требования изменили, и сказали, что такой код падать не должен, даже если из-за этого он будет работать медленнее.


2. В COW-строках всё плохо с неконстантными методами
Это скорее проблема всего языка С++, что в нём нельзя заранее узнать как будет использоваться результат функции.
Например, operator[] возвращает ссылку на char. Но он не знает что с ней будут делать: только читать или ещё и записывать. Если записывать, то COW (что переводится как copy-on-write, напомню) вынуждает реализацию склонировать строку. В результате иногда COW-строки работают не так эффективно как могли бы: в них есть ложные копирования из-за того, что программист вызывал неконстантную перегрузку вместо константной.
void foo(std::string& s) {
  printf("%c", s[0]);  // медленно
  printf("%c", std::as_const(s)[0]);  // всегда быстро
}

Но расставлять const или писать везде as_const утомительно...

3.В COW-строках есть atomic, которые не очень работают на многоядерных системах

На самом деле полная фигня и упереться в это в реально программе довольно сложно. Обычно либо строки разные, либо для них достаточно быстро вызывается метод clone/detach, либо они константные и атомики не трогаются.

Упереться разве что можно, если у тебя в реализации пустая строка сделана синглтоном и существует в единственном экземпляре. Тогда все потоки с пустыми строками будут теребить этот несчастный счётчик. Впрочем в практичных реализациях COW-строк этот случай учтён: например, введением строк с глобальным временем жизни, у которых не нужно трогать счётчик, так как строка заведомо живёт дольше остальной программы. И пустая строка как раз попадает в эту категорию.



Примерно такой список причин по убыванию важности.

И да, у не-COW строк тоже свой список недостатков: нельзя сказать, что они в любом сценарии лучше.



P>Там еще перед этим есть string: COW MT fixes

P>

P>Например, есть два потока, в них по строке, которые ссылаются на общий динамический объект. Если потоки работают со строками и одновременно решают их удалить, получается, что мы из двух потоков будем пытаться одновременно менять динамический счетчик ссылок use_count. Это приведет к тому, что либо возникнет утечка памяти, либо приложение аварийно завершится.


Ты же понимаешь, что это стиль написания статьи такой: сначала дать заведомо неправильную реализацию, а потом показать как этот фрагмент кода исправить?
То что написано в этом абзаце относится только к первой версии реализации строки из статьи, а не к реализациям std::string в популярных версиях STL.

P>Не совсем понял, а когда это строки стали thread safe?

Во-первых, до С++11 в языке не было вообще ничего про многопоточность. Реализации поддерживали многопоточность, все ей пользовались, но в стандарте её не было. Поэтому и не было требования thread-safe у контейнеров.
Во-вторых, у других стандартных контейнеров вполне себе есть подмножество операций, которые обязаны быть потокобезопасными (например, но не только). И со строками аналогично. Так что надо уточнять какие операции ты имеешь ввиду: некоторые потокобезопасные, другие — нет.
В-третьих, в этом фрагменте статьи идёт речь о совсем другой ситуации: не когда два потока меняют одну и ту же переменную строкового типа, а когда каждый поток меняет свою переменную, которая находится у него в эксклюзивном владении:
const int threads = 2;
std::vector<std::string> v{threads, "long shared string "};

for (int i = 0; i < threads ; ++i) {
  auto fn = [i, &v]() {
    v[i] += ('0' + i);
    puts(v[i].c_str());
  };
  std::thread(fn).detach();
}

Тут каждый поток меняет только свою строку: v[0] либо v[1]. Это разные переменные к которым доступ идёт из разных потоков. Поэтому даже если бы было сказано, что ни одна операция с std::string не является thread-safe, то такой код всё равно должен работать правильно.

И в нормальных реализация COW-строк он действительно работает всегда правильно, даже не смотря на то, что все строки ссылаются изначально на один и тот же общий буфер.
Вот как например std::string устроена в libstdc++ до С++11: bits/cow_string.h — вообще никаких проблем с этим сценарием. И никаких мьютексов или спинлоков для взаимоисключения одновременного доступа там тоже нет — просто аккуратно написан код, в котором действия выполняются в порядке, который никогда не приводит в ошибочное состояние. В других версиях STL для С++03 — аналогично.

И в статье не говорится, что описанная ситуация — это какой-то фатальный сценарий для правильной работы COW-строк, в ней скорее говорится, что если ты реализуешь COW-контейнер вручную, то об этой ситуации просто нужно не забыть. Это примерно на уровне того, что при реализации MyClass::operator=(const MyClass& other) нужно тоже подумать чуть-чуть про то, что произойдёт, если сделать самоприсваивание a = a, и в каком порядке нужно выполнять действия, чтобы не уронить программу из-за того, что ресурсы this будут освобождены до чтения ресурсов из other.