Programacin Avanzada, Concurrente y Distribuida Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 2 Universidad Politcnica de Madrid -UPM
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 3 Universidad Politcnica de Madrid -UPM
PRLOGO 7 PARTE I. Desarrollo de una aplicacin distribuida y concurrente en LINUX 1 1. . EDICIN, COMPILACIN, Y DEPURACIN DE UNA APLICACIN C/C++ BAJO LINUX 11 1.1. INTRODUCCIN 11 1.2. LOGIN EN MODO TEXTO 12 1.3. MANEJO DE ARCHIVOS Y DIRECTORIOS EN MODO TEXTO. 12 1.4. EL EDITOR DE TEXTO 15 1.5. DESARROLLO C/C++ EN LINUX EN MODO TEXTO 15 1.6. EL PROCESO DE CREACIN DE UN EJECUTABLE 16 1.7. LAS HERRAMIENTAS DE DESARROLLO 17 1.8. EL COMPILADOR GCC 17 1.9. MAKEFILE Y LA HERRAMIENTA MAKE 19 1.10. TIPOS DE ERROR 20 1.11. DEPURACIN DE LA APLICACIN. 21 1.12. CREACIN DE UN SCRIPT 22 1.13. DESARROLLO EN UN ENTORNO GRAFICO 23 1.14. EJERCICIO PRCTICO 24 1.15. EJERCICIO PROPUESTO 25 2 2. . INTRODUCCIN A LOS SISTEMAS DISTRIBUIDOS. COMUNICACIN POR SOCKETS 27 2.1. OBJETIVOS 27 2.2. SISTEMA DISTRIBUIDO 28 2.3. SERVICIOS DE SOCKETS EN POSIX 29 2.3.1 PROGRAMA CLIENTE 30 2.3.2 SERVIDOR 32 2.4. ENCAPSULACIN DE UN SOCKET EN UNA CLASE C++ 35 2.4.1 ENVO DE MLTIPLES MENSAJES 36 2.4.2 CONEXIONES MLTIPLES. 38 2.5. ESTRUCTURA DE FICHEROS 42 2.6. TRANSMITIENDO EL PARTIDO DE TENIS 44 2.6.1 CONEXIN 44 2.6.2 ENVO DE DATOS 45 2.7. EJERCICIOS PROPUESTOS 45 Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 4 Universidad Politcnica de Madrid -UPM 3 3. . COMUNICACIONES Y CONCURRENCIA 47 3.1. INTRODUCCIN 47 3.2. REQUISITOS 49 3.3. FUNCIONAMIENTO DE GLUT 49 3.3.1 LANZANDO UN HILO 50 3.4. ESTRUCTURA DEL SERVIDOR 51 3.5. MLTIPLES CONEXIONES SIMULTANEAS 52 3.6. MOSTRAR LOS CLIENTES CONECTADOS 53 3.7. RECEPCIN COMANDOS MOVIMIENTO 55 3.8. GESTIN DESCONEXIONES 56 3.9. FINALIZACIN DEL PROGRAMA 56 3.10. EJERCICIO PROPUESTO 57 4 4. . COMUNICACIN Y SINCRONIZACIN INTERPROCESO 59 4.1. INTRODUCCIN 59 4.2. EL PROBLEMA DE LA SINCRONIZACION 60 4.3. COMUNICACIN INTERPROCESO 61 4.4. TUBERAS CON NOMBRE 62 4.5. MEMORIA COMPARTIDA 64 4.6. EJERCICIOS PROPUESTOS 68
PARTE II. Programacin avanzada 5 5. . PROGRAMACIN DE CDIGO EFICIENTE 73 5.1. INTRODUCCIN 73 5.2. MODOS DE DESARROLLO 77 5.3. TIPOS DE OPTIMIZACIONES 77 5.4. VELOCIDAD DE EJECUCIN 78 5.5. ALGUNAS TCNICAS 79 5.5.1 CASOS FRECUENTES 79 5.5.2 BUCLES 80 5.5.3 GESTIN DE MEMORIA 83 5.5.4 TIPOS DE DATOS 85 5.5.5 TCNICAS EN C++ 86 5.6. CASOS PRCTICOS 87 5.6.1 ALGORTMICA VS. MATEMTICAS 87 5.6.2 GENERACIN DE NMEROS PRIMOS 88 5.6.3 PRE-COMPUTACIN DE DATOS 90 5.7. OBTENIENDO PERFILES (PROFILING) DEL CDIGO 93 5.8. CONCLUSIONES 95 Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 5 Universidad Politcnica de Madrid -UPM 6 6. . SERIALIZACIN DE DATOS 97 6.1. INTRODUCCIN 97 6.2. REPRESENTACIN OBJETOS EN MEMORIA 102 6.3. SERIALIZACIN EN C 103 6.3.1 CON FORMATO (TEXTO) 104 6.3.2 SIN FORMATO (BINARIA) 104 6.4. SERIALIZACIN EN C++ 107 6.4.1 CON FORMATO (TEXTO) 108 6.4.2 SIN FORMATO (BINARIA) 111 6.5. CONCLUSIONES 112 7 7. . BSQUEDAS EN UN ESPACIO DE ESTADOS MEDIANTE RECURSIVIDAD 113 7.1. INTRODUCCIN 113 7.2. BSQUEDA PRIMERO EN PROFUNDIDAD 115 7.2.1 TERMINOLOGA 116 7.2.2 ESTRUCTURAS DE DATOS 116 7.2.3 ANLISIS 117 7.3. BSQUEDA PRIMERO EN ANCHURA 119 7.4. METODOLOGA GENERAL DE RESOLUCIN DE UN PROBLEMA DE BSQUEDA MEDIANTE COMPUTACIN 120 7.5. IMPLEMENTACIN DE UNA BSQUEDA DFS MEDIANTE RECURRENCIA 121 7.5.1 LA PILA DE LLAMADAS 122 7.5.2 BSQUEDA DFS COMO RECURSIN 124 8 8. . EJECUCIN DISTRIBUIDA DE TAREAS 133 8.1. INTRODUCCIN 133 8.2. EL PROBLEMA DE LAS N-REINAS 134 8.2.1 HISTORIA 134 8.2.2 CARACTERSTICAS 135 8.2.3 2ESTRUCTURAS DE DATOS 136 8.3. IMPLEMENTACIN CENTRALIZADA 138 8.3.1 DESCRIPCIN 139 8.3.2 ESTRUCTURAS DE DATOS 140 8.3.3 CONTROL DE LA BSQUEDA 141 8.3.4 ALGORITMO DE BSQUEDA 145 8.4. IMPLEMENTACIN DISTRIBUIDA 147 8.4.1 ARQUITECTURA CLIENTE-SERVIDOR 147 8.4.2 PROTOCOLO DE COMUNICACIN 148 8.4.3 IMPLEMENTACIN DEL CLIENTE 148 8.5. IMPLEMENTACIN DEL SERVIDOR 153 8.5.1 COMUNICACIN CON EL CLIENTE 153
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 6 Universidad Politcnica de Madrid -UPM
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 7 Universidad Politcnica de Madrid -UPM
PRLOGO Generalmente la formacin en informtica de un ingeniero (industrial, automtica, telecomunicaciones o similar) comienza por la programacin estructurada, en lenguajes como C o Matlab, y luego se complementa con Programacin Orientada a Objetos (POO) e Ingeniera del Software, con Anlisis y Diseo Orientados a Objetos, UML, etc. Sin embargo, existen una serie de tcnicas y tecnologas software que escapan del alcance de los anteriores cursos. La programacin de tareas concurrentes, los sistemas distribuidos, la programacin de cdigo eficiente o algortmica avanzada son temas que quedan a menudo relegados, y sin embargo son muy necesarios en tareas de ingeniera industrial, comunicaciones y similares. Este libro trata de cubrir dichos aspectos, de una manera prctica y aplicada. La primera parte desarrolla una aplicacin grfica distribuida: un tpico juego de computador en red. En esta aplicacin se requiere el uso de comunicaciones por red (con sockets), as como la utilizacin de tcnicas de programacin concurrente con multi-proceso y multi-hilo, de una manera que esperamos que sea atractiva y motivadora para el lector. El desarrollo se realiza en Linux (Posix), presentando una introduccin al manejo bsico, desarrollo y depuracin con herramientas GNU como g++, make y gdb. El cdigo de soporte para estos captulos se encuentra en www.elai.upm.es La segunda parte cubre algunos tpicos genricos avanzados como la programacin de cdigo eficiente, la serializacin de datos, la recurrencia o la computacin distribuida, tpicos que muchas veces estn ntimamente relacionados con los anteriores.
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 8 Universidad Politcnica de Madrid -UPM
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 9 Universidad Politcnica de Madrid -UPM
Parte I. Desarrollo de una aplicacin distribuida y concurrente en LINUX Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 11 Universidad Politcnica de Madrid -UPM 1 1 . .
EDICIN, COMPILACIN, Y DEPURACIN DE UNA APLICACIN C/C++ BAJO LINUX 1.1. INTRODUCCIN En este primer tema realizamos una aproximacin al SO operativo linux, y fundamentalmente al desarrollo de aplicaciones en C/C++, desarrolladas, depuradas y ejecutadas en un computador con Linux. Aunque el objetivo de este curso es el aprendizaje de programacin concurrente y sistemas distribuidos, en este primer tema nos ceiremos al trabajo de desarrollo convencional en linux, para aprender tanto el desarrollo sin interfaz grafica de ventanas, como algunas de las herramientas graficas. Tambin se manejaran algunos comandos o mandatos bsicos de linux para crear, editar y manejar archivos, y se introducir el uso de las herramientas de desarrollo bsico como son gcc, g++, make y gdb. Este tema comienza por la descripcin de los comandos bsicos para trabajar en modo texto, para despus desarrollar y depurar una pequea aplicacin ejemplo en modo texto. Por ultimo, se trabajara en modo grafico, completando un cdigo ya avanzado para terminar con el juego del tenis que funcione en modo local, para dos jugadores, esto es, los dos jugadores utilizan el mismo teclado y la misma pantalla. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 12 Universidad Politcnica de Madrid -UPM
Figura 1-1. Objetivo del captulo: Desarrollo del juego del Tenis en modo local 1.2. LOGIN EN MODO TEXTO Aunque el computador arranque en modo grafico, la primera parte de esta prctica se va a desarrollar en modo texto. Para ello cmbiese del terminal grafico al primer terminal de texto, mediante la correspondiente combinacin de teclas (Ctrl+Alt+F1) Entrar en la cuenta de usuario correspondiente. Consejo: Aunque dispongas de la contrasea de administrador es absolutamente recomendable no utilizarla para trabajar normalmente. En caso de que seas el administrador del sistema, crea una cuenta de usuario normal para realizar la prctica. Probar a realizar el login en los distintos terminales virtuales (saliendo luego con el comando exit de los que no se vayan a utilizar) 1.3. MANEJO DE ARCHIVOS Y DIRECTORIOS EN MODO TEXTO. Para familiarizarse con el manejo de archivos y directorios en linux se va a crear la siguiente estructura de archivos, en la que los archivos de texto contienen el texto Hola que tal: /home/usuario/ |------------->carpeta1 | |------->subcarpeta11 | | |------->archivo11.txt | |------->archivo1.txt |------------->carpeta2 |------->archivo2.txt Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 13 Universidad Politcnica de Madrid -UPM
Utilizar y explorar los comandos y opciones siguientes:
Tabla 1-1. Comandos bsicos consola linux Comando Accin Opciones pwd muestra el directorio actual ls muestra el contenido del directorio actual -a (muestra todos los archivos, incluidos ocultos) l, muestra detalles de los archivos mkdir [directorio] crea el directorio con el nombre dado
cd [ruta] cambia al directorio que indica la ruta correspondiente
cat [fichero] concatena el fichero a salida estndar - significa entrada estndar. Para crear un archivo se puede redireccionarla de la siguiente forma cat - >nombre_fichero.txt
chmod usuario+permiso [fichero] cambia los permisos (r=read, w=write, x=execute) a usuario (a=all, o=others, u=user, g=group)
rm [archivo] Borra el archivo -r = borra recursivamente el directorio seleccionado (OJO, usar con mucha precaucin) cp [origen] [destino] Copia el archivo o archivos origen al destino seleccionado
mv [origen] [destino] Mueve el archivo o archivos origen al destino seleccionado Tambin sirve para renombrar un archivo rmdir [directorio] Borra el directorio, que previamente debe estar vaco
exit o logout Termina la sesin (salir)
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 14 Universidad Politcnica de Madrid -UPM Tabla 1-2. Caracteres comodin (wildcars) * Una cadena de caracteres cualesquiera
? Un carcter cualquiera
Tabla 1-3. Directorios importantes . Directorio actual Opcion .. Directorio superior cd Vuelve al directorio inicial raiz del usuario \home\usuario
Tabla 1-4. Ayuda Comando funcin Opcion man [comando] Muestra las paginas man del comando seleccionado
comando Muestra una ayuda breve del comando al que se aplica --help -h info [comando] Muestra las paginas info del comando al que se aplica
whatis [comando] Busca en una base de datos descripciones cortas del comando
Tabla 1-5. Ayudas del shell bash Teclas funcin Opcion Tab Autocompletar, rellena el nombre del comando o archivo segn las posibles opciones que conozca
Tab+Tab Muestra todas las opciones que tiene autocompletar
Arrow Up Sube en la historia de comandos
Arrow Down Baja en la historia de comandos
Una vez creada la estructura, quitar el permiso de escritura al archivo11.txt e intentar concatenarle la cadena Muy bien gracias. Volver a reinstaurar el permiso y repetir la operacin. Borrar primero el archivo2.txt y luego la carpeta2. Borrar a continuacin todo el rbol de la carpeta1. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 15 Universidad Politcnica de Madrid -UPM
1.4. EL EDITOR DE TEXTO Se va a utilizar el editor vi o vim para crear y modificar los archivos de cdigo fuente necesarios, por ser el editor incluido por defecto en linux, y del que conviene tener al menos unas nociones bsicas que nos permitan sacarnos de un apuro en caso de necesidad. Para crear un archivo nuevo en la carpeta actual teclear: vi [fichero] Si el archivo no existe lo crea y si existe lo abre para editar. vi tiene dos modos de funcionamiento: Modo comando: cada tecla realiza una funcin especfica (borrar, mover) Este es el modo por defecto al arrancar el editor. Modo insercin: cada tecla inserta el carcter correspondiente en el texto. Para entrar en este modo se debe pulsar la tecla i y para salir de l se debe pulsar Esc. Operaciones bsicas :w graba el archivo al disco :q salir de editor :q! salir del editor sin grabar los cambios (forzar la salida) :wq grabar y salir 1.5. DESARROLLO C/C++ EN LINUX EN MODO TEXTO Vamos a construir una aplicacin con dos ficheros fuente, que muestre por pantalla una tabla de senos de varios ngulos. Para ello seguiremos los siguientes pasos: 1. Verificar mediante pwd que se encuentra en el directorio de usuario adecuado 2. Crear una carpeta pract1 que va a contener los archivos de la prctica, y cambiar el directorio actual a la misma 3. Crear los archivos fuente siguientes:
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 16 Universidad Politcnica de Madrid -UPM /* * archivo: principal.c */
#include <stdio.h> #include misfunc.h int main(void) { int i; for(i=0;i<10;i++) { printf("Seno de %d es %f \n",i,seno(i)); } return 1; }
1.6. EL PROCESO DE CREACIN DE UN EJECUTABLE El compilador genera un fichero o modulo objeto (binario) por cada uno de los ficheros fuentes contenidos en el proyecto. Estos mdulos objeto no necesitan para ser compilados ms que el fichero fuente de origen, aunque se referencien funciones externas a dicho fichero. El proceso de enlazado une los mdulos objeto resolviendo las referencias entre ellos, as como las referencias a posibles bibliotecas o libreras externas al proyecto, y generando el archivo ejecutable. El sistema operativo es el encargado de unir el ejecutable con las libreras dinmicas cuando el programa es cargado en memoria. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 17 Universidad Politcnica de Madrid -UPM
Figura 1-2. Proceso de creacin de un ejecutable 1.7. LAS HERRAMIENTAS DE DESARROLLO Se van a utilizar a partir de ahora los compiladores y distintas herramientas. Puede ser que en su sistema linux no vengan instaladas por defecto. Si ese es el caso, debe de instalarlas. El gestor de aplicaciones o paquetes de su distribucin le ayudara a hacerlo. En cualquier caso es importante remarcar que las herramientas de desarrollo utilizadas son GNU, con licencia GPL, es decir son gratuitas y su instalacin es totalmente legal. Si utiliza un sistema basado en Debian, la forma ms sencilla de instalar estas herramientas seria: sudo apt-get install build-essential 1.8. EL COMPILADOR GCC El compilador utilizado en linux se llama gcc. La sintaxis adecuada para la compilacin y linkado del anterior programa seria: gcc o prueba principal.c misfunc.c lm Fichero fuente A .c, .cpp Fichero fuente B .c, .cpp COMPILADOR LINKADO Biblioteca esttica A .a Biblioteca esttica B .a
EJECUCION Ejecutable Modulo objeto A .o Modulo objeto B .o
Libreras dinmicas .so Proceso en ejecucin Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 18 Universidad Politcnica de Madrid -UPM Ejecutar el programa mediante: ./prueba
Figura 1-3. Salida por pantalla de nuestra aplicacin Comprobar con ls al los permisos de ejecucin del archivo ls -al La sintaxis es la siguiente: gcc o [nombre_ejecutable] [ficheros_fuente] l[librera] Realmente este comando ha realizado la compilacin y el linkado todo seguido, de forma transparente para el usuario. Si se desea desacoplar las dos fases se realiza de la siguiente manera: Compilacin fichero a fichero : gcc c principal.c gcc c misfunc.c (Ntese que aqu no es necesario especificar que se va a linkar con la librera matemtica, ya que solo se esta compilando en un modulo objeto .o) Compilacin de varios ficheros en la misma lnea gcc c principal.c misfunc.c Enlazado gcc o prueba principal.o misfunc.o lm Ntese que la opcin lm hace referencia a linkar l con la librera m o de nombre completo libm.a o libm.so que es la librera estndar matemtica en sus versiones estticas o dinmicas. Buscar con find / -name libm.* Eliminar archivos objeto rm *.o Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 19 Universidad Politcnica de Madrid -UPM 1.9. MAKEFILE Y LA HERRAMIENTA MAKE Hemos visto un ejemplo sencillo, en el que teclear el comando para compilar y crear el ejecutable es muy sencillo. Sin embargo este procedimiento puede ser largo y tedioso en el caso de grandes proyectos con muchos ficheros fuente y mltiples opciones de compilacin. Por ello existe una herramienta, el make, que haciendo uso de la configuracin de un fichero denominado Makefile (sin extensin, tpicamente situado en la carpeta en la que tenemos el proyecto), se encarga de todo este trabajo. Entre otras cosas, se encarga de realizar la comprobacin de que ficheros han sido modificados, para solo compilar dichos archivos, ahorrando mucho tiempo al usuario. La sintaxis del Makefile es muy potente y compleja, por lo que aqu se realiza solamente la descripcin de una configuracin bsica para el proyecto de esta practica. Para ello crear y editar con el vi el archivo siguiente: #Makefile del proyecto
principal.o: principal.c misfunc.h $(CC) c principal.c misfunc.o: misfunc.c misfunc.h $(CC) c principal.c
clean: rm f *.o prueba
Los comentarios en un Makefile se preceden de # #Makefile del proyecto El Makefile permite la definicin de variables, mediante una simple asignacin. En la primera parte del Makefile establecemos algunas variables de conveniencia. Se define la cadena CC que nos definir el compilador que se va a usar CC=gcc Se define la cadena CFLAGS que nos definir las opciones de compilacin, en este caso habilita la informacin que posibilita la depuracin del ejecutable CFLAGS= -g La cadena LIBS almacena las libreras con las que hay que linkar para generar el ejecutable LIBS= -lm La cadena OBJS define los mdulos objeto que componen el ejecutable. Aqu se deben listar todos los archivos objeto necesarios, si nos olvidamos alguno, el enlazador encontrara un error. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 20 Universidad Politcnica de Madrid -UPM OBJS=misfunc.o principal.o A partir de aqu comienzan las reglas, cada regla tiene la siguiente estructura: objetivo (target): prerequisitos o dependencias comando Cada regla mira si los prerrequisitos o dependencias han sido modificados, y caso de que lo hayan sido, construye el objetivo utilizando el comando. La siguiente cadena establece la construccin del ejecutable a partir de los objetos, y linkando con las libreras LIBS y generando el ejecutable prueba prueba: $(OBJS) $(CC) $(OBJS) $(LIBS) o prueba Que es totalmente equivalente a: prueba: misfunc.o principal.o gcc misfunc.o principal.o -lm o prueba Que significa: Si alguno o ambos de los ficheros objeto han cambiado, se tiene que volver a linkar el ejecutable prueba, a partir de los ficheros objeto y enlazando con la librera matemtica lm. A su vez especificamos la compilacin de cada uno de los mdulos objeto: principal.o: principal.c misfunc.h $(CC) c principal.c misfunc.o: misfunc.c misfunc.h $(CC) c principal.c Las dos primeras lneas, analizan si han sido modificados principal.c o misfunc.h, y en su caso, significa que hay que volver a compilar el modulo objeto a partir del cdigo fuente. El Makefile analiza las dependencias recursivas, esto es, si el fichero misfunc.h ha sido modificado, primero compilara con las dos ultimas reglas los ficheros objeto principal.o y misfunc.o. Como estos ficheros han sido modificados, invocara a su vez a la regla superior, linkando y obteniendo el ejecutable prueba. La regla clean (make clean) elimina los objetos y el ejecutable clean: rm f *.o prueba Lo que significa que si tecleamos en la lnea de comandos: make clean en vez de construir el ejecutable, se borran los archivos binarios temporales y el ejecutable 1.10. TIPOS DE ERROR Existen dos tipos de errores en un programa, errores en tiempo de ejecucin y errores en tiempo de compilacin. Vamos a ver la diferencia entre ambos: Errores en tiempo de compilacin. Son errores, principalmente de sintaxis. El compilador los detecta y nos informa de ello, no produciendo Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 21 Universidad Politcnica de Madrid -UPM un ejecutable. Vamos a provocar un error de este estilo. Realizamos el cambio: printf("Seno de %d es %f \n",i,seno(float(i)); Quitamos el punto y coma del final: printf("Seno de %d es %f \n",i,seno(float(i)) Y compilamos de nuevo. Nos saldr un mensaje informndonos del error sintctico y en que lnea se produce. Errores en tiempo de ejecucin. Tambin llamados errores lgicos o run-time error. Es un error que no es capaz de detectar el compilador porque no es un fallo en la sintaxis, pero que produce un error al ejecutar el programa por un fallo lgico. Por ejemplo, la divisin por cero, sintcticamente no es un error en el programa, pero al realizar la divisin, se produce un error en tiempo de ejecucin. En todo caso, si el compilador detecta la divisin por cero (por ejemplo al hacer int a=3/0;) puede emitir un warning. int a=0; int b=3; int c=b/a; Compilamos este programa y lo ejecutamos. El programa fallara y nos saldr un mensaje informndonos de ello. Tambin cabe la posibilidad de que un fallo en el cdigo del programa produzca un comportamiento no deseado, pero que este no resulte en un fallo fatal y el programa finalice bruscamente. 1.11. DEPURACIN DE LA APLICACIN. Para depurar un programa se debe ejecutar el depurador seguido del nombre del ejecutable (que debe haber sido creado con la opcin g) gdb prueba El depurador arranca y muestra un nuevo prompt (gdb) que espera a recibir los comandos adecuados para ejecutar el programa paso a paso o como se le indique. Los comandos que puede recibir este prompt se dividen en distintos grupos, mostrados por el comando (gdb) help Si se desea ver los comandos que pertenecen a cada grupo se debe escribir (p.ej. para ver los comandos que permiten gestionar la ejecucin del programa) (gdb) help [nombre grupo] (ejemplo: running ) Y para ver la ayuda de un comando en particular: (gdb) help [comando] Caben destacar por su utilidad los siguientes comandos pertenecientes a los grupos: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 22 Universidad Politcnica de Madrid -UPM Tabla 1-6. Comandos bsicos de gdb Grupo Comando Accin running run comienza la depuracin del programa step ejecuta un paso, entrando en funciones next ejecuta un paso, sin entrar en funciones finish termina la ejecucin del programa continue continua la ejecucin del programa, hasta el siguiente breakpoint data display [exp] muestra el contenido de la variable exp cada vez que el programa se para undisplay [exp] quita el comportamiento anterior print [exp] Muestra el contenido de exp breakpoint break [num_linea] inserta un punto de parada o Breakpoint en la lnea correspondiente clear [num_linea] Eliminan el breakpoint de la lnea correspondiente delete break Pregunta si se desea eliminar todos los breakpoints status info [opcion] Muestra informacin acerca de la opcin elegida, por ejemplo info break muestra los breakpoints. ninguno quit sale del debugger Realizar la depuracin del programa anterior, viendo el valor de las posibles variables, ejecutando paso a paso. 1.12. CREACIN DE UN SCRIPT Se puede crear un archivo de texto que sirva para ejecutar una serie de comandos consecutivos en el shell, en lo que se llama un script. Para ver un ejemplo se va a crear un script que muestre el nombre de la carpeta actual y a continuacin muestre el contenido de dicha carpeta, para termina ejecutando el programa prueba. Para ello creamos un archivo: vi miscript echo La carpeta actual es pwd echo Y contiene lo siguiente ls Si intentamos ejecutar el script, nos dir que no tiene permisos de ejecucin. Para eso realizamos el cambio: chmod a+x miscript Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 23 Universidad Politcnica de Madrid -UPM Debe de quedar claro que con un script no se tiene cdigo mquina, ni se compila, ni se inicia un proceso. Simplemente se la pasan al shell unos comandos en lotes. 1.13. DESARROLLO EN UN ENTORNO GRAFICO Existen distintas herramientas para el desarrollo C/C++ en linux, entre las que se podran destacar el Kdevelop, Anjuta, o Eclipse. Para el desarrollo de nuestra aplicacin hemos optado por Geany, que realmente es ms un editor de texto que un entorno de desarrollo, pero sin embargo tiene las caractersticas necesarias para nuestra aplicacin. Geany dispone de resaltado en colores del cdigo, y de gestion de la compilacin mediante Makefile, que permite mediante la pulsacin de F9 la invocacin automtica de Makefile (aunque el fichero Makefile lo debemos proveer nosotros), as como la gestin de los posibles errores de compilacin, con la posibilidad de saltar a la lnea del error simplemente haciendo doble click en el mensaje de error.
Figura 1-4. El editor Geany Si se desea instalar el editor, as como las libreras necesarias de Glut, es necesario: sudo apt-get install geany glutg3-dev
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 24 Universidad Politcnica de Madrid -UPM 1.14. EJERCICIO PRCTICO Se suministra en una carpeta un conjunto de ficheros de cdigo fuente, con algunas clases de C++, necesarias para el desarrollo del juego del Tenis, fundamentalmente las clases Mundo, Esfera, Plano, Raqueta, y la clase auxiliar Vector2D. Todas las clases estn completas, exceptuando la clase Mundo. #include "Vector2D.h" class Esfera { public: Esfera(); virtual ~Esfera();
Vector2D centro; Vector2D velocidad; float radio;
void Mueve(float t); void Dibuja(); };
#include "Esfera.h" #include "Vector2D.h" class Plano { public: bool Rebota(Esfera& e); bool Rebota(Plano& p); void Dibuja(); Plano(); virtual ~Plano();
#include "Plano.h" #include "Vector2D.h" class Raqueta : public Plano { public: void Mueve(float t); Raqueta(); virtual ~Raqueta();
Vector2D velocidad; };
class CMundo { public: void Init(); Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 25 Universidad Politcnica de Madrid -UPM CMundo(); virtual ~CMundo();
void InitGL(); void OnKeyboardDown(unsigned char key, int x, int y); void OnTimer(int value); void OnDraw(); }; Se solicita al alumno que complete la clase Mundo para obtener el juego del tenis funcional. Se debe escribir un Makefile para la construccin del ejecutable. 1.15. EJERCICIO PROPUESTO El alumno debe de completar el juego con alguna funcionalidad extra, como por ejemplo, que cada una de las raquetas sea capaz de disparar un disparo, que cuando impacta al oponente lo inmoviliza, o disminuye el tamao de su raqueta. Tambin se propone el desarrollo de cualquier otro juego de complejidad similar. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 27 Universidad Politcnica de Madrid -UPM 2 2 . .
INTRODUCCIN A LOS SISTEMAS DISTRIBUIDOS. COMUNICACIN POR SOCKETS
2.1. OBJETIVOS En el captulo anterior se ha desarrollado el juego bsico del tenis en el que dos jugadores, compartiendo el mismo teclado y el mismo monitor, cada uno con distintas teclas puede controlar su raqueta arriba y abajo para jugar la partida. El objetivo final es la consecucin del juego totalmente distribuido, es decir, cada jugador podr jugar en su propio ordenador, con su teclado y su monitor, y los dos ordenadores estarn conectados por la red. En este captulo se presenta una introduccin a los sistemas distribuidos, los servicios proporcionados en POSIX para el manejo de Sockets, que son los conectores necesarios (el recurso software) para la comunicacin por la red, y su uso en nuestra aplicacin. No pretende ser una gua exhaustiva de dichos servicios sino una descripcin prctica del uso ms sencillo de los mismos, y como integrarlos en nuestra aplicacin para conseguir nuestros objetivos. De hecho, en el curso del captulo se desarrolla una clase C++ que encapsula los servicios de Sockets, permitiendo al usuario un uso muy sencillo de los mismos que puede valer para numerosas aplicaciones, aunque obviamente no para todo. Como primera aproximacin al objetivo final se va a realizar en este captulo la retransmisin del partido de tenis por la red. Esto es, los dos jugadores van a seguir jugando en la misma mquina con el mismo teclado, pero sin embargo otro usuario desde otra mquina podr conectarse remotamente a travs de la red a la mquina y a Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 28 Universidad Politcnica de Madrid -UPM la aplicacin en la que juegan los jugadores (el servidor), y esta le enviara constantemente los datos necesarios para que la mquina remota (el cliente) pueda simplemente dibujar el estado actual de la partida. De esta forma lo que se permite es que los clientes sean meros espectadores de la partida. Inicialmente se plantea la solucin para un nico espectador, y finalmente se aborda la solucin para mltiples espectadores. No obstante esta ltima requerir para su correcto funcionamiento el uso de programacin concurrente (hilos) que se abordara en sucesivos captulos.
Figura 2-1. Objetivo del captulo: Retransmisin de la partida de tenis a ordenadores remotos conectados a travs de la red al servidor En sucesivos captulos se completar el desarrollo del juego distribuido haciendo que los jugadores puedan realmente jugar en dos mquinas distintas, que transmitirn los comandos de los jugadores por la misma red al servidor, para que este los ejecute sin necesidad de tener a dichos jugadores utilizando el mismo teclado fsico de la mquina en la que corre el servidor. 2.2. SISTEMA DISTRIBUIDO Llamaremos sistema distribuido a una solucin software cuya funcionalidad es repartida entre distintas mquinas, teniendo cada mquina su propio procesador (o propios procesadores), su propia memoria, y corriendo su propio sistema operativo. Adems, no es necesario que las mquinas sean iguales, ni ejecuten el mismo SO ni el mismo software. Las mquinas estarn interconectadas por una red que sirve para el intercambio de mensajes entre dichas mquinas.
RED Retransmisin partido N posibles clientes que se conectan al servidor para ver el partido Servidor, en el que juegan los dos jugadores con el mismo teclado Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 29 Universidad Politcnica de Madrid -UPM 2.3. SERVICIOS DE SOCKETS EN POSIX A continuacin se presenta el cdigo de un programa cliente y de un programa servidor, para describir breve y generalmente los servicios de sockets implicados. Este cdigo es prcticamente el ms bsico posible, sin comprobacin de errores. El funcionamiento ser como sigue: Primero se arranca el programa servidor, que inicializa el socket servidor y se queda a la espera de una conexin. A continuacin se debe lanzar el programa cliente que se conectar al servidor. Una vez que ambos estn conectados, el servidor enviara al cliente unos datos (una frase) que el cliente mostrar por pantalla, y a finalmente terminarn ambos programas. El funcionamiento en lneas generales queda representado en la siguiente figura:
Figura 2-2. Conexin sockets Servidor Cliente Se crea el socket de conexin Se le asigna una direccin y un puerto y se pone a la escucha El socket de conexin se queda bloqueado a la espera Aceptando una conexin socket() bind() listen() accept() Se crea el socket de conexin y comunicacin (es el mismo) Se conecta a la direccin del servidor socket() connect() Comunicacin Comunicacin send() recv() send() recv() TCP/IP Cierre shutdown() close() Cierre shutdown() close() Cuando el cliente se conecta al socket de conexin que esta Aceptando, este devuelve un socket de conexin que es con el que se realiza la comunicacin Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 30 Universidad Politcnica de Madrid -UPM 2.3.1 Programa cliente El cdigo del programa cliente bsico es el siguiente: //includes necesarios para los sockets #include <iostream> #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> #include <arpa/inet.h> #include <netinet/in.h>
#define INVALID_SOCKET -1
int main() { //declaracion de variables int socket_conn;//the socket used for the send-receive struct sockaddr_in server_address; char address[]="127.0.0.1"; int port=12000;
// Configuracion de la direccion IP de connexion al servidor server_address.sin_family = AF_INET; server_address.sin_addr.s_addr = inet_addr(address); server_address.sin_port = htons(port);
//creacion del socket socket_conn=socket(AF_INET, SOCK_STREAM,0);
//conexion int len= sizeof(server_address); connect(socket_conn,(struct sockaddr *) &server_address,len);
//comunicacion char cad[100]; int length=100; //read a maximum of 100 bytes
int r=recv(socket_conn,cad,length,0); std::cout<<"Rec: "<<r<<" contenido: "<<cad<<std::endl;
//cierre del socket shutdown(socket_conn, SHUT_RDWR); close(socket_conn); socket_conn=INVALID_SOCKET; return 1; } A continuacin se describe brevemente el programa: Las primeras lneas son algunos #includes necesarios para el manejo de servicios de sockets. En el caso de querer utilizar los sockets en Windows, el fichero de cabecera y la librera con la que hay que enlazar se podran establecer con las lneas: #include <winsock2.h> #pragma comment (lib, "ws2_32.lib") En las primeras lneas del main() se declaran las variables necesarias para el socket. int socket_conn;//the socket used for the send-receive struct sockaddr_in server_address; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 31 Universidad Politcnica de Madrid -UPM La primera lnea declara el descriptor del socket (de tipo entero) que se utiliza tanto para la conexin como para la comunicacin. La segunda declaracin declara una estructura de datos que sirve para almacenar la direccin IP y el nmero de puerto del servidor y la familia de protocolos que se utilizaran en la comunicacin. La asignacin de esta estructura a partir de la IP definida como una cadena de texto y el puerto definido como un entero se hace como sigue: char address[]="127.0.0.1"; int port=12000; server_address.sin_family = AF_INET; server_address.sin_addr.s_addr = inet_addr(address); server_address.sin_port = htons(port); Ntese que la IP que utilizaremos ser la 127.0.0.1. Esta IP es una IP especial que significa la mquina actual (direccin local). Realmente ejecutaremos nuestras 2 aplicaciones (cliente y servidor) en la misma mquina, utilizando la direccin local de la mquina. No obstante esto se puede cambiar. Para ejecutar el servidor en una mquina que tiene la IP 192.168.1.13 por ejemplo, basta poner dicha direccin en ambos programas, ejecutar el servidor en esa mquina, y el cliente en cualquier otra (que sea capaz de enrutar mensajes hacia esa IP). A continuacin se crea el socket, especificando la familia de protocolos (en este caso protocolo de Internet AF_INET) y el tipo de comunicacin que se quiere emplear (fiable=SOCK_STREAM, no fiable=SOCK_DGRAM). En nuestro caso utilizaremos siempre comunicacin fiable. //creacion del socket socket_conn=socket(AF_INET, SOCK_STREAM,0); Esta funcin generalmente no produce errores, aunque en algn caso podra hacerlo. Como regla general conviene comprobar su valor, que ser igual a -1 (INVALID_SOCKET) si la funcin ha fallado. A continuacin se intenta la conexin con el socket especificado en la direccin del servidor. //conexion int len= sizeof(server_address); connect(socket_conn,(struct sockaddr *) &server_address,len); Esta funcin connect() fallar si no esta el servidor preparado por algn motivo (lo que sucede muy a menudo). Por lo tanto es ms que conveniente comprobar el valor de retorno de connect() para actuar en consecuencia. Se podra hacer algo como: if(connect(socket_conn,(struct sockaddr *) &server_address,len)!=0) { std::cout<<"Client could not connect"<<std::endl; return -1; } Si la conexin se realiza correctamente, el socket ya esta preparado para enviar y recibir informacin. En este caso hemos decidido que va a ser el servidor el que enva datos al cliente. Esto es un convenio entre el cliente y el servidor, que adopta el programador cuando disea e implementa el sistema. Como el cliente va a recibir informacin, utilizamos la funcin de recepcin. En esta funcin, se le suministra un buffer en el que guarda la informacin y el nmero de bytes mximo que se espera recibir. La funcin recv() se bloquea hasta que el servidor enve alguna informacin. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 32 Universidad Politcnica de Madrid -UPM Dicha informacin puede ser menor que el tamao mximo suministrado. El valor de retorno de la funcin recv() es el numero de bytes recibidos. //comunicacion char cad[100]; int length=100; //read a maximum of 100 bytes
int r=recv(socket_conn,cad,length,0); std::cout<<"Rec: "<<r<<" contenido: "<<cad<<std::endl; Por ultimo se cierra la comunicacin y se cierra el socket. shutdown(socket_conn, SHUT_RDWR); close(socket_conn); socket_conn=INVALID_SOCKET;
2.3.2 Servidor El cdigo del programa servidor es algo ms complejo, ya que debe realizar ms tareas. La principal caracterstica es que se utilizan 2 sockets diferentes, uno para la conexin y otro para la comunicacin. El servidor comienza enlazando el socket de conexin a una direccin IP y un puerto (siendo la IP la de la mquina en la que corre el servidor), escuchando en ese puerto y quedando a la espera Accept de una conexin., en estado de bloqueo. Cuando el cliente se conecta, el Accept se desbloquea y devuelve un nuevo socket, que es por el que realmente se envan y reciben datos. #include <sys/time.h> #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> #include <arpa/inet.h> #include <netinet/in.h> #include <iostream> #define INVALID_SOCKET -1
int main() { int socket_conn=INVALID_SOCKET;//used for communication int socket_server=INVALID_SOCKET;//used for connection struct sockaddr_in server_address; struct sockaddr_in client_address;
// Configuracion de la direccion del servidor char address[]="127.0.0.1"; int port=12000; server_address.sin_family = AF_INET; server_address.sin_addr.s_addr = inet_addr(address); server_address.sin_port = htons(port);
//creacion del socket servidor y escucha socket_server = socket(AF_INET, SOCK_STREAM, 0); int len = sizeof(server_address);
int on=1; //configuracion del socket para reusar direcciones setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&on,sizeof(on)); Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 33 Universidad Politcnica de Madrid -UPM
//escucha bind(socket_server,(struct sockaddr *) &server_address,len); // Damos como maximo 5 puertos de conexion. listen(socket_server,5);
//aceptacion de cliente (bloquea hasta la conexion) unsigned int leng = sizeof(client_address); socket_conn = accept(socket_server, (struct sockaddr *)&client_address, &leng);
//notese que el envio se hace por el socket de communicacion char cad[]="Hola Mundo"; int length=sizeof(cad); send(socket_conn, cad, length,0);
//cierre de los dos sockets, el servidor y el de comunicacion
return 1; } Hasta la creacin del socket del servidor, el programa es similar al cliente, quitando la excepcin de que se declaran los 2 sockets, el de conexin y el de comunicacin. La primera diferencia son las lneas: //configuracion del socket para reusar direcciones int on=1; setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&on,sizeof(on)); Estas lneas se utilizan para que el servidor sea capaz de re-usar la direccin y el puerto que han quedado abiertos sin ser cerrados correctamente en una ejecucin anterior. Cuando esto sucede, el sistema operativo deja la direccin del socket reservada y por tanto un intento de utilizarla para un servidor acaba en fallo. Con estas lneas podemos configurar y habilitar que se re-usen las direcciones previas. La segunda diferencia es que en vez de intentar la conexin con connect(), el servidor debe establecer primero en que direccin va a estar escuchando su socket de conexin, lo que se establece con las lneas: int len = sizeof(server_address); bind(socket_server,(struct sockaddr *) &server_address,len); // Damos como maximo una cola de 5 conexiones. listen(socket_server,5); La funcin bind() enlaza el socket de conexin con la IP y el puerto establecidos anteriormente. Esta funcin tambin es susceptible de fallo. El fallo ms comn es cuando se intenta enlazar el socket con una direccin y puerto que ya estn ocupados por otro socket. En este caso la funcin devolver -1, indicando el error. A veces es posible que si no se cierra correctamente un socket (por ejemplo, si el programa finaliza bruscamente), el SO piense que dicho puerto esta ocupado, y al Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 34 Universidad Politcnica de Madrid -UPM volver a ejecutar el programa, el bind() falle, no teniendo sentido continuar la ejecucin. La gestin bsica de este error podra ser: if(0!=bind(socket_server,(struct sockaddr *) &server_address,len)) { std::cout<<Fallo en el Bind()<<std::endl; return -1; } La funcin listen() permite definir cuantas peticiones de conexin al servidor sern encoladas por el sistema. Ntese que esto no significa que realmente se atiendan las peticiones de conexin. Es el usuario a travs de la funcin accept() el que acepta una conexin. El numero de conexiones depender de cuantas veces ejecute el programa dicho accept(). //aceptacion de cliente (bloquea hasta la conexion) unsigned int leng = sizeof(client_address); socket_conn = accept(socket_server, (struct sockaddr *)&client_address, &leng); Lo ms importante del accept() es que en su modo normal bloquea el programa hasta que realmente se realiza la conexin por parte del cliente. A esta funcin se le suministra el socket de conexin, y devuelve el socket que realmente se utilizar para la comunicacin. Si algo falla en la conexin, la funcin devolver -1, lo que corresponde a nuestra definicin de socket invalido INVALID_SOCKET, lo que podemos comprobar: if(socket_conn==INVALID_SOCKET) { std::cout<<Error en el accept<<std::endl; return -1; } Una vez que se ha realizado la conexin, la comunicacin se hace por el nuevo socket, utilizando las mismas funciones de envo y recepcin que se podran usar en el cliente. Como en el ejemplo actual, por convenio hemos establecido que ser el servidor el que enva un mensaje al cliente, el cdigo siguiente enva el mensaje Hola Mundo por el socket: char cad[]="Hola Mundo"; int length=sizeof(cad); //notese que el envio se hace por el socket de communicacion send(socket_conn, cad, length,0); La funcin send() tambin puede fallar, si el socket no esta correctamente conectado (se ha desconectado el cliente por ejemplo). La funcin devuelve el nmero de bytes enviados correctamente o -1 en caso de error. Tpicamente, si la conexin es buena, la funcin devolver como retorno un valor igual a length, aunque tambin es posible que no consiga enviar todos los datos que se le ha solicitado. Una solucin completa debe contemplar esta posibilidad y reenviar los datos que no han sido enviados. No obstante y por simplicidad, realizamos ahora una gestin sencilla de este posible error: if(lenght!=send(socket_conn, cad, length,0)) { std::cout<<Fallo en el send()<<std::endl; return -1; } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 35 Universidad Politcnica de Madrid -UPM El cierre de los sockets se realiza de la misma manera que en el cliente, exceptuando que se deben cerrar correctamente los 2 sockets, el de conexin y el de comunicacin. La salida por pantalla al ejecutar las aplicaciones (primero arrancar el servidor y luego el cliente) debera ser (en el lado del cliente): Rec: 11 contenido: Hola Mundo Ntese que los bytes recibidos son 11 porque incluyen el carcter nulo \0 de final de la cadena 2.4. ENCAPSULACIN DE UN SOCKET EN UNA CLASE C++ La API vista en el apartado anterior es C, y aparte de las funciones descritas, tiene otras funcionalidades que no se vern en este curso. Es una prctica habitual cuando se puede desarrollar en C++ encapsular la funcionalidad de la API en una clase o conjunto de clases que oculten parcialmente los detalles ms complejos, facilitando la tarea al usuario. As, por ejemplo, las Microsoft Fundation Classes (MFC) tienen sus clases CSocket y CAsyncSocket para estas tareas. Tambin se pueden encontrar en Internet numerosos envoltorios (wrappers) de C++ para los sockets en linux. Vamos a desarrollar una clase C++ que encapsule la funcionalidad vista en los programas anteriores. Es comn encontrar, bajo una perspectiva estricta de Programacin Orientada a Objetos (POO) que el cliente y servidor se implementan en clases separadas. No obstante, se adopta ahora un enfoque ms sencillo con una sola clase, que utiliza diferentes mtodos en caso del cliente y del servidor. EJERCICIO: Desarrollar la clase Socket, de acuerdo con la cabecera siguiente, para que encapsule los detalles de implementacin anteriores. //includes necesarios class Socket { public: Socket(); virtual ~Socket();
// 0 en caso de exito y -1 en caso de error int Connect(char ip[],int port); //para el cliente int InitServer(char ip[],int port);//para el servidor
//devuelve un socket, el empleado realmente para la comunicacion //el socket devuelto podria ser invalido si el accept falla Socket Accept();//para el servidor void Close();//para ambos
//-1 en caso de error, // numero de bytes enviados o recibidos en caso de exito int Send(char cad[],int length); int Receive(char cad[],int length);
private: int sock; }; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 36 Universidad Politcnica de Madrid -UPM El cdigo del servidor se ver simplificado a: #include <iostream> #include "Socket.h"
int main() { Socket servidor; servidor.InitServer("127.0.0.1",12000);
Socket conn=servidor.Accept();
char cad[]="Mensaje"; int length=sizeof(cad);
conn.Send(cad,length);
conn.Close(); servidor.Close();
return 1; } Y el cdigo del cliente: #include "Socket.h" #include <iostream>
int main() { Socket client; client.Connect("127.0.0.1",12000);
char cad[1000]; int length=1000;
int r=client.Receive(cad,length); std::cout<<"Recibidos: "<<r<<" contenido: "<<cad<<std::endl;
client.Close();
return 1; }
2.4.1 Envo de mltiples mensajes Obviamente, la comunicacin no necesariamente se reduce al envo de un mensaje. Supngase que el servidor lo que quiere enviar es un mensaje 10 veces. Aunque el mensaje podra ser distinto cada vez, para realizar la prueba podemos enviar 10 veces el mismo saludo, quedando el cdigo del servidor como sigue:
char cad[]="Hola Mundo"; int length=sizeof(cad);
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 37 Universidad Politcnica de Madrid -UPM for(int i=0;i<10;i++) { int err=conn.Send(cad,length); if(err!=length) { std::cout<<"Send error"<<std::endl; break; } } En el lado del cliente podramos conocer que nos van a enviar 10 mensajes y realizar un bucle similar: char cad[1000]; int length=1000; for(int i=0;i<10;i++) { int r=client.Receive(cad,length); if(r<0) { std::cout<<Error en la recepcion<<std::endl; break; } else std::cout<<"Rec: "<<r<<" contenido: "<<cad<<std::endl; } No obstante al ejecutar estos programas podramos obtener una salida como la siguiente en el cliente: Rec: 11 Hola Mundo Rec: 33 Hola Mundo Rec: 66 Hola Mundo Error en la recepcion Esto se debe a que el servidor enva seguido y todo lo rpido que le permite el bucle for los mensajes, que llegan al cliente. Si el cliente solicita recibir un mensaje de una longitud mxima de 1000 caracteres puede leer efectivamente ms de un mensaje enviado por el servidor. Al sacarlos por pantalla no aparece Hola Mundo Hola Mundo porque hay un terminador de cadena \0 entre ambos. En este punto caben dos alternativa como posibles soluciones a este problema: 1. El servidor enva datos mucho ms despacio de lo que recibe el cliente. En este caso no se suele presentar ningn problema. Supngase que el servidor espera 1 segundo antes de enviar el siguiente mensaje. El cliente ir recibiendo los mensajes por separado sin problemas: char cad[]="Hola Mundo"; int length=sizeof(cad);
for(int i=0;i<10;i++) { usleep(1000000);//espera 1 segundo int err=conn.Send(cad,length); if(err!=length) { std::cout<<"Send error"<<std::endl; break; } } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 38 Universidad Politcnica de Madrid -UPM 2. Existe un convenio entre el cliente y el servidor que especifica como son los mensajes, para que el cliente sepa que es lo que va a recibir y como lo tiene que interpretar. Este convenio puede consistir en especificar una longitud fija para los mensajes, o en establecer un carcter terminador de mensaje. En el caso anterior podramos haber recorrido los mensajes buscando los caracteres nulos \0 que nos separaran cada mensaje. Si consideramos los mensajes de longitud fija el cdigo del servidor podra ser: //definimos los mensajes de 100 bytes siempre char cad[100]="Hola Mundo"; int length=sizeof(cad); //length=100
for(int i=0;i<10;i++) { int err=conn.Send(cad,length); //enviamos 100 bytes
if(err!=length) { std::cout<<"Send error"<<std::endl; break; } } Y el cdigo del cliente quedara: char cad[100]; int length=100; //vamos a recibir mensajes de 100 bytes for(int i=0;i<10;i++) { int r=client.Receive(cad,length); if(r<0) { std::cout<<Error en la recepcion<<std::endl; break; } else std::cout<<"Rec: "<<r<<" contenido: "<<cad<<std::endl; } Ntese que aunque se necesitan solo unos pocos bytes para enviar Hola Mundo, realmente se envan muchos ms. Es un enfoque bastante ineficiente, pero muy simple. Se supone que se van a enviar distintos mensajes y que nunca sern ms largos que 100 caracteres. La salida por pantalla es correcta porque se incluye el carcter final de cadena \0, por lo que realmente no se imprimen los 100 caracteres existentes en el buffer. 2.4.2 Conexiones mltiples. Un servidor puede aceptar ms de una conexin, de tal forma que puede permitir ejecutar varias veces seguidas el mismo cliente, o incluso a distintos clientes desde distintas mquinas. Las conexiones pueden incluso ser simultneas, es decir se puede permitir conectarse a un cliente y cuando termina de comunicar con el, permitir la conexin de otro cliente, o se puede permitir la conexin y comunicacin simultnea con varios clientes. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 39 Universidad Politcnica de Madrid -UPM Un cliente solo puede comunicarse con un servidor. 2.4.2.1. Conexiones secuenciales Una primera opcin es que el servidor atienda secuencialmente las conexiones de los distintos clientes, esto es, se conecta un cliente, se comunica con el y vuelve a esperar aceptando en el accept() a un nuevo cliente.
Figura 2-3. Servidor que permite mltiples conexiones secuenciales de clientes El cliente permanecera inalterado, y el cdigo del servidor quedara como sigue: Servidor Cliente Se crea el socket de conexin Se le asigna una direccin y un puerto y se pone a la escucha El socket de conexin se queda bloqueado a la espera Aceptando una conexin Comunicacin Cierre del socket de comunicacion Cierre del socket de conexion Seguir aceptando clientes? SI NO Comunicacin Conexin Se crea el socket de conexin y comunicacin Cierre Socket de conexion Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 40 Universidad Politcnica de Madrid -UPM #include <iostream> #include "Socket.h"
int main() { Socket servidor; servidor.InitServer("127.0.0.1",12000);
while(1) { Socket conn=servidor.Accept();
//comunicacion, en este caso envio de 1 unico mensaje char cad[]="Hola mundo"; int length=sizeof(cad);
conn.Send(cad,length);
conn.Close(); } servidor.Close();
return 1; } La funcin listen() toma sentido en este contexto, ya que permite poner a la cola peticiones de conexiones de varios clientes que intentan la conexin mientras el servidor esta comunicando con el cliente actual. Cuando el servidor vuelve al accept() se atienden dichas peticiones de conexin. 2.4.2.2. Conexiones simultneas. Es posible que el servidor acepte la conexin de varios clientes y enve datos a todos ellos, manteniendo la conexin activa con todos simultneamente. Para ello y dado que aun no estamos utilizando programacin concurrente, primero se realiza el accept() de tantos clientes como se vayan a conectar (el servidor debe conocer dicho numero). Hay que recordar que el accept() bloquea hasta que se conecta un cliente, por lo tanto hasta que no se conecten tantos clientes como accept() se intenten, el programa no podr continuar. Como cada conexin devuelve un socket diferente a travs del accept(), estos sockets se pueden almacenar en un vector, y manejar todas las conexiones en el servidor a travs de dicho vector. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 41 Universidad Politcnica de Madrid -UPM
Figura 2-4. Comunicacin simultanea con varios clientes El cdigo resultante en el servidor podra ser: #include <iostream> #include "Socket.h" int main() { Socket servidor; servidor.InitServer("127.0.0.1",12000); Socket conexiones[5]; for(i=0;i<5;i++) conexiones[i]=servidor.Accept();
//comunicacion, en este caso envio de 1 unico mensaje //se envia a los 5 clientes char cad[]="Hola mundo"; int length=sizeof(cad);
for(i=0;i<5;i++) conexiones[i].Send(cad,length); for(i=0;i<5;i++) conexiones[i].Close(); servidor.Close(); return 1; } ClienteN Comunicacion Conexion Se crea el socket de conexin y comunicacin Cierre Servidor Cliente1 Se crea el socket de conexin Se le asigna una direccin y un puerto y se pone a la escucha El socket de conexin se queda bloqueado a la espera Aceptando una conexin Comunicacin N Cierre de los N sockets de comunicacin y del de conexin Seguir aceptando clientes? SI NO Comunicacin Conexin Se crea el socket de conexin y comunicacin Cierre Comunicacin 1 Socket de conexinN Socket de conexin 1 Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 42 Universidad Politcnica de Madrid -UPM 2.5. ESTRUCTURA DE FICHEROS Ahora que se ha visto como realizar el envo de informacin por la red, y se dispone de una clase que encapsula la funcionalidad de los sockets se va a proceder a comenzar el desarrollo de la aplicacin distribuida del juego del tenis. Debe quedar claro que solo hay que desarrollar dos aplicaciones, la aplicacin servidor y la aplicacin cliente. La aplicacin servidor se ejecutar una vez, pero la aplicacin cliente (el mismo binario) puede ser ejecutado mltiples veces y en distintas mquinas. Se parte de la aplicacin desarrollada en el tema anterior, que constituye el juego del tenis (los dos jugadores en la misma mquina), cuyos ficheros se encuentran todos en la misma carpeta y los cuales son: Esfera.h y Esfera.cpp (la clase Esfera) Plano.h y Plano.cpp (la clase Plano) Raqueta.h y Raqueta.cpp (la clase Raqueta) Vector2D.h y Vector2D.cpp (la clase Vector2D) Mundo.h y Mundo.cpp (la clase Mundo) Tenis.cpp (el fichero principal con el main() ) Makefile La primera intencin podra ser duplicar esta carpeta para realizar las modificaciones necesarias en cada una de ellas y transformarlas en el servidor y el cliente. No obstante, esto implicara que habra mucho cdigo idntico duplicado en dos sitios. Por ejemplo, la clase Plano ser exactamente igual en el cliente y en el servidor, su parametrizacin es igual, se dibuja igual. Por tanto no es necesario (de hecho es contraproducente) que el cdigo este repetido. Se pueden desarrollar ambos programas, el cliente y el servidor compartiendo uno o varios archivos de cdigo fuente. Si se analiza la funcionalidad del servidor y del cliente se llega a la conclusin que ambas aplicaciones son iguales, exceptuando: El servidor atiende el teclado, cambiando la velocidad de las raquetas, pero los clientes no, son solo espectadores. Esto se hace en la funcin CMundo::OnKeyboardDown() El servidor cambia las posiciones de los objetos (anima), realiza los clculos de las colisiones. El cliente no tiene que mover los objetos (podra moverlos de forma diferente al servidor), solo tiene que recibir la informacin del servidor de donde estn los objetos en cada instante de tiempo. El cambio de posicin de los objetos se hace en la funcin CMundo::OnTimer(). Como se ve, la nica clase que va a tener diferencias entre el servidor y el cliente es la clase CMundo. Por tanto, se propone nicamente duplicar este archivo con dos nombres diferentes (aunque el nombre de la clase se puede mantener.) Tambin es necesario duplicar el archivo en el que se encuentra el main(), ya que es Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 43 Universidad Politcnica de Madrid -UPM el que instancia la clase CMundo, y en funcin de si es el servidor o el cliente, necesitara hacer un #include a MundoServidor.h o a MundoCliente.h Esfera.h y Esfera.cpp (la clase Esfera) Plano.h y Plano.cpp (la clase Plano) Raqueta.h y Raqueta.cpp (la clase Raqueta) Vector2D.h y Vector2D.cpp (la clase Vector2D) MundoServidor.h y MundoServidor.cpp (la clase Mundo para el servidor) MundoCliente.h y MundoCliente.cpp (la clase Mundo para el cliente) servidor.cpp (el fichero principal con el main(), para el servidor ) cliente.cpp (el fichero principal con el main(), para el cliente ) Makefile En el Makefile se especifican como se construyen las dos aplicaciones diferentes: CC=g++ CFLAGS= -g LIBS= -lm -lglut OBJS=Esfera.o Plano.o Raqueta.o Vector2D.o Socket.o HEADERS=Esfera.h MundoCliente.h MundoServidor.h Plano.h Raqueta.h Vector2D.h
Socket.o: Socket.cpp $(HEADERS) $(CC) $(CFLAGS) -c Socket.cpp MundoCliente.o: MundoCliente.cpp $(HEADERS) $(CC) $(CFLAGS) -c MundoCliente.cpp MundoServidor.o: MundoServidor.cpp $(HEADERS) $(CC) $(CFLAGS) -c MundoServidor.cpp Esfera.o: Esfera.cpp $(HEADERS) $(CC) $(CFLAGS) -c Esfera.cpp Plano.o: Plano.cpp $(HEADERS) $(CC) $(CFLAGS) -c Plano.cpp Raqueta.o: Raqueta.cpp $(HEADERS) $(CC) $(CFLAGS) -c Raqueta.cpp Vector2D.o: Vector2D.cpp $(HEADERS) $(CC) $(CFLAGS) -c Vector2D.cpp servidor.o: servidor.cpp $(HEADERS) $(CC) $(CFLAGS) -c servidor.cpp cliente.o: cliente.cpp $(HEADERS) $(CC) $(CFLAGS) -c cliente.cpp clean: rm -f *.o cliente servidor Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 44 Universidad Politcnica de Madrid -UPM Con este Makefile la simple invocacin make construye tanto el servidor como el cliente 2.6. TRANSMITIENDO EL PARTIDO DE TENIS Inicialmente vamos a realizar el envo de los datos necesarios del servidor a un nico cliente. Para ello se deben de seguir los siguientes pasos: 2.6.1 Conexin Aadir el Socket de conexin y el de comunicacin en la clase Mundo del servidor: Socket server; Socket conn; Aadir el Socket en la clase Mundo del cliente Socket client; En la funcin de inicializacin del juego en el servidor se establece la direccin IP y el puerto del servidor y se espera la aceptacin de un cliente: //en el fichero MundoServidor void CMundo::Init() { //inicializacion de la pantalla, coordenadas, etc
server.InitServer("127.0.0.1",12000); conn=server1.Accept(); } Ntese en este punto que si se compila y ejecuta el servidor no se muestra nada por pantalla. Sencillamente el programa esta bloqueado a la espera de la conexin y ni siquiera ha creado aun la ventana grafica. No obstante el accept se podra realizar ms tarde, despus de haber creado la ventana. El cliente tambin realiza en su funcin init() la conexin del socket: //del fichero MundoCliente void CMundo::Init() { //inicializacion de la pantalla, coordenadas, etc
client.Connect("127.0.0.1",12000); }
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 45 Universidad Politcnica de Madrid -UPM 2.6.2 Envo de datos Lo primero es necesario establecer cuales son los datos que es necesario que enve el servidor al cliente. Dado que la pantalla es en su mayora esttica, las variables que es necesario transmitir podran ser: Coordenadas (x, y) de la pelota Coordenadas (x1, y1, x2, y2) de la raqueta del jugador 1. Coordenadas (x1, y1, x2, y2) de la raqueta del jugador 2. Estos datos deben de ser enviados por el servidor cada vez que se produce un cambio en los mismos, es decir, en cada temporizacin del timer. Cmo se envan datos numricos? Aunque una solucin ms evolucionada se presentara en un tema posterior, una primera solucin sencilla consiste en escribir (sprintf()) estos valores numricos en una cadena de texto y enviar dicha cadena de texto. EJERCICIO: 1. Realizar el envo de los datos por el socket de comunicacin en el servidor (MundoServidor), en la funcin CMundo::OnTimer(), al final de la misma, manteniendo el cdigo existente encargado de realizar la animacin y lgica del juego. 2. Eliminar el cdigo de la funcin CMundo::OnTimer() de MundoCliente y sustituirlo por la recepcin del mensaje del servidor y la extraccin de los valores numricos.
2.7. EJERCICIOS PROPUESTOS Realizar la retransmisin del juego a un numero fijo de clientes, por ejemplo 3 Implementar los conceptos desarrollados en este tema en un juego de complejidad similar.
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 47 Universidad Politcnica de Madrid -UPM 3 3 . .
COMUNICACIONES Y CONCURRENCIA 3.1. INTRODUCCIN En el captulo anterior hemos concluido con dos programas, un servidor y un cliente, en el que el servidor enviaba los datos de la partida de tenis de forma continua al cliente. De hecho, tambin podamos permitir que se conectaran varios clientes y despus (una vez conectados todos los clientes, con lo que se tenia que conocer su numero) enviar los datos a todos los clientes. Pero aun no podemos permitir que los clientes espectadores se conecten y desconecten cuando quieran, o que los jugadores puedan efectivamente jugar de forma remota. Tal como esta planteado el programa, esto no es posible hacerlo con programacin convencional (secuencial). Analizaremos en este captulo el porque y veremos la solucin a dichos problemas. Comenzamos analizando un sencillo ejemplo. Supngase que se esta diseando un controlador de una mquina, que se plasma finalmente en un regulador que podra tener el siguiente aspecto (en pseudocdigo): void main() { float referencia=3.0f; float K=1.2f; while(1) { float medida=GetValorSensor(); float error=referencia-medida; float comando=K*error;//regulador proporcional EnviaComando(comando); } } Donde las funciones GetValorSensor() y EnviaComando() realizaran la interfaz correspondiente con el hardware de la mquina. Obviamente el programa se tiene que ejecutar de forma continua, recalculando en cada pasada el nuevo error y Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 48 Universidad Politcnica de Madrid -UPM enviando un comando nuevo. El programa anterior utiliza una referencia (el punto al que se quiere llevar el sistema) fija. Supngase que ahora se desea que el usuario sea capaz de introducir por teclado dicha referencia tantas veces como quiera (para llevar la mquina a distintos puntos) y que se programa de la siguiente forma: void main() { float referencia=3.0f; float K=1.2f; while(1) { printf("Introduzca referencia: "); scanf("%f",&referencia); float medida=GetValorSensor(); float error=referencia-medida; float comando=K*error; EnviaComando(comando); } } El efecto conseguido es que el programa se queda parado en el scanf() esperando a la entrada del usuario. Cuando el usuario teclea un valor, se calcula y enva un comando a la mquina y el programa se vuelve a quedar parado en el scanf(). Si el usuario no teclea una nueva referencia, la mquina sigue funcionando con el comando anterior de forma indefinida. Obviamente, la solucin anterior no es valida. Tenemos dos tareas diferentes: la ejecucin de forma continua del control y la interfaz con el usuario. Dichas tareas tienen que ejecutarse de forma paralela a la vez. No podemos dejar de ejecutar el control por el hecho de que el usuario este tecleando una referencia, ni podemos inhabilitar al usuario de teclear una referencia por el hecho de que se este ejecutando el control de forma continua. La solucin es utilizar programacin concurrente. En el ejemplo anterior se podra lanzar un hilo dedicado a la gestin de la entrada del usuario mientras que el hilo principal ejecuta el control. El programa en pseudo cdigo podra quedar as: float referencia=0.0f;//variable global void hilo_usuario() { while(1) { printf("Introduzca referencia: "); scanf("%f",&referencia); } } void main() { float K=1.2f; crear_hilo ( hilo_usuario ); while(1) { float medida=GetValorSensor(); float error=referencia-medida; float comando=K*error; EnviaComando(comando); } } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 49 Universidad Politcnica de Madrid -UPM Ntese como se ha puesto la variable referencia como global, para que ambos hilos tengan acceso a la misma. Los hilos comunican informacin entre ellos a travs de memoria global de la aplicacin. 3.2. REQUISITOS Vamos a resumir las funcionalidades que nos quedan por implementar en nuestro sistema distribuido: Queremos permitir que los clientes se puedan conectar en el instante que quieran. El servidor no debe quedar bloqueado por esperar a que los clientes se conecten. Queremos permitir cualquier nmero de clientes espectadores. De dichos espectadores, nicamente los dos primeros podrn efectivamente controlar las raquetas. Los dos primeros clientes que se conecten podrn controlar las raquetas, el primero de ellos con las teclas w y s y el segundo con las teclas l y o. El servidor debe de gestionar adecuadamente las desconexiones de los clientes. 3.3. FUNCIONAMIENTO DE GLUT El funcionamiento bsico de la librera GLUT se plasma en la funcin glutMainLoop(), que es invocada desde el main(): //los callbacks void OnDraw(void); void OnTimer(int value); void OnKeyboardDown(unsigned char key, int x, int y);
int main(int argc,char* argv[]) { //Inicializar el gestor de ventanas GLUT //y crear la ventana glutInit(&argc, argv); glutInitWindowSize(800,600); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH); glutCreateWindow("ClienteTenis");
//Registrar los callbacks glutDisplayFunc(OnDraw); glutTimerFunc(25,OnTimer,0); glutKeyboardFunc(OnKeyboardDown);
//pasarle el control a GLUT,que llamara a los callbacks glutMainLoop();
return 0; } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 50 Universidad Politcnica de Madrid -UPM Dicha funcin contiene en su interior un bucle continuo (en caso contrario terminara la funcin main() y terminara el programa). Dicho bucle continuo se podra representar a nivel conceptual como: void glutMainLoop() { while(1) { if(pulsacion_teclado) OnKeyBoardDown(tecla); //la funcion del usuario if(hay_que_dibujar) OnDraw(); //la funcion del usuario if(tiempo_temporizador) OnTimer();//la funcion del usuario } } Por lo tanto, si se introduce alguna funcin que bloquee la secuencia continua de ejecucin, la aplicacin se vera bloqueada por completo. Por ejemplo, supngase que se ubica un scanf() en la funcin CMundo::OnTimer() para cambiar el radio de la pelota: void CMundo::OnTimer(int value) { printf("Introduzca el radio: "); scanf("%f",&esfera.radio);
El resultado final es la aplicacin bloqueada. 3.3.1 Lanzando un hilo Podramos conseguir el anterior objetivo, mediante el uso de un hilo, de la siguiente forma: void* hilo_usuario(void* d) { CMundo* p=(CMundo*) d; while(1) { printf("Introduzca el radio: "); scanf("%f",&p->esfera.radio); } } void CMundo::Init() { //inicializaciones varias
pthread_t thid; pthread_create(&thid,NULL,hilo_usuario,this); } En este caso, la esfera esta contenida dentro de la clase CMundo, sin embargo, el hilo es una funcin global, no es una funcin de la clase CMundo. Para conseguir el Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 51 Universidad Politcnica de Madrid -UPM acceso del hilo al objeto mundo, lo que se puede hacer es pasarle un puntero al mismo aprovechando el cuarto parmetro de la funcin pthread_create(). El hilo se encargara a su vez de hacer el cast correspondiente para poder acceder a los miembros de la clase CMundo. 3.4. ESTRUCTURA DEL SERVIDOR Se ha visto en los requisitos que es necesario realizar distintas tareas, de forma simultanea: El hilo principal del servidor se encargara de realizar la animacin de la escena (a travs de la funcin OnTimer), del dibujo y de enviar los datos por los sockets a los clientes. Como el envo no es bloqueante, no es necesario crear un hilo para esta tarea. La aceptacin de nuevos clientes si que es bloqueante. Siempre se tiene que estar ejecutando el accept() si queremos que los clientes puedan conectarse y desconectarse cuando quieran. Por lo tanto es necesario un hilo para esta tarea. Para que los clientes remotos puedan efectivamente jugar de forma distribuida, es necesario que enven informacin al servidor. Cada vez que se pulse una tecla, enviaran dicha tecla al servidor. El servidor debe de estar esperando a dicho mensaje. El problema es que la recepcin de mensajes, en principio tambin es bloqueante, por lo que el programa queda bloqueado hasta que se recibe dicho mensaje. La solucin es implementar un hilo para cada uno de los dos jugadores que este a la espera de dichos mensajes. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 52 Universidad Politcnica de Madrid -UPM
Figura 3-1. Estructura del servidor Ntese adems que las frecuencias a las que funcionan los distintos hilos son muy variables. El hilo principal ejecuta cada 25 milisegundos, aproximadamente. Sin embargo el hilo de aceptacin de nuevos clientes ejecuta una iteracin del bucle cada vez que se conecta un nuevo cliente, lo que puede tardar de forma variable desde pocos milisegundos a infinito tiempo. Los hilos de recepcin de los comandos de los jugadores funcionan a una frecuencia variable que coincide con las pulsaciones de teclado de los jugadores. 3.5. MLTIPLES CONEXIONES SIMULTANEAS Para permitir la conexin simultanea de mltiples clientes, es necesario mantener un socket por cada uno de dichos clientes. Para tal efecto declaramos en la clase CMundo (del fichero MundoServidor.h) un vector de la STL de objetos de la clase Socket. Usamos un vector STL porque nos permite de forma cmoda aadir nuevos objetos, quitar elementos y recorrerlo de forma sencilla. Tambin aadimos un mtodo a CMundo denominado GestionaConexiones(), que se encargara de realizar dicha gestin. class CMundo { public:
Programa servidor //hilo principal
OnTimer() { //tareas //animacion
//envio //datos } //hilo de //aceptacion de //nuevos clientes
while(1) { //accept() } //hilo de //recepcion de //comandos del //jugador1
while(1) { //recv() } //hilo de //recepcion de //comandos del //jugador2
while(1) { //recv() }
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 53 Universidad Politcnica de Madrid -UPM
}; A continuacin lanzamos un hilo denominado hilo_conexiones(), y de forma similar a como hacamos anteriormente, pasamos un puntero al objeto actual (this) a dicho hilo. Como es interesante manejarnos dentro de la clase mundo, la nica tarea que tiene que hacer la funcin hilo_conexiones()es invocar al mtodo GestionaConexiones(). Dicho mtodo entrara en un bucle infinito en el que se repite un accept(). Cada vez que se conecte un cliente, se le aade al vector de clientes conectados. void* hilo_conexiones(void* d) { CMundo* p=(CMundo*) d; p->GestionaConexiones(); } void CMundo::GestionaConexiones() { while(1) { Socket s=servidor.Accept(); conexiones.push_back(s); } } void CMundo::Init() { //inicializacion datos servidor.InitServer("127.0.0.1",12000); pthread_t thid_hilo_conexiones; pthread_create(&thid_hilo_conexiones,NULL,hilo_conexiones,this); }
3.6. MOSTRAR LOS CLIENTES CONECTADOS Una ampliacin interesante al apartado anterior seria mostrar en la ventana los clientes conectados y sus nombres, aparte de los puntos de los dos jugadores. Para ello aadimos un nuevo vector a la clase CMundo del servidor. Tambin transformamos las variables de los puntos de los jugadores en un vector: class CMundo { public:
int puntos[2]; }; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 54 Universidad Politcnica de Madrid -UPM Cada vez que se conecte un cliente nuevo nos deber enviar su nombre, para aadirlo a nuestro vector. Por lo tanto segn se conecta un cliente, esperamos con un Receive() dicho mensaje con el nombre. void CMundo::GestionaConexiones() { while(1) { Socket s=servidor.Accept(); char cad[100]; s.Receive(cad,100); nombres.push_back(cad); conexiones.push_back(s); } } Los nombres de los clientes pueden ser mostrados por pantalla: void CMundo::OnDraw() {
char cad[100]; sprintf(cad,"Servidor"); print(cad,300,10,1,0,1); int i; for(i=0;i<nombres.size();i++) { if(i<2) { sprintf(cad,"%s %d",nombres[i].data(),puntos[i]); Print(cad,50,50+20*i,1,0,1); } else { sprintf(cad,"%s",nombres[i].data()); Print(cad,50,50+20*i,1,1,1); } } } Por supuesto el cliente nos debe enviar el nombre, lo que se puede preguntar al usuario mediante un scanf() al comenzar el programa, y enviarlo inmediatamente despus del Connect(). As el mtodo Init() de la clase CMundo (del cliente) quedara as: void CMundo::Init() { //inicializacion del mundo
char nombre[100]; printf("Introduzca su nombre: "); scanf("%s",nombre); cliente.Connect("127.0.0.1",12000);
cliente.Send(nombre,strlen(nombre)+1); } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 55 Universidad Politcnica de Madrid -UPM 3.7. RECEPCIN COMANDOS MOVIMIENTO Cuando el programa cliente detecte una pulsacin de teclado, enviara dicha pulsacin al servidor, para que el servidor la interprete como juzgue necesario. El envo del cliente se realiza fcilmente en la funcin OnKeyboardDown(): void CMundo::OnKeyboardDown(unsigned char key, int x, int y) { char cad[100]; sprintf(cad,"%c",key); cliente.Send(cad,strlen(cad)+1); } Ntese como este envo se realiza nicamente si el usuario pulsa una tecla. El hilo implementado en el servidor tendr una forma similar al hilo anterior: void* hilo_comandos1(void* d) { CMundo* p=(CMundo*) d; p->RecibeComandosJugador1(); } void CMundo::RecibeComandosJugador1() { while(1) { usleep(10); if(conexiones.size()>=1) { char cad[100]; conexiones[0].Receive(cad,100); unsigned char key; sscanf(cad,%c,&key); if(key=='s')jugador1.velocidad.y=-4; if(key=='w')jugador1.velocidad.y=4; } } std::cout<<"Terminando hilo comandos jugador1"<<std::endl; } void CMundo::Init() { //Inicializacion
server.InitServer("127.0.0.1",12000);
pthread_t thid_hilo_conexiones, thid_hilo_comandos1; pthread_create(&thid_hilo_conexiones,NULL,hilo_conexiones,this); pthread_create(&thid_hilo_comandos1,NULL,hilo_comandos1,this); } Ntese en este caso la comprobacin conexiones.size()>=1, para asegurarnos de que efectivamente existe al menos 1 cliente conectado. Adems se ha aadido un retardo usleep(10) para evitar que el bucle while(1) ejecute en vacio si no hay clientes conectados, lo que supondra una sobrecarga de la CPU innecesaria. EJERCICIO: Compltese el programa, aadiendo un segundo hilo que gestione los comandos del segundo jugador. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 56 Universidad Politcnica de Madrid -UPM 3.8. GESTIN DESCONEXIONES En cualquier instante los clientes espectadores pueden desconectar. Qu pasa entonces con el vector de sockets mantenido por el servidor? Las desconexiones deben de ser analizadas y gestionadas adecuadamente. La forma ms sencilla de detectar las desconexiones es en el envo realizado dentro de la funcin CMundo::OnTimer() en el lado del servidor. El envo hay que hacerlo a todos los clientes conectados. Podramos utilizar el retorno de Send() para realizar la eliminacin del cliente del vector. No obstante hay que tener en cuenta los efectos del borrado sobre el vector que se est recorriendo. void CMundo::OnTimer(int value) {
for(i=0;i<conexiones.size();i++) //MALA SOLUCION { if(-1==conexiones[i].Send(cad,strlen(cad)+1)) { conexiones.erase(conexiones.begin()+i); nombres.erase(nombres.begin()+i); if(i<2) puntos[0]=puntos[1]=0; } } } La solucin ms sencilla consiste en ir recorriendo el vector al revs, del final al principio, con lo que las eliminaciones no afectan al bucle for. void CMundo::OnTimer(int value) {
for(i=conexiones.size()-1;i>=0;i--) { if(0>=conexiones[i].Send(cad,200)) { conexiones.erase(conexiones.begin()+i); nombres.erase(nombres.begin()+i); if(i<2) puntos[0]=puntos[1]=0; } } } Ntese que adems, si se ha desconectado uno de los dos primeros clientes (es decir uno de los dos jugadores), entonces el primer espectador pasara a ocupar su lugar y comenzara una nueva partida, poniendo los marcadores a cero. 3.9. FINALIZACIN DEL PROGRAMA Hasta este punto, cuando se cierra el programa servidor, los hilos acaban de forma forzada. Es conveniente en cualquier programa realizar un cierre ordenado de todos los hilos en ejecucin. Para ello se deben seguir los siguientes pasos (todos ellos en el servidor): Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 57 Universidad Politcnica de Madrid -UPM Aadir una variable denominada acabar, que inicialmente vale 0 a la clase CMundo. Poner dicha variable a 1 en el destructor de la clase CMundo. Utilizar la variable como condicin de repeticin en los bucles while() de los hilos: while(!acabar) {
} Poner los identificadores de todos los hilos como variables de la clase CMundo, para que puedan ser utilizados en el pthread_join Ejecutar el pthread_join() tantas veces como sea necesario en el destructor de la clase CMundo, para esperar a que terminen los hilos. En este punto se analiza el resultado cuando se cierra el programa servidor. Realmente se est esperando a la finalizacin de los hilos? La respuesta es no. Los hilos estn bloqueados en el accept() y en el recv() por lo que aunque modifiquemos la bandera acabar esta no es tenida en cuenta hasta la siguiente iteracin del bucle. Hay que conseguir que se desbloqueen el accept() y el recv() de los hilos, lo que se puede hacer de forma sencilla cerrando el socket del servidor, antes de los pthread_join() 3.10. EJERCICIO PROPUESTO
Realizar la misma tarea con otro juego de complejidad similar. Analizar los posibles problemas de sincronizacin que pueden aparecer en caso de conexiones y desconexiones de clientes. Aumentar la informacin que se retransmite, para que los clientes tengan tambin la informacin de quien esta conectado y quien esta jugando, as como los puntos actuales de la partida. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 59 Universidad Politcnica de Madrid -UPM
4 4 . .
COMUNICACIN Y SINCRONIZACIN INTERPROCESO 4.1. INTRODUCCIN Existen otros mecanismos para comunicar datos entre distintos procesos diferentes a los sockets, cuando los procesos se ejecutan en una mquina con una memoria principal comn y gestionada por un nico sistema operativo (monocomputador). A diferencia de la comunicacin por sockets, que se suele denominar programacin distribuida, estos mecanismos entran dentro de la denominada comunicacin interproceso (Inter Process Comunication IPC). Entre estos mecanismos destacan: Las tuberas sin nombre (pipes) y con nombre (FIFOS) La memoria compartida El hecho de tener varios procesos (o hilos) accediendo a unos datos comunes de forma concurrente puede originar problemas de sincronizacin en esos datos. Para prevenir estos problemas hay tambin otros mecanismos como: Los mutex y las variables condicionales Las tuberas (usadas para sincronizar) Los semforos Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 60 Universidad Politcnica de Madrid -UPM 4.2. EL PROBLEMA DE LA SINCRONIZACION Cuando existen varios hilos accediendo de forma concurrente a unos datos, se pueden presentar problemas de concurrencia. En nuestra aplicacin, tenemos varios hilos accediendo de forma concurrente al vector de conexiones. En concreto el hilo principal, a travs del timer: void CMundo::OnTimer(int value) {
for(i=conexiones.size()-1;i>=0;i--) { char cad[1000]; sprintf(cad,"%f %f %f %f %f %f %f %f %f %f", esfera.centro.x,esfera.centro.y, jugador1.x1,jugador1.y1, jugador1.x2,jugador1.y2, jugador2.x1,jugador2.y1, jugador2.x2,jugador2.y2); if(0>=conexiones[i].Send(cad,strlen(cad)+1)) { conexiones.erase(conexiones.begin()+i); nombres.erase(conectados.begin()+i); puntos[0]=puntos[1]=0; } } } El hilo de gestin de las conexiones: void CMundo::GestionaConexiones() { while(!acabar){ Socket s=server.Accept(); char cad[100]; s.Receive(cad,100); nombres.push_back(cad); conexiones.push_back(s); }
} Y los hilos de recepcin de mensajes de los jugadores: void CMundo::RecibeComandosJugador1() { Socket s; while(!acabar) { usleep(10); if(conexiones.size()>0) { char cad[100]; conexiones[0].Receive(cad,100); //peligroso printf("Llego la tecla %c\n",cad[0]); unsigned char key=cad[0]; if(key=='s')jugador1.velocidad.y=-4; if(key=='w')jugador1.velocidad.y=4; } } } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 61 Universidad Politcnica de Madrid -UPM Ms concretamente: Es posible que mientras el hilo que recibe los mensajes del jugador decide que hay un jugador conectado (conexiones.size()>0), el hilo principal que enva los datos por el socket, se de cuenta que dicho cliente ha sido desconectado y decida borrarlo del vector. Si el vector queda vacio, un acceso a conexiones[0] genera un error fatal segmentation fault, y nuestro servidor abortara de manera inesperada. No obstante, en la prctica es bastante improbable que suceda esto, y seguramente serian necesarias cientos de conexiones y desconexiones para que este efecto fuera visible. Por lo tanto, no abordaremos de momento el problema de la sincronizacin, pero hay que tener en cuenta que en una aplicacin real sera totalmente obligatorio realizar esta sincronizacin, sino nuestro programa podra fallar en un momento inesperado. Sin embargo si hay un motivo por el que el servidor puede cerrar inesperadamente. Es la recepcin de la seal SIGPIPE cuando se intenta enviar algo por un socket que ha sido cerrado. Si no se gestiona esta seal, el comportamiento por defecto termina el programa. La forma ms sencilla de obviar esta seal, es indicar a la funcin send() en sus banderas, que no enve esta seal en caso de error, lo que se hace de la siguiente forma: int err=send(sock, cad, length,MSG_NOSIGNAL);
4.3. COMUNICACIN INTERPROCESO En este tema se propone el siguiente esquema como ejemplo del uso de distintos mecanismos de comunicacin interproceso:
Figura 4-1. Ejemplo de comunicacin interproceso con tuberas y memoria compartida RED TCP/IP Logger FIFO Bot Memoria compartida Servidor Cliente Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 62 Universidad Politcnica de Madrid -UPM En el computador que corre el servidor, se desarrollara un programa que sirva para mostrar eventos de una forma ordenada por pantalla, aunque tambin podra decidir guardarlos a disco, a una base de datos, etc. Los eventos sern los puntos marcados, y quien (el nombre del jugador) que ha marcado un tanto, y sern enviados mediante cadenas de texto por una tubera con nombre o FIFO, al programa que llamaremos logger. En el lado del cliente se desarrollara un programa sencillo que pueda controlar los movimientos de la raqueta correspondiente automticamente. A este programa le llamaremos bot. El cliente y la aplicacin bot intercambiaran datos en una zona de memoria compartida. Ambas aplicaciones nuevas sern aplicaciones de tipo consola. El makefile de las cuatro aplicaciones quedara como sigue: CC=g++ CPPFLAGS=-g LIBS= -lm -lglut -lpthread OBJS=Esfera.o Plano.o Raqueta.o Vector2D.o Socket.o
4.4. TUBERAS CON NOMBRE Las tuberas son un mecanismo tanto de comunicacin como de sincronizacin. Las tuberas sin nombre o pipes se utilizan en procesos que han sido creados mediante fork() y tienen relaciones padre-hijo, de tal forma que heredan dicha tubera. Cuando se trata de procesos totalmente separados, la tubera tiene que ser con nombre para que ambos procesos sean capaces de acceder a ella. Las tuberas con nombre se direccionan como un archivo (un archivo especial) en la estructura de directorios. En las tuberas con nombre tiene que existir un proceso que se encargue de crear dicho pseudoarchivo, que adems tiene que ser el primer proceso que comience a ejecutar. Dicho proceso podra tener un cdigo como el siguiente, para enviar por el FIFO una frase a otro proceso que se conecte al mismo: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 63 Universidad Politcnica de Madrid -UPM #include <stdio.h> #include <string.h> #include <signal.h> #include <fcntl.h> #include <stdlib.h> #include <unistd.h>
int main(int argc,char* argv[]) { mkfifo("/ruta/MiFifo1",0777);
int pipe=open("/ruta/MiFifo1",O_WRONLY);
char cad[150]=Hola que tal; int ret=write(pipe,cad,strlen(cad)+1);
close(pipe); unlink("/ruta/MiFifo1");
return 0; } Donde: mkfifo("/ruta/MiFifo1",0777); crea un archivo con un icono especial en forma de tubera en la ruta indicada, y con los permisos correspondientes (0777= permisos de lectura, escritura y ejecucin para todo el mundo). int pipe=open("/ruta/MiFifo1",O_WRONLY); La funcin open() abre dicha tubera con el acceso especificado (O_WRONLY, O_RDONLY, O_RDWR) y devuelve un descriptor de archivo (pipe) que es el utilizado para enviar y recibir datos. Ntese que esta funcin bloquea hasta que se conecta alguien en el otro extremo de la tubera. A continuacin se hace un envo de datos: int ret=write(pipe,cad,strlen(cad)+1); Y finalmente se cierra la tubera y se elimina el pseudoarchivo close(pipe); unlink("/ruta/MiFifo1"); El otro proceso nicamente debe de abrir la tubera, usarla y cerrarla, pero no crear el archivo ni borrarlo. Lgicamente, este segundo proceso debe de ser arrancado despus del anterior, para que la tubera sea creada primero antes de intentar abrirla. int main(void) { int pipe=open("/ruta/MiFifo1",O_RDONLY);
char cad[150]; read(pipe,cad,sizeof(cad));
printf("Cadena=%s\n",cad);
close(pipe); return 1; } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 64 Universidad Politcnica de Madrid -UPM Hay que recordar que la tubera es un mecanismo totalmente unidireccional, no permite que el receptor enve datos por la misma tubera. Si se desea implementar comunicacin bidireccional es necesario el uso de 2 tuberas. Ejercicio: Implementar el programa Logger y los cambios necesarios en el Servidor, para que este enve al Logger el nombre y nmero de puntos que lleva un jugador solo en el momento de marcar un tanto. Seguir los siguientes pasos: 1. El programa Logger se ejecuta antes que el servidor, por lo tanto ser el encargado de crear y destruir el archivo. 2. El logger entra en un bucle infinito de recepcin de datos. 3. Aadir el identificador del FIFO como atributo de la clase CMundo en el servidor. 4. Abrir la tubera (antes de lanzar los hilos) 5. Enviar los datos cuando se produzcan puntos. 6. Cerrar la tubera adecuadamente 4.5. MEMORIA COMPARTIDA La memoria compartida es un mecanismo exclusivamente de comunicacin que permite tener en comn una zona de memoria, accesible desde varios procesos. Dichos procesos, una vez inicializada y accedida, vern la zona de memoria compartida como memoria propia del proceso. Esta forma de trabajar resulta muy interesante especialmente si la cantidad de datos a compartir entre los distintos procesos es muy elevada. Hay distintas interfaces a la memoria compartida, como las funciones de BSD y la memoria compartida POSIX. En este captulo se utiliza la memoria compartida POSIX. As un proceso que quisiera tener en comn una zona de memoria de 10 datos de tipo entero, compartida con otros procesos, podra hacer algo de la forma:
//memoria compartida key_t mi_key=ftok("/bin/ls",12); int shmid=shmget(mi_key,sizeof(datos),0x1ff|IPC_CREAT); char* punt=(char*)shmat(shmid,0,0x1ff);
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 65 Universidad Politcnica de Madrid -UPM while(1) { int i,num; printf("Numero de dato: "); scanf("%d",&i); printf("Dato: "); scanf("%d",&num); datos[i]=num; memcpy(punt,datos,sizeof(datos)); }
shmdt(punt); shmctl(shmid,IPC_RMID,NULL);
return 1; } Donde key_t mi_key=ftok("/bin/ls",12); obtiene una llave nica que sirve para identificar la zona de memoria compartida. Los parmetros suministrados a esta funcin tienen que ser los mismos en los diferentes procesos que utilicen la zona de memoria, y son un nombre de archivo (uno cualquiera existente en el sistema de archivos) y un numero entero. A continuacin se obtiene el descriptor mediante la funcin shmget(), a la que se le indica el tamao en nmero de bytes de la misma, los permisos (0x1ff significa acceso total a todos). En el caso que el proceso realmente quiera crear la zona porque todava no existe, debe especificar la bandera IPC_CREAT. int shmid=shmget(mi_key,sizeof(datos),0x1ff|IPC_CREAT); La obtencin de un puntero, cuyo tipo se puede adaptar sencillamente con un cast, se obtiene con la funcin shmat(), a la que se especifican otra vez los permisos particulares de este acceso. char* punt=(char*)shmat(shmid,0,0x1ff); El acceso posterior a la zona de memoria se puede hacer con algn tipo de cast, de indireccin por ndices de un vector o directamente copiando datos a esa zona de memoria. Una vez terminada de utilizar, es necesario soltar el puntero asignado y liberar la zona de memoria: shmdt(punt); shmctl(shmid,IPC_RMID,NULL); Como en el caso anterior, el proceso que efectivamente crea la zona de memoria debe de ser arrancado antes que los procesos que accedan a ella. Uno de estos procesos, podra tener el aspecto siguiente: #include <sys/types.h> #include <stdio.h> #include <sys/shm.h> #include <stdlib.h> #include <fcntl.h> #include <string.h>
int main(void) { int datos[10]; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 66 Universidad Politcnica de Madrid -UPM int i; key_t mi_key=ftok("/bin/ls",12); int shmid=shmget(mi_key,sizeof(datos),0x1ff); char* punt=(char*)shmat(shmid,0,0x1ff);
while(1) { memcpy(datos,punt,sizeof(datos)); for(i=0;i<10;i++) printf("%d ",datos[i]); printf("\n"); } shmdt(punt); return 1; } Para utilizar cmodamente la memoria compartida en nuestra aplicacin, vamos a crear (solo la declaracin es necesaria) una clase de conveniencia que agrupe los distintos datos que se necesitaran compartir entre el cliente y el bot: #include "Esfera.h" #include "Raqueta.h"
class DatosMemCompartida { public: Esfera esfera; Raqueta raqueta1; Raqueta raqueta2; int jugador;//0 es raqueta1, 1 raqueta 2, otra cosa, espectador int accion; //1 arriba, 0 nada, -1 abajo }; La primera cosa que se observa es que el bot difcilmente podr realizar ninguna decisin sino sabe que raqueta est controlando (o si est controlando alguna). Esta informacin tampoco la tiene el cliente, ya que este se limita a transmitir las teclas pulsadas, y el servidor le har caso o no. Para incluir esta informacin, debe de ser el servidor el que enve a todos los clientes el nmero de cliente que son. As si es el cliente 0, sabr que es el primer jugador con la raqueta1, y si es el cliente 1, sabr que es el segundo jugador con la raqueta2. for(i=conexiones.size()-1;i>=0;i--) { char cad[1000]; sprintf(cad,"%d %f %f %f %f %f %f %f %f %f %f", i,esfera.centro.x,esfera.centro.y, jugador1.x1,jugador1.y1, jugador1.x2,jugador1.y2, jugador2.x1,jugador2.y1, jugador2.x2,jugador2.y2); } El cliente, tambin aadir una variable denominada num_cliente a la clase CMundo, y la extraer convenientemente de la cadena recibida. Es necesario aadir las variables siguientes a la clase CMundo del cliente, para que acceda adecuadamente a la zona de memoria compartida: #include "DatosMemCompartida.h"
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 67 Universidad Politcnica de Madrid -UPM class CMundo { public:
DatosMemCompartida* datos; int shmid;
}; El cliente ser el encargado de crear la zona de memoria compartida, lo que se puede hacer en la funcin Init(): void CMundo::Init() {
key_t mi_key=ftok("/bin/ls",12); int shmid =shmget(mi_key,sizeof(DatosMemCompartida),0x1ff|IPC_CREAT); datos=(DatosMemCompartida*)shmat(shmid,0,0x1ff); } Cada vez que el cliente obtiene datos nuevos los pone en la zona de memoria compartida, para que el bot tenga acceso a ellos: void CMundo::OnTimer(int value) { char cad[1000]; client.Receive(cad,1000); sscanf(cad,"%d %f %f %f %f %f %f %f %f %f %f", &num_cliente, &esfera.centro.x,&esfera.centro.y, &jugador1.x1,&jugador1.y1, &jugador1.x2,&jugador1.y2, &jugador2.x1,&jugador2.y1, &jugador2.x2,&jugador2.y2);
datos->jugador=num_cliente; datos->esfera.centro=esfera.centro; datos->raqueta1=jugador1; datos->raqueta2=jugador2; } El bot a su vez, lee los datos de la memoria compartida y toma una decisin acerca de la accin a realizar: #include <stdio.h> #include <sys/shm.h> #include <string.h> #include "DatosMemCompartida.h"
int main(int argc,char* argv[]) { int i; key_t mi_key=ftok("/bin/ls",12); int shmid=shmget(mi_key,sizeof(DatosMemCompartida),0x1ff); DatosMemCompartida* dat =(DatosMemCompartida*)shmat(shmid,0,0x1ff); while(1) { usleep(25000); if(dat->jugador==0) Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 68 Universidad Politcnica de Madrid -UPM { dat->accion=0;//accion por defecto, ninguna //completar
} if(dat->jugador==1) { } }
shmdt(dat); return 0; }
Ejercicio: Completar el bot para que tome una decisin del movimiento a realizar
El cliente, consultara la accin decidida por el bot y la llevar a cabo. Esta tarea tambin ser llevada a cabo en el OnTimer: void CMundo::OnTimer(int value) {
Ejercicio: Asegurar que la zona de memoria compartida se libera cuando se cierra la aplicacin cliente. Utilizar una bandera para indicar al bot que tambin debe de cerrarse.
4.6. EJERCICIOS PROPUESTOS Se proponen a continuacin algunas posibles mejoras a realizar en el juego: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 69 Universidad Politcnica de Madrid -UPM Hacer que el jugador pueda controlar mediante su cliente con el teclado la raqueta. nicamente cuando el jugador deja de controlarla durante 10 segundos, entra automticamente el bot y coge el control de nuevo. Realizar un tercer programa en el computador del servidor que permitiera a un comentarista del partido ir tecleando comentarios que fueran guardados en el fichero (mostrados por pantalla en nuestro caso). Tener en cuenta posibles problemas de sincronizacin o gestin de mensajes. Aadir un hilo al programa bot que sirva de interfaz con el usuario y pida al mismo que teclee algn valor que le permita cambiar el comportamiento del bot, ms hbil, menos hbil, por ejemplo. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 70 Universidad Politcnica de Madrid -UPM
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 71 Universidad Politcnica de Madrid -UPM
Parte II. Programacin avanzada
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 72 Universidad Politcnica de Madrid -UPM
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 73 Universidad Politcnica de Madrid -UPM
5 5 . .
PROGRAMACIN DE CDIGO EFICIENTE 5.1. INTRODUCCIN Se establece como pre-requisito en este libro que el lector conoce el lenguaje C y que es capaz de programar en dicho lenguaje, sintetizando pequeos algoritmos y soluciones. Tambin se asume conocimiento del lenguaje C++ y de conceptos de programacin orientada a objetos. No obstante es bastante posible que el lector todava no tenga en consideracin cuando programa que el cdigo que esta tecleando puede funcionar ms o menos rpido cuando se ejecute en el computador. Hay que tener en cuenta que el computador, PC o microprocesador va ejecutando secuencialmente las instrucciones (ya compiladas en lenguaje mquina), y lo hace de manera tan rpida que los pequeos programas realizados por un aprendiz se ejecutan sin ningn problema. Sin embargo, en el desarrollo de aplicaciones reales, ya sean de gestin, ingeniera o cientficas o incluso ldicas como videojuegos, hay que tener en cuenta que el volumen (cantidad de lneas de cdigo) de dichas aplicaciones es elevadsimo y el microprocesador debe de ejecutar gran cantidad de cdigo. En muchos de estos casos es importante tener en cuenta la eficiencia o cuanto de rpido ejecuta el cdigo que estamos programando. Veamos un ejemplo sencillo, en el que queremos programar una funcin que calcule la exponencial de un nmero real, ya que necesitamos dicha funcin para nuestros clculos ingenieriles. Una forma comn de calcular la exponencial en sistemas informticos es utilizar su desarrollo de Taylor:
2 3 1 ... 1! 2! 3! ! n x x x x x e n
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 74 Universidad Politcnica de Madrid -UPM Parece razonable enfocar la solucin al problema mediante la siguiente descomposicin en funciones, entre las que aparecen de forma lgica la potencia y el factorial. La funcin exponencial recurre a ellas para calcular el trmino n-simo de la serie de Taylor. La estructura del programa con este esquema seria: #define PRECISION 100
void main(void) { double x,e_x; int i; printf("Numero: "); scanf("%lf",&x); e_x=exponencial(x); printf("la exp.de %lf es %lf\n",x,e_x); } Y la implementacin de las funciones quedara: double factorial(int valor) { int i; double fact=1; for (i=valor;i>0;i--) fact*=(double)i; return(fact); } double potencia(double base,int expo) { int i; double pot=1; for (i=1;i<=expo;i++) pot*=base; return(pot); } double exponencial(double num) { int i; double resultado=1; for (i=1;i<=PRECISION;i++) resultado+=(potencia(num,i)/factorial(i)); return(resultado); } Aunque esta solucin es impecable desde el punto de vista estructural (la subdivisin del problema en partes), tiene un importante fallo: una gran ineficiencia a la hora de calcular la exponencial. Considrese cada trmino de Taylor. Se puede apreciar que para calcular el trmino n-esimo hay que realizar los siguientes clculos: (n veces) ! ( 1) ( 2) (n terminos) 2 1 n x x x x x n n n n
Es decir, para calcular la potencia, hacen falta n multiplicaciones y para calcular el factorial hacen falta otras n multiplicaciones. Por tanto el termino dcimo Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 75 Universidad Politcnica de Madrid -UPM de la serie, requiere 10 veces (20 frente a 2) multiplicaciones que el termino de grado 2. Obviamente, el problema se agrava a medida que se incrementa el nmero de trminos de la serie. Se dice que el coste computacional de calcular cada termino crece linealmente con el ordinal del termino, lo que se representa habitualmente como O(n). No obstante hay una solucin ha este problema, basndose en la recursividad en el calculo del numerador y el denominador de cada termino. Resulta obvio que: 1 ! ( 1)! n n x x x n n n
Lo que implica que el numerador y denominador de cada trmino se pueden calcular a partir del numerador y denominador del trmino anterior. Esta solucin es obviamente mucho ms eficiente, ya que para calcular cada trmino hacen falta nicamente 2 multiplicaciones, una para el numerador y otra para el denominador, independientemente del ordinal del trmino en cuestin. Se dice que cada termino se pude calcular (a partir del numerador y denominador del termino anterior) en tiempo constante (independientemente del ordinal del termino) lo que se representa comnmente como O(1). La implementacin de esta solucin se realiza en una funcin denominada exponencial2(): double exponencial2(double num) { int i; double resultado=1; double numerador=1.0,denominador=1.0f; for (i=1;i<=PRECISION;i++) { numerador*=num; denominador*=i; resultado+=numerador/denominador; } return(resultado); } Es importante resaltar en este punto que la solucin numrica al problema es exactamente la misma que en el caso anterior. No es una simplificacin, ni una aproximacin, se calcula el mismo resultado pero de dos formas diferentes. Para poner de relieve las diferencias entre ambas soluciones, las ejecutamos cien mil veces cada una. Tngase en cuenta que los clculos que puede hacer una aplicacin real pueden ser muy numerosos, y quizs el calculo de la exponencial puede ser requerido miles de veces. int main(int argc, char* argv[]) { double num; printf("Numero: "); scanf("%lf",&num);
tiempo();
for(int i=0;i<100000;i++) exponencial(num);
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 76 Universidad Politcnica de Madrid -UPM tiempo(); printf("la exp.de %lf es %lf\n",num,exponencial(num));
for(i=0;i<100000;i++) exponencial2(num);
tiempo(); printf("la exp.de %lf es %lf\n",num,exponencial2(num));
return 0; } Para medir los tiempos de ejecucin hemos utilizado una funcin de conveniencia denominada tiempo() que se encarga de sacar por pantalla el tiempo transcurrido entre dos llamadas a la misma: #include <stdlib.h> #include <sys/timeb.h>
void tiempo() { static struct timeb t1={0}; struct timeb t2; ftime(&t2); float t=((t2.time-t1.time)*1000+ (t2.millitm-t1.millitm))/1000.0f; if(t1.time!=0) printf("Tiempo= %f\n",t); t1=t2; } El resultado de ejecutar el programa anterior podra ser similar al siguiente, que es el resultado de ejecutarlo en un Intel Core2 Duo a 3Ghz con WindowsXP y compilando en Visual C++ 6.0. Estos resultados pueden variar lgicamente en funcin de la mquina, el sistema operativo y el sistema de desarrollo. Numero: 1 Tiempo= 3.500000 la exp.de 1.000000 es 2.718282 Tiempo= 0.141000 la exp.de 1.000000 es 2.718282 Como se puede apreciar, el tiempo necesario en el primer caso es de 3,5 segundos, frente a los 141 milisegundos que tarda en el segundo caso. Es decir, la segunda solucin es unas 25 veces ms rpida que la primera. Al final, el resultado puede ser una aplicacin que deja a la espera al usuario varios segundos antes de darle un resultado, con la incomodidad que ello supone, si se utiliza el primer enfoque, mientras que en el segundo caso la aplicacin responder mucho ms rpidamente y por tanto la satisfaccin del usuario ser mayor y las probabilidades de xito del software tambin sern incrementadas. El desarrollo de cdigo eficiente y el anlisis de la ejecucin de cdigo es una disciplina mucho ms all de lo que puede cubrir este libro. Este tema trata nicamente de ilustrar algunas tcnicas y ejemplos que introduzcan al lector en este problema, de tal forma que el programador novel empiece a tener en cuenta criterios Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 77 Universidad Politcnica de Madrid -UPM de eficiencia cuando programa, y tenga un punto de partida practico y sencillo a dichas disciplinas. 5.2. MODOS DE DESARROLLO Es importante resaltar en este punto que los entornos de desarrollo y compiladores permiten fundamentalmente dos modos de desarrollo: La versin de desarrollo, depuracin o Debug es la versin que permite depurar el cdigo en tiempo de ejecucin (con un debugger tpicamente integrado en el entorno). El ejecutable generado no esta optimizado para ejecutarse rpidamente ni para menor tamao. La versin final o Release es para generar un ejecutable optimizado para mayor velocidad, pero que no permite la depuracin del cdigo en busca de errores. En Visual Studio se puede seleccionar entre ellas en Menu-> Build -> Set Active Configuration y seleccionar la que se desee. La configuracin por defecto es la Debug. En linux y gcc se utiliza la bandera -g para indicar el modo de depuracin. La optimizacin y medida de tiempos de ejecucin se realizan tpicamente en la versin Release, que es la que ejecuta ms rpidamente. 5.3. TIPOS DE OPTIMIZACIONES Existen cuatro tipos bsicos de optimizaciones que se pueden tratar en el desarrollo de un software: Memoria: intentar minimizar el uso de la memoria utilizada por nuestro programa. Tamao del ejecutable: que el tamao del ejecutable en disco sea lo ms pequeo posible. Eficiencia de ejecucin (procesamiento): que la aplicacin ejecute lo ms rpido posible o utilizando la menor cantidad posible de CPU Tamao datos: Ancho de banda, espacio en disco; que los datos que utiliza, almacena o comunica a travs de cualquier canal sean lo ms reducidos o compactos posibles. Hay algunas optimizaciones que es capaz de realizar automticamente un buen compilador, como detectar funciones inline o la tcnica de desenrollar bucles o loop unrolling. Sin embargo, el compilador no puede suplir la labor del programador en disear o usar un buen algoritmo, utilizar una estructura de datos eficiente o seleccionar un formato adecuado. Dadas las caractersticas habituales de los computadores actuales, en los que la memoria y el almacenamiento en disco duro son muy abundantes, la optimizacin ms Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 78 Universidad Politcnica de Madrid -UPM comn e importante es la de ejecucin de los programas, y es en la que ms se incide en este tema. 5.4. VELOCIDAD DE EJECUCIN La velocidad de ejecucin de un procesador se mide generalmente en Mflops o Mflop/s, o millones de operaciones de coma flotante por segundo. Realmente es una medida de procesamiento matemtico, que contabiliza el nmero de adiciones y multiplicaciones de nmeros de coma flotante de precisin doble (64 bits) que realiza un sistema cada segundo. No obstante, una medida interesante y prctica en el desarrollo de aplicaciones es el tiempo total de ejecucin que tarda un determinado algoritmo o cdigo en ejecutar. Estas medidas son las que se realizan en este tema. Este enfoque contabiliza el tiempo total del cdigo, no solo las operaciones aritmticas de coma flotante. Recerdese que gran parte del cdigo se destina a estructuras de control, acceso a memoria, direccionamiento de matrices y vectores, etc. Existe un banco de pruebas (benchmark) denominado el test de Linpack que consiste en la resolucin de un sistema de ecuaciones lineales denso (100, 1000 ecuaciones) mediante el mtodo del pivote parcial, midiendo la eficiencia real en Mflops. Este test de Linpack es el que se utiliza tambin para clasificar los computadores y establecer la lista de los ms rpidos (el Top 500 de los supercomputadores), mediante una medida de eficiencia real en un calculo numrico concreto. Ntese que muchos fabricantes proporcionan una medida de eficiencia de pico o perfecta en base a su arquitectura, su velocidad de reloj, el tamao de su memoria, etc. Pero esto no se ajusta a la eficiencia real, tal y como muestra la siguiente tabla: Tabla 7. Eficiencia en el test de Linpack (100 ecuaciones) para diversos procesadores
Los motivos por los que la eficiencia real no coincide con la de pico son muy numerosos, ya que en la eficiencia de ejecucin influye el algoritmo, las optimizaciones realizadas por el compilador y el programador, el tamao del problema, el sistema operativo, etc. Tambin hay que insistir en que los resultados son una medida parcial del rendimiento del sistema, ya que la eficiencia de un computador en la ejecucin de Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 79 Universidad Politcnica de Madrid -UPM aplicaciones reales depende tambin de otros factores, como la velocidad de acceso a disco, las capacidades de la tarjeta grafica, las comunicaciones con dispositivos, etc.
5.5. ALGUNAS TCNICAS
5.5.1 Casos frecuentes Considrese la estructura if-else if que maneja distintos casos o condiciones, las cuales puede suceder con distinta probabilidad: if(condicion1) { //hacer algo1 } else if(condicion2) { //hacer algo2 } else if(condicion3) { //hacer algo3 } Esta estructura va evaluando las condiciones hasta que encuentra una condicin cierta. Si la condicin 1 es cierta solo es necesario hacer 1 comprobacin, mientras que si es falsa, hacen falta al menos 2 clculos, el de la condicin 1 y el de la 2. Si las condiciones 1 y 2 son falsas, entonces son necesarias 3 operaciones. Se puede decir que el nmero medio de evaluaciones de condicin necesarias es: m N =Pr(condicion1)*1+Pr(condicion2)*2+Pr(condicion3)*3 Pr(condicion)=probabilidad de que sea la primera cierta
As, si la probabilidad de que la primera condicin sea cierta es muy pequea, por ejemplo del 1%, al igual que la segunda condicin, mientras que la de la tercera es del 98%, el nmero medio de comprobaciones seria: m N =0,98*3+0,01*2+0,01*1=2,97 Si por el contrario, la probabilidad de la primera condicin fuera del 98%, mientras que la de las otras dos fuera del 1%, el nmero medio de comprobaciones seria: m N =0,01*3+0,01*2+0,98*1=1,03 Una implementacin de prueba se puede realizar obteniendo nmeros aleatorios en el rango 0-100, y expresando las condiciones respecto a esos nmeros aleatorios: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 80 Universidad Politcnica de Madrid -UPM void casos1() { float valor=100*rand()/(float)RAND_MAX; if(valor>=99.0f) { } else if(valor>=98) { } else if(valor<98.0f) { } } void casos2() { float valor=100*rand()/(float)RAND_MAX; if(valor<98.0f) { } else if(valor<99.0f) { } else if(valor>=99.0f) { } }
int main(int argc, char* argv[]) { tiempo(); for(int i=0;i<10000000;i++) casos1(); tiempo(); for(i=0;i<10000000;i++) casos2(); tiempo(); return 0; } El resultado de ejecutar este programa (en la mquina citada anteriormente) seria el siguiente, donde se aprecia la ganancia en tiempo de cmputo: Tiempo= 0.125000 Tiempo= 0.047000 Hay que resaltar, que al igual que antes el programa realiza exactamente la misma funcin. Esta tcnica se puede resumir como: poner los casos frecuentes primero 5.5.2 Bucles 5.5.2.1. Desenrollado de bucles El desenrollado de bucles o loop unrolling es una tcnica que consiste en repetir el cdigo interno de un bucle varias veces para evitar precisamente la iteracin de dicho bucle. Ntese que en la mayora de los casos, los bucles solo sirven para evitar la repeticin de cdigo al programador. Pero dicho bucle incurre en un coste Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 81 Universidad Politcnica de Madrid -UPM computacional al realizar las operaciones necesarias. Los compiladores modernos son generalmente capaces de detectar situaciones en las que el loop unrolling es posible y lo realizan automticamente. Cuando el compilador es capaz de detectar que el numero de iteraciones del bucle es fijo (constante), generalmente produce internamente dicho desenrollado, mejorando la eficiencia de ejecucin del cdigo. Solo en pocos casos en los que la eficiencia puede ser critica y el compilador no puede detectarlo, se recurre al loop unrolling manual, en el que el programador lo realiza directamente en cdigo. En aplicaciones criticas (avinica, por ejemplo), esto puede llegar a ser una practica comn. Imagnese un programa que tiene que inicializar un vector de 1000 elementos, cada uno con un valor igual a su ordinal. Tal como ilustra el programa siguiente, eso se puede realizar de la forma tradicional, o realizando un unrolling en este caso de 10 en 10, aunque este tamao puede variar. En el caso de optimizaciones automticas realizadas por el compilador, este decidir el tamao del unrolling. main(int argc, char* argv[]) { int size=1000; float vector[1000]; //Metodo 1 tiempo(); for(int i=0;i<100000;i++) for(int j=0;j<size;j++) vector[j]=j; //Metodo 2 tiempo(); for(i=0;i<100000;i++) for(int j=0;j<size;j+=10) { vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; vector[j]=j++; } tiempo(); return 0; } Los tiempos de ejecucin muestran la ventaja de este procedimiento: Tiempo= 0.266000 Tiempo= 0.093000 5.5.2.2. Invariantes en bucles Al ser los bucles tareas repetitivas, en muchos casos un gran nmero de veces, conviene prestar atencin a elementos repetitivos o invariantes dentro de los bucles. El siguiente fragmento de cdigo tiene como objetivo rellenar el vector de 10000 componentes con unos valores que dependen del ordinal del elemento, as como de dos variables a y b, que en este caso se obtienen aleatoriamente (aunque podran Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 82 Universidad Politcnica de Madrid -UPM venir de otra parte de la aplicacin). El valor de dichas variables influyen no solo en el clculo, sino que la relacin entre ambas establece que calculo se debe realizar, tal y como se aprecia en el cdigo siguiente: void bucle1() { double a=rand(); double b=rand(); double result[10000]; for(int j=0;j<10000;j++) { if(a<b) result[j]=a*j/b; else result[j]=b*j/a; } } Sin embargo, un anlisis de este cdigo nos muestra que la comparacin del if(a<b) se est realizando 10000 veces de forma innecesaria, ya que los valores de a y b no se modifican dentro del bucle. Por lo tanto, resulta ms eficiente sacar la comparacin de dentro del bucle, y repetir el cdigo del bucle para cada uno de los dos casos resultantes: void bucle2() { double a=rand(); double b=rand(); double result[10000]; if(a<b) for(int j=0;j<10000;j++) result[j]=a*j/b; else for(int j=0;j<10000;j++) result[j]=b*j/a; } En este caso, el compilador no ha sido capaz de detectar esta posibilidad, pero si por ejemplo las variables a y b tuvieran valores constantes, el compilador quizas si seria capaz de optimizar. De hecho se puede ir ms lejos y detectar que no solo la comparacin es invariante dentro del bucle, sino que parte de la operacin aritmtica realizada en la asignacin de valores al vector tambin lo es. Por lo tanto podemos tambin extraer dicho clculo y realizarlo una nica vez antes de comenzar los bucles: void bucle3() { double a=rand(); double b=rand(); double result[10000]; double c=a/b; if(a<b) for(int j=0;j<10000;j++) result[j]=j*c; else for(int j=0;j<10000;j++) result[j]=j/c; } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 83 Universidad Politcnica de Madrid -UPM Si ejecutamos las tres soluciones miles de veces: main(int argc, char* argv[]) { tiempo(); for(int i=0;i<10000;i++) bucle1(); tiempo(); for(i=0;i<10000;i++) bucle2(); tiempo(); for(i=0;i<10000;i++) bucle3(); tiempo(); return 0; } Se obtienen los siguientes tiempos: Tiempo= 2.516000 Tiempo= 1.562000 Tiempo= 0.891000 Una vez ms, la ganancia computacional es visible. Se han presentado en este caso invariantes aritmticos y estructurales, pero tambin es posible extraer la declaracin de objetos dentro de un bucle fuera del mismo para evitar la repetida reserva de memoria. De cualquier forma, tal y como se recomienda en las conclusiones, generalmente no es necesario hacer un anlisis exhaustivo buscando estas posibilidades dentro de los bucles mientras se programa. En general se programan los bucles, si es necesario se analiza el rendimiento y si se aprecia alguna posible mejora significativa y necesaria dentro del bucle, se implementa. 5.5.3 Gestin de memoria El uso de memoria, la reserva y liberacin de memoria, ya sea utilizando memoria dinmica o utilizando memoria esttica y dejando al compilador realizar la tarea, lleva asociado un coste computacional. Generalmente este coste es muy pequeo, ya que el manejo de memoria suele estar muy optimizado, pero puede ser relevante en aplicaciones que manejen gran cantidad de datos o lo hagan de forma muy repetitiva. Si por ejemplo se desea copiar la informacin de una matriz tridimensional a otra, o asignar todas sus componentes a cero, se podra implementar de la siguiente forma: int a[3][3][3]; int b[3][3][3]; for(i=0;i<3;i++) for(j=0;j<3;j++) for(k=0;k<3;k++) b[i][j][k] = a[i][j][k]; for(i=0;i<3;i++) for(j=0;j<3;j++) for(k=0;k<3;k++) a[i][j][k] = 0; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 84 Universidad Politcnica de Madrid -UPM No obstante, usando la contigidad en la reserva de memoria y los mecanismos de asignacin por defecto entre objetos, se puede implementar exactamente lo mismo de una forma mucho ms compacta y ms eficiente en su ejecucin, ya que se evitan los bucles y se realiza directamente una copia de un bloque de memoria en otro: typedef struct { int element[3][3][3]; } Three3DType;
Three3DType a,b; ... b = a; memset(a,0,sizeof(a)); Otra estrategia, cuando se requiere utilizar continuamente datos de dimensin variable es reaprovechar la memoria ya reservada en caso de que sea posible. Una solucin simple consistira en reservar memoria para cada nuevo conjunto de datos (de dimensin n variable), utilizarlos, y a continuacin liberar la memoria utilizada. while(continuar) { //obtener n
int* p=new int[n];
//hacer lo que sea
delete [] p; } Aunque este enfoque es eficiente desde el punto de vista del uso de memoria (siempre se utiliza la mnima cantidad de memoria necesaria), la memoria suele ser muy abundante. Sin embargo el coste computacional de la reserva y liberacin puede ser relevante. En ese caso seria ms conveniente el siguiente enfoque, en el que solo se libera memoria en caso de que no haya suficiente para almacenar los datos, para reservar a continuacin el tamao necesario. Si se tiene un tamao reservado y se necesita menos tamao, no se libera la memoria, sino que directamente se utiliza (desaprovechando una parte). De esta forma, el tamao reservado se estabiliza en el mximo necesario: int max=0; int* p; while(continuar) { //obtener n if(n>max) { delete [] p; p=new int[n]; } //hacer lo que sea } delete [] p; Algo similar puede ocurrir por ejemplo usando la Standard Template Library (STL). Si necesitamos crear una cadena de gran tamao, aadiendo uno a uno nuevos caracteres, podramos hacer: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 85 Universidad Politcnica de Madrid -UPM std::string cadena; for(i = 0; i < 1000; i++) encoded.append(1, letra); Sin embargo es mucho ms eficiente hacer que la cadena reserve automticamente espacio para 1000 caracteres. De estar forma la adicin (append) de caracteres funciona mucho ms rpido porque no tiene que redimensionar la memoria interna dinmicamente: std::string cadena; cadena.reserve(1000); for(i = 0; i < 1000; i++) encoded.append(1, letra);
5.5.4 Tipos de datos Cuando se decide utilizar un tipo de dato u otro, hay que tener en cuenta que esto puede tener consecuencias en el coste computacional. Los procesadores actuales tienen hardware dedicado para realizar operaciones con datos tanto de tipo real como entero. Es posible por tanto, que el procesamiento de nmeros reales de precisin simple (float) se realice ms rpidamente que el de los datos de precisin doble (double). Curiosamente tambin es posible que ciertas operaciones con nmeros enteros se realicen ms rpidamente con enteros de 4 bytes (int) que con enteros de menor tamao (short o char), en ciertos procesadores de 32 bits, ya que su arquitectura esta diseada para este tamao de datos. Por otra parte, la seleccin de un tipo de datos u otro tambin puede tener una seria repercusin en la memoria utilizada, en caso de estructuras de informacin muy grandes. De igual forma, si esos datos se deciden guardar en el disco duro, un tamao muy grande se traducir en un archivo que ocupe mucho espacio, adems del consiguiente tiempo necesario para escribir en el disco, que en general es una operacin relativamente lenta. Tmese como ejemplo las imgenes, como las tomadas por una cmara o un escner. Si una imagen normal tiene millones de pxeles, por ejemplo 1024x768, y cada pxel necesita tpicamente representar la informacin de color (3 componentes), entonces hacen falta aproximadamente 2.4 millones de datos. Las componentes de color admiten una representacin comn como enteros en el rango 0-255. Si optamos por utilizar variables de tamao 1 byte (unsigned char), entonces necesitaremos 2.4Mb de memoria para almacenar dicha imagen en memoria. Si por el contrario utilizramos variables de tamao 4 bytes (int), entonces multiplicaramos obviamente por 4, requiriendo aproximadamente 9.4Mb. El gasto de memoria es pues considerable. Si es el programador el que crea nuevos tipos de datos, es conveniente que tenga en cuenta estos criterios. As por ejemplo, en la creacin de un nuevo tipo de datos para representar matrices, puede ser muy interesante que el tipo de datos contemple la posibilidad de codificar explcitamente distintas posibles representaciones especiales de matrices como matrices diagonales, matrices Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 86 Universidad Politcnica de Madrid -UPM simtricas, matrices triangulares o matrices dispersas, aprovechando estas caractersticas especiales para conseguir una mayor eficiencia. La mayora de libreras matemticas existentes que manejan matrices implementan esta funcionalidad. 5.5.5 Tcnicas en C++ El lenguaje de programacin C++ tiene algunas caractersticas que tienen que ser tenidas en cuenta cuando se programa. Por ejemplo, cuando se pasa un objeto a un mtodo por valor, de tal forma que el mtodo no pueda modificar dicho objeto, se realiza una copia del objeto. Si el objeto tiene un tamao en memoria importante se incurre en un coste computacional. Este coste computacional puede ser evitado con el uso de referencias constantes: void metodo(ClaseA a); //se realiza una copia de a void metodo(const ClaseA& a);//no se realiza copia de a El polimorfismo es una potente utilidad que puede ser utilizada para realizar una buena ingeniera del software y un buen diseo utilizando patrones. No obstante, hay que tener en cuenta que el polimorfismo (a travs de la virtualidad de mtodos), tiene tambin un coste computacional asociado, ya que la decisin de a que funcin se llama tiene que realizarse en tiempo de ejecucin. Esto no quiere decir que no haya que utilizar el polimorfismo, simplemente que hay que tenerlo en consideracin como posible factor en aplicaciones de uso de CPU muy intensivo, sobre todo si el polimorfismo se encuentra en el ncleo computacional de la aplicacin. La encapsulacin de datos dentro de clases utiliza tpicamente mtodos de acceso a dichos datos. Una vez ms hay que tener en cuenta que la llamada de mtodos tiene un coste computacional asociado. Si se tienen problemas de eficiencia quizs puede ser necesario dejar los datos de una clase como public para poder acceder a ellos directamente. El uso de funciones inline puede mitigar este efecto, ya que el compilador sustituye las llamadas a la funcin por el cdigo que est dentro, en vez de enlazar con ella, tantas veces como sea necesario. As el tamao del ejecutable es algo mayor, pero se evita la sobrecarga de invocacin de funciones. Aunque no se especifique un mtodo como inline, el compilador tiene capacidad para detectar, decidir y compilar como inline dicho mtodo, si con ello calcula que conseguir ms eficiencia. El uso de funciones inline suele recomendarse con funciones de hasta un mximo de 3 lneas. El uso de constructores y destructores es tambin una interesante capacidad de C++, pero tampoco hay que olvidar que los mismos tienen un coste computacional asociado. Si el nmero de construcciones es elevado conveniente tener en cuenta que la inicializacin en la construccin es ms eficiente que la asignacin. As si tenemos la siguiente clase: ClaseA{ ClaseA(ClaseB b); protected: ClaseB B; }; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 87 Universidad Politcnica de Madrid -UPM Una implementacin del constructor podra hacer: ClaseA::ClaseA(ClaseB b) { B=b; } Sin embargo es ms eficiente: ClaseA::ClaseA(ClaseB b):B(b) { }
5.6. CASOS PRCTICOS Se presentan en esta seccin algunos ejemplos concretos que permiten profundizar algo ms en algunos conceptos, a la vez que proporcionan una idea de escenarios ms reales de aplicacin. 5.6.1 Algortmica vs. Matemticas Ahora se quiere realizar un programa que necesita calcular la suma de los n primeros nmeros naturales: n i=1 i La forma que viene inmediatamente a la cabeza del programador es la utilizacin de un bucle para realizar dicho sumatorio, resultando en: int Suma1(int n) { int i, sum = 0; for (i = 1; i <= n; i++) sum += i; return sum; } No obstante, existe una solucin cerrada o analtica a este sumatorio: n i=1 ( 1) i 2 n n
La implementacin de esta solucin es inmediata: int Suma2(int n) { int sum = (n * (n+1)) / 2; return sum; }
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 88 Universidad Politcnica de Madrid -UPM Para contabilizar algn tiempo distinto de cero (para la segunda solucin), es necesaria la repeticin del clculo 10 millones de veces: int main(int argc, char* argv[]) { tiempo(); for(int i=0;i<10000000;i++) Suma1(1000); tiempo(); for(i=0;i<10000000;i++) Suma2(1000); tiempo(); return 0; } La salida por pantalla en la mquina anteriormente descrita es la siguiente: Tiempo= 8.437000 Tiempo= 0.032000 En este caso queda de relevancia una abismal diferencia entre una solucin u otra. Adems tambin es importante destacar que esta optimizacin de ninguna manera podr ser nunca incluida por el compilador. Se puede concluir que nada sustituye el razonamiento y el conocimiento de un buen ingeniero software. Resaltamos que no es suficiente con ser un buen programador y conocer el lenguaje. Un buen ingeniero software tiene que tener slidas bases de matemticas, fsica, etc. 5.6.2 Generacin de nmeros primos En el apartado anterior se ha convertido una solucin algortmica en una solucin cerrada o analtica (matemtica). Esto no siempre es posible, y muchas veces una solucin algortmica es totalmente necesaria. No obstante, la importancia de elegir un algoritmo u otro es bien conocida. En problemas tpicos, como la ordenacin de un vector segn algn criterio, se conoce bien que algoritmos funcionan ms rpidamente que otros y se puede elegir entre un conjunto el ms conveniente para nuestra aplicacin. De hecho muchas libreras implementan las distintas opciones para eleccin del usuario. Sin embargo, en numerosas ocasiones el programador tendr que desarrollar su propio algoritmo. Imagnese que se necesita programar una aplicacin que clasifique los primeros N nmeros enteros en primos y no primos. Por motivos de implementacin se decide utilizar un vector de enteros con significado booleano, en el que el ndice u ordinal del elemento corresponde al numero, y el valor del vector (0: falso, no primo, 1: verdadero, primo) corresponde a la clasificacin realizada. Dicho vector es pasado como parmetro a una funcin que es la encargada de rellenar dicho vector con los valores adecuados. En una primera aproximacin, resulta lgico recorrer los N primeros nmeros enteros y para cada uno de ellos estudiar si es primo o no lo es. La forma de hacerlo es comprobar si es divisible por los nmeros enteros menores que el. A priori se supone que el numero es primo (es_primo[i]=1;). Si al realizar una divisin, se comprueba que no lo es, se marca como no primo y se termina la comprobacin. Un sencillo anlisis matemtico revela que no es necesario probar la divisibilidad por Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 89 Universidad Politcnica de Madrid -UPM todos los nmeros inferiores al considerado, sino nicamente por los nmeros hasta la raz cuadrada del nmero considerado. void Metodo1(int es_primo[],int n) { for(int i=0;i<n;i++) { es_primo[i]=1; for(int j=2;j<=sqrt(i);j++) { if(i%j==0) { es_primo[i]=0; break; } } } } Sin embargo en este caso existe un enfoque mucho ms eficiente, consistente en una solucin inversa, en la que en vez de ir analizando cada numero si es o no primo mediante divisiones, vamos a ir eliminando nmeros que sabemos que no son primos. La solucin anterior se puede considerar una solucin hacia atrs, mientras que la propuesta ahora es una solucin hacia delante. Es decir, si cogemos el nmero 2, podemos realizar una especie de tabla de multiplicar y concluir rpidamente que los nmeros 4, 6, 8, etc. no son primos. A continuacin podemos repetir el razonamiento con el numero 3, concluyendo que los nmeros 6, 9, 12, etc. tampoco son primos. Podramos proceder as con todos los nmeros, pero ms eficiente aun es hacerlo solo sobre los primos. Si el numero 4 ha sido ya marcado como no primo, entonces lo omitimos del proceso, ya que sus mltiplos (8, 12, 16, etc.) tambin habrn sido ya marcados como no primos, y por lo tanto seria redundante e innecesario. La implementacin de este mtodo quedara como sigue: void Metodo2(int es_primo[],int n) { for(int i=0;i<n;i++) es_primo[i]=1;
i=2; while(i<n) { for(int j=2;i*j<n;j++) //marcar no primos { es_primo[i*j]=0; } do //buscar siguiente primo { i++; } while(i<n && !es_primo[i]); } } La utilizacin de estos dos mtodos, incluyendo vectores de dimensin dinmica, y la comprobacin de que ambos resultados son idnticos seria:
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 90 Universidad Politcnica de Madrid -UPM #include <stdio.h> #include <stdlib.h> #include <math.h> #include <sys/timeb.h> #include <memory.h>
int main(int argc, char* argv[]) { printf("Introduce n="); int n=0; scanf("%d",&n); int* es_primo=new int[n]; int* es_primo2=new int[n];
delete [] es_primo; delete [] es_primo2; return 0; } El resultado de ejecutar este cdigo en la mquina anteriormente descrita es el siguiente: Introduce n=1000000 Tiempo= 0.953000 Tiempo= 0.047000 Como anteriormente, se pone de relieve una gran ganancia en tiempo de cmputo, ya que el segundo mtodo es unas 20 veces ms rpido, gracias al nuevo mtodo. 5.6.3 Pre-computacin de datos El sensor LMS200 de SICK es un sensor lser que proporciona 181 medidas de distancia (rango) en un intervalo de 180 grados, es decir una medida cada grado. Este sensor se utiliza en numerosas aplicaciones industriales, seguridad, robtica, etc. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 91 Universidad Politcnica de Madrid -UPM
Figura 5-1. Sensor lser LMS200 de SICK Este sensor proporciona de manera continua dichos datos de rango-distancia, que deben de ser procesados por el computador si se desea obtener en coordenadas cartesianas el perfil del objeto escaneado, o las coordenadas de los posibles obstculos u objetos. Una solucin al problema, implementada mediante una funcin podra ser: void Cartesianas1(double rango[],double x[],double y[]) { for(int i=0;i<=180;i++) { x[i]=rango[i]*cos(i); y[i]=rango[i]*sin(i); } } Las operaciones ms costosas (o lentas) en el cdigo anterior son las funciones trigonomtricas de seno y coseno. A primera vista parece que no se puede evitar dicho clculo, lo que es cierto. Pero tambin es cierto que entre diferentes llamadas a la funcin, los ngulos de los que se calcula el seno y el coseno son siempre los mismos, de 0 a 180, con intervalos de 1 grado. Por tanto, se puede evitar tener que recalcular dichos valores en cada llamada a la funcin. Para ello podemos optar por pre-calcular unos vectores declaramos como variables globales por simplicidad. Tngase en cuenta que una solucin real utilizara algn otro mecanismo mejor desde el punto de vista de la ingeniera del software como variables estticas, variables miembro de un clase, etc. El calculo de los valores lo realizamos en una funcin que solo necesitar ser llamada una nica vez. La funcin de clculo de coordenadas cartesianas utilizar ahora los valores precomputados en lugar de recurrir a las funciones matemticas originales. double sin_alfa[181],cos_alfa[181]; void PrecomputaDatos() { for(int i=0;i<=180;i++) { cos_alfa[i]=cos(i); sin_alfa[i]=sin(i); } } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 92 Universidad Politcnica de Madrid -UPM void Cartesianas2(double rango[],double x[],double y[]) { for(int i=0;i<=180;i++) { x[i]=rango[i]*cos_alfa[i]; y[i]=rango[i]*sin_alfa[i]; } } Como en otros casos anteriores, cabe resaltar que no estamos haciendo aqu ninguna aproximacin numrica ni simplificacin del problema. El resultado numrico ser idntico para ambas soluciones. Ejecutamos ambos mtodos miles de veces. Tngase en cuenta que esto no difiere mucho de la realidad, ya que en la prctica el sensor esta proporcionando datos de forma continua al computador. #include <stdio.h> #include <stdlib.h> #include <math.h> #include <sys/timeb.h>
return 0; } En la ejecucin se aprecia que el clculo de las funciones trigonometricas efectivamente tiene un elevado coste asociado. La precomputacion de los valores es prcticamente despreciable, pero supone un gran ahorro de tiempo (unas 20 veces ms rpido) Tiempo= 0.328000 Tiempo= 0.000000 (precomputo) Tiempo= 0.015000 En otros casos puede resultar que los senos y los cosenos no sean siempre los de los mismos ngulos. Aun as en esos casos se puede implementar una solucin interpolada, en la que se precomputan unas tablas con valores distribuidos siguiendo unos determinados intervalos. Dichas tablas se utilizan mediante interpolacin para cualquier valor intermedio. La solucin as programada es una aproximacin numrica Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 93 Universidad Politcnica de Madrid -UPM la solucin real, con un cierto error. No obstante, la ganancia en velocidad puede suponer una ventaja frente a la precisin numrica. Algunas aplicaciones de grficos interactivos, videojuegos y tarjetas graficas utilizan tcnicas basadas en este concepto. 5.7. OBTENIENDO PERFILES (PROFILING) DEL CDIGO Aunque se puede analizar un programa midiendo tiempos de la forma que lo hemos hecho, es poco practico. Para analizar la ejecucin de cdigo, existen herramientas (denominadas Profilers) que permiten ejecutar el cdigo, contabilizando las llamadas a las funciones, el tiempo que emplea cada lnea del programa, etc. mostrando informes como resultado de dicho anlisis. En general, cuando se realiza un programa real, en el que el coste computacional es importante, es necesario utilizar un profiler para analizar donde se utilizan los recursos. Es importante resaltar que generalmente mejorando un 20% del cdigo se puede conseguir un 80% de las optimizaciones posibles. Por tanto no merece la pena disear absolutamente todo el cdigo condicionado a la eficiencia. Simplemente analizando los cuellos de botella y mejorando ciertos aspectos se puede conseguir un buen resultado con una carga de trabajo razonable. El Visual Studio tiene incorporado un profiler que permite analizar algunos tiempos de ejecucin de nuestro programa. Para activarlo es necesario ir a Project Settings->Link->Enable profiling. A continuacin se reconstruye el proyecto (Rebuild all). Para ejecutar el profiler, iremos a Menu->Build->Profile. En el programa anterior, se obtiene el siguiente resultado: Profile: Function timing, sorted by time Date: Thu Jan 15 17:07:04 2009
Program Statistics ------------------ Command line at 2009 Jan 15 17:07: "F:\...........\Precomputo" Total time: 251,998 millisecond Time outside of functions: 6,875 millisecond Call depth: 2 Total functions: 7 Total hits: 20006 Function coverage: 71,4% Overhead Calculated 7 Overhead Average 7
Module Statistics for precomputo.exe ------------------------------------ Time in module: 245,123 millisecond Percent of time in module: 100,0% Functions in module: 7 Hits in module: 20006 Module function coverage: 71,4%
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 94 Universidad Politcnica de Madrid -UPM Func Func+Child Hit Time % Time % Count Function --------------------------------------------------------- 228,48 93,2 228,48 93,2 10000 Cartesianas1(..) (precomputo.obj) 16,001 6,5 16,001 6,5 10000 Cartesianas2() (precomputo.obj) 0,401 0,2 245,12 100,0 1 _main (precomputo.obj) 0,213 0,1 0,213 0,1 4 tiempo(void) (precomputo.obj) 0,023 0,0 0,023 0,0 1 PrecomputaDatos(void) (precomputo.obj) Otros entornos como Matlab, tienen Profilers ms avanzados, que permiten un anlisis ms en profundidad y emiten informes ms completos, incluyendo grficos. Como ejemplo, podemos analizar el siguiente cdigo Matlab: function pruebaProfile
A=randn(10,10); b=randn(10,1);
for i=1:20000
x1=inv(A)*b; x2=A\b; if(x1~=x2) disp "error"; display x1; display x2; end
B=A*A; C=A+randn(10,10); D=A+C*C;
end Para activar el Profiler y analizar este cdigo, se realiza en la lnea de comandos: >> profile on; >> pruebaProfile >> profile off >> profile report El resultado final es el siguiente. Aunque Matlab tambin tiene un visor especifico para el Profiler, que tambin dispone de ms funcionalidad.
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 95 Universidad Politcnica de Madrid -UPM
Figura 5-2. Profiling de una funcin programada en Matlab La figura anterior muestra el tiempo utilizado en cada lnea, tanto en tiempo total como en porcentaje. Se aprecia que la solucin del sistema Ax=b es lo que ms tiempo utiliza. Esta solucin se puede hacer de dos formas posibles, mediante la inversa (x1=Inv(A)*b), o mediante eliminacin de Gauss (x2=A\b). El profiler demuestra como la segunda opcin es ms eficiente, lo que refuerza la importancia de la utilizacin de un algoritmo adecuado para la solucin de un problema. 5.8. CONCLUSIONES Aunque en este tema se han presentado varias tcnicas de optimizacin para un cdigo ms eficiente, esto no quiere decir que el programador deba perder tiempo en implementar todo su cdigo teniendo en cuenta dicha eficiencia. En este apartado nos gustara pues resumir algunas ideas importantes: No ofuscarse en la eficiencia del cdigo. Segn Donald Knuth premature optimization is the root of all evil. Centrarse en el diseo, la correccin y la ingeniera del software y dejar el problema de la eficiencia para el final, con el uso de un profiler. No hay que asumir que algunas operaciones son ms rpidas que otras. Benchmark everything. Medir tiempos. Utilizar siempre un profiler. Reducir cdigo no implica siempre eficiencia. Recurdese el loop unrolling Si se pueden tener en cuenta algunas optimizaciones tpicas y sencillas sobre la marcha, como es el paso de parmetros por referencia constante, que es una practica habitual en buenos programadores C++. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 96 Universidad Politcnica de Madrid -UPM Muchas veces hay que realizar un balance. El uso de optimizaciones para un cdigo ms eficiente a veces es contrapuesto a la buena ingeniera, a la legibilidad y compresin del cdigo, a la encapsulacin, a la modularidad. Otras veces, la velocidad puede requerir mucha memoria, y hay que tomar una decisin de compromiso entre eficiencia de ejecucin y uso de otros recursos. Se recomienda el uso de componentes desarrollados y probados, ya que generalmente estos ya han tenido en cuenta criterios de eficiencia. Estudiar y seleccionar los algoritmos y estructuras de datos ms eficientes para nuestro problema. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 97 Universidad Politcnica de Madrid -UPM
6 6 . .
SERIALIZACIN DE DATOS 6.1. INTRODUCCIN La serializacin de datos (marshalling en ingles), es el proceso de codificar un conjunto de informacin o datos (objetos en programacin Orientada a Objetos), en una estructura de informacin lineal o serie de bytes. Este proceso es necesario para almacenar datos en un dispositivo de almacenamiento, enviar datos por mecanismos de comunicacin serie (puertos serie, USB, por red TCP/IP). La serie de bytes puede ser utilizada posteriormente para recuperar la informacin, y volver a generar la estructura de informacin original. La serializacin es pues un mecanismo muy utilizado para transportar objetos por la red, hacer persistente objetos en ficheros o bases de datos, etc. Es por tanto una tcnica necesaria en sistemas distribuidos, pero no se restringe a ellos. Aunque muchos lenguajes de programacin incluyen soporte nativo para serializacin de datos, este soporte puede no ser suficiente en casos de estructuras de informacin dinmicas creadas por el usuario, o en el caso en que el usuario deba decidir que informacin es la relevante para ser transmitida o almacenada y cual no. Siguiendo el planteamiento practico de este libro, se propone un ejemplo como gua de este captulo. Igualmente, este captulo no pretende ser un anlisis riguroso ni una solucin completa al problema de la serializacin de datos, sino simplemente dar al lector una perspectiva del problema y algunas ideas para abordarlo. No obstante, las metodologas presentadas en el captulo pueden ser mas que suficientes para abordar programas relativamente simples como la aplicacin distribuida propuesta en la segunda parte. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 98 Universidad Politcnica de Madrid -UPM En el ejemplo que se propone se tiene una escena (que podra pertenecer a un juego de ordenador, una simulacin, un salva pantallas) consistente en un bosque. Para el dibujo se ha utilizado OpenGL y para la gestin de las ventanas se utiliza la librera GLUT. Dicho bosque esta formado por una serie de rboles en distintas posiciones, con distintas alturas, colores y tamaos de copa. El cdigo correspondiente a la escena de la figura se puede encontrar en el cdigo adjunto a este libro.
Figura 6-1. Representacin grafica de la escena cuyos datos se van a serializar La declaracin de la clase Bosque es la siguiente. Como se aprecia, todos los atributos de las clases son pblicos. En un buen diseo, esto no debera ser as, pero para nuestro caso se prefiere por simplicidad didctica. #include "Arbol.h" #define MAX_ARBOLES 100
int numero; Arbol arbol[MAX_ARBOLES]; }; La clase Arbol, aparte de la posicin, esta fundamentalmente compuesta por un cilindro (tronco) y una esfera (copa):
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 99 Universidad Politcnica de Madrid -UPM #include "Cilindro.h" #include "Esfera.h"
class Arbol { public: void Dibuja(); void PideDatos(); void Imprime();
float x; float y; Cilindro tronco; Esfera copa; }; Y por ltimo, las clases Esfera y Cilindro son parametrizaciones sencillas de las primitivas correspondientes: class Esfera { public: void Dibuja(); void PideDatos(); void Imprime();
float radio; unsigned short verde,rojo,azul; }; class Cilindro { public: void Dibuja(); void Imprime(); void PideDatos(); Cilindro(); virtual ~Cilindro(); float radio; float altura; }; El diagrama de clases de diseo que representa estas clases es el siguiente: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 100 Universidad Politcnica de Madrid -UPM
Figura 6-2. Diagrama de Clases de Diseo No obstante, es destacable la estructuracin de los objetos. El siguiente diagrama muestra la disposicin en rbol de la informacin. El bosque esta compuesto por una serie de rboles, y cada uno de ellos tiene su propia copa y su propio tronco. Tambin es de relevancia la distribucin de responsabilidades, y el flujo recursivo de invocaciones. En el diagrama se han mostrado algunos mensajes correspondientes a la responsabilidad de dibujar el entorno. Cuando el gestor de ventanas GLUT decide redibujar, acaba llamando al mtodo Bosque::Dibuja(). Este mtodo a su vez delega, llamando al mtodo Arbol::Dibuja(), para cada uno de los rboles que lo componen. A su vez, cada rbol se dibuja a si mismo, diciendo a sus componentes (el tronco y la copa) que se dibujen. Se puede decir que es una aplicacin del patrn Experto en Informacin, ya que cada objeto es responsable de pintarse a si mismo, dado que el tiene la informacin necesaria para pintarse. As, se va procediendo, avanzando primero en profundidad y luego en anchura en el rbol de informacin representado en la figura. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 101 Universidad Politcnica de Madrid -UPM
Figura 6-3. Estructura de objetos de la aplicacin As, los mtodos correspondientes realizan invocaciones a los mtodos de los objetos que los componen, tal y como se muestra (como ejemplo) para la funcin de dibujo del bosque. void Bosque::Dibuja() { int i; for(i=0;i<numero;i++) { arbol[i].Dibuja(); } } Asimismo, tambin existen en las clases funciones que permiten solicitar los datos de un nuevo bosque al usuario para que los teclee por pantalla, mostrar (imprimir) por pantalla los datos de un bosque y generar un bosque aleatorio de un determinado numero de rboles. Supngase en este punto que es necesario almacenar toda la informacin de este bosque en un archivo en el disco duro, para luego poder recuperarlo. O que como la escena forma parte de un juego distribuido, y todos los jugadores se deben mover en la misma escena, es necesario empaquetar en un vector de bytes la escena para enviarla por la red, de tal forma que pueda ser recuperado en un computador remoto. Se plantean a continuacin distintas alternativas. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 102 Universidad Politcnica de Madrid -UPM 6.2. REPRESENTACIN OBJETOS EN MEMORIA Un objeto almacena sus variables de forma contigua en memoria. Supngase que se declara un objeto de la clase Arbol, y se le solicitan los datos al usuario. Arbol arbol; a.PideDatos(); Al realizar dicha declaracin se reserva un espacio en memoria como el que se ilustra en la figura siguiente, en el que se van reservando recursivamente espacio para las variables y los objetos de los que se compone, con un ordenamiento que sigue el establecido en la declaracin (.h) de la clase.
Figura 6-4. Almacenamiento en memoria de un objeto tipo rbol Esta propiedad puede ser utilizada para una fcil serializacin de los datos. Supngase que se quiere almacenar los datos de dicho rbol en un fichero, para su posterior recuperacin. Si se abre el archivo en modo binario y se realiza una escritura sin formato mediante fwrite(), se puede volcar una copia completa de los datos del rbol al archivo. FILE* f=fopen("Arbol.txt","wb"); fwrite(&arbol,sizeof(Arbol),1,f); Como el archivo es binario, si se intenta abrir con un editor de texto, no se encontrar ninguna informacin inteligible por el humano. Pero si posteriormente se desea recuperar la informacin de dicho archivo, sobre un objeto de la clase Arbol (que no necesita inicializar ni pedir sus datos, ya que sern asignados en la lectura), basta con realizar los siguientes pasos: Arbol a; FILE* f=fopen("Arbol.txt","rb"); fread(&a,sizeof(Arbol),1,f); El mismo razonamiento puede aplicar a todo el bosque, de tal forma que podra ser almacenado en un fichero mediante: FILE* f=fopen("Bosque.txt","wb"); fwrite(&bosque,sizeof(bosque),1,f); Y posteriormente recuperado: FILE* f=fopen("Bosque.txt","rb");
x y radio altura radio rojo verde azul copa tronco arbol Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 103 Universidad Politcnica de Madrid -UPM fread(&bosque,sizeof(bosque),1,f); De igual forma, si hubiramos deseado enviar los datos del bosque por la red, podramos haber escrito en un vector de bytes la informacin, para posteriormente enviar ese vector de bytes por el socket correspondiente (aunque realmente este es un paso que se puede obviar en este caso) char* buffer=new char[sizeof(bosque)]; memcpy(buffer,&bosque,sizeof(Bosque)); Y de la misma forma podramos recuperar la informacin del vector mediante: memcpy(&bosque,buffer,sizeof(Bosque)); Aunque a primera vista podra parecer que ya hemos resuelto el problema de la serializacin, esto no es cierto. En este caso funciona ya que toda la memoria es esttica, incluido el vector de tamao variable del bosque: int numero; Arbol arbol[MAX_ARBOLES]; No obstante, ya hay un problema de eficiencia importante. Siempre se estn serializando el nmero mximo posible de rboles, aunque nuestro bosque tenga muchos menos. Esto implica un mayor tamao de archivo o un mayor tamao del buffer para enviar por la red, con el consiguiente despilfarro de recursos del sistema. La implementacin ha sido realizada as por simplicidad y evitar la memoria dinmica. Pero en realidad, la capacidad del vector de rboles debera ser gestionada dinmicamente. Que pasara si esto fuera as? Que la copia de memoria no seria valida, ya que se estara copiando nicamente un puntero, que posteriormente no ser valido. Otro motivo por el que este esquema de serializacin puede ser no valido es el hecho de que no se requiera serializar todos los datos de un objeto, sino solo algunos de ellos. Esto es algo muy comn, ya que muchas clases contienen como variables miembro variables auxiliares o temporales que se requieren para el funcionamiento interno de la clase, pero que no tienen mas alcance. Siguiendo el mtodo anterior se serializan el 100% de las variables miembro de la clase, sean relevantes o necesarias o no. En este ultimo caso, tambin se esta incurriendo en un gasto innecesario de los recursos del sistema. Para solucionar estos problemas, el programador puede desarrollar su propia estrategia de serializacin, que le permita gestionar que variables se serializan y cuales no, as como gestionar adecuadamente la memoria dinmica de los objetos. Se presentan a continuacin algunos enfoques tpicos.
6.3. SERIALIZACIN EN C Aunque la estructura bsica de la aplicacin es Orientada a Objetos, la serializacin tambin tiene que ser realizada en aplicaciones en C. Se presentan en esta seccin algunas tcnicas para realizar esta tarea recurriendo nicamente a funciones de C. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 104 Universidad Politcnica de Madrid -UPM Generalmente se puede clasificar la serializacin en: Con formato (texto). La informacin se almacena de tal forma que un humano puede leerla, interpretarla e incluso modificarla fcilmente. Para ello se almacena como cadena de caracteres (ASCII), en la que incluso se pueden almacenar caracteres especiales para facilitar la lectura como tabulaciones y retornos de carro. Sin formato (binaria). La informacin se almacena como un vector de bytes en el que se almacenan byte a byte (en codificacin binaria) todos los datos, sin necesidad de separarlos por caracteres especiales. El resultado es ininteligible por un humano. Cuando se utiliza el formato, la representacin de un dato del mismo tipo puede tener distinta longitud. Por ejemplo para representar el entero 12 hacen falta solo 2 bytes (2 caracteres, uno para el 1 y otro para el 2), mientras que para el 123456 haran falta 6 bytes. Lo mismo sucede con nmeros de coma flotante como el 0.1 o el 3.1415. Sin embargo en formato binario, los datos ocupan siempre exactamente el mismo tamao. Por ejemplo un entero puede ocupar siempre 4 bytes, al igual que un float. La conclusin es que en general, el formato binario es mas eficiente (necesita menos espacio), ya que adems no necesita separadores, y tiene la ventaja aadida de no tener ninguna perdida de precisin numrica, por redondeos o formatos. Por el contrario presenta la desventaja de no poder ser analizada fcilmente por un humano. 6.3.1 Con formato (texto) La serializacin con formato en C es bastante tediosa. Por una parte hay que desarrollar cdigo segn se desee serializar a un archivo o a una cadena de texto para su envo por red. Tambin se requiere un uso intensivo de las funciones de manejo de cadenas sprintf(), sscanf(), strcat(), strcpy(), etc., ya que escribir con formato en una cadena no es una tarea obvia. Esta variante no ser desarrollada en este captulo. Se deja al lector como ejercicio, que poda desarrollar fcilmente una vez ledas y comprendidas las secciones siguientes. 6.3.2 Sin formato (binaria) La serializacin binaria se apoya sobre una serie de macros write que van insertando en un vector de bytes los datos correspondientes, que pueden ser de tipo char (carcter o entero de 1 byte), short (entero de 2 bytes), long (entero de 4 bytes), float (real de 4 bytes) o double (real de 8 bytes). Por cada una de ellas existe la contraria read, que sirve para extraer del vector la variable. #define writeChar(x,y,z){x[y++] = z;} #define writeShort(x,y,z){*((unsigned short*)((char*)&x[y]))=z; y+=2;} #define writeLong(x,y,z){*((unsigned long *)((char*)&x[y]))=z; y+=4;} #define writeFloat(x,y,z){*((float *)((char *)&x[y])) = z; y += 4;} #define writeDouble(x,y,z){*((double *)((char *)&x[y])) = z; y += 8;} Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 105 Universidad Politcnica de Madrid -UPM
#define readChar(x, y, z) {z = x[y++];} #define readShort(x,y,z){z=*(unsigned short*)((char *)&x[y]); y += 2;} #define readLong(x,y,z){z=(*(unsigned long*)((char *)&x[y])); y += 4;} #define readFloat(x, y, z) {z = (*(float *)((char *)&x[y])); y += 4;} #define readDouble(x, y, z){z = (*(double *)((char *)&x[y])); y += 8;} Los tres argumentos de la macro son el buffer o vector de bytes, la posicin o ndice del vector de bytes y la variable. Se puede considerar todos ellos como pasados por referencia, ya que la macro puede modificar (y modifica) sus valores. Cabe destacar el aumento automtico del ndice segn el tamao de la variable, de tal forma que el usuario de las macros puede despreocuparse de esta cuenta. Para implementar la funcionalidad de serializacin y deserializacion, seguimos con la estructura establecida para el dibujo y siguiendo el patrn del Experto en Informacin, y aadimos a cada una de las clases (Bosque, Arbol, Cilindro, Esfera) los siguientes mtodos: void Read(char cad[],int& cont); void Write(char cad[],int& cont); Ntese que el paso del contador cont a las funciones se hace por referencia, de tal forma que la funcin pueda incrementar dicho contador. La implementacin de estos mtodos para el bosque seria: void Bosque::Write(char cad[], int& cont) { writeChar(cad,cont,numero); int i; for(i=0;i<numero;i++) arbol[i].Write(cad,cont); }
void Bosque::Read(char cad[], int& cont) { readChar(cad,cont,numero); int i; for(i=0;i<numero;i++) arbol[i].Read(cad,cont); } Ntese como lo primero que hacen las funciones es gestionar el nmero de rboles que componen el bosque. Aunque tambin se pueden plantear otras soluciones que no requieren el almacenamiento explicito de este tamao, su utilizacin simplifica mucho la solucin. Tambin es importante recordar que con estas funciones, ya no importa si el vector de rboles ha sido creado esttica o dinmicamente.
La clase Arbol a su vez procede de forma similar: void Arbol::Write(char cad[], int& cont) { writeFloat(cad,cont,x); writeFloat(cad,cont,y); tronco.Write(cad,cont); copa.Write(cad,cont); }
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 106 Universidad Politcnica de Madrid -UPM void Arbol::Read(char cad[], int& cont) { readFloat(cad,cont,x); readFloat(cad,cont,y); tronco.Read(cad,cont); copa.Read(cad,cont); } En este caso es el programador el que decide el que se serializa, con que formato y en que orden. Es importante por tanto que se respeten estos criterios en el desempaquetamiento de los datos, ya que de no hacerlo el resultado ser incorrecto. No obstante, la implementacin de la de-serializacin correspondiente siguiendo el diseo realizado y el patrn Experto en Informacin, se ubica en el mismo lugar, siendo fcil la comprobacin de la necesaria simetra. La serializacin se completa con las funciones correspondientes en Cilindro y Esfera: void Cilindro::Write(char cad[], int& cont) { writeFloat(cad,cont,radio); writeFloat(cad,cont,altura); } void Cilindro::Read(char cad[], int& cont) { readFloat(cad,cont,radio); readFloat(cad,cont,altura); } void Esfera::Write(char cad[], int &cont) { writeFloat(cad,cont,radio); writeChar(cad,cont,rojo); writeChar(cad,cont,verde); writeChar(cad,cont,azul); } void Esfera::Read(char cad[], int& cont) { readFloat(cad,cont,radio); readChar(cad,cont,rojo); readChar(cad,cont,verde); readChar(cad,cont,azul); } Una vez realizada esta implementacin, podemos realizar la serializacin de un bosque de la siguiente forma: bosque.Aleatorio(50);
char buffer[3000]; int cont=0; bosque.Write(buffer,cont); Ntese que en esta implementacin se supone que el buffer tiene capacidad suficiente para almacenar dicha informacin, y no se realiza ninguna comprobacin al respecto. Esto, obviamente, no es una solucin ni valida ni completa, ya que el buffer podra ser pequeo y producirse un desbordamiento, con el consiguiente error en tiempo de ejecucin. En una solucin real se debe al menos comprobar que el tamao del buffer (que puede ser pasado en otro parmetro) es suficiente, aunque tambin seria adecuada la posibilidad de consultar primero el espacio necesario, o utilizar Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 107 Universidad Politcnica de Madrid -UPM memoria dinmica redimensionando el buffer cuando sea necesario. Aun as, la solucin expuesta describe adecuadamente la naturaleza del diseo adoptado, que puede ser fcilmente extensible a dicha comprobacin. La extraccin de la informacin se realizara pues de la siguiente forma: cont=0; bosque.Read(buffer,cont);
Figura 6-5. Propagacin de mensajes Write entre los objetos 6.4. SERIALIZACIN EN C++ La serializacin utilizando un lenguaje de mas alto nivel como es C++ es mas sencilla, no solo por el lenguaje en si mismo, sino por las libreras de soporte del mismo. De especial importancia en este caso es la existencia de streams (flujos, aunque los seguiremos llamando streams) en la librera estndar de C++ Standard Template Library (STL). Recurdese la peculiaridad de que para incluir las cabeceras de esta librera no se incluye el .h #include <iostream> //Include de la STL, OK #include <iostream.h> //Include de librera IO de C++, NO Entre las clases pertenecientes a la IOStream Library, destacamos las siguientes, que van a ser las utilizadas en nuestro cdigo: istream Stream de entrada Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 108 Universidad Politcnica de Madrid -UPM ostream Stream de salida ifstream Stream de entrada de fichero. Derivada de istream. ofstream Stream de salida a fichero. Derivada de ostream. istringstream Stream de entrada de cadena (string). Derivada de istream. ostringstream Stream de salida a cadena (string). Derivada de ostream. Tambin hay que recordar que a esta librera pertenecen los objetos globales cin, cout, cerr y clog (dentro del espacio de nombres std). La potencia de C++ (como el polimorfismo), as como esta librera hacen que programar la serializacin con y sin formato sea bastante ms sencillo. 6.4.1 Con formato (texto) La serializacin con formato se realiza fcilmente con los operadores de insercin (<<) y extraccin (>>) que ya se encuentra implementado para los tipos bsicos (int, float, etc.), y que se puede sobrecargar fcilmente para tipos de datos (clases) programadas por el usuario. Como dichos operadores no son mtodos de la clase, se declaran como amigos (friend), para que tengan acceso a los posibles atributos protegidos o privados. Aunque en este caso no sea necesario ya que todos los atributos son pblicos, mantenemos la amistad para conseguir una implementacin tpica. Ntese que tanto la insercin como la extraccin admiten un primer parmetro de las clases base istream y ostream, aunque luego se pueden utilizar las clases derivadas segn se desee utilizar un fichero o una cadena. #include "Arbol.h" #define MAX_ARBOLES 100
Gracias al using namespace std se evita el tener que anteponer el prefijo std a todas las clases: std::istream, std::ofstream, etc. El segundo parmetro es una referencia en el caso de la extraccin, ya que el operador deber modificar el objeto correspondiente. En el caso de la insercin, el objeto no debe de ser modificada, y por tanto se utiliza una referencia constante. #include "Cilindro.h" #include "Esfera.h" #include <iostream> using namespace std; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 109 Universidad Politcnica de Madrid -UPM
Ambos operadores devuelven una referencia a istream u ostream, para poder concatenar operaciones: stream>>a>>b; //stream es un objeto de tipo istream (p. ej. ifstream) stream<<a<<b; //stream es un objeto de tipo ostream (p. ej. ofstream) La declaracin de los operadores para las clases Esfera y Cilindro es totalmente anloga. La implementacin de los operadores sigue la filosofa anteriormente expuesta, manejando ahora el operador sobrecargado correspondiente: istream& operator>>(istream& s, Bosque& b) { s>>b.numero; int i; for(i=0;i<b.numero;i++) s>>b.arbol[i]; return s; } La lectura o extraccin no supone ningn problema, porque en la misma ya se procesan los separadores (recurdese que es una serializacin con formato) como los espacios o retornos de carro. Sin embargo, en la escritura o serializacin es el programador el encargado de establecer dichos separadores. Con el objeto endl se consigue un final de lnea. ostream& operator<<(ostream& s, const Bosque& b) { s<<b.numero<< endl; int i; for(i=0;i<b.numero;i++) s<<b.arbol[i]<< endl; return s; } Si queremos escribir dos variables en la misma lnea, entonces tenemos que separarlas por espacios o tabulaciones. istream& operator>>(istream& s, Arbol& a) { s>>a.x>>a.y; s>>a.tronco; s>>a.copa; return s; } ostream& operator<<(ostream& s, const Arbol& a) { s<<a.x<<" "<<a.y<<std::endl; s<<a.tronco; s<<a.copa; return s; } La serializacin de la Esfera y el Cilindro quedaran como sigue: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 110 Universidad Politcnica de Madrid -UPM istream& operator>>(istream& s, Cilindro& c) { s>>c.radio>>c.altura; return s; } ostream& operator<<(ostream& s, const Cilindro& c) { s<<c.radio<<" "<<c.altura<<std::endl; return s; } istream& operator>>(istream& s, Esfera& e) { s>>e.radio>>e.rojo>>e.verde>>e.azul; return s; } ostream& operator<<(ostream& s, const Esfera& e) { s<<e.radio<<" "<<e.rojo<<" "<<e.verde<<" "<<e.azul<<endl; return s; } Una vez realizada esta implementacin podemos serializar los datos cmodamente desde un fichero, sacarlos por la consola, a una cadena-stream, etc.: cout<<bosque; //a consola ofstream file("Bosque.txt"); file<<bosque; //a un fichero ostringstream str; //a una cadena-stream str<<bosque; string cadena=str.str();//Como obtener la cadena (para enviar //por un socket, por ejemplo) El resultado de ejecutar la primera lnea de cdigo seria similar a lo siguiente, que por otra parte debera coincidir con el contenido del fichero de texto Bosque.txt. Se aprecian claramente los valores de los atributos respectivos, valores que se podran modificar fcilmente. 50 -9.97497 1.27171 0.2 4.38661 1.80874 117 174 0
-2.99417 7.91925 0.2 5.64568 1.7466 34 233 0
4.21003 0.270699 0.2 4.60799 1.01498 18 156 0
La deserializacin seria igualmente sencilla, sin importar si los datos vienen de un fichero o de una cadena-stream (recibida por un socket, por ejemplo). ifstream file("Bosque.txt"); //desde un fichero file>>bosque; istringstream str; //la cadena coge algun valor str>>bosque; //Desde una stringstream Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 111 Universidad Politcnica de Madrid -UPM 6.4.2 Sin formato (binaria) En el apartado anterior, si deseamos hacer una serializacin binaria por eficiencia, o simplemente porque no queremos que los datos sean fcilmente visibles, podramos intentar abrir el fichero en modo binario: ofstream file("Bosque.txt",ios::binary); Pero esto no es suficiente, ya que los operadores insercin y extraccin trabajan sobre los tipos bsicos siempre con formato (en modo texto), y por tanto se serializan de ese modo, aunque el fichero sea abierto en modo binario. Si se desea que la serializacin sea completamente binaria, hay que recurrir a las funciones especificas de la IOStream library que hacen estas tareas. Estas funciones se llaman tpicamente read y write. Aadimos a todas nuestras clases unos mtodos que se llamen de forma similar, y que admitan una referencia a stream. Gracias a esta referencia, podremos utilizar el polimorfismo, y nuestros mtodos funcionarn igual para las clases derivadas correspondientes (fstreams y stringstreams). Los siguientes mtodos sern entonces aadidos a las clases Bosque, Arbol, Esfera y Cilindro: void Read(std::istream& str); void Write(std::ostream& str); La filosofa coincide completamente con la desarrollada anteriormente en lenguaje C, a excepcin que ahora se utilizan las funciones de lectura y escritura sin formato (read y write) en un stream: void Bosque::Read(std::istream& str) { str.read((char*)&numero,sizeof(numero)); int i; for(i=0;i<numero;i++) arbol[i].Read(str); } void Bosque::Write(std::ostream& str) { str.write((char*)&numero,sizeof(numero)); int i; for(i=0;i<numero;i++) arbol[i].Write(str); } Como anteriormente, preservar el orden es totalmente necesario: void Arbol::Read(std::istream& str) { str.read((char*)&x,sizeof(float)); str.read((char*)&y,sizeof(float)); tronco.Read(str); copa.Read(str); } void Arbol::Write(std::ostream& str) { str.write((char*)&x,sizeof(float)); str.write((char*)&y,sizeof(float)); tronco.Write(str); copa.Write(str); } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 112 Universidad Politcnica de Madrid -UPM El resto del cdigo se puede encontrar en las carpetas adjuntas. Si ahora se desea guardar los datos del bosque en un archivo binario, bastara con realizar: ofstream file("Bosque.txt",ios::binary); bosque.Write(file); Abriendo el archivo con un editor de textos se puede apreciar que el contenido es totalmente ininteligible. Si posteriormente se desea recuperar los datos del bosque desde dicho archivo se podra hacer: ifstream file("Bosque.txt",ios::binary); bosque.Read(file);
6.5. CONCLUSIONES Se ha presentado en este captulo la problemtica de la serializacion de datos y sus aplicaciones en persistencia (ficheros de datos) o comunicaciones. Asimismo se han introducido algunos ejemplos de tcnicas y estrategias que permiten realizar esta tarea de forma ordenada, con el correspondiente cdigo en los lenguajes C y C++. El ejemplo explicado es una aplicacin grafica, pero el uso de la serializacion es mucho mas extenso, tanto que los diseadores de sistemas de desarrollo, libreras y lenguajes ya la tienen en cuenta desde el comienzo, proporcionando dichos servicios de una u otra forma. Aunque en este tema se han explicado tcnicas que permiten al usuario realizar la tarea, se aconseja estudiar en detalle el sistema de desarrollo utilizado y libreras de terceros en el caso de proyectos software reales. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 113 Universidad Politcnica de Madrid -UPM
7 7 . .
BSQUEDAS EN UN ESPACIO DE ESTADOS MEDIANTE RECURSIVIDAD 7.1. INTRODUCCIN La bsqueda y la representacin del conocimiento son dos de los problemas fundamentales de la Inteligencia Artificial (IA). La bsqueda puede formalizarse mediante un espacio de estados que, a su vez, puede verse como un grafo donde los nodos representan estados de dicho espacio y los arcos dirigidos las reglas (operadores, transiciones etc.) que permiten el paso entre estados. La formalizacin de un problema de modo que se pueda resolver mediante algn tipo de bsqueda se denomina representacin del conocimiento. Un espacio de estados para un problema de bsqueda puede formalizarse como una cuadrupla <S, A, I, O> donde S representa el conjunto de estados (o configuraciones) posibles que pueden darse, A las acciones (reglas, operadores etc.) que permiten el paso entre estados, I la configuracin (o estado) inicial y O la configuracin (estado) objetivo a alcanzar. En el caso general, los conjuntos I y O pueden contener ms de un estado. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 114 Universidad Politcnica de Madrid -UPM 3 6 4 5 8 7 2 1 3 6 4 5 8 7 2 1 4 3 6 5 8 7 2 1 3 5 6 4 8 7 2 1 3 6 4 5 8 7 2 1 4 3 6 5 8 7 2 1 4 3 6 7 5 8 2 1 3 6 4 5 8 7 2 1 4 3 6 7 5 8 2 1 Inicio Objetivo a b c d e f g 3 6 4 5 8 7 2 1 3 6 4 5 8 7 2 1 4 3 6 5 8 7 2 1 3 5 6 4 8 7 2 1 3 6 4 5 8 7 2 1 4 3 6 5 8 7 2 1 4 3 6 5 8 7 2 1 4 3 6 7 5 8 2 1 3 6 4 5 8 7 2 1 4 3 6 7 5 8 2 1 Inicio Objetivo 3 6 4 5 8 7 2 1 4 3 6 7 5 8 2 1 Inicio Objetivo a b c d e f g
Figura 1. Ejemplo de un problema de bsqueda: el puzzle-8. En trazo grueso se ha representado el camino solucin. En la Figura 1 se muestra un ejemplo de problema de bsqueda extrado del mundo de los juegos. En el puzzle-8, 8 piezas numeradas del 1 al 8 y un hueco comparten una cuadrcula 3x3. El objetivo del juego es obtener una configuracin objetivo a partir de una configuracin de piezas y hueco dada. Las piezas solo pueden moverse en horizontal y vertical ocupando el hueco (casilla sombreada). La figura muestra un posible rbol de bsqueda generado para encontrar la solucin. Los nodos de dicho rbol son las configuraciones intermedias que se atraviesan durante la bsqueda y los arcos (o ramas) los posibles movimientos legales (en el ejemplo dos por cada estado, por lo que el rbol se denomina binario). En la figura se ha regruesado el camino solucin. Un aspecto fundamental de cualquier procedimiento de bsqueda es cmo evadir la explosin combinatoria de estados que pueden aparecer. Por ejemplo, en el puzzle-8 una solucin tiene de promedio unos 20 pasos. El factor de ramificacin (el nmero de estados descendientes posibles para un nodo cualquiera del rbol de bsqueda) tiene una media ligeramente menor que 3, con lo que el tamao del espacio de bsqueda est en torno a 20 9 3 10 , un nmero muy considerable teniendo en cuenta la aparente simplicidad del problema. En torno a 10 9 estados seran, pues, los recorridos por un procedimiento de bsqueda sistemtica exhaustivo que, mediante ensayo y error, generara todos los posibles estados intermedios entre el nodo raz y el objetivo. Este es el procedimiento de control de la bsqueda ms sencillo conocido como fuerza bruta. Existen una cantidad importante de algoritmos de control de propsito general para realizar bsquedas exhaustivas, conocidos como tcnicas de bsquedas desinformada (o tambin, bsqueda a ciegas) que conforman un marco genrico para cualquier problema planteado como una bsqueda en un grafo. Esta seccin se centra en la Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 115 Universidad Politcnica de Madrid -UPM implementacin de uno de ellos: la bsqueda primero en profundidad (Depth-First- Search o simplemente DFS) De manera informal, la bsqueda DFS consiste en elegir en todo momento para continuar la bsqueda al candidato que se encuentre a mayor profundidad. En el otro extremo se encuentra la bsqueda primero-en-anchura que se decanta por el candidato situado a menor profundidad de entre los posibles. Tomando como ejemplo nuevamente el problema planteado en la Figura 1, y suponiendo que en caso de empates se elije siempre el candidato ms a la izquierda, la seleccin de nodos sera {a, b, d, e, c, f , g} para la bsqueda DFS y {a, b, c, d, e, f, g} para la bsqueda primero en anchura. 7.2. BSQUEDA PRIMERO EN PROFUNDIDAD Muchos de los algoritmos que recorren grafos se describen con facilidad pero rara es la vez que no presentan dificultades a nivel de detalle. En el caso de bsquedas en grafos los algoritmos de control deben tener especial cuidado con la aparicin de estados repetidos y ciclos. Sin deteccin de ciclos es posible que la bsqueda quede atrapada en un bucle infinito (ver figura 2).
a b c e d a b c e d
Figura 2: Un grafo de bsqueda que presenta un ciclo. Sin un control de repeticin de estados la bsqueda podra quedar atrapada indefinidamente en {a, d, e, c}. Existen problemas donde la aparicin de los temidos ciclos simplemente no es posible. En estos casos el algoritmo de control se simplifica considerablemente y es ms rpido. A continuacin se describe el algoritmo primero-en-profundidad escrito en pseudocdigo: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 116 Universidad Politcnica de Madrid -UPM Procedimiento PRIMERO EN PROFUNDIDAD (INICIO, OBJETIVO) Inicializacin: ABIERTOS:={INICIO}, CERRADOS :={} REPETIR hasta alcanzar OBJETIVO o ABIERTOS est vaco 1. Quitar de ABIERTOS el elemento ms a la izquierda y llamarlo X 2. Generar los hijos de X 3. Aadir X a CERRADOS 4. Eliminar aquellos hijos de X que estn en ABIERTOS o en CERRADOS 5. Aadir los hijos de X a ABIERTOS por la izquierda
Como puede apreciarse, el algoritmo es completamente independiente del dominio. La bsqueda parte de un estado inicial INICIO y termina cuando se genera un sucesor que resulta ser el estado OBJETIVO. El bucle de control general lleva implcita dicha comprobacin, que se entiende puede realizarse en tiempo polinomial. De forma intuitiva, el procedimiento elige un candidato de los posibles, genera los sucesores y los guarda como nuevos candidatos a expandir. 7.2.1 Terminologa El trmino hijo en el pseudocdigo hace referencia a un sucesor directo, empleando la analoga entre un rbol de bsqueda y un rbol genealgico. As, es frecuente utilizar relaciones de parentesco para indicar la profundidad de la relacin (abuelo, bisabuelo, nieto etc.) Un nodo raz del que cuelga un subgrafo ser antecesor de todos los nodos de dicho subgrafo. Anlogamente, dichos nodos sern descendientes de aqul. La relacin de parentesco resulta inadecuada cuando existen ciclos en el grafo (como en el caso de la figura 2). Se denominan hojas a aquellos nodos del rbol de bsqueda que no tienen sucesores. La bsqueda no puede continuar por un nodo hoja teniendo que retroceder en el rbol a algn nodo antecesor, lo que se conoce como vuelta-atrs. 7.2.2 Estructuras de datos A pesar de la aparente sencillez del pseudocdigo se requieren, en el caso general, las siguientes estructuras de datos: Una lista de nodos ABIERTOS: Esta lista puede verse como una cola LIFO (Last In First Out) donde el ltimo elemento que entra es el primer elemento ledo. Si se visualiza la cola como una estructura horizontal donde los datos pueden entrar y salir por ambos extremos izquierda y derecha, una cola LIFO se consigue introduciendo y leyendo datos por el mismo lugar. Unas lista de nodos CERRADOS Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 117 Universidad Politcnica de Madrid -UPM Para cada nodo del rbol hay que almacenar la informacin del camino recorrido. Esto permite recuperar la trayectoria desde el nodo raz una vez alcanzado un estado OBJETIVO. En el algoritmo propuesto basta con almacenar para cada nodo examinado quin es su padre. La lista ABIERTOS almacena el conjunto de estados generados en cualquier momento de la bsqueda, pero todava no analizados (es decir, se desconocen sus posibles sucesores). Puede perfectamente producirse la paradoja de que un estado OBJETIVO se encuentre en ABIERTOS pero no sea seleccionado para continuar la bsqueda con lo que el procedimiento todava podra tardar un tiempo exponencial en darse cuenta que ya ha encontrado lo que buscaba. El conjunto de nodos por los que la bsqueda puede continuar en cualquier momento se denomina frontera y coincide con la lista de nodos en ABIERTOS para el algoritmo primero en profundidad. La lista CERRADOS corresponde con el conjunto de estados ya examinados (es decir, cuyos sucesores ya han sido generados y se encuentran en ABIERTOS). Esta lista es necesaria para controlar la aparicin de estados repetidos y ciclos durante la bsqueda). Dependiendo del problema particular, es posible que algunas de las estructuras y operaciones indicadas para el algoritmo no sean necesarias. Para ello es necesario realizar un anlisis previo del tipo de rbol de bsqueda que puede generarse. Como ejemplo, en problema de las 3 en raya no pueden producirse estados repetidos (en cada turno aparece una nueva pieza en el tablero). Un algoritmo primero-en- profundidad para decidir la mejor jugada en este caso no necesita comprobar si cada estado nuevo ya ha sido generado con anterioridad con lo que la lista CERRADOS es innecesaria El caso del puzzle-8 (figura 1) es el caso opuesto. En cada turno es posible realizar un movimiento que genera el nodo padre (en el ejemplo de la figura, el movimiento de la pieza 3 a la izquierda en el estado b, genera el nodo inmediatamente antecesor a. Ntese que en la figura se ha dibujado el grafo de bsqueda sin estados repetidos (es decir, un verdadero rbol). Operadores de transicin entre estados bidireccionales (muy frecuentes en problemas de enrutamiento) generan tambin ciclos durante la bsqueda. Una vez que se detecte esta posibilidad es necesario almacenar todos los estados de la bsqueda recorridos de manera dinmica, si se quiere garantizar que el procedimiento sea completo (es decir, que encuentre una solucin si la hubiere). 7.2.3 Anlisis El pseudocdigo no presenta grandes dificultades. En cada iteracin de elije un nodo frontera en ABIERTOS (lnea 1), se calculan sus sucesores directos (lnea 2) y se aade dicho nodo a CERRADOS (lnea 3), puesto que ya ha sido analizado. La lnea 4 es necesaria para gestionar sucesores repetidos y ciclos. Un sucesor nuevo repetido puede estar en ABIERTOS (en cuyo caso se ha generado con anterioridad pero an no se han examinado) o en CERRADOS, en cuyo caso se expandi con anterioridad en el grafo. Todos los sucesores recin generados se eliminan si estn bien en ABIERTOS, bien en CERRADOS y solo los que quedan se aaden a la cola (lnea 5). Para conseguir Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 118 Universidad Politcnica de Madrid -UPM que la cola sea LIFO, condicin imprescindible para que la bsqueda sea primero en profundidad, se incluye la direccin de carga y descarga, en este caso por el mismo lado. La figura 3 ejemplifica la generacin de un rbol de bsqueda primero en profundidad para un espacio de estados dado. a b c f a b c e d a b c d e a b e d f a b e d f a b e d f a c c c a b c f a b c e d a b c d e a b e d f a b e d f a b e d f a b e d f a b e d f a c c c
Figura 3. Ejemplo de bsqueda primero-en-profundidad para el espacio de estados recuadrado en la figura. A medida que la bsqueda avanza el rbol evoluciona de izquierda a derecha y de arriba abajo. En computacin se dice que un algoritmo es correcto si para cualquier solucin candidata que genera, dicha solucin satisface las especificaciones del problema. Ms fuerte es el requisito de completitud. Un algoritmo se dice que es completo cuando si existe una solucin la encuentra. Es interesante destacar que, de forma un tanto sorprendente, la bsqueda primero en profundidad (DFS) no garantiza la completitud en el caso general. Esto es as porque cabe la posibilidad de que el algoritmo se pierda en ramales de profundidad infinita y nunca llegue a examinar el camino o caminos que llevan al estado OBJETIVO. Imagine el lector que quiere saber si es un descendiente directo de Abraham Lincoln y dispone del conocimiento necesario para ello. Si decide emplear una bsqueda DFS en sentido inverso (es decir, analizando padres, abuelos, bisabuelos etc. con la esperanza de encontrar a Lincoln) una bsqueda DFS podra retrotraerse hasta la prehistoria an en el caso altamente improbable de que s fuera descendiente directo. Dicho en otros trminos, si el algoritmo DFS se ejecuta indefinidamente, no es posible concluir ni a favor ni en contra de la premisa de partida. Por el contrario, el requerimiento en memoria es muy modesto. Como puede verse en la figura, DFS solo necesita almacenar un nico camino desde el nodo raz al nodo actual junto con todos los nodos sucesores generados por ese camino. As pues, el problema de DFS reside en el tiempo de cmputo pero no en la cantidad de memoria que necesita para su ejecucin. La figura 4 muestra una traza completa del algoritmo DFS para un problema de enrutamiento. Como puede apreciarse, la lista ABIERTOS coincide en todo momento con la frontera de la bsqueda. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 119 Universidad Politcnica de Madrid -UPM a b c d e {e, d, b, a} {c} 4 {c ,e, d, b, a} {} 5 {d, b, a} {e, c} 3 {b, a} {d, e, c} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it. a b c d e a b c d e {e, d, b, a} {c} 4 {c ,e, d, b, a} {} 5 {d, b, a} {e, c} 3 {b, a} {d, e, c} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it. {e, d, b, a} {c} 4 {c ,e, d, b, a} {} 5 {d, b, a} {e, c} 3 {b, a} {d, e, c} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it.
Figura 4. Traza de una bsqueda primero en profundidad sobre el espacio de estados que aparece a la izquierda. 7.3. BSQUEDA PRIMERO EN ANCHURA Aunque esta seccin est dedicada a una implementacin prctica de la bsqueda DFS, resulta interesante compararla con otra tcnica de bsqueda sistemtica desinformada denominada primero-en-anchura. En este caso, la estrategia de seleccin de nodos consiste en elegir aquel candidato en ABIERTOS que se encuentre a menor profundidad. Intuitivamente, el rbol se genera horizontalmente o en anchura lo que da el nombre a esta tcnica. La implementacin primero-en-anchura (BFS, del ingls Breadth-First-Search) es esencialmente idntica a la bsqueda DFS solo que, en este caso, los nodos que entran deben extraerse de la lista en primer lugar, es decir, la lista ABIERTOS es, en este caso, un cola FIFO. Si se modifica la lnea 5 del pseudocdigo DFS para que los sucesores de X se almacenen en ABIERTOS por la derecha entonces se transforma en una bsqueda primero-en-anchura. La figura 5 muestra la nueva traza para el mismo espacio de estados empleado en la figura 4. a b c d e {d, c, b, a} {e} 4 {e, d, c, b, a} {} 5 {c, b, a} {d, e} 3 {b, a} {c, d, e} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it. a b c d e a b c d e {d, c, b, a} {e} 4 {e, d, c, b, a} {} 5 {c, b, a} {d, e} 3 {b, a} {c, d, e} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it. {d, c, b, a} {e} 4 {e, d, c, b, a} {} 5 {c, b, a} {d, e} 3 {b, a} {c, d, e} 2 {a} {b, c} 1 {} {a} 0 CERRADOS ABIERTOS N it.
Figura 5. Traza de una bsqueda primero en anchura sobre el espacio de estados que aparece a la izquierda. Los nodos recin generados se incorporan a ABIERTOS por la derecha. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 120 Universidad Politcnica de Madrid -UPM Al recorrer un espacio de estados primero-en-profundidad, la frontera de la bsqueda pasa a ser el conjunto de nodos en un mismo nivel del rbol y sus posibles sucesores (a diferencia de la bsqueda DFS que almacenada solamente el camino desde la raz). Este hecho hace que la memoria requerida por BFS sea exponencialmente mayor que la bsqueda DFS. Un ejemplo: el nmero de nodos hoja de un rbol uniforme con factor de ramificacin 5 y profundidad 6 es 5 6 =15625, el orden de magnitud de los nodos que el procedimiento BFS debera mantener en memoria. Un algoritmo DFS equivalente necesitara aproximadamente 5x6=30, es decir 5 nodos por cada nivel. Sin embargo BFS, a diferencia de DFS, es completo. Esto quiere decir que garantiza encontrar una solucin al problema si sta existe. La demostracin es trivial. Si existe una solucin al problema, sta debe encontrarse en una profundidad finita del rbol d. Como BFS expande primero en anchura, completar el nivel d del rbol antes de pasar a niveles superiores, con lo que encontrar la solucin en un tiempo finito. Como se explic en la seccin 7.2, el algoritmo primero en profundidad puede perderse en una rama de profundidad infinita y nunca llegar a encontrar la solucin (en una profundidad d pero en un camino distinto). En teora de computacin es muy frecuente la dicotoma espacio-tiempo. Un procedimiento que consume mucha memoria es, en la mayora de los casos, ms eficiente que un procedimiento equivalente que consume menos. La dicotoma es perfectamente aplicable a las tcnicas BFS y DFS. 7.4. METODOLOGA GENERAL DE RESOLUCIN DE UN PROBLEMA DE BSQUEDA MEDIANTE COMPUTACIN Antes de abordar el problema de la implementacin es importante destacar que existen un conjunto de consideraciones previas y tareas a realizar para la implementacin de un procedimiento eficiente que resuelva un problema de bsqueda genrico. Dicho problema se presupone bien formado. Entre las tareas a realizar destacan: Definicin del problema de una manera formal: Por ejemplo, para un problema de enrutamiento definir con precisin las reglas de movimiento entre ciudades, para el puzzle-8 el movimiento de las piezas en horizontal y vertical etc. Anlisis: En esta fase se estudia minuciosamente el problema ya formalizado para determinar aquellas caractersticas que puedan tener influencia en las tcnicas de bsqueda que se van a emplear en su resolucin. Por ejemplo, si existe una explosin combinatoria en el nmero de estados, y no se tiene conocimiento especfico del dominio para guiar la bsqueda, entonces BFS no es recomendable por que consume excesiva memoria. Aislamiento y representacin adecuada del conocimiento necesario: Entre otras tareas, resulta extremadamente relevante para la eficiencia global del procedimiento de bsqueda una representacin adecuada de Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 121 Universidad Politcnica de Madrid -UPM la nocin de estado y de los operadores que permiten la transicin entre estados. En la figura 6 se muestran dos representaciones alternativas de los operadores de movimiento para el puzzle-8. Se deja al criterio del lector el decidir cul de las dos sera ms adecuada con vistas a la implementacin de un algoritmo de bsqueda para ese problema. Eleccin adecuada de la mejor tcnica de bsqueda: A partir de la informacin adquirida en las etapas anteriores se decide cul de las numerosas tcnicas de bsqueda de propsito general es ms adecuada para el problema particular. Si la eleccin se restringe a una bsqueda DFS o BFS, un tamao de espacio de estados grande apunta hacia la tcnica DFS, a expensas de perder completitud en el caso peor. Para tamaos razonables de espacio de estados se aconseja la bsqueda primero en anchura que es completa. 5 3 6 4 8 7 2 1 5 3 6 4 8 7 2 1 5 3 6 4 8 7 2 1 5 3 6 4 8 7 2 1 5 3 6 4 8 7 2 1
Figura 6. Ejemplo de dos representaciones de los operadores de movimiento en el puzzle-8: las piezas hacia el cuadro vaco (derecha) o el cuadro vaco hacia las piezas (izquierda). 7.5. IMPLEMENTACIN DE UNA BSQUEDA DFS MEDIANTE RECURRENCIA La recurrencia es una tcnica de programacin que consiste en especificar la ejecucin de un proceso mediante su propia definicin. Un algoritmo recursivo es, por tanto, aqul que plantea la solucin a un problema en trminos de una llamada a s mismo, lo que se conoce como llamada recurrente (o recursiva). Existen ejemplos de recurrencia en todas las reas de las ciencias. En matemticas una funcin recursiva f(x) es ( ) 3 ( 3) f x f x . En informtica el ejemplo tpico para ilustrar recurrencia es un algoritmo para computar el factorial de un nmero como el que se muestra a continuacin: //Procedimiento Factorial(n) int factorial(int n) { if(n<2) return 1;
return n*factorial(n-1); } Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 122 Universidad Politcnica de Madrid -UPM Como puede apreciarse en el ejemplo, lo que realmente est ocurriendo es que en cada nivel de recursin el problema se va descomponiendo en problemas iguales pero de menor tamao (el problema original tiene tamao n, tras la primera llamada recursiva pasa a ser de tamao n-1 etc.). El problema ms pequeo es el factorial de 1 que se resuelve directamente en la segunda lnea de la funcin. Es importante destacar que la lnea de cdigo if(n<2) return 1; no solamente resuelve el factorial de 1 sino que permite salir de la recursin. Sin una sentencia de control de este tipo, el procedimiento quedara indefinidamente atrapado en un bucle infinito, debido a la circularidad inherente a la tcnica. Es fundamental garantizar que la condicin de salida (en este caso n<2) se cumple en un nivel de recurrencia finito. En el ejemplo y puesto que la llamada a Factorial se realiza con un valor una unidad menos que en el nivel anterior, resulta evidente que la condicin de salida se va a cumplir siempre en el nivel de recursin n-2 y, por tanto, el procedimiento tiene que terminar. La sencillez del procedimiento Factorial permite analizar fcilmente el flujo de ejecucin. En cada nueva llamada a Factorial el flujo entra por la primera lnea de la funcin (justo despus de {) y puede salir debido a la instruccin return 1; o bien por la instruccin return n*factorial(n-1); En ambos casos, el flujo contina en la funcin del nivel de recurrencia anterior justo donde se realiz la llamada; es decir, se devuelve el resultado de la operacin y el flujo completa le ejecucin de la ltima lnea. Esto se puede ver con ms claridad aadiendo una variable intermedia al cdigo de la siguiente manera: //Procedimiento Factorial(n) int factorial(int n) { int resultado; if(n<2) return 1; resultado = factorial(n-1); return n*resultado; } Como resumen, al emplear recurrencia hay que tener en cuenta siempre que el flujo de ejecucin cumple con las especificaciones del problema, prestando especialmente atencin a la condicin de salida. 7.5.1 La pila de llamadas Al ejecutar cualquier proceso, los sistemas operativos le asignan un espacio en memoria para cubrir sus necesidades, espacio que no puede ser utilizado por el resto de procesos en ejecucin. Este espacio reservado se conoce como rea de memoria del proceso. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 123 Universidad Politcnica de Madrid -UPM La pila de llamadas es una pila de datos LIFO en el rea de memoria de un proceso. La funcin principal es almacenar el punto donde devolver el control del flujo de ejecucin una vez terminada la funcin (o subrutina) activa en ese momento. De esta manera se pueden invocar funciones dentro de otras funciones sin perder el hilo de ejecucin. Cada nueva llamada introduce la direccin de retorno a la funcin invocante en la pila y empuja al resto de direcciones. Al terminar dicha funcin se lee la primera direccin de la pila como punto de retorno. Una de las ventajas adicionales de la pila de llamadas es que soporta recurrencia. Para la pila, el hecho de que una funcin A llame a una funcin B o se llame a s misma es irrelevante; basta almacenar en la pila la direccin de la instruccin siguiente a ejecutar una vez termine. En el ejemplo del Factorial, la direccin correspondera a la lnea resultado = factorial(n-1); Adicionalmente la pila de llamadas puede emplearse, entre otras cosas, para almacenar de forma eficiente las variables locales pertenecientes a la funcin activa. Estas variables pierden su valor una vez que termina la funcin. La pila puede realizar esta reserva de forma muy eficiente, reubicando el puntero de pila. Como desventaja, hay que decir que el rea de memoria reservada para la pila es bastante limitada. Cuando se sobrepasa aparece el tpico error en tiempo de ejecucin de desbordamiento de pila (o stack overflow), bien conocido por los programadores. Como ejemplo compile y ejecute este cdigo escrito en C++: #include <iostream.h> #define MAX_SIZE 100 #define MAX_DEPTH 100
void ProcRecursivo(int k) { int vector[MAX_SIZE][MAX_SIZE];
void main() { cout<<"Comienzo de recursion"<<endl; try{ ProcRecursivo(0); } catch(...){ cout<<"Stack Overflow"<<endl; } cout<<"Fin de recursion"<<endl; } Este cdigo dispone de una funcin ProcRecursivo que se llama de forma recursiva y que tiene como nica misin inicializar una matriz de enteros a cero en cada nivel de recurrencia. Obsrvese que, al estar cada matriz declarada localmente, el compilador, por defecto, reservar espacio en memoria en la pila de llamadas. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 124 Universidad Politcnica de Madrid -UPM ProcRecursivo se llama a s mismo incrementando previamente en una unidad el valor que controla la salida de la recursin. Cuando la recurrencia alcanza el nivel MAX_DEPTH se produce la primera vuelta atrs. A partir de este momento se va liberando de forma secuencial la pila de llamadas hasta retornar al nivel de recurrencia 0 de partida. La funcin main llama a ProcRecursivo. Las instrucciones try y catch en C++ (y en muchos otros lenguajes de alto nivel) sirven para gestionar la aparicin de excepciones en tiempo de ejecucin. try encapsula entre llaves aquellas instrucciones susceptibles de producir algn tipo de excepcin y catch encapsula entre llaves las tareas a realizar si se producen (los manipuladores de las excepciones). Finalizado el bloque catch, el flujo de ejecucin contina con la siguiente instruccin despus del bloque. La sintaxis catch(...) indica que el bloque contiene los manipuladores para cualquier tipo de excepcin (incluyendo excepciones especficas de C). En el ejemplo, la excepcin slo puede producirse por desbordamiento de pila. En tal caso aparecera el mensaje Stack Overflow en pantalla para despus continuar con la ejecucin de la instruccin que muestra la cadena Fin de recursin en pantalla. La ejecucin del cdigo anterior para valores de MAX_DEPTH = 100 y MAX_SIZE=100 (Pentium D@3GHz, 1GB RAM sobre Windows XP) ya produce desbordamiento de pila utilizando el compilador Visual Studio 6.0. Por defecto el compilador otorga 1 MB de memoria a la pila de llamadas, y en este caso, la memoria ocupada es 100 100( ) 100( ) 2(int) 2 x matriz x nivel x MB producindose el desbordamiento. Si se modifican las opciones del compilador y se reserva 10MB de memoria virtual para la pila (opcin /stack:0x10000000) ya no se produce la excepcin. Es interesante comentar que el desbordamiento de pila tampoco se va a producir si se compila con la opcin de mxima velocidad (/O2) debido a que una de las optimizaciones que realiza el compilador es reservar memoria para la matriz bidimensional fuera de la pila. Con esta opcin de compilacin activada, el tamao de la matriz vector deja de constituir un problema para valores de MAX_SIZE muy superiores a 100. 7.5.2 Bsqueda DFS como recursin El algoritmo para un procedimiento genrico de bsqueda primero en profundidad descrito en la seccin 7.2 estaba formulado de manera iterativa sobre una estructura de datos LIFO. En cada iteracin se va modificando la lista ABIERTOS hasta que, o bien se elige un estado OBJETIVO de dicha lista o bien la lista queda vaca, en cuyo caso el procedimiento termina sin encontrar una solucin. Para simplificar, se asume en este apartado que el grafo de bsqueda no tiene ciclos ni estados repetidos con lo que se puede prescindir de la estructura CERRADOS. Una manera alternativa de entender el procedimiento DFS es la de una recursin donde la tarea que se repite en diferentes niveles est formada por las subtareas siguientes: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 125 Universidad Politcnica de Madrid -UPM Extraccin de un nodo de ABIERTOS (y eliminar) Generacin de sus sucesores Aadir dichos sucesores a la lista de ABIERTOS A su vez, la salida de la recursin (vuelta atrs de la funcin activa en ese momento) se produce cuando: Se ha encontrado un estado OBJETIVO No se han encontrado sucesores para el nodo actual: lo que implica que es un nodo hoja y hay que volver atrs para continuar por otro camino Si se estima que la dimensin del espacio de estados es relativamente pequea, se puede emplear la propia pila para almacenar los estados de ABIERTOS en el nivel del rbol que se corresponde con el nivel de recursin. Intuitivamente la lista ABIERTOS se divide por niveles y los nodos de cada nivel se declaran como variables locales en la pila de llamadas. El algoritmo recursivo DFS modificado para permitir que la pila de llamadas gestione la frontera de la bsqueda se muestra en la figura 7:
Procedimiento DFS_RECURSIVO (ACTUAL, OBJETIVO) Valor inicial: ACTUAL = Estado inicial 1. Si ACTUAL = OBJETIVO finalizar 2. Generar los hijos de ACTUAL y almacenar en L (variable local) 3. REPETIR hasta que L est vaco a. Seleccionar un nodo de L y llamarlo X b. DFS_RECURSIVO (X, OBJETIVO) c. Borrar X de L
Figura 7. Procedimiento recursivo primero en profundidad que permite emplear la pila de llamadas para almacenar la frontera de la bsqueda. Tomando como ejemplo el espacio de estados de la figura 4, la primera llamada a la funcin recursiva de bsqueda almacenara localmente los nodos {b,c}, hijos del nodo raz. Una nueva llamada pasando como parmetro el nodo b almacenara en la nueva lista local los nodos {d, e} descendientes directos el nodo actual. Al ser d un nodo hoja, la expansin de dicho nodo provoca que la funcin termine tras detectarse en la lnea 3 que no hay descendencia. Tras la vuelta-atrs, la ejecucin continua por la lnea 3.c y se selecciona e el ltimo nodo abierto en este nivel de recurrencia (nivel 3 del rbol de bsqueda). Tras sucesivas vueltas atrs se expande el nodo c y finaliza la bsqueda. La ventaja del algoritmo de la figura 7 es que aprovecha la forma en que el Sistema Operativo gestiona la ejecucin de procesos en memoria para implementar la lista ABIERTOS en una bsqueda DFS. La frontera de la bsqueda se divide por niveles en el rbol y los nodos en cada nivel se almacenan por separado y de manera local a la correspondiente funcin. La pila de llamadas se encarga de borrar la estructura de Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 126 Universidad Politcnica de Madrid -UPM datos cuando se produce la vuelta atrs, una vez que se han analizado todos los nodos en el nivel de recurrencia actual. El procedimiento recursivo descrito para computar el factorial de un nmero puede verse tambin como una bsqueda en un grafo. Desde esta perspectiva, el espacio de estados tiene forma de rbol con una nica rama donde el estado inicial es el factorial del nmero buscado y el estado objetivo tiene el valor unidad (factorial de 1). El procedimiento recorre el rbol hacia delante hasta alcanzar dicho estado (nivel de recurrencia mxima). La solucin al problema se encuentra en el propio camino, y se genera durante las sucesivas vueltas atrs. 7.5.2.1. Generacin de una clave Como ejemplo sencillo de todo lo expuesto se propone como problema a resolver el encontrar la clave de un nmero de 6 dgitos entre 0 y 9 que controla el acceso a una cuenta de usuario en un servidor remoto. El procedimiento a realizar tiene que generar todas las combinaciones posibles de la clave (10 6 ) y bombardear al servidor. Se considera aqu solamente la rutina generadora de claves posibles. Este problema puede abordarse de forma trivial mediante un procedimiento iterativo empleando bucles anidados.; cada bucle genera un nmero de la clave y el bucle ms interior (en este caso el sexto) es el que genera la clave completa. La solucin en C sera la siguiente: #include <iostream.h> #define TAM_NUMEROS 10 #define TAM_CLAVE 6
void main() { int clave[TAM_CLAVE];
for(int i=0; i< TAM_NUMEROS; i++) for(int j=0; j< TAM_NUMEROS; j++) for(int k=0; k< TAM_NUMEROS; k++) for(int l=0; l< TAM_NUMEROS; l++) for(int m=0; m< TAM_NUMEROS; m++) for(int n=0; n< TAM_NUMEROS; n++){ //Generando clave clave[0]=i; clave[1]=j; clave[2]=k; clave[3]=l; clave[4]=m; clave[5]=n; cout<<i<<j<<k<<l<<m<<n<<endl; } } Este problema puede tambin enfocarse como un problema de bsqueda y resolverse mediante la exploracin de un espacio de estados mediante la tcnica de primero en profundidad. Un posible cdigo para la implementacin recursiva que genera todas las posibles claves es: #include <iostream.h> #define TAM_CLAVE 6 #define TAM_NUMEROS 10 Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 127 Universidad Politcnica de Madrid -UPM
//Generacin de sucesores y llamada recursiva clave[depth]=-1; for(int j=0; j<TAM_NUMEROS; j++){ clave[depth]+=1; FuncRec(depth+1); } }
void main() { FuncRec(0); } Como en ejemplos anteriores existe una funcin (FuncRec) que de forma recursiva atraviesa el espacio de bsqueda de claves generando las 10 6 combinaciones. La configuracin del estado se almacena, en este ejemplo, en un vector global clave que es el que se va modificando en cada transicin. La verdadera clave es el valor de esta estructura de datos en un nodo hoja del rbol de bsqueda. La semntica detrs de cada nodo del rbol para una profundidad k es el conjunto de claves que tienen como valores en ndices 0, 1, 2,, k-1 predeterminados por el camino desde el nodo raz hasta el nodo actual. El subgrafo que cuelga de dicho nodo conforma el espacio del resto de posibles claves con valores , 1, , 1 k k tamaodeclave . Cuando la profundidad es exactamente 6 la construccin de la clave est completa. Entonces se presenta en pantalla y se produce la vuelta atrs: if(depth == TAM_CLAVE) //Salida de recursin { for(int i=0; i<TAM_CLAVE; i++) cout<<clave[i]; cout<<endl; return; //Vuelta atrs } La generacin de sucesores se lleva a cabo en las lneas de cdigo: clave[depth]=-1; for(int j=0; j<TAM_NUMEROS; j++) clave[depth]+=1; La primera instruccin inicializa la configuracin del estado en el nivel de profundidad siguiente (en el nivel de recursin k se generan los sucesores con valores de 0 a 9 en la posicin k-sima de la clave). En este caso no se almacenan todos los sucesores localmente en cada nivel sino que segn se van generando se llama a la funcin de siguiente nivel. El cdigo completo de generacin y llamada es: Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 128 Universidad Politcnica de Madrid -UPM clave[depth]=-1; for(int j=0; j<TAM_NUMEROS; j++) { clave[depth]+=1; FuncRec(depth+1); } El procedimiento es completo y para ello basta con analizar la forma del rbol de bsqueda. El rbol tiene 6 niveles de profundidad y en cada nivel, todos los nodos tienen exactamente 10 hijos (lo que se conoce como factor de ramificacin del rbol), por lo que en el ltimo nivel hay exactamente 10 6 nodos hoja que son el nmero de claves posibles a generar. 7.5.2.1.1 Comparativa entre ambos algoritmos Desde la perspectiva de la complejidad computacional, el algoritmo iterativo es ms eficiente en tiempo ya que el algoritmo recursivo tiene que generar no solamente los nodos hoja sino el resto del rbol. El nmero de nodos totales N de un rbol uniforme con factor de ramificacin b y profundidad d es:
2 3 1 1 d d N b b b b b En el ejemplo b=10 y d = 6 con 10 6 hojas y 1+10+100+1000+10000+100000 =111.111 nodos adicionales hasta completar la totalidad del rbol (aproximadamente un 11%). Adems los compiladores modernos consiguen buenas optimizaciones de iteraciones pero no de recurrencias. En la parte positiva del cdigo recursivo cabe destacar: Es ms compacto: El nmero de sentencias que necesita es claramente ms corto y adems no depende del tamao de la clave. Lamentablemente no se puede decir lo mismo de la legibilidad. Es parametrizable completamente: El algoritmo iterativo permite definir un parmetro TAM_NUMEROS configurable pero no permite definir el parmetro TAM_CLAVE. Esto quiere decir que habr que aadir tantas sentencias for como nmeros tenga la clave, lo que no ocurre en la versin recursiva. En cuanto a los requisitos en espacio, ambas implementaciones presentan un buen comportamiento. En el caso de la versin recursiva solamente se emplea la pila de llamadas para pasar el parmetro profundidad que es la nica informacin que se requiere para construir los nodos sucesores. Los diferentes estados se generan al vuelo actualizando una nica variable global clave. En este ejemplo la versin iterativa es ms intuitiva porque el problema de desciframiento de claves se presta a ello. Sin embargo, existen muchos otros problemas donde no es fcil, ni mucho menos intuitivo, implementar el control de las iteraciones para conseguir la solucin. Para estos problemas y debido al buen comportamiento de la bsqueda primero en profundidad en cuanto al consumo de memoria, el uso de recursin es preferible. Los algoritmos ms eficientes para muchos problemas NP-Duros (como por ejemplo el problema del Mximo Clique) se implementan mediante esta tcnica. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 129 Universidad Politcnica de Madrid -UPM 7.5.2.2. Permutaciones Un ejemplo ligeramente ms complicado es la generacin de permutaciones de N nmeros mediante una bsqueda recursiva primero en profundidad. En este caso el estado se almacena en un vector de N nmeros denominado permuta. En el nodo raz permuta tiene todos sus elementos a cero y contiene el valor de la permutacin en los nodos hoja. Como en el ejemplo anterior, los nodos intermedios del rbol sirven para ir rellenando la estructura de datos de forma adecuada. Para permutaciones de N nmeros, el rbol de bsqueda tiene profundidad N+1, donde el nivel 0 corresponde al nodo raz y el nivel N al de las N! hojas solucin. La funcin recursiva propuesta bien escribe el valor cero en el vector permuta o bien escribe el valor del nivel en el rbol del nodo actual. Un valor cero en permuta indica al generador de sucesores que esa posicin debe ser rellenada en niveles superiores y un valor distinto de cero determina el valor de la permutacin en esa posicin para cualquier nodo sucesor. De manera intuitiva, la funcin recursiva genera tantos sucesores como valores a cero (o huecos) tiene permuta en el momento de la invocacin. Inicialmente, permuta tiene todos los valores a cero con lo que tendr N sucesores en el nivel 1, lo que se corresponde con las diferentes posiciones del 1 en las N! permutaciones. En la llamada recursiva del nivel 2, permuta ya tiene puesto el 1 en alguna posicin con lo que el nmero de sucesores ser N-1, las diferentes posiciones que puede ocupar el 2 en el conjunto de permutaciones posibles fijado ya el 1. La bsqueda contina expandiendo nodos hasta alcanzar las hojas en el nivel N, en cuyo caso permuta est completa (carece de huecos) y se produce la vuelta atrs. La figura 8 muestra el rbol de estados completo para el procedimiento propuesto con N = 3. 0 0 0 0 0 1 0 1 0 1 0 0 0 2 1 2 0 1 0 1 2 2 1 0 1 0 2 1 2 0 3 2 1 2 3 1 3 1 2 2 1 3 1 3 2 1 2 3 0 1 2 3 0 0 0 0 0 1 0 1 0 1 0 0 0 2 1 2 0 1 0 1 2 2 1 0 1 0 2 1 2 0 3 2 1 2 3 1 3 1 2 2 1 3 1 3 2 1 2 3 0 1 2 3
Figura 8. rbol de bsqueda para generar permutaciones de 3 nmeros mediante la tcnica de primero en profundidad. Como en el ejemplo anterior, ocurre que el nmero de estados generados es superior al nmero de permutaciones solucin. Para el rbol de la figura se puede demostrar que el nmero de nodos computados (llamadas a la funcin recursiva) ser ms del doble y menos del triple de las permutaciones posibles (por ejemplo, para N = 4 los nodos visitados son 65 y existen 24 permutaciones posibles). Una posible implementacin de la funcin recursiva que recorre el rbol de la figura 8 primero en profundidad es:
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 130 Universidad Politcnica de Madrid -UPM int nivel = -1; void FuncRec(int k) { nivel++; permuta[k] = nivel; if(nivel == N){ //Nodo hoja: Permutacin generada Mostrar(); } else{ for (int pos = 0; pos < N; pos++){ if (permuta[pos] == 0) FuncRec(pos); } } nivel--; permuta[k] = 0; } La funcin emplea la variable global nivel para llevar la cuenta del nivel de profundidad del rbol generado y permuta para almacenar las permutaciones y guiar la bsqueda. La informacin pasada en cada llamada es la posicin en permuta donde se va a aadir el valor correspondiente al siguiente nivel de profundidad. Nada ms entra en la funcin se determina el primer estado sucesor: nivel++; permuta[k] = nivel; para despus comprobar si se est hoja, en cuyo caso se muestra la permutacin completa en pantalla: if(nivel == N) Mostrar(); En caso de que el nodo actual no sea un nodo hoja se generan el resto de estados sucesores que, como se explic anteriormente, correspondern a valores nulos de permuta. Esta condicin se verifica justo antes de la expansin: for (int pos = 0; pos < N; pos++) if (permuta[pos] == 0) FuncRec(pos); Finalmente, tanto si es un nodo hoja como si no, se borra en el estado del nivel anterior la ltima modificacin de permuta para conseguir que el generador de sucesores en dicho nivel funcione correctamente. En el nivel 1 del rbol de bsqueda en la figura 8, esto equivale a borrar el 1 de permuta[0] justo antes de la vuelta atrs al nodo raz, para que el nuevo nodo sucesor sea en efecto permuta={0,1,0} y no permuta={1,1,0}. En este segundo caso, los sucesores que se generaran no seran correctos. El cdigo que realiza el borrado es: nivel--; permuta[k] = 0; Inicialmente nivel se inicializa a -1 para que la primera llamada a FuncRec corresponda con el nivel 0 que sirve como ndice de la primera modificacin de permuta. Permuta arranca con todo ceros. El cdigo completo que muestra todas las permutaciones de 4 nmeros en pantalla es el siguiente: #include <iostream.h> #define N 4
int nivel=-1; int permuta[N]; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 131 Universidad Politcnica de Madrid -UPM void Mostrar(){ for (int i = 0; i < N; i++) cout<<permuta[i]; cout<<endl; }
void main(){ for (int i = 0; i < N; i++) permuta[i] = 0; FuncRec(0); } Cabe destacar que tanto en el generador de permutaciones como en el generador de claves no se ha seguido estrictamente en la implementacin el pseudocdigo descrito en la figura 7. En particular, no se ha empleado la pila de llamadas para almacenar toda la informacin de los estados frontera en cada nivel del rbol por dos razones: Era posible mantener una nica estructura de datos global y modificarla localmente para conseguir representar todos los estados del rbol de bsqueda y En ambos ejemplos se ha generado la informacin relativa a las transiciones de forma secuencial con las llamadas recursivas de forma que resultaba innecesario almacenar todos los estados nuevos de golpe. En la prctica ambas condiciones no son demasiado frecuentes y es ms habitual encontrar implementaciones que siguen exactamente el pseudocdigo descrito en la figura 7, con la siguiente salvedad: si el tamao del espacio de estados es muy grande o si se busca mxima eficiencia, la reserva de espacio en memoria reservado para variables locales a la funcin recursiva resulta excesivamente lenta ya que se debe asignar y liberar en cada llamada. En estos casos, la solucin habitual pasa por reservar a priori el espacio en memoria para todos los estados del rbol (siempre que sea posible) antes de lanzar el procedimiento de bsqueda recursivo. En resumen, los identificadores que reservan espacio en memoria para la informacin de los estados del rbol deben ser globales a la funcin recursiva si se busca una mxima eficiencia. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 133 Universidad Politcnica de Madrid -UPM
8 8 . .
EJECUCIN DISTRIBUIDA DE TAREAS 8.1. INTRODUCCIN La problemtica de la ejecucin distribuida de tareas est hoy en da plenamente vigente despus del gran desarrollo que ha tenido Internet. En la prctica existen innumerables problemas computacionales donde se produce una fuerte explosin combinatoria que no son abordables adecuadamente por una nica unidad de proceso. Para estos casos, el rpido desarrollo de Internet est llevando, cada vez ms, al empleo de los tiempos muertos de la ingente capacidad de procesamiento conectada a la red para realizar, lo que podra denominarse, supercomputacin distribuida. Entre los numerosos ejemplos de este tipo de procesamiento cabe destacar el cmputo del genoma humano. El problema de la computacin distribuida o descentralizada est estrechamente ligado con el de la computacin paralela. En este caso, los avances tecnolgicos han permitido la aparicin de nuevos procesadores formados por mltiples ncleos (unidades de procesamiento) que ya se comercializan a gran escala. Por ejemplo, los procesadores Cell, desarrollados conjuntamente por Sony, IBM y Toshiba en el 2001, aceleran notablemente aplicaciones de procesado de vectores y multimedia. La videoconsola PlayStation3 de Sony fue su primera gran aplicacin. Otro ejemplo interesante es el gran avance que han tenido la arquitectura de las tarjetas grficas modernas, hasta el punto de que muchos clculos pueden llevarse a cabo ahora ms rpidamente por su unidad de procesamiento (conocida como GPU), en comparacin con las CPUs tradicionales. Tanto la computacin distribuida como la computacin en paralelo se basan en la descomposicin del procedimiento a realizar en subtareas, lo ms independientes posibles, de tal modo que la solucin final se pueda generar con cierta facilidad a partir Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 134 Universidad Politcnica de Madrid -UPM de las soluciones de cada una de las partes. Existen problemas fcilmente paralelizables (como por ejemplo la generacin de claves o el problema de las N- Reinas) y otros mucho menos aptos para ello (e.g. muchos problemas no triviales de optimizacin o el algoritmo de bsqueda mini-max). Las dificultades de trocear un problema en partes adecuadas para su computacin por separado se pueden clasificar en 3 grandes grupos. Estos son: Necesidad de comunicacin entre las unidades de procesamiento, con el consiguiente incremento en el tiempo de cmputo total. Particiones no independientes: En la mayora de problemas importantes es casi imposible un fraccionamiento en partes totalmente independientes. En el caso general, aparecen problemas de sincronizacin derivados de que unas unidades de procesamiento necesitan esperar la finalizacin de otras para continuar. Repeticin de tareas: En muchos casos no se puede evitar fraccionamientos con solapamiento. Esto hace que se pueda estar ejecutando a la vez la misma tarea en diferentes unidades de procesamiento. En este captulo se muestra detalladamente un ejemplo de computacin distribuida para un problema clsico del mundo de los juegos: el problema de las N- Reinas. 8.2. EL PROBLEMA DE LAS N-REINAS
8.2.1 Historia El problema de las 8-Reinas consiste en colocar en un tablero de ajedrez de dimensiones 8x8, ocho reinas tal que ninguna se ataque entre s de acuerdo con las reglas del ajedrez. La generalizacin del problema a un tablero de dimensiones N x N se conoce como el problema de las N-Reinas. Este problema fue publicado por primera vez de forma annima en la revista alemana Schach en el ao 1848; posteriormente se le atribuy a un ajedrecista del momento, Max Bezzel, del que poco ms se conoce. Ya en aquel tiempo atrajo la atencin de la lite matemtica, entre los que se inclua el gran Carl Friedrich Gauss, que intent enumerar todas las distintas soluciones al problema. Gauss slo pudo encontrar 72 configuraciones distintas, lo que da una idea de la dificultad de este problema aparentemente sencillo. Solo unos aos ms tarde, en 1850, Nauck public las 92 soluciones del problema. En 1901, Netto por primera vez generaliz el problema a encontrar N reinas en un tablero N x N, aunque otras fuentes atribuyen al propio Nauck ese honor. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 135 Universidad Politcnica de Madrid -UPM 8.2.2 Caractersticas El problema de las N-Reinas es un problema terico que se enmarca dentro del rea de juegos. Ha sido un problema ampliamente estudiado desde la segunda mitad del siglo XIX y para el que se han descubierto algunas soluciones analticas cerradas; stas describen un procedimiento para obtener una o algunas pocas configuraciones objetivo para todo valor de N (N>3) (obviamente para N=1 la solucin existe y es trivial). Un ejemplo de solucin cerrada se enuncia a continuacin:
Se anima a lector a emplear este procedimiento para encontrar una configuracin objetivo para valores de N bajos (por ejemplo N=10). Este y otros mtodos analticos permiten afirmar los dos siguientes postulados:
Sin embargo, estos mtodos puramente analticos no son capaces de responder a ninguna pregunta acerca de la forma del espacio de estados del problema, ni tan siquiera proporcionar un conjunto de soluciones representativo de cada instancia. En el campo de la Inteligencia Artificial, el problema de las N-reinas se emplea como demostrador de prcticamente todas las tcnicas de bsqueda heurstica conocidas, dada la sencillez del enunciado y la tremenda explosin combinatoria que genera. Empleando tcnicas de mejora iterativa basadas principalmente en minimizacin de conflictos, Sosic y Gu a principios de los aos 90 pudieron ubicar ms de 3.000.000 de reinas en un tablero vaco; esta lnea de investigacin contina abierta en la actualidad. stas y otras tcnicas de bsqueda local, sin embargo, no permiten encontrar todas las soluciones del problema. Este ltimo es el escenario ms difcil ya que la explosin combinatoria que se produce empleando una bsqueda exhaustiva desinformada (e.g. un procedimiento primero en profundidad) es 2 2 2 ! !( )! N N N N N N , lo que para valores de N mayores de 30 resulta muy difcilmente 1. Sea R la parte entera del resto de N/12 2. Sea L el conjunto de todos los nmeros pares de 2 (incluido) a N en orden creciente. 3. Si R es 3 o 9 coloque el 2 al final de la lista 4. Aada a L (empezando por el final) el conjunto de nmeros impares de 1 a N de acuerdo a las siguientes reglas: a. Si R es 8 intercambie parejas (por ejemplo 3,1,7,5,11,9,15,13...) b. Si R es 2 intercambie las posiciones del 1 y el 3 y coloque el 5 al final de L c. Si R es 3 9 coloque 1 y 3 al final de la lista manteniendo el orden 5. Coloque la primera reina en la casilla de la primera fila que indica el primer nmero de L; la segunda reina en la casilla de la segunda fila indicada por el segundo nmero de L y as sucesivamente. 1. El problema tiene solucin para N =1 y para todo N mayor 3 2. Se sabe como construir al menos una solucin cuando sta existe Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 136 Universidad Politcnica de Madrid -UPM computable en la prctica. Esta cifra puede mejorarse mucho teniendo en cuenta que slo puede colocarse una reina por fila y por columna. An con todo, la bsqueda de todas las posibles configuraciones necesita de heursticas para atravesar el desierto formado por el gigantesco espacio de bsqueda y encontrar los oasis de soluciones. Una caracterstica singular de las N-reinas es que, si bien la dimensin del espacio de estados es claramente exponencial en el nmero de reinas a colocar, el nmero de soluciones tambin crece exponencialmente con N (ver Tabla 1). Tabla 1. Nmero de soluciones distintas del problema de las N-Reinas para diferentes valores de N. 4 5 6 7 8 9 10 11 12 13 14 15 2 10 4 40 92 352 724 2.680 14.200 73.712 365.596 2.279.184
Esta distribucin no es homognea en el espacio de estados sino ms bien existen enormes zonas vacas salpicadas de grandes concentraciones de soluciones. Intuitivamente esto quiere decir que, a mayor N, no es en absoluto evidente que el problema sea exponencialmente ms difcil (es ms, todo apunta a que esta afirmacin es falsa). A principios de los 90, Kal encontr una heurstica que permita computar las primeras 100 soluciones para cualquier N entre 4 y 1000 (ambos inclusive) en un tiempo casi lineal en N, por lo que conjetur que la densidad del espacio de soluciones podra ser uniforme. Recientemente, en una investigacin llevada a cabo por los propios autores se ha encontrado una nueva heurstica que corrobora esa afirmacin y extiende el cmputo a valores de N hasta 5000. Cuando se aborda el problema de las N-Reinas desde la perspectiva de la completitud se emplean fundamentalmente dos enfoques distintos: Conocer el nmero exacto de soluciones que existen para cualquier valor de N: En este enfoque interesa slo el nmero exacto de soluciones y no necesariamente su enumeracin explcita ni, desde luego, su almacenamiento (lo que sera, por otro lado, imposible dada la explosin combinatoria del nmero de soluciones). Enumerar las primeras K soluciones para cualquier valor de N: En este caso se exige el cmputo explcito. El valor de K no suele ser muy grande (por ejemplo 100), pero el suficiente para que el procedimiento de resolucin no pueda emplear mtodos analticos cerrados. Fuera del mbito de los juegos, es interesante mencionar la aplicacin del problema de las N-Reinas en el campo de la optimizacin, donde constituye un importante modelo terico en problemas de planificacin (scheduling) y de asignacin de tareas (task assignment problems). 8.2.3 Estructuras de datos La implementacin tpica del problema divide el tablero por filas y codifica una solucin cualquiera como N nmeros entre 1 y N que representan las casillas ocupadas Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 137 Universidad Politcnica de Madrid -UPM en cada fila, para un total de N! posibles configuraciones (las permutaciones de los N nmeros (ver figura 2)).
4 3 2 1 4 3 2 1 F 1 F 2 F 3 F 4 F 1 F 2 F 3 F 4
Figura 2. Solucin del problema de las 4-Reinas. Dicha solucin puede codificarse como la cuadrupla {2, 4, 1, 3} que corresponde a la columna de la casilla ocupada en cada fila. En consecuencia, es suficiente un vector de N nmeros para codificar cualquier estado del espacio de soluciones y un procedimiento de bsqueda sistemtico (vlido) es cualquier algoritmo que genere permutaciones. Si se pretende abordar la generacin explcita de las primeras k soluciones, prcticamente la nica alternativa razonable es realizar una bsqueda primero en profundidad guiada por una heurstica adeudada. En este caso, la bsqueda no se debe desarrollar en un espacio de soluciones (como en el clculo de permutaciones) sino que cada estado del rbol se corresponde con una fila del tablero (o, alternativamente una columna), que se va rellenando hasta completar una solucin en los nodos situado a una profundidad N. De manera intuitiva en cada nivel del rbol se aade una reina al tablero hasta alcanzar una solucin. Si en un estado concreto no existen casillas libres en la fila o columna correspondiente se produce una vuelta-atrs y la ltima reina colocada se elimina del tablero. Segn se expuso en la seccin 7.3, el control de la bsqueda slo requiere almacenar tanto el camino actual como todos los nodos sucesores directos de dicho camino. Si tomamos como factor de ramificacin medio del rbol (b) el valor de N/2, el espacio mximo requerido durante la bsqueda, teniendo en cuenta que la profundidad del rbol (d) no puede exceder de N, ser:
2 2 2 2 N N N Espaciomximo d N lo que no supone mayor problema para los computadores actuales. Respecto a la generacin de los nodos sucesores a partir del padre, el mayor coste computacional reside en el clculo de las casillas atacadas tras colocar una nueva reina fruto de los rayos diagonales (las interacciones entre filas y columnas se pueden computar de manera sencilla actualizando una estructura de filas y columnas ocupadas). Para el cmputo eficiente de las casillas libres al vuelo es necesario aadir nuevas estructuras de datos como por ejemplo registros que llevan la cuenta del Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 138 Universidad Politcnica de Madrid -UPM nmero total de las casillas no atacadas en filas, columnas y diagonales etc. stas y otras estructuras bien elegidas permiten que la determinacin de las casillas libres se realice en tiempo constante pero requieren tiempo adicional para su cmputo. Es interesante mencionar que existen codificaciones ms o menos ingeniosas que asocian bits con caractersticas del dominio de manera que una operacin de enmascaramiento permite ejecutar varias operaciones en paralelo con significado en el problema. Estas estructuras pueden ser auxiliares (como por ejemplo emplear un vector de bits por cada diagonal del tablero, donde cada bit representa una casilla) o pueden estar en el corazn mismo del control de la bsqueda. El algoritmo elegido para implementar las N-Reinas que se describe en esta seccin emplea este tipo de codificacin. 8.3. IMPLEMENTACIN CENTRALIZADA Se presenta en esta seccin un procedimiento que permite obtener, al menos en teora, todas las soluciones distintas del problema de las N-Reinas para valores de N hasta 32 (en la prctica esto no va a ser posible debido al crecimiento fuertemente exponencial de las soluciones con N segn muestra la tabla 1). El algoritmo genera explcitamente todas las posibles soluciones y lleva la cuenta del total. La restriccin en el valor de N se debe a que, para la codificacin de las casillas libres en una fila se emplea un nico entero de 32 bits, un bit por cada casilla de la fila. El control de la bsqueda se realiza mediante la tcnica primero en profundidad implementada de forma recursiva (ver seccin 7.5). Ms concretamente, el tablero se rellena por filas y la colocacin de una nueva reina en una fila provoca un cambio de estado; se puede decir, por tanto, que la bsqueda se realiza en un espacio de filas donde cada estado-fila queda determinado por el nmero de casillas libres que dispone (aqullas casillas no atacadas por reinas ya presentes en el tablero). Los estados-fila sucesores se generan emplazando una nueva reina en cualquiera de las casillas libres del estado-fila actual, con la particularidad que, debido a las estructuras de datos empleadas, las filas siempre se completan en direccin descendente empezando por la parte superior del tablero. Para aclarar estos conceptos, la figura 3 muestra una posible traza del rbol de bsqueda para el problema de las 4-Reinas. Todos los nodos en un mismo nivel del rbol se corresponden con la misma fila del tablero, pero con diferentes distribuciones de casillas libres; el nodo raz del rbol, por tanto, corresponde al estado-fila extremo superior del tablero que inicialmente est vaco. Los nodos hoja del rbol estn marcados con una cruz, con la excepcin del nodo hoja solucin que se encuentra en el 4 nivel. Los nodos hoja en niveles del rbol inferiores a N capturan el hecho de que una fila no tiene casillas libres, lo que supone un error en la ubicacin de una reina en niveles superiores y provoca una vuelta atrs. Se observa como la realizacin de la bsqueda en un espacio de filas en lugar de un espacio de soluciones permite podar la bsqueda reduciendo el tamao de rbol generado.
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 139 Universidad Politcnica de Madrid -UPM nSol++ nSol++
Figura 3. Traza del rbol de bsqueda del problema de las 4-reinas. El nodo raz representa la fila superior del tablero. Estados marcados con una cruz son nodos hoja no solucin. Al encontrara una configuracin solucin se incrementa el contador nSol y contina la bsqueda. En el ejemplo, la bsqueda yerra al comenzar colocando una reina en la esquina superior derecha del tablero. Tras producirse la ltima poda en el nivel 3 (para la configuracin de reinas en estados superiores del camino no existen casillas libres en la fila actual) se produce una vuelta atrs. Posteriormente, tras encontrar una configuracin solucin (estado marcado con el parmetro nsol) se incrementa en una unidad la cuenta de soluciones y la bsqueda contina hasta que no existen sucesores que explorar o bien, en el caso general, el contador llega a un valor K. 8.3.1 Descripcin En la implementacin propuesta, el control de la bsqueda obedece ntegramente al pseudocdigo propuesto para bsquedas primero en profundidad en el captulo 7. Las reinas se colocan por filas; para cada nuevo estado alcanzado se realizan las siguientes tareas en orden: 1. Comprobacin si el nuevo estado es solucin: Para ello basta analizar si el nivel de profundidad del rbol es N. En este caso se suma uno al contador de soluciones y se realiza una vuelta-atrs para continuar por un nuevo camino. 2. Seleccin de la siguiente fila no ocupada: Las filas se rellenan de arriba abajo, empezando por la fila superior y terminando por la base del Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 140 Universidad Politcnica de Madrid -UPM tablero. En cada nivel del rbol se coloca una reina en alguna de las casillas libres de la fila correspondiente. 3. Generacin de los nodos sucesores: Para ello se calcula el nuevo conjunto de casillas no atacadas (libres) para la fila del siguiente nivel. Este es el proceso ms costoso en tiempo de cualquier implementacin y las estructuras de datos se eligen para minimizar dicho cmputo. Si no existen casillas libres en la fila elegida (y la profundidad del rbol es menor que N) entonces es que se ha producido un error en la colocacin de alguna de las reinas anteriores. Se efecta entonces una vuelta-atrs al nodo padre para retomar la bsqueda. 4. Seleccin de un nodo sucesor NS de entre los generados en el paso 3. 5. Convertir NS en el estado actual e ir al paso 1: Este paso se implementa como llamada recursiva a la propia funcin encargada del procedimiento de bsqueda. 8.3.2 Estructuras de datos Para optimizar el cmputo de las casillas libres en cada fila se ha empleado una codificacin mediante vectores de bits. Este tipo de codificaciones se utilizan con mucha frecuencia para tratar de reducir el tiempo de cmputo aprovechando que los registros de la CPU pueden efectuar un nmero de operaciones de enmascaramiento de bits en paralelo equivalente al tamao de los registros de la ALU (tpicamente 32 o 64). Intuitivamente, si se consiguen asociar bits a unidades de informacin acerca del dominio, entonces una sola operacin de enmascaramiento entre dos registros permite realizar 32 o 64 operaciones con sentido, con la consiguiente ganancia en eficiencia. Las estructuras de datos empleadas son: El tablero: La informacin del tablero, en cada nodo, se reduce a una fila, y ms concretamente a las casillas libres (no atacadas) de la fila. Cada fila se codifica como un nmero de 32 bits donde cada casilla equivale a un bit. Una casilla libre (no atacada) se codifica con un bit a uno y cero en caso contrario. La posicin relativa de los bits indica la posicin de la casilla en la fila; el bit ms bajo representa la columna ms a la derecha del tablero, el segundo bit la columna inmediatamente a la izquierda y as sucesivamente, para un mximo de N bits por fila, el nmero de columnas del tablero. El inconveniente principal de esta codificacin es que slo es vlida para tableros de dimensin 32 x 32 como mximo. Los movimientos de la reina: Los movimientos de la reina en el ajedrez (todas las casillas en las 8 direcciones en el plano) se van a codificar como operaciones de desplazamiento y enmascaramiento de bits. La idea fundamental es que slo es necesario computar las casillas atacadas en la fila correspondiente al estado actual y no las del resto de filas todava sin rellenar. El procedimiento cmputo se reduce a Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 141 Universidad Politcnica de Madrid -UPM generar las casillas libres en una determinada fila a partir del conocimiento de casillas libres en la fila inmediatamente superior. Para ello se utilizan 3 enteros izq, abajo y dcha. Una explicacin ms en detalle se expone en la seccin siguiente. Nmero de fila del nodo actual: Coincide con el nivel de profundidad del nodo en el rbol de bsqueda empezando la cuenta por el borde superior del tablero (fila 0) y terminando en la base (fila N-1). Se almacena en un entero en cada nivel y se gestiona a travs de la pila de llamadas. Estructuras auxiliares: La configuracin de inicial de las casillas libres en una fila se guarda en la variable TODOUNOS. Este valor es constante durante toda la bsqueda y se calcula una vez al inicio. Otras estructuras son: un entero nSOL que lleva la cuenta del nmero de soluciones encontradas hasta el momento y la constante N que indica la dimensin del tablero. 8.3.3 Control de la bsqueda La funcin recursiva que controla la bsqueda se denomina FuncRec. Su definicin es la siguiente: void FuncRec(int fila, int izq, int abajo, int dcha) { int estado, sucesor;
if (fila == N) { nSOL++; } else { estado = TODOUNOS & ~(izq | abajo | dcha); while (estado) { sucesor = -estado & estado; estado ^= sucesor; FuncRec(fila+1, (izq | sucesor )<<1, abajo | sucesor, (dcha | sucesor)>>1); } } } Segn lo ya expuesto, los nodos del rbol de bsqueda son filas sin completar y para cada nuevo estado-fila hay que de actualizar el conjunto de casillas libres (no atacadas) en esa fila. Esta actualizacin se realiza a partir de la informacin que el nodo padre pasa a su sucesor, los parmetros fila, izq, abajo y dcha. Inicialmente se comprueba si el nuevo estado-fila es un nodo hoja solucin; para ello basta con saber si se ha alcanzado la profundidad mxima del rbol N. En caso afirmativo se suma uno al contador de soluciones nSOL y se vuelve atrs en el rbol para continuar la bsqueda. Esta comprobacin se realiza en la instruccin if (fila == N) nSOL++; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 142 Universidad Politcnica de Madrid -UPM Si la fila actual no es la ltima, entonces lo primero es generar de forma explcita el estado a partir de la informacin recibida del nodo padre. Esto se efecta mediante operaciones de enmascaramiento de bits en la lnea de cdigo estado = TODOUNOS & ~(izq | abajo | dcha) Esta instruccin requiere una explicacin ms detallada. El operador & en C es el operador binario AND a nivel de bits. Su resultado es un bit a 1 si en esa posicin los bits de ambos operandos estn tambin a uno. En caso contrario el bit toma el valor cero. Un ejemplo:
El operador ~ es el operador unario complemento a uno en C. Como resultado, el nmero sobre el que opera intercambia los bits a cero por los bits a uno. La combinacin de operadores c1 & ~ c2 es interpretada por el compilador como c1 &(~ c2 ). El resultado es la puesta a nivel bajo de los bits de c1 que estn en la posicin ocupada por los bits a 1 de c2. Esta combinacin de operadores se conoce comnmente como borrado de c1 ya que el segundo operando lleva la informacin de los bits a borrar en el primero. Un ejemplo:
Volvamos ahora al cmputo de las casillas libres en la nueva fila. La instruccin que genera el nuevo estado-fila lo hace borrando aquellas casillas libres (inicialmente todas lo son por lo que estado coincide con TODOUNOS), que ahora resultan atacadas por reinas ya emplazadas en el tablero. Esta informacin est contenida en los parmetros izq, abajo y dcha que, de manera intuitiva, se corresponden con las casillas atacadas por todas las reinas ya colocadas, segn las tres direcciones del plano correspondientes (inferior izquierda, abajo, inferior derecha). No es necesario analizar los ataques en las otras 5 direcciones del plano porque las reinas se van colocando por filas en orden descendente y, por tanto, cualquier casilla atacada en la fila actual solo se puede deber a reinas situadas en filas superiores. Los 3 parmetros con informacin de casillas atacadas son enteros de 32 bits. Un bit a uno en cualquiera de ellos representa una casilla atacada por reinas situadas en filas superiores en la direccin correspondiente. La figura 4 muestra un ejemplo del valor de estas estructuras de datos para el problema de las 4-Reinas. Una reina acaba de emplazarse en la fila superior y la nueva llamada recursiva a FuncRec recibe como Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 143 Universidad Politcnica de Madrid -UPM parmetros izq = 0100 2 , abajo = 0010 2 y dcha = 0001 2 , que corresponden a las casillas atacadas en las tres direcciones. El nuevo estado, las casillas libres en la fila inmediatamente debajo, se construye borrando todos esos bits de TODOUNOS (todas las casillas libres), lo que da como resultado una nica casilla libre estado = 1000 2
marcada por el cuarto bit a uno (la casilla del extremo izquierdo de la fila). izq abajo dcha izq = 0100 2 abajo = 0010 2 dcha = 0001 2 estado nuevo = 1000 2 F nueva izq abajo dcha izq = 0100 2 abajo = 0010 2 dcha = 0001 2 estado nuevo = 1000 2 F nueva
Figura 4. Valor de los parmetros izq, abajo y dcha tras colocar una reina en la fila superior del tablero para el problema de las 4-Reinas. El estado en la fila nueva viene determinado por la operacin estado = 1111 2 &~ (izq | abajo | dcha) = 1000 2 . Computado el estado actual de la fila, los posibles sucesores se obtienen situando una nueva reina en cualquiera de los bits a 1 de la variable estado. Esto se realiza de forma iterativa en el bucle determinado por while (estado) { sucesor = -estado & estado; estado ^= sucesor; FuncRec(fila+1, (izq | sucesor )<<1, abajo | sucesor, (dcha | sucesor)>>1); } que nuevamente requiere cierta explicacin. La primera lnea de cdigo, nada ms entrar en el bucle, obtiene el primer bit a 1 de la palabra estado mediante una ingeniosa pero muy conocida operacin a nivel de bits, combinacin de operadores y &: sucesor = -estado & estado; La operacin resta vista como operador unario calcula el complemento a 2 del operando al que afecta. La secuencia de operaciones - y & sobre un mismo nmero borra todos los bits exceptuando el bit a uno ms bajo de dicho nmero. Por ejemplo, -1101 2 & 1101 2 devuelve 0001 2 mientras que -1100 2 & 1100 2 devuelve 0100 2 . El lector puede fcilmente comprobar que esta propiedad se cumple para cualquier nmero. Por tanto, sucesor ser un nmero formado por un nico bit a 1, el bit ms bajo de estado. La siguiente lnea de cdigo dentro del bucle completa el control del mismo. estado ^= sucesor; El operador ^ en C es la mscara XOR bit a bit, operador binario tambin conocido por distinto ya que mantiene a 1 aquellos bits que son diferentes en los dos operandos y borra los que son iguales. En este caso, como el nico bit a uno de sucesor tiene que estar en estado, el resultado es el borrado de ese bit en Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 144 Universidad Politcnica de Madrid -UPM estado. Intuitivamente, en cada iteracin se elige el bit a uno ms bajo de estado y despus se borra, lo que implica que las reinas se colocan de derecha a izquierda en las casillas libres de cada fila. Cuando estado est vaco finaliza la ejecucin del bucle. Es interesante hacer notar que el mismo resultado se obtendra mediante el operador ya visto de borrado: estado &= ~sucesor; pero sera menos eficiente ya que se necesita una operacin ms de enmascaramiento. Finalmente, decidido una vez el sucesor, es necesario actualizar las estructuras de datos izq, abajo y dcha antes de proceder a una nueva llamada recursiva. En el cdigo esto se realiza en la propia instruccin de llamada a la funcin: FuncRec(fila+1, (izq | sucesor )<<1, abajo | sucesor, (dcha | sucesor)>>1); El primer parmetro de FuncRec, el nmero de la fila, siempre se incrementa en una unidad. Su valor inicial es 0, la fila superior del tablero. El segundo parmetro es la actualizacin de la estructura izq a partir de su valor actual. Este cmputo puede verse como un desplazamiento hacia la izquierda una unidad de un nmero que tiene por bits a uno todas las columnas donde se encuentran las reinas ya colocadas en el tablero (incluyendo la ltima, en la fila actual y posicin sucesor) segn se desprende de la figura 5. En C, el operador desplazamiento a izquierdas tiene como smbolo <<. La sintaxis es la misma que la del operador de flujo de salida pero, en este caso, el operando de la derecha es un entero que indica el nmero unidades de desplazamiento de los bits del operando de la izquierda en la direccin apuntada por el smbolo. estado act = 11100 2 sucesor = 00100 2 izq act = 00010 2 1 1 1 F actual F nueva izq nue = (izq act | sucesor) <<1 = 01100 2 estado act = 11100 2 sucesor = 00100 2 izq act = 00010 2 1 1 1 F actual F nueva izq nue = (izq act | sucesor) <<1 = 01100 2
Figura 5. Actualizacin de la estructura de datos izq. izq act es el valor en la fila actual (F actual ) e izq nue el nuevo valor calculado a partir del anterior. estado act
contiene las casillas libres en la fila actual. De entre stas, se ha elegido colocar una nueva reina en la casilla central de F actual , lugar que ocupa en la figura, almacenndose su posicin en la variable sucesor. Ahora bien, para obtener el nuevo valor de izq no basta con desplazar el antiguo una posicin a la izquierda (equivalente a izq nue = izq act <<1) ya que esta operacin tiene en cuenta los ataques en esta diagonal de todas las reinas situadas en filas anteriores a F actual pero no incluye la ltima que se encuentra en sucesor. De ah que izq nue sea compute a partir de la unin entre sucesor e izq act . Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 145 Universidad Politcnica de Madrid -UPM Un razonamiento anlogo puede hacerse para computar las casillas atacadas en la diagonal descendente dcha solo que, en este caso, el desplazamiento de bits es hacia la derecha una posicin (operador >> de C). En este punto es interesante destacar que la operacin de enmascaramiento que genera el estado actual al entrar en FuncRec: TODOUNOS & ~(izq | abajo | dcha) lleva implcita tambin la gestin de bordes. Este problema es inherente a los juegos de tablero y debe tenerse muy en cuenta en la seleccin de las estructuras de datos para codificar el problema. Como ejemplo, considrese el problema de las 4- Reinas nuevamente. Si se coloca una reina en la esquina superior izquierda, la codificacin de izq nue para la segunda fila sera 10000 2 , pero al estar situada la reina en el extremo, ese bit a 1 queda fuera del rango de columnas del tablero. TODOUNOS es, en este caso, 1111 2 y lleva implcita la informacin del tamao del tablero. La mscara & ~ , por tanto, acta slo sobre las 4 casillas posibles de la fila resolviendo el problema de rangos de forma muy eficiente y elegante. Por ltimo, los nuevos ataques en la direccin vertical, sentido descendente (variable abajo) coinciden con el valor anterior aadiendo sucesor. Esto es as ya que el ataque a lo largo de cualquier columna corresponde al mismo bit en cada fila. La figura 6 muestra todas las estructuras de datos relacionadas con el cambio de estado para el ejemplo de la figura 4. izq n = sucesor << 1 = 0100 2 estado act = 0000 2 F actual F nueva izq abajo dcha sucesor = 0010 2 dcha n = sucesor >> 1= 0001 2 abajo n = sucesor | 1 = 0010 2 estado nuevo = 1111 2 &~ (0010 2 | 0001 2 | 0010 2 ) = 1000 2 izq n = sucesor << 1 = 0100 2 estado act = 0000 2 F actual F nueva izq abajo dcha sucesor = 0010 2 dcha n = sucesor >> 1= 0001 2 abajo n = sucesor | 1 = 0010 2 estado nuevo = 1111 2 &~ (0010 2 | 0001 2 | 0010 2 ) = 1000 2
Figura 6. Ejemplo de cmputo de casillas libres. La reina en la figura est codificada en sucesor y provoca la transicin a la fila nueva. La fila actual es el borde superior del tablero (nodo raz del rbol) y los valores de izq act , dcha act y abajo act en ese nodo son 0000 2 . Los valores de izq n , abajo n y dcha n en la figura representan las casillas atacadas en la nueva fila. 8.3.4 Algoritmo de bsqueda Una vez explicada en detalle la funcin recursiva principal que dirige la bsqueda, el resto de cdigo no ofrece especial dificultad. El cdigo completo para el problema de las 8-Reinas es:
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 146 Universidad Politcnica de Madrid -UPM #include <stdio.h> #define N 8 //Max 32
const int TODOUNOS =(1 << N) - 1; int nSOL;
void FuncRec(int fila, int izq, int abajo, int dcha) { int estado, sucesor;
int main(void) { nSOL = 0; FuncRec(0, 0, 0, 0); printf("N=%d -> %d\n", N, nSOL); return 0; } El cmputo de soluciones lo lleva la variable nSol y el valor inicial de las filas TODOUNOS, ambas definidas como globales. Es interesante destacar las operaciones de bits que sirven para inicializar TODOUNOS: const int TODOUNOS =(1 << N) - 1; Primeramente se desplaza la constante 1 (que hay que visualizar como un nmero de 32 bits con el bit ms bajo a uno) N posiciones a la izquierda, con lo que se sita en la posicin N+1. Debido al acarreo, la operacin resta de una unidad convierte a unos todos los ceros a la derecha del uno desplazado. El uno en la posicin N+1 acta como barrera y evita, al ponerse a nivel bajo, la propagacin indebida del bit de acarreo ms all de su posicin. La llamada inicial a la funcin de bsqueda se realiza con todos los parmetros a cero (izq, abajo y dcha estn a nivel bajo al inicio). Con estos valores, el cmputo del estado-fila en el nodo raz tiene tambin valor 0, o visto de otro modo, la primera reina puede emplazarse en cualquier casilla del borde superior del tablero vaco. Por ltimo, cabe destacar que la bsqueda que realiza este procedimiento es desinformada al no incorporar ninguna heurstica de decisin. Las reinas se emplazan en filas consecutivas en direccin descendente y se van colocando por columnas de derecha a izquierda (posiciones bajas a posiciones altas de bits a 1 en estado). Por Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 147 Universidad Politcnica de Madrid -UPM esta razn, y a pesar de que la codificacin admite tableros de dimensin hasta 32x32, valores de N superiores a 18 difcilmente pueden ser resueltos por un computador comercial con este algoritmo. A partir de N = 20 la tarea es prcticamente imposible. 8.4. IMPLEMENTACIN DISTRIBUIDA El problema de las N-Reinas pertenece a la categora de problemas fcilmente paralelizables; basta considerar como particiones del espacio problema las casillas libres en cualquier fila. Resulta evidente que cada subproblema resultante de ubicar una reina en una casilla libre del estado-fila actual es totalmente independiente del subproblema derivado de seleccionar otra casilla libre en la misma fila. En consecuencia, cada subrbol puede ser resuelto en paralelo sin necesidad de sincronizacin y con la seguridad de que no se estn repitiendo configuraciones solucin, un escenario idlico en el marco del cmputo paralelo. Se muestra en esta seccin una implementacin de esta paralelizacin tipo del problema de las N-Reinas, en el marco de un sistema distribuido. La implementacin se ha desarrollado para la plataforma Win32 y se emplean Sockets para establecer las comunicaciones entre los ordenadores remotos. El objetivo de este ejemplo, sin embargo, no es mostrar el empleo de Sockets en esta plataforma, sino el de presentar el potencial que tienen los sistemas distribuidos para resolver tareas en paralelo de forma ms eficiente que un sistema centralizado, al ser capaces de aprovechar el trabajo de mltiples unidades de proceso conectadas en red. Por este motivo, se asumir que existe una clase de tipo wrapper que encapsula los servicios del recurso Socket y que se encuentra a disposicin del programador mediante el mecanismo de herencia. En este sentido, la mayor parte de las explicaciones que aparecen en esta seccin pueden considerarse multiplataforma. 8.4.1 Arquitectura cliente-servidor La arquitectura distribuida elegida tiene a un cliente que centraliza la distribucin de la carga sobre un conjunto de servidores. El cliente se encarga de subdividir el problema en partes que sern resueltas por los diferentes servidores en la red; stos ltimos son los que ejecutan el algoritmo de bsqueda y devuelven como resultado al cliente el nmero de soluciones encontradas de cada problema parcial. El cliente, por su parte, tras finalizar el reparto de la carga, enva una peticin de resultado a los diferentes servidores cada segundo. Cuando todas las soluciones parciales han sido recibidas, muestra la suma total por pantalla. Con objeto de simplificar el ejemplo, la particin del espacio se ha realizado asignando en la primera fila (correspondiendo al borde superior del tablero) una casilla libre a cada servidor; ste resuelve el subproblema resultante tras la ubicacin de dicha reina en el tablero. En consecuencia, habr un mximo de N subproblemas a resolver y podrn existir un mximo de N servidores trabajando en paralelo. La figura 7 muestra la arquitectura descrita.
Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 148 Universidad Politcnica de Madrid -UPM Cliente Servidores Cliente + + + + Servidores Cliente Servidores Cliente Servidores Cliente + + + + Servidores Cliente + + + + Servidores
Figura 7. Arquitectura cliente-servidor para el problema de las 4-Reinas. El cliente divide el problema en 4 partes y recibe las soluciones de cada parte para generar el total. 8.4.2 Protocolo de comunicacin La informacin que tiene que circular entre cliente y servidor es bastante escasa. En la etapa de reparto de carga el cliente solo tiene que enviar dos nmeros enteros: el tamao del problema (parmetro N) y la posicin de la reina en la primera fila (que determina la subtarea a resolver). Este parmetro se mide desde el borde derecho del tablero; para un tablero de lado N la esquina superior derecha tiene valor 0 y la esquina superior izquierda valor N-1. El protocolo de este envo es una cadena de caracteres que tiene la forma siguiente: CABECERA:Nqueens DATOS:<Tamao del tablero> <Casilla de la primera reina> Este mensaje tiene acuse de recibo mediante la cadena OK por parte de cada servidor para indicar que se ha recibido satisfactoriamente. Una vez realizado el envo anterior, el cliente central lanza, cada segundo, una peticin de resultado a cada servidor y recibe de ellos un entero solucin si han terminado su parte. El mensaje de peticin de resultado es la cadena de caracteres Resultado. Cada servidor devuelve entonces la solucin obtenida o -1 si no ha terminado an. Cuando todos los mensajes de peticin han sido contestados satisfactoriamente, el cliente presenta la suma de los resultados en pantalla. 8.4.3 Implementacin del cliente En esta seccin se describe en detalle todo lo relativo al funcionamiento de la parte del cliente. Como se indic la comienzo de esta seccin, se van a omitir la mayora de detalles acerca de los servicios de Win32 para Sockets. A todos los efectos, estos servicios se van a considerar transparentes para el programador y heredados de una clase Socket a su disposicin. En cambio, s se describir en detalle la manera de hacer uso de esta clase mediante el mecanismo de herencia. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 149 Universidad Politcnica de Madrid -UPM 8.4.3.1. Comunicacin con el servidor Las comunicaciones entre cliente y servidor se realizan a travs de Sockets. A efectos del ejemplo, bastar saber que existe una clase Socket que encapsula la recepcin y envo de mensajes y que tiene (entre otras) dos funciones miembro pblicas: class Socket { public: int SendMsg(const char* cad, int length); int ReceiveMsg(char* cad, int* size, int timeout = 200); // } que se encargan de las comunicaciones. La funcin SendMsg permite enviar una cadena de tamao length (medido en bytes) mientras que ReceiveMsg recibe una cadena de tamao mximo size. Ambos servicios devuelven un 0 si la comunicacin se ha efectuado con xito. Para el ejemplo se ha creado una clase cliente MyLiveClient que hereda estos servicios de comunicaciones mediante derivacin pblica de la clase Socket. Su fichero de cabecera es: class MyLiveClient : public Socket { public: int RecibirResultado(); int EnviarNReinas(int size, int posq); MyLiveClient() {}; ~MyLiveClient(); }; Las dos funciones importantes de la clase son EnviarNReinas y RecibirResultado. La primera enva la informacin de la particin del problema (el tamao del tablero y la posicin de la reina en la fila superior) y la segunda realiza la peticin de resultado, ambas siguiendo el protocolo descrito en la seccin anterior. El cdigo de la funcin envo no requiere demasiado comentario: int MyLiveClient::EnviarNReinas(int size, int posq) { char cad[100]; sprintf(cad,"Nqueens %d %d",size, posq); if(0!=SendMsg(cad,strlen(cad)+1)) return -1;
int max_size=100; if(0!=ReceiveMsg(cad,&max_size)) return -1;
cout<<cad<<endl;
return 0; //OK (-1 ERROR) } Se emplea el servicio SendMsg heredado de la clase Socket para realizar el envo de la tarea al servidor remoto y ReceiveMsg para gestionar un acuse de recibo que se muestra en pantalla. En ambos casos, el control de errores se gestiona a Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 150 Universidad Politcnica de Madrid -UPM travs del parmetro de retorno. La funcin strlen empleada dentro del segundo parmetro del servicio SendMsg devuelve el nmero de caracteres de la cadena argumento excluyendo el carcter nulo al final de la cadena. Este es el motivo por el que en la instruccin if(0!=SendMsg(cad,strlen(cad)+1)) return -1; hay que aadir una unidad al resultado de strlen. La implementacin de la funcin que pide y recibe el resultado es la siguiente: int MyLiveClient::RecibirResultado() { // 0 OK, -1 ERROR
sscanf(cad,"%d",&res); if(res>=0){ cout<<"Recibido resultado correcto: "<<res<<endl; return res; } return -1; } De nuevo el cdigo no requiere demasiada explicacin. Una vez enviada la peticin mediante el mensaje Resultado la instruccin if(0!=ReceiveMsg(cad,&max_size)) return -1; recibe en la cadena de caracteres cad la posible solucin numrica. Tras formatear la cadena como nmero (mediante el servicio sscanf), se comprueba que ste es mayor o igual que cero en cuyo caso se muestra un mensaje en pantalla y se devuelve su valor. En caso contrario la funcin devuelve -1 para indicar que la tarea no ha finalizado. Ntese que se acepta el valor cero como resultado porque pueden existir subproblemas sin ninguna configuracin solucin (e.g. N=4 con la primera reina situada en una de las esquinas). 8.4.3.2. Hilo principal del cliente El hilo principal del lado del cliente divide y enva cada subproblema a los servidores remotos. Para ello es necesario inicializar un recurso cliente por cada particin del problema y la comunicacin con cada servidor remoto se establece con un Socket distinto del lado del cliente. El hilo principal debe gestionar, por tanto, un vector de Sockets de tamao el nmero de particiones del problema. Para el ejemplo, se ha definido un parmetro global NUM_PARTES que, en tiempo de compilacin, proporciona las particiones deseadas. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 151 Universidad Politcnica de Madrid -UPM El cdigo completo del hilo principal se detalla a continuacin. #include <iostream.h> #include "MyLiveClient.h" #define NUM_PARTES 1 #define N 9
int main() { MyLiveClient client_array[NUM_PARTES];
//Arranque del vector de sockets for(int i=0; i<NUM_PARTES;i++) client_array[i].Init("127.0.0.1",12000+i);
Sleep(1000); //Espera mientras arrancan los hilos de com.
//Recoger resultados cada segundo bool b_terminado; int sol[NUM_PARTES]; while(1){ Sleep(1000); //1 segundo por peticin b_terminado=true; for(int i=0; i<NUM_PARTES; i++){ if( (sol[i]=client_array[i].RecibirResultado())==-1) b_terminado=false; } if(b_terminado) break; }
//Clculo de la solucin int total=0; for( i=0; i<NUM_PARTES; i++) total+=sol[i];
//Presentacin de la solucin cout<<"Numero de reinas: "<<total<<endl;
//Cierre de sockets for( i=0; i<NUM_PARTES; i++) client_array[i].Close(); return 0; } El vector de sockets est compuesto por objetos de la clase MyLiveClient que se crean e inicializan nada mas comenzar la ejecucin del hilo principal mediante la funcin heredada Init de la clase Socket. Las instrucciones de arranque son: MyLiveClient client_array[NUM_PARTES]; for(int i=0; i<NUM_PARTES;i++) client_array[i].Init("127.0.0.1",12000+i); La funcin Init requiere dos argumentos, la direccin IP del servidor remoto y el puerto. Como es lgico, ambos deben coincidir con el servicio de establecimiento de conexin en el servidor remoto. En el ejemplo, se emplea la direccin genrica IP Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 152 Universidad Politcnica de Madrid -UPM que existe en toda mquina para poder realizar pruebas en modo local y lo que cambia para cada Socket cliente es la configuracin del puerto (empezando por el 12.000). Posteriormente, el hilo principal del cliente realiza, cada segundo, la peticin del resultado a todos los servidores remotos mediante un bucle del que slo se sale si todos los servidores han finalizado. Esta funcionalidad se ha implementado de la manera ms sencilla posible y es manifiestamente mejorable (por ejemplo, no se distingue entre servidores que han finalizado el cmputo y los que no). El control de esta operacin se lleva a cabo mediante el flag b_terminado. Tras la recepcin de las soluciones parciales, el cliente calcula la suma total y muestra el resultado en pantalla. Finalmente, la funcin miembro Close es invocada para cada objeto MyLiveClient liberando el recurso Socket en memoria y cerrando su hilo de ejecucin. Esta funcin, al igual que Init, es heredada de la clase Socket mediante derivacin. La figura 8 muestra la traza de la sesin del cliente para el problema de las 8-Reinas con la primera reina en la esquina derecha como nica particin (en este caso solo hay 4 soluciones). La comunicacin se establece localmente en el puerto 12000. La explicacin de la sesin es la siguiente: La lnea Connection indica que se ha establecido comunicacin con el servidor. Tras el envo de los datos correctos del problema, el servidor responde con un mensaje OK que se muestra en pantalla, de acuerdo con el protocolo implementado en la funcin miembro EnviarNReinas de MyLiveClient. Una llegada de una solucin mayor o igual que cero tras la peticin de resultado (mediante la funcin miembro RecibirResultado) muestra el mensaje Recibido resultado correcto: 4. El hilo principal sale entonces del bucle de peticiones. Se calcula la suma total y se muestra en pantalla (mensaje Nmero de reinas: 4). El cierre del socket cliente provoca una advertencia en pantalla de desconexin.
Figura 8. Traza de la sesin cliente para el problema de las 4-Reinas con la primera reina en la esquina derecha como nica particin. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 153 Universidad Politcnica de Madrid -UPM 8.5. IMPLEMENTACIN DEL SERVIDOR En el lado del servidor es donde se encuentra el procedimiento de bsqueda para el problema de las N-Reinas que llega desde el lado del cliente. El servidor empleado es sencillo y slo permite conexiones secuenciales de clientes; esto es, atiende a un cliente cada vez y al terminar queda a la espera de un nuevo cliente. En este caso slo est previsto un nico Socket cliente por sesin que pasa la informacin del problema y recoge el resultado. Esto slo es aceptable en el caso de disponer de todo el tiempo de procesamiento de los servidores remotos conectados, ya que la tarea pasada tiene una complejidad computacional elevada. En la prctica, sin embargo, raras veces se dar esta circunstancia y sera ms lgico una arquitectura que permitiera a los servidores un acceso simultneo a varios clientes. 8.5.1 Comunicacin con el cliente De forma similar al caso del cliente, se dispone de la clase Socket que encapsula los servicios de comunicacin. Para la gestin del protocolo y el lanzamiento del algoritmo de bsqueda se ha desarrollado una clase MyLiveServer que hereda pblicamente de aqulla. Su fichero de cabecera (.h) es: class MyLiveServer : public Socket { NQueen* m_pNQ; public: MyLiveServer(NQueen* pq); ~MyLiveClient(); virtual int OnMsg(char* cad,int length); }; La clase est lo ms desacoplada posible de la implementacin del procedimiento de bsqueda; la relacin se establece a travs del dato miembro privado m_pNQ que es un puntero a la clase NQueen que encapsula el algoritmo recursivo descrito con anterioridad. La direccin del objeto bsqueda se pasa en el momento de la llamada al constructor: MyLiveServer(NQueen* pq); Para la gestin del protocolo, MyLiveServer dispone de una funcin miembro OnMsg que es llamada cuando llega cualquier peticin del lado del cliente. Esta funcin est prevista en la arquitectura heredada y se sobreescribe aqu para implementar el protocolo. El calificativo virtual indica que se ha previsto polimorfismo para este servicio. La implementacin de OnMsg es la siguiente: int MyLiveServer::OnMsg(char* cad,int length) { //LLamada a funcin heredada Socket::OnMsg()
//Muestra el mensaje en pantalla cout<<"Ha llegado el siguiente mensaje: "<<cad<<endl;
//Deserializacin int N, posq; char message[100]=""; char nombre[100]; Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 154 Universidad Politcnica de Madrid -UPM sscanf(cad,"%s %d %d",nombre, &N, &posq);
//Protocolo if(strcmp(nombre,"Nqueens")==0){ //Recepcin de tarea if((N<=0) || (N>=32) || (posq>N-1) || (posq<0) ){ sprintf(message,"%s","Error en Datos"); if( 0!=SendMsg(message,20) ) return -1; }else{ //OK m_pNQ->Set(N); m_pNQ->SetReinaPrimeraFila(posq); sprintf(message,"%s","OK"); if( 0!=SendMsg(message,20) ) return -1; } } else if(strcmp(nombre,"Resultado")==0){ //Envo de resultado sprintf(message,"%d",m_pNQ->GetCount()); if( 0!=SendMsg(message,10) ) return -1; } return 1; } Al recibir un mensaje nuevo, la funcionalidad heredada llama al servicio OnMsg que extrae la informacin del mensaje prevista en el protocolo; se asigna a la variable local nombre la cabecera del mensaje, a la variable local N el tamao del tablero y a posq la posicin de la reina en la primera fila. Posteriormente se analiza la informacin recibida. Si la cabecera es Nqueens la peticin se reconoce como un envo if(strcmp(nombre,"Nqueens")==0){} mientras que si es una peticin de resultado se enva la informacin relativa a la solucin if(strcmp(nombre,"Resultado")==0{} En ambos casos se emplea la funcin strcmp que devuelve un cero si la cadena del argumento primero es exactamente igual que la del segundo. Detectada la peticin de ejecutar una tarea, se comprueban posibles errores en los parmetros y se actualizan los valores de la instancia de la clase NQueen que se encarga del procedimiento de bsqueda. Esta instancia se pas como puntero en el constructor del objeto MyLiveServer. La actualizacin de los datos se realiza en las instrucciones: m_pNQ->Set(N); m_pNQ->SetReinaPrimeraFila(posq); Caso de recibir una peticin de resultado, se llama a la funcin miembro GetCount() de la clase NQueen para obtener dicho valor y se enva como cadena al cliente: sprintf(message,"%d",m_pNQ->GetCount()); if( 0!=SendMsg(message,10) ) return -1; El significado de los parmetros de la funcin SendMsg es el mismo que en el caso del cliente, por lo que no se aade ningn comentario adicional. Por ltimo, destacar que si se detecta cualquier error en la transmisin de datos entre cliente y servidor, OnMsg retorna -1; si no de detecta ningn problema la funcin devuelve 1. Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 155 Universidad Politcnica de Madrid -UPM 8.5.1.1. Procedimiento de bsqueda El algoritmo recursivo para las N-Reinas ya descrito tiene que modificarse ligeramente para recibir como parmetro la posicin de la reina en la primera fila. Para una gestin ordenada del procedimiento de bsqueda se ha definido la clase NQueen cuyo fichero de cabecera (.h) es el siguiente: class NQueen { public: NQueen(); NQueen (int N); virtual ~NQueen();
void Reset(); void Set(int N); int SetReinaPrimeraFila(int posq); int GetSol();
int SolveQ();
private: void FuncRec(int fila, int izq, int abajo, int dcha); int m_TODOUNOS; int m_sol; int m_N; int m_posq; //0 a (N-1) }; Los datos miembro de la clase contienen la informacin inicial para el algoritmo tal y como se present en las secciones anteriores; m_N tiene el valor de N y m_TODOUNOS es un entero con los N primeros bits a 1 y el resto a cero. A stos se aade ahora m_posq que contiene la posicin de la reina en la primera fila, punto de partida de la bsqueda. La funcin miembro privada FuncRec lanza el procedimiento recursivo de bsqueda y es idntica a la ya descrita e el caso general. El interfaz de la clase consta de la funcin GetSol, que devuelve el valor solucin almacenado en m_posq, diversas funciones de inicializacin y el proceso que gestiona el inicio de la bsqueda SolveQ. El cdigo fuente de SolveQ es: int NQueen::SolveQ() { int izq, dcha, abajo, pos;
m_sol = 0; m_TODOUNOS = (1 << m_N) - 1;
//Reina en la primera fila pos =(1<<m_posq); izq=pos <<1; dcha=pos >>1; abajo=pos;
FuncRec(1, izq, abajo, dcha); return m_sol; } Iniciados los parmetros m_sol y m_TODOUNOS, se procede de forma manual a ubicar la primera reina en la casilla m_posq del borde superior del tablero Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 156 Universidad Politcnica de Madrid -UPM (fila 0). Para ello, basta con actualizar las estructuras de datos izq, dcha, y abajo que permiten computar el estado-fila siguiente mediante las operaciones con bits ya explicadas anteriormente: pos=(1<<m_posq): Traduce la posicin relativa de la reina a la mscara con un nico bit a uno correspondiendo a esa posicin. La operacin de desplazamiento determine que m_posq tome valores entre 0 (pos = 0000..0012) y N-1. izq=pos<<1: casilla de la segunda fila atacada por la reina situada en la casilla m_posq de la primera fila, considerando su movimiento en la direccin diagonal izquierda y sentido descendente. dcha=pos>>1: casilla de la segunda fila atacada por la reina situada en la casilla m_posq de la primera fila considerando su movimiento en la direccin diagonal derecha y sentido descendente. abajo=pos: casilla de la segunda fila atacada por la reina situada en la casilla m_posq de la primera fila considerando su movimiento en vertical y sentido descendente. 8.5.1.2. Hilo principal del servidor Una vez que se lanza el hilo de comunicaciones mediante la funcin heredada server.Init, el hilo principal del servidor entra en un bucle infinito y comprueba cada segundo si existe una bsqueda que completar. Para ello se ha elegido el valor del parmetro m_posq como elemento de comprobacin. Si recibe una tarea correcta, m_posq toma un valor mayor que cero. Si la tarea recibida no es correcta o ha terminado la bsqueda actual, m_posq toma el valor -1. La funcin main del servidor es la siguiente: int main(int argc, char* argv[]) { NQueen queen; MyLiveServer server(&queen); server.Init("127.0.0.1",12000);
while(1) { if(queen.GetPos()>=0){ //Comprueba si existe tarea queen.SolveQ(); cout<<"Solucion Encontrada: "<<queen.GetSol()<<endl; queen.SetReinaPrimeraFila(-1); //Fin de bsqueda } Sleep(1000); //Esperar 1 segundo } } Los parmetros de la funcin server.Init() son la direccin IP y el puerto donde est escuchando el servidor. Los parmetros que figuran permiten realizar pruebas con la arquitectura cliente-servidor en una sola mquina, para el cdigo del cliente descrito en la seccin anterior. El servicio Sleep (Win32) suspende la ejecucin del proceso que lo ejecuta durante el tiempo que figura como argumento (medido en milisegundos). Al terminar la bsqueda, la instruccin Rodrguez-Losada & San Segundo, 2009. Programacin Avanzada, Concurrente y Distribuida 157 Universidad Politcnica de Madrid -UPM queen.SetReinaPrimeraFila(-1) asigna el valor -1 al dato miembro m_posq. De esta manera se consigue que el hilo principal de ejecucin no entre en el bucle hasta que haya una nueva peticin del cliente ya que queen.GetPos() devuelve ahora como resultado -1. La figura 9 muestra la traza de la sesin del cliente para el problema de las 8-Reinas con una reina situada en la esquina derecha del tablero como nica particin. La comunicacin se establece localmente en el puerto 12000. La explicacin de la sesin es la siguiente: Las dos primeras lneas de la sesin Comenzando Thread Server y Server: indican que se ha arrancado un Socket correctamente y que se encuentra a la espera de la llegada de un mensaje por parte del cliente. Esto se corresponde con la llamada a la funcin miembro heredada Init. La aparicin de ambos mensajes pertenece tambin a la funcionalidad heredada. Tras la llegada del mensaje con el problema a resolver, se llama a la funcin miembro OnMsg implementada en MyLiveServer. Esta funcin llama, a su vez, a la funcin OnMsg miembro de la jerarqua heredada (mensajes Client connected from: y Connection) y posteriormente muestra los datos recibidos en pantalla Al terminar el hilo principal el procedimiento de bsqueda recursivo, se muestra la solucin en pantalla
Al llegar una peticin de resultado la funcin OnMsg muestra el mensaje en pantalla (Ha llegado el siguiente mensaje: Resultado). Al detectarse la desconexin del cliente lanza un mensaje de error y elimina el Socket de comunicacin abierto para l, quedando a la espera de la llegada de mensajes de nuevos clientes.
Figura 9. Traza de la sesin del servidor remoto correspondiente a la traza del lado del cliente mostrada en la Figura 8.