Объяснение времени жизни лямбда для сопрограмм C ++ 20 - PullRequest
3 голосов
/ 09 марта 2020

Безумие имеет полезную библиотеку для сопрограмм в стиле C ++ 20.

В файле Readme он утверждает:

ВАЖНО: Вы должны быть очень внимательно относитесь к временам жизни временных лямбда-объектов. Вызов лямбда-сопрограммы возвращает folly :: coro :: Task, который захватывает ссылку на лямбду, и поэтому, если возвращаемая задача не сразу co_awaited, тогда задача останется с висячей ссылкой, когда временная лямбда выйдет из области видимости.

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

#include <folly/experimental/coro/Task.h>
#include <folly/experimental/coro/BlockingWait.h>
#include <folly/futures/Future.h>
using namespace folly;
using namespace folly::coro;

int main() {
    fmt::print("Result: {}\n", blockingWait(foo()));
}

Я скомпилировал следующее с адресным дезинфицирующим средством, чтобы увидеть, не будет ли каких-либо свисающих ссылок.

РЕДАКТИРОВАТЬ: уточненный вопрос

Вопрос : Почему второй пример не вызывает предупреждение ASAN?

Согласно cppreference :

Когда сопрограмма достигает оператора co_return, она выполняет следующее:

...

  • или вызывает promise.return_value (expr) для co_return expr, где expr имеет не void тип
  • уничтожает все переменные с автоматическим c сроком хранения в обратном порядке, в котором они были созданы.
  • вызывает обещание.final_suspend () и co_await - результат.

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


ОШИБКА АСАН : Я полагаю, 'i 'не существует там, где В сопрограмме ожидается

auto foo() -> Task<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }(); // lambda is destroyed after this semicolon
    return task;
}

НЕТ ОШИБКИ - почему?

auto foo() -> Task<int> {
  auto task = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  }();
  co_return co_await std::move(task);
}

ОШИБКА АСАН : Та же проблема, что и в первом примере?

auto foo() -> folly::SemiFuture<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }();
    return std::move(task).semi();
}

NO ERROR ... и для правильной меры просто возвращение константы (без захвата лямбда-состояния) работает нормально. Сравните с первым примером:

auto foo() -> Task<int> {
    auto task = []() -> folly::coro::Task<int> {
        co_return 1;
    }();
    return task;
}

1 Ответ

6 голосов
/ 09 марта 2020

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

Сначала немного терминологии.

В C ++ "лямбда" - это объект , а не функция. У лямбда-объекта есть перегрузка для оператора вызова функции operator(), который вызывает код, записанный в лямбда-тело. Это все лямбда, поэтому, когда я впоследствии ссылаюсь на «лямбда», я имею в виду объект C ++ и , а не функцию .

В C ++ быть «сопрограммой» является свойство функции , а не объекта. Сопрограмма - это функция, внешне идентичная обычной функции, но реализованная внутри таким образом, что ее выполнение может быть приостановлено. Когда сопрограмма приостанавливается, выполнение возвращается к функции, которая непосредственно вызвала / возобновила сопрограмму.

Выполнение сопрограммы может быть позже возобновлено (механизм для этого я не буду обсуждать много Вот). Когда сопрограмма приостановлена, все переменные стека в этой функции сопрограммы сохраняются до точки приостановки сопрограммы. Этот факт позволяет возобновить работу сопрограммы; это то, что делает код сопрограммы похожим на нормальный C ++, даже если выполнение может происходить совершенно непересекающимся образом.

Сопрограмма не является объектом, а лямбда-выражение не является функцией. Итак, когда я использую, казалось бы, противоречивый термин «сопрограмм лямбда», то, что я действительно имею в виду, это объект, перегрузка которого operator() оказывается сопрограммой.

Понятно ли нам? ОК.

Важный факт # 1:

Когда компилятор оценивает лямбда-выражение, он создает значение типа лямбда. Это prvalue будет (в конечном итоге) инициализировать объект, обычно как временный в рамках функции, которая оценивала рассматриваемое лямбда-выражение. Но это может быть переменная стека. Что это на самом деле не имеет значения; важно то, что при оценке лямбда-выражения существует объект, который во всех отношениях похож на обычный объект C ++ любого пользовательского типа. Это означает, что у него есть время жизни.

Значения, «захваченные» лямбда-выражением, по сути являются переменными-членами лямбда-объекта. Это могут быть ссылки или значения; это на самом деле не имеет значения. Когда вы используете имя захвата в лямбда-теле, вы действительно получаете доступ к именованной переменной-члену лямбда-объекта. И правила для переменных-членов в лямбда-объекте ничем не отличаются от правил для переменных-членов в любом определяемом пользователем объекте.

Важный факт # 2:

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

И , то есть все , которые будут сохранены.

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

Важный факт № 3:

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

Это , почему вы сделали его сопрограммой для начала .

Смысл объекта folly::coro::Task состоит в том, чтобы по существу отслеживать выполнение сопрограммы после приостановки, а также маршалировать любое возвращаемое значение (значения), генерируемые им. Это также может позволить запланировать возобновление некоторого другого кода после выполнения сопрограммы, которую он представляет. Таким образом, Task может представлять длинную серию выполнений сопрограмм с каждой передачей данных следующим.

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

Итак, давайте соединим эти факты вместе.

Если вы - функция, которая создает лямбда, тогда у вас (по крайней мере, в течение некоторого периода времени) есть значение этой лямбды, верно? Вы либо сохраните его самостоятельно (как временную переменную или переменную стека), либо передадите его кому-то еще. Либо вы, либо кто-то другой в какой-то момент призовет operator() этой лямбды. В этот момент лямбда-объект должен быть живым, функциональным объектом, или у вас на руках гораздо большая проблема.

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

И вот где мы сталкиваемся с последствиями IF # 3. Видите, время жизни лямбда-объекта контролируется кодом, который изначально вызывал лямбда-выражение. Но выполнение сопрограммы в пределах этой лямбды контролируется каким-то произвольным внешним кодом. Система, которая управляет этим выполнением, является объектом Task, возвращаемым непосредственному вызывающему при начальном выполнении лямбда-сопрограммы.

Таким образом, есть Task, который представляет выполнение функции сопрограммы. Но есть и лямбда-объект. Это оба объекта, но они являются отдельными объектами с разными временами жизни.

IF # 1 говорит нам, что лямбда-захваты являются переменными-членами, а правила C ++ говорят нам, что время жизни член управляется временем жизни объекта, членом которого он является. IF # 2 говорит нам, что эти переменные-члены не сохраняются механизмом приостановки сопрограммы. И если IF # 3 говорит нам, что выполнение сопрограммы регулируется Task, выполнение которого может (очень) не связано с исходным кодом.

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

Как именно вы это делаете, зависит от вас.


Теперь давайте посмотрим на ваши примеры.

Пример 1 не работает по очевидным причинам. Код, вызывающий сопрограмму, создает временный объект, представляющий лямбду. Но этот временный уходит из сферы сразу. Не предпринимается никаких усилий для обеспечения того, чтобы лямбда оставалась в существовании во время выполнения Task. Это означает, что сопрограмма может быть возобновлена ​​после уничтожения лямбда-объекта, в котором она живет.

Это плохо.

Пример 2 на самом деле так же плох. Временное лямбда-выражение уничтожается сразу после создания tasks, поэтому простое его использование не должно иметь значения. Однако, ASAN, возможно, просто не поймал это, потому что теперь это происходит внутри сопрограммы. Если ваш код был:

Task<int> foo() {
  auto func = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  };

  auto task = func();

  co_return co_await std::move(task);
}

Тогда код будет в порядке. Причина в том, что co_await при Task заставляет текущую сопрограмму приостанавливать ее выполнение до тех пор, пока не будет выполнена последняя вещь в Task, и эта «последняя вещь» - func. А поскольку объекты стека сохраняются с помощью приостановки сопрограммы, func будет существовать до тех пор, пока эта сопрограмма существует.

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

Технически пример 4 так же плох, как и все остальные. Однако, поскольку лямбда не захватывает, ей никогда не требуется доступ к каким-либо элементам лямбда-объекта. На самом деле он никогда не обращается к какому-либо объекту, срок жизни которого закончился, поэтому ASAN никогда не замечает, что объект вокруг сопрограммы мертв. Это UB, но это UB, который вряд ли причинит тебе боль. Если бы вы явно извлекли указатель на функцию из лямбды, даже этого UB не произошло бы:

Task<int> foo() {
    auto func = +[]() -> folly::coro::Task<int> { //The + extracts a function pointer from a captureless lambda for complex, convoluted reasons.
        co_return 1;
    };
    auto task = func();
    return task;
}
...