Octubre de 2015

Volumen 30, número 10

Corrutinas de Windows con C++ - en Visual C++ 2015

Por Kenny Kerr | Octubre de 2015

En primer lugar, estudié las corrutinas en C++ en 2012 y escribí sobre las ideas que recopilé en una serie de artículos en MSDN Magazine. Exploré un formulario ligero de multitarea cooperativa que emulaba las corrutinas mediante trucos inteligentes con instrucciones switch. Después, abordé varios esfuerzos para mejorar la eficacia y la componibilidad de sistemas asíncronos con las extensiones propuestas para valores futuros o promesas. Por último, traté algunos desafíos que existen incluso con la visión más futurista posible, así como una propuesta para lo que se conoce como funciones reanudables. Si le interesan algunos de estos desafíos y la historia relacionada con la elegante simultaneidad en C++, le animo a leer estos artículos:

Gran parte de esta información es teórica, ya que no disponía de un compilador que implementara todas estas ideas y tenía que emularlas de distintas formas. Y entonces apareció Visual Studio 2015 a principios de este año. Esta edición de Visual C++ incluye una opción de compilador experimental denominada /await que desbloquea una implementación de corrutinas admitida directamente por el compilador. Se acabaron la piratería, las macros y los demás trucos de magia. Esto es real, es experimental y aún no ha sido sancionado por el comité de C++. Y no se trata solo de azúcar sintáctico en el front end del compilador, como ocurre con la palabra clave yield y los métodos asíncronos de C#. La implementación de C++ incluye una profunda investigación de ingeniería en el back-end del compilador que ofrece una implementación increíblemente escalable. De hecho, va mucho más allá de lo que encontraría si el front end del compilador simplemente proporcionara una sintaxis más cómoda para trabajar con valores futuros o promesas o incluso la clase de tareas Runtime de simultaneidad. Por tanto, volvamos a visitar este tema y veamos qué aspecto tiene hoy en día. Muchas cosas han cambiado desde 2012, por lo que debemos comenzar con una breve recapitulación para ilustrar de dónde venimos y dónde estamos antes de contemplar ejemplos más específicos y usos prácticos.

Concluí las series mencionadas con un ejemplo convincente de las funciones reanudables, así que empezaré por ahí. Imagine un par de recursos para leer un archivo y escribir en una conexión de red:

struct File
{
  unsigned Read(void * buffer, unsigned size);
};
struct Network
{
  void Write(void const * buffer, unsigned size);
};

Puede usar su imaginación para el resto, pero esto es bastante representativo de qué aspecto puede tener la E/S sincrónica tradicional. El método de lectura de archivos intentará leer los datos desde la posición del archivo actual en el búfer hasta un tamaño máximo y devolverá el número real de bytes copiados. Si el valor devuelto es inferior al tamaño solicitado, esto suele significar que se ha llegado al final del archivo. La clase Network modela un protocolo típico orientado a la conexión, como TCP o una canalización con nombre de Windows. El método Write copia un número específico de bytes en la pila de red. Una operación sincrónica típica es muy sencilla de imaginar, pero le ayudaré a verla con mayor claridad con la Ilustración 1 para que tenga un marco de referencia.

Ilustración 1: Operación de copia sincrónica

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
while (unsigned const actual = file.Read(buffer, sizeof(buffer)))
{
  network.Write(buffer, actual);
}

Siempre que el método Read devuelva un valor superior a cero, los bytes resultantes se copian del búfer intermedio en la red mediante el método Write. Este es el tipo de código que cualquier programador razonable entendería sin problemas, independientemente de su experiencia. Naturalmente, Windows ofrece servicios que pueden descargar este tipo de operación por completo en el kernel para evitar todas las transiciones, pero dichos servicios están limitados a escenarios específicos, y esto es representativo de los tipos de operaciones de bloqueo a las que las aplicaciones suelen estar ligadas.

La biblioteca estándar de C++ ofrece valores futuros en un intento de admitir las operaciones asincrónicas, pero han sido muy criticados por su ingenuo diseño. Hablé de estos problemas en 2012. Incluso pasando por alto estos problemas, volver a escribir un ejemplo de copia de archivo a red en la Ilustración 1 es una tarea no trivial. La traducción más directa del bucle while sincrónico (y simple) requiere un algoritmo de iteración cuidadosamente creado para que pueda recorrer una cadena de valores futuros:

template <typename F>
future<void> do_while(F body)
{
  shared_ptr<promise<void>> done = make_shared<promise<void>>();
  iteration(body, done);
  return done->get_future();
}

El algoritmo cobra vida en la función de iteración:

template <typename F>
void iteration(F body, shared_ptr<promise<void>> const & done)
{
  body().then([=](future<bool> const & previous)
  {
    if (previous.get()) { iteration(body, done); }
    else { done->set_value(); }
  });
}

El lambda debe capturar la promesa compartida por el valor, ya que es bastante más iterativo que recursivo. Sin embargo, esto es problemático, ya que conlleva un par de operaciones interbloqueadas para cada iteración. Asimismo, los valores futuros aún no tienen un método “then” para encadenar continuaciones, aunque podría simularlo con la clase de tarea Runtime de simultaneidad. Aun así, asumiendo que estos algoritmos y continuaciones de valores futuros existen, podría reescribir la operación de copia sincrónica de la Ilustración 1 de una forma asincrónica. En primer lugar, tendría que agregar sobrecargas asincrónicas a las clases File y Network. Quizá algo parecido a lo siguiente:

struct File
{
  unsigned Read(void * buffer, unsigned const size);
  future<unsigned> ReadAsync(void * buffer, unsigned const size);
};
struct Network
{
  void Write(void const * buffer, unsigned const size);
  future<unsigned> WriteAsync(void const * buffer, unsigned const size)
};

El valor futuro del método WriteAsync debe repetir el número de bytes copiados, ya que esto es todo lo que puede tener cualquier continuación para decidir si finalizar la iteración. Otra opción para la clase File podría ser proporcionar un método EndOfFile. En cualquier caso, dados estos nuevos primitivos, la operación de copia se puede expresar de forma que pueda entenderse después de ingerir una cantidad suficiente de cafeína. En la Ilustración 2 se muestra este enfoque.

Ilustración 2: Operación de copia con valores futuros

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
future<void> operation = do_while([&]
{
  return file.ReadAsync(buffer, sizeof(buffer))
    .then([&](task<unsigned> const & read)
    {
      return network.WriteAsync(buffer, read.get());
    })
    .then([&](task<unsigned> const & write)
    {
      return write.get() == sizeof(buffer);
    });
});
operation.get();

El algoritmo do_while facilita el encadenamiento de continuaciones siempre que el “cuerpo” del bucle devuelva true. Así que se llama a ReadAsync, cuyo resultado usa WriteAsync, cuyo resultado se prueba como condición de bucle. No es ninguna ciencia espacial, pero no desearía escribir código como ese. Es artificial y rápidamente se hace demasiado complejo como para razonar sobre él. Hablemos de las funciones reanudables.

Al agregar la opción de compilador /await, hacemos posible la compatibilidad del compilador con las funciones reanudables, una implementación de corrutinas para C++. Se les llama funciones reanudables en lugar de simplemente corrutinas porque están diseñadas para comportarse lo más parecido posible a las funciones C++ tradicionales. De hecho, a diferencia de lo que comenté en 2012, un usuario de una función no tendría por qué saber en absoluto que se implementa, en efecto, como una corrutina.

En lo que respecta a este escrito, la opción de compilador /await también necesita la opción /Zi en lugar de la opción /ZI predeterminada para la característica de editar y continuar del depurador. También debe deshabilitar las comprobaciones de SDL con la opción /sdl- y evitar las opciones /RTC, ya que las comprobaciones en tiempo de ejecución del compilador no son compatibles con las corrutinas. Todas estas limitaciones son temporales y se deben a la naturaleza experimental de la implementación, y espero que se hayan solucionado en las próximas actualizaciones del compilador. Pero merecen la pena, como puede ver en la Ilustración 3. Es absoluta e irrefutablemente más sencillo de escribir, y más fácil de entender que lo que se requería para la operación de copia implementada con valores futuros. De hecho, se parece mucho al ejemplo sincrónico original de la Ilustración 1. Además, en este caso no hay necesidad de que el valor futuro WriteAsync devuelva ningún valor específico.

Ilustración 3: Operación de copia en la función reanudable

future<void> Copy()
{
  File file = // Open file
  Network network = // Open connection
  uint8_t buffer[4096];
  while (unsigned copied = await file.ReadAsync(buffer, sizeof(buffer)))
  {
    await network.WriteAsync(buffer, copied);
  }
}

La palabra clave await usada en la Ilustración 3, así como otras nuevas palabras clave proporcionadas por la opción de compilador /await, pueden aparecer solo en una función de copia circundante que devuelve un valor futuro. Voy a usar los mismos métodos ReadAsync y WriteAsync del ejemplo anterior, pero es importante observar que el compilador no sabe nada sobre valores futuros. De hecho, no necesitan ser futuros en absoluto. Así que, ¿cómo funciona? Bueno, no funcionará a menos que se escriban determinadas funciones de adaptador para proporcionar al compilador los enlaces necesarios. Esto es análogo a la forma en la que el compilador descubre cómo enlazar una instrucción for basada en rangos para las funciones begin y end adecuadas. En el caso de una expresión await, en lugar de buscar begin y end, el compilador busca funciones adecuadas denominadas await_ready, await_suspend y await_resume. Al igual que begin y end, estas nuevas funciones pueden ser funciones miembro o funciones libres. La capacidad de escribir funciones no miembro es tremendamente útil, ya que puede escribir adaptadores para tipos existentes que proporcionen la semántica necesaria, como es el caso de los valores futuro que hemos explorado hasta ahora. La Ilustración 4 proporciona un conjunto de adaptadores que ayudarían para la interpretación del compilador de la función reanudable en la Ilustración 3.

Ilustración 4: Adaptadores await para un valor futuro hipotético

namespace std
{
  template <typename T>
  bool await_ready(future<T> const & t)
  {
    return t.is_done();
  }
  template <typename T, typename F>
  void await_suspend(future<T> const & t, F resume)
  {
    t.then([=](future<T> const &)
    {
      resume();
    });
  }
  template <typename T>
  T await_resume(future<T> const & t)
  {
    return t.get();
  }
}

De nuevo, tenga en cuenta que la plantilla de clases de valores futuros de la biblioteca estándar de C++ aún no ofrece un método “then” para agregar una continuación, pero es todo lo que necesitaría para hacer que este ejemplo funcione con el compilador actual. La palabra clave await en una función reanudable configura de forma eficaz un posible punto de suspensión en el que la ejecución debe abandonar la función si la operación aún no se ha completado. Si await_ready devuelve true, entonces la ejecución no se suspende y se llama inmediatamente a await_resume para obtener el resultado. Si, por el contrario, await_ready devuelve false, se llama a await_suspend, lo que permite que la operación registre una función de reanudación proporcionada por el compilador para que se la llame en la finalización eventual del proceso. Tan pronto como se llama a la función de reanudación, las corrutinas se reanudan en el punto de suspensión anterior y la ejecución continúa a la siguiente expresión await o hasta la finalización de la función.

Tenga en cuenta que la reanudación se produce en cualquier subproceso denominado como función de reanudación del compilador. Esto significa que es totalmente posible que una función reanudable comience a funcionar en un subproceso y, más adelante, se reanude y continúe la ejecución en otro subproceso. Esto es recomendable desde la perspectiva del rendimiento, ya que la alternativa conllevaría enviar la reanudación a otro subproceso, lo cual suele ser caro e innecesario. Por otra parte, puede haber casos en los que sea recomendable e incluso necesario en caso de que un código posterior tenga afinidad con un subproceso, como ocurre con el código más gráfico. Desafortunadamente, la palabra clave await aún no tiene una forma de permitir al autor de una expresión await proporcionar una sugerencia como esta al compilador. Esto no tiene precedentes. El runtime de simultaneidad sí cuenta con esta opción pero, curiosamente, el lenguaje C++ en sí proporciona un patrón que puede seguir:

int * p = new int(1);
// Or
int * p = new (nothrow) int(1);

De la misma forma, la expresión await necesita un mecanismo para proporcionar una sugerencia a la función await_suspend para que afecte al contexto del subproceso en el que se produce la reanudación:

await network.WriteAsync(buffer, copied);
// Or
await (same_thread) network.WriteAsync(buffer, copied);

De forma predeterminada, la reanudación tiene lugar de la forma más eficiente posible para la operación. La misma constante same_thread del algún tipo hipotético std::same_thread_t eliminaría la ambigüedad entre las sobrecargas de la función await_suspend. La función await_suspend de la Ilustración 3 debería ser la opción predeterminada y más eficaz, ya que probablemente se reanudaría en un subproceso de un trabajador y se completaría sin mayor cambio de contexto. La sobrecarga same_thread mostrada en la Ilustración 5 podría solicitarse cuando el consumidor necesite afinidad con el suproceso.

Ilustración 5: Sobrecarga hipotética de await_suspend

template <typename T, typename F>
void await_suspend(future<T> const & t, F resume, same_thread_t const &)
{
  ComPtr<IContextCallback> context;
  check(CoGetObjectContext(__uuidof(context),
    reinterpret_cast<void **>(set(context))));
  t.then([=](future<T> const &)
  {
    ComCallData data = {};
    data.pUserDefined = resume.to_address();
    check(context->ContextCallback([](ComCallData * data)
    {
      F::from_address(data->pUserDefined)();
      return S_OK;
    },
    &data,
    IID_ICallbackWithNoReentrancyToApplicationSTA,
    5,
    nullptr));
  });
}

Esta sobrecarga recupera la interfaz IContextCallback para el suproceso de llamada (o apartamento). La continuación llamará finalmente a la función de reanudación del compilador de este mismo contexto. Si resulta que es el STA de la aplicación, esta podría continuar perfectamente interactuando con otros servicios con afinidad de subproceso. La plantilla de clase ComPtr y la función de ayuda de comprobación forman parte de la biblioteca Modern, que puede descargar en github.com/kennykerr/modern, pero también puede usar cualquiera que tenga a su disposición.

He abarcado mucha materia, alguna de la cual seguirá siendo de algún modo teórica, pero el compilador Visual C++ ya está manos a la obra para hacerlo posible. Es un momento emocionante para los desarrolladores de C++ interesados en la simultaneidad, y espero que se me una de nuevo el próximo mes, ya que ahondaré más en las funciones reanudables con Visual C++.


Kenny Kerr es programador informático radicado en Canadá, además de autor para Pluralsight y MVP de Microsoft. Tiene un blog en kennykerr.ca y puede seguirlo en Twitter en @kennykerr.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Gor Nishanov