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