Compartir a través de


Este artículo proviene de un motor de traducción automática.

Windows con C++

Volver al futuro con las funciones reanudables

Kenny Kerr

 

Kenny KerrConcluí mi última columna (msdn.microsoft.com/magazine/jj618294) destacando algunos posibles mejoras a C ++ 11 futuros y promesas que transforman de ser en gran medida académica y simplista práctico y útil para la construcción de sistemas asincrónicas eficientes y extensible. En gran parte, esto fue inspirado por la obra de Niklas Gustafsson y Artur Laksberg del equipo de Visual C++.

Como una representación del futuro de futuros, dio un ejemplo a lo largo de estas líneas:

 

int main()
{
  uint8 b[1024];
  auto f = storage_read(b, sizeof(b), 0).then([&]()
  {
    return storage_write(b, sizeof(b), 1024);
  });
  f.wait();
}

El storage_read y el storage_write funciones devuelven un futuro que representan las respectivas operaciones de I/O que podrían terminar en algún momento en el futuro. Estas funciones modelo algunos subsistema de almacenamiento con páginas de 1 KB. El programa conjunto Lee la primera página de almacenamiento en el búfer y luego lo copia en la segunda página de almacenamiento de información. La novedad de este ejemplo es en el uso del hipotética "entonces" método añadido a la clase futura, permitiendo que las operaciones de lectura y escritura que compone en una sola operación lógica de I/O, que puede ser perfectamente esperó a.

Esta es una gran mejora sobre el mundo de ripeo de pila que he descrito en mi última columna, sin embargo, es en sí mismo todavía no del todo el sueño utópico de una instalación de coroutine-como apoyada por el lenguaje que he descrito en mi columna de agosto de 2012, "Ligero cooperativa multitarea con C++" (msdn.microsoft.com/magazine/jj553509). En esa columna con éxito demostrado cómo se puede lograr una facilidad con algunos trucos de macro dramático — pero no sin inconvenientes significativos, principalmente relacionados con la incapacidad de usar variables locales. Este mes quiero compartir algunas reflexiones sobre cómo podría lograrse en el propio lenguaje C++.

Necesariamente comencé esta serie de artículos explorar técnicas alternativas para lograr la concurrencia con una solución práctica porque la realidad es que necesitamos soluciones que funcionan hoy en día. Hacemos, sin embargo, es necesario para mirar hacia el futuro y empuje el C++ comunidad adelante exigiendo un mayor apoyo para escribir aplicaciones de / O-intensivas en una manera más natural y productiva. Seguramente sistemas altamente escalables de escritura no deberían ser la competencia exclusiva de los programadores de JavaScript y C# y el programador de C++ raro con suficiente fuerza de voluntad. Además, tenga en cuenta que esto no es sólo de comodidad y elegancia en la sintaxis y el estilo de programación. La capacidad de tener varias solicitudes de E/S activas en un momento dado tiene el potencial de mejorar considerablemente el rendimiento. Controladores de almacenamiento y de red están diseñados para escalar así como más solicitudes de I/O en vuelo. En el caso de los controladores de almacenamiento, las solicitudes se pueden combinar para mejorar el hardware de almacenamiento en búfer y reducir tiempos de búsqueda. En el caso de los controladores de red, más solicitudes significan grandes paquetes de la red, operaciones optimizadas de ventana deslizante y mucho más.

Voy a cambiar engranajes ligeramente para ilustrar cómo rápidamente complejidad rears su fea cabeza. En lugar de simplemente leer y escribir a y desde un dispositivo de almacenamiento de información, ¿qué servir contenido de un archivo mediante una conexión de red? Como antes, voy a empezar con un enfoque sincrónico y trabajar desde allí. Equipos sería fundamentalmente asincrónicas, pero mortales ciertamente no somos. No sé ustedes, pero yo nunca he sido mucho de un multitasker. Considere las siguientes clases:

class file { uint32 read(void * b, uint32 s); };
class net { void write(void * b, uint32 s); };

Utilice su imaginación para completar el resto. Sólo necesito una clase de archivo que permite a un cierto número de bytes a leer desde un archivo. Voy más, supongo que el objeto de archivo será realizar un seguimiento de la compensación. Asimismo, la clase neta podría modelo una secuencia TCP donde se maneja el desplazamiento de datos TCP a través de su aplicación de ventana deslizante que necesariamente queda oculta a la persona que llama. Para una variedad de razones, quizás relacionados con el almacenamiento en caché o contención, método no podría devolver siempre el número de bytes que realmente pidió leer el archivo. Sin embargo, sólo devolverá cero cuando se ha alcanzado el final del archivo. El método de escritura neto es más sencillo porque la implementación de TCP, por diseño, afortunadamente hace una enorme cantidad de trabajo que mantenga el presente simple para la persona que llama. Se trata de un escenario imaginario básico pero bastante representativa de la entrada-salida de OS. Ahora puedo escribir el siguiente programa simple:

int main()
{
  file f = ...; net n = ...; uint8 b[4096];
  while (auto actual = f.read(b, sizeof(b)))
  {
    n.write(b, actual);
  }
}

Dado un archivo de 10 KB, os podéis imaginar la siguiente secuencia de eventos antes de que se agote el bucle:

read 4096 bytes -> write 4096 bytes ->
read 4096 bytes -> write 4096 bytes ->
read 2048 bytes -> write 2048 bytes ->
read 0 bytes

Como el ejemplo sincrónico en mi última columna, no es difícil averiguar qué está pasando aquí, gracias a la naturaleza secuencial de C++. Realizando el cambio a composición asincrónica es un poco más difícil. El primer paso es transformar el archivo y clases netas para volver futuros:

class file { future<uint32> read(void * b, uint32 s); };
class net { future<void> write(void * b, uint32 s); };

Esta fue la parte fácil. Reescribir la función principal para tomar ventaja de cualquier asincronía en estos métodos presenta algunos desafíos. Ya no es suficiente utilizar hipotético "entonces" método del futuro, porque ya no simplemente estoy lidiando con composición secuencial. Sí, es cierto que una escritura sigue una lectura, pero sólo si la lectura Lee realmente algo. Para complicar las cosas aún más, una lectura también sigue una escritura en todos los casos. Podría verse tentado a pensar en términos de cierres, pero ese concepto abarca la composición del Estado y el comportamiento y no la composición del comportamiento con el comportamiento de otros.

Pude empezar creando cierres sólo para la lectura y las operaciones de escritura:

auto read = [&]() { return f.read(b, sizeof(b)); };
auto write = [&](uint32 actual) { n.write(b, actual); };

Por supuesto, esto no funciona bien porque el futuro entonces el método no sabe qué pasa a la función de escritura:

read().then(write);

Para enfrentar esto, necesito algún tipo de Convenio que permitirá futuros reenviar el estado. Una elección obvia (tal vez) es reenviar el futuro sí. El método entonces esperará entonces una expresión toma un parámetro futuro del tipo adecuado, permitirme escribir esto:

auto read = [&]() { return f.read(b, sizeof(b)); };
auto write = [&](future<uint32> previous) { n.write(b, 
  previous.get()); };
read().then(write);

Esto funciona, y podría incluso quiero mejorar dimensionabilidad seguir definiendo que la expresión que el método entonces espera también debe devolver un futuro. Sin embargo, el problema sigue siendo cómo expresar el bucle condicional. En última instancia, resulta para ser más sencillo que reconsidere el bucle original como un bucle do... while en lugar de ello, porque esto es más fácil expresar de forma iterativa. Entonces pude elaborar un algoritmo do_while para imitar este patrón de manera asincrónica, condicionalmente encadenamiento futuros y trayendo la composición iterativa fin basado en el resultado de un futuro <bool> valor, por ejemplo:

future<void> do_while(function<future<bool>()> body)
{
  auto done = make_shared<promise<void>>();
  iteration(body, done);
  return done->get_future();  
}

La función do_while crea primero una promesa de referencia contada cuyo futuro último señala la terminación del bucle. Esto se pasa a la función de iteración junto con la función que representa el cuerpo del bucle:

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

Esta función de iteración es el corazón del algoritmo do_while, proporcionando el encadenamiento de una invocación a la siguiente, así como la capacidad de romper y finalización de la señal. Aunque puede parecer recursiva, recuerde que el objetivo es separar las operaciones asincrónicas de la pila, y así el bucle no crece la pila. Mediante el algoritmo de do_while es relativamente fácil, y ahora puedo escribir el programa que se muestra en la figura 1.

Figura 1 utilizando un algoritmo de do_while

int main()
{
  file f = ...; net n = ...; uint8 b[4096];
  auto loop = do_while([&]()
  {
    return f.read(b, sizeof(b)).then([&](future<uint32> previous)
    {
      return n.write(b, previous.get());
    }).then([&]()
    {
      promise<bool> p;
      p.set_value(!f.eof);
      return p.get_future();
    });
  });
  loop.wait();
}

Naturalmente, la función do_while devuelve un futuro y en este caso es esperado en, pero esto podría fácilmente haber evitado mediante el almacenamiento de las variables locales de la función principal del montón con shared_ptrs. Dentro de la expresión lambda pasada a la función de do_while, comienza la operación de lectura, seguido por la operación de escritura. Para mantener este ejemplo simple, supongo escribir volverá inmediatamente si ha dicho que para escribir cero bytes. Una vez finalizada la operación de escritura, yo Verifique el estado del archivo final de archivo y devolver un futuro proporcionando el valor de la condición de bucle. Esto asegura que el cuerpo del bucle se repetirá hasta que se agote el contenido del archivo.

Aunque este código no es particularmente desagradable — y, de hecho, es probablemente mucho limpiador de ripeo de pila — un poco apoyo de la lengua sería recorrer un largo camino. Niklas Gustafsson ya ha propuesto tal diseño y lo llamó "reanudables funciones". Edificio sobre las mejoras propuestas para futuros y promesas y agregar un poco de azúcar sintáctico, podría escribir una función reanudable para encapsular la operación asincrónica sorprendentemente compleja como sigue:

future<void> file_to_net(shared_ptr<file> f, 
  shared_ptr<net> n) resumable
{
  uint8 b[4096];
  while (auto actual = await f->read(b, sizeof(b)))
  {
    await n->write(b, actual);
  }
}

La belleza de este diseño es que el código tiene una llamativa semejanza a la original versión sincrónica, y eso es lo que estoy buscando, después de todo. Observe la palabra clave contextual "reanudar", siguiendo la lista de parámetros de la función. Esto es análogo a la hipotética "async" palabra clave que describí en mi columna de agosto de 2012. A diferencia de lo que he demostrado en esa columna, sin embargo, esto llevaría a cabo por el propio compilador. Así no sería complicaciones y limitaciones como las que enfrentan la ejecución de la macro. Puede usar instrucciones switch y variables locales — y constructores y destructores funcionaría como se esperaba, pero sus funciones ahora sería capaces de hacer una pausa y reanudar de forma similar a lo que el prototipo con macros. No sólo eso, sino sería liberado de las trampas de captura de variables locales sólo para que les salga del ámbito, un error común al usar expresiones lambda. El compilador podría cuidar de proporcionar almacenamiento para las variables locales dentro de funciones reanudables en el montón.

En el ejemplo anterior también observe la palabra clave "esperan" anterior a la lectura y escritura llamadas a métodos. Esta palabra clave define un punto de reanudación y espera una expresión resultando en un futuro-como objeto que puede utilizar para determinar si pausar y reanudar más tarde o simplemente continuar con la ejecución si la operación asincrónica ocurrido completar de forma sincrónica. Obviamente, para lograr el mejor rendimiento, necesito manejar el escenario muy común de las operaciones asincrónicas completar sincrónicamente, quizás debido a la caché o escenarios de falla en ayuno.

Observe que he dicho que la palabra clave aguardad espera un futuro como objeto. Estrictamente hablando, no hay ninguna razón debe ser un objeto real de futuro. Sólo tiene que proporcionar el comportamiento necesario para apoyar la detección de finalización asincrónica y señalización. Esto es análogo a la forma plantillas de trabajo hoy. Este objeto de futuro-como sería necesario apoyar el método entonces que me ilustró en mi última columna, así como el método get existentes. Para mejorar el rendimiento en casos donde el resultado es inmediatamente disponible, los métodos de is_done y try_get propuesto también sería útiles. Por supuesto, puede optimizar el compilador basado en la disponibilidad de tales métodos.

Esto no es tan descabellada como podría parecer. C# ya tiene una instalación casi idéntica en forma de métodos de async, el equivalente moral de funciones reanudables. Incluso proporciona una palabra clave de advertencia que funciona de la misma manera como he ilustrado. Mi esperanza es que la comunidad de C++ incorporarán funciones reanudables, o algo parecido, por lo que todos podremos escribir sistemas asincrónicos eficientes y extensible naturalmente y fácilmente.

Para un análisis detallado de las funciones reanudables, incluyendo un vistazo a cómo podría implementarse, lea papel de Niklas Gustafsson, "Reanudable funciones", en bit.ly/zvPr0a.

Kenny Kerr es un artesano de software con una pasión por el desarrollo de Windows nativo. Llegar a él en kennykerr.ca.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Artur Laksberg