Windows и C++

В поисках эффективных и компонуемых асинхронных систем

Кенни Керр

 

Kenny KerrАрхитектура аппаратного обеспечения сильно повлияла на язык программирования C, который следует императивному подходу к программированию компьютеров. При таком подходе программа описывается как последовательность выражений, которые отражают ее состояние. Это был осознанный выбор Денниса Ритчи (Dennis Ritchie), проектировщика языка C. Тем самым он смог создать успешную альтернативу языку ассемблера. Ритчи также внедрил структурную и процедурную парадигму, которая доказала свою эффективность в улучшении качества программ и расширении возможностей их сопровождения, что привело к созданию гораздо более сложного и мощного системного программного обеспечения.

Язык ассемблера обычно состоит из набора инструкций, поддерживаемых процессорами конкретного типа. Программист может ссылаться на регистры — буквально малые объемы памяти в самом процессоре, — а также на адреса в основной памяти. Язык ассемблера также содержит некоторые инструкции для переходов в различные участки программы, обеспечивая примитивный способ создания повторно используемых процедур. Чтобы реализовать функции на C, резервируется небольшое количество памяти, называемое стеком. По большей части этот стек, или стек вызовов, хранит информацию о каждой вызванной функции, чтобы программа могла автоматически сохранять состояние (как локальное, так и общее с вызвавшим кодом) и знать, откуда следует возобновлять выполнение по завершении функции. В наши дни это настолько неотъемлемая часть компьютерных вычислений, что большинство программистов считают это мелочью, не стоящей внимания, но это невероятно важная часть того, что делает возможным написание эффективных и сложных программ. Рассмотрим такой код:

int sum(int a, int b) { return a + b; }
int main()
{
  int x = sum(3, 4);
  return sum(x, 5);
}

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

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

Задачи и нарезка стека

Как я упоминал в прошлой статье (msdn.microsoft.com/magazine/jj553509), параллельная обработка не обязательно подразумевает программирование с применением многопоточности. Здесь возможно некоторое недопонимание. Поскольку язык C++ изначально не предоставлял никакой явной поддержки параллельной обработки, программисты, естественно, использовали различные методики, чтобы самостоятельно организовать такую обработку. По мере усложнения программ возникла вполне очевидная необходимость в делении программ на логические задачи. Каждая задача должна была бы быть своего рода мини-программой с собственным стеком. ОС реализует это с помощью потоков, и у каждого потока есть свой стек. Это позволяет выполнять задачи независимо и зачастую с вытеснением в соответствии с политикой планирования и доступностью нескольких процессорных ядер. Однако каждая задача, или мини-программа на C++, проста в написании и может выполняться последовательно благодаря изоляции стеков и состояний, хранимых в этих стеках. Увы, этот подход «один поток — одна задача» имеет некоторые очевидные ограничения. Во многих случаях выделение потока под каждую задачу влечет серьезные издержки и вообще является избыточным. И даже если бы это было не так, недостаток кооперации между потоками ведет к сильному усложнению из-за необходимости в синхронизации доступа к общему состоянию или во взаимодействии между потоками.

Другой подход, завоевавший широкую популярность, — программирование, управляемое событиями. Возможно, тот факт, что параллельная обработка не подразумевает многопоточного программирования, становится более очевидным при рассмотрении множества примеров разработок UI и библиотек, где для реализации одной из форм кооперативного управления задачами полагаются на функции обратного вызова. Но ограничений в этом подходе, как минимум, не меньше, чем в подходе «один поток — одна задача». Практически сразу ваша четкая последовательная программа превращается в мешанину из функций обратного вызова. Это иногда называют нарезкой стека (stack ripping), так как процедура, которая ранее была одной функцией, теперь разделена на две или более функций. Нарезка стека катастрофична, если вас хоть сколько-нибудь волнуют проблемы усложнения. Вместо одной функции у вас теперь минимум две. Вместо опоры на автоматическое сохранение локальных переменных в стеке вы должны явным образом управлять хранением этого состояния, так как оно должно присутствовать в двух участках стека. Простые языковые конструкции вроде циклов должны быть переписаны для адаптации под такое разделение. Наконец, отладка подобных программ намного труднее, поскольку состояние программы больше не содержится в стеке и зачастую его нужно вручную «заново собирать» в уме программиста. Рассмотрим пример простого драйвера флеш-памяти для встраиваемой системы из моей прошлой статьи, представленного синхронными операциями, чтобы обеспечить четкое последовательное выполнение:

void storage_read(void * buffer, uint32 size, uint32 offset);
void storage_write(void * buffer, uint32 size, uint32 offset);
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0);
  storage_write(buffer, sizeof(buffer), 1024);
}

Нетрудно представить, что здесь происходит. Буфер размером в 1 Кб, поддерживаемый стеком, передается функции storage_read, приостанавливая программу до тех пор, пока не все данные не будут считаны в этот буфер. Тот же буфер потом передается функции storage_write, приостанавливая программу до завершения операции. После этого программа корректно возвращает управление, автоматически освобождая пространство стека, использовавшееся для операции копирования. Очевидный недостаток в том, что эта программа не делает ничего полезного в приостановленном состоянии, ожидая завершения ввода-вывода.

В прошлой статье я продемонстрировал простую методику для реализации кооперативной многозадачности на C++ таким способом, который позволяет вернуться к последовательному стилю программирования. Однако без возможности использования локальных переменных эта методика довольно ограниченна. Хотя управление стеком остается автоматическим при вызове и возврате функций, утрата автоматических переменных в стеке довольно серьезное ограничение. Тем не менее, это позволяет избежать нарезки стека во всей ее красе. Подумайте, на что будет похож предыдущий код при использовании традиционного подхода с управлением на основе событий, и вы отчетливо увидите нарезку стека в действии. Во-первых, функции для работы с хранилищем пришлось бы объявлять заново для поддержки какой-то разновидности уведомления о событиях, обычно посредством функции обратного вызова:

typedef void (* storage_done)(void * context);
void storage_read(void * b, uint32 s, uint32 o, storage_done, void * context);
void storage_write(void * b, uint32 s, uint32 o, storage_done, void * context);

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

void write_done(void *)
{
  ...уведомляем о завершении...
}
void read_done(void * b)
{
  storage_write(b, 1024, 1024, write_done, nullptr);
}
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0, read_done, buffer);
  ...ожидаем уведомления о завершении...
}

Это явно гораздо сложнее, чем прежний синхронный подход, и тем не менее это во многом является нормой в сегодняшних программах на C и C++. Обратите внимание на то, как операция копирования, которая изначально была заключена в функцию main, теперь распределена по трем функциям. И дело не только в этом: фактически сейчас вам нужно рассматривать программу едва ли не в обратном порядке, так как обратный вызов write_done нужно объявлять до read_done, и все они должны быть объявлены до функции main. Однако данная программа — не более чем упрощенный пример, и вы уже должны представлять, во что это выльется при полной реализации «цепочки событий» в любом настоящем приложении.

В C++11 было сделано несколько важных шагов на пути к элегантному решению, но пока задача решена лишь отчасти. Хотя в стандартные библиотеки C++11 теперь заложено многое из того, что необходимо для параллельной обработки, в самом языке по-прежнему мало что заметно. Да и в библиотеках не зашли настолько далеко, чтобы программист легко писал на их основе более сложные компонуемые и асинхронные программы. Но не следует недооценивать проделанную работу, и C++11 является хорошим фундаментом для дальнейших улучшений. Сначала я покажу вам, что предлагает C++11 и чего в нем не хватает, а потом вы увидите возможное решение.

Замыкания и лямбда-выражения

В общих терминах, замыкание (closure) — это функция, связанная с неким состоянием, которое содержит какую-либо нелокальную информацию, необходимую данной функции для выполнения. Возьмем, к примеру, функцию TrySubmitThreadpoolCallback, о которой я рассказывал в прошлогодней серии статей о пуле потоков (msdn.microsoft.com/magazine/hh335066):

void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * state) { ... }
int main()
{
  void * state = ...
  TrySubmitThreadpoolCallback(callback, state, nullptr);
  ...
}

Заметьте, что Windows-функция принимает как функцию, так и некое состояние. Это фактически скрытое замыкание; оно не выглядит как типичное замыкание, но функциональность та же. Пожалуй, объекты-функции дают тот же результат. Замыкания как полноценная концепция приобрели известность в мире функционального программирования, но в C++11 предприняты серьезные меры для поддержки и этой концепции — в форме лямбда-выражений:

void submit(function<void()> f) { f(); }
int main()
{
  int state = 123;
  submit([state]() { printf("%d\n", state); });
}

В этом примере имеется простая функция submit; притворимся, будто она вызывает выполнение переданного объекта-функции в каком-то другом контексте. Объект-функция создается из лямбда-выражения в функции main. Это лямбда-выражение включает атрибуты, необходимые для его определения в качестве замыкания. Часть [state] указывает, что состояние должно «захватываться» («captured»), а остальное — это фактически анонимная функция, имеющая доступ к этому состоянию. Вы можете четко видеть, что компилятор создаст допустимый эквивалент объекта-функции. Будь функция submit шаблоном, компилятор мог бы даже оптимизировать сам объект-функцию, что позволило бы выиграть не только в наглядности синтаксиса, но и в производительности. Однако возникает более серьезный вопрос: действительно ли это допустимое замыкание? Замыкает ли выражение эта лямбда за счет связывания нелокальной переменной? Следующий пример должен прояснить по меньшей мере часть головоломки:

int main()
{
  int state = 123;
  auto f = [state]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

Эта программа выводит «123», а не «0», так как переменная state была захвачена по значению, а не по ссылке. Конечно, я могу указать захват этой переменной по ссылке:

int main()
{
  int state = 123;
  auto f = [&]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

Здесь для захвата переменных по ссылке я указываю режим по умолчанию и даю компилятору возможность самому догадаться, что я ссылаюсь на переменную state. Как и следовало ожидать, эта программа теперь послушно печатает «0» вместо «123». Проблема, конечно, в том, что хранилище для этой переменной все еще связано с тем фреймом стека, в котором она была объявлена. Если в выполнении функция submit возникнет задержка и произойдет раскрутка стека, состояние может быть утрачено, и ваша программа стала бы некорректной.

В динамических языках наподобие JavaScript эта проблема обходится слиянием императивного мира C с функциональным стилем, при котором зависимость от стека гораздо меньше и каждый объект, по сути, является неупорядоченным ассоциативным контейнером. C++11 предоставляет шаблоны shared_ptr и make_shared — эффективные альтернативы, хоть и не совсем точные эквиваленты. Так, лямбда-выражения и смарт-указатели отчасти решают эту проблему, позволяя определять замыкания в контексте и разрешая отделять состояние от стека без чрезмерных синтаксических издержек. Решение не идеальное, но это лишь начало.

Обязательства и фьючерсы

На первый взгляд ответ могло бы дать другое средство C++11 — фьючерсы (ключевое слово future). Фьючерсы можно рассматривать как явную поддержку асинхронных вызовов функций. Разумеется, проблема в определении того, что именно это означает и как оно реализуется. Пояснить, что такое фьючерсы, гораздо легче на примере. Версия исходной синхронной функции storage_read с поддержкой фьючерса могла бы выглядеть так:

// void storage_read(void * b, uint32 s, uint32 o);
future<void> storage_read(void * b, uint32 s, uint32 o);

Единственное различие — возвращаемый тип обернут в шаблон future. Идея в том, что новая функция storage_read будет начинать передачу данных или ставить их в очередь до возврата объекта future. Этот future потом можно использовать как синхронизирующий объект для ожидания завершения операции:

int main()
{
  uint8 buffer[1024];
  auto f = storage_read(buffer, sizeof(buffer), 0);
  ...
  f.wait();
  ...
}

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

future<void> storage_read(void * b, uint32 s, uint32 o)
{
  promise<void> p;
  auto f = p.get_future();
  begin_transfer(move(p), b, s, o);
  return f;
}

По завершении операции драйвер хранилища можно уведомить об этом фьючерс:

p.set_value();

Что еще за value? Ну, это вообще не значение, поскольку мы используем специализации promise и future для void, но вы можете вообразить нечто вроде абстракции файловой системы, построенной поверх этого драйвера хранилища, которая могла бы включать функцию file_read. Эту функцию, возможно, пришлось бы вызывать, не зная размера конкретного файла. Впоследствии она могла бы возвращать количество переданных байтов:

future<int> file_read(void * b, uint32 s, uint32 o);

В этом сценарии нужно было бы использовать еще и promise с типом int, тем самым обеспечивая канал, по котором сообщается количество переданных байтов:

promise<int> p;
auto f = p.get_future();
...
p.set_value(123);
...
f.wait();
printf("bytes %d\n", f.get());

У future имеется метод get, через который можно получить результат. Отлично, у нас есть способ ожидания на future, и все наши проблемы решены! Увы, не стоит так торопиться с выводами. Действительно ли это решает проблему? Можем ли мы одновременно запускать несколько операций? Да. Можно ли легко компоновать агрегатные операции или даже ожидать одну или все незавершенные операции? Нет. В исходном примере с синхронными функциями операция чтения обязательно завершалась до начала операции записи. Так что на деле фьючерсы продвигают нас не так уж и далеко. Проблема в том, что акт ожидания на фьючерсе по прежнему является синхронной операцией и нет никакого стандартного способа скомпоновать цепочку событий. Нет и способа создать агрегат фьючерсов.

Фьючерсы в будущем

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

int bytes;
if (f.try_get(bytes))
{
  printf("bytes %d\n", bytes);
}

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

int main()
{
  uint8 buffer[1024];
  auto fr = storage_read(buffer, sizeof(buffer), 0);
  auto fw = fr.then([&]()
  {
    return storage_write(buffer, sizeof(buffer), 1024);
  });
  ...
}

Функция storage_read возвращает фьючер чтения (fr), а лямбда-выражение применяется для конструирования продолжения (continuation) этого фьючерса, используя его метод then, что дает нам фьючерс записи (fw). Поскольку фьючерсы всегда возвращают управление, вы могли бы предпочесть более неявный, но эквивалентный стиль:

auto f = storage_read(buffer, sizeof(buffer), 0).then([&]()
{
  return storage_write(buffer, sizeof(buffer), 1024);
});

В этом случае имеется лишь один явный фьючерс, представляющий кульминацию всех операций. Это можно было бы назвать последовательной композицией, но параллельная композиция AND и OR также была бы существенной для большинства нетривиальных систем (подумайте о WaitForMultipleObjects). Здесь нам понадобилась бы пара вариативных (variadic) функций wait_any и wait_all. И вновь они возвращали бы фьючерсы, позволяя нам предоставлять лямбда-выражение в качестве продолжения агрегата, используя метод then, как и ранее. Также было бы удобно передавать завершенный фьючерс в продолжение в тех случаях, где конкретный завершенный фьючерс не является явным.

Более детализированное мнение по будущему фьючерсов, в том числе по такой важной тематике, как отмена операций, изложено в техническом документе Артура Лаксберга (Artur Laksberg) и Никласа Густафссона (Niklas Gustafsson) «A Standard Programmatic Interface for Asynchronous Operations» по ссылке bit.ly/MEgzhn.

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


Кенни Керр (Kenny Kerr) — высококвалифицированный специалист в области разработки ПО для Windows. С ним можно связаться через kennykerr.ca.

Выражаю благодарность за рецензирование статьи эксперту Артуру Лаксбергу (Artur Laksberg).