La API del grupo de hilos de Win32 — Concurrencia sin crear hilos, con CreateThreadpoolWork

· · Windows, Multithreading, C++, Desarrollo en Windows, Win32 API, Mejora del rendimiento

«Un CreateThread por cliente.» «Uno para el temporizador.» «Uno para esperar un evento.» — En el código nativo de Windows, los hilos tienden a proliferar de este modo. Cada hilo consume una pila y un objeto del kernel, y crearlos y destruirlos también tiene un coste. El trabajo es de grano fino, pero el hilo es pesado: el grupo de hilos es lo que el sistema operativo ofrece para absorber ese desajuste.

La comodidad del ThreadPool y Task.Run de .NET es bien conocida, pero de hecho Win32 nativo también tiene una API de grupo de hilos bien diseñada y estándar del sistema operativo. Rediseñada de forma integral en Windows Vista, esta API es el fundamento de la concurrencia nativa: puede gestionar trabajo, temporizadores, esperas y E/S asíncrona mediante un mecanismo unificado de devolución de llamada. Dirigido a desarrolladores que escriben aplicaciones, servicios y DLL de Windows en C/C++, este artículo explica la estructura y el uso de esta API, y las trampas en las que es fácil caer, a partir de fuentes primarias.

1. Conclusión principal

  • Para emitir un gran número de trabajos de corta duración, y para sustituir hilos que solo esperan, un grupo de hilos gana a su propio CreateThread. Se deja la gestión de hilos al sistema operativo y se puede reducir el número de hilos y los cambios de contexto.1
  • Lo que debe usarse es la API nueva (la familia CreateThreadpoolWork). El rediseño de Vista la hizo más sencilla, más fiable y de mayor rendimiento que la API antigua (la familia QueueUserWorkItem), y también se pueden crear varios grupos independientes en un proceso.12
  • Hay cuatro tipos de objeto. work, al que se envían trabajos; timer, que se dispara en un instante o con un periodo; wait, que se dispara cuando un objeto del kernel queda señalizado; e io, que se dispara cuando finaliza una E/S asíncrona. Todos ellos montan el mismo mecanismo de devolución de llamada.3
  • El apagado es «esperar, luego cerrar». Hace falta una disciplina de no dejar atrás una devolución de llamada en ejecución —esperar la finalización con la familia WaitForThreadpoolWorkCallbacks, o gestionarla de forma masiva mediante un grupo de limpieza—.4
  • Dentro de una devolución de llamada: no bloquear durante mucho tiempo (si lo va a hacer, CallbackMayRunLong); no esperar de forma síncrona la finalización en el mismo grupo; no ensuciar el estado del hilo. Estas tres son reglas de hierro.56
  • Uso desde una DLL: vigile una carrera de descarga. Espere la finalización en una función de apagado explícita, y conozca las API dedicadas como FreeLibraryWhenCallbackReturns.3

2. Por qué un grupo, y cuándo un grupo

La idea de un grupo de hilos es sencilla. En lugar de crear un hilo por trabajo, se lanzan trabajos (devoluciones de llamada) a un conjunto de hilos trabajadores que gestiona el sistema operativo. Los trabajadores ejecutan trabajos uno tras otro, y el sistema operativo ajusta el número a la carga.

La documentación oficial enumera tipos concretos de aplicación en los que un grupo compensa.1

  • Aplicaciones que emiten en paralelo un gran número de elementos de trabajo pequeños (búsqueda, E/S de red, etc.)
  • Aplicaciones que crean y desmontan con frecuencia hilos de corta duración
  • Aplicaciones que procesan en paralelo trabajo independiente en segundo plano
  • Aplicaciones que mantienen hilos dedicados a esperar objetos del kernel o eventos

El último punto es fácil de pasar por alto. Si hay cinco hilos que solo existen para dormir de modo que «se ejecuten cuando el evento quede señalizado», esos se pueden sustituir por cinco objetos wait del grupo, y la espera se agrega en los hilos de espera del grupo.

A la inversa, también hay trabajo que no encaja en un grupo. Trabajo que necesita un cambio de prioridad de hilo, que exige COM STA, que sigue ejecutándose durante toda la vida del proceso: el trabajo que necesita una «personalidad» en el hilo se mantiene en un hilo dedicado. Un hilo trabajador es un recurso compartido; se toma prestado.

Elección entre un hilo dedicado y un grupoPrimero se comprueba si hace falta una personalidad de hilo como prioridad o STA, y si se ejecuta durante mucho tiempo; solo el trabajo de corta duración, de gran volumen o de tipo espera que no encaje en ninguna de las dos va al grupo de hilosNoNo¿Hace falta personalidad (prioridad, STA)?Mantenerlo en un hilo dedicado¿Se ejecuta durante mucho tiempo?Ponerlo en el grupo de hilosTrabajo breve, esperas, temporizadores, E/S

Figura 1: El único trabajo que se puede poner en un grupo es el que «no necesita personalidad y termina pronto». Todo lo demás se queda en un hilo dedicado como hasta ahora.

Hay también un dato de historia que conviene fijar. La API del grupo de hilos tiene dos generaciones. La API antigua que continúa desde Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject, etc.), y la API nueva rediseñada de forma integral en Vista (la familia CreateThreadpoolWork). La API nueva unifica los tipos de hilo trabajador, ofrece hilos persistentes dedicados, varios grupos en un proceso, grupos de limpieza y más, y la documentación oficial afirma textualmente que es «más sencilla, más fiable, de mejor rendimiento y más flexible».1 La API antigua también tiene restricciones estructurales como «no se puede cancelar un trabajo una vez encolado».2 A partir de aquí, este artículo trata solo la API nueva.

Correspondencia entre la API antigua del grupo de hilos y la API nuevaQueueUserWorkItem de la API antigua se corresponde con el objeto work de la API nueva, las colas de temporizador con timer, las esperas registradas con wait, y BindIoCompletionCallback con ioQueueUserWorkItemworkTimer queuestimerRegistered waitswaitBindIoCompletionCallbackio

Figura 2: El destino de migración desde la API antigua es uno a uno. Un inventario del código existente puede empezar por esta correspondencia.

3. Los cuatro objetos — work, timer, wait e io

En el centro de la API nueva hay cuatro tipos de objeto cuyas condiciones de disparo de la devolución de llamada difieren.3

Objeto Función de creación Cuándo se dispara la devolución de llamada
work CreateThreadpoolWork Cuando se envía con SubmitThreadpoolWork
timer CreateThreadpoolTimer Cuando llega el instante o el periodo indicado
wait CreateThreadpoolWait Cuando un objeto del kernel queda señalizado
io CreateThreadpoolIo Cuando finaliza la E/S asíncrona del identificador asociado
Los cuatro objetos del grupo de hilos y el mecanismo de devolución de llamadawork se dispara con un envío explícito, timer con el tiempo, wait con una señal de un objeto del kernel, e io con la finalización de E/S asíncrona; todos se ejecutan como devoluciones de llamada en el mismo conjunto de hilos trabajadores¿Qué objeto?work(al enviar)¿Timer, wait o io?timer(tiempo / periodo)¿Wait o io?wait(al señalizar)io(finalización de E/S)Los trabajadores ejecutan la devolución

Figura 3: Las condiciones de disparo difieren, pero los cuatro se unifican en un mecanismo en el que «los trabajadores del mismo grupo ejecutan la devolución de llamada».

Esta unificación es una fortaleza práctica. En lugar de escribir el procesamiento periódico, la respuesta a eventos y el procesamiento de finalización de E/S cada uno en un hilo dedicado, se pueden alinear en un solo estilo de devolución de llamada. Los temporizadores se reúnen en una única cola de temporizador para todo el grupo, y las esperas se agregan en un número reducido de hilos de espera: los hilos que «solo duermen» desaparecen del proceso.1

Sustituir hilos que solo esperan por objetos waitLos hilos que solo esperaban y dormían uno por evento se convierten en objetos wait y se agregan en los hilos de espera del grupo, de modo que la devolución de llamada se ejecuta solo al señalizarse5 hilos dedicados a esperar, dormidos por separadoConsume 5 pilas y 5 hilos5 objetos waitAgregados en los hilos de espera del grupoLa devolución se ejecuta solo al señalizarse

Figura 4: Los hilos que «solo duermen y esperan» se pueden eliminar convirtiéndolos en objetos wait. Es un primer movimiento claro para una migración al grupo.

4. El patrón básico — Un ida y vuelta con un objeto work

Recorremos las maneras una vez con el objeto work, que es el de uso más frecuente.4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // The context is fixed at creation time. Per-item data is passed through a synchronised queue
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // Take one item under exclusive control
    ProcessItem(item);
}

// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }

// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) Close
CloseThreadpoolWork(work);

Hay dos puntos que conviene retener. Primero, se puede hacer SubmitThreadpoolWork del mismo objeto work más de una vez. Cada envío ejecuta la devolución de llamada (en paralelo).7 Sin embargo, el contexto que se pasa a la devolución de llamada queda fijo en el momento de la creación, así que cuando se escribe «N elementos del mismo tipo de trabajo» con un solo objeto work, se pone una cola sincronizada en el contexto como en el código anterior y se toma un elemento por envío (un diseño que crea un objeto work por elemento también es válido). Segundo, espere siempre la finalización antes de cerrar. Cerrar el objeto mientras queda una devolución de llamada en ejecución o encolada, o liberar memoria a la que la devolución de llamada se refiere, es use-after-free tal cual. Para que esta espera sea un cierre seguro, detener primero al emisor es un requisito previo: en una estructura en la que otro hilo aún puede hacer Submit en paralelo con la espera, un envío posterior a la espera entra en carrera con Close. Pasar TRUE como segundo argumento de WaitForThreadpoolWorkCallbacks también intenta cancelar los envíos que aún no han empezado.

Ciclo de vida de un objeto workSe crea con CreateThreadpoolWork; se envía con SubmitThreadpoolWork y las devoluciones de llamada se ejecutan en paralelo. Al apagar, primero se detienen los envíos nuevos, se espera a que todas las devoluciones de llamada terminen con WaitForThreadpoolWorkCallbacks y luego se cierra con CloseThreadpoolWorkCrear con CreateThreadpoolWorkEnviar con SubmitThreadpoolWork (se puede repetir)Las devoluciones se ejecutan en paraleloDetener los envíos nuevosEsperar la finalización con WaitForThreadpoolWorkCallbacksCerrar con CloseThreadpoolWork

Figura 5: El orden de apagado es «detener envíos → esperar la finalización → cerrar». Sáltese cualquiera y tendrá use-after-free o una carrera.

De forma predeterminada, las devoluciones de llamada se ejecutan en el grupo predeterminado del proceso. Para muchos usos eso basta. El capítulo siguiente es para cuando se quieren dividir los grupos.

5. Grupos personalizados y grupos de limpieza

Dividir el grupo. Se puede crear un grupo independiente con CreateThreadpool y fijar el tope y el suelo del número de hilos con SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum.8 El uso típico es el aislamiento. Para que «trabajo de tipo lote que puede ser lento» no se coma los trabajadores de «trabajo que necesita responder de inmediato», se dividen los grupos y se da a cada uno su propio presupuesto de hilos.

Vincularlos con un entorno de devolución de llamada. En qué grupo se ejecuta el trabajo se indica inicializando un TP_CALLBACK_ENVIRON (entorno de devolución de llamada), apuntándolo al grupo con SetThreadpoolCallbackPool y pasándolo como tercer argumento de CreateThreadpoolWork y similares.9

Reunirlos con un grupo de limpieza. En un módulo que crea muchos objetos, el procesamiento de apagado tiende a convertirse en una recitación de «esperar a todos, cerrar todos». Si se crea un grupo con CreateThreadpoolCleanupGroup y se adjunta cada objeto a él a través del entorno de devolución de llamada, un solo CloseThreadpoolCleanupGroupMembers realiza la espera de finalización y la liberación de todos los objetos miembros juntas.34

Vincular la configuración a través de un entorno de devolución de llamadaEl entorno de devolución de llamada apunta a un grupo personalizado y a un grupo de limpieza; work y timer creados con ese entorno se ejecutan en ese grupo, y una operación masiva sobre el grupo de limpieza reúne la espera de finalización y la liberaciónEntorno de devolución (TP_CALLBACK_ENVIRON)Grupo personalizado (controlar el nº de hilos)Grupo de limpiezaSe pasa al crear work / timer / wait / ioEsperar la finalización y liberar de una vez

Figura 6: Un entorno de devolución de llamada es el mecanismo que inyecta «en qué grupo se ejecuta, y quién limpia» en el momento de crear el objeto.

6. Trampas — Disciplina dentro de una devolución de llamada

Casi todos los errores del grupo de hilos vienen de «hacer lo que uno quiere en un hilo prestado».

Bloquear durante mucho tiempo. El grupo ajusta su número de hilos asumiendo que las devoluciones de llamada regresan pronto. Hacer un trabajo largo o una espera larga con los valores predeterminados retrasa la ejecución de otras devoluciones de llamada. Una devolución de llamada que puede tardar debe declarar «esto va a tardar» con CallbackMayRunLong (el grupo lo toma como pista para añadir un hilo), o enviarse de entrada a un hilo dedicado. Tenga en cuenta que CallbackMayRunLong devuelve FALSE cuando no puede preparar un trabajador para otras devoluciones de llamada. Si se sigue bloqueando sin comprobar el valor de retorno, se sigue atascando el grupo, así que cuando es FALSE, caiga del lado que no bloquea: partir el trabajo, enviarlo a un hilo dedicado, etc.5

Esperar de forma síncrona la finalización en el mismo grupo. Una forma en la que, dentro de la devolución de llamada A, se espera con WaitForThreadpoolWorkCallbacks o similar la finalización del trabajo B enviado al mismo grupo se convierte en un interbloqueo por hambruna del grupo en el momento en que todos los trabajadores están «esperando a otro trabajador». Reescriba una dependencia entre trabajos no como una espera sino como una continuación que «envía el siguiente desde la devolución de llamada de finalización de B».

La estructura de un interbloqueo por hambruna del grupoSi todos los hilos trabajadores esperan de forma síncrona la finalización de otro trabajo enviado al mismo grupo, no queda ningún trabajador libre para ejecutar ese trabajo, y todos esperan para siempreTrabajador 1: espera a que termine XTrabajos X e Y esperando a ejecutarseTrabajador 2: espera a que termine YNo queda trabajador libre para ejecutarlosTodos esperan para siempre (hambruna)

Figura 7: Si se espera de forma síncrona a un trabajador desde dentro de un trabajador, no queda nadie para ejecutar el trabajo esperado.

Ensuciar el estado del hilo. Un hilo trabajador se reutiliza para la siguiente devolución de llamada. Un cambio de prioridad de hilo, el estado de inicialización COM, un valor dejado en TLS, un bloqueo que se olvidó de abandonar: cualquiera de estos se convierte en contaminación de la siguiente devolución de llamada (no relacionada). «Una función que se lanza a un grupo no debe depender de la personalidad del hilo» ha sido una advertencia oficial desde la era de la API antigua.6 Hay mecanismos dedicados para la limpieza; por ejemplo, LeaveCriticalSectionWhenCallbackReturns puede pedir al grupo que «libere este bloqueo cuando esta devolución de llamada regrese».3

Una carrera con la descarga de la DLL. Si la DLL que contiene el código se descarga mientras una devolución de llamada está en ejecución, se produce una violación de acceso. La forma básica es esperar a fondo la finalización en la función de apagado de la DLL; FreeLibraryWhenCallbackReturns se ofrece para la situación «esta devolución de llamada es el último trabajo, y cuando termine quiero que la DLL se libere incluyéndome a mí». Esta API, sin embargo, solo «suelta una referencia cuando la devolución de llamada en ejecución regresa»; no impide una descarga antes de que la devolución de llamada haya empezado. Se usa como pareja: tomar una referencia de módulo propia con GetModuleHandleEx antes de enviar, y hacer que la devolución de llamada suelte esa referencia con esta API.3 Y no se debe hacer esta espera de finalización dentro de DllMain: como se indica en «DllMain y el bloqueo del cargador», esperar a otro hilo dentro de DllMain es un patrón de interbloqueo.

Prepararse para una carrera entre la descarga de la DLL y una devolución de llamadaUna descarga de la DLL mientras una devolución de llamada está en ejecución se convierte en una violación de acceso, así que la forma básica es esperar la finalización en una función de apagado explícita y luego cerrar; cuando la última devolución de llamada libera ella misma la DLL, se usa FreeLibraryWhenCallbackReturnsUnload durante callbackAccess violationEsperar, luego cerrarDescarga seguraEn función de cierreFreeLibraryWhenCallbackReturnsúltimo callback libera DLLNo dentro de DllMain

Figura 8: La forma básica es hacer «esperar, luego cerrar» en una función de apagado explícita. Esperar dentro de DllMain invita a un interbloqueo distinto.

Excepciones y cuelgues dentro del trabajo enviado. Una excepción no controlada en un hilo trabajador se lleva el proceso consigo. Aplique una política de capturar las excepciones de forma integral a la entrada de la devolución de llamada y registrarlas, del mismo modo que lo haría para la función de hilo de un hilo dedicado.

7. Cómo se relaciona con la biblioteca estándar y .NET — En qué capa se escribe

Por último, ordenamos cómo encaja con las otras herramientas.

  • Si std::async / std::thread de C++ bastan, son la primera candidata. Son portátiles, el código es corto, y hasta la semántica de future la fija el estándar.10
  • Razones para usar el grupo de hilos de Win32 de forma directa son cuando (1) se quiere un mecanismo unificado de devolución de llamada que incluya timer, wait e io, (2) se quiere la división de grupos o el control del número de hilos, o (3) no se quieren mantener hilos propios dentro de una DLL o un componente COM.
  • En el lado de .NET, ThreadPool y Task desempeñan el mismo papel, y la finalización de E/S está ligada a IOCP. Esta estructura de sótano se explica en «IOCP y el grupo de hilos de .NET».
Decidir con las herramientas de qué capa se escribeSi async o thread del C++ estándar bastan, use esos; use el grupo de hilos de Win32 de forma directa cuando necesite la integración de temporizadores, esperas y finalizaciones de E/S, la división de grupos o el control del número de hilos, o no quiera hilos propios dentro de una DLL o un componente COMNoIntegración de timer, wait e ioDivisión de grupos / control de nºEvitar hilos propios dentro de una DLL¿Bastan las herramientas de C++ estándar?std::async / std::thread¿Qué necesita?Grupo de hilos de Win32

Figura 9: En caso de duda, empiece por la biblioteca estándar; el turno de esta API llega cuando aparece un requisito que aquella no puede expresar.

Dicho de otro modo, esta API es el fundamento de la concurrencia en el punto en que se ha decidido «escribir nativo». Una limpieza realista es por etapas: como destino de migración desde una proliferación de CreateThread propios, empiece por introducir el objeto work, luego sustituya los hilos que solo esperan por wait y los hilos de temporizador por timer.

8. Resumen

  • Para emitir un gran número de trabajos de corta duración, y para limpiar hilos que solo esperan y que solo temporizan, el grupo de hilos estándar del sistema operativo en lugar de hilos propios. Lo que se usa es la API nueva a partir de Vista.
  • En el centro están los cuatro objetos work, timer, wait e io. Las condiciones de disparo difieren; se unifican en el mismo conjunto de trabajadores y el mismo estilo de devolución de llamada.
  • Las maneras son «crear → enviar → esperar la finalización → cerrar». Varios envíos se ejecutan en paralelo. Un grupo de limpieza puede masificar el procesamiento de apagado.
  • Las tres reglas de hierro de una devolución de llamada: no bloquear durante mucho tiempo (si lo va a hacer, CallbackMayRunLong); no esperar de forma síncrona en el mismo grupo; no ensuciar el estado del hilo.
  • Uso desde una DLL: vigile una carrera de descarga. Espere la finalización en una función de apagado explícita; no lo haga en DllMain.
  • Donde el C++ estándar o .NET bastan, use esos. El turno de esta API es cuando se necesita la integración de timer/wait/io o el control de grupos.

La API del grupo de hilos está, entre las API de Win32, en el lado más nuevo y mejor diseñado. Una vez terminado el paso de la idea de «crear un hilo» a la idea de «lanzar una devolución de llamada», la concurrencia en código nativo se vuelve considerablemente más clara de escribir.

Artículos relacionados

Áreas de consultoría relacionadas

En KomuraSoft LLC nos encargamos del diseño de migración de código nativo cuyos hilos han proliferado hacia el grupo de hilos, de revisiones de diseño del procesamiento concurrente en aplicaciones y DLL de C++, y de la investigación de la causa de cuelgues y fallos provocados por hambruna del grupo o por devoluciones de llamada. Puede consultarnos empezando por un inventario del código existente.

Referencias

  1. Microsoft Learn, Thread Pools. Sobre que un grupo de hilos es una colección de hilos trabajadores que ejecutan de forma eficiente devoluciones de llamada asíncronas en nombre de una aplicación; sobre los tipos de aplicación a los que encaja (emitir en paralelo un gran número de elementos de trabajo pequeños, crear y desmontar con frecuencia hilos de corta duración, procesar trabajo independiente en paralelo, esperas exclusivas de objetos del kernel, etc.); y sobre el rediseño integral de Vista (unificación de tipos de hilo trabajador, una sola cola de temporizador, hilos persistentes dedicados, grupos de limpieza, varios grupos en un proceso, y la API nueva).  2 3 4 5

  2. Microsoft Learn, Thread Pooling. Sobre la estructura de las API más antiguas del grupo de hilos (QueueUserWorkItem, colas de temporizador, esperas registradas, BindIoCompletionCallback); sobre que no hay forma de cancelar un trabajo una vez encolado; y sobre que la API nueva del grupo de hilos introducida en Vista se afirma como más sencilla y superior en fiabilidad, rendimiento y flexibilidad.  2

  3. Microsoft Learn, threadpoolapiset.h header. Sobre la lista de funciones que incluye las cuatro funciones de creación de objetos CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait y CreateThreadpoolIo; los grupos de limpieza (CreateThreadpoolCleanupGroup); y la limpieza ligada a la finalización de la devolución de llamada (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns, etc.).  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. Sobre el procedimiento básico de crear con CreateThreadpoolWork, enviar con SubmitThreadpoolWork, esperar la finalización con WaitForThreadpoolWorkCallbacks y cerrar con CloseThreadpoolWork; y sobre un ejemplo de configuración que combina un grupo personalizado con un entorno de devolución de llamada y un grupo de limpieza.  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). Sobre notificar al grupo que la devolución de llamada actual puede ejecutarse durante mucho tiempo, de modo que el grupo pueda usar eso como material para decidir si asegurar un hilo para otras devoluciones de llamada; y sobre considerar un hilo dedicado para una devolución de llamada de larga duración siempre que sea posible.  2

  6. Microsoft Learn, Thread Pooling. Sobre que los elementos de trabajo enviados a un grupo de hilos, y las funciones que llaman, tienen que ser seguros para el grupo; sobre no asumir que el hilo que ejecuta es un hilo dedicado y persistente; y sobre evitar el uso de TLS y de llamadas asíncronas que requieren un hilo persistente.  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). Sobre poder enviar el mismo objeto work más de una vez sin esperar a que termine una devolución de llamada anterior, de modo que las devoluciones de llamada se ejecutan en paralelo; y sobre que el grupo puede ajustar (limitar) el número de hilos por eficiencia. 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). Sobre poder fijar un tope al número de hilos trabajadores de un grupo creado con CreateThreadpool (el suelo es SetThreadpoolThreadMinimum). 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). Sobre crear un objeto work a partir de una función de devolución de llamada y un puntero de contexto; y sobre que el tercer argumento, TP_CALLBACK_ENVIRON, puede indicar el entorno de ejecución de la devolución de llamada (el grupo al que pertenece, etc.), y NULL significa que se ejecuta en el entorno predeterminado. 

  10. Microsoft Learn, <future>. Sobre que la ejecución asíncrona por tarea mediante std::async y future se ofrece como biblioteca estándar, de modo que se puede escribir concurrencia sin gestionar hilos de forma directa. 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué tiene de mejor un grupo de hilos frente a crear hilos propios con CreateThread?
La eficiencia cuando hay un gran número de trabajos de corta duración, y una reducción del código de gestión de hilos. Crear y destruir un hilo tiene un coste que no se puede ignorar, así que una aplicación que repite «CreateThread por cada trabajo y destruirlo al terminar», o que mantiene muchos hilos que solo existen para dormir esperando un evento, puede reducir el número de hilos y los cambios de contexto al pasar a un grupo. La documentación oficial también enumera como candidatas a un grupo las aplicaciones que emiten en paralelo un gran número de elementos de trabajo pequeños, las que crean muchos hilos de corta duración y las que tienen hilos dedicados únicamente a esperar objetos del kernel. A la inversa, el trabajo que «necesita una personalidad propia en el hilo» —un cambio de prioridad, COM STA, un procesamiento dedicado de larga duración— debe seguir manteniéndose en un hilo dedicado como hasta ahora.
¿En qué se diferencia de las funciones antiguas del grupo de hilos como QueueUserWorkItem?
El grupo de hilos se rediseñó de forma integral en Windows Vista. Las API actuales de la familia threadpoolapiset (CreateThreadpoolWork y similares) son la API nueva; QueueUserWorkItem, RegisterWaitForSingleObject y similares son la API antigua (heredada). La API nueva unifica los tipos de hilo trabajador, permite crear varios grupos independientes en un proceso y ofrece mecanismos como la liberación masiva mediante un grupo de limpieza y la liberación de un bloqueo o la descarga de una DLL ligada a la finalización de la devolución de llamada. La documentación oficial también dice que la API nueva es más sencilla y superior en fiabilidad, rendimiento y flexibilidad. La API antigua tiene además restricciones estructurales, como «no hay forma de cancelar un trabajo una vez encolado», así que en código nuevo use la API nueva.
¿Hay cosas que no se deben hacer dentro de una devolución de llamada?
Hay tres grandes. Primera, bloquear durante mucho tiempo o hacer un trabajo largo con los valores predeterminados. El grupo ajusta su número de hilos asumiendo que las devoluciones de llamada terminan pronto, así que para un trabajo que va a tardar o se declara con CallbackMayRunLong o se usa un hilo dedicado. Segunda, esperar de forma síncrona la finalización de otro trabajo enviado al mismo grupo. Si todos los trabajadores terminan «esperando a otro trabajador», se produce un interbloqueo por hambruna del grupo. Tercera, depender de la personalidad del hilo. Los hilos trabajadores se comparten entre devoluciones de llamada, así que devolver con una prioridad de hilo o un estado de inicialización COM cambiados, o dejar estado en TLS, contamina la siguiente devolución de llamada. Para la limpieza al final (liberar un bloqueo o descargar una DLL) se ofrecen mecanismos dedicados como LeaveCriticalSectionWhenCallbackReturns y FreeLibraryWhenCallbackReturns.
¿Hay puntos a vigilar al usar el grupo de hilos desde una DLL?
El mayor peligro es «la DLL se descarga mientras una devolución de llamada sigue en ejecución». Si la devolución de llamada se ejecuta después de la descarga, se produce una violación de acceso. El lado de la DLL debe, en su procesamiento de apagado, esperar de forma fiable la finalización de las devoluciones de llamada que emitió —con una función de espera como WaitForThreadpoolWorkCallbacks, o CloseThreadpoolCleanupGroupMembers en un grupo de limpieza— y solo entonces cerrar los objetos. Esperar esto dentro de DllMain, sin embargo, puede interbloquearse por interacción con el bloqueo del cargador, así que la regla es hacerlo en una función de apagado explícita, no en DllMain. Para la situación en que la propia devolución de llamada quiere liberar la DLL porque «este trabajo es el último», se ofrece una API dedicada, FreeLibraryWhenCallbackReturns.
Ahora que tenemos std::async de C++ y el ThreadPool de .NET, ¿sigue habiendo ocasiones para usar esta API de forma directa?
Las hay. El criterio es «¿basta la herramienta de esa capa?». Si la granularidad de concurrencia que se necesita en C++ la cubren std::async o std::thread, la biblioteca estándar es la primera candidata también desde el punto de vista de la portabilidad. Por otro lado, querer unificar temporizadores, esperas de objetos del kernel y finalizaciones de E/S asíncrona en un solo mecanismo de devolución de llamada; querer dividir grupos y controlar el número de hilos por tipo de trabajo; no querer mantener hilos propios dentro de una DLL o un componente COM —esos requisitos son los que cubre el grupo de hilos de Win32. La relación con el ThreadPool de .NET y IOCP se trata en un artículo relacionado, y mientras se escriba nativo, conocer este mecanismo que está en la capa de debajo no es tiempo perdido.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog