La API del grupo de hilos de Win32 — Concurrencia sin crear hilos, con CreateThreadpoolWork
· Go Komura · 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 familiaQueueUserWorkItem), 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.
flowchart TB
accTitle: Elección entre un hilo dedicado y un grupo
accDescr: Primero 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 hilos
q1{"¿Hace falta personalidad (prioridad, STA)?"} -->|"Sí"| ded["Mantenerlo en un hilo dedicado"]
q1 -->|"No"| q2{"¿Se ejecuta durante mucho tiempo?"}
q2 -->|"Sí"| ded
q2 -->|"No"| pool["Ponerlo en el grupo de hilos"]
pool -.-> ex["Trabajo 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.
flowchart TB
accTitle: Correspondencia entre la API antigua del grupo de hilos y la API nueva
accDescr: QueueUserWorkItem 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 io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Timer queues"] --> n2["timer"]
o5["Registered waits"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
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 |
flowchart TB
accTitle: Los cuatro objetos del grupo de hilos y el mecanismo de devolución de llamada
accDescr: work 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
kind{"¿Qué objeto?"}
kind --> w["work(al enviar)"]
kind --> more{"¿Timer, wait o io?"}
more --> t["timer(tiempo / periodo)"]
more --> rest{"¿Wait o io?"}
rest --> wt["wait(al señalizar)"]
rest --> io["io(finalización de E/S)"]
w --> pool["Los trabajadores ejecutan la devolución"]
t --> pool
wt --> pool
io --> pool
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
flowchart TB
accTitle: Sustituir hilos que solo esperan por objetos wait
accDescr: Los 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ñalizarse
old2["5 hilos dedicados a esperar, dormidos por separado"] -.-> waste["Consume 5 pilas y 5 hilos"]
new2["5 objetos wait"] --> agg["Agregados en los hilos de espera del grupo"]
agg --> cb2["La 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.
flowchart TB
accTitle: Ciclo de vida de un objeto work
accDescr: Se 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 CloseThreadpoolWork
c["Crear con CreateThreadpoolWork"] --> s["Enviar con SubmitThreadpoolWork (se puede repetir)"]
s --> run["Las devoluciones se ejecutan en paralelo"]
run --> stop3["Detener los envíos nuevos"]
stop3 --> w["Esperar la finalización con WaitForThreadpoolWorkCallbacks"]
w --> cl["Cerrar 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
flowchart TB
accTitle: Vincular la configuración a través de un entorno de devolución de llamada
accDescr: El 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ón
env["Entorno de devolución (TP_CALLBACK_ENVIRON)"] --> cp["Grupo personalizado (controlar el nº de hilos)"]
env --> cg["Grupo de limpieza"]
env --> obj["Se pasa al crear work / timer / wait / io"]
cg -.-> close["Esperar 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».
flowchart TB
accTitle: La estructura de un interbloqueo por hambruna del grupo
accDescr: Si 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 siempre
w1["Trabajador 1: espera a que termine X"] --> q["Trabajos X e Y esperando a ejecutarse"]
w2["Trabajador 2: espera a que termine Y"] --> q
q -.-> none["No queda trabajador libre para ejecutarlos"]
none -.-> dead["Todos 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.
flowchart TB
accTitle: Prepararse para una carrera entre la descarga de la DLL y una devolución de llamada
accDescr: Una 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 FreeLibraryWhenCallbackReturns
risk["Unload durante callback"] -.-> av["Access violation"]
g1["Esperar, luego cerrar"] --> safe["Descarga segura"]
g1 -.-> g1N["En función de cierre"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["último callback libera DLL"]
g1 -.-> ng["No dentro de DllMain"]
av ~~~ g1
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::threadde C++ bastan, son la primera candidata. Son portátiles, el código es corto, y hasta la semántica defuturela 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,
ThreadPoolyTaskdesempeñ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».
flowchart TB
accTitle: Decidir con las herramientas de qué capa se escribe
accDescr: Si 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 COM
q1{"¿Bastan las herramientas de C++ estándar?"} -->|"Sí"| std["std::async / std::thread"]
q1 -->|"No"| q2{"¿Qué necesita?"}
q2 -->|"Integración de timer, wait e io"| tp["Grupo de hilos de Win32"]
q2 -->|"División de grupos / control de nº"| tp
q2 -->|"Evitar hilos propios dentro de una DLL"| tp
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
- Los entresijos de E/S de Windows (parte 3) — Puertos de finalización de E/S (IOCP) y el grupo de subprocesos de .NET: el sótano de async/await
- Buenas prácticas de multithreading en la práctica — Edición C++: eliminando los accidentes desde la estructura con RAII y jthread
- Buenas prácticas de multihilo en la práctica — Edición C — Programar con seguridad al estilo de la API Win32
- Spurious Wakeups — Why Condition Variables Wake “Without Being Notified” and How to Wait Correctly on Windows
- DllMain and the Loader Lock — The Real Reason You’re Told to “Do Nothing in DLL Initialization”
- Por qué priorizar la espera por eventos sobre Sleep(1) en Windows
Á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.
- Desarrollo de aplicaciones Windows
- Consultoría técnica y revisión de diseño
- Investigación de fallos y análisis de causas
- Contacto
Referencias
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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). ↩
-
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. ↩
-
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 relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Canalizaciones con nombre en la práctica — El IPC estándar de Windows, del diseño a la seguridad
Guía práctica de las canalizaciones con nombre, el IPC estándar de Windows. Organiza, a partir de fuentes primarias, la elección entre mo...
Cómo interpretar los códigos de error de Windows — la estructura de tres capas de Win32, HRESULT y NTSTATUS
Antes de buscar 0x80004005, descompóngalo. Explicamos la estructura de tres capas Win32/HRESULT/NTSTATUS, el patrón 0x8007xxxx y cómo inv...
Cómo funcionan el portapapeles y arrastrar y soltar — Gestionar correctamente la transferencia de datos OLE en aplicaciones empresariales
Una tabla de Excel se deforma al pegarla y deja de poder pegarse si cierra el origen: es el portapapeles colocando el mismo contenido en ...
WPR/WPA en la práctica — Introducción al análisis de rendimiento del sistema para «todo el PC va lento»
Problemas como «todo el PC va lento» o «el arranque es lento», que el Administrador de tareas no explica, se investigan con WPR/WPA leyen...
El apagado de Windows visto desde la aplicación ── cómo sobrevivir correctamente a la notificación de cierre, el reinicio y el corte de energía
Un reinicio nocturno de Windows Update corrompió datos de medición: ese accidente se evita con buen diseño. Explicamos, con fuentes ofici...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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.