Texto del Seminario

Anuncio
Grado en Ingenierı́a Informática
Programación Concurrente y de Tiempo Real
Seminario I: Concurrencia con C++11
Iván Félix Álvarez Garcı́a*
Alumno Colaborador de la Asignatura
Índice
1. Instalación de C++11
2
2. Creación y Ejecución de Threads
2.1. Creación de Threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2. Identificación de Threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.3. Ejecución con Funciones Lambda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
2
3
4
3. Control de la Exclusion Mutua
3.1. El Problema de la Exclusión Mutua
3.2. Uso de mutex . . . . . . . . . . . . .
3.3. Excepciones y cerrojos . . . . . . . .
3.4. Gestión automática de cerrojos. . . .
3.5. Conclusión. . . . . . . . . . . . . . .
.
.
.
.
.
5
5
7
8
10
10
.
.
.
.
.
10
10
12
13
14
16
5. Tipos Atómicos
5.1. Tipos atómicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2. Conclusión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
17
17
18
6. Comparativa
19
4. Bloqueo Avanzado y Variables
4.1. Bloqueo Recursivo . . . . . .
4.2. Bloqueo de Tiempo Finito . .
4.3. Uso Único . . . . . . . . . . .
4.4. Variables de Condición . . . .
4.5. Conclusión . . . . . . . . . .
*
de
. .
. .
. .
. .
. .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Condición
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
Revisión Técnica: Profesores A. Tomeu y A. Salguero
1
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
1.
Instalación de C++11
Para poder comenzar a desarrollar nuestros programas en C++11, debemos tener instaladas las
herramientas necesarias para poder trabajar con él. Para ello, en esta sección se explicará brevemente
como instalar el compilador de C++11 tanto en Ubuntu como en Fedora.
Instalación en Ubuntu: Para instalar el compilador de C++11 en Ubuntu, sólo tenemos que
introducir la siguiente orden en la terminal: sudo apt-get install g++.
Instalación en Fedora: Al igual que para ubuntu, abrimos la terminal e introducimos: su yum
install gcc-c++.
El desarrollo, tanto del documento como de los códigos, se ha llevado a cabo bajo el sistema operativo
Ubuntu 13.10.
2.
Creación y Ejecución de Threads
C++11 introduce una nueva biblioteca de hilos. Esta biblioteca incluye utilidades para el lanzamiento y manipulación de los mismos. También incluye utilidades para la sincronización como los
mutex y otros cerrojos, varibles atómicas y otras utilidades más complejas. En cambio, no están disponibles ejecutores de procesamiento de tareas ni marcos de paralelismo divide y vencerás como el
habitual fork/join de Java. A lo largo de las distintas partes que componen este documento, se
explicarán a un nivel elemental las principales caracterı́sticas que proporciona esta nueva biblioteca.
Para compilar los ejemplos que se irán proponiendo, necesitaremos parametrizar al compilador con el
flag -std=c++0x o -std=c++11 para conseguir que el compilador soporte el multihebrado. Es también
necesario añadir como parámetro del compilador el flag -pthread.
2.1.
Creación de Threads
Crear y ejecutar un hilo es muy fácil. Cuándo se crea una instancia de un objeto de clase
std::thread, automáticamente es lanzado, a diferencia del lenguaje Java, donde la creación de un
hilo y su ejecucion requieren acciones diferentes (instanciación del hilo y ejecución mediante el método
start()) Cuándo creamos un hilo, tenemos que proporcionalre el segmento de código concurrente que
va a ejecutar. La primera opción para esto, es parametrizar al constructor con un puntero a la función
que contiene el código que deseamos que el hilo ejecute. Comenzaremos con el ejemplo más común, es
decir, un programa Hola Mundo concurrente
1
2
3
4
5
6
7
8
9
10
11
12
13
#i n c l u d e <i o s t r e a m >
#i n c l u d e <thread >
void hola (){
s t d : : c o u t << ” Hola d e s d e e l h i l o ” << s t d : : e n d l ;
}
i n t main ( ) {
std : : thread t1 ( hola ) ;
t1 . j o i n ( ) ;
return 0;
}
2
Considerando que el archivo que contiene el código anterior se llama lanzarunhilo.cpp,
el comando que debemos de introducir en la terminal para su compilación es el siguiente:
g++ lanzarunhilo.cpp -o luh -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero de código objeto ejecutable llamado luh.
Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./luh
.
Todos los métodos de control de hilos están localizados en la cabecera thread. Una cosa interesante
de este primer ejemplo es la llamada a la función join(). Llamando a esta función, forzamos al
hilo actual (que en este caso el hilo principal) a esperar al otro hilo (vemos que el comportamiento es
perfectamente homologable al método join() de la clase java.lang.Thread y se utiliza a los mismos
efectos: gestión de co-rutinas). Si omitimos esta llamada, el resultado es indefinido. El programa puede
imprimir Hola desde el hilo y una nueva lı́nea, puede imprimir sólo Hola desde el hilo sin nueva lı́nea, o
puede que no imprima nada. Esto se debe a que el hilo principal puede retornar de la función principal
antes de que el hilo t1 termine su ejecución.
2.2.
Identificación de Threads
Cada hilo tiene un identificador único que nos permite distinguirlos entre sı́. La clase std::thread
proporciona el método get id() que retorna el identificador único de un hilo. Podemos obtener una
referencia al hilo actual con la variable std::this thread. El siguiente ejemplo ejecuta una serie de
hilos y cada uno de ellos imprime su identificador.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#i n c l u d e <thread >
#i n c l u d e <i o s t r e a m >
#i n c l u d e <v e c t o r >
void hola ( ) {
s t d : : c o u t << ” Hola d e s d e e l h i l o ” << s t d : : t h i s t h r e a d : : g e t i d ( ) << s t d : : e n d l ;
}
i n t main ( ) {
s t d : : v e c t o r <s t d : : thread > h i l o s ;
f o r ( i n t i =0; i < 5 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ( h o l a ) ) ;
}
f o r ( auto& t h r e a d : h i l o s ) {
thread . j o i n ( ) ;
}
return 0;
}
Considerando que el archivo que contiene el código anterior se llama lanzarhilosfuncion.cpp,
el comando que debemos de introducir en la terminal para su compilación es el siguiente:
g++ lanzarhilosfuncion.cpp -o lhsf -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado lhsf. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./lhsf
.
Lanzar cada hilo uno tras otro, y luego almacenarlos en un vector, es una de las maneras más comunes
3
para manejar varios hilos. De esta manera, podemos cambiar fácilmente el número de hilos. Incluso
con un ejemplo pequeño como este, el resultado no es predecible. El caso teórico:
Hola
Hola
Hola
Hola
Hola
desde
desde
desde
desde
desde
el
el
el
el
el
hilo
hilo
hilo
hilo
hilo
140276650997504
140276667782912
140276659390208
140276642604800
140276676175616
.
Es en general, el caso menos común. Podemos obtener también resultados como este:
Hola desde el hilo Hola desde el hilo Hola desde el hilo 139810974787328Hola desde el hilo
139810983180032Hola desde el hilo
139810966394624
139810991572736
139810958001920
.
O muchos otros resultados diferentes. Esto es debido al entrelazado de instrucciones. No tenemos una
manera de controlar el orden de ejecución de los hilos. Un hilo puede ser adelantado en cualquier
momento, introduciéndose en la salida uno a uno (primero se introduce la cadena, luego se añade el
identificador y, finalmente, la nueva lı́nea), por consiguiente, un hilo puede imprimir su primera parte
y luego ser interrumpido, provocando que imprima su segunda parte después de todos los otros hilos.
2.3.
Ejecución con Funciones Lambda
Cuándo el código que tiene que ser ejecutado por cada hilo es muy pequeño, no es necesario crear
una función para especificarlo. En este caso, podemos usar una función lambda para definir el código
del hilo. Se puede reescribir el código del último ejemplo usando lambda fácilmente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#i n c l u d e <thread >
#i n c l u d e <i o s t r e a m >
#i n c l u d e <v e c t o r >
i n t main ( ) {
s t d : : v e c t o r <s t d : : thread > h i l o s ;
f o r ( i n t i = 0 ; i < 5 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ( [ ] ( ) {
s t d : : c o u t << ” Hola d e s d e e l h i l o ” << s t d : : t h i s t h r e a d : : g e t i d ( )
<< s t d : : e n d l ;
}));
}
f o r ( auto& t h r e a d : h i l o s ) {
thread . j o i n ( ) ;
}
return 0;
}
4
Considerando que el archivo que contiene el código anterior se llama lanzarhiloslambda.cpp,
el comando que debemos de introducir en la terminal para su compilación es el siguiente:
g++ lanzarhiloslambda.cpp -o lhsl -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado lhsl. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./lhsl
.
Aquı́ usamos una expresión lambda en lugar del puntero a función. Por supuesto, esto produce exactamente el mismo resultado que el ejemplo anterior.
3.
Control de la Exclusion Mutua
Anteriormente, vimos como lanzar hilos para ejecutar código en paralelo. Todos los códigos ejecutados en los hilos eran independientes. En el caso general, se utilizan objetos compartidos entre los
hilos. Y cuando lo hagamos, nos enfrentaremos a otro problema: la sincronización en el acceso a los
recurso comunes.
Ilustramos este problema con un simple código.
3.1.
El Problema de la Exclusión Mutua
A modo de ejemplo, consideremos una sencilla estructura contenedora. Esta estructura almacena
un valor en una variable, y dispone de un método para incrementar o decrementar el valor. La estructura es la siguiente:
1
2
3
4
5
6
7
s t r u c t Contador {
i n t v a l o r =0;
};
void incrementar (){
++v a l o r ;
}
.
No hay nada nuevo aquı́. Ahora, vamos a ejecutar algunos hilos que comparten el acceso a la estructura
y realizaremos algunos incrementos sobre ella:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
i n t main ( ) {
Contador c o n t a d o r ;
s t d : : v e c t o r <s t d : : thread > h i l o s ;
f o r ( i n t i = 0 ; i < 5 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ([& c o n t a d o r ] ( ) {
f o r ( i n t i = 0 ; i < 1 0 0 ; ++i ) {
contador . incrementar ( ) ;
}
}));
}
f o r ( auto& t h r e a d : h i l o s ) {
thread . j o i n ( ) ;
}
5
17
18
19
20
s t d : : c o u t << c o n t a d o r . v a l o r << s t d : : e n d l ;
return 0;
}
Si la estructura Contandor y la función principal se encuentran juntas en un archivo llamado,
por ejemplo, contadorhilos.cpp, el comando que debemos de introducir en la terminal para su
compilación serı́a el siguiente:
g++ contadorhilos.cpp -o chs -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado chs. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./chs
.
Otra vez, nada nuevo. Lanzamos 5 hilos y cada uno de ellos incrementa el contador cien veces. Una
vez que todos los hilos han finalizado su trabajo, imprimimos el valor del contador.
Si lanzamos este programa, deberı́amos esperar que se imprimiera 500. Pero este no es el caso. Uno
no puede decir que imprimirá este programa. A continuación se muestran algunos de los resultados
obtenidos en diversos experimentos realizados:
442
500
477
400
422
487
.
El problema es que el incremento sobre el contador de la estructura no es una operación atómica. De
hecho, un incremento consta de tres operaciones:
1. Leer el valor actual de la variable valor.
2. Añadir uno al valor actual.
3. Escribir ese nuevo valor en la variable valor.
Cuando ejecutamos ese código usando un solo hilo, no hay problemas. Se ejecutará cada parte de
la operación una después de la otra. Pero cuando tenemos varios hilos, podemos comenzar a tener
problemas. Imaginemos la siguiente situación:
Hilo 1 : Lee el valor, obtiene 0, añade 1, por lo que el valor es igual a 1.
Hilo 2 : Lee el valor, obtiene 0, añade 1, por lo que el valor es igual a 1.
Hilo 1 : Escribe 1 en el valor del campo y retorna 1.
Hilo 2 : Escribe 1 en el valor del campo y retorna 1.
Estas situaciones, como ya se ha indicado vienen derivadas de la presencia de entrelazado de ejecución. El entrelazado describe las posibles situaciones de varios hilos ejecutando instrucciones en
paralelo sobre un recurso común. Incluso para tres operaciones y dos hilos, hay muchas posibilidades
de entrelazado. Cuándo tenemos más hilos y más operaciones, es casi imposible enumerar los posibles
6
entrelazados, pero con seguridad, alguno de ellos será patológico.
Hay varias soluciones para resolver este problema propuesta en la literatura desde un punto de
vista teórico:
1. Algoritmos de e.m. con variables compartidas.
2. Semáforos.
3. Referencias atómicas.
4. Monitores.
5. Comparar e intercambio.
6. etc.
A continuación aprendermos cómo usar semáforos en C++11 para resolver este problema. De
hecho, veremos un caso especial de semáforo llamado mutex. Un mutex es un objeto. Sólo un hilo
puede obtener el cerrojo sobre un mutex al mismo tiempo, y además lo hace de forma atómica.
Esta simple (y poderosa) propiedad de un mutex nos permite usarlo para resolver los problemas de
sincronización.
3.2.
Uso de mutex
En las nuevas bibliotecas de C++11, los mutex se encuentran disponibles en la cabecera mutex y
la clase que representa a un mutex, es la clase std::mutex. Hay dos métodos importantes en un mutex:
lock() y unlock(). Como su propio nombre indica, el primero de ellos permite a un hilo obtener el
cerrojo y el segundo lo libera. El método lock() se bloquea. El hilo sólo retorna desde el método lock()
cuando se ha obtenido el cerrojo. Vemos que el comportamiento es homologable a los cerrojos de clase
ReentrantLock disponibles en Java.
Para conseguir que la estructura Contador sea segura y estable frente a hilos concurrentes, tenemos
que añadir un miembro std::mutex a ella y realizar las operaciones lock()/unlock() del mutex en cada
función del objeto:
1
2
3
4
5
6
7
8
9
10
11
12
s t r u c t Contador {
s t d : : mutex mutex ;
int valor ;
Contador ( ) : v a l o r ( 0 ) {}
};
void incremento (){
mutex . l o c k ( ) ;
++v a l o r ;
mutex . u n l o c k ( ) ;
}
Si esta nueva estructura Contador y la función principal que vimos anteriormente se encuentran
juntas en un archivo llamado contadorhilosmutex.cpp, el comando que debemos de introducir en
la terminal para su compilación es el siguiente:
g++ contadorhilosmutex.cpp -o chsm -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado chsm. Para ejecutarlo
debemos de introducir en la terminal el siguiente comando: ./chsm
7
.
Si comprobamos ahora esta nueva implementación, que es segura frente a hilos, el programa siempre
imprime 500.
3.3.
Excepciones y cerrojos
Ahora vamos a ver qué sucede en otro caso. Imaginemos que el Contador tiene una operación de
decremento que lanza una excepción si el valor es 0:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
s t r u c t Contador {
int valor ;
Contador ( ) : v a l o r ( 0 ) {}
void incremento (){
++v a l o r ;
}
v o i d decremento ( ) {
i f ( v a l o r == 0 ) {
throw ” Va l or no puede s e r menor que 0 ” ;
}
};
}
−−v a l o r ;
.
Queremos acceder a esta estructura concurrentemente sin modificar la clase. Ası́ que creamos una
envoltura con cerrojos para la misma.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
s t r u c t ContadorConcurrente {
s t d : : mutex mutex ;
Contador c o n t a d o r ;
void incremento (){
mutex . l o c k ( ) ;
contador . incremento ( ) ;
mutex . u n l o c k ( ) ;
}
};
v o i d decremento ( ) {
mutex . l o c k ( ) ;
c o n t a d o r . decremento ( ) ;
mutex . u n l o c k ( ) ;
}
.
Esta envoltura trabaja bien para la mayorı́a de los casos, pero cuando una excepción ocurre en el método decrementar, tenemos un gran problema. En efecto, si una excepción ocurre, la función unlock()
no es llamada y por lo tanto, el cerrojo no es liberado. Como consecuencia, nuestro programa queda
completamente bloqueado. Para solucionar este problema, tenemos que usar una estructura try/catch
para desbloquear el cerrojo antes de lanzar de nuevo la excepción:
8
1
2
3
4
5
6
7
8
9
10
v o i d decremento ( ) {
mutex . l o c k ( ) ;
try {
c o n t a d o r . decremento ( ) ;
} catch ( std : : s t r i n g e ){
mutex . u n l o c k ( ) ;
throw e ;
}
mutex . u n l o c k ( ) ;
}
.
El código no es difı́cil, pero si lo miramos, es mejorable. Ahora imaginemos que es una función con 10
diferentes puntos de salida. Tendremos que llamar a la función unlock() desde cada uno de esos puntos
y la probabilidad de que se nos olvide alguno es grande. Incluso, es más grande el riesgo de que no
añadamos una llamada para liberar el cerrojo cuando añadimos un nuevo punto de salida a una función.
La próxima sección da una buena solución a este problema.
La nueva función principal empleada para el ejemplo que acabamos de ver es:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
i n t main ( ) {
ContadorConcurrente c o n t a d o r c o n c u r r e n t e ;
s t d : : v e c t o r <s t d : : thread > h i l o s ;
f o r ( i n t i = 0 ; i < 3 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ([& c o n t a d o r c o n c u r r e n t e ] ( ) {
f o r ( i n t i = 0 ; i < 1 0 0 ; ++i ) {
contadorconcurrente . incremento ( ) ;
}
}));
}
f o r ( i n t i = 0 ; i < 3 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ([& c o n t a d o r c o n c u r r e n t e ] ( ) {
f o r ( i n t i = 0 ; i < 1 0 0 ; ++i ) {
c o n t a d o r c o n c u r r e n t e . decremento ( ) ;
}
}));
}
f o r ( auto& t h r e a d : h i l o s ) {
thread . j o i n ( ) ;
}
s t d : : c o u t << c o n t a d o r c o n c u r r e n t e . c o n t a d o r . v a l o r << s t d : : e n d l ;
return 0;
}
Si unimos esta nueva estructura Contandor, la estructura ContadorConcurrente, con la última función
decremento que hemos visto, y la función principal que se encuentra justo arriba de esta caja en un
archivo llamado, por ejemplo, contadorhilosmutexexcepcion.cpp, el comando que debemos de
introducir en la terminal para su compilación serı́a el siguiente:
g++ contadorhilosmutexexcepcion.cpp -o chsme -pthread -std=c++11 -Wl,--no-asneeded
9
Tras la compilación se nos habrá generado un ejecutable llamado chsme. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./chsme
3.4.
Gestión automática de cerrojos.
Cuándo queremos proteger un bloque completo de código (una función en nuestro caso, aunque
puede estar adentro de un loop o otra estructura de control), existe una buena solución para evitar
olvidar la liberación del cerrojo: std::lock guard.
Esta clase es un gestor simple e inteligente para cerrojos. Cuando el std::lock guard es creado, automáticamente llama a la función lock() del mutex. Cuando el guardián se destruye, se libera también
el cerrojo. Podemos usarlo como sigue:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
s t r u c t C o n t a d o r Co n c u r r e n t e S e g u r o {
s t d : : mutex mutex ;
Contador c o n t a d o r ;
void incremento (){
s t d : : l o c k g u a r d <s t d : : mutex> g u a r d i a n ( mutex ) ;
contador . incremento ( ) ;
}
};
v o i d decremento ( ) {
s t d : : l o c k g u a r d <s t d : : mutex> g u a r d i a n ( mutex ) ;
c o n t a d o r . decremento ( ) ;
}
.
Como se puede apreciar, es mucho más bonito.
Con esta solución, no tenemos que manejar todos los casos de salida de la función, todos ellos son
manejados por el destructor de la instancia de std::lock guard.
3.5.
Conclusión.
Hemos terminado con los semáforos. En este capı́tulo, aprendimos como proteger datos compartidos usando mutex de la biblioteca de hilos de C++.
Tenemos que tener presente que los cerrojos son lentos. En efecto, cuando usamos cerrojos creamos
una sección de código secuencial. Si queremos una aplicación altamente paralela, hay otras soluciones,
distintas de los cerrojos, que lo realizan mucho mejor, pero está fuera del alcance de este capı́tulo.
4.
Bloqueo Avanzado y Variables de Condición
En esta sección continuaremos trabajando con mutex añadiendo algunas técnicas más avanzadas.
Estudiaremos también otra técnica de control muy útil en concurrencia: las variables de condición.
4.1.
Bloqueo Recursivo
Consideremosmos una clase sencilla como esta:
10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
s t r u c t Compleja {
s t d : : mutex mutex ;
int i ;
Compleja ( ) : i ( 0 ) {}
v o i d mul ( i n t x ) {
s t d : : l o c k g u a r d <s t d : : mutex> c e r r o j o ( mutex ) ;
i ∗= x ;
}
};
void div ( i n t x ){
s t d : : l o c k g u a r d <s t d : : mutex> c e r r o j o ( mutex ) ;
i /= x ;
}
.
Y queremos añadir un tercer método que haga uso de los dos métodos ya definidos sin problemas, por
lo que agregamos una nueva función:
1
2
3
4
5
v o i d ambas ( i n t x , i n t y ) {
s t d : : l o c k g u a r d <s t d : : mutex> c e r r o j o ( mutex ) ;
mul ( x ) ;
div (y ) ;
}
.
Comprobemos ahora el funcionamiento de la nueva version de la clase, a través del siguiente programa:
1
2
3
4
5
6
i n t main ( ) {
Compleja c o m p l e j a ;
c o m p l e j a . ambas ( 3 2 , 2 3 ) ;
return 0;
}
Para probar este ejemplo debemos de incluir la estructura Compleja, con la nueva función ambas(int x,
int y), y la función principal, en un archivo llamado, por ejemplo, cogercerrojointerbloqueo.cpp.
El comando que debemos de introducir en la terminal para su compilación es el siguiente:
g++ cogercerrojointerbloqueo.cpp -o cci -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado cci. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./cci
.
Si ejecutamos el programa, veremos que el programa nunca termina. El problema es muy sencillo. En
la función ambas(), el hilo adquiere el cerrojo y luego llama a la función mul(). En esta función, el hilo
intenta adquirir el cerrojo de nuevo, pero el cerrojo ya está bloqueado. Este es un caso de interbloqueo.
Por defecto, un hilo no puede adquirir el mismo mutex dos veces.
Hay una solución simple a este problema: std::recursive mutex. Con este mutex se puede adquirir
varias veces el cerrojo por el mismo hilo. Aquı́ está la versión correcta de la estructura Compleja,
11
utilizando un mutex de tipo recursivo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
s t r u c t Compleja {
s t d : : r e c u r s i v e m u t e x mutex ;
int i ;
Compleja ( ) : i ( 0 ) {}
v o i d mul ( i n t x ) {
s t d : : l o c k g u a r d <s t d : : r e c u r s i v e m u t e x > c e r r o j o ( mutex ) ;
i ∗= x ;
}
void div ( i n t x ){
s t d : : l o c k g u a r d <s t d : : r e c u r s i v e m u t e x > c o r r o j o ( mutex ) ;
i /= x ;
}
};
v o i d ambas ( i n t x , i n t y ) {
s t d : : l o c k g u a r d <s t d : : r e c u r s i v e m u t e x > c e r r o j o ( mutex ) ;
mul ( x ) ;
div ( y ) ;
}
Consideremos que esta nueva estructura Compleja y la función principal vista anteriormente, se
encuentran en un archivo llamado, por ejemplo, cogercerrojo.cpp. El comando que debemos de
introducir en la terminal para su compilación es el siguiente:
g++ cogercerrojo.cpp -o cc -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado cc. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./cc
.
Esta vez, la aplicación trabaja correctamente.
4.2.
Bloqueo de Tiempo Finito
A veces, no queremos que un hilo espere indefinidamente para adquirir un mutex. Sobre todo,
si nuestro hilo puede hacer en lugar de esperar. Para este propósito, la biblioteca estándar tiene una
solución: std::timed mutex y std::recursive timed mutex (por si necesitamos las propiedades recursivas
del mutex). Tenemos acceso a las mismas funciones que con un std::mutex : lock() y unlock(), pero
tenemos también dos nuevas funciones: try lock for() y try lock until().
La primera de ellas es también la más útil. Esto nos permite establecer un tiempo de espera después
de que la función retorne automáticamente, incluso si el cerrojo no se ha adquirido. La función retorna true si el cerrojo se ha adquirido, y false en otro caso. Vamos a intentarlo con un ejemplo sencillo:
1
2
3
4
5
6
7
8
#i n c l u d e <i o s t r e a m >
#i n c l u d e <thread >
#i n c l u d e <mutex>
s t d : : timed mutex mutex ;
void trabajo (){
s t d : : chrono : : m i l l i s e c o n d s t i m e o u t ( 1 0 0 ) ;
12
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
while ( true ){
i f ( mutex . t r y l o c k f o r ( t i m e o u t ) ) {
s t d : : c o u t << s t d : : t h i s t h r e a d : : g e t i d ( ) << ” : t r a b a j a r con e l mutex”
<< s t d : : e n d l ;
s t d : : chrono : : m i l l i s e c o n d s DuracionDormir ( 2 5 0 ) ;
s t d : : t h i s t h r e a d : : s l e e p f o r ( DuracionDormir ) ;
mutex . u n l o c k ( ) ;
s t d : : t h i s t h r e a d : : s l e e p f o r ( DuracionDormir ) ;
} else {
s t d : : c o u t << s t d : : t h i s t h r e a d : : g e t i d ( ) << ” : t r a b a j a r con e l mutex”
<< s t d : : e n d l ;
s t d : : chrono : : m i l l i s e c o n d s DuracionDormir ( 1 0 0 ) ;
s t d : : t h i s t h r e a d : : s l e e p f o r ( DuracionDormir ) ;
}
}
}
i n t main ( ) {
std : : thread t1 ( trabajo ) ;
std : : thread t2 ( trabajo ) ;
t1 . j o i n ( ) ;
t2 . j o i n ( ) ;
return 0;
}
Consideremos que el código anterior se encuentre en un archivo llamado, por ejemplo,
dormirhilos.cpp. El comando que debemos de introducir en la terminal para su compilación serı́a
el siguiente:
g++ dormirhilos.cpp -o dhs -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado dhs. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./dhs
.
(El ejemplo es completamente inútil en la práctica)
La primera cosa interesante en este ejemplo es la declaración de la duración del intento de adquisición del cerrojo con
std::chrono::milliseconds. Esto es también un nueva caracterı́stica del estándar de C++11. Se tiene acceso a varias unidades de tiempo: nanosegundos (nanoseconds), microsegundos (microseconds),
milisegundos (milliseconds), segundos (seconds), minutos (minutes) y horas (hours). Utilizamos una
variable de este tipo para establecer el tiempo de la función try lock for. También usamos esto para
hacer que duerma un hilo con std::this thread::sleep for(duración). El resto del ejemplo no tiene nada
de emocionante, sólo algunas impresiones para ver los resultados visualmente. Tenemos que tener en
cuenta que el programa nunca se detiene, hay que acabar con él.
4.3.
Uso Único
A veces queremos que una función sea llamada una sola vez sin importar el número de hilos que
la utilicen. Imagine una función que tenga dos partes: La primera parte ha de ser llamada una sola
vez y la segunda tiene que ser ejecutada cada vez que la función se llame. Podemos usar la función
13
std::call once para resolver este problema muy fácilmente. A continuación se muestra un ejemplo usando este mecanismo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#i n c l u d e <i o s t r e a m >
#i n c l u d e <thread >
#i n c l u d e <mutex>
s t d : : o n c e f l a g bandera ;
void hacer algo (){
s t d : : c a l l o n c e ( bandera , [ ] ( ) { s t d : : c o u t << ” Llamado una vez ” << s t d : : e n d l ; } ) ;
s t d : : c o u t << ” Llamado cada vez ” << s t d : : e n d l ;
}
i n t main ( ) {
std : : thread
std : : thread
std : : thread
std : : thread
t1 .
t2 .
t3 .
t4 .
join
join
join
join
t1 ( h a c e r
t2 ( h a c e r
t3 ( h a c e r
t4 ( h a c e r
algo
algo
algo
algo
);
);
);
);
();
();
();
();
return 0;
}
Consideremos que el código anterior se encuentre en un archivo llamado, por ejemplo, callonce.cpp.
El comando que debemos de introducir en la terminal para su compilación es el siguiente:
g++ callonce.cpp -o co -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado co. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./co
.
Cada std::call once se corresponde a una variable std::once flag. Aquı́ establecemos un cierre que se
ejecutará una vez, sin embargo, un puntero a función o un std::función hará el truco.
4.4.
Variables de Condición
Una variable de condición gestiona una lista de hilos a la espera de que otro hilo les notifique el
cumplimiento de una condición concreta. Cada hilo que quiera -o más habitualmente deba- esperar
sobre una variable de condición, tiene que adquirir el cerrojo primero. El cerrojo es liberado cuando
el hilo comienza a esperar sobre la condición y debe ser adquirido de nuevo cuando el hilo es despertado.
Un buen ejemplo ya conocido es un buffer finito y utilizando de forma concurrente por varios.
Suele ser un buffer con estructura de cola circular y con una cierta capacidad dada, con un comienzo
y un fin. A continuación se muestra la implementación de esta estructura de datos usando métodos
protegidos y variables de condición, lo cuál en la prácticas nos proporciona el conocido monitor para
el problema del productor-consumidor:
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
struct BufferLimitado {
int ∗ buffer ;
i n t capacidad ;
int frente ;
int cola ;
i n t contador ;
s t d : : mutex c e r r o j o ;
std : : c o n d i t i o n v a r i a b l e no lleno ;
std : : c o n d i t i o n v a r i a b l e no vacio ;
BufferLimitado ( i n t capacidad ) : capacidad ( capacidad ) , f r e n t e ( 0 ) , cola (0)
, contador (0) {
b u f f e r = new i n t [ c a p a c i d a d ] ;
}
˜ BufferLimitado (){
delete [ ] buffer ;
}
v o i d d e p o s i t a r ( i n t dato ) {
s t d : : u n i q u e l o c k <s t d : : mutex> l ( c e r r o j o ) ;
n o l l e n o . w a i t ( l , [& contado r , &c a p a c i d a d ] ( ) { r e t u r n c o n t a d o r != c a p a c i d a d ; } ) ;
b u f f e r [ c o l a ] = dato ;
cola = ( c ola + 1) % capacidad ;
++c o n t a d o r ;
no vacio . notify one ( ) ;
}
int extraer (){
s t d : : u n i q u e l o c k <s t d : : mutex> l ( c e r r o j o ) ;
n o v a c i o . w a i t ( l , [& c o n t a d o r ] ( ) { r e t u r n c o n t a d o r != 0 ; } ) ;
int resultado = buffer [ frente ] ;
f r e n t e = ( f r e n t e + 1) % capacidad ;
−−c o n t a d o r ;
no lleno . notify one ( );
return resultado ;
};
}
.
Los mutex son gestionados mediante un objeto std::unique lock. Es una envoltura para manejar un
cerrojo. Esto es necesario para que las variables de condición puedan usarlo. Para despertar un hilo
que está esperando en una variable de condición, usamos la función notify one(). La función de espera
(wait) es una función un poco especial. Se toma como primer argumento el unique lock y como segundo
argumento un predicado. El predicado debe devolver falso (false) cuando debe continuarse la espera (es
equivalente a while(!pred()) variable condicion.wait(l);). El resto del ejemplo no tiene nada de especial.
Nosotros podemos usar esta estructura para resolver el problema de múltiples consumidores/multiples productores. Este problema modela un patrón muy común en la programación concurrente. Varios
hilos (consumidores) están a la espera de que se produzca un dato por otros hilos (productores). A
continuación se propone un ejemplo con varios hilos productores y consumidores usando el monitor:
15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
v o i d consumidor ( i n t id , B u f f e r L i m i t a d o& b u f f e r ) {
f o r ( i n t i = 0 ; i < 5 0 ; ++i ) {
int valor = buffer . extraer ( ) ;
s t d : : c o u t << ” Consumidor ” << i d << ” e x t r a i d o ” << v a l o r << s t d : : e n d l ;
s t d : : t h i s t h r e a d : : s l e e p f o r ( s t d : : chrono : : m i l l i s e c o n d s ( 2 5 0 ) ) ;
}
}
v o i d p r o d u c t o r ( i n t id , B u f f e r L i m i t a d o& b u f f e r ) {
f o r ( i n t i = 0 ; i < 7 5 ; ++i ) {
buffer . depositar ( i );
s t d : : c o u t << ” P r o d u c t o r ” << i d << ” p r o d u c i d o ” << i << s t d : : e n d l ;
s t d : : t h i s t h r e a d : : s l e e p f o r ( s t d : : chrono : : m i l l i s e c o n d s ( 1 0 0 ) ) ;
}
}
i n t main ( ) {
BufferLimitado buffer ( 200 );
std
std
std
std
std
::
::
::
::
::
thread
thread
thread
thread
thread
c1 .
c2 .
c3 .
p1 .
p2 .
join
join
join
join
join
c1 ( consumidor
c2 ( consumidor
c3 ( consumidor
p1 ( p r o d u c t o r ,
p2 ( p r o d u c t o r ,
, 0 , std : : r e f ( buffer ) ) ;
, 1 , std : : r e f ( buffer ) ) ;
, 2 , std : : r e f ( buffer ) ) ;
0 , std : : r e f ( buffer ) ) ;
1 , std : : r e f ( buffer ) ) ;
();
();
();
();
();
return 0;
}
Se considera que la estructura BufferLimitado y el código anterior se encuentran en un archivo
llamado, por ejemplo, productorconsumidor.cpp. El comando que debemos de introducir en la
terminal para su compilación es el siguiente:
g++ productorconsumidor.cpp -o pc -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se habrá generado un fichero ejecutable llamado pc. Para ejecutarlo debemos de introducir en la terminal el siguiente comando: ./pc
.
Se activan tres hilos consumidores y dos hilos productores que acceden a la estructura monitorizada
constanmente. Una cosa interesante sobre este ejemplo es el uso de std::ref para pasar el buffer por
referencia, esto es necesario para evitar una pasar una copia del buffer por valor.
4.5.
Conclusión
En esta sección hemos visto como usar objetos recursive mutex para permitir a un hilo adquirir un
cerrojo más de una vez. Luego, hemos visto como adquirir un mutex con tiempo de espera definido.
Después de eso, hemos estudiado un método para llamar a una función una sola vez. Y finalmente,
hemos usado las variables de condición para resolver el problema de multiples consumidores / multiples
productores.
16
5.
Tipos Atómicos
Ya vimos técnicas avanzadas sobre los mutex. En este capı́tulo, continuaremos trabajando sobre
los mutex con técnicas más avanzadas. Estudiaremos también otras técnicas de concerrencia de la
biblioteca de concurrencia de C++11: los tipos atómicos.
5.1.
Tipos atómicos
Tomaremos el ejemplo de un Contador:
1
2
3
4
5
6
7
8
9
s t r u c t Contador {
int valor ;
v o i d i n c r e m e n t o ( ) { ++v a l o r ; }
v o i d decremento ( ) { −−v a l o r ; }
};
int obtener (){ return valor ; }
.
Vimos ya que esta clase no era segura para su uso en entornos multihilo. Vimos también como hacerla
segura si se usaban los mutex. Esta vez, veremos como hacerla segura usando tipos atómicos. La principal ventaja de esta técnica es su rendimiento. En muchos casos, las operaciones atómicas (es decir,
std::atomic operations) son implementadas con operaciones sin bloqueos que son mucho más rápidas
que los cerrojos.
La biblioteca de concurrencia de C++11 introduce los tipos atómicos como una clase de plantilla
std::atomic. Puede usar cualquier tipo que desee con la plantilla y las operaciones sobre esta varaible
serán atómicas y seguras contra hilos. Se tiene que tener en cuenta, que le corresponde a la implementación de la biblioteca elegir cuales de los mecanismos de sincronización son usados para hacer la
operación sobre este tipo atómico. En plataformas estandar para tipos ı́ntegros como int, long, float
serán técnicas sin bloqueos. Si queremos hacer un tipo grande (vease, 2MB de almacenamiento), puede
usar también std::atomic, pero también serán usados los mutex para proveer accesos seguros. En este
caso, no hay mejora de rendimiento.
Las principales funciones que std::atomic ofrece son las funciones de almacenamiento y de carga que
atómicamente permiten establecer y obtener el contenido de la std::atomic. Otra función interesante
es la función de intercambio, que establece lo atómico a un nuevo valor y devuelve el valor declarado
previamente. Finalmente hay dos funciones, compare exchange weak y compare exchange strong, que
realizan intercambios atómicos sólo si el valor es igual al valor proporcionado. Estas dos últimas funciones pueden ser usadas para implementar algoritmos sin bloqueos.
std::atomic está especializada para todos los tipos integrados para proveer funciones miembros
especı́ficas (como los operadores ++, −−, fetch add, fetch sub, ...).
Es bastante fácil hacer un contador seguro con std::atomic:
1
2
3
4
5
6
#i n c l u d e <atomic>
s t r u c t ContadorAtomico {
s t d : : atomic<i n t > v a l o r ;
v o i d i n c r e m e n t o ( ) { ++v a l o r ; }
17
7
8
9
10
11
v o i d decremento ( ) { −−v a l o r ; }
};
int obtener (){ return valor . load ( ) ; }
.
Si probamos este contador junto con la función principal siguiente, veremos que el valor es siempre el
esperado.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
i n t main ( ) {
ContadorAtomico c o n t a d o r a t o m i c o ;
contadoratomico . valor . s t o r e ( 0 ) ;
s t d : : v e c t o r <s t d : : thread > h i l o s ;
f o r ( i n t i = 0 ; i < 3 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ([& c o n t a d o r a t o m i c o ] ( ) {
f o r ( i n t i = 0 ; i < 1 0 0 ; ++i ) {
contadoratomico . incremento ( ) ;
}
}));
}
f o r ( i n t i = 0 ; i < 3 ; ++i ) {
h i l o s . push back ( s t d : : t h r e a d ([& c o n t a d o r a t o m i c o ] ( ) {
f o r ( i n t i = 0 ; i < 1 0 0 ; ++i ) {
c o n t a d o r a t o m i c o . decremento ( ) ;
}
}));
}
f o r ( auto& t h r e a d : h i l o s ) {
thread . j o i n ( ) ;
}
s t d : : c o u t << c o n t a d o r a t o m i c o . o b t e n e r ( ) << s t d : : e n d l ;
return 0;
}
Se considera que la estructura ContadorAtomico y el código anterior se encuentran en un archivo
llamado, por ejemplo, contadoratomico.cpp. El comando que debemos de introducir en la terminal
para su compilación serı́a el siguiente:
g++ contadoratomico.cpp -o ca -pthread -std=c++11 -Wl,--no-as-needed
Tras la compilación se nos habrá generado un ejecutable llamado ca. Para ejecutarlo debemos
de introducir en la terminal el siguiente comando: ./ca
5.2.
Conclusión
En este capı́tulo hemos visto una técnica muy elegante para realizar operaciones atómicas sobre
cualquier tipo. Se aconseja el uso de std::atomic cada vez que se necesite hacer operaciones atómicas
de un tipo, especialmente en los tipos integrados.
18
6.
Comparativa
En las secciones anteriores, vimos algunas técnicas de sincronización de C++11: cerrojos, lock
guards y referencias atómicas. En esta, se presentarán los resultados de una pequeña comparativa
para contrastar las diferentes técnicas entre sı́. En esta comparativa, la sección crı́tica es un simple
incremento de un entero. La sección crı́tica estará protegida usando tres técnicas:
1. Un sencillo std::mutex con llamadas a lock() y unlock().
2. Un sencillo std::mutex bloqueado con std::lock guard.
3. Una referencia atómica entera.
La prueba se ha realizado con 1, 2, 4, 8, 16, 32, 64 y 128 hilos. Cada prueba se repitió 5 veces.
Como era de esperar, las versiones mutex son mucho más lentas que la versión atómica. Un punto
interesante, es que la versión atómica no tiene un buena escalabilidad. Se esperaba que el impacto
de añadir un hilo no fuese tan alto. También se puede notar que la versión de lock guard tiene una
sobrecarga cuando hay pocos hilos. En conclusión, no se debe bloquear con cerrojos cuando todo lo
que necesitamos es modificar los tipos integrados en la plantilla std:.atomic. Por lo que, en conclusión,
std::atomic es mucho más rápido. Los buenos algoritmos sin bloqueos son casi siempre más rapidos
que los algoritmos con cerrojos.
19
Referencias
[1] Williams, Anthony, C++ Concurrency in Action: Practical Multithreading. Manning Publications Co., 2012.
[2] Wicht, Baptiste, C++11 Concurrency Tutorial. 2012. URL: http://www.baptiste-wicht.
com/series/cpp11-concurrency-tutorial/
20
Descargar