Compartir a través de


Windows con C++

Grupo de subprocesos de Windows y trabajo

Kenny Kerr

Kenny KerrSimultaneidad significa cosas diferentes para diferentes personas. Algunos piensan en términos de agentes y mensajes; máquinas de estado de cooperación, pero asincrónicas. Otros piensan en términos de tareas, generalmente en la forma de funciones o expresiones que se pueden ejecutar al mismo tiempo. Otros hasta piensan en términos de paralelismo de datos, en que la estructura de los datos permite la simultaneidad. Incluso usted podría pensar en estas como técnicas complementarias o superpuestas. Independientemente de cómo vea el mundo de la simultaneidad, de una forma u otra en el corazón de todo enfoque contemporáneo respecto de la simultaneidad se encuentra un grupo de subprocesos.

Los subprocesos son relativamente costosos de crear. Una cantidad excesiva de ellos introduce una sobrecarga de programación que afecta la ubicación y el rendimiento general de la memoria caché. En la mayoría de los sistemas bien diseñados, la unidad de simultaneidad tiene una vida útil relativamente corta. Idealmente, existe una manera sencilla de crear subprocesos según sea necesario, volver a usarlos para trabajo adicional y evitar crear demasiados de ellos de alguna manera inteligente con el fin de usar la capacidad informática disponible de forma eficaz. Afortunadamente, ese ideal hoy en día es una realidad, y no en la biblioteca de algún tercero, sino justo en el corazón de la API de Windows. La API del grupo de subprocesos de Windows no solo cumple estos requisitos, sino que además se integra sin problemas con muchos de los bloques de compilación básicos de la API de Windows. Simplifica en gran medida la escritura de aplicaciones escalables y con capacidad de respuesta. Si lleva mucho tiempo como desarrollador de Windows, sin duda estará familiarizado con la piedra angular de la escalabilidad de Windows que es el puerto de terminación E/S. Consuélese con el hecho de que un puerto de terminación E/S se ubica en el corazón del grupo de subprocesos de Windows.

Tenga en cuenta que un grupo de subprocesos no debe verse simplemente como una manera de evitar llamar a CreateThread con todos sus parámetros y la llamada obligatoria a CloseHandle en el controlador resultante. Desde luego, esto puede ser conveniente, pero también puede ser engañoso. La mayoría de los desarrolladores tienen expectativas acerca del modelo preventivo de programación impulsado por prioridades que Windows implementa. Los subprocesos con la misma prioridad generalmente compartirán el tiempo del procesador. Cuando el quántum de un subproceso (la cantidad de tiempo que demora en ejecutarse) llega a su fin, Windows determina si otro subproceso con la misma prioridad está listo para ejecutarse. Naturalmente, muchos factores influyen en la programación de los subprocesos, pero dado que se crean dos subprocesos aproximadamente al mismo tiempo, con la misma prioridad, ambos realizando algún tipo de operación informática, uno esperaría que ambos empiecen a ejecutarse a un par de quántums entre sí.

Pero no es así con el grupo de subprocesos. El grupo de subprocesos (y en realidad cualquier abstracción de programación basada en un puerto de terminación E/S) se basa en un modelo de trabajo en cola. El grupo de subprocesos garantiza la utilización completa de los núcleos, pero también evita la sobreprogramación. Si se envían dos unidades de trabajo aproximadamente al mismo tiempo en una máquina de núcleo único, entonces solo se despacha la primera. La segunda solo se iniciará si la primera finaliza o se bloquea. Este modelo es óptimo para el rendimiento, ya que el trabajo se ejecutará de manera más eficaz con menos interrupciones, pero también significa que no hay garantías de latencia.

La API del grupo de subprocesos está diseñada como un conjunto de objetos en colaboración. Hay objetos que representan unidades de trabajo, temporizadores, E/S asincrónico y más. Incluso hay objetos que representan conceptos desafiantes como cancelación y limpieza. Afortunadamente, la API no obliga a los desarrolladores a lidiar con todos estos objetos y, en una suerte de bufé, usted puede consumir, según sus necesidades. Naturalmente, esta libertad presenta el riesgo de usar la API de manera ineficaz o inapropiada. Es por eso que los próximos meses me dedicaré a esta columna. A medida que comience a entender los distintos roles que cumplen las diversas partes de la API, descubrirá que el código que necesita escribir se vuelve más sencillo en lugar de más complejo.

En esta primera entrega, le mostraré cómo comenzar a enviar trabajo al grupo de subprocesos. Las funciones se exponen al grupo de subprocesos como objetos de trabajo. Un objeto de trabajo consta de un puntero de función así como un puntero nulo, denominado contexto, que el grupo de subprocesos pasa a la función cada vez que se ejecuta. Un objeto de trabajo se puede enviar varias veces a ejecución, pero la función y el contexto no se pueden cambiar sin crear un nuevo objeto de trabajo.

La función CreateThreadpoolWork crea un objeto de trabajo. Si la función se realiza correctamente, devuelve un puntero opaco que representa al objeto de trabajo. Si está incorrecta, .devuelve un valor de puntero nulo y proporciona más información a través de la función GetLastError. Dado un objeto de trabajo, la función CloseThreadpoolWork informa al grupo de subprocesos que el objeto se puede liberar. Esta función no devuelve un valor y, por una cuestión de eficacia, supone que el objeto de trabajo es válido. Afortunadamente, la plantilla unique_handle que presenté en la columna del mes pasado se encarga de esto. Esta es una clase de rasgos que puede usarse con unique_handle, así como un typedef por conveniencia:

struct work_traits
{
  static PTP_WORK invalid() throw()
  {
    return nullptr;
  }

  static void close(PTP_WORK value) throw()
  {
    CloseThreadpoolWork(value);
  }
};

typedef unique_handle<PTP_WORK, work_traits> work;

Ahora puedo crear un objeto de trabajo y dejar que el compilador se encargue de su vida útil, ya sea que el objeto resida en la pila o en un contenedor. Desde luego, antes de poder hacerlo, necesito una función para que este la llame, conocida como una devolución de llamada. La devolución de llamada se declara de la siguiente manera:

void CALLBACK hard_work(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK);

El macro CALLBACK asegura que la función implementa la convención de llamada adecuada que la API de Windows espera para devoluciones de llamada, dependiendo de la plataforma objetivo. Crear un objeto de trabajo para esta devolución de llamada mediante el trabajo typedef es sencillo y continúa el patrón que destaqué en la columna del mes pasado, tal como se muestra aquí:

void * context = ... 
work w(CreateThreadpoolWork(hard_work, context, nullptr));
check_bool(w);

En este punto, todo lo que tengo es un objeto que representa algún trabajo por realizar, pero el grupo de subprocesos en sí aún no entra en juego, ya que la devolución de llamada del trabajo no se ha enviado para su ejecución. La función SubmitThreadpoolWork envía la devolución de llamada de trabajo al grupo de subprocesos. Se puede llamar varias veces con el mismo objeto de trabajo para permitir que se ejecuten varias devoluciones de llamada al mismo tiempo. A continuación se muestra la función.

SubmitThreadpoolWork(w.get());

Claro que incluso enviar el trabajo no garantiza su pronta ejecución. La devolución de llamada del trabajo se pone en cola, pero es posible que el grupo de subprocesos limite el nivel de latencia (la cantidad de subprocesos del trabajador) para mejorar la eficacia. Como todo esto es bastante impredecible, necesita haber una manera de esperar las devoluciones de llamada pendientes, tanto aquellas actualmente en ejecución como las todavía pendientes. Idealmente, también sería posible cancelar esas devoluciones de llamada de trabajo a las que todavía debe darse una oportunidad de ejecutarse. Por lo general, cualquier tipo de operación de “espera” de bloqueo son malas noticias para la simultaneidad, pero aún es necesaria para realizar una cancelación o apagado predecible. Ese será tema de una próxima columna, así que no gastaré mucho tiempo en ello aquí. Sin embargo, por ahora, la función WaitForThreadpoolWorkCallbacks cumple los requisitos ya mencionados. Aquí se muestra un ejemplo:

bool cancel = ...
WaitForThreadpoolWorkCallbacks(w.get(), cancel);

El valor del segundo parámetro determina si las devoluciones de llamada pendientes se cancelarán o si la función espera a que finalicen si todavía no han comenzado a ejecutarse. Ahora tengo suficiente para compilar un grupo funcional básico, puedo tomar la API del grupo de subprocesos y unas gotas de C++ 2011 para crear algo mucho más entretenido de usar. Además, proporciona un buen ejemplo para usar todas las funciones que he presentado hasta ahora.

Un grupo funcional sencillo debe permitirme enviar una función para ejecución de manera asincrónica. Debo ser capaz de definir esta función mediante una expresión lambda, una función con nombre o un objeto de función, según sea necesario. Un enfoque es usar una colección simultánea para almacenar una cola de funciones y pasar esta cola a una devolución de llamada de trabajo. Visual C++ 2010 incluye la plantilla de clase concurrent_queue que servirá a este propósito. Supongo que está usando la implementación actualizada de Service Pack 1, ya que la original tenía un error que arrojaba una infracción de acceso si la cola no estaba vacía al momento de su destrucción.

Puedo continuar y comenzar a definir la clase del grupo funcional de la siguiente manera:

class functional_pool
{
  typedef concurrent_queue<function<void()>> queue;

  queue m_queue;
  work m_work;

  static void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK)
  {
    auto q = static_cast<queue *>(context);

    function<void()> function;
    q->try_pop(function);

    function();
  }

Como puede ver, la clase functional_pool administra una cola de objetos de función así como un objeto de trabajo único. La devolución de llamada supone que el contexto es un puntero para la cola y además supone que hay al menos una función presente en la cola. Ahora puedo crear el objeto de trabajo para esta devolución de llamada y establecer el contexto adecuadamente, tal como se muestra aquí:

public:

  functional_pool() :
    m_work(CreateThreadpoolWork(callback, &m_queue, nullptr))
  {
    check_bool(m_work);
  }

Se necesita una plantilla de función para atender los diversos tipos de funciones que se pueden enviar. Su trabajo es simplemente poner en cola la función y llamar a SubmitThreadpoolWork para indicar al grupo de subprocesos que envíe una devolución de llamada de trabajo para su ejecución, tal como se muestra aquí:

template <typename Function>
void submit(Function const & function)
{
  m_queue.push(function);
  SubmitThreadpoolWork(m_work.get());
}

Por último, el destructor functional_pool debe asegurarse de que no se ejecuten más devoluciones de llamada antes de permitir que la cola se destruya, de lo contrario pasarán cosas horribles. Aquí se muestra un ejemplo:

~functional_pool()
{
  WaitForThreadpoolWorkCallbacks(m_work.get(), true);
}

Ahora puedo crear un objeto functional_pool y enviar trabajo de manera bastante fácil mediante una expresión lambda:

functional_pool pool;

pool.submit([]
{
  // Do this asynchronously
});

Claramente, habrá alguna penalización de rendimiento por poner en cola funciones de manera explícita y poner en cola devoluciones de llamada de trabajo de manera implícita. Usar este enfoque en aplicaciones de servidor, en que la concurrencia se encuentra por lo general bastante estructurada probablemente no sería buena idea. Si solo tiene un puñado de devoluciones de llamada únicas para controlar el grueso de sus cargas de trabajo asincrónicas, probablemente lo mejor es que use punteros de función. Sin embargo, este enfoque puede ser útil en aplicaciones de cliente. Si hay muchas operaciones diferentes de corta vida útil que quisiera controlar de manera simultánea para mejorar la capacidad de respuesta, la conveniencia de usar expresiones lambda tiende a ser más significativa.

De todas maneras, este artículo no se trata de las expresiones lambda sino de enviar trabajo al grupo de subprocesos. La función TrySubmitThreadpoolCallback proporciona un enfoque aparentemente más sencillo para lograr el mismo cometido, tal como se muestra aquí:

void * context = ...
check_bool(TrySubmitThreadpoolCallback(
  simple_work, context, nullptr));

Es casi como si las funciones CreateThreadpoolWork y SubmitThreadpoolWork se hubieran fusionado en una, y, en esencia, eso es lo que ocurre. La función TrySubmitThreadpoolCallback hace que el grupo de subprocesos cree un objeto de trabajo de manera interna, cuya devolución de llamada se envíe a ejecución de inmediato. Debido a que el objeto de trabajo es propiedad del grupo de subprocesos, no tiene que preocuparse de liberarlo. De hecho, no puede, porque la API nunca expone el objeto de trabajo. La firma de la devolución de llamada proporciona más evidencia, como se muestra aquí:

void CALLBACK simple_work(
  PTP_CALLBACK_INSTANCE, void * context);

La devolución de llamada se ve casi igual que antes, salvo por el tercer parámetro que falta. Al principio, esto parece ideal: una API más sencilla y menos por que preocuparse. Sin embargo, no hay una manera obvia de esperar a que la devolución de llamada finalice, ni qué decir de cancelarla. Tratar de escribir la clase functional_pool en términos de TrySubmitThreadpoolCallback sería problemático y requeriría sincronización adicional. Una próxima columna abordará cómo se puede lograr esto a través de la API del grupo de subprocesos. Incluso si fuera capaz de solucionar estos problemas, existe un problema menos obvio posiblemente mucho más devastador en la práctica. Cada llamada a TrySubmitThreadpoolCallback implica la creación de un objeto de trabajo nuevo con sus recursos asociados. Con cargas de trabajo pesadas, esto rápidamente puede provocar que el grupo de subprocesos consuma una gran cantidad de memoria y arroje más penalizaciones por rendimiento.

Usar un objeto de trabajo también proporciona otros beneficios de manera explícita. El último parámetro de la devolución de llamada en su forma original proporciona un puntero al mismo objeto de trabajo que envió la instancia en ejecución. Puede usarlo para poner en cola instancias adicionales de la misma devolución de llamada. Incluso puede usarla para liberar el objeto de trabajo. Sin embargo, este tipo de trucos puede meterlo en problemas, ya que cada vez es más difícil saber cuándo es seguro enviar trabajo y cuándo es seguro liberar recursos de aplicación. En la columna del próximo mes, examinaré el entorno del grupo de subprocesos mientras continúo explorando la API del grupo de subprocesos de Windows.       

Kenny Kerr* es un artesano del software que siente una pasión por el desarrollo nativo de Windows. Puede ponerse en contacto con él en kennykerr.ca.*

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo:Hari Pulapaka y Pedro Teixeira