Compartir a través de


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

Windows con C++

Multitarea cooperativa ligero con C++

Kenny Kerr

 

Kenny KerrSi usted trabaja para una empresa que tiene uno de esos codificación documentos de estándares que aniquilan a un bosque entero fueron alguna vez a imprimirse, mejor sería detener lectura ahora.Es probable que lo que voy a mostrar violará muchas de las vacas sagradas en la épica antes mencionada.Voy a contarles sobre una técnica particular que originalmente desarrollado para me permiten escribir código completamente asincrónica eficiente y sin necesidad de máquinas de estado complejo.

A menos que su nombre es Donald Knuth, es probable que cualquier código que se escribe se parezca a algo que ya se ha hecho.De hecho, la cuenta más antigua de que encuentro de la técnica que describo aquí es una mención por Knuth a sí mismo, y es el más reciente por un caballero de Inglaterra con el nombre de Simon Tatham, conocido por el popular emulador de terminal PuTTY.Sin embargo, parafraseando al juez en el reciente v de Oracle.Disputa de Google, "usted no puede patentar un bucle for." Aún así, estamos todos endeudados a nuestros compañeros en el campo del equipo de programación impulsar la nave.

Antes de bucear en describir lo que mi técnica es y cómo funciona, necesito presentar un rápido desvío en la esperanza de que le dará un poco más de perspectiva sobre lo que está por venir.En "compiladores: Principios, técnicas y herramientas, segunda edición"(Prentice Hall, 2006) por Alfred V.Aho, Mónica S.Lam, Ravi Sethi y Jeffrey D.Ullman, más conocido como el libro del dragón — los autores resumen el propósito de un compilador como un programa que puede leer un programa en un solo idioma y traducirlo a un programa equivalente en otro idioma.Si fueras a pedir a diseñador de C++ Bjarne Stroustrup qué idioma solía escribir C++, que él diría que era C++.Lo que esto significa es que escribió un preprocesador que lea C++ y había producido C, un compilador c estándar podría traducir más en algún lenguaje de máquina.Si usted busca cerrar suficiente, puede ver las variantes de esta idea en muchos lugares.El compilador de C#, por ejemplo, traduce la palabra clave de rendimiento aparentemente mágicos en una clase regular que implementa un iterador.El año pasado, me di cuenta que el compilador de Visual C++ primero traducido partes de C + + sintaxis CX en C++ estándar antes de compilarlo aún más.Esto puede haber cambiado desde entonces, pero el punto es que los compiladores son fundamentalmente acerca de la traducción.

A veces, cuando se utiliza un lenguaje particular, podría concluir que una característica sería deseable, pero la naturaleza de la lengua le prohíbe a implementarlo.Esto rara vez ocurre en C++, porque el lenguaje de diseño es adecuado para la expresión de diferentes técnicas, gracias a su rica sintaxis e instalaciones meta-programming.Sin embargo, esto de hecho ha sucedido a mí.

Paso la mayoría de mi tiempo estos días trabajando en un sistema operativo incrustado que he creado desde cero.Ni Linux, Windows o cualquier otro sistema operativo se ejecuta bajo las cubiertas.Depender de ningún software de código abierto alguna, y de hecho que normalmente sería imposible de todas formas por razones que se aclarará.Esto ha abierto mis ojos a un mundo de programación c y C++, que es bastante diferente del tradicional mundo de desarrollo de software de PC.Más sistemas embebidos tienen restricciones muy diferentes de los de programas «normales».Tienen que ser extremadamente confiable.Fracaso puede ser costoso.Los usuarios son rara vez alrededor de un programa fallido "reiniciar".Sistemas podrían tener que funcionar durante años sin interrupciones y sin intervención humana.Imaginar un mundo sin Windows Update o similar.Estos sistemas también podrían tener relativamente escasos recursos informáticos.Exactitud, fiabilidad y, en particular, concurrencia todos desempeñan un papel central en el diseño de tales sistemas.

Como tal, el lujoso mundo de Visual C++, con sus potentes bibliotecas, rara vez es adecuado.Incluso si mi microcontrolador incrustado de destino Visual C++, las bibliotecas de acompañamiento no son apropiadas para sistemas con tan escasos recursos computacionales y, a menudo, duro restricciones en tiempo real.Uno de los microprocesadores que actualmente estoy usando tiene solo 32 KB de memoria RAM a menos de 50 MHz, y esto es aún más lujoso a algunos en la comunidad incrustada.Debe quedar claro que por "incorporada" no significa su smartphone promedio con un concierto de la mitad de RAM y un procesador de 1 GHz.

En "programación: Principios y práctica utilizando C++ "(Addison-Wesley Professional, 2008), Stroustrup cita las asignaciones de tienda libre (por ejemplo, new y delete), excepciones y dynamic_cast en una breve lista de características que deben ser evitados en sistemas embebidos más porque no son predecibles.Por desgracia, impide el uso de la mayor parte de la norma, suministrada por el proveedor y abrir fuente C++ bibliotecas disponibles hoy en día.

El resultado es que la programación más incrustada — y de eso se trata, de modo de núcleo programación en Windows — todavía emplea c en lugar de hacerlo en C++.Dado que c es principalmente un subconjunto de C++, tienden a utilizar un compilador de C++ pero palo a un subconjunto estricto de la lengua que es predecible, portátil y funciona bien en sistemas embebidos.

Esto me llevó en un viaje para encontrar una técnica adecuada para permitir la concurrencia en mi sistema operativo poco incrustado.Hasta este punto, mi sistema operativo tuvo un único subproceso, si se puede llamar así.No hay ninguna operación de bloqueo, para interrumpir en cualquier momento necesitaba implementar algo que podría tomar algún tiempo, como esperando una E/s de almacenamiento o para un tiempo de espera de retransmisión de red, necesito escribir una máquina de estado cuidadosamente construidas.Esto es una práctica estándar con sistemas orientada a eventos, pero resulta en código que es difícil razonar a través de lógica, porque el código no es secuencial.

Imaginar un controlador de almacenamiento.Puede haber una función de storage_read para leer una página de memoria de almacenamiento flash persistente de un dispositivo.La función storage_read podría comprobar primero si el periférico o el bus está ocupado, y si es así, simplemente cola la solicitud de lectura antes de regresar.En algún punto del periférico y autobús convertido en libre y la solicitud pueden proceder.Esto podría implicar la desactivación del transceptor, formato un comando adecuado para el autobús, preparando la memoria directa búferes de acceso, permitiendo el transceptor nuevamente y luego regresar para permitir que la persona que llama hacer otra cosa mientras la transferencia completa de hardware.Finalmente el bus señales de la llamada y su finalización se notifica mediante algún mecanismo de devolución de llamada, y proceder de cualquier otras solicitudes en cola.Huelga decir, puede resultar bastante complicado de administrar las colas, las devoluciones de llamada y máquinas de estado.Verificar que todo está correcto es aún más difícil.Aún no he descrito cómo podría implementarse un sistema de archivos encima de esta abstracción o cómo un servidor Web puede utilizar el sistema de archivos para servir datos — todo sin nunca bloquear.Se necesita un enfoque diferente para reducir la complejidad creciente e inevitable.

Ahora, imagine que C++ tenía algunas palabras clave que le permite transportar el procesador de pila de llamadas de uno a otro en mid-function.Imagina el siguiente código de C++ hipotético:

void storage_read(storage_args & args) async
{
  wait while (busy);
  busy = true;
  begin_transfer(args);
  yield until (done_transmitting());
  busy = false;
}

Observe la palabra clave contextual "async" después de la lista de parámetros. También estoy usando dos palabras clave separada imaginario llamado "Espere mientras" y "rendimiento hasta". Tenga en cuenta lo que significa para que C++ tener esa palabras clave. El compilador de alguna manera tendría que expresar la noción de un interludio, si así lo desea. Knuth había llamado un coroutine. La palabra clave async puede aparecer con una declaración de función para que el compilador sabe que la función puede ejecutarse de forma asincrónica y así debe llamarse apropiadamente. Las palabras clave de rendimiento y esperar hipotético es los puntos reales en que la función deja de ejecutar de forma sincrónica y potencialmente puede volver a la persona que llama, sólo para continuar donde lo dejó en una etapa posterior. También podrías imaginar una palabra clave condicional "esperar" así como una declaración de rendimiento incondicional.

He visto alternativas a esta forma cooperativa de simultaneidad — en particular la biblioteca agentes asincrónica incluidas con Visual C++, pero todas las soluciones que encontré dependían de algunos programación de motor de tiempo de ejecución. Lo que proponemos aquí y se ilustran en un momento es que es totalmente posible que un compilador de C++ de hecho podría ofrecer cooperativo concurrencia sin cualquier tipo de coste de tiempo de ejecución. Tenga en cuenta que no estoy diciendo que esto solucionará el problema de escalabilidad de manycore. Lo que estoy diciendo es que deberíamos poder escribir programas rápidos y sensibles orientado a eventos sin que ello suponga a un programador. Y como con los lenguajes c y C++ existente, nada debería impedir que esas técnicas se utiliza junto con hilos de OS u otros runtimes de concurrencia.

Obviamente, C++ no admite lo que estoy describiendo ahora. Lo que descubrí, sin embargo, es que puede ser simulado utilizando razonablemente bien el estándar de c o C++ y sin depender de engaño de ensamblador. Con este enfoque, la función de storage_read descrita anteriormente podría ser como sigue:

task_begin(storage_read, storage_args & args)
{
  task_wait_while(busy);
  busy = true;
  begin_transfer(args);
  task_yield_until(done_transmitting());
  busy = false;
}
task_end

Obviamente, estoy depender macros aquí. GASP! Claramente, esto viola el artículo 16 de las normas de codificación de C++ (bit.ly/8boiZ0), pero las alternativas son peores. La solución ideal es que el lenguaje apoyar esto directamente. Alternativas incluyen el uso de longjmp, pero me parece que es peor y tiene sus propias trampas. Otro enfoque sería utilizar lenguaje ensamblador, pero luego pierdo portabilidad todos. Es discutible si se podría incluso hacer tan eficientemente en lenguaje ensamblador, porque lo más probable es resultaría en una solución que utiliza más memoria debido a la pérdida de información contextual y la implementación de una pila por tarea inevitable. Así que me humor como muestro usted cuán simple y eficaz, que esto es, y luego cómo funciona todo.

Para mantener las cosas claras, que llamaré en adelante estas tareas funciones asincrónicas. Dado a la tarea que he descrito anteriormente, puede programarse llamándolo simplemente como una función:

storage_args = { ...
};
storage_read(args);

Tan pronto como la tarea decide que no puede continuar, simplemente volverá a la persona que llama. Tareas de emplean un tipo de valor devuelto de bool para indicar a los llamadores si han completado. Así, continuamente puede programar una tarea hasta su finalización, como sigue:

while (storage_read(args));

Por supuesto, esto bloquearía la llamada hasta que se complete la tarea. Esto realmente sería apropiado, tal vez cuando el programa inicie por primera vez con el fin de cargar un archivo de configuración o similar. Aparte de esa excepción, rara vez se desea bloquear de esta manera. Lo que necesita es una manera de esperar para una tarea de manera cooperativa:

task_wait_for(storage_read, args);

Esto supone que la persona que llama es en sí mismo una tarea y luego dará a su llamador hasta que finalice la tarea anidada, momento en el que continuará. Ahora permítanme definir vagamente las palabras clave de la tarea, o pseudo-functions y luego ir a través de un ejemplo o dos que realmente puedes probar por ti mismo:

  • task_declare (name, parámetro) declara una tarea, normalmente en un archivo de encabezado.
  • task_begin (name, parámetro) comienza la definición de una tarea, normalmente en un archivo de código fuente de C++.
  • task_endEnds la definición de una tarea.
  • task_return () finaliza la ejecución de una tarea y devuelve el control al llamador.
  • task_wait_until (expresión) espera hasta que la expresión es verdadera antes de continuar.
  • task_wait_while (expresión) espera mientras que la expresión es verdadera antes de continuar.
  • task_wait_for (name, parámetro) ejecuta la tarea y espera a que termine antes de continuar.
  • task_yield () rendimientos de control incondicionalmente, continuar cuando la tarea es reprogramada.
  • task_yield_until (expresión) rendimientos de control al menos una vez, continuar cuando la expresión es distinto de cero.

Es importante recordar que ninguna de estas rutinas bloquear de ninguna manera. Todos están diseñados para alcanzar un alto grado de simultaneidad en forma cooperativa. Permítanme ilustrar con un ejemplo sencillo. Este ejemplo utiliza dos tareas, uno para solicitar al usuario un número y otro calcular una simple media aritmética de los números como llegan. En primer lugar es la tarea de promedio, se muestra en la figura 1.

Figura 1 la tarea promedio

struct average_args
{
  int * source;
  int sum;
  int count;
  int average;
  int task_;
};
task_begin(average, average_args & args)
{
  args.sum = 0;
  args.count = 0;
  args.average = 0;
  while (true)
  {
    args.sum += *args.source;
    ++args.count;
    args.average = args.sum / args.count;
    task_yield();
  }
}
task_end

Una tarea acepta exactamente un argumento que debe ser pasado por referencia y debe incluir una variable de miembro entero llamada task_. Obviamente, esto es algo que el compilador podría ocultar del llamador dado el escenario ideal de soporte de primera clase de lengua. Sin embargo, con el propósito de esta simulación, necesito una variable para realizar un seguimiento de los progresos de la tarea. Todas las necesidades de la persona que llama no es inicializarlo a cero.

La tarea es interesante ya que contiene un infinito bucle con una llamada task_yield en el cuerpo del bucle while. La tarea inicializa algún Estado antes de entrar en este bucle. A continuación, actualiza sus agregados y rendimiento, permitiendo que otras tareas para ejecutar antes de repetir indefinidamente.

A continuación corresponde a la entrada, como se muestra en la figura 2.

Figura 2 la tarea de entrada

struct input_args
{
  int * target;
  int task_;
};
task_begin(input, input_args & args)
{
  while (true)
  {
    printf("number: ");
    if (!scanf_s("%d", args.target))
    {
      task_return();
    }
    task_yield();
  }
}
task_end

Esta tarea es interesante en que demuestra que de hecho pueden bloquear las tareas, como la scanf_s función hará mientras se espera a la entrada. Aunque no es la ideal para un sistema orientado a eventos. Esta tarea también ilustra utilizando la llamada de task_return para completar la tarea en mid-function en lugar de una expresión condicional mientras declaración. Una tarea completa llamando al task_return o por la caída del extremo de la función, por así decirlo. De cualquier manera, la persona que llama ven esto como la finalización de la tarea, y será capaz de retomar.

Para llevar estas tareas a la vida, todo lo que necesitas es una simple función principal actuar como un programador de tareas:

int main()
{
  int share = -1;
  average_args aa = { &share };
  input_args ia = { &share };
  while (input(ia))
  {
    average(aa);
    printf("sum=%d count=%d average=%d\n",
      aa.sum, aa.count, aa.average);
  }
}

Las posibilidades son infinitas. Podría escribir las tareas que representan los temporizadores, los productores y los consumidores, controladores de conexión TCP y mucho más.

¿Cómo funciona? En primer lugar tener en cuenta otra vez que la solución ideal es que el compilador implementar esta, en cuyo caso puede utilizar todo tipo de trucos ingeniosos para implementarlo eficientemente, y lo que voy a describir no será realmente en las cercanías de los sofisticados o complicado.

Como mejor como les digo, este desciende a un descubrimiento por un programador llamado Tom Duff, quien descubrió que se pueden jugar trucos inteligentes con la instrucción switch. Como es válido sintácticamente, puede anidar varios selección o instrucciones de iteración dentro de una instrucción switch efectivamente saltar dentro y fuera de una función a voluntad. Duff publicó una técnica de loop manual desenrollado y Tatham entonces se dio cuenta de que podría utilizarse para simular corrutinas. Tomé esas ideas y había aplicado las siguientes tareas.

Las macros task_begin y task_end definen la instrucción switch circundante:

#define task_begin(name, parameter) \
                                    \
  bool name(parameter)              \
  {                                 \
    bool yield_ = false;            \
    switch (args.task_)             \
    {                               \
      case 0:
#define task_end                    \
                                    \
    }                               \
    args.task_ = 0;                 \
    return false;                   \
  }

Ahora debería ser obvio cuál es la variable task_ solo para y cómo funciona todo. Inicializando task_ a cero asegura que la ejecución salta al comienzo de la tarea. Cuando termina una tarea, nuevamente fijó volver a cero como una conveniencia para que la tarea puede reiniciarse fácilmente. Dado, la macro task_wait_until proporciona el salto necesario ubicación y facilidad de retorno cooperativo:

#define task_wait_until(expression)      \
                                         \
  args.task_ = __LINE__; case __LINE__:  \
  if (!(expression))                     \
  {                                      \
    return true;                         \
  }

La variable task_ se fija a la macro de número de línea predefinidos y la instrucción case obtiene el mismo número de línea, asegurando que si la tarea se obtiene, se reanudará la próxima vez que ha programado el código de derecho donde se quedó. Las macros restantes se muestran en figura 3.

Figura 3 las Macros restantes

#define task_return()                    \
                                         \
  args.task_ = 0;                        \
  return false;
#define task_wait_while(expression)      \
                                         \
  task_wait_until(!(expression))
#define task_wait_for(name, parameter)   \
                                         \
  task_wait_while(name(parameter))
#define task_yield_until(expression)     \
                                         \
  yield_ = true;                         \
  args.task_ = __LINE__; case __LINE__:  \
  if (yield_ || !(expression))           \
  {                                      \
    return true;                         \
  }
#define task_yield()                     \
                                         \
  task_yield_until(true)

Estas deben ser patente. Quizás la sutileza sólo digno de mención es task_yield_until, ya que es similar a task_wait_until, sino por el hecho de que siempre obtendrá al menos una vez. task_yield, a su vez, sólo producirá exactamente una vez, y estoy seguro que cualquier compilador respetable optimizará lejos mi taquigrafía. Debo mencionar que task_wait_until también es una gran manera de lidiar con las condiciones de falta de memoria. En lugar de fallar en alguna operación anidada con dudosa capacidad de recuperación, usted puede simplemente ceder hasta que logra la asignación de memoria, dando la oportunidad para completar y esperemos que libere memoria necesaria de otras tareas. Nuevamente, esto es crítico para sistemas embebidos donde el fracaso no es una opción.

Dado que estoy emulando a corrutinas, hay algunos escollos. Confiable no puede usar variables locales dentro de las tareas, y cualquier código que viola la validez de la sentencia switch oculto va a causar problemas. Todavía, dado que yo puedo definir mi propio task_args — y teniendo en cuenta cuánto más simple mi código es gracias a esta técnica — estoy agradecido que funciona tan bien como lo hace.

Me pareció útil para deshabilitar las siguientes advertencias del compilador de Visual C++:

#pragma warning(disable: 4127) // Conditional expression is constant
#pragma warning(disable: 4189) // Local variable is initialized but not referenced
#pragma warning(disable: 4706) // Assignment within conditional expression

Finalmente, si está utilizando el IDE de Visual C++, asegúrese de deshabilitar "Editar y continuar la depuración" mediante /Zi en lugar de/Zi.

Como llegué a la conclusión esta columna, Miró a su alrededor la Web de cualesquiera iniciativas similares y encontré el nuevo async y aguardan las palabras clave que ha introducido el compilador de Visual Studio 2012 C#. En muchos sentidos, esto es un intento de resolver un problema similar. Espero que la comunidad de C++ a seguir su ejemplo. La pregunta es si estas soluciones llegará para c y C++, de manera que produzca código predecible adecuado para sistemas embebidos como que he descrito en esta columna o si va depender de un runtime de simultaneidad, como el actual de Visual C++ bibliotecas hacer. Mi esperanza es que algún día podré tirar estas macros, pero hasta que llegue ese día, voy siendo productivo con esta técnica ligero, cooperativa y multitarea.

Estén atentos para la próxima entrega de Windows con C++ en el que te voy a mostrar algunas técnicas nuevas que Niklas Gustafsson y Artur Laksberg desde el equipo de Visual C++ han estado trabajando en traer funciones reanudables a C++.

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