Эта статья написана с целью подчеркнуть некоторые риски, связанные с переносом базы кода на использование корутин. Я считаю, что продолжающееся неграмотное использование этой функциональности может привести к уменьшению безопасности и замедлению программ.

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

▍ Содержание



▍ Как работают обычные функции и стек


При вызове обычной функции аргументы обычно передаются с помощью регистров, и если их оказывается недостаточно, они могут передаваться через стек, использование которого определяется посредством двоичного интерфейса приложений (ABI, Application Binary Interface).

Первым делом внутри функции происходит резервирование достаточного пространства стека для хранения локальных и временных переменных. Не все они попадают в стек — некоторые могут находиться в регистрах или исключаться полностью в ходе оптимизации.

В завершении же функции указатель стека сбрасывается до своего состояния на момент первого её вызова, и результат отправляется вызывающему.

В зависимости от ABI в этом процессе могут варьироваться многие детали. Например, некоторые интерфейсы могут иметь волатильные и не волатильные регистры, требующие сохранения и восстановления.

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

За счёт активного использования кэша L1/L2 при каждом вызове функции в потоке выполнение происходит очень быстро.

▍ Как работают корутины в С++


Аналогично тому, как аргументы в обычной функции передаются с помощью регистров и стека, корутины используют тот же ABI, который был задан ранее. Тем не менее код сильно отличается.

В отличие от обычной функции корутина может быть приостановлена, что позволяет вызывающей функции продолжить вызывать другие функции и возобновить её выполнение позднее, возможно, в другом потоке.

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

Ниже показан пример перевода кода в процессе компиляции.

Исходный код корутины:
task<void> func()
{
  int mylocal = 10;
  co_return;
}
Код после компиляции:
struct func_frame
{
  task<void>::promise_type __promise;
  int __step = 0;

  decltype(__promise.initial_suspend()) __initial_suspend_awaiter;
  decltype(__promise.final_suspend()) __final_suspend_awaiter;

  struct 
  {
    // Локальные и временные переменные
    int mylocal;
  } local_and_temps;

  void resume()
  {
    switch(__step)
    {
      case 0:
        // co_await __promise.initial_suspend();
        __initial_suspend_awaiter = _promise.initial_suspend();
        if (!__initial_suspend_awaiter.await_ready())
        {
          __step = 1;
          __initial_suspend_awaiter.await_suspend();
          return;
        }
      case 1:
        __initial_suspend_awaiter.await_resume();
        // .. тело функции
        mylocal = 10;
        // co_return
        __promise.return_void();
        // co_await __promise.final_suspend();
        __final_suspend_awaiter = _promise.final_suspend();
        if (!__final_suspend_awaiter.await_ready())
        {
          __final_suspend_awaiter.await_suspend();
          return;
        }
        delete this;
    }
  }
};

task<void> func()
{
  func_frame * frame = task<void>::promise_type::operator new(func_frame);
  task<void> ret = frame->__promise.get_return_object()
  frame->resume();
  return ret;
}


И хотя это не описывает конкретный принцип работы корутины, надеюсь, вы сможете представить, что происходит внутренне с учётом присутствия скрытой структуры/блока памяти, хранящей состояние корутины.

Внутри этого состояния сохраняются локальные и временные переменные. В этом небольшом примере mylocal, вероятно, будет удалена в ходе оптимизации, но в реальной корутине, где локальные и временные переменные должны сохраняться после возобновления функции, она должна помещаться в local_and_temps. К тому же, все эти переменные инициализируются по месту, и перед нами лишь пример кода.

При использовании HALO (coroutine Heap Allocation eLision Optimization, оптимизация пропуска выделения динамической памяти), когда компилятор может определить точное время жизни корутины и знает схему её фреймов, он может заменить тело func копией func_frame в виде локальной переменной вызывающего (которым может являться обычная функция или корутина).

Некоторые специалисты ошибочно считали, что выделение может выполняться по требованию в первой точке приостановки. Это невозможно, поскольку потребовало бы перемещения всех локальных и временных переменных вместе со ссылками на них, которые компилятор не способен отследить за пределами границ ABI.

Корутины можно представить и более удачным образом. Мне нравится рассматривать их как простой процесс превращения функции в структуру, которая содержит локальные и временные переменные, с последующим использованием функции возобновления, выполняющей эту изначальную функцию поэтапно.

▍ Асинхронный код пишется не так, как синхронный


Фраза «пишите асинхронный код как синхронный» ужасна. Она внушает людям ложное чувство безопасности. Любой, кто написал приличный объём асинхронного кода, знает, что всё не так просто.

Здесь появляется совершенно новый набор возможных ошибок, о возникновении которых вы даже и не думаете, потому что не уделяете точкам приостановки должного внимания ввиду простоты их написания. Вы просто добавляете co_await, и на этом всё.

▍ Безопасность: срок жизни аргументов


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

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

Рассмотрим следующий пример:

task<void> async_insert(T && val);
task<void> async_find(const T &);
task<void> async_write(span<byte>);

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

▍ Безопасность: инвалидация итераторов и указателей


Поскольку функция может на какое-то время быть приостановлена, за это время также может измениться какое-либо глобальное состояние. Представьте следующую ситуацию:

task<void> send_all(string s)
{
  for (auto & source : m_sources)
  {
    co_await source.send(s);
  }
}

При написании этого кода большинство специалистов, вероятно, учтут лишь то, что send сделает недействительным m_sources только в случае неудачного связывания, которое очень маловероятно. Однако, поскольку в ходе цикла коруитна может оказаться приостановлена, в это время вполне может выполниться другой компонент, сделав данные недействительными.

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

task<void> send_all(string s)
{
  std::vector<task<void>> sends;
  sends.reserve(m_sources.size());
  for (auto & source : m_sources)
  {
    sends.push_back(source->send(s));
  }

  co_await wait_all( sends.begin(), sends.end() );
}

Поначалу выглядит неплохо. Но кто будет отвечать за сохранение ресурсов на случай их удаления при выполнении send_all?

Для их сохранения можно выполнить следующее:

struct Source : std::enable_shared_from_this<Source>
{};

task<void> Source::send(string s)
{
  auto pSelf = this->shared_from_this();
  ...
}

Или проделать то же самое в вызывающей функции:

task<void> send_all(string s)
{
  std::vector<task<void>> sends;
  std::vector<shared_ptr<Source>> sources = m_sources;
  sends.reserve(sources.size());
  for (auto & source : sources)
  {
    sends.push_back(source->send(s));
  }

  co_await wait_all( sends.begin(), sends.end() );
}

Ещё один вариант — отследить ожидающие промисы.

struct Source
{
  std::vector<std::coroutine_handle> dependent_coroutines;
  ~Source()
  {
    for (auto & coroutine : dependent_coroutines)
      coro.destroy();
  }

  task<void> send(string s)
  {
    auto corohandle = co_await get_current_coroutine{};
    dependent_coroutines.push_back( corohandle );
    ...
    dependent_coroutines.erase( dependent_coroutines.find( corohandle ) );
  }
}

Также можно блокировать и разблокировать данные.

task<void> send_all(string s)
{
  co_await m_sourcesLock.read_lock();
  std::vector<task<void>> sends;
  sends.reserve(m_sources.size());
  for (auto & source : m_sources)
  {
    sends.push_back(source->send(s));
  }

  co_await wait_all( sends.begin(), sends.end() );
  co_await m_sourcesLock.read_unlock();
}

Решить эту проблему можно разными способами, каждый из которых будет иметь свои плюсы и минусы.

▍ Безопасность: ранний и отложенный запуск корутин


В случае использования stackless-корутин обычно есть два подхода: раннее выполнение, когда функция начинает выполняться сразу и до первой точки приостановки; и отложенное, когда функция запускается в приостановленном состоянии, начиная выполняться только в момент своего первого возобновления.

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

В большинстве популярных библиотек корутин используется отложенный запуск. Ранний же запуск реализуется с помощью библиотеки Boost.Asio.

Если вам известно, что функция всегда будет использоваться по следующему паттерну:

co_await fetch_data("key");

То вы можете предположить, что key будет сохраняться вызывающей функцией, потому что здесь сразу же выполняется co_await. В этом случае вы вполне обойдётесь такой реализацией:

eager_task<string> fetch_data(string_view key)
{
  auto it = cache.find(key)
  if (it != cache.end())
  {
    return it->second;
  }

  auto data = co_await fetch_remote(key);
  cache.emplace(key, data);
}

Затем, спустя один-два года, кто-нибудь может добавить:

eager_task<void> fetch_mydata(string_view key)
{
  return fetch_data(std::format("mysystem/{}", key));
}

Вы выполняете тесты. Всё работает прекрасно. Но как только этот код начинает активно использоваться, в fetch_data начинают возникать произвольные сбои (отсутствует key, потому что его данные исчезли).

Если вам повезёт, вы обнаружите его в fetch_mydata, но он может оказаться совершенно в другой системе. Тогда вам нужно заметить, что он не выполняет co_return и co_await, о чём большинство разработчиков, опять же, не подумают.

Если у вас используется отложенная корутина, этот код будет давать сбой регулярно. Подобную проблему можно выявить с помощью таких инструментов, как AddressSanitizer или иных, способных обнаруживать использование после освобождения.

▍ Безопасность: тестирование точек приостановки


Как вы видели в примерах, связанных с безопасностью, приостановка/возобновление функции может вносить множество случайных багов.

Именно поэтому важно регулярно тестировать в корутинах точки приостановки, что имеет свои сложности, поскольку функция, вызывающая другую функцию, которая может находиться в ожидании, не определяет, приостановлена ли она.

Это ведёт к необходимости встраивания поддержки в каждую допускающую ожидание функцию или использование await_transform. Причём оба этих варианта реализовать не так просто, особенно когда речь идёт о добавлении этого поведения с помощью зависимости.

▍ Производительность: выделение памяти


В случае вызова корутины без выполнения HALO под её фрейм/состояние выделяется память.
При этом в результате попыток избежать проблем, связанных с жизненным циклом аргументов и инвалидацией итераторов/указателей, происходят дополнительные операции выделения памяти.

Вы можете понадеяться, что их ускорит использование имеющихся у каждого потока 64 бинов tcache, в каждом из которых сохраняется до 7 аллокаций равного размера (от 24 до 1024 байт).

Тем не менее, если вернуться к функции send_all, которая получала string, то для каждого источника, для которого она вызывала send, производилось два выделения памяти (под фрейм корутины и аргумент строки) одинакового размера. Поэтому, если у вас есть 7 источников, то вы неизбежно исчерпаете tcache. Если же и аргумент строки, и фрейм корутины входят в один бин, то для его исчерпания хватит всего 4 источников.

▍ Производительность: раздувание стека HALO


При использовании HALO фреймы корутин не нужно помещать в кучу, они могут оставаться локальными (в стеке или как часть вызывающей корутины).

Это может показаться достаточно простым, если у вас не очень много крупных фреймов стека. Тем не менее, если вы объедините множество компонентов, то фреймы у вас получатся очень большие.

Представьте такой случай:

task<void> post_metric(string name, int value)
{
  char sendbuffer[16*1024];
  ...
}

task<void> post_metrics()
{
  std::array metrics { 
    post_metric("a", ...);
    post_metric("b", ...);
    post_metric("c", ...);
    ...
  };
  co_await when_all(begin(metrics), end(metrics));
}

Здесь потребуется не менее 16 КБ для каждой post_metric. При этом от post_metric может происходить множество параллельных вызовов, и в результате вы получите значительно больший размер фрейма.

Теперь рассмотрим следующий код:

task<void> func()
{
  if (cond)
  {
    co_await func1()
  }
  else
  {
    co_await func2();
    co_await post_metrics();
  }
}

В этом случае func также может включать размер post_metrics. И поскольку она является рекурсивной, то даже когда cond будет true, объём func1 и func2 также будет засчитываться в её размер.

Кроме того, вы можете реализовать механизм, определяющий, для чего нужно выполнять HALO, исходя из вероятности перехода в базовый блок. То есть сделать так, чтобы один компилятор выполнял эту оптимизацию для одной ветви, а другой — для другой, что приведёт к очень непоследовательной производительности.

▍ Производительность: отладочные сборки


Даже самая простая корутина содержит множество внутренних вызовов функций.

task<void> func()
{
  co_return;
}
func()
promise_type::operator new()
promise_type::promise_type()
promise_type::get_return_value()
promise_type::initial_suspend()
initial_suspend::initial_suspend()
initial_suspend::await_ready()
initial_suspend::await_suspend() [opt]
initial_suspend::await_resume()
promise_type::return_void()
final_suspend::final_suspend()
final_suspend::await_ready()
final_suspend::await_suspend() [opt]
promise_type::operator delete()

Получается, что одна функция фактически содержит в себе 13 других, многие из которых вследствие оптимизаций будут встроены, что избавит нас от накладных издержек на их вызовы.

А что насчёт отладочной сборки? В таком случае все эти функции могут сохраниться в неоптимизированном состоянии.

▍ Вирусная природа stateless-корутин


Корутины в С++ не имеют состояний, то есть приостановка происходит только для одной функции, а не для всего стека вызовов.

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

Причём этот процесс может охватывать всё больший и больший диапазон. Достаточно всего нескольких глубоко вложенных вызовов функций, которые станут корутинами, чтобы их сеть быстро распространилась на подавляющую часть базы кода.

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

▍ Альтернатива: stackful-корутины


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

При использовании stackful-корутин у вас всё равно могут возникать те же проблемы с безопасностью, хотя спектр проблем, связанных с продолжительностью жизни, сокращается, поскольку здесь уже присутствует реальный стек.

При этом проблемы с производительностью отличаются. В этом случае у вас отсутствуют издержки, связанные с тем, что каждый вызов функции может привести к очередному выделению памяти или созданию множества других функций. Однако здесь утрачивается возможность оптимизировать корутины до минимального уровня за счёт встраивания всех внутренних вызовов функций.

Stackful-корутины не вирусны по своей природе, поэтому, если нужно будет между ними переключиться, для этого не потребуется одобрения всего стека вызовов. Хотя это также означает, что кто-то может приостановить вашу функцию без вашего ведома.

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ????️

Комментарии (6)


  1. vanxant
    18.08.2023 16:05
    +7

    Статья, видимо, интересная, но перевод как обычно ужасен. Уже в первом разделе сразу две грубых фактических ошибки:

    если их [регистров] оказывается достаточно, они [переменные] могут передаваться через стек

    ... конечно же, если НЕ достаточно

    В завершении же функции изначально зарезервированное пространство стека сбрасывается до своего состояния на момент первого её вызова

    ... чего? Сбрасывается (точнее восстанавливается) указатель стека, а не состояние стека целиком.

    И это явно косяки не оригинала, а переводчика, который ни в зуб ногой. Соответственно, возникает вопрос, какую чушь он напорол дальше. Я не знаю, как устроены корутины именно в С++, но из этой статьи с таким качеством перевода и не узнаю:(


    1. Bright_Translate Автор
      18.08.2023 16:05
      +1

      Спасибо за отмеченные недочёты. К сожалению, исходный текст написан изначально достаточно неграмотно. Какие-то моменты я, как получается, интерпретировал неверно. Буду признателен, если вы укажете и на другие ошибки. Исправлю.


    1. Bright_Translate Автор
      18.08.2023 16:05

      Что касается второй правки насчёт пространства стека, то вот исходный текст. Finally at the end of the function the stack space initially reserved get’s reset to where it was initially when the function first call happens then returns to the caller. По этому фрагменту можно примерно понять уровень знания английского языка автором. Возможно, и уровень знания плюсов, раз он ясно пишет про пространство стека, а не указатель, как исправили вы. Только вот тогда не понятно, как вписать в этот контекст указатель, когда всё предложение по смыслу выстроено вокруг пространства стека.


      1. vanxant
        18.08.2023 16:05

        stack space initially reserved get’s reset

        здесь подлежащее это первые четыре слова, "изначально зарезервированное пространство стека" [сбрасывается]. Не весь стек, а только зарезервированное пространство (т.е. указатель на вершину)


        1. Bright_Translate Автор
          18.08.2023 16:05

          У меня так и было переведено. Не про весь стек, а про изначально зарезервированное пространство...


    1. MiraclePtr
      18.08.2023 16:05
      -1

      Ещё резануло глаз"...функция будет встроена...", мозг на пару секунд зависает, пытаясь понять, а что же автор имел в виду - формально перевод по смыслу правильный, но я ни разу не слышал, чтобы плюсовики использовали это слово в этом контексте, обычно используют кальку с английского "заинлайнена" :)