You are on page 1of 25

4.2.

4 Ejecución de los hilos


El subproceso que ejecuta la función "principal" a veces se denomina subproceso principal. Por lo
tanto, después de iniciar los hilos, imprime el mensaje

Mientras tanto, los hilos iniciados por las llamadas a pthread create también se están ejecutando.
Obtienen sus rangos al seleccionar la Línea 33 y luego imprimen sus mensajes. Tenga en cuenta que
cuando se finaliza un hilo, dado que el tipo de su función tiene un valor de retorno, el hilo debe
devolver algo. En este ejemplo, los hilos en realidad no necesitan devolver nada, por lo que
devuelven NULL.

En Pthreads, el programador no controla directamente dónde se ejecutan los subprocesos. 2 No hay


argumento en pthread create que diga qué núcleo debe ejecutar qué hilo. La ubicación del hilo está
controlada por el sistema operativo. De hecho, en un sistema muy cargado, todos los subprocesos se
pueden ejecutar en el mismo núcleo. De hecho, si un programa inicia más hilos que núcleos,
deberíamos esperar que se ejecuten varios hilos en un solo núcleo. Sin embargo, si hay un núcleo
que no se está utilizando, los sistemas operativos típicamente colocarán un nuevo hilo en dicho
núcleo.

4.2.5 Detener los hilos


En las líneas 25 y 26, llamamos a la función pthread_join una vez para cada hilo. Una sola llamada
a pthread_join esperará a que se complete el subproceso asociado con el objeto pthread_t. La
sintaxis de pthread join es

El segundo argumento se puede usar para recibir cualquier valor de retorno calculado por el hilo.
Entonces, en nuestro ejemplo, cada subproceso ejecuta un "retorno" y, finalmente, el subproceso
principal llamará a pthread_join para ese subproceso para completar la terminación.

4.2.6 Comprobación de errores


Con el interés de mantener el programa compacto y fácil de leer, hemos resistido la tentación de
incluir muchos detalles que, por lo tanto, serían importantes en un programa "real". La fuente más
probable de problemas en este ejemplo (y en muchos programas) es la entrada del usuario o la falta
de ella. Por lo tanto, sería una muy buena idea comprobar que el programa se inició con argumentos
de línea de comando y, si lo fue, verificar el valor real del número de subprocesos para ver si es
razonable. Si visita el sitio web del libro, puede descargar una versión del programa que incluye
esta comprobación básica de errores.
También puede ser una buena idea verificar los códigos de error devueltos por las funciones
Pthreads. Esto puede ser especialmente útil cuando recién está comenzando a usar Pthreads y
algunos de los detalles del uso de la función no están completamente claros.

4.2.7 Otros enfoques para iniciar thread

En nuestro ejemplo, el usuario específico el número de subprocesos que debe comenzar a escribir
en un argumento de línea de comandos. El hilo principal crea todos los hilos "subsidiarios".
Mientras se están ejecutando los subprocesos, el subproceso principal imprime un mensaje y luego
espera a que los demás subprocesos finalicen. Este enfoque de la programación por hilos es muy
similar a nuestro enfoque de programación MPI, en el cual el sistema MPI inicia una colección de
procesos y espera a que se complete.

Sin embargo, hay un enfoque muy diferente al diseño de programas multiproceso. En este enfoque,
los hilos subsidiarios solo se inician cuando surge la necesidad. Como ejemplo, imagine un servidor
web que maneje las solicitudes de información sobre el tráfico de alta velocidad en el Área de la
Bahía de San Francisco. Supongamos que el hilo principal recibe las solicitudes y los hilos
secundarios realmente cumplen las solicitudes. A la 1 en punto de una típica mañana de martes,
probablemente habrá muy pocas solicitudes, mientras que a las 5 en punto de una típica tarde de
martes, probablemente habrá miles de solicitudes. Por lo tanto, un enfoque natural para el diseño de
este servidor web es tener los subprocesos subsidiarios de inicio de subproceso principal cuando
recibe solicitudes.

Ahora, debemos tener en cuenta que el inicio del hilo necesariamente implica un cierto gasto. El
tiempo requerido para iniciar un hilo será mucho mayor que, por ejemplo, una operación aritmética
de coma flotante, por lo que en las aplicaciones que necesitan un rendimiento máximo, el enfoque
de "iniciar subprocesos según sea necesario" puede no ser ideal. En tal caso, puede tener sentido
utilizar un esquema algo más complicado, un esquema que tiene características de ambos enfoques.
Nuestro hilo principal puede iniciar todos los hilos que anticipa necesitar al comienzo del programa
(como en nuestro programa de ejemplo). Sin embargo, cuando un hilo no funciona, en lugar de
terminar, puede permanecer inactivo hasta que haya más trabajo disponible. En la Asignación de
programación 4.5 veremos cómo podemos implementar dicho esquema.

4.3 MULTIPLICACIÓN MATRIZ-VECTOR


Echemos un vistazo a la escritura de un programa de multiplicación de matriz-vector con Pthreads.
Recuerde que si A = (a ij ) es una matriz m × n y es un vector de columna
n-dimensional entonces el producto matriz-vector Ax = y es un vector de columna m-dimensional,
en el que la i-ésima componente yi se obtiene al encontrar el producto escalar
de la i-ésima fila de A con x:

Matrix-vector multiplication
Ver la Figura 4.3. El pseudocódigo para un programa en serie para la multiplicación de matriz-
vector podría verse así:

Queremos paralelizar esto dividiendo el trabajo entre los hilos. Una posibilidad es dividir las
iteraciones del bucle externo entre los hilos. Si hacemos esto, cada hilo calculará algunos de los
componentes de y. Por ejemplo, supongamos que m = n = 6 y el número de subprocesos,
thread_count o t, es tres. Entonces el cálculo se puede dividir entre los hilos de la siguiente manera:

Para calcular y [0], el hilo 0 necesitará ejecutar el código

Por lo tanto, el hilo 0 necesitará acceder a cada elemento de la fila 0 de A y a cada elemento de x.
De manera más general, el hilo que ha sido asignado y [i] necesitará ejecutar el código

Por lo tanto, este hilo necesitará acceder a cada elemento de la fila i de "A" y a cada elemento de
"x". Vemos que cada hilo necesita acceder a cada componente de "x", mientras que cada hilo solo
necesita acceder a sus filas asignadas de A y componentes asignados de "y". Esto sugiere que, como
mínimo, se debe compartir "x". Hagamos también que "A" e y compartan. Esto podría violar
nuestro principio de que solo deberíamos hacer variables globales las que necesiten ser global. Sin
embargo, en los ejercicios, veremos con más detalle algunos de los problemas involucrados al hacer
que las variables "A" e "y" sean locales para la función de subprocesos, y veremos que hacerlas
globales puede tenga sentido. En este punto, simplemente observaremos que si son globales, el hilo
principal puede inicializar fácilmente toda "A" simplemente leyendo sus entradas de "stdin", y el
vector de producto "y" puede ser fácilmente impreso por el hilo principal.

Habiendo tomado estas decisiones, solo necesitamos escribir el código que usará cada hilo para
decidir qué componentes de "y" calculará. Para simplificar el código, supongamos que tanto "m"
como "n" son divisibles por "t". Nuestro ejemplo con "m" = 6 y "t" = 3 sugiere que cada hilo
obtiene componentes m / t. Además, el subproceso 0 obtiene el primer m / t, el subproceso 1 obtiene
el siguiente m / t, y así sucesivamente. Por lo tanto, las fórmulas para los componentes asignados al
subproceso "q" podrían ser

Con estas fórmulas, podemos escribir la función hilo que lleva a cabo la multiplicación matriz-
vector. Ver el Programa 4.2. Tenga en cuenta que en este código, asumimos que "A", "x", "y", "m" y
"n" son globales y compartidos.

Si ya ha leído el capítulo de MPI, puede recordar que le tomó más trabajo escribir un programa de
multiplicación de matriz-vector utilizando MPI. Esto se debió al hecho de que las estructuras de
datos se distribuyeron necesariamente, es decir, cada proceso MPI solo tiene acceso directo a su
propia memoria local. Por lo tanto, para el código MPI, necesitamos recopilar explícitamente todas
las x en la memoria de cada proceso. Vemos en este ejemplo que hay instancias en las que escribir
programas de memoria compartida es más fácil que escribir programas de memoria distribuida. Sin
embargo, en breve veremos que hay situaciones en las que los programas de memoria compartida
pueden ser más complejos.

4.4 SECCIONES CRÍTICAS


La multiplicación de matrices-vectores era muy fácil de codificar porque se accedía a las
ubicaciones de memoria compartida de una manera altamente deseable. Después de la
inicialización, todas las variables, excepto "y", solo son leídas por los hilos. Es decir, a excepción de
"y", ninguna de las variables compartidas cambia después de que el subproceso principal las ha
inicializado. Además, aunque los hilos cambian a "y", solo un hilo realiza cambios en cualquier
componente individual, por lo que no hay intentos por dos (o más) hilos para modificar un solo
componente. ¿Qué pasa si este no es el caso? Es decir, ¿qué ocurre cuando varios subprocesos
actualizan una única ubicación de memoria? También discutimos esto en los Capítulos 2 y 5, por lo
que si ha leído uno de estos capítulos, ya conoce la respuesta. Pero veamos un ejemplo.
Probemos a estimar el valor de π. Hay muchas fórmulas diferentes que podríamos usar. Uno de los
más simples es
Esta no es la mejor fórmula para calcular π, porque toma muchos términos en el lado derecho antes
de que sea muy precisa. Sin embargo, para nuestros propósitos, muchos términos serán mejores.
El siguiente código de serie usa esta fórmula:

Podemos tratar de paralelizar esto de la misma manera que paralelizamos el programa de


multiplicación matriz-vector: divida las iteraciones en el bucle for entre los hilos y haga que la suma
sea una variable compartida. Para simplificar los cálculos, supongamos que el número de
subprocesos, el recuento de subprocesos o "t", divide equitativamente la cantidad de términos en la
suma, n.
Entonces, si nn = n / t, el hilo 0 puede agregar los primeros términos nn . Por lo tanto, para el
subproceso 0, la variable de bucle "i" estará en el rango de 0 a nn - 1. El subproceso 1 agregará los
siguientes términos nn , por lo que para el subproceso 1, la variable de bucle va de nn a 2 nn - 1. En
general, para el subproceso "q", la variable de ciclo se extenderá

Programa 4.3: un intento de una función con thread para calcular π

Además, el signo del primer término, término qnn , será positivo si q nn es par y negativo si q nn es
impar. La función de subprocesos puede usar el código que se muestra en el Programa 4.3.

Si ejecutamos el programa Pthreads con dos hilos y "n" es relativamente pequeño, encontramos que
los resultados del programa Pthreads están de acuerdo con el programa de suma de serie. Sin
embargo, a medida que n se hace más grande, comenzamos a obtener algunos resultados peculiares.
Por ejemplo, con un procesador de doble núcleo obtenemos los siguientes resultados:
Tenga en cuenta que a medida que aumenta n, la estimación con un hilo se hace cada vez mejor. De
hecho, con cada aumento de factor 10 en n obtenemos otro dígito correcto. Con n = 10 ^ 5, el
resultado calculado mediante un solo hilo tiene cinco dígitos correctos. Con n = 10 ^ 6, tiene seis
dígitos correctos, y así sucesivamente. El resultado calculado por dos hilos coincide con el resultado
calculado por un hilo cuando n = 10 ^ 5. Sin embargo, para valores más grandes de n, el resultado
calculado por dos subprocesos en realidad empeora. De hecho, si ejecutamos el programa varias
veces con dos subprocesos y el mismo valor de n, veríamos que el resultado calculado por dos
subprocesos cambia de ejecución a ejecución. La respuesta a nuestra pregunta original debe ser
claramente: "Sí, importa si varios hilos intentan actualizar una única variable compartida".

Recordemos por qué este es el caso. Recuerde que la adición de dos valores generalmente no es una
sola instrucción de máquina. Por ejemplo, aunque podemos agregar el contenido de una ubicación
de memoria "y" a una ubicación de memoria "x" con una sola declaración en C,

lo que hace la máquina suele ser más complicado. Los valores actuales almacenados en "x" e "y" se
almacenarán, en general, en la memoria principal de la computadora, que no tiene circuitos para
realizar operaciones aritméticas. Antes de que se pueda llevar a cabo la adición, los valores
almacenados en "x" e "y" pueden por lo tanto transferirse desde la memoria principal a registros en
la CPU. Una vez que los valores están en registros, la adición se puede llevar a cabo. Después de
completar la adición, es posible que el resultado deba transferirse de un registro a la memoria.

Supongamos que tenemos dos hilos, y cada uno calcula un valor que está almacenado en su variable
privada "y". Supongamos también que queremos agregar estos valores privados en una variable
compartida "x" que el hilo principal ha inicializado a 0. Cada hilo ejecutará el siguiente código:

Supongamos también que el hilo 0 calcula y = 1 y el hilo 1 calcula y = 2.El resultado "correcto"
debería ser x = 3. Aquí hay un posible escenario:
Vemos que si el hilo 1 copia "x" de la memoria en un registro antes de que el hilo 0 almacene su
resultado, el cálculo llevado a cabo por el hilo 0 será sobrescrito por el hilo 1. El problema podría
revertirse: si el hilo 1 se adelanta al hilo 0 , entonces su resultado puede ser sobreescrito por el hilo
0. De hecho, a menos que uno de los hilos almacene su resultado antes de que el otro hilo empiece a
leer "x" de la memoria, el resultado del "ganador" será sobrescrito por el "perdedor".
Este ejemplo ilustra un problema fundamental en la programación de memoria compartida: cuando
varios subprocesos intentan actualizar un recurso compartido, en nuestro caso una variable
compartida, el resultado puede ser impredecible. Recuerde que, en términos más generales, cuando
múltiples hilos intentan acceder a un recurso compartido, como una variable compartida o un
archivo compartido, al menos uno de los accesos es una actualización y los accesos pueden dar
como resultado un error, tenemos una condición de carrera. En nuestro ejemplo, para que nuestro
código produzca el resultado correcto, debemos asegurarnos de que una vez que uno de los
subprocesos comience a ejecutar el enunciado x = x + y, termine de ejecutar el enunciado antes de
que el otro hilo comience a ejecutar el enunciado. Por lo tanto, el código x = x + y es una sección
crítica, es decir, es un bloque de código que actualiza un recurso compartido que solo se puede
actualizar por un hilo a la vez.

4.5 BUSY-WAITING
Cuando, por ejemplo, el hilo 0 quiere ejecutar el enunciado x = x + y, primero debe asegurarse de
que el hilo 1 ya no esté ejecutando el enunciado. Una vez que el hilo 0 se asegura de esto, necesita
proporcionar algo para que el hilo 1 determine que él, el hilo 0, está ejecutando el enunciado, de
modo que el hilo 1 no intente comenzar a ejecutar el enunciado hasta que el hilo 0 haya terminado .
Finalmente, después de que el hilo 0 haya completado la ejecución del enunciado, debe
proporcionar alguna forma para que el hilo 1 determine que está hecho, de modo que el hilo 1 pueda
comenzar a ejecutar el enunciado de forma segura.
Un enfoque simple que no involucra ningún concepto nuevo es el uso de una variable de bandera.
Supongamos que flag es un int compartido que el hilo principal establece en 0. Además,
supongamos que agregamos el siguiente código a nuestro ejemplo:

Supongamos que el subproceso 1 finaliza la asignación en la línea 1 antes del subproceso 0. ¿Qué
sucede cuando alcanza la instrucción while en la línea 2? Si miras la declaración "while" por un
minuto, verás que tiene la propiedad un tanto peculiar de que su cuerpo está vacío. Entonces, prueba
si "flag! = my_rank" es verdadera, el hilo 1 ejecutará la prueba por segunda vez. De hecho, seguirá
volviendo a ejecutar la prueba hasta que la prueba sea falsa. Cuando la prueba es falsa, el hilo 1
continuará ejecutando el código en la sección crítica

Como asumimos que el hilo principal ha inicializado "flag" a 0, el hilo 1 no pasará a la sección
crítica en la línea 3 hasta que thread 0 ejecute la instrucción "flag ++". De hecho, vemos que a
menos que ocurra alguna catástrofe en el hilo 0, eventualmente alcanzará el hilo 1. Sin embargo,
cuando el hilo 0 ejecuta su primera prueba de "flag! = My_rank", la condición es falsa, y continuará
hasta ejecuta el código en la sección crítica x = x + y. Cuando termine con esto, veremos que
ejecutará flag ++, y el subproceso 1 finalmente puede ingresar a la sección crítica.

La clave aquí es que el subproceso 1 no puede entrar en la sección crítica hasta que el subproceso 0
haya completado la ejecución de "flag ++". Y, siempre que las instrucciones se ejecuten
exactamente como están escritas, esto significa que thread1 no puede entrar en la sección crítica
hasta que thread 0 lo haya completado.

El ciclo while es un ejemplo de espera ocupada. En espera ocupada, un hilo prueba repetidamente
una condición, pero, de hecho, no funciona hasta que la condición tenga el valor apropiado (falso en
nuestro ejemplo).

Tenga en cuenta que dijimos que la solución de espera ocupada funcionaría "siempre que las
instrucciones se ejecuten exactamente como están escritas". Si la optimización del compilador está
activada, es posible que el compilador realice cambios que afectarán la corrección de "ocupado".
-esperando". La razón de esto es que el compilador no sabe que el programa es multiproceso, por lo
que no "sabe" que las variables "x" y "bandera" pueden ser modificadas por otro hilo. Por ejemplo,
si nuestro código

se ejecuta con un solo hilo, el orden de las declaraciones while (flag! = my rank) y x = x + y no es
importante. Por lo tanto, una optimización en el compilador podría determinar que el programa
haría un mejor uso de los registros si se cambia el orden de las declaraciones. Por supuesto, esto
dará como resultado el código

que anula el propósito del ciclo ocupado-espera. La solución más simple a este problema es
desactivar las optimizaciones del compilador cuando usamos "busy-waiting". Para una alternativa a
desactivar completamente las optimizaciones, vea el ejercicio 4.3.
Podemos ver inmediatamente que "busy_waiting" no es una solución ideal al problema de controlar
el acceso a una sección crítica. Dado que el hilo 1 ejecutará la prueba una y otra vez hasta que
thread 0 ejecute "flag ++", si el hilo 0 se retrasa (por ejemplo, si el sistema operativo lo reemplaza
para ejecutar algo diferente), el hilo 1 simplemente "girará" en el test, comiendo ciclos de CPU.
Esto puede ser desastroso para el rendimiento. Desactivar las optimizaciones del compilador
también puede degradar seriamente el rendimiento.

Antes de continuar, volvamos a nuestro programa de cálculo π en la figura 4.3 y corrijámoslo


usando busy-waiting. La sección crítica en esta función es la Línea 15. Por lo tanto, podemos
preceder esto con un ciclo de "espera ocupada". Sin embargo, cuando se realiza un subproceso con
la sección crítica, si simplemente incrementa el indicador, eventualmente el indicador será mayor
que t, el número de subprocesos y ninguno de los subprocesos podrá regresar a la sección crítica. Es
decir, después de ejecutar la sección crítica una vez, todos los hilos quedarán bloqueados para
siempre en el ciclo "espera ocupada". Por lo tanto, en este caso, no queremos simplemente
incrementar el indicador. Por el contrario, el último hilo, hilo (t - 1), debe reiniciar la bandera a
cero. Esto se puede lograr reemplazando flag ++ con
Con este cambio, obtenemos la función de hilo que se muestra en el Programa 4.4. Si compilamos
el programa y lo ejecutamos con dos hilos, vemos que está calculando los resultados correctos. Sin
embargo, si agregamos un código para computar el tiempo transcurrido, vemos que cuando n = 10 ^
8, la suma en serie es consistentemente más rápida que la suma paralela. Por ejemplo, en el sistema
de doble núcleo, el tiempo transcurrido para la suma calculada por dos hilos es de aproximadamente
19.5 segundos, mientras que el tiempo transcurrido para la suma de serie es de aproximadamente
2.8 segundos
¿Por qué es esto? Por supuesto, hay una sobrecarga asociada con "iniciar" y "unir" los hilos. Sin
embargo, podemos estimar esta sobrecarga escribiendo un programa Pthreads en el que la función
de subproceso simplemente retorna:

Cuando encontramos el tiempo que transcurre entre el inicio del primer hilo y el segundo hilo,
vemos que en este sistema en particular, la "sobrecarga (overhead) " es inferior a 0.3 milisegundos,
por lo que la ralentización no se debe a la sobrecarga del hilo. Si miramos de cerca
Cuando encontramos el tiempo que transcurre entre el inicio del primer hilo y el segundo hilo,
vemos que en este sistema en particular, la "sobrecarga (overhead) " es inferior a 0.3 milisegundos,
por lo que la ralentización no se debe a la sobrecarga del hilo. Si observamos de cerca la función de
subproceso que usa "espera ocupada", vemos que los subprocesos alternan entre ejecutar el código
de sección crítica en la Línea 16. Inicialmente "_flag" es 0, por lo que el subproceso 1 debe esperar
hasta que el subproceso 0 ejecute el crítico sección e indicador de incrementos. Entonces, el hilo 0
debe esperar hasta que el hilo 1 se ejecute y se incremente. Los hilos se alternarán entre esperar y
ejecutar, y evidentemente la espera y el incremento incrementan el tiempo total de ejecución por un
factor de siete.
Como veremos, "busy_waiting" no es la única solución para proteger una sección crítica. De hecho,
hay soluciones mucho mejores. Sin embargo, dado que el código en una sección crítica solo puede
ser ejecutado por un hilo a la vez, no importa cómo limitemos el acceso a la sección crítica,
efectivamente serializaremos el código en la sección crítica. Por lo tanto, si es posible, debemos
minimizar el número de veces que ejecutamos el código de sección crítica. Una forma de mejorar
mucho el rendimiento de la función suma es hacer que cada subproceso use una variable privada
para almacenar su contribución total a la suma. Luego, cada subproceso puede agregar su
contribución a la suma global una vez, después del ciclo for. Ver el Programa 4.5. Cuando
ejecutamos esto en el sistema dual-core con n = 10 ^ 8, el tiempo transcurrido se reduce a 1.5
segundos para dos hilos, una mejora sustancial

4.6 MUTEXES

Dado que un hilo que está ocupado, esperando, puede usar continuamente la CPU, la espera
ocupada generalmente no es una solución ideal al problema de limitar el acceso a una sección
crítica. Dos mejores soluciones son mutexes y semáforos. Mutex es una abreviatura de exclusión
mutua, y un mutex es un tipo especial de variable que, junto con un par de funciones especiales, se
puede utilizar para restringir el acceso a una sección crítica a un único hilo a la vez. Por lo tanto, se
puede usar un mutex para garantizar que un hilo "excluya" el resto de los hilos mientras ejecuta la
sección crítica. Por lo tanto, el mutex garantiza el acceso mutuamente exclusivo a la sección crítica.

El estándar Pthreads incluye un tipo especial para mutexes: pthread_mutex_t. El sistema debe
inicializar una variable de tipo pthread_mutex_t antes de su uso. Esto se puede hacer con una
llamada a

No haremos uso del segundo argumento, por lo que solo pasaremos en NULL. Cuando un programa
Pthreads termina de usar un mutex, debe llamar
Para obtener acceso a una sección crítica, un hilo llama

Cuando un hilo termina de ejecutar el código en una sección crítica, debe llamar

La llamada a pthread_mutex_lock provocará que el hilo espere hasta que no haya otro hilo en la
sección crítica, y la llamada a pthread_mutex_unlock notifica al sistema que llamada del hilo ha
completado la ejecución del código en la sección crítica.
Podemos usar mutexes en lugar de busy-waiting en nuestro programa global sum declarando una
variable global mutex, haciendo que el hilo principal lo inicialice, y luego, en lugar de busy-waiting
e incrementando un flag, los hilos llaman a pthread_mutex_lock antes de ingresar a la sección
crítica , y llaman a pthread_mutex_unlock cuando hayan terminado con la sección crítica. Ver
programa 4.6. El primer hilo para llamar a pthread_mutex_lock, de hecho, "bloqueará la puerta" a la
sección crítica. Cualquier otro hilo que intente ejecutar el código de sección crítica primero también
debe llamar a pthread_mutex_lock, y hasta que el primer subproceso llame a
pthread_mutex_unlock, todos los subprocesos que han llamado pthread_mutex_lock bloquearán sus
llamadas; solo esperarán hasta que se complete el primer subproceso.Después de que el primer hilo
llame a pthread_mutex_unlock, el sistema elegirá uno de los hilos bloqueados y le permitirá
ejecutar el código en la sección crítica. Este proceso se repetirá hasta que todos los hilos hayan
terminado de ejecutar la sección crítica.
"Bloquear" y "desbloquear" la puerta de la sección crítica no es la única metáfora que se usa en
conexión con mutexes. Los programadores suelen decir que el hilo que ha regresado de una llamada
a pthread_mutex_lock ha "obtenido el mutex" u "obtenido el bloqueo". Cuando se utiliza esta
terminología, un hilo que llama a pthread_mutex_unlock "renuncia" al mutex o bloqueo.

Observe que con mutexes (a diferencia de nuestra solución de espera ocupada), el orden en que los
hilos ejecutan el código en la sección crítica es más o menos aleatorio: el primer hilo para llamar a
pthread_mutex_lock será el primero en ejecutar el código en la sección crítica . Los accesos
posteriores serán programados por el sistema. Pthreads no garantiza (por ejemplo) que los hilos
obtengan el bloqueo en el orden en que lo llamaron Pthread_mutex_lock. Sin embargo, en nuestro
entorno, solo un número finito de hilos intentará adquirir el bloqueo. Finalmente, cada hilo obtendrá
el bloqueo.
Si observamos el rendimiento (no optimizado) del programa busy-wait π (con la sección crítica
después del bucle) y el programa mutex, vemos que para ambas versiones la relación del tiempo de
ejecución del programa de un solo subproceso con el programa multiproceso es igual al número de
subprocesos, siempre que el número de subprocesos no sea mayor que el número de núcleos.
(Consulte la Tabla 4.1). Es decir,

Si observamos el rendimiento (no optimizado) del programa busy-wait π (con la sección crítica
después del bucle) y el programa mutex, vemos que para ambas versiones la relación del tiempo de
ejecución del programa de un solo subproceso con el programa multiproceso es igual al número de
subprocesos, siempre que el número de subprocesos no sea mayor que el número de núcleos.
(Consulte la Tabla 4.1). Es decir, siempre que el recuento de subprocesos sea menor o igual que la
cantidad de núcleos. Recuerde que T serial / T paralelo se llama aceleración, y cuando la
aceleración es igual al número de subprocesos, hemos logrado un rendimiento más o menos "ideal"
o aceleración lineal.
Si comparamos el rendimiento de la versión que usa la espera de ocupado con la versión que usa
mutex, no vemos mucha diferencia en el tiempo de ejecución general cuando los programas se
ejecutan con menos hilos que núcleos. Esto no debería ser sorprendente,

Si comparamos el rendimiento de la versión que usa la espera de ocupado con la versión que usa
mutex, no vemos mucha diferencia en el tiempo de ejecución general cuando los programas se
ejecutan con menos hilos que núcleos. Esto no debería sorprender, ya que cada hilo solo ingresa a la
sección crítica una vez; entonces, a menos que la sección crítica sea muy larga, o las funciones
Pthreads sean muy lentas, no esperaríamos que los hilos se demoraran demasiado esperando
ingresar a la sección crítica. Sin embargo, si comenzamos a aumentar el número de subprocesos
más allá del número de núcleos, el rendimiento de la versión que usa mutex permanece
prácticamente sin cambios, mientras que el rendimiento de la versión de "espera ocupada" se
degrada.

Vemos que cuando utilizamos "busy-waiting", el rendimiento puede degradarse si hay más hilos que
núcleos. Esto debería tener sentido. Por ejemplo, supongamos que tenemos dos núcleos y cinco
hilos. Supongamos también que el subproceso 0 está en la sección crítica, el subproceso 1 está en el
bucle "espera ocupada" y los subprocesos 2, 3 y 4 han sido desprogramados por el sistema
operativo. Después de que el hilo 0 completa la sección crítica y establece "flag = 1", terminará y el
hilo 1 puede ingresar a la sección crítica para que el sistema operativo pueda programar el hilo 2, el
hilo 3 o el hilo 4. Suponga que programa el hilo 3, que girará en el ciclo while. Cuando el
subproceso 1 finaliza la sección crítica y establece el indicador = 2, el sistema operativo puede
programar el subproceso 2 o el subproceso 4. Si programa el subproceso 4, tanto el subproceso 3
como el subproceso 4, estarán muy activos girando en el bucle ocupado-espera hasta que el el
sistema operativo desprograma uno de ellos y programa el hilo 2. Consulte la Tabla 4.2.

4.9 READ-WRITE LOCKS

Echemos un vistazo al problema de controlar el acceso a una gran estructura de datos compartidos,
que puede ser simplemente buscada o actualizada por los hilos. En aras de la explicitud,
supongamos que la estructura de datos compartidos es una lista enlazada ordenada de int, y las
operaciones de interés son "_Member", "_Insert" y "_Delete".

4.9.1 Funciones de lista enlazada


La lista en sí está compuesta por una colección de nodos de lista, cada uno de los cuales es una
estructura con dos miembros: un int y un puntero al siguiente nodo. Podemos definir dicha
estructura con la definición

Una lista típica se muestra en la Figura 4.4. Un puntero, head_p, con tipo struct_list_node_s * se
refiere al primer nodo en la lista. El siguiente miembro del último nodo es NULL (que se indica
mediante una barra inclinada (/) en el _next_ miembro).
La función _Member (Programa 4.9) usa un puntero para recorrer la lista hasta que encuentre el
valor deseado o determine que el valor deseado no puede estar en la lista. Como la lista está
ordenada, la última condición ocurre cuando el puntero curr_p es NULL o cuando el miembro de
datos del nodo actual es más grande que el valor deseado.

La función Insertar (Programa 4.10) comienza buscando la posición correcta en la que insertar el
nuevo nodo. Como la lista está ordenada, debe buscar hasta que encuentre un nodo cuyo miembro
de datos sea mayor que el valor que se insertará. Cuando encuentra este nodo, necesita insertar el
nuevo nodo en la posición que precede al nodo que se ha encontrado. Dado que la lista está
vinculada de forma individual, no podemos "hacer una copia de seguridad" de esta posición sin
atravesar la lista por segunda vez. Hay varios enfoques para hacer frente a esto: el enfoque que
usamos es definir un segundo puntero pred_p, que, en general, se refiere al predecesor del nodo
actual. Cuando salimos del bucle que busca la posición para insertar, el siguiente miembro del nodo
mencionado por pred_p se puede actualizar para que se refiera al nuevo nodo. Ver figura 4.5.

La función Eliminar (Programa 4.11) es similar a la función Insertar, ya que también necesita
realizar un seguimiento del predecesor del nodo actual mientras busca el nodo que se eliminará. El
siguiente miembro del nodo predecesor puede actualizarse después de que se complete la búsqueda.
Ver figura 4.6.
4.9.2 Una lista enlazada multiproceso
Ahora intentemos usar estas funciones en un programa Pthreads. Para compartir el acceso a la lista,
podemos definir head_p para que sea una variable global. Esto simplificará los encabezados de
función para Miembro, Insertar y Eliminar, ya que no necesitaremos pasar ni head_p ni un puntero a
head_p, solo tendremos que pasar el valor de interés.

¿Cuáles son ahora las consecuencias de tener múltiples hilos ejecutando simultáneamente las tres
funciones?
Como múltiples hilos pueden leer simultáneamente una ubicación de memoria sin conflicto, debe
quedar claro que múltiples hilos pueden ejecutar simultáneamente un miembro. Por otro lado,
Eliminar e Insertar también escribe en ubicaciones de memoria, por lo que puede haber problemas
si intentamos ejecutar cualquiera de estas operaciones al mismo tiempo que otra operación. Como
ejemplo, suponga que el hilo 0 está ejecutando Member (5) al mismo tiempo que el hilo 1 está
ejecutando Delete (5). El estado actual de la lista se muestra en la Figura 4.7. Un problema obvio es
que si el hilo 0 está ejecutando Member (5), informará que 5 está en la lista, cuando, de hecho,
puede eliminarse incluso antes del hilo 0
Como múltiples hilos pueden leer simultáneamente una ubicación de memoria sin conflicto, debe
quedar claro que múltiples hilos pueden ejecutar simultáneamente un miembro. Por otro lado,
Eliminar e Insertar también escribe en ubicaciones de memoria, por lo que puede haber problemas
si intentamos ejecutar cualquiera de estas operaciones al mismo tiempo que otra operación. Como
ejemplo, suponga que el hilo 0 está ejecutando Member (5) al mismo tiempo que el hilo 1 está
ejecutando Delete (5). El estado actual de la lista se muestra en la Figura 4.7. Un problema obvio es
que si el hilo 0 está ejecutando el miembro (5), informará que 5 está en la lista, cuando, de hecho,
puede borrarse incluso antes de que vuelva el hilo 0. Un segundo problema obvio es si el
subproceso 0 está ejecutando Member (8), el subproceso 1 puede liberar la memoria utilizada para
el nodo que almacena 5 antes de que el subproceso 0 pueda avanzar al nodo que almacena 8.
Aunque las implementaciones típicas de free no sobrescriben la memoria liberada , si la memoria se
reasigna antes de que avance el hilo 0, puede haber problemas serios. Por ejemplo, si la memoria se
reasigna para su uso en algo que no sea un nodo de lista, lo que el subproceso 0 "piensa" es que el
siguiente miembro se puede setear para apuntar basura, y después de que se ejecuta

desreferenciando curr_p puede resultar en una violación de segmentación.


En términos más generales, podemos tener problemas si tratamos de ejecutar simultáneamente otra
operación mientras estamos ejecutando un Insertar o Eliminar. Está bien que varios hilos ejecuten
miembros simultáneamente, es decir, que lean los nodos de la lista, pero no es seguro que múltiples
hilos accedan a la lista si al menos uno de los hilos ejecuta un Insertar o un Eliminar, es decir, está
escribiendo a los nodos de la lista (ver Ejercicio 4.11).
¿Cómo podemos lidiar con este problema? Una solución obvia es simplemente bloquear la lista
cada vez que un hilo intente acceder a ella. Por ejemplo, una llamada a cada una de las tres
funciones puede estar protegida por un mutex, por lo que podríamos ejecutar

en lugar de simplemente llamar a _Member (value).


Un problema igualmente obvio con esta solución es que estamos serializando el acceso a la lista, y
si la gran mayoría de nuestras operaciones son llamadas a miembros, no aprovecharemos esta
oportunidad para el paralelismo. Por otro lado, si la mayoría de nuestras operaciones son llamadas a
Insertar y Eliminar, esta puede ser la mejor solución, ya que tendremos que serializar el acceso a la
lista para la mayoría de las operaciones, y esta solución será fácil de usar. implementar.
Una alternativa a este enfoque implica un bloqueo de "grano más fino". En lugar de bloquear toda la
lista, podríamos tratar de bloquear nodos individuales. Agregaríamos, por ejemplo, un mutex a la
lista del nodo struct:

Ahora, cada vez que intentamos acceder a un nodo, primero debemos bloquear el mutex asociado
con el nodo. Tenga en cuenta que esto también requerirá que tengamos un mutex asociado con el
puntero head_p. Entonces, por ejemplo, podríamos implementar _Member como se muestra en el
Programa 4.12. Es cierto que esta implementación es mucho más compleja que la función miembro
original. También es mucho más lento, ya que, en general, cada vez que se accede a un nodo, se
debe bloquear y desbloquear un mutex. Como mínimo, agregará dos llamadas de función al acceso
de nodo, pero también puede agregar un retraso sustancial si un subproceso tiene que esperar un
bloqueo. Otro problema es que la adición de un campo mutex a cada nodo aumentará
sustancialmente la cantidad de almacenamiento necesario para la lista. Por otro lado, el bloqueo de
grano fino podría ser una aproximación más cercana a lo que queremos. Como solo estamos
bloqueando los nodos de interés actual, múltiples hilos pueden acceder de manera simultánea a
diferentes partes de la lista, independientemente de las operaciones que están ejecutando.

4.9.3 Pthreads read-write locks

Ninguna de nuestras listas enlazadas multiproceso aprovecha el potencial de acceso simultáneo a


ningún nodo por subprocesos que están ejecutando miembro. La primera solución solo permite que
un subproceso acceda a la lista completa en cualquier instante, y la segunda solo permite que un
subproceso acceda a un nodo determinado en cualquier instante. Los bloqueos de lectura y escritura
de Pthreads proporcionan una alternativa. Un bloqueo de lectura y escritura es similar a un mutex,
excepto que proporciona dos funciones de bloqueo. La primera función de bloqueo bloquea el
bloqueo de lectura y escritura para la lectura, mientras que el segundo lo bloquea para la escritura.
De este modo, múltiples hilos pueden obtener el bloqueo llamando a la función de bloqueo de
lectura, mientras que solo un hilo puede obtener el bloqueo llamando a la función de bloqueo de
escritura. Por lo tanto, si algún subproceso posee el candado para lectura, cualquier subproceso que
desee obtener el candado para escritura se bloqueará en la llamada a la función de bloqueo de
escritura. Además, si un hilo posee el candado para escritura, cualquier hilo que desee obtener el
candado para lectura o escritura se bloqueará en sus respectivas funciones de bloqueo.
Usando los bloqueos de lectura y escritura Pthreads, podemos proteger nuestras funciones de listas
enlazadas con el siguiente código (estamos ignorando los valores de retorno de la función):

La sintaxis para las nuevas funciones Pthreads es

Como sugieren sus nombres, la primera función bloquea el bloqueo de lectura y escritura para su
lectura, el segundo lo bloquea para escritura y el último lo desbloquea.
Al igual que con los mutex, los bloqueos de lectura y escritura deben inicializarse antes de su uso y
destruirse después de su uso. La siguiente función se puede utilizar para la inicialización:

También como con mutexes, no usaremos el segundo argumento, así que pasaremos NULL. La
siguiente función se puede usar para la destrucción de un bloqueo de lectura y escritura:
4.9.4 Desempeño de las diversas implementaciones
Por supuesto, realmente queremos saber cuál de las tres implementaciones es la "mejor", por lo que
incluimos nuestras implementaciones en un pequeño programa en el que el hilo principal primero
inserta una cantidad especificada por el usuario de claves generadas aleatoriamente en una lista
vacía. Una vez iniciado por el hilo principal, cada hilo lleva a cabo un número de operaciones
especificado por el usuario en la lista. El usuario también especifica los porcentajes de cada tipo de
operación (Miembro, Insertar, Eliminar). Sin embargo, qué operación ocurre cuando y en qué tecla
está determinada por un generador de números aleatorios. Por lo tanto, por ejemplo, el usuario
puede especificar que se deben insertar 1000 claves en una lista inicialmente vacía y que los hilos
deben realizar un total de 100.000 operaciones. Además, ella podría especificar que el 80% de las
operaciones debería ser _Member, el 15% debería ser Insert y el restante 5% debería ser Delete. Sin
embargo, dado que las operaciones se generan aleatoriamente, puede ocurrir que los hilos ejecuten
un total de, digamos, 79,000 llamadas a Miembros, 15,500 llamadas a Insertar y 5500 llamadas a
Eliminar.
Las tablas 4.3 y 4.4 muestran los tiempos (en segundos) que demoraron 100.000 operaciones en una
lista que se inicializó para contener 1000 claves. Ambos conjuntos de datos se tomaron en un
sistema que contiene cuatro procesadores de doble núcleo.
La Tabla 4.3 muestra los tiempos en que el 99.9% de las operaciones son Miembros y el 0.1%
restante se divide por partes iguales entre Insertar y Eliminar. La Tabla 4.4 muestra los tiempos en
que el 80% de las operaciones son Miembros, el 10% son Insertar y el 10% son Eliminar. Tenga en
cuenta que en ambas tablas cuando se utiliza un subproceso, los tiempos de ejecución para los
bloqueos de lectura y escritura y las implementaciones de un solo mutex son casi los mismos. Esto
tiene sentido: las operaciones están serializadas, y como no hay contención para el bloqueo de
lectura / escritura o el mutex, la sobrecarga asociada a ambas implementaciones debe consistir en
una llamada de función antes de la operación de lista y una llamada de función después de la
operación. Por otro lado, la implementación que usa un mutex por nodo es mucho más lenta. Esto
también tiene sentido, ya que cada vez que se accede a un único nodo habrá dos llamadas a
funciones: una para bloquear el mutex del nodo y otra para desbloquearlo. Por lo tanto, hay una
sobrecarga considerable para esta implementación.
La inferioridad de la implementación que usa un mutex por nodo persiste cuando utilizamos varios
hilos. Hay demasiada sobrecarga asociada con todo el bloqueo y desbloqueo para hacer que esta
implementación sea competitiva con las otras dos implementaciones.
Quizás la diferencia más llamativa entre las dos tablas es el rendimiento relativo de la
implementación de bloqueo de lectura / escritura y la implementación de exclusión única cuando se
utilizan múltiples hilos. Cuando hay muy pocas Insert y Delete s, la implementación de bloqueo de
lectura y escritura es mucho mejor que la implementación de exclusión única. Dado que la
implementación de un solo mutex serializará todas las operaciones, esto sugiere que si hay muy
pocas Insert y Delete, los bloqueos de lectura y escritura hacen un muy buen trabajo al permitir el
acceso concurrente a la lista. Por otro lado, si hay un número relativamente grande de Insertar y
Eliminar (por ejemplo, 10% cada uno), hay muy poca diferencia entre el rendimiento de la
implementación de bloqueo de lectura / escritura y la implementación de exclusión única. Por lo
tanto, para las operaciones de lista enlazada, los bloqueos de lectura / escritura pueden proporcionar
un aumento considerable en el rendimiento, pero solo si el número de Insertar y Eliminar es
bastante pequeño.
También tenga en cuenta que si usamos un mutex o un mutex por nodo, el programa siempre es tan
rápido o más rápido cuando se ejecuta con un hilo. Además, cuando el número de insertos y
eliminaciones es relativamente grande, el programa de bloqueo de lectura / escritura también es más
rápido con un hilo. Esto no es sorprendente para la implementación de un mutex, ya que los accesos
efectivos a la lista están serializados. Para la implementación de bloqueo de lectura / escritura,
parece que cuando hay una cantidad importante de bloqueos de escritura, hay demasiada contención
para los bloqueos y el rendimiento general se deteriora significativamente.
En resumen, la implementación de bloqueo de lectura / escritura es superior a la de un mutex y la de
un mutex por implementación de nodo. Sin embargo, a menos que el número de inserciones y
eliminaciones sea pequeño, una implementación en serie será superior.

4.10 CACHES, CACHE COHERENCE, AND FALSE SHARING

Recuerde que, durante varios años, los procesadores han podido ejecutar operaciones mucho más
rápido de lo que pueden acceder a los datos en la memoria principal. Si un procesador debe leer
datos de la memoria principal para cada operación, pasará la mayor parte de su tiempo simplemente
esperando que lleguen los datos de la memoria. También recuerde que para abordar este problema,
los diseñadores de chips han agregado bloques de memoria relativamente rápida a los procesadores.
Esta memoria más rápida se llama memoria caché.
El diseño de la memoria caché toma en consideración los principios de la localidad temporal y
espacial: si un procesador accede a la ubicación de memoria principal x en el tiempo t, entonces es
probable que a veces cerca de t acceda a ubicaciones de memoria principal cercanas a x. Por lo
tanto, si un procesador necesita acceder a la ubicación de memoria principal x, en lugar de transferir
solo el contenido de x a / desde la memoria principal, un bloque de memoria que contiene x se
transfiere desde / hacia la caché del procesador. Tal bloque de memoria se llama línea de caché o
bloque de caché.
En la Sección 2.3.4, vimos que el uso de la memoria caché puede tener un gran impacto en la
memoria compartida. Recordemos por qué. Primero, considere la siguiente situación: suponga que
"x" es una variable compartida con el valor cinco, y tanto el hilo 0 como el hilo 1 leen "x" de la
memoria en sus cachés (separados), porque ambos desean ejecutar el enunciado

Aquí, my_y es una variable privada definida por ambos hilos. Ahora supongamos que el hilo 0
ejecuta la instrucción

Finalmente, supongamos que el hilo 1 ahora se ejecuta


donde mi z es otra variable privada.
¿Cuál es el valor en my_z? ¿Son cinco? ¿O son seis? El problema es que hay (al menos) tres copias
de x: la que está en la memoria principal, la que está en la memoria caché de la cadena 0 y la que
está en la memoria caché de la cadena 1. Cuando el hilo 0 ejecutaba "x ++", ¿qué pasó con los
valores en la memoria principal y el caché del hilo 1? Este es el problema de coherencia de caché,
que discutimos en el Capítulo 2. Vimos allí que la mayoría de los sistemas insisten en que los
cachés sean conscientes de que se han realizado cambios en los datos que almacenan en caché. La
línea en el caché del subproceso 1 se marcaría como no válida cuando el subproceso 0 ejecutara "x
++", y antes de asignar my_z = x, el subproceso principal 1 vería que su valor de "x" estaba
desactualizado. Por lo tanto, el hilo de ejecución central 0 tendría que actualizar la copia de "x" en
la memoria principal (ahora o antes), y el hilo de ejecución central 1 obtendría la línea con el valor
actualizado de x de la memoria principal. Para más detalles, ver el Capítulo 2.
El uso de la coherencia del caché puede tener un efecto dramático en el rendimiento de los sistemas
de memoria compartida. Para ilustrar esto, recuerde nuestro ejemplo de multiplicación de matriz-
vector Pthreads: El hilo principal inicializó una matriz A m × n y un vector n-dimensional x. Cada
hilo fue responsable de calcular los componentes m / t del vector del producto y = Ax. (Como es
habitual, t es el número de subprocesos). Las estructuras de datos que representan A, x, y, m y n
fueron todas compartidas. Para facilitar la referencia, reproducimos el código en el Programa 4.13.
Si T_serial es el tiempo de ejecución del programa en serie y T_parallel es el tiempo de ejecución
del programa paralelo, recuerde que la eficiencia E del programa paralelo es la aceleración S
dividida por el número de subprocesos:

Como S ≤ t, E ≤ 1. La Tabla 4.5 muestra los tiempos de ejecución y las eficiencias de nuestra
multiplicación matriz-vector con diferentes conjuntos de datos y diferentes números de hilos.
En cada caso, el número total de adiciones y multiplicaciones de coma flotante es de 64, 000, 000,
por lo que un análisis que solo considere las operaciones aritméticas predeciría que un único
subproceso que ejecute el código tomaría la misma cantidad de tiempo para las tres entradas. Sin
embargo, está claro que este no es el caso. Con un hilo, el sistema 8, 000, 000 × 8 requiere
aproximadamente un 14% más de tiempo que el sistema 8000 × 8000, y el sistema 8 × 8, 000, 000
requiere aproximadamente un 28% más de tiempo que el sistema 8000 × 8000. Ambas diferencias
se pueden atribuir al menos parcialmente al rendimiento de la memoria caché.
Recuerde que se produce una pérdida de escritura cuando un núcleo intenta actualizar una variable
que no está en la memoria caché y tiene que acceder a la memoria principal. Un generador de
perfiles de caché (como Valgrind [49]) muestra que cuando el programa se ejecuta con la entrada de
8, 000, 000 × 8, tiene muchas más omisiones de escritura de caché que cualquiera de las otras
entradas. La mayoría de estos ocurren en la Línea 9. Dado que el número de elementos en el vector
y es mucho mayor en este caso (8,000,000 vs. 8000 u 8), y cada elemento debe inicializarse, no es
sorprendente que esta línea reduzca la velocidad de la ejecución. del programa con la entrada de 8,
000, 000 × 8.
También recuerde que una falla de lectura ocurre cuando un núcleo intenta leer una variable que no
está en la memoria caché y tiene que acceder a la memoria principal. Un perfilador de caché
muestra que cuando el programa se ejecuta con la entrada de 8 × 8, 000, 000, tiene muchas más
pérdidas de lectura de caché que cualquiera de las otras entradas. Esto ocurre en la Línea 11, y un
estudio cuidadoso de este programa (ver Ejercicio 4.15) muestra que la fuente principal de las
diferencias se debe a las lecturas de x. Una vez más, esto no es sorprendente, ya que para esta
entrada, x tiene 8,000,000 de elementos, frente a solo 8000 u 8 para las otras entradas
Cabe señalar que puede haber otros factores que están afectando el rendimiento relativo del
programa de un único subproceso con las diferentes entradas. Por ejemplo, no hemos tenido en
cuenta si la memoria virtual (ver Sección 2.2.4) ha afectado el rendimiento del programa con las
diferentes entradas. ¿Con qué frecuencia necesita la CPU acceder a la tabla de páginas en la
memoria principal?
De más interés para nosotros, sin embargo, es la enorme diferencia en la eficiencia a medida que
aumenta el número de subprocesos. La eficiencia de dos hilos del programa con la entrada 8 × 8,
000, 000 es casi un 20% menor que la eficiencia del programa con las entradas 8, 000, 000 × 8 y
8000 × 8000. La eficiencia de cuatro hilos del programa con la entrada 8 × 8, 000, 000 es casi un
60% menor que la eficiencia del programa con la entrada de 8, 000, 000 × 8 y más del 60% menos
que la eficiencia del programa con el 8000 × 8000 de entrada. Estas dramáticas disminuciones en la
eficiencia son aún más notables cuando observamos que con un hilo el programa es mucho más
lento con 8 × 8, 000, 000 de entrada. Por lo tanto, el numerador en la fórmula para la eficiencia:

será mucho más grande. ¿Por qué, entonces, el rendimiento multiproceso del programa es mucho
peor con la entrada 8 × 8, 000, 000?
En este caso, una vez más, la respuesta tiene que ver con el caché. Echemos un vistazo al programa
cuando lo ejecutamos con cuatro hilos. Con la entrada de 8, 000, 000 × 8, y tiene 8,000,000 de
componentes, por lo que a cada subproceso se le asignan 2,000,000 de componentes. Con la entrada
de 8000 × 8000, a cada subproceso se le asignan 2000 componentes de y, y con la entrada de 8 × 8,
000, 000, a cada subproceso se le asignan 2 componentes. En el sistema que usamos, una línea de
caché es de 64 bytes. Como el tipo de y es doble y el doble es de 8 bytes, una sola línea de caché
puede almacenar 8 dobles.
La coherencia de caché se aplica al "nivel de línea de caché". Es decir, cada vez que se escribe un
valor en una línea de caché, si la línea también se almacena en el caché de otro procesador, se
invalidará toda la línea, no solo el valor que fue escrito. El sistema que estamos usando tiene dos
procesadores de doble núcleo y cada procesador tiene su propio caché. Supongamos por el
momento que los hilos 0 y 1 se asignan a uno de los procesadores y los hilos 2 y 3 se asignan a la
otra. Supongamos también que para el problema de 8 × 8, 000, 000, todo y se almacena en una sola
línea de caché. Entonces cada escritura en algún elemento de y invalidará la línea en la memoria
caché del otro procesador. Por ejemplo, cada vez que el hilo 0 actualiza y [0] en el enunciado

Si el hilo 2 o 3 está ejecutando este código, tendrá que volver a cargar y. Cada hilo actualizará cada
uno de sus componentes 8, 000, 000 veces. Vemos que con esta asignación de subprocesos a
procesadores y componentes de y a líneas de caché, todos los subprocesos tendrán que volverse a
cargar y muchas veces. Esto sucederá a pesar del hecho de que solo un hilo accede a cualquier
componente de y, por ejemplo, solo el hilo 0 accede a y [0].
Cada hilo actualizará sus componentes asignados de y un total de 16,000,000 de veces. Parece que
muchas de estas actualizaciones, si no la mayoría, obligan a los subprocesos a acceder a la memoria
principal. Esto se llama intercambio falso. Supongamos que dos hilos con cachés separados acceden
a diferentes variables que pertenecen a la misma línea de caché. Además, supongamos que al menos
uno de los subprocesos actualiza su variable. Entonces, aunque ninguno de los subprocesos ha
escrito en una variable que utiliza el otro subproceso, el controlador de caché invalida toda la línea
de caché y fuerza a los subprocesos a obtener los valores de las variables de la memoria principal.
Los subprocesos no comparten nada (excepto una línea de caché), pero el comportamiento de los
subprocesos con respecto al acceso a la memoria es el mismo que si estuvieran compartiendo una
variable, de ahí el nombre de uso compartido falso.
¿Por qué el intercambio falso no es un problema con las otras entradas? Veamos qué sucede con la
entrada 8000 × 8000. Supongamos que el hilo 2 está asignado a uno de los procesadores y el hilo 3
está asignado a otro. (En realidad, no sabemos qué subprocesos se asignan a qué procesadores, pero
resulta que, vea el ejercicio 4.16, no importa). El subproceso 2 es responsable de la informática.

y el hilo 3 es responsable del calculo

Si una línea de caché contiene 8 dobles consecutivos, la única posibilidad de compartir falsamente
se encuentra en la interfaz entre sus elementos asignados. Si, por ejemplo, una sola línea de caché
contiene

entonces es concebible que pueda haber un intercambio falso de esta línea de caché. Sin embargo, el
hilo 2 tendrá acceso

al final de su ciclo for i, mientras que el hilo 3 accederá

al comienzo de su ciclo for i. Por lo tanto, es muy probable que cuando el hilo 2 tenga acceso
(digamos) y [5996], el hilo 3 sea largo con los cuatro
De manera similar, cuando el hilo 3 accede, digamos, y [6003], es muy probable que el hilo 2 no
esté cerca de comenzar a acceder

Por lo tanto, es poco probable que compartir falsamente los elementos de y sea un problema
significativo con la entrada 8000 × 8000. Un razonamiento similar sugiere que es poco probable
que el intercambio falso de y sea un problema con la entrada de 8, 000, 000 × 8. También tenga en
cuenta que no tenemos que preocuparnos por compartir falsamente A o X, ya que sus valores nunca
se actualizan con el código de multiplicación matriz-vector.
Esto nos lleva a la pregunta de cómo podemos evitar el intercambio falso en nuestro programa de
multiplicación de matriz-vector. Una posible solución es "rellenar" el vector y con elementos
ficticios para asegurar que cualquier actualización por un hilo no afecte a la línea de caché de otro
hilo. Otra alternativa es hacer que cada hilo use su propio almacenamiento privado durante el ciclo
de multiplicación, y luego actualice el almacenamiento compartido cuando haya terminado. Ver
Ejercicio 4.18.

4.11 THREAD-SAFETY

Veamos otro posible problema que ocurre en la programación de memoria compartida: seguridad de
hilos. Un bloque de código es seguro para subprocesos si puede ser ejecutado simultáneamente por
múltiples hilos sin causar problemas.
Como ejemplo, supongamos que queremos usar múltiples hilos para "tokenizar" un archivo.
Supongamos que el archivo consta de texto en inglés común y que los tokens son solo secuencias
contiguas de caracteres separados del resto del texto por espacios en blanco: un espacio, una pestaña
o una nueva línea. Un enfoque simple para este problema es dividir el archivo de entrada en líneas
de texto y asignar las líneas a los hilos en una operación de contramarcha: la primera línea va al hilo
0, la segunda va al hilo 1,. . . , la tth va al hilo t, el t + 1 va al hilo 0, y así sucesivamente.
Podemos serializar el acceso a las líneas de entrada usando semáforos. Luego, después de que un
hilo ha leído una sola línea de entrada, puede tokenizar la línea. Una forma de hacerlo es usar la
función "strtok" en "string.h", que tiene el siguiente prototipo:

Su uso es un poco inusual: la primera vez que se llama argumento de cadena debe ser el texto a ser
tokenizado, por lo que en nuestro ejemplo debe ser la línea de entrada. Para llamadas posteriores, el
primer argumento debe ser NULL. La idea es que, en la primera llamada, strtok almacena en caché
un puntero a la cadena, y para las llamadas posteriores devuelve tokens sucesivos tomados de la
copia en caché. Los caracteres que delimitan tokens se deben pasar en “separadores”. Deberíamos
pasar la cadena "\ t \ n" como argumento de “separadores”
Dadas estas suposiciones, podemos escribir la función de hilo que se muestra en el Programa 4.14.
El hilo principal ha inicializado una matriz de t semáforos, uno para cada hilo. El semáforo del
subproceso 0 se inicializa a 1. Todos los demás semáforos se inicializan a 0. Por lo tanto, el código
en las líneas 9 a 11 obligará a los subprocesos a acceder secuencialmente a las líneas de entrada. El
hilo 0 leerá inmediatamente la primera línea, pero todos los otros hilos se bloquearán en sem_wait.
Cuando el hilo 0 ejecuta sem_post, el hilo 1 puede leer una línea de entrada. Después de que cada
hebra ha leído su primera línea de entrada (o al final del archivo), se lee cualquier entrada adicional
en las líneas 24 a 26. La función de los fgets lee una sola línea de entrada y las líneas 15 a 22
identifican los tokens en la línea .
Programa 4.14: Un primer intento de un tokenizador multiproceso

Cuando ejecutamos el programa con un solo hilo, tokenize correctamente la corriente de entrada. La
primera vez que lo ejecutamos con dos hilos y la entrada

la salida también es correcta. Sin embargo, la segunda vez que lo ejecutamos con esta entrada,
obtenemos la siguiente salida.

¿Que pasó? Recuerde que strtok almacena en caché la línea de entrada. Lo hace al declarar que una
variable tiene una clase de almacenamiento estático. Esto hace que el valor almacenado en esta
variable persista de una llamada a la siguiente. Desafortunadamente para nosotros, esta cadena en
caché es compartida, no privada. Por lo tanto, la llamada de thread 0 a strtok con la tercera línea de
la entrada aparentemente ha sobreescrito el contenido de la llamada de la cadena 1 con la segunda
línea.
La función strtok no es segura para subprocesos: si hay varios hilos que la invocan
simultáneamente, la salida que produce puede no ser correcta. Lamentablemente, no es raro que las
funciones de la biblioteca C no sean seguras para subprocesos. Por ejemplo, ni el generador de
números aleatorios en stdlib.h ni la función de conversión de tiempo localtime en el tiempo. h es
seguro para subprocesos. En algunos casos, el estándar C especifica una versión alternativa, segura
para subprocesos de una función. De hecho, hay una versión de strtok segura para subprocesos:

Se supone que el "_r" sugiere que la función es reentrante, lo que a veces se usa como sinónimo de
seguridad de subprocesos. Los primeros dos argumentos tienen el mismo propósito que los
argumentos para strtok. El argumento saveptr Append '' p '' to '' saveptr '' es utilizado por strtok r
para hacer un seguimiento de dónde está la función en la cadena de entrada; sirve para el propósito
del puntero en caché en strtok. Podemos corregir nuestra función original Tokenize reemplazando
las llamadas a strtok con llamadas a strtok r. Simplemente necesitamos declarar una variable char *
para pasar para el tercer argumento, y reemplazar las llamadas en la línea 16 y la línea 21 con las
llamadas

respectivamente.

4.11.1 Los programas incorrectos pueden producir resultados correctos

Tenga en cuenta que nuestra versión original del programa tokenizer muestra una forma
especialmente insidiosa de error del programa: la primera vez que lo ejecutamos con dos
subprocesos, el programa producía resultados correctos. No fue hasta una ejecución posterior que
vimos un error. Esto, desafortunadamente, no es una ocurrencia rara en programas paralelos. Es
especialmente común en los programas de memoria compartida. Dado que, en su mayor parte, los
hilos se ejecutan de forma independiente el uno del otro, como notamos anteriormente, la secuencia
exacta de las sentencias ejecutadas es no determinista. Por ejemplo, no podemos decir cuándo el
hilo 1 llamará primero a strtok. Si su primera llamada tiene lugar después de que el hilo 0 ha
tokenizado su primera línea, entonces los tokens identificados para la primera línea deberían ser
correctos. Sin embargo, si el hilo 1 llama a strtok antes de que el hilo 0 haya finalizado el
tokenizado de su primera línea, es posible que el hilo 0 no identifique todos los tokens en la primera
línea. Por lo tanto, es especialmente importante en el desarrollo de programas de memoria
compartida para resistir la tentación de asumir que dado que un programa produce resultados
correctos, debe ser correcto. Siempre debemos tener cuidado con las condiciones de carrera.

You might also like