Estados de subprocesos. Secciones críticas Finalización de la sincronización en el sistema operativo Windows

Este objeto de sincronización solo se puede usar localmente dentro del proceso que lo creó. El resto de objetos se pueden utilizar para sincronizar los hilos de diferentes procesos. El nombre del objeto "sección crítica" está asociado con una selección abstracta de una parte del código del programa (sección) que realiza algunas operaciones, cuyo orden no se puede violar. Es decir, un intento por parte de dos hilos diferentes de ejecutar simultáneamente el código de esta sección resultará en un error.

Por ejemplo, puede ser conveniente proteger las funciones de escritor con una sección de este tipo, ya que se debe excluir el acceso simultáneo de varios escritores.

Se introducen dos operaciones para la sección crítica:

entrar en la seccion Mientras cualquier subproceso esté en la sección crítica, todos los demás subprocesos dejarán de esperar automáticamente cuando intenten ingresar. Un hilo que ya ha entrado en esta sección puede entrar varias veces sin esperar a que se libere.

dejar la sección Cuando un hilo sale de una sección, el contador del número de entradas de este hilo en la sección se decrementa, de modo que la sección quedará libre para otros hilos sólo si el hilo sale de la sección tantas veces como entró. Cuando se libera una sección crítica, solo se despertará un hilo, esperando el permiso para ingresar a esta sección.

En términos generales, en otras API que no son de Win32 (como OS/2), la sección crítica no se trata como un objeto de sincronización, sino como una pieza de código de programa que puede ser ejecutada por un solo subproceso de aplicación. Es decir, entrar en la sección crítica se considera como un cierre temporal del mecanismo de cambio de hilo hasta la salida de esta sección. La API de Win32 trata las secciones críticas como objetos, lo que genera cierta confusión: sus propiedades son muy parecidas a las de los objetos exclusivos sin nombre ( exclusión mutua, vea abajo).

Al usar secciones críticas, se debe tener cuidado de no asignar fragmentos de código demasiado grandes en la sección, ya que esto puede provocar retrasos significativos en la ejecución de otros subprocesos.

Por ejemplo, en relación con los montones ya considerados, no tiene sentido proteger todas las funciones del montón con una sección crítica, ya que las funciones de lectura se pueden ejecutar en paralelo. Además, el uso de una sección crítica incluso para sincronizar escritores en realidad parece ser un inconveniente, ya que para sincronizar un escritor con lectores, estos últimos aún tendrán que ingresar a esta sección, lo que prácticamente conduce a la protección de todas las funciones por un único sección.

Hay varios casos de uso efectivo de secciones críticas:

los lectores no entran en conflicto con los escritores (solo se necesita proteger a los escritores);

todos los subprocesos tienen aproximadamente los mismos derechos de acceso (por ejemplo, no puede seleccionar escritores y lectores puros);

al construir objetos de sincronización compuestos, que consisten en varios estándar, para proteger las operaciones secuenciales en un objeto compuesto.

En las partes anteriores del artículo, hablé de principios generales y métodos específicos para construir aplicaciones de subprocesos múltiples. Los diferentes subprocesos casi siempre necesitan interactuar periódicamente entre sí, y surge inevitablemente la necesidad de sincronización. Hoy vamos a echar un vistazo a la herramienta de sincronización de Windows más importante, potente y versátil: Kernel Sync Objects.

WaitForMultipleObjects y otras funciones de espera

Como recordará, para sincronizar hilos, generalmente necesita suspender temporalmente la ejecución de uno de los hilos. Sin embargo, debe ser traducido por medio sistema operativo a un estado de espera en el que no consume tiempo de CPU. Ya conocemos dos funciones que pueden hacer esto: SuspendThread y ResumeThread. Pero como dije en la parte anterior del artículo, debido a algunas características, estas funciones no son adecuadas para la sincronización.

Hoy veremos otra función que también pone el subproceso en estado de espera, pero a diferencia de SuspendThread/ResumeThread, está diseñada específicamente para organizar la sincronización. Es WaitForMultipleObjects. Debido a que esta característica es tan importante, me desviaré un poco de mi regla de no entrar en los detalles de la API y hablaré de ella con más detalle, incluso daré su prototipo:

DWORD WaitForMultipleObjects (

DWORD nCount , // número de objetos en la matriz lpHandles

MANGO CONSTANTE * lpManeja , // puntero a una matriz de descriptores de objetos del kernel

BOOL bEsperar todo , // indicador que indica si esperar todos los objetos o solo uno es suficiente

DWORD dwMilisegundos // se acabó el tiempo

El parámetro principal de esta función es un puntero a una matriz de identificadores de objetos del núcleo. Hablaremos de cuáles son estos objetos a continuación. Por ahora, es importante que sepamos que cualquiera de estos objetos puede estar en uno de dos estados: neutral o "señalización" (estado señalado). Si el indicador bWaitAll es FALSO, la función regresará tan pronto como al menos uno de los objetos dé una señal. Y si la bandera es VERDADERA, esto sucederá solo cuando todos los objetos comiencen a señalar a la vez (como veremos, esta es la propiedad más importante de esta función). En el primer caso, por el valor devuelto, puede averiguar cuál de los objetos dio la señal. Debe restarle la constante WAIT_OBJECT_0 y obtendrá un índice en la matriz lpHandles. Si el tiempo de espera excede el tiempo de espera especificado en el último parámetro, la función dejará de esperar y devolverá el valor WAIT_TIMEOUT. Como tiempo de espera, puede especificar la constante INFINITE , y luego la función esperará "hasta que se detenga", o puede viceversa 0, y luego el hilo no se suspenderá en absoluto. En este último caso, la función regresará inmediatamente, pero su resultado te dirá el estado de los objetos. Esta última técnica se utiliza muy a menudo. Como puede ver, esta función tiene capacidades ricas. Hay varias otras funciones de WaitForXXX, pero todas son variaciones del tema principal. En particular, WaitForSingleObject es solo una versión simplificada del mismo. El resto tiene cada uno su propia funcionalidad adicional, pero se usan, en general, con menos frecuencia. Por ejemplo, hacen posible responder no solo a las señales de los objetos del kernel, sino también a la llegada de mensajes de nueva ventana en la cola del hilo. Su descripción, así como información detallada sobre WaitForMultipleObjects, la encontrará, como de costumbre, en MSDN.

Ahora, sobre qué son estos misteriosos "objetos del núcleo". Para empezar, estos incluyen los propios hilos y procesos. Entran en el estado de señalización inmediatamente después de completarse. Esta es una característica muy importante porque a menudo es necesario realizar un seguimiento de cuándo finaliza un subproceso o un proceso. Deje, por ejemplo, que se complete nuestra aplicación de servidor con un conjunto de subprocesos de trabajo. Al mismo tiempo, el subproceso de control debe informar a los subprocesos de trabajo de alguna manera que es hora de finalizar el trabajo (por ejemplo, configurando un indicador global), y luego esperar hasta que todos los subprocesos se hayan completado, haciendo todo lo necesario para la finalización correcta. de la acción: liberar recursos, informar a los clientes del apagado, cerrar conexiones de red, etc.

El hecho de que los hilos activen una señal al final del trabajo hace que sea extremadamente fácil resolver el problema de sincronización con la terminación del hilo:

// Para simplificar, solo tengamos un hilo de trabajo. Vamos a ejecutarlo:

MANGO hWorkerThread = :: Crear hilo (...);

// Antes de que finalice el trabajo, de alguna manera debemos decirle al subproceso de trabajo que es hora de cargar.

// Espera a que termine el hilo:

DWORD dwWaitResult = :: WaitForSingleObject ( hWorkerHilo , INFINITO );

si( dwEsperarResultado != ESPERA_OBJECT_0 ) { /* manejo de errores */ }

// El "mango" de la secuencia se puede cerrar:

VERIFICAR (:: CerrarManejar ( hWorkerHilo );

/* Si CloseHandle falla y devuelve FALSO, no lanzo una excepción. En primer lugar, incluso si esto sucediera debido a un error del sistema, no tendría consecuencias directas para nuestro programa, ya que, dado que cerramos el identificador, no se espera ningún trabajo con él en el futuro. En realidad, la falla de CloseHandle solo puede significar un error en su programa. Por lo tanto, insertaremos aquí la macro VERIFY para no perderla en la etapa de depuración de la aplicación. */

El código que espera a que finalice el proceso tendrá un aspecto similar.

Si no existiera tal capacidad incorporada, el subproceso de trabajo tendría que pasar de alguna manera información sobre su finalización al subproceso principal. Incluso si hiciera esto último, el subproceso principal no podía estar seguro de que al trabajador no le quedaran al menos un par de instrucciones del ensamblador para ejecutar. A situaciones individuales(por ejemplo, si el código del subproceso está en una DLL que debe descargarse cuando finaliza), esto puede ser fatal.

Quiero recordarle que incluso después de que un subproceso (o proceso) haya finalizado, sus identificadores seguirán vigentes hasta que la función CloseHandle los cierre explícitamente. (Por cierto, ¡no olvide hacer esto!) Esto se hace solo para que en cualquier momento pueda verificar el estado de la transmisión.

Entonces, la función WaitForMultipleObjects (y sus análogos) le permite sincronizar la ejecución de un hilo con el estado de los objetos de sincronización, en particular, otros hilos y procesos.

Objetos especiales del kernel

Pasemos a la consideración de los objetos del núcleo, que están diseñados específicamente para la sincronización. Estos son eventos, semáforos y mutexes. Veamos brevemente cada uno de ellos:

evento

Quizás el objeto de sincronización más simple y fundamental. Esta es solo una bandera que se puede configurar con las funciones SetEvent / ResetEvent: señalización o neutral. Un evento es la forma más conveniente de señalar a un subproceso en espera que ha ocurrido algún evento (por eso se llama) y puede continuar trabajando. Usando un evento, podemos resolver fácilmente el problema de sincronización al inicializar un subproceso de trabajo:

// Mantengamos el identificador del evento en una variable global para simplificar:

MANEJAR g_hEventInitComplete = NULO ; // ¡nunca deje una variable sin inicializar!

{ // código en el hilo principal

// crea un evento

g_hEventInitComplete = :: Crear evento ( NULO,

FALSO , // hablaremos de este parámetro más tarde

FALSO , // estado inicial - neutral

si(! g_hEventInitComplete ) { /* No te olvides del manejo de errores */ }

// crea un hilo de trabajo

DWORD idWorkerThread = 0 ;

MANGO hWorkerThread = :: Crear hilo ( NULO , 0 , & WorkerThreadProc , NULO , 0 , & idWorkerThread );

si(! hWorkerHilo ) { /* manejo de errores */ }

// espera una señal del subproceso de trabajo

DWORD dwWaitResult = :: WaitForSingleObject ( g_hEventInitComplete , INFINITO );

si( dwEsperarResultado != ESPERA_OBJECT_0 ) { /* error */ }

// ahora puede estar seguro de que el subproceso de trabajo ha completado la inicialización.

VERIFICAR (:: CerrarManejar ( g_hEventInitComplete )); // no olvide cerrar los objetos innecesarios

g_hEventInitComplete = NULO ;

// función de flujo de trabajo

DWORD WINAPI WorkerThreadProc ( LPVOID_parámetro )

InicializarTrabajador (); // inicialización

// indica que la inicialización está completa

BOOL está bien = :: EstablecerEvento ( g_hEventInitComplete );

si(! esta bien ) { /* error */ }

Cabe señalar que hay dos variedades de eventos marcadamente diferentes. Podemos seleccionar uno de ellos usando el segundo parámetro de la función CreateEvent. Si es TRUE, se crea un evento cuyo estado se controla solo manualmente, es decir, mediante las funciones SetEvent/ResetEvent. Si es FALSO, se generará un evento de reinicio automático. Esto significa que tan pronto como una señal de este evento libere algún subproceso en espera de un evento determinado, se restablecerá automáticamente a un estado neutral. Su diferencia es más pronunciada en una situación en la que varios subprocesos están esperando un evento a la vez. Un evento controlado manualmente es como un pistoletazo de salida. Tan pronto como se establezca en el estado señalado, todos los subprocesos se liberarán a la vez. Un evento de reinicio automático, por otro lado, es como un torniquete de metro: liberará solo un flujo y volverá a un estado neutral.

exclusión mutua

Comparado con un evento, este es un objeto más especializado. Por lo general, se usa para resolver un problema de sincronización común, como acceder a un recurso compartido por varios subprocesos. En muchos sentidos, es similar a un evento de reinicio automático. La principal diferencia es que tiene un enlace especial a un hilo específico. Si el mutex está en estado señalado, significa que está libre y no pertenece a ningún subproceso. Tan pronto como un determinado subproceso haya esperado este mutex, este último se restablece a un estado neutral (aquí es como un evento de reinicio automático), y el subproceso se convierte en su propietario hasta que libera explícitamente el mutex con la función ReleaseMutex, o termina. Por lo tanto, para asegurarse de que solo un subproceso funcione con datos compartidos a la vez, todos los lugares donde se lleva a cabo dicho trabajo deben estar rodeados por un par: WaitFor - ReleaseMutex:

MANEJAR g_hMutex ;

// Permita que el identificador mutex se almacene en una variable global. Por supuesto, debe crearse con anticipación, antes del inicio de los subprocesos de trabajo. Supongamos que esto ya se ha hecho.

En t yo espero = :: WaitForSingleObject ( g_hMutex , INFINITO );

cambiar( yo espero ) {

caso ESPERA_OBJECT_0 : // Todo esta bien

descanso;

caso ESPERA_ABANDONADO : /* Algún hilo finalizó, olvidándose de llamar a ReleaseMutex. ¡Lo más probable es que esto signifique un error en su programa! Por lo tanto, por si acaso, insertaremos ASSERT aquí, pero en la versión final (lanzamiento) consideraremos este código como exitoso. */

AFIRMAR ( falso );

descanso;

defecto:

// El manejo de errores debería estar aquí.

// Una pieza de código protegida por un mutex.

ProcessCommonData ();

VERIFICAR (:: LiberarMutex ( g_hMutex ));

¿Por qué es mejor un mutex que un evento de reinicio automático? En el ejemplo anterior, también podría usarse, solo ReleaseMutex tendría que ser reemplazado por SetEvent. Sin embargo, puede surgir la siguiente dificultad. La mayoría de las veces, tiene que trabajar con datos compartidos en varios lugares. ¿Qué sucede si ProcessCommonData en nuestro ejemplo llama a una función que trabaja con los mismos datos y que ya tiene su propio par de WaitFor - ReleaseMutex (en la práctica esto es muy común)? Si tuviéramos que usar un evento, el programa obviamente colgaría, porque dentro del bloque protegido, el evento está en un estado neutral. El mutex es más complicado. Siempre permanece en el estado de señalización para el subproceso maestro, aunque esté en el estado neutral para todos los demás subprocesos. Por lo tanto, si un subproceso ha adquirido la exclusión mutua, llamar de nuevo a la función WaitFor no se bloqueará. Además, un contador también está integrado en el mutex, por lo que se debe llamar a ReleaseMutex la misma cantidad de veces que hubo llamadas a WaitFor . Por lo tanto, podemos proteger de forma segura cada pieza de código que funciona con datos compartidos con un par WaitFor - ReleaseMute x sin preocuparnos de que este código se pueda llamar recursivamente. Esto hace que el mutex sea una herramienta muy fácil de usar.

Semáforo

Un objeto de sincronización aún más específico. Debo confesar que en mi práctica aún no ha habido un caso en el que sea de utilidad. Un semáforo está diseñado para limitar el número máximo de subprocesos que pueden trabajar en un recurso al mismo tiempo. Esencialmente, un semáforo es un evento con un contador. Mientras este contador sea mayor que cero, el semáforo está en estado de señalización. Sin embargo, cada llamada a WaitFor decrementa este contador en uno hasta que se convierte en cero y el semáforo pasa al estado neutral. Al igual que un mutex, un semáforo tiene una función ReleaseSemaphor que incrementa un contador. Sin embargo, a diferencia de un mutex, un semáforo no está vinculado a un subproceso, y llamar a WaitFor/ReleaseSemaphor nuevamente disminuirá/incrementará el contador.

¿Cómo se puede utilizar un semáforo? Por ejemplo, se puede usar para restringir artificialmente los subprocesos múltiples. Como ya mencioné, demasiados subprocesos activos simultáneamente pueden degradar notablemente el rendimiento de todo el sistema debido a los frecuentes cambios de contexto. Y si tuviéramos que crear demasiados subprocesos de trabajo, podemos limitar la cantidad de subprocesos activos simultáneamente a un número del orden de la cantidad de procesadores.

¿Qué más se puede decir sobre los objetos de sincronización del kernel? Es muy conveniente darles nombres. Todas las funciones que crean objetos de sincronización tienen el parámetro correspondiente: CreateEvent, CreateMutex, CreateSemaphore. Si llama, por ejemplo, CreateEvent dos veces, ambas veces especificando el mismo nombre no vacío, la segunda vez que la función, en lugar de crear un nuevo objeto, devolverá el identificador de uno existente. Esto sucederá incluso si la segunda llamada se realizó desde otro proceso. Este último es muy conveniente en los casos en que desea sincronizar subprocesos que pertenecen a diferentes procesos.

Cuando ya no necesite el objeto de sincronización, no olvide llamar a la función CloseHandle que mencioné anteriormente cuando hablé de subprocesos. De hecho, no necesariamente eliminará el objeto de inmediato. El punto es que un objeto puede tener varios identificadores, y luego se eliminará solo cuando se cierre el último.

quiero recordarte que La mejor manera para asegurarse de que CloseHandle o una función de "limpieza" similar sea llamada, incluso en el caso de una situación anormal, es colocarla en un destructor. Por cierto, esto se describió una vez bien y con gran detalle en el artículo de Kirill Pleshivtsev "Smart Destructor". En los ejemplos anteriores, no utilicé esta técnica únicamente con fines educativos, para que el trabajo de las funciones de la API fuera más visual. En el código real, siempre debe usar clases contenedoras con destructores inteligentes para la limpieza.

Por cierto, con la función ReleaseMutex y similares, surge constantemente el mismo problema que con CloseHandle. Debe llamarse al final del trabajo con datos compartidos, independientemente del éxito con el que se completó este trabajo (después de todo, se podría generar una excepción). Las consecuencias del "olvido" son aquí más graves. Si no se llama a CloseHandle, solo se perderán recursos (¡lo cual también es malo!), entonces un mutex no liberado evitará que otros subprocesos funcionen con el recurso compartido hasta que finalice el subproceso fallido, lo que probablemente no permitirá que la aplicación funcione normalmente. Para evitar esto, una clase especialmente entrenada con un destructor inteligente nos ayudará nuevamente.

Terminando la revisión de los objetos de sincronización, me gustaría mencionar un objeto que no está en la API de Win32. Muchos de mis colegas se preguntan por qué Win32 no tiene un objeto especializado "uno escribe, muchas lecturas". Una especie de "mutex avanzado", que se asegura de que solo un subproceso pueda acceder simultáneamente a los datos compartidos para escribir, y varios subprocesos solo pueden leer a la vez. Se puede encontrar un objeto similar en UNIX "ah. Algunas bibliotecas, por ejemplo de Borland, ofrecen emularlo en función de los objetos de sincronización estándar. Sin embargo, el beneficio real de tales emulaciones es muy dudoso. Tal objeto puede implementarse de manera efectiva solo en el nivel del kernel del sistema operativo Pero en el kernel de Windows no proporciona tal objeto.

¿Por qué los desarrolladores del núcleo de Windows NT no se ocuparon de esto? ¿Por qué somos peores que UNIX? En mi opinión, la respuesta es que simplemente todavía no ha habido una necesidad real de tal objeto para Windows. En una máquina monoprocesador normal, donde los subprocesos aún no pueden trabajar físicamente simultáneamente, será prácticamente equivalente a un mutex. En una máquina multiprocesador, puede beneficiarse al permitir que los subprocesos del lector se ejecuten en paralelo. Al mismo tiempo, esta ganancia será tangible solo cuando la probabilidad de una "colisión" de hilos de lectura sea alta. Sin duda, por ejemplo, en una máquina con procesador 1024, dicho objeto kernel será vital. Existen máquinas similares, pero son sistemas especializados que ejecutan sistemas operativos especializados. A menudo, tales sistemas operativos se construyen sobre la base de UNIX, probablemente a partir de ahí un objeto como "uno escribe, muchos leen" se introdujo en las versiones más utilizadas de este sistema. Pero en las máquinas x86 estamos acostumbrados a que, por regla general, solo se instale uno y solo ocasionalmente dos procesadores. Y solo los modelos más avanzados de procesadores, como Intel Xeon, admiten 4 o incluso más configuraciones de procesador, pero estos sistemas siguen siendo exóticos. Pero incluso en un sistema tan "avanzado", un "mutex avanzado" puede brindar una ganancia de rendimiento notable solo en situaciones muy específicas.

Por lo tanto, implementar un mutex "avanzado" simplemente no vale la pena. En una máquina de "procesador bajo", incluso puede ser menos eficiente debido a la complejidad de la lógica del objeto en comparación con un mutex estándar. Tenga en cuenta que la implementación de dicho objeto no es tan simple como podría parecer a primera vista. Con una implementación fallida, si hay demasiados subprocesos de lectura, el subproceso de escritura simplemente "no llegará" a los datos. Por estas razones, tampoco recomiendo que intente emular dicho objeto. En aplicaciones reales en máquinas reales, un mutex regular o una sección crítica (que se discutirá en la siguiente parte del artículo) se encargará perfectamente de la tarea de sincronizar el acceso a los datos compartidos. Aunque, supongo, con el desarrollo del sistema operativo Windows, el objeto del kernel "uno escribe, muchas lecturas" aparecerá tarde o temprano.

Nota. De hecho, el objeto "uno escribe, muchos leen" en Windows NT todavía existe. Simplemente no lo sabía cuando escribí este artículo. Este objeto se denomina "recursos del núcleo" y no es accesible para los programas en modo usuario, por lo que probablemente no sea tan conocido. Las similitudes al respecto se pueden encontrar en el DDK. Gracias a Konstantin Manurin por señalarme esto.

Punto muerto

Ahora volvamos a la función WaitForMultipleObjects, más precisamente, a su tercer parámetro, bWaitAll. Prometí decirte por qué es tan importante la capacidad de esperar varios objetos a la vez.

Es comprensible por qué se necesita una función para esperar uno de varios objetos. En ausencia de una función especial, esto podría hacerse, excepto verificando secuencialmente el estado de los objetos en un ciclo vacío, lo cual, por supuesto, es inaceptable. Pero la necesidad de una función especial que le permita esperar el momento en que varios objetos entren en el estado de señal a la vez no es tan obvia. De hecho, imagine la siguiente situación típica: en un momento determinado, nuestro hilo necesita acceso a dos conjuntos de datos compartidos a la vez, cada uno de los cuales es responsable de su propio mutex, llamémoslos A y B. Parecería que el hilo puede primero espere hasta que se libere el mutex A, captúrelo, luego espere a que se libere el mutex B... Parece que podemos hacerlo con un par de llamadas a WaitForSingleObject. De hecho, esto funcionará, pero solo mientras todos los demás subprocesos adquieran los mutex en el mismo orden: primero A, luego B. ¿Qué sucede si un determinado subproceso intenta hacer lo contrario: primero adquiere B, luego A? Tarde o temprano, surgirá una situación en la que un subproceso ha capturado mutex A, otro B, el primero está esperando que se libere B, el segundo A. Está claro que nunca esperarán esto y el programa se colgará.

Este tipo de punto muerto es un error muy común. Como todos los errores relacionados con la sincronización, aparece solo de vez en cuando y puede arruinar muchos nervios para un programador. Al mismo tiempo, casi cualquier esquema que involucre varios objetos de sincronización está plagado de interbloqueos. Por lo tanto, se debe prestar especial atención a este problema en la etapa de diseño de dicho circuito.

En el ejemplo simple dado, el bloqueo es bastante fácil de evitar. Es necesario exigir que todos los subprocesos adquieran mutexes en un cierto orden: primero A, luego B. Sin embargo, en un programa complejo, donde hay muchos objetos relacionados entre sí de varias maneras, esto no suele ser tan fácil de lograr. No dos, pero muchos objetos e hilos pueden estar involucrados en un bloqueo. Por lo tanto, lo más manera confiable Para evitar un punto muerto en una situación en la que un subproceso necesita varios objetos de sincronización a la vez, debe capturarlos todos con una llamada a la función WaitForMultipleObjects con el parámetro bWaitAll=TRUE. A decir verdad, en este caso, simplemente trasladamos el problema de los puntos muertos al núcleo del sistema operativo, pero lo principal es que ya no será nuestra preocupación. Sin embargo, en un programa complejo con muchos objetos, cuando no siempre es posible decir de inmediato cuál de ellos será necesario para realizar una operación en particular, a menudo no es fácil reunir todas las llamadas de WaitFor en un solo lugar y combinarlas también.

Por lo tanto, hay dos formas de evitar el interbloqueo. Debe asegurarse de que los objetos de sincronización siempre sean capturados por subprocesos exactamente en el mismo orden, o que sean capturados por una sola llamada a WaitForMultipleObjects . El último método es más simple y preferido. Sin embargo, en la práctica, con el cumplimiento de ambos requisitos, surgen constantemente dificultades, es necesario combinar ambos enfoques. El diseño de circuitos de temporización complejos suele ser una tarea nada trivial.

Ejemplo de sincronización

En situaciones más típicas, como las que describí anteriormente, no es difícil organizar la sincronización, un evento o un mutex es suficiente. Pero periódicamente hay casos más complejos donde la solución al problema no es tan obvia. Me gustaría ilustrar esto con un ejemplo concreto de mi práctica. Como verá, la solución resultó ser sorprendentemente simple, pero antes de encontrarla, tuve que probar varias opciones sin éxito.

Así que la tarea. Casi todos los administradores de descarga modernos, o simplemente "sillas mecedoras", tienen la capacidad de restringir el tráfico para que la "silla mecedora" que se ejecuta en segundo plano no interfiera mucho con la navegación del usuario por la Web. Estaba desarrollando un programa similar, y me dieron la tarea de implementar tal "característica". Mi mecedora funcionaba de acuerdo con el esquema clásico de subprocesos múltiples, cuando cada tarea, en este caso, descargar un archivo específico, es manejada por un subproceso separado. El límite de tráfico debería haber sido acumulativo para todos los flujos. Es decir, era necesario asegurarse de que durante un intervalo de tiempo determinado todos los flujos leyeran de sus sockets no más de una determinada cantidad de bytes. Simplemente dividir este límite en partes iguales entre flujos obviamente será ineficiente, ya que la descarga de archivos puede ser muy desigual, uno se descargará rápidamente y el otro lentamente. Por lo tanto, necesitamos un contador común para todos los subprocesos, cuántos bytes se han leído y cuántos más se pueden leer. Aquí es donde la sincronización es útil. El requisito de que en cualquier momento se pudiera detener cualquiera de los subprocesos de trabajo proporcionaba una complejidad adicional a la tarea.

Formulemos el problema con más detalle. Decidí encerrar el sistema de sincronización en una clase especial. Aquí está su interfaz:

clase Ccuota {

público: // métodos

vacío establecer ( int sin firmar _nCuota );

int sin firmar Solicitud ( int sin firmar _nBytesParaLeer , HANDLE_hStopEvent );

vacío Liberar ( int sin firmar _nBytesRevertir , HANDLE_hStopEvent );

Periódicamente, digamos una vez por segundo, el subproceso de control llama al método Set, estableciendo la cuota de descarga. Antes de que el subproceso de trabajo lea los datos recibidos de la red, llama al método Request, que verifica que la cuota actual no sea cero y, de ser así, devuelve la cantidad de bytes que se pueden leer menos que la cuota actual. La cuota se reduce correspondientemente en este número. Si la cuota es cero cuando se llama a Request, el subproceso de llamada debe esperar hasta que la cuota esté disponible. A veces sucede que en realidad se reciben menos bytes de los solicitados, en cuyo caso el subproceso devuelve parte de la cuota que le asignó el método Release. Y, como decía, el usuario puede en cualquier momento dar la orden de detener la descarga. En este caso, la espera debe interrumpirse, independientemente de la presencia de una cuota. Para ello se utiliza un evento especial: _hStopEvent. Dado que las tareas se pueden iniciar y detener de forma independiente, cada subproceso de trabajo tiene su propio evento de detención. Su identificador se pasa a los métodos Request y Release.

En una de las opciones fallidas, intenté usar una combinación de un mutex que sincroniza el acceso a la clase CQuota y un evento que señala la presencia de una cuota. Sin embargo, el evento de parada no encaja en este esquema. Si un subproceso desea adquirir una cuota, entonces su estado de espera debe ser controlado por una expresión booleana compleja: ((mutex Y evento de cuota) O evento de parada). Pero WaitForMultipleObjects no permite esto, puede combinar varios objetos del kernel con una operación AND u OR, pero no mezclados. Intentar dividir la espera con dos llamadas consecutivas a WaitForMultipleObjects inevitablemente genera un punto muerto. En general, este camino resultó ser un callejón sin salida.

Ya no dejaré entrar la niebla y te diré la solución. Como dije, un mutex es muy similar a un evento de reinicio automático. Y aquí tenemos ese caso raro en el que es más conveniente usarlo, pero no uno, sino dos a la vez:

clase Ccuota {

privado: // datos

int sin firmar m_nCuota ;

CEvent m_eventHasQuota ;

CEvent m_eventNoQuota ;

Solo se puede configurar uno de estos eventos a la vez. Cualquier subproceso que manipule la cuota debe establecer el primer evento si la cuota restante no es cero y el segundo si la cuota se ha agotado. Un subproceso que quiere obtener una cuota debe esperar al primer evento. El subproceso que aumenta la cuota solo necesita esperar cualquiera de estos eventos, porque si ambos están en estado de reinicio, significa que otro subproceso está trabajando actualmente con la cuota. Así, dos eventos realizan dos funciones a la vez: sincronización de acceso a datos y espera. Finalmente, dado que el subproceso está esperando uno de los dos eventos, el evento que indica que se detenga se incluye fácilmente.

Daré un ejemplo de la implementación del método Request. El resto se implementan de manera similar. Simplifiqué ligeramente el código utilizado en el proyecto real:

int sin firmar Ccuota :: Solicitud ( int sin firmar _nSolicitar , HANDLE_hStopEvent )

si(! _nSolicitar ) devolver 0 ;

int sin firmar n Proporcionar = 0 ;

MANEJAR eventos [ 2 ];

hEventos [ 0 ] = _hStopEvent ; // El evento de parada tiene mayor prioridad. Lo ponemos primero.

hEventos [ 1 ] = m_eventHasQuota ;

En t iEsperarResultado = :: WaitForMultipleObjects ( 2 , hEventos , FALSO , INFINITO );

cambiar( iEsperarResultado ) {

caso ESPERA_FALLIDO :

// ERROR

tirar nuevo Excepción CWin32 ;

caso ESPERA_OBJECT_0 :

// Detener evento. Lo manejé con una excepción personalizada, pero no hay nada que me impida implementarlo de alguna otra manera.

tirar nuevo CStopException ;

caso ESPERA_OBJECT_0 + 1 :

// Evento "cuota disponible"

AFIRMAR ( m_nCuota ); // Si la señal fue dada por este evento, pero en realidad no hay cuota, entonces en algún lugar cometimos un error. ¡Hay que buscar el bicho!

si( _nSolicitar >= m_nCuota ) {

n Proporcionar = m_nCuota ;

m_nCuota = 0 ;

m_eventNoCuota . establecer ();

más {

n Proporcionar = _nSolicitar ;

m_nCuota -= _nSolicitar ;

m_eventHasQuota . establecer ();

descanso;

devolver n Proporcionar ;

Una pequeña nota. La biblioteca MFC no se usó en ese proyecto, pero, como probablemente ya haya adivinado, hice mi propia clase CEvent, un envoltorio alrededor del objeto kernel "evento", similar al MFC "schnoy". son muy útiles cuando hay algún recurso (en este caso, un objeto kernel) que debe recordarse para liberar al final del trabajo. En el resto, no importa si escribe SetEvent(m_hEvent) o m_event.Set( ).

Espero que este ejemplo lo ayude a diseñar su propio esquema de tiempo si se encuentra con una situación no trivial. Lo principal es analizar su esquema con el mayor cuidado posible. ¿Podría haber una situación en la que no funcionaría correctamente, en particular, podría ocurrir un bloqueo? Detectar tales errores en el depurador suele ser un asunto inútil, aquí solo ayuda un análisis detallado.

Así que hemos considerado Herramienta esencial sincronización de subprocesos: objetos de sincronización del núcleo. Es una herramienta poderosa y versátil. Con él, puede construir incluso esquemas de sincronización muy complejos. Afortunadamente, tales situaciones no triviales son raras. Además, la versatilidad siempre viene a costa del rendimiento. Por lo tanto, en muchos casos vale la pena usar las otras funciones de sincronización de subprocesos disponibles en Windows, como las secciones críticas y las operaciones atómicas. No son tan universales, pero son simples y efectivos. Hablaremos de ellos en la siguiente parte.

Un proceso es una instancia de un programa cargado en la memoria. Esta instancia puede crear hilos, que son una secuencia de instrucciones para ejecutar. Es importante comprender que no son procesos los que se ejecutan, sino hilos.

Además, cualquier proceso tiene al menos un hilo. Este subproceso se denomina subproceso principal (principal) de la aplicación.

Dado que casi siempre hay muchos más subprocesos que procesadores físicos para su ejecución, los subprocesos en realidad no se ejecutan simultáneamente, sino a su vez (la distribución del tiempo del procesador se produce precisamente entre los subprocesos). Pero el cambio entre ellos sucede con tanta frecuencia que parece como si estuvieran funcionando en paralelo.

Dependiendo de la situación, los subprocesos pueden estar en tres estados. Primero, un subproceso puede ejecutarse cuando se le da tiempo de CPU, es decir puede estar activo. En segundo lugar, puede estar inactivo y esperando que se le asigne un procesador, es decir, estar en un estado de preparación. Y hay una tercera, también muy condición importante- estado de bloqueo. Cuando un subproceso está bloqueado, no se le asigna ningún tiempo. Por lo general, se coloca un candado mientras se espera algún evento. Cuando ocurre este evento, el subproceso pasa automáticamente del estado bloqueado al estado listo. Por ejemplo, si un subproceso está realizando cálculos mientras que otro tiene que esperar a que los resultados se guarden en el disco. El segundo podría usar un ciclo como "while(!isCalcFinished) continue;", pero en la práctica es fácil ver que el procesador está 100% ocupado mientras se ejecuta este ciclo (esto se denomina espera activa). Tales bucles deben evitarse siempre que sea posible, en los que el mecanismo de bloqueo proporciona una ayuda inestimable. El segundo subproceso puede bloquearse hasta que el primer subproceso establezca un evento para indicar que la lectura ha finalizado.

Sincronización de subprocesos en el sistema operativo Windows

Windows implementa la multitarea preventiva, lo que significa que en cualquier momento el sistema puede interrumpir la ejecución de un hilo y transferir el control a otro. Anteriormente, en Windows 3.1, se usaba un método de organización llamado multitarea cooperativa: el sistema esperaba hasta que el hilo mismo le transfiriese el control, y por eso si una aplicación se congelaba, había que reiniciar la computadora.

Todos los subprocesos que pertenecen al mismo proceso comparten algunos recursos comunes, como el espacio de direcciones de RAM o los archivos abiertos. Estos recursos pertenecen a todo el proceso, y por tanto a cada uno de sus hilos. Por lo tanto, cada subproceso puede trabajar con estos recursos sin restricciones. Pero... Si un subproceso aún no ha terminado de trabajar con ningún recurso compartido, y el sistema ha cambiado a otro subproceso que usa el mismo recurso, entonces el resultado del trabajo de estos subprocesos puede ser extremadamente diferente de lo que se pretendía. Tales conflictos también pueden surgir entre subprocesos que pertenecen a diferentes procesos. Siempre que dos o más subprocesos utilizan algún tipo de recurso compartido, se produce este problema.

Ejemplo. Subprocesos no sincronizados: si suspende temporalmente el subproceso de visualización (pausar), el subproceso de relleno de matriz de fondo continuará ejecutándose.

#incluir #incluir en un; MANGO hThr; uThrID largo sin firmar; void Thread(void* pParams) ( int i, num = 0; while (1) ( for (i=0; i<5; i++) a[i] = num; num++; } } int main(void) { hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) printf("%d %d %d %d %d\n", a, a, a, a, a); return 0; }

Es por eso que se necesita un mecanismo que permita a los hilos coordinar su trabajo con recursos compartidos. Este mecanismo se denomina mecanismo de sincronización de subprocesos.

Este mecanismo es un conjunto de objetos del sistema operativo que son creados y administrados por software, son comunes a todos los subprocesos del sistema (algunos son compartidos por subprocesos que pertenecen al mismo proceso) y se utilizan para coordinar el acceso a los recursos. Los recursos pueden ser cualquier cosa que puedan compartir dos o más subprocesos: un archivo en el disco, un puerto, una entrada de base de datos, un objeto GDI e incluso una variable de programa global (a la que se puede acceder desde subprocesos que pertenecen al mismo proceso).

Hay varios objetos de sincronización, los más importantes son mutex, sección crítica, evento y semáforo. Cada uno de estos objetos implementa su propio método de sincronización. Además, los propios procesos y subprocesos se pueden utilizar como objetos de sincronización (cuando un subproceso está esperando la finalización de otro subproceso o proceso); así como archivos, dispositivos de comunicación, entrada de consola y notificaciones de cambios.

Cualquier objeto de sincronización puede estar en el llamado estado señalado. Para cada tipo de objeto, este estado tiene un significado diferente. Los hilos pueden verificar el estado actual de un objeto y/o esperar a que ese estado cambie y así coordinar sus acciones. Esto asegura que cuando un hilo trabaja con objetos de sincronización (los crea, cambia de estado), el sistema no interrumpirá su ejecución hasta que complete esta acción. Por lo tanto, todas las operaciones finales sobre los objetos de sincronización son atómicas (indivisibles.

Trabajar con objetos de sincronización

Para crear uno u otro objeto de sincronización, se llama a una función WinAPI especial del tipo Create... (por ejemplo, CreateMutex). Esta llamada devuelve un identificador de objeto (HANDLE) que pueden usar todos los subprocesos que pertenecen al proceso dado. Es posible acceder al objeto de sincronización desde otro proceso, ya sea heredando el identificador del objeto o, preferiblemente, llamando a la función Abrir... del objeto. Después de esta llamada, el proceso recibirá un identificador, que luego se puede usar para trabajar con el objeto. Un objeto, a menos que esté destinado a ser utilizado dentro de un solo proceso, debe recibir un nombre. Los nombres de todos los objetos deben ser diferentes (incluso si son de diferentes tipos). No puede, por ejemplo, crear un evento y un semáforo con el mismo nombre.

Mediante el descriptor disponible de un objeto, puede determinar su estado actual. Esto se hace con la ayuda de los llamados. funciones pendientes. La función más utilizada es WaitForSingleObject. Esta función toma dos parámetros, el primero es el identificador del objeto, el segundo es el tiempo de espera en ms. La función devuelve WAIT_OBJECT_0 si el objeto está en el estado señalado, WAIT_TIMEOUT si el tiempo de espera expiró y WAIT_ABANDONED si el mutex no se liberó antes de que terminara el subproceso propietario. Si el tiempo de espera se especifica como cero, la función regresa inmediatamente; de ​​lo contrario, espera la cantidad de tiempo especificada. Si el estado del objeto se señala antes de que expire este tiempo, la función devolverá WAIT_OBJECT_0; de lo contrario, la función devolverá WAIT_TIMEOUT. Si la constante simbólica INFINITE se especifica como el tiempo, entonces la función esperará indefinidamente hasta que se señale el estado del objeto.

Es muy importante que la llamada a la función de espera bloquee el hilo actual, es decir mientras un subproceso está inactivo, no se le asigna tiempo de procesador.

Secciones críticas

Una sección de objeto crítico ayuda al programador a aislar la sección de código donde un subproceso accede a un recurso compartido y evita el uso simultáneo del recurso. Antes de usar el recurso, el subproceso ingresa a la sección crítica (llama a la función EnterCriticalSection). Si cualquier otro subproceso intenta ingresar a la misma sección crítica, su ejecución se detendrá hasta que el primer subproceso abandone la sección con una llamada a LeaveCriticalSection. Solo se usa para subprocesos en un solo proceso. El orden de ingreso a la sección crítica no está definido.

También hay una función TryEnterCriticalSection que verifica si una sección crítica está ocupada actualmente. Con su ayuda, el hilo en el proceso de espera de acceso al recurso no puede bloquearse, pero puede realizar algunas acciones útiles.

Ejemplo. Sincronización de hilos utilizando secciones críticas.

#incluir #incluir SECCIÓN_CRÍTICA cs; en un; MANGO hThr; uThrID largo sin firmar; void Thread(void* pParams) ( int i, num = 0; while (1) ( EnterCriticalSection(&cs); for (i=0; i<5; i++) a[i] = num; num++; LeaveCriticalSection(&cs); } } int main(void) { InitializeCriticalSection(&cs); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { EnterCriticalSection(&cs); printf("%d %d %d %d %d\n", a, a, a, a, a); LeaveCriticalSection(&cs); } return 0; }

Exclusión mutua

Los objetos de exclusión mutua (mutexes, mutex - de MUTual EXclusion) le permiten coordinar la exclusión mutua del acceso a un recurso compartido. El estado señalado de un objeto (es decir, el estado "establecido") corresponde al momento en que el objeto no pertenece a ningún subproceso y puede ser "capturado". Por el contrario, el estado de "restablecimiento" (no señalado) corresponde al momento en que algún subproceso ya posee este objeto. El acceso a un objeto se otorga cuando el subproceso que posee el objeto lo libera.

Dos (o más) subprocesos pueden crear una exclusión mutua con el mismo nombre llamando a la función CreateMutex. El primer subproceso en realidad crea la exclusión mutua, y los siguientes subprocesos obtienen un identificador de un objeto ya existente. Esto hace posible que varios subprocesos adquieran un identificador para el mismo mutex, lo que libera al programador de tener que preocuparse por quién crea realmente el mutex. Si se utiliza este enfoque, es deseable establecer el indicador bInitialOwner en FALSO, de lo contrario, habrá algunas dificultades para determinar el creador real de la exclusión mutua.

Múltiples subprocesos pueden adquirir un identificador para el mismo mutex, lo que hace posible la comunicación entre procesos. Puede utilizar los siguientes mecanismos para este enfoque:

  • Un proceso secundario creado con la función CreateProcess puede heredar el controlador de exclusión mutua si se especificó el parámetro lpMutexAttributes cuando la función CreateMutex creó la exclusión mutua.
  • Un subproceso puede obtener un duplicado de un mutex existente mediante la función DuplicateHandle.
  • Un subproceso puede especificar el nombre de una exclusión mutua existente al llamar a las funciones OpenMutex o CreateMutex.

Para declarar un mutex propiedad del subproceso actual, se debe llamar a una de las funciones pendientes. El subproceso que posee el objeto puede "capturarlo" repetidamente tantas veces como quiera (esto no conducirá al autobloqueo), pero tendrá que liberarlo tantas veces usando la función ReleaseMutex.

Para sincronizar los hilos de un proceso, es más eficiente usar secciones críticas.

Ejemplo. Sincronización de hilos mediante mutexes.

#incluir #incluir MANEJAR hMutex; en un; MANGO hThr; uThrID largo sin firmar; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hMutex, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; ReleaseMutex(hMutex); } } int main(void) { hMutex=CreateMutex(NULL, FALSE, NULL); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hMutex, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); ReleaseMutex(hMutex); } return 0; }

Desarrollos

Los objetos de evento se utilizan para notificar a los subprocesos en espera que se ha producido un evento. Hay dos tipos de eventos: con restablecimiento manual y automático. El restablecimiento manual se realiza mediante la función ResetEvent. Los eventos de restablecimiento manual se utilizan para notificar varios subprocesos a la vez. Al usar un evento de reinicio automático, solo un subproceso en espera recibirá la notificación y continuará su ejecución, el resto esperará más.

La función CreateEvent crea un objeto de evento, SetEvent: establece el evento en el estado de señal, ResetEvent: restablece el evento. La función PulseEvent establece el evento, y después de reanudar los hilos que esperan este evento (todos con reinicio manual y solo uno con reinicio automático), lo reinicia. Si no hay subprocesos esperando, PulseEvent simplemente reinicia el evento.

Ejemplo. Sincronización de hilos mediante eventos.

#incluir #incluir MANEJO hEvento1, hEvento2; en un; MANGO hThr; uThrID largo sin firmar; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hEvent2, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; SetEvent(hEvent1); } } int main(void) { hEvent1=CreateEvent(NULL, FALSE, TRUE, NULL); hEvent2=CreateEvent(NULL, FALSE, FALSE, NULL); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hEvent1, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); SetEvent(hEvent2); } return 0; }

semáforos

Un objeto semáforo es en realidad un objeto mutex con un contador. Este objeto se deja "capturar" por un cierto número de hilos. Después de eso, la "captura" será imposible hasta que uno de los subprocesos del semáforo "capturados" previamente lo libere. Los semáforos se utilizan para limitar el número de subprocesos que pueden acceder a un recurso al mismo tiempo. Durante la inicialización, el número máximo de subprocesos se transfiere al objeto, después de cada "captura", el contador de semáforos disminuye. El estado de la señal corresponde a un valor de contador mayor que cero. Cuando el contador es cero, el semáforo se considera desarmado (reset).

La función CreateSemaphore crea un objeto de semáforo con una indicación de su valor inicial máximo posible, OpenSemaphore: devuelve un identificador a un semáforo existente, el semáforo se captura mediante funciones de espera, mientras que el valor del semáforo se reduce en uno, ReleaseSemaphore: libera el semáforo con un aumento en el valor del semáforo por el valor especificado en el número de parámetro.

Ejemplo. Sincronización de hilos mediante semáforos.

#incluir #incluir MANGO hSem; en un; MANGO hThr; uThrID largo sin firmar; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hSem, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; ReleaseSemaphore(hSem, 1, NULL); } } int main(void) { hSem=CreateSemaphore(NULL, 1, 1, "MySemaphore1"); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hSem, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); ReleaseSemaphore(hSem, 1, NULL); } return 0; }

Acceso protegido a variables

Hay una serie de funciones que le permiten trabajar con variables globales de todos los subprocesos sin preocuparse por la sincronización, porque. estas funciones se encargan de ello por sí mismas - su ejecución es atómica. Estas son las funciones InterlockedIncrement, InterlockedDecrement, InterlockedExchange, InterlockedExchangeAdd e InterlockedCompareExchange. Por ejemplo, la función InterlockedIncrement aumenta atómicamente el valor de una variable de 32 bits en uno, lo que es útil para varios contadores.

Para obtener información completa sobre el propósito, el uso y la sintaxis de todas las funciones de la API de WIN32, debe utilizar el sistema de ayuda MS SDK, que forma parte de los entornos de programación Borland Delphi o CBuilder, así como MSDN, que se suministra como parte de el sistema de programación Visual C.


Para programas que utilizan múltiples hilos o procesos, es necesario que todos ellos realicen las funciones que se les asignan en la secuencia deseada. En el entorno de Windows 9x, para este propósito, se propone utilizar varios mecanismos que aseguren el buen funcionamiento de los hilos. Estos mecanismos se denominan mecanismos de sincronización. Suponga que está desarrollando un programa en el que dos subprocesos se ejecutan en paralelo. Cada subproceso accede a una variable global compartida. Un subproceso, cada vez que se accede a esta variable, la incrementa y el segundo subproceso la disminuye. Con el trabajo asíncrono simultáneo de subprocesos, surge inevitablemente la siguiente situación: - el primer subproceso ha leído el valor de una variable global en una local; - El sistema operativo lo interrumpe, ya que la cantidad de tiempo del procesador que se le ha asignado ha terminado, y transfiere el control al segundo hilo; - el segundo hilo también leyó el valor de la variable global en una variable local, la disminuyó y volvió a escribir el nuevo valor; - El sistema operativo vuelve a transferir el control al primer hilo, que, sin saber nada de las acciones del segundo hilo, incrementa su variable local y escribe su valor en la global. Obviamente, los cambios realizados por el segundo hilo se perderán. Para evitar tales situaciones, es necesario separar en el tiempo el uso de los datos compartidos. En tales casos, se utilizan mecanismos de sincronización que aseguran el correcto funcionamiento de múltiples hilos. Herramientas de sincronización en el sistema operativoventanas: 1) sección crítica (CríticoSección) es un objeto que pertenece al proceso, no al kernel. Esto significa que no puede sincronizar subprocesos de diferentes procesos. También hay funciones de inicialización (creación) y borrado, entrada y salida de una sección crítica: creación - InitializeCriticalSection(...), borrado - DeleteCriticalSection(...), entrada - EnterCriticalSection(...), salida - LeaveCriticalSection (...). Restricciones: dado que no es un objeto del núcleo, no es visible para otros procesos, es decir, solo puede proteger los subprocesos de su propio proceso. La sección crítica analiza el valor de una variable de proceso especial que se utiliza como indicador para evitar que varios subprocesos ejecuten un fragmento de código al mismo tiempo. Entre los objetos de sincronización, las secciones críticas son las más simples. 2) exclusión mutuamudableexcluir. Este es un objeto del kernel, tiene un nombre, lo que significa que se pueden usar para sincronizar el acceso a datos compartidos de varios procesos, más precisamente, de hilos de diferentes procesos. Ningún otro subproceso puede adquirir una exclusión mutua que ya sea propiedad de uno de los subprocesos. Si un mutex protege algunos datos compartidos, solo podrá realizar su función si cada subproceso verifica el estado de este mutex antes de acceder a estos datos. Windows trata un mutex como un objeto compartido que se puede señalar o restablecer. El estado señalado del mutex indica que está ocupado. Los subprocesos deben analizar de forma independiente el estado actual de los mutexes. Si desea que subprocesos de otros procesos accedan al mutex, debe asignarle un nombre. Funciones: CreateMutex(name) - creación, hnd=OpenMutex(name) - apertura, WaitForSingleObject(hnd) - espera y ocupación, ReleaseMutex(hnd) - liberación, CloseHandle(hnd) - cierre. Se puede utilizar para proteger contra el reinicio de programas. 3) semáforo -semáforo. El objeto del núcleo "semáforo" se utiliza para la contabilidad de recursos y sirve para limitar el acceso simultáneo a un recurso por parte de varios subprocesos. Usando un semáforo, puede organizar el trabajo del programa de tal manera que varios subprocesos puedan acceder al recurso al mismo tiempo, pero la cantidad de estos subprocesos será limitada. Al crear un semáforo, se especifica la cantidad máxima de subprocesos que pueden trabajar simultáneamente con el recurso. Cada vez que un programa accede a un semáforo, el contador de recursos del semáforo se reduce en uno. Cuando el valor del contador de recursos llega a cero, el semáforo no está disponible. crear CreateSemaphore, abrir OpenSemaphore, tomar WaitForSingleObject, liberar ReleaseSemaphore 4 ) evento -evento. Los eventos generalmente solo notifican el final de alguna operación, también son objetos del núcleo. No solo puede liberar explícitamente, sino que también hay una operación de configuración de eventos. Los eventos pueden ser manuales (manual) y únicos (single). Un solo evento es más una bandera general. Un evento está en el estado señalado si fue establecido por algún hilo. Si el programa requiere que solo uno de los subprocesos reaccione ante él en caso de un evento, mientras que todos los demás subprocesos continúan esperando, entonces se usa un solo evento. Un evento manual no es solo un indicador común en varios subprocesos. Realiza funciones algo más complejas. Cualquier subproceso puede establecer este evento o restablecerlo (borrarlo). Una vez que se configura un evento, permanecerá en este estado durante un tiempo arbitrariamente largo, independientemente de cuántos subprocesos estén esperando que se configure el evento. Cuando todos los subprocesos que esperan este evento reciban un mensaje de que se ha producido el evento, se restablecerá automáticamente. Funciones: SetEvent, ClearEvent, WaitForEvent. Tipos de eventos: 1) evento de reinicio automático: WaitForSingleEvent. 2) un evento con reinicio manual (manual), entonces el evento debe reiniciarse: ReleaseEvent. Algunos teóricos destacan otro objeto de sincronización: WaitAbleTimer es un objeto del núcleo del sistema operativo que cambia de forma independiente a un estado libre después de un intervalo de tiempo específico (reloj de alarma).

A veces, cuando se trabaja con múltiples subprocesos o procesos, se vuelve necesario sincronizar la ejecución dos o más de ellos. La razón de esto suele ser que dos o más subprocesos pueden requerir acceso a un recurso compartido que De Verdad no se puede proporcionar a varios subprocesos a la vez. Un recurso compartido es un recurso al que pueden acceder varias tareas en ejecución al mismo tiempo.

El mecanismo que asegura el proceso de sincronización se denomina Restricción de acceso. La necesidad también surge en los casos en que un subproceso está esperando un evento generado por otro subproceso. Naturalmente, debe haber alguna manera por la cual el primer subproceso se suspenderá hasta que ocurra el evento. Después de eso, el hilo debe continuar su ejecución.

Hay dos estados generales en los que puede estar una tarea. En primer lugar, la tarea puede llevarse a cabo(o esté listo para ejecutarse tan pronto como tenga acceso a los recursos del procesador). En segundo lugar, la tarea puede ser obstruido. En este caso, su ejecución se suspende hasta que se libera el recurso que necesita o se produce un evento determinado.

Windows tiene servicios especiales que le permiten restringir el acceso a recursos compartidos de cierta manera, porque sin la ayuda del sistema operativo, un proceso o subproceso separado no puede determinar por sí mismo si tiene acceso exclusivo a un recurso. El sistema operativo Windows contiene un procedimiento que, en una operación continua, verifica y, si es posible, establece el indicador de acceso a recursos. En el lenguaje de los desarrolladores de sistemas operativos, tal operación se llama comprobar e instalar la operación. Las banderas utilizadas para asegurar la sincronización y controlar el acceso a los recursos se denominan semáforos(semáforo). La API de Win32 proporciona soporte para semáforos y otros objetos de sincronización. La biblioteca MFC también incluye soporte para estos objetos.

Objetos de sincronización y clases mfc

La interfaz de Win32 admite cuatro tipos de objetos de sincronización, todos basados ​​de una forma u otra en el concepto de semáforo.

El primer tipo de objeto es el propio semáforo, o semáforo clásico (estándar). Permite que un número limitado de procesos e hilos accedan a un solo recurso. En este caso, el acceso al recurso está completamente limitado (un único subproceso o proceso puede acceder al recurso en un determinado período de tiempo), o solo una pequeña cantidad de subprocesos y procesos obtienen acceso simultáneo. Los semáforos se implementan con un contador que disminuye cuando se asigna un semáforo a una tarea y aumenta cuando una tarea libera el semáforo.

El segundo tipo de objetos de sincronización es semáforo exclusivo (mutex). Está diseñado para restringir completamente el acceso a un recurso para que solo un proceso o subproceso pueda acceder al recurso en un momento dado. De hecho, este es un tipo especial de semáforo.

El tercer tipo de objetos de sincronización es evento, o objeto de evento. Se utiliza para bloquear el acceso a un recurso hasta que algún otro proceso o subproceso declare que se puede utilizar el recurso. Por lo tanto, este objeto señala la ejecución del evento requerido.

Usando el objeto de sincronización del cuarto tipo, es posible prohibir la ejecución de ciertas secciones del código del programa por varios subprocesos simultáneamente. Para ello, estas parcelas deben ser declaradas como sección crítica. Cuando un subproceso ingresa a esta sección, se prohíbe que otros subprocesos hagan lo mismo hasta que el primer subproceso salga de esta sección.

Las secciones críticas, a diferencia de otros tipos de objetos de sincronización, se usan solo para sincronizar subprocesos dentro de un solo proceso. Se pueden usar otros tipos de objetos para sincronizar subprocesos dentro de un proceso o para sincronizar procesos.

En MFC, el mecanismo de sincronización proporcionado por la interfaz Win32 se admite a través de las siguientes clases derivadas de la clase CSyncObject:

    Sección crítica- implementa una sección crítica.

    CEvento- implementa el objeto de evento

    CMutex- implementa un semáforo exclusivo.

    Csemáforo- implementa un semáforo clásico.

Además de estas clases, MFC también define dos clases de sincronización auxiliares: CSingleLock y CMultiLock. Controlan el acceso al objeto de sincronización y contienen los métodos utilizados para otorgar y liberar dichos objetos. Clase CSingleLock controla el acceso a un único objeto de sincronización y la clase CMultiLock- a varios objetos. En lo que sigue, consideraremos sólo la clase CSingleLock.

Cuando se crea cualquier objeto de sincronización, el acceso a él se puede controlar mediante la clase CSingleLock. Para hacer esto, primero debe crear un objeto de tipo CSingleLock usando el constructor:

CSingleLock(CSyncObject* pObject, BOOL bInitialLock = FALSO);

El primer parámetro es un puntero a un objeto de sincronización, como un semáforo. El valor del segundo parámetro determina si el constructor debe intentar acceder al objeto dado. Si este parámetro es distinto de cero, se otorgará el acceso; de lo contrario, no se intentará acceder. Si se otorga acceso, entonces el subproceso que creó el objeto de clase CSingleLock, se detendrá hasta que el método libere el objeto de sincronización correspondiente desbloquear clase CSingleLock.

Una vez que se ha creado un objeto de tipo CSingleLock, el acceso al objeto señalado por el parámetro pObject se puede controlar mediante dos funciones: cerrar y desbloquear clase CSingleLock.

Método cerrar está diseñado para acceder al objeto al objeto de sincronización. El subproceso que lo llamó se suspende hasta que se completa el método, es decir, hasta que se accede al recurso. El valor del parámetro determina cuánto tiempo esperará la función para obtener acceso al objeto requerido. Cada vez que el método se completa con éxito, el valor del contador asociado con el objeto de sincronización se reduce en uno.

Método desbloquear libera el objeto de sincronización, lo que permite que otros subprocesos utilicen el recurso. En la primera variante del método, el valor del contador asociado con el objeto dado se incrementa en uno. En la segunda opción, el primer parámetro determina cuánto se debe aumentar este valor. El segundo parámetro apunta a una variable en la que se escribirá el valor anterior del contador.

Cuando se trabaja con una clase CSingleLock El procedimiento general para controlar el acceso a un recurso es el siguiente:

    cree un objeto de tipo CSyncObj (por ejemplo, un semáforo) que se utilizará para controlar el acceso al recurso;

    utilizando el objeto de sincronización creado, cree un objeto de tipo CSingleLock;

    llame al método Lock para obtener acceso al recurso;

    hacer una llamada al recurso;

    llame al método Unlock para liberar el recurso.

A continuación se describe cómo crear y utilizar semáforos y objetos de evento. Una vez que comprenda estos conceptos, podrá aprender y utilizar fácilmente los otros dos tipos de objetos de sincronización: secciones críticas y mutexes.