Page 1

Programación Avanzada, Concurrente y Distribuida

Diego Rodríguez-Losada González Pablo San Segundo Carrillo


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

2

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

3

PRÓLOGO

7

PARTE I. Desarrollo de una aplicación distribuida y concurrente en LINUX 1. EDICIÓN, COMPILACIÓN, Y DEPURACIÓN DE UNA APLICACIÓN C/C++ BAJO LINUX

11

1.1. 1.2. 1.3. 1.4. 1.5. 1.6. 1.7. 1.8. 1.9. 1.10. 1.11. 1.12. 1.13. 1.14. 1.15.

11 12 12 15 15 16 17 17 19 20 21 22 23 24 25

INTRODUCCIÓN LOGIN EN MODO TEXTO MANEJO DE ARCHIVOS Y DIRECTORIOS EN MODO TEXTO. EL EDITOR DE TEXTO DESARROLLO C/C++ EN LINUX EN MODO TEXTO EL PROCESO DE CREACIÓN DE UN EJECUTABLE LAS HERRAMIENTAS DE DESARROLLO EL COMPILADOR GCC MAKEFILE Y LA HERRAMIENTA MAKE TIPOS DE ERROR DEPURACIÓN DE LA APLICACIÓN. CREACIÓN DE UN SCRIPT DESARROLLO EN UN ENTORNO GRAFICO EJERCICIO PRÁCTICO EJERCICIO PROPUESTO

2. INTRODUCCIÓN A LOS SISTEMAS DISTRIBUIDOS. COMUNICACIÓN POR SOCKETS

27

2.1. 2.2. 2.3. 2.3.1 2.3.2 2.4. 2.4.1 2.4.2 2.5. 2.6. 2.6.1 2.6.2 2.7.

27 28 29 30 32 35 36 38 42 44 44 45 45

OBJETIVOS SISTEMA DISTRIBUIDO SERVICIOS DE SOCKETS EN POSIX PROGRAMA CLIENTE SERVIDOR ENCAPSULACIÓN DE UN SOCKET EN UNA CLASE C++ ENVÍO DE MÚLTIPLES MENSAJES CONEXIONES MÚLTIPLES. ESTRUCTURA DE FICHEROS TRANSMITIENDO EL PARTIDO DE TENIS CONEXIÓN ENVÍO DE DATOS EJERCICIOS PROPUESTOS

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

4

3. COMUNICACIONES Y CONCURRENCIA

47

3.1. 3.2. 3.3. 3.3.1 3.4. 3.5. 3.6. 3.7. 3.8. 3.9. 3.10.

47 49 49 50 51 52 53 55 56 56 57

INTRODUCCIÓN REQUISITOS FUNCIONAMIENTO DE GLUT LANZANDO UN HILO ESTRUCTURA DEL SERVIDOR MÚLTIPLES CONEXIONES SIMULTANEAS MOSTRAR LOS CLIENTES CONECTADOS RECEPCIÓN COMANDOS MOVIMIENTO GESTIÓN DESCONEXIONES FINALIZACIÓN DEL PROGRAMA EJERCICIO PROPUESTO

4. COMUNICACIÓN Y SINCRONIZACIÓN INTERPROCESO

59

4.1. 4.2. 4.3. 4.4. 4.5. 4.6.

59 60 61 62 64 68

INTRODUCCIÓN EL PROBLEMA DE LA SINCRONIZACION COMUNICACIÓN INTERPROCESO TUBERÍAS CON NOMBRE MEMORIA COMPARTIDA EJERCICIOS PROPUESTOS

PARTE II. Programación avanzada 5. PROGRAMACIÓN DE CÓDIGO EFICIENTE

73

5.1. 5.2. 5.3. 5.4. 5.5. 5.5.1 5.5.2 5.5.3 5.5.4 5.5.5 5.6. 5.6.1 5.6.2 5.6.3 5.7. 5.8.

73 77 77 78 79 79 80 83 85 86 87 87 88 90 93 95

INTRODUCCIÓN MODOS DE DESARROLLO TIPOS DE OPTIMIZACIONES VELOCIDAD DE EJECUCIÓN ALGUNAS TÉCNICAS CASOS FRECUENTES BUCLES GESTIÓN DE MEMORIA TIPOS DE DATOS TÉCNICAS EN C++ CASOS PRÁCTICOS ALGORÍTMICA VS. MATEMÁTICAS GENERACIÓN DE NÚMEROS PRIMOS PRE-COMPUTACIÓN DE DATOS OBTENIENDO PERFILES (PROFILING) DEL CÓDIGO CONCLUSIONES

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida 6. SERIALIZACIÓN DE DATOS 6.1. 6.2. 6.3. 6.3.1 6.3.2 6.4. 6.4.1 6.4.2 6.5.

5 97

INTRODUCCIÓN REPRESENTACIÓN OBJETOS EN MEMORIA SERIALIZACIÓN EN C CON FORMATO (TEXTO) SIN FORMATO (BINARIA) SERIALIZACIÓN EN C++ CON FORMATO (TEXTO) SIN FORMATO (BINARIA) CONCLUSIONES

97 102 103 104 104 107 108 111 112

7. BÚSQUEDAS EN UN ESPACIO DE ESTADOS MEDIANTE RECURSIVIDAD

113

7.1. 7.2. 7.2.1 7.2.2 7.2.3 7.3. 7.4.

INTRODUCCIÓN 113 115 BÚSQUEDA PRIMERO EN PROFUNDIDAD 116 TERMINOLOGÍA 116 ESTRUCTURAS DE DATOS 117 ANÁLISIS 119 BÚSQUEDA PRIMERO EN ANCHURA METODOLOGÍA GENERAL DE RESOLUCIÓN DE UN PROBLEMA DE BÚSQUEDA MEDIANTE COMPUTACIÓN 120 7.5. IMPLEMENTACIÓN DE UNA BÚSQUEDA DFS MEDIANTE RECURRENCIA 121 122 7.5.1 LA PILA DE LLAMADAS 124 7.5.2 BÚSQUEDA DFS COMO RECURSIÓN

8. EJECUCIÓN DISTRIBUIDA DE TAREAS

133

8.1. 8.2. 8.2.1 8.2.2 8.2.3 8.3. 8.3.1 8.3.2 8.3.3 8.3.4 8.4. 8.4.1 8.4.2 8.4.3 8.5. 8.5.1

133 134 134 135 136 138 139 140 141 145 147 147 148 148 153 153

INTRODUCCIÓN EL PROBLEMA DE LAS N-REINAS HISTORIA CARACTERÍSTICAS 2ESTRUCTURAS DE DATOS IMPLEMENTACIÓN CENTRALIZADA DESCRIPCIÓN ESTRUCTURAS DE DATOS CONTROL DE LA BÚSQUEDA ALGORITMO DE BÚSQUEDA IMPLEMENTACIÓN DISTRIBUIDA ARQUITECTURA CLIENTE-SERVIDOR PROTOCOLO DE COMUNICACIÓN IMPLEMENTACIÓN DEL CLIENTE IMPLEMENTACIÓN DEL SERVIDOR COMUNICACIÓN CON EL CLIENTE

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

6

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

7

PRÓLOGO Generalmente la formación en informática de un ingeniero (industrial, automática, telecomunicaciones o similar) comienza por la programación estructurada, en lenguajes como C o Matlab, y luego se complementa con Programación Orientada a Objetos (POO) e Ingeniería del Software, con Análisis y Diseño Orientados a Objetos, UML, etc. Sin embargo, existen una serie de técnicas y tecnologías software que escapan del alcance de los anteriores cursos. La programación de tareas concurrentes, los sistemas distribuidos, la programación de código eficiente o algorítmica avanzada son temas que quedan a menudo relegados, y sin embargo son muy necesarios en tareas de ingeniería industrial, comunicaciones y similares. Este libro trata de cubrir dichos aspectos, de una manera práctica y aplicada. La primera parte desarrolla una aplicación gráfica distribuida: un típico juego de computador en red. En esta aplicación se requiere el uso de comunicaciones por red (con sockets), así como la utilización de técnicas de programación 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 introducción al manejo básico, desarrollo y depuración con herramientas GNU como g++, make y gdb. El código de soporte para estos capítulos se encuentra en www.elai.upm.es La segunda parte cubre algunos tópicos genéricos avanzados como la programación de código eficiente, la serialización de datos, la recurrencia o la computación distribuida, tópicos que muchas veces están íntimamente relacionados con los anteriores.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

8

Universidad Politécnica de Madrid -UPM


Rodr铆guez-Losada & San Segundo, 2009. Programaci贸n Avanzada, Concurrente y Distribuida

9

Parte I. Desarrollo de una aplicaci贸n distribuida y concurrente en LINUX

Universidad Polit茅cnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

11

1.

EDICIÓN, COMPILACIÓN, Y DEPURACIÓN DE UNA APLICACIÓN C/C++ BAJO LINUX 1.1. INTRODUCCIÓN En este primer tema realizamos una aproximación 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 programación concurrente y sistemas distribuidos, en este primer tema nos ceñiremos al trabajo de desarrollo convencional en linux, para aprender tanto el desarrollo sin interfaz grafica de ventanas, como algunas de las herramientas graficas. También se manejaran algunos comandos o mandatos básicos de linux para crear, editar y manejar archivos, y se introducirá el uso de las herramientas de desarrollo básico como son gcc, g++, make y gdb. Este tema comienza por la descripción de los comandos básicos para trabajar en modo texto, para después desarrollar y depurar una pequeña aplicación ejemplo en modo texto. Por ultimo, se trabajara en modo grafico, completando un código 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.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

12

Figura 1-1. Objetivo del capítulo: 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 práctica se va a desarrollar en modo texto. Para ello cámbiese del terminal grafico al primer terminal de texto, mediante la correspondiente combinación de teclas (Ctrl+Alt+F1) Entrar en la cuenta de usuario correspondiente. Consejo: Aunque dispongas de la contraseña 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 práctica. 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

|

|

|

|------->archivo1.txt

|------->archivo11.txt

|------------->carpeta2 |------->archivo2.txt Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

13

Utilizar y explorar los comandos y opciones siguientes: Tabla 1-1. Comandos básicos consola linux

Comando pwd ls

Acción muestra el directorio actual muestra el contenido del directorio actual

mkdir [directorio]

crea el directorio con el nombre dado cambia al directorio que indica la ruta correspondiente concatena el fichero a salida estándar

cd [ruta] cat [fichero]

chmod usuario+permiso [fichero] rm [archivo]

cp [origen] [destino] mv [origen] [destino] rmdir [directorio] exit o logout

cambia los permisos (r=read, w=write, x=execute) a usuario (a=all, o=others, u=user, g=group) Borra el archivo

Copia el archivo o archivos origen al destino seleccionado Mueve el archivo o archivos origen al destino seleccionado Borra el directorio, que previamente debe estar vacío Termina la sesión (salir)

Opciones -a (muestra todos los archivos, incluidos ocultos) –l, muestra detalles de los archivos

‘-‘ significa entrada estándar. Para crear un archivo se puede redireccionarla de la siguiente forma cat >”nombre_fichero.txt”

-r = borra recursivamente el directorio seleccionado (OJO, usar con mucha precaución)

También sirve para renombrar un archivo

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

14

Tabla 1-2. Caracteres comodin (wildcars)

* ?

Una cadena de caracteres cualesquiera Un carácter cualquiera Tabla 1-3. Directorios importantes

. .. cd

Directorio actual Directorio superior Vuelve al directorio inicial raiz del usuario “\home\usuario”

Opcion

Tabla 1-4. Ayuda

Comando man [comando] comando info [comando] whatis [comando]

función Muestra las paginas “man” del comando seleccionado Muestra una ayuda breve del comando al que se aplica Muestra las paginas “info” del comando al que se aplica Busca en una base de datos descripciones cortas del comando

Opcion --help -h

Tabla 1-5. Ayudas del shell bash

Teclas Tab

Tab+Tab Arrow Up Arrow Down

función Autocompletar, rellena el nombre del comando o archivo según las posibles opciones que conozca Muestra todas las opciones que tiene autocompletar Sube en la historia de comandos Baja en la historia de comandos

Opcion

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 operación. Borrar primero el archivo2.txt y luego la carpeta2. Borrar a continuación todo el árbol de la carpeta1. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

15

1.4. EL EDITOR DE TEXTO Se va a utilizar el editor “vi” o “vim” para crear y modificar los archivos de código fuente necesarios, por ser el editor incluido por defecto en linux, y del que conviene tener al menos unas nociones básicas 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 función específica (borrar, mover…) Este es el modo por defecto al arrancar el editor.

Modo inserción: cada tecla inserta el carácter 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 básicas •

: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 aplicación 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 práctica, y cambiar el directorio actual a la misma 3. Crear los archivos fuente siguientes:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida /* * */

16

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; }

/* * archivo: misfunc.h */ #ifndef _MIS_FUNC_H_INCLUDED #define _MIS_FUNC_H_INCLUDED float seno(float num); #endif //_MIS_FUNC_H_INCLUDED

/* * */

archivo: misfunc.c

#include “misfunc.h” #include <math.h> float seno(float num) { return sin(num); }

1.6. EL PROCESO DE CREACIÓN DE UN EJECUTABLE El compilador genera un fichero o modulo objeto (binario) por cada uno de los ficheros fuentes contenidos en el proyecto. Estos módulos objeto no necesitan para ser compilados más que el fichero fuente de origen, aunque se referencien funciones externas a dicho fichero. El proceso de enlazado une los módulos objeto resolviendo las referencias entre ellos, así como las referencias a posibles bibliotecas o librerías externas al proyecto, y generando el archivo ejecutable. El sistema operativo es el encargado de unir el ejecutable con las librerías dinámicas cuando el programa es cargado en memoria. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida Fichero fuente A “.c, .cpp”

17

Fichero fuente B “.c, .cpp”

COMPILADOR

Modulo objeto A “.o”

Modulo objeto B “.o”

Biblioteca estática A “.a”

Biblioteca estática B “.a”

LINKADO

Ejecutable

Librerías dinámicas “.so”

EJECUCION

Proceso en ejecución Figura 1-2. Proceso de creación 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 distribución 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 instalación es totalmente legal. Si utiliza un sistema basado en Debian, la forma más 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 compilación y linkado del anterior programa seria: gcc –o prueba principal.c misfunc.c –lm Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

18

Ejecutar el programa mediante: ./prueba

Figura 1-3. Salida por pantalla de nuestra aplicación

Comprobar con ls –al los permisos de ejecución del archivo ls -al La sintaxis es la siguiente: gcc –o [nombre_ejecutable] [ficheros_fuente] –l[librería] Realmente este comando ha realizado la compilación y el linkado todo seguido, de forma transparente para el usuario. Si se desea desacoplar las dos fases se realiza de la siguiente manera: •

Compilación fichero a fichero :

gcc –c principal.c gcc –c misfunc.c (Nótese que aquí no es necesario especificar que se va a linkar con la librería matemática, ya que solo se esta compilando en un modulo objeto .o) •

Compilación de varios ficheros en la misma línea

gcc –c principal.c misfunc.c •

Enlazado

gcc –o prueba principal.o misfunc.o –lm Nótese que la opción –lm hace referencia a linkar –l con la librería “m” o de nombre completo “libm.a” o “libm.so” que es la librería estándar matemática en sus versiones estáticas o dinámicas. Buscar con find / -name “libm.*” •

Eliminar archivos objeto

rm *.o

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

19

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 múltiples opciones de compilación. Por ello existe una herramienta, el “make”, que haciendo uso de la configuración de un fichero denominado “Makefile” (sin extensión, típicamente situado en la carpeta en la que tenemos el proyecto), se encarga de todo este trabajo. Entre otras cosas, se encarga de realizar la comprobación 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 descripción de una configuración básica para el proyecto de esta practica. Para ello crear y editar con el “vi“ el archivo siguiente: #Makefile del proyecto CC=gcc CFLAGS= -g LIBS= -lm OBJS=misfunc.o principal.o prueba: $(OBJS) $(CC) $(OBJS) $(LIBS) –o prueba 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 definición de variables, mediante una simple asignación. 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 compilación, en este caso habilita la información que posibilita la depuración del ejecutable CFLAGS= -g

La cadena LIBS almacena las librerías con las que hay que linkar para generar el ejecutable LIBS= -lm

La cadena OBJS define los módulos objeto que componen el ejecutable. Aquí se deben listar todos los archivos objeto necesarios, si nos olvidamos alguno, el enlazador encontrara un error.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

20

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 construcción del ejecutable a partir de los objetos, y linkando con las librerías 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 librería matemática –lm. A su vez especificamos la compilación de cada uno de los módulos objeto: principal.o: principal.c misfunc.h $(CC) –c principal.c misfunc.o: misfunc.c misfunc.h $(CC) –c principal.c

Las dos primeras líneas, 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 código 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 línea 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 ejecución y errores en tiempo de compilación. Vamos a ver la diferencia entre ambos: •

Errores en tiempo de compilación. Son errores, principalmente de sintaxis. El compilador los detecta y nos informa de ello, no produciendo Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

21

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 informándonos del error sintáctico y en que línea se produce. •

Errores en tiempo de ejecución. También llamados errores lógicos 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 lógico. Por ejemplo, la división por cero, sintácticamente no es un error en el programa, pero al realizar la división, se produce un error en tiempo de ejecución. En todo caso, si el compilador detecta la división 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 informándonos de ello. También cabe la posibilidad de que un fallo en el código del programa produzca un comportamiento no deseado, pero que este no resulte en un fallo fatal y el programa finalice bruscamente.

1.11.

DEPURACIÓN DE LA APLICACIÓN.

Para depurar un programa se debe ejecutar el depurador seguido del nombre del ejecutable (que debe haber sido creado con la opción –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 ejecución 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: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

22

Tabla 1-6. Comandos básicos de gdb

Grupo running

Comando run step next finish continue

data

display [exp]

breakpoint

undisplay [exp] print [exp] break [num_linea] clear [num_linea] delete break

status

info [opcion]

ninguno

quit

Acción comienza la depuración del programa ejecuta un paso, entrando en funciones ejecuta un paso, sin entrar en funciones termina la ejecución del programa continua la ejecución del programa, hasta el siguiente breakpoint muestra el contenido de la variable “exp” cada vez que el programa se para quita el comportamiento anterior Muestra el contenido de “exp” inserta un punto de parada o “Breakpoint” en la línea correspondiente Eliminan el breakpoint de la línea correspondiente Pregunta si se desea eliminar todos los breakpoints Muestra información acerca de la opción elegida, por ejemplo “ info break” muestra los breakpoints. sale del debugger

Realizar la depuración del programa anterior, viendo el valor de las posibles variables, ejecutando paso a paso.

1.12.

CREACIÓN 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 continuación 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 ejecución. Para eso realizamos el cambio: chmod a+x miscript Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

23

Debe de quedar claro que con un script no se tiene código máquina, 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 podrían destacar el Kdevelop, Anjuta, o Eclipse. Para el desarrollo de nuestra aplicación hemos optado por Geany, que realmente es más un editor de texto que un entorno de desarrollo, pero sin embargo tiene las características necesarias para nuestra aplicación. Geany dispone de resaltado en colores del código, y de gestion de la compilación mediante Makefile, que permite mediante la pulsación de F9 la invocación automática de Makefile (aunque el fichero Makefile lo debemos proveer nosotros), así como la gestión de los posibles errores de compilación, con la posibilidad de saltar a la línea 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 librerías necesarias de Glut, es necesario: sudo apt-get install geany glutg3-dev

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

1.14.

24

EJERCICIO PRÁCTICO

Se suministra en una carpeta un conjunto de ficheros de código 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 están 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(); float float float protected: float };

x1,y1; x2,y2; r,g,b; Distancia(Vector2D punto, Vector2D *direccion);

#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();

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

25

CMundo(); virtual ~CMundo(); void void void void

InitGL(); OnKeyboardDown(unsigned char key, int x, int y); OnTimer(int value); OnDraw();

};

Se solicita al alumno que complete la clase Mundo para obtener el juego del tenis funcional. Se debe escribir un Makefile para la construcción 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 tamaño de su raqueta. También se propone el desarrollo de cualquier otro juego de complejidad similar.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

27

2.

INTRODUCCIÓN A LOS SISTEMAS DISTRIBUIDOS. COMUNICACIÓN POR SOCKETS

2.1. OBJETIVOS En el capítulo anterior se ha desarrollado el juego básico 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 consecución del juego totalmente distribuido, es decir, cada jugador podrá jugar en su propio ordenador, con su teclado y su monitor, y los dos ordenadores estarán conectados por la red. En este capítulo se presenta una introducción a los sistemas distribuidos, los servicios proporcionados en POSIX para el manejo de Sockets, que son los conectores necesarios (el recurso software) para la comunicación por la red, y su uso en nuestra aplicación. No pretende ser una guía exhaustiva de dichos servicios sino una descripción práctica del uso más sencillo de los mismos, y como integrarlos en nuestra aplicación para conseguir nuestros objetivos. De hecho, en el curso del capítulo 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 aproximación al objetivo final se va a realizar en este capítulo la “retransmisión” del partido de tenis por la red. Esto es, los dos jugadores van a seguir jugando en la misma máquina con el mismo teclado, pero sin embargo otro usuario desde otra máquina podrá conectarse remotamente a través de la red a la máquina y a Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

28

la aplicación en la que juegan los jugadores (el servidor), y esta le enviara constantemente los datos necesarios para que la máquina 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 solución para un único espectador, y finalmente se aborda la solución para múltiples espectadores. No obstante esta última requerirá para su correcto funcionamiento el uso de programación concurrente (hilos) que se abordara en sucesivos capítulos.

RED “Retransmisión” partido Servidor, en el que juegan los dos jugadores con el mismo teclado

N posibles “clientes” que se conectan al servidor para ver el partido

Figura 2-1. Objetivo del capítulo: Retransmisión de la partida de tenis a ordenadores remotos conectados a través de la red al servidor En sucesivos capítulos se completará el desarrollo del juego distribuido haciendo que los jugadores puedan realmente jugar en dos máquinas distintas, que transmitirán 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 físico de la máquina en la que corre el servidor.

2.2. SISTEMA DISTRIBUIDO Llamaremos sistema distribuido a una solución software cuya funcionalidad es repartida entre distintas máquinas, teniendo cada máquina su propio procesador (o propios procesadores), su propia memoria, y corriendo su propio sistema operativo. Además, no es necesario que las máquinas sean iguales, ni ejecuten el mismo SO ni el mismo software. Las máquinas estarán interconectadas por una red que sirve para el intercambio de mensajes entre dichas máquinas.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

29

2.3. SERVICIOS DE SOCKETS EN POSIX A continuación se presenta el código de un programa cliente y de un programa servidor, para describir breve y generalmente los servicios de sockets implicados. Este código es prácticamente el más básico posible, sin comprobación 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 conexión. A continuación se debe lanzar el programa cliente que se conectará al servidor. Una vez que ambos estén conectados, el servidor enviara al cliente unos datos (una frase) que el cliente mostrará por pantalla, y a finalmente terminarán ambos programas. El funcionamiento en líneas generales queda representado en la siguiente figura:

TCP/IP

Cliente socket()

Se crea el socket de conexión y comunicación (es el mismo)

Servidor Se crea el socket de conexión

socket()

Se le asigna una dirección y un puerto y se pone a la escucha

bind()

El socket de conexión se queda bloqueado a la espera “Aceptando una conexión”

connect()

Se conecta a la dirección del servidor

recv()

accept()

Cuando el cliente se conecta al socket de conexión que esta “Aceptando”, este devuelve un socket de conexión que es con el que se realiza la comunicación

send() Comunicación

listen()

Comunicación

send() recv()

shutdown() close()

Cierre

Cierre

shutdown() close()

Figura 2-2. Conexión sockets

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

30

2.3.1 Programa cliente El código del programa cliente básico 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 continuación se describe brevemente el programa: Las primeras líneas 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 librería con la que hay que enlazar se podrían establecer con las líneas: #include <winsock2.h> #pragma comment (lib, "ws2_32.lib")

En las primeras líneas 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;

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

31

La primera línea declara el descriptor del socket (de tipo entero) que se utiliza tanto para la conexión como para la comunicación. La segunda declaración declara una estructura de datos que sirve para almacenar la dirección IP y el número de puerto del servidor y la familia de protocolos que se utilizaran en la comunicación. La asignación 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);

Nótese que la IP que utilizaremos será la “127.0.0.1”. Esta IP es una IP especial que significa la máquina actual (dirección local). Realmente ejecutaremos nuestras 2 aplicaciones (cliente y servidor) en la misma máquina, utilizando la dirección local de la máquina. No obstante esto se puede cambiar. Para ejecutar el servidor en una máquina que tiene la IP “192.168.1.13” por ejemplo, basta poner dicha dirección en ambos programas, ejecutar el servidor en esa máquina, y el cliente en cualquier otra (que sea capaz de enrutar mensajes hacia esa IP). A continuación se crea el socket, especificando la familia de protocolos (en este caso protocolo de Internet AF_INET) y el tipo de comunicación que se quiere emplear (fiable=SOCK_STREAM, no fiable=SOCK_DGRAM). En nuestro caso utilizaremos siempre comunicación fiable. //creacion del socket socket_conn=socket(AF_INET, SOCK_STREAM,0);

Esta función generalmente no produce errores, aunque en algún caso podría hacerlo. Como regla general conviene comprobar su valor, que será igual a -1 (INVALID_SOCKET) si la función ha fallado. A continuación se intenta la conexión con el socket especificado en la dirección del servidor. //conexion int len= sizeof(server_address); connect(socket_conn,(struct sockaddr *) &server_address,len);

Esta función connect() fallará si no esta el servidor preparado por algún motivo (lo que sucede muy a menudo). Por lo tanto es más que conveniente comprobar el valor de retorno de connect() para actuar en consecuencia. Se podría 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 conexión se realiza correctamente, el socket ya esta preparado para enviar y recibir información. En este caso hemos decidido que va a ser el servidor el que envía datos al cliente. Esto es un convenio entre el cliente y el servidor, que adopta el programador cuando diseña e implementa el sistema. Como el cliente va a recibir información, utilizamos la función de recepción. En esta función, se le suministra un buffer en el que guarda la información y el número de bytes máximo que se espera recibir. La función recv() se bloquea hasta que el servidor envíe alguna información. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

32

Dicha información puede ser menor que el tamaño máximo suministrado. El valor de retorno de la función 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 comunicación y se cierra el socket. shutdown(socket_conn, SHUT_RDWR); close(socket_conn); socket_conn=INVALID_SOCKET;

2.3.2 Servidor El código del programa servidor es algo más complejo, ya que debe realizar más tareas. La principal característica es que se utilizan 2 sockets diferentes, uno para la conexión y otro para la comunicación. El servidor comienza enlazando el socket de conexión a una dirección IP y un puerto (siendo la IP la de la máquina en la que corre el servidor), escuchando en ese puerto y quedando a la espera “Accept” de una conexión., 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 envían 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));

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

33

//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 shutdown(socket_conn, SHUT_RDWR); close(socket_conn); socket_conn=INVALID_SOCKET; shutdown(socket_server, SHUT_RDWR); close(socket_server); socket_server=INVALID_SOCKET; return 1; }

Hasta la creación del socket del servidor, el programa es similar al cliente, quitando la excepción de que se declaran los 2 sockets, el de conexión y el de comunicación. La primera diferencia son las líneas: //configuracion del socket para reusar direcciones int on=1; setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&on,sizeof(on));

Estas líneas se utilizan para que el servidor sea capaz de re-usar la dirección y el puerto que han quedado abiertos sin ser cerrados correctamente en una ejecución anterior. Cuando esto sucede, el sistema operativo deja la dirección del socket reservada y por tanto un intento de utilizarla para un servidor acaba en fallo. Con estas líneas podemos configurar y habilitar que se re-usen las direcciones previas. La segunda diferencia es que en vez de intentar la conexión con connect(), el servidor debe establecer primero en que dirección va a estar escuchando su socket de conexión, lo que se establece con las líneas: 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 función bind() enlaza el socket de conexión con la IP y el puerto establecidos anteriormente. Esta función también es susceptible de fallo. El fallo más común es cuando se intenta enlazar el socket con una dirección y puerto que ya están ocupados por otro socket. En este caso la función 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

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

34

volver a ejecutar el programa, el bind() falle, no teniendo sentido continuar la ejecución. La gestión básica de este error podría ser: if(0!=bind(socket_server,(struct sockaddr *) &server_address,len)) { std::cout<<”Fallo en el Bind()”<<std::endl; return -1; }

La función listen() permite definir cuantas peticiones de conexión al servidor serán encoladas por el sistema. Nótese que esto no significa que realmente se atiendan las peticiones de conexión. Es el usuario a través de la función accept() el que acepta una conexión. 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 más importante del accept() es que en su modo normal bloquea el programa hasta que realmente se realiza la conexión por parte del cliente. A esta función se le suministra el socket de conexión, y devuelve el socket que realmente se utilizará para la comunicación. Si algo falla en la conexión, la función devolverá -1, lo que corresponde a nuestra definición 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 conexión, la comunicación se hace por el nuevo socket, utilizando las mismas funciones de envío y recepción que se podrían usar en el cliente. Como en el ejemplo actual, por convenio hemos establecido que será el servidor el que envía un mensaje al cliente, el código siguiente envía 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 función send() también puede fallar, si el socket no esta correctamente conectado (se ha desconectado el cliente por ejemplo). La función devuelve el número de bytes enviados correctamente o -1 en caso de error. Típicamente, si la conexión es buena, la función devolverá como retorno un valor igual a “length”, aunque también es posible que no consiga enviar todos los datos que se le ha solicitado. Una solución completa debe contemplar esta posibilidad y reenviar los datos que no han sido enviados. No obstante y por simplicidad, realizamos ahora una gestión sencilla de este posible error: if(lenght!=send(socket_conn, cad, length,0)) { std::cout<<”Fallo en el send()”<<std::endl; return -1; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

35

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 conexión y el de comunicación. La salida por pantalla al ejecutar las aplicaciones (primero arrancar el servidor y luego el cliente) debería ser (en el lado del cliente): Rec: 11 contenido: Hola Mundo Nótese que los bytes recibidos son 11 porque incluyen el carácter nulo ‘\0’ de final de la cadena

2.4. ENCAPSULACIÓN 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 verán en este curso. Es una práctica 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 más complejos, facilitando la tarea al usuario. Así, por ejemplo, las Microsoft Fundation Classes (MFC) tienen sus clases CSocket y CAsyncSocket para estas tareas. También 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 común encontrar, bajo una perspectiva estricta de Programación Orientada a Objetos (POO) que el cliente y servidor se implementan en clases separadas. No obstante, se adopta ahora un enfoque más sencillo con una sola clase, que utiliza diferentes métodos en caso del cliente y del servidor. EJERCICIO: Desarrollar la clase Socket, de acuerdo con la cabecera siguiente, para que encapsule los detalles de implementación 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; };

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

36

El código 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 código 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 Envío de múltiples mensajes Obviamente, la comunicación no necesariamente se reduce al envío de un mensaje. Supóngase que el servidor lo que quiere enviar es un mensaje 10 veces. Aunque el mensaje podría ser distinto cada vez, para realizar la prueba podemos enviar 10 veces el mismo saludo, quedando el código del servidor como sigue: char cad[]="Hola Mundo"; int length=sizeof(cad);

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

37

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 podríamos 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 podríamos 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 envía seguido y todo lo rápido que le permite el bucle for los mensajes, que llegan al cliente. Si el cliente solicita recibir un mensaje de una longitud máxima de 1000 caracteres puede leer efectivamente más 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 envía datos mucho más despacio de lo que recibe el cliente. En este caso no se suele presentar ningún problema. Supóngase 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; } }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

38

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 carácter terminador de mensaje. En el caso anterior podríamos haber recorrido los mensajes buscando los caracteres nulos ‘\0’ que nos separarían cada mensaje. Si consideramos los mensajes de longitud fija el código del servidor podría 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 código del cliente quedaría: 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; }

Nótese que aunque se necesitan solo unos pocos bytes para enviar “Hola Mundo”, realmente se envían muchos más. Es un enfoque bastante ineficiente, pero muy simple. Se supone que se van a enviar distintos mensajes y que nunca serán más largos que 100 caracteres. La salida por pantalla es correcta porque se incluye el carácter final de cadena ‘\0’, por lo que realmente no se imprimen los 100 caracteres existentes en el buffer.

2.4.2 Conexiones múltiples. Un servidor puede aceptar más de una conexión, de tal forma que puede permitir ejecutar varias veces seguidas el mismo cliente, o incluso a distintos clientes desde distintas máquinas. Las conexiones pueden incluso ser simultáneas, es decir se puede permitir conectarse a un cliente y cuando termina de comunicar con el, permitir la conexión de otro cliente, o se puede permitir la conexión y comunicación simultánea con varios clientes. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

39

Un cliente solo puede comunicarse con un servidor.

2.4.2.1.

Conexiones secuenciales

Una primera opción 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.

Servidor Se crea el socket de conexión

Se le asigna una dirección y un puerto y se pone a la escucha

Cliente Se crea el socket de conexión y comunicación

Conexión

Comunicación

El socket de conexión se queda bloqueado a la espera “Aceptando una conexión”

Socket de conexion

Comunicación

Cierre Cierre del socket

¿Seguir aceptando clientes?

SI

NO Cierre del socket

Figura 2-3. Servidor que permite múltiples conexiones secuenciales de clientes El cliente permanecería inalterado, y el código del servidor quedaría como sigue: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

40

#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 función listen() toma sentido en este contexto, ya que permite poner a la cola peticiones de conexiones de varios clientes que intentan la conexión mientras el servidor esta comunicando con el cliente actual. Cuando el servidor vuelve al accept() se atienden dichas peticiones de conexión.

2.4.2.2.

Conexiones simultáneas.

Es posible que el servidor acepte la conexión de varios clientes y envíe datos a todos ellos, manteniendo la conexión activa con todos simultáneamente. Para ello y dado que aun no estamos utilizando programación 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 conexión devuelve un socket diferente a través del accept(), estos sockets se pueden almacenar en un vector, y manejar todas las conexiones en el servidor a través de dicho vector.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

41

Servidor Se crea el socket de conexión

Se le asigna una dirección y un puerto y se pone a la escucha

ClienteN Cliente1

Se crea el socket de conexión y Se crea el socket i ió de conexión y comunicación

El socket de conexión se queda bloqueado a la espera “Aceptando una conexión”

Conexion Conexión Comunicacion

Socket de conexiónN Socket de conexión 1

Comunicación Cierre ¿Seguir aceptando clientes?

Cierre

SI

NO Comunicación N Comunicación 1

Cierre de los N sockets de comunicación y del de conexión

Figura 2-4. Comunicación simultanea con varios clientes El código resultante en el servidor podría 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; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

42

2.5. ESTRUCTURA DE FICHEROS Ahora que se ha visto como realizar el envío de información 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 aplicación distribuida del juego del tenis. Debe quedar claro que solo hay que desarrollar dos aplicaciones, la aplicación servidor y la aplicación cliente. La aplicación servidor se ejecutará una vez, pero la aplicación cliente (el mismo binario) puede ser ejecutado múltiples veces y en distintas máquinas. Se parte de la aplicación desarrollada en el tema anterior, que constituye el juego del tenis (los dos jugadores en la misma máquina), 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 intención podría 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 implicaría que habría mucho código idéntico duplicado en dos sitios. Por ejemplo, la clase Plano será exactamente igual en el cliente y en el servidor, su parametrización es igual, se dibuja igual. Por tanto no es necesario (de hecho es contraproducente) que el código este repetido. Se pueden desarrollar ambos programas, el cliente y el servidor compartiendo uno o varios archivos de código fuente. Si se analiza la funcionalidad del servidor y del cliente se llega a la conclusión 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 función CMundo::OnKeyboardDown(…)

El servidor cambia las posiciones de los objetos (anima), realiza los cálculos de las colisiones. El cliente no tiene que mover los objetos (podría moverlos de forma diferente al servidor), solo tiene que recibir la información del servidor de donde están los objetos en cada instante de tiempo. El cambio de posición de los objetos se hace en la función 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.) También es necesario duplicar el archivo en el que se encuentra el main(), ya que es

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

43

el que instancia la clase CMundo, y en función 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 all: servidor cliente servidor: $(OBJS) MundoServidor.o servidor.o $(CC) $(CFLAGS) -o servidor servidor.o MundoServidor.o $(OBJS) $(LIBS) cliente: $(OBJS) MundoCliente.o cliente.o $(CC) $(CFLAGS) -o cliente cliente.o MundoCliente.o $(OBJS) $(LIBS) 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

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

44

Con este Makefile la simple invocación make construye tanto el servidor como el cliente

2.6. TRANSMITIENDO EL PARTIDO DE TENIS Inicialmente vamos a realizar el envío de los datos necesarios del servidor a un único cliente. Para ello se deben de seguir los siguientes pasos:

2.6.1 Conexión Añadir el Socket de conexión y el de comunicación en la clase Mundo del servidor: Socket server; Socket conn;

Añadir el Socket en la clase Mundo del cliente Socket client;

En la función de inicialización del juego en el servidor se establece la dirección IP y el puerto del servidor y se espera la aceptación 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(); }

Nótese 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 conexión y ni siquiera ha creado aun la ventana grafica. No obstante el “accept” se podría realizar más tarde, después de haber creado la ventana. El cliente también realiza en su función init() la conexión del socket: //del fichero MundoCliente void CMundo::Init() { //inicializacion de la pantalla, coordenadas, etc client.Connect("127.0.0.1",12000); }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

45

2.6.2 Envío de datos Lo primero es necesario establecer cuales son los datos que es necesario que envíe el servidor al cliente. Dado que la pantalla es en su mayoría estática, las variables que es necesario transmitir podrían 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 temporización del timer. ¿Cómo se envían datos numéricos? Aunque una solución más evolucionada se presentara en un tema posterior, una primera solución sencilla consiste en escribir (sprintf()) estos valores numéricos en una cadena de texto y enviar dicha cadena de texto. EJERCICIO: 1. Realizar el envío de los datos por el socket de comunicación en el servidor (MundoServidor), en la función CMundo::OnTimer(), al final de la misma, manteniendo el código existente encargado de realizar la animación y lógica del juego. 2. Eliminar el código de la función CMundo::OnTimer() de MundoCliente y sustituirlo por la recepción del mensaje del servidor y la extracción de los valores numéricos.

2.7. EJERCICIOS PROPUESTOS •

Realizar la retransmisión del juego a un numero fijo de clientes, por ejemplo 3

Implementar los conceptos desarrollados en este tema en un juego de complejidad similar.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

3.

47

COMUNICACIONES Y CONCURRENCIA

3.1. INTRODUCCIÓN En el capítulo 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, también podíamos permitir que se conectaran varios clientes y después (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 programación convencional (secuencial). Analizaremos en este capítulo el porque y veremos la solución a dichos problemas. Comenzamos analizando un sencillo ejemplo. Supóngase que se esta diseñando un controlador de una máquina, que se plasma finalmente en un regulador que podría tener el siguiente aspecto (en pseudocódigo): 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() realizarían la interfaz correspondiente con el hardware de la máquina. Obviamente el programa se tiene que ejecutar de forma continua, recalculando en cada pasada el nuevo error y Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

48

enviando un comando nuevo. El programa anterior utiliza una referencia (el punto al que se quiere llevar el sistema) fija. Supóngase que ahora se desea que el usuario sea capaz de introducir por teclado dicha referencia tantas veces como quiera (para llevar la máquina 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 envía un comando a la máquina y el programa se vuelve a quedar parado en el scanf(). Si el usuario no teclea una nueva referencia, la máquina sigue funcionando con el comando anterior de forma indefinida. Obviamente, la solución anterior no es valida. Tenemos dos tareas diferentes: la ejecución 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 solución es utilizar programación concurrente. En el ejemplo anterior se podría lanzar un hilo dedicado a la gestión de la entrada del usuario mientras que el hilo principal ejecuta el control. El programa en pseudo código podría 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); } }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

49

Nótese como se ha puesto la variable “referencia” como global, para que ambos hilos tengan acceso a la misma. Los hilos comunican información entre ellos a través de memoria global de la aplicación.

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 número de clientes “espectadores”. De dichos espectadores, únicamente los dos primeros podrán efectivamente controlar las raquetas.

Los dos primeros clientes que se conecten podrán 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 básico de la librería GLUT se plasma en la función 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; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

50

Dicha función contiene en su interior un bucle continuo (en caso contrario terminaría la función main() y terminaría el programa). Dicho bucle continuo se podría 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 función que bloquee la secuencia continua de ejecución, la aplicación se vera bloqueada por completo. Por ejemplo, supóngase que se ubica un scanf() en la función CMundo::OnTimer() para cambiar el radio de la pelota: void CMundo::OnTimer(int value) { printf("Introduzca el radio: "); scanf("%f",&esfera.radio); jugador1.Mueve(0.025f); jugador2.Mueve(0.025f); esfera.Mueve(0.025f); …

El resultado final es la aplicación bloqueada.

3.3.1 Lanzando un hilo Podríamos 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 función global, no es una función de la clase CMundo. Para conseguir el Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

51

acceso del hilo al objeto “mundo”, lo que se puede hacer es pasarle un puntero al mismo aprovechando el cuarto parámetro de la función 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 animación de la escena (a través de la función OnTimer), del dibujo y de enviar los datos por los sockets a los clientes. Como el envío no es bloqueante, no es necesario crear un hilo para esta tarea.

La aceptación 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 envíen información 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 recepción de mensajes, en principio también es bloqueante, por lo que el programa queda bloqueado hasta que se recibe dicho mensaje. La solución es implementar un hilo para cada uno de los dos jugadores que este a la espera de dichos mensajes.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

52

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

//hilo de //recepcion de //comandos del //jugador2

while(1) { //recv() }

while(1) { //recv() }

Figura 3-1. Estructura del servidor Nótese además 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 aceptación de nuevos clientes ejecuta una iteración 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 recepción de los comandos de los jugadores funcionan a una frecuencia variable que coincide con las pulsaciones de teclado de los jugadores.

3.5. MÚLTIPLES CONEXIONES SIMULTANEAS Para permitir la conexión simultanea de múltiples 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 cómoda añadir nuevos objetos, quitar elementos y recorrerlo de forma sencilla. También añadimos un método a CMundo denominado GestionaConexiones(), que se encargara de realizar dicha gestión. class CMundo { public: …

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

53

Socket servidor; std::vector<Socket> conexiones; void GestionaConexiones(); … };

A continuación lanzamos un hilo denominado hilo_conexiones(), y de forma similar a como hacíamos 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 función hilo_conexiones()es invocar al método GestionaConexiones(). Dicho método entrara en un bucle infinito en el que se repite un accept(). Cada vez que se conecte un cliente, se le añade 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 ampliación 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 añadimos un nuevo vector a la clase CMundo del servidor. También transformamos las variables de los puntos de los jugadores en un vector: class CMundo { public: … Socket servidor; std::vector<Socket> conexiones; std::vector<std::string> nombres; void GestionaConexiones(); int puntos[2]; };

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

54

Cada vez que se conecte un cliente nuevo nos deberá enviar su nombre, para añadirlo a nuestro vector. Por lo tanto según 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 después del Connect(). Así el método 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); }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

55

3.7. RECEPCIÓN COMANDOS MOVIMIENTO Cuando el programa cliente detecte una pulsación de teclado, enviara dicha pulsación al servidor, para que el servidor la interprete como juzgue necesario. El envío del cliente se realiza fácilmente en la función OnKeyboardDown(): void CMundo::OnKeyboardDown(unsigned char key, int x, int y) { char cad[100]; sprintf(cad,"%c",key); cliente.Send(cad,strlen(cad)+1); }

Nótese como este envío 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); }

Nótese en este caso la comprobación conexiones.size()>=1, para asegurarnos de que efectivamente existe al menos 1 cliente conectado. Además se ha añadido un retardo usleep(10) para evitar que el bucle while(1) ejecute en vacio si no hay clientes conectados, lo que supondría una sobrecarga de la CPU innecesaria. EJERCICIO: Complétese el programa, añadiendo un segundo hilo que gestione los comandos del segundo jugador.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

56

3.8. GESTIÓN 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 más sencilla de detectar las desconexiones es en el envío realizado dentro de la función CMundo::OnTimer() en el lado del servidor. El envío hay que hacerlo a todos los clientes conectados. Podríamos utilizar el retorno de Send() para realizar la eliminación 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 solución más sencilla consiste en ir recorriendo el vector al revés, 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; } } }

Nótese que además, 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. FINALIZACIÓN 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 ejecución. Para ello se deben seguir los siguientes pasos (todos ellos en el servidor): Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

57

Añadir 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 condición de repetición 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 finalización de los hilos? La respuesta es no. Los hilos están 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 iteración 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 sincronización que pueden aparecer en caso de conexiones y desconexiones de clientes.

Aumentar la información que se retransmite, para que los clientes tengan también la información de quien esta conectado y quien esta jugando, así como los puntos actuales de la partida.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

4.

59

COMUNICACIÓN Y SINCRONIZACIÓN INTERPROCESO

4.1. INTRODUCCIÓN Existen otros mecanismos para comunicar datos entre distintos procesos diferentes a los sockets, cuando los procesos se ejecutan en una máquina con una memoria principal común y gestionada por un único sistema operativo (monocomputador). A diferencia de la comunicación por sockets, que se suele denominar programación distribuida, estos mecanismos entran dentro de la denominada comunicación interproceso (Inter Process Comunication IPC). Entre estos mecanismos destacan: •

Las tuberías 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 sincronización en esos datos. Para prevenir estos problemas hay también otros mecanismos como: •

Los mutex y las variables condicionales

Las tuberías (usadas para sincronizar)

Los semáforos

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

60

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 aplicación, tenemos varios hilos accediendo de forma concurrente al vector de conexiones. En concreto el hilo principal, a través 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 gestión 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 recepción 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; } } }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

61

Más 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 envía 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 práctica 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 sincronización, pero hay que tener en cuenta que en una aplicación real sería totalmente obligatorio realizar esta sincronización, sino nuestro programa podría fallar en un momento inesperado. Sin embargo si hay un motivo por el que el servidor puede cerrar inesperadamente. Es la recepción de la señal SIGPIPE cuando se intenta enviar algo por un socket que ha sido cerrado. Si no se gestiona esta señal, el comportamiento por defecto termina el programa. La forma más sencilla de obviar esta señal, es indicar a la función send() en sus banderas, que no envíe esta señal en caso de error, lo que se hace de la siguiente forma: int err=send(sock, cad, length,MSG_NOSIGNAL);

4.3. COMUNICACIÓN INTERPROCESO En este tema se propone el siguiente esquema como ejemplo del uso de distintos mecanismos de comunicación interproceso: Memoria compartida TCP/IP

Logger FIFO

Bot

RED

Servidor

Cliente

Figura 4-1. Ejemplo de comunicación interproceso con tuberías y memoria compartida

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

62

En el computador que corre el servidor, se desarrollara un programa que sirva para mostrar eventos de una forma ordenada por pantalla, aunque también podría decidir guardarlos a disco, a una base de datos, etc. Los eventos serán los puntos marcados, y quien (el nombre del jugador) que ha marcado un tanto, y serán enviados mediante cadenas de texto por una tubería 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 automáticamente. A este programa le llamaremos “bot”. El cliente y la aplicación “bot” intercambiaran datos en una zona de memoria compartida. Ambas aplicaciones nuevas serán aplicaciones de tipo consola. El makefile de las cuatro aplicaciones quedaría como sigue: CC=g++ CPPFLAGS=-g LIBS= -lm -lglut -lpthread OBJS=Esfera.o Plano.o Raqueta.o Vector2D.o Socket.o all: servidor cliente bot logger logger: logger.o $(CC) $(CPPFLAGS) -o logger logger.o $(LIBS) bot: bot.o $(CC) $(CPPFLAGS) -o bot bot.o $(LIBS) servidor: $(OBJS) MundoServidor.o servidor.o $(CC) $(CPPFLAGS) -o servidor MundoServidor.o servidor.o $(OBJS) $(LIBS) cliente: $(OBJS) MundoCliente.o cliente.o $(CC) $(CPPFLAGS) -o cliente MundoCliente.o cliente.o $(OBJS) $(LIBS) depend: makedepend *.cpp -Y clean: rm -f *.o servidor cliente bot logger #DEPENDENCIAS

4.4. TUBERÍAS CON NOMBRE Las tuberías son un mecanismo tanto de comunicación como de sincronización. Las tuberías 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 tubería. Cuando se trata de procesos totalmente separados, la tubería tiene que ser con nombre para que ambos procesos sean capaces de acceder a ella. Las tuberías con nombre se direccionan como un archivo (un archivo especial) en la estructura de directorios. En las tuberías con nombre tiene que existir un proceso que se encargue de crear dicho pseudoarchivo, que además tiene que ser el primer proceso que comience a ejecutar. Dicho proceso podría tener un código como el siguiente, para enviar por el FIFO una frase a otro proceso que se conecte al mismo: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida #include #include #include #include #include #include

63

<stdio.h> <string.h> <signal.h> <fcntl.h> <stdlib.h> <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 tubería en la ruta indicada, y con los permisos correspondientes (0777= permisos de lectura, escritura y ejecución para todo el mundo). int pipe=open("/ruta/MiFifo1",O_WRONLY);

La función open() abre dicha tubería 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. Nótese que esta función bloquea hasta que se conecta alguien en el otro extremo de la tubería. A continuación se hace un envío de datos: int ret=write(pipe,cad,strlen(cad)+1);

Y finalmente se cierra la tubería y se elimina el pseudoarchivo close(pipe); unlink("/ruta/MiFifo1");

El otro proceso únicamente debe de abrir la tubería, usarla y cerrarla, pero no crear el archivo ni borrarlo. Lógicamente, este segundo proceso debe de ser arrancado después del anterior, para que la tubería 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; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

64

Hay que recordar que la tubería es un mecanismo totalmente unidireccional, no permite que el receptor envíe datos por la misma tubería. Si se desea implementar comunicación bidireccional es necesario el uso de 2 tuberías. Ejercicio: Implementar el programa Logger y los cambios necesarios en el Servidor, para que este envíe al Logger el nombre y número 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 recepción de datos. 3. Añadir el identificador del FIFO como atributo de la clase CMundo en el servidor. 4. Abrir la tubería (antes de lanzar los hilos) 5. Enviar los datos cuando se produzcan puntos. 6. Cerrar la tubería adecuadamente

4.5. MEMORIA COMPARTIDA La memoria compartida es un mecanismo exclusivamente de comunicación que permite tener en común una zona de memoria, accesible desde varios procesos. Dichos procesos, una vez inicializada y accedida, verán 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 capítulo se utiliza la memoria compartida POSIX. Así un proceso que quisiera tener en común una zona de memoria de 10 datos de tipo entero, compartida con otros procesos, podría hacer algo de la forma: #include #include #include #include #include #include

<sys/types.h> <stdio.h> <sys/shm.h> <stdlib.h> <fcntl.h> <string.h>

int main(void) { int datos[10]={0}; //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);

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

65

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 parámetros suministrados a esta función 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 continuación se obtiene el descriptor mediante la función shmget(), a la que se le indica el tamaño en número 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 todavía no existe, debe especificar la bandera IPC_CREAT. int shmid=shmget(mi_key,sizeof(datos),0x1ff|IPC_CREAT);

La obtención de un puntero, cuyo tipo se puede adaptar sencillamente con un cast, se obtiene con la función 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 algún tipo de cast, de indirección 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, podría tener el aspecto siguiente: #include #include #include #include #include #include

<sys/types.h> <stdio.h> <sys/shm.h> <stdlib.h> <fcntl.h> <string.h>

int main(void) { int datos[10];

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

66

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 cómodamente la memoria compartida en nuestra aplicación, vamos a crear (solo la declaración 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” difícilmente podrá realizar ninguna decisión sino sabe que raqueta está controlando (o si está controlando alguna). Esta información 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 información, debe de ser el servidor el que envíe a todos los clientes el número 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, también añadirá una variable denominada num_cliente a la clase CMundo, y la extraerá convenientemente de la cadena recibida. Es necesario añadir las variables siguientes a la clase CMundo del cliente, para que acceda adecuadamente a la zona de memoria compartida: #include "DatosMemCompartida.h"

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

67

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 función 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 decisión acerca de la acción a realizar: #include #include #include #include

<stdio.h> <sys/shm.h> <string.h> "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)

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

68

{ dat->accion=0;//accion por defecto, ninguna //completar

} if(dat->jugador==1) { } } shmdt(dat); return 0; }

Ejercicio: Completar el “bot” para que tome una decisión del movimiento a realizar El cliente, consultara la acción decidida por el “bot” y la llevará a cabo. Esta tarea también será llevada a cabo en el OnTimer: void CMundo::OnTimer(int value) { … if(num_cliente==0) { if(datos->accion==-1) OnKeyboardDown('s',0,0); if(datos->accion==1) OnKeyboardDown('w',0,0); datos->accion=0; } if(num_cliente==1) { if(datos->accion==-1) OnKeyboardDown('l',0,0); if(datos->accion==1) OnKeyboardDown('o',0,0); datos->accion=0; } }

Ejercicio: Asegurar que la zona de memoria compartida se libera cuando se cierra la aplicación cliente. Utilizar una bandera para indicar al “bot” que también debe de cerrarse.

4.6. EJERCICIOS PROPUESTOS Se proponen a continuación algunas posibles mejoras a realizar en el juego:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

69

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 automáticamente 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 sincronización o gestión de mensajes.

Añadir un hilo al programa “bot” que sirva de interfaz con el usuario y pida al mismo que teclee algún valor que le permita cambiar el comportamiento del “bot”, más hábil, menos hábil, por ejemplo.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

70

Universidad Politécnica de Madrid -UPM


Rodr铆guez-Losada & San Segundo, 2009. Programaci贸n Avanzada, Concurrente y Distribuida

71

Parte II. Programaci贸n avanzada

Universidad Polit茅cnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

72

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

5.

73

PROGRAMACIÓN DE CÓDIGO EFICIENTE

5.1. INTRODUCCIÓN 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 pequeños algoritmos y soluciones. También se asume conocimiento del lenguaje C++ y de conceptos de programación orientada a objetos. No obstante es bastante posible que el lector todavía no tenga en consideración cuando programa que el código que esta tecleando puede funcionar más o menos rápido 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 máquina), y lo hace de manera tan rápida que los pequeños programas realizados por un aprendiz se ejecutan sin ningún problema. Sin embargo, en el desarrollo de aplicaciones reales, ya sean de gestión, ingeniería o científicas o incluso lúdicas como videojuegos, hay que tener en cuenta que el volumen (cantidad de líneas de código) de dichas aplicaciones es elevadísimo y el microprocesador debe de ejecutar gran cantidad de código. En muchos de estos casos es importante tener en cuenta la eficiencia o cuanto de rápido ejecuta el código que estamos programando. Veamos un ejemplo sencillo, en el que queremos programar una función que calcule la exponencial de un número real, ya que necesitamos dicha función para nuestros cálculos ingenieriles. Una forma común de calcular la exponencial en sistemas informáticos es utilizar su desarrollo de Taylor: ex = 1 +

x x 2 x3 xn + + + ... + 1! 2! 3! n!

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

74

Parece razonable enfocar la solución al problema mediante la siguiente descomposición en funciones, entre las que aparecen de forma lógica la potencia y el factorial. La función exponencial recurre a ellas para calcular el término n-ésimo de la serie de Taylor. La estructura del programa con este esquema seria: #define PRECISION 100 double exponencial(double num); double factorial(int num); double potencia(double base,int expo); 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 implementación de las funciones quedaría: 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 solución es impecable desde el punto de vista estructural (la subdivisión del problema en partes), tiene un importante fallo: una gran ineficiencia a la hora de calcular la exponencial. Considérese cada término de Taylor. Se puede apreciar que para calcular el término n-esimo hay que realizar los siguientes cálculos: x n = x ⋅ x ⋅ x ⋅ (n veces) ⋅ x n ! = n ⋅ (n − 1) ⋅ (n − 2) ⋅ (n terminos) ⋅ 2 ⋅1 Es decir, para calcular la potencia, hacen falta “n” multiplicaciones y para calcular el factorial hacen falta otras “n” multiplicaciones. Por tanto el termino décimo Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

75

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 número de términos 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 solución ha este problema, basándose en la recursividad en el calculo del numerador y el denominador de cada termino. Resulta obvio que: x n = x ⋅ x n −1 n ! = n ⋅ (n − 1)! Lo que implica que el numerador y denominador de cada término se pueden calcular a partir del numerador y denominador del término anterior. Esta solución es obviamente mucho más eficiente, ya que para calcular cada término hacen falta únicamente 2 multiplicaciones, una para el numerador y otra para el denominador, independientemente del ordinal del término en cuestión. 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 comúnmente como O(1). La implementación de esta solución se realiza en una función 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 solución numérica al problema es exactamente la misma que en el caso anterior. No es una simplificación, ni una aproximación, 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. Téngase en cuenta que los cálculos que puede hacer una aplicación real pueden ser muy numerosos, y quizás 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);

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

76

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 ejecución hemos utilizado una función 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 podría 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 lógicamente en función de la máquina, 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 solución es unas 25 veces más rápida que la primera. Al final, el resultado puede ser una aplicación 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 aplicación responderá mucho más rápidamente y por tanto la satisfacción del usuario será mayor y las probabilidades de éxito del software también serán incrementadas. El desarrollo de código eficiente y el análisis de la ejecución de código es una disciplina mucho más allá de lo que puede cubrir este libro. Este tema trata únicamente de ilustrar algunas técnicas y ejemplos que introduzcan al lector en este problema, de tal forma que el programador novel empiece a tener en cuenta criterios

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

77

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 versión de desarrollo, depuración o Debug es la versión que permite depurar el código en tiempo de ejecución (con un debugger típicamente integrado en el entorno). El ejecutable generado no esta optimizado para ejecutarse rápidamente ni para menor tamaño.

La versión final o Release es para generar un ejecutable optimizado para mayor velocidad, pero que no permite la depuración del código 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 configuración por defecto es la Debug. En linux y gcc se utiliza la bandera “-g” para indicar el modo de depuración. La optimización y medida de tiempos de ejecución se realizan típicamente en la versión Release, que es la que ejecuta más rápidamente.

5.3. TIPOS DE OPTIMIZACIONES Existen cuatro tipos básicos de optimizaciones que se pueden tratar en el desarrollo de un software: •

Memoria: intentar minimizar el uso de la memoria utilizada por nuestro programa.

Tamaño del ejecutable: que el tamaño del ejecutable en disco sea lo más pequeño posible.

Eficiencia de ejecución (procesamiento): que la aplicación ejecute lo más rápido posible o utilizando la menor cantidad posible de CPU

Tamaño datos: Ancho de banda, espacio en disco; que los datos que utiliza, almacena o comunica a través de cualquier canal sean lo más reducidos o compactos posibles.

Hay algunas optimizaciones que es capaz de realizar automáticamente un buen compilador, como detectar funciones “inline” o la técnica de desenrollar bucles o “loop unrolling”. Sin embargo, el compilador no puede suplir la labor del programador en diseñar o usar un buen algoritmo, utilizar una estructura de datos eficiente o seleccionar un formato adecuado. Dadas las características habituales de los computadores actuales, en los que la memoria y el almacenamiento en disco duro son muy abundantes, la optimización más Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

78

común e importante es la de ejecución de los programas, y es en la que más se incide en este tema.

5.4. VELOCIDAD DE EJECUCIÓN La velocidad de ejecución 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 matemático, que contabiliza el número de adiciones y multiplicaciones de números de coma flotante de precisión doble (64 bits) que realiza un sistema cada segundo. No obstante, una medida interesante y práctica en el desarrollo de aplicaciones es el tiempo total de ejecución que tarda un determinado algoritmo o código en ejecutar. Estas medidas son las que se realizan en este tema. Este enfoque contabiliza el tiempo total del código, no solo las operaciones aritméticas de coma flotante. Recúerdese que gran parte del código 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 resolución de un sistema de ecuaciones lineales denso (100, 1000 ecuaciones) mediante el método del pivote parcial, midiendo la eficiencia real en Mflops. Este test de Linpack es el que se utiliza también para clasificar los computadores y establecer la lista de los más rápidos (el Top 500 de los supercomputadores), mediante una medida de eficiencia real en un calculo numérico concreto. Nótese que muchos fabricantes proporcionan una medida de eficiencia de pico o “perfecta” en base a su arquitectura, su velocidad de reloj, el tamaño 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 ejecución influye el algoritmo, las optimizaciones realizadas por el compilador y el programador, el tamaño del problema, el sistema operativo, etc. También hay que insistir en que los resultados son una medida parcial del rendimiento del sistema, ya que la eficiencia de un computador en la ejecución de Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

79

aplicaciones reales depende también de otros factores, como la velocidad de acceso a disco, las capacidades de la tarjeta grafica, las comunicaciones con dispositivos, etc.

5.5. ALGUNAS TÉCNICAS

5.5.1 Casos frecuentes Considérese 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 condición cierta. Si la condición 1 es cierta solo es necesario hacer 1 comprobación, mientras que si es falsa, hacen falta al menos 2 cálculos, el de la condición 1 y el de la 2. Si las condiciones 1 y 2 son falsas, entonces son necesarias 3 operaciones. Se puede decir que el número medio de evaluaciones de condición necesarias es: N m =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 condición sea cierta es muy pequeña, por ejemplo del 1%, al igual que la segunda condición, mientras que la de la tercera es del 98%, el número medio de comprobaciones seria: N m =0,98*3+0,01*2+0,01*1=2,97 Si por el contrario, la probabilidad de la primera condición fuera del 98%, mientras que la de las otras dos fuera del 1%, el número medio de comprobaciones seria: N m =0,01*3+0,01*2+0,98*1=1,03 Una implementación de prueba se puede realizar obteniendo números aleatorios en el rango 0-100, y expresando las condiciones respecto a esos números aleatorios: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

80

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 máquina citada anteriormente) seria el siguiente, donde se aprecia la ganancia en tiempo de cómputo: Tiempo= 0.125000 Tiempo= 0.047000

Hay que resaltar, que al igual que antes el programa realiza exactamente la misma función. Esta técnica 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 técnica que consiste en repetir el código interno de un bucle varias veces para evitar precisamente la iteración de dicho bucle. Nótese que en la mayoría de los casos, los bucles solo sirven para evitar la repetición de código al programador. Pero dicho bucle incurre en un coste Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

81

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 automáticamente. 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 ejecución del código. 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 código. En aplicaciones criticas (aviónica, por ejemplo), esto puede llegar a ser una practica común. Imagínese 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 tamaño puede variar. En el caso de optimizaciones automáticas realizadas por el compilador, este decidirá el tamaño 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 ejecución 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 número de veces, conviene prestar atención a elementos repetitivos o invariantes dentro de los bucles. El siguiente fragmento de código 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 podrían Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

82

venir de otra parte de la aplicación). El valor de dichas variables influyen no solo en el cálculo, sino que la relación entre ambas establece que calculo se debe realizar, tal y como se aprecia en el código 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 análisis de este código nos muestra que la comparación 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 más eficiente sacar la comparación de dentro del bucle, y repetir el código 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 más lejos y detectar que no solo la comparación es invariante dentro del bucle, sino que parte de la operación aritmética realizada en la asignación de valores al vector también lo es. Por lo tanto podemos también extraer dicho cálculo 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; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

83

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 más, la ganancia computacional es visible. Se han presentado en este caso invariantes aritméticos y estructurales, pero también es posible extraer la declaración 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 análisis 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 Gestión de memoria El uso de memoria, la reserva y liberación de memoria, ya sea utilizando memoria dinámica o utilizando memoria estática y dejando al compilador realizar la tarea, lleva asociado un coste computacional. Generalmente este coste es muy pequeño, 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 información de una matriz tridimensional a otra, o asignar todas sus componentes a cero, se podría 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;

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

84

No obstante, usando la contigüidad en la reserva de memoria y los mecanismos de asignación por defecto entre objetos, se puede implementar exactamente lo mismo de una forma mucho más compacta y más eficiente en su ejecución, 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 dimensión variable es reaprovechar la memoria ya reservada en caso de que sea posible. Una solución simple consistiría en reservar memoria para cada nuevo conjunto de datos (de dimensión “n” variable), utilizarlos, y a continuación 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 mínima cantidad de memoria necesaria), la memoria suele ser muy abundante. Sin embargo el coste computacional de la reserva y liberación puede ser relevante. En ese caso seria más 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 continuación el tamaño necesario. Si se tiene un tamaño reservado y se necesita menos tamaño, no se libera la memoria, sino que directamente se utiliza (desaprovechando una parte). De esta forma, el tamaño reservado se estabiliza en el máximo 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 tamaño, añadiendo uno a uno nuevos caracteres, podríamos hacer: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

85

std::string cadena; for(i = 0; i < 1000; i++) encoded.append(1, letra);

Sin embargo es mucho más eficiente hacer que la cadena reserve automáticamente espacio para 1000 caracteres. De estar forma la adición (append) de caracteres funciona mucho más rápido porque no tiene que redimensionar la memoria interna dinámicamente: 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 números reales de precisión simple (float) se realice más rápidamente que el de los datos de precisión doble (double). Curiosamente también es posible que ciertas operaciones con números enteros se realicen más rápidamente con enteros de 4 bytes (int) que con enteros de menor tamaño (short o char), en ciertos procesadores de 32 bits, ya que su arquitectura esta diseñada para este tamaño de datos. Por otra parte, la selección de un tipo de datos u otro también puede tener una seria repercusión en la memoria utilizada, en caso de estructuras de información muy grandes. De igual forma, si esos datos se deciden guardar en el disco duro, un tamaño muy grande se traducirá en un archivo que ocupe mucho espacio, además del consiguiente tiempo necesario para escribir en el disco, que en general es una operación relativamente lenta. Tómese como ejemplo las imágenes, como las tomadas por una cámara o un escáner. Si una imagen normal tiene millones de píxeles, por ejemplo 1024x768, y cada píxel necesita típicamente representar la información de color (3 componentes), entonces hacen falta aproximadamente 2.4 millones de datos. Las componentes de color admiten una representación común como enteros en el rango 0-255. Si optamos por utilizar variables de tamaño 1 byte (unsigned char), entonces necesitaremos 2.4Mb de memoria para almacenar dicha imagen en memoria. Si por el contrario utilizáramos variables de tamaño 4 bytes (int), entonces multiplicaríamos 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 creación de un nuevo tipo de datos para representar matrices, puede ser muy interesante que el tipo de datos contemple la posibilidad de codificar explícitamente distintas posibles representaciones especiales de matrices como matrices diagonales, matrices

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

86

simétricas, matrices triangulares o matrices dispersas, aprovechando estas características especiales para conseguir una mayor eficiencia. La mayoría de librerías matemáticas existentes que manejan matrices implementan esta funcionalidad.

5.5.5 Técnicas en C++ El lenguaje de programación C++ tiene algunas características que tienen que ser tenidas en cuenta cuando se programa. Por ejemplo, cuando se pasa un objeto a un método por valor, de tal forma que el método no pueda modificar dicho objeto, se realiza una copia del objeto. Si el objeto tiene un tamaño 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 ingeniería del software y un buen diseño utilizando patrones. No obstante, hay que tener en cuenta que el polimorfismo (a través de la virtualidad de métodos), tiene también un coste computacional asociado, ya que la decisión de a que función se llama tiene que realizarse en tiempo de ejecución. Esto no quiere decir que no haya que utilizar el polimorfismo, simplemente que hay que tenerlo en consideración como posible factor en aplicaciones de uso de CPU muy intensivo, sobre todo si el polimorfismo se encuentra en el núcleo computacional de la aplicación. La encapsulación de datos dentro de clases utiliza típicamente métodos de acceso a dichos datos. Una vez más hay que tener en cuenta que la llamada de métodos tiene un coste computacional asociado. Si se tienen problemas de eficiencia quizás 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 función por el código que está dentro, en vez de enlazar con ella, tantas veces como sea necesario. Así el tamaño del ejecutable es algo mayor, pero se evita la sobrecarga de invocación de funciones. Aunque no se especifique un método como inline, el compilador tiene capacidad para detectar, decidir y compilar como inline dicho método, si con ello calcula que conseguirá más eficiencia. El uso de funciones inline suele recomendarse con funciones de hasta un máximo de 3 líneas. El uso de constructores y destructores es también una interesante capacidad de C++, pero tampoco hay que olvidar que los mismos tienen un coste computacional asociado. Si el número de construcciones es elevado conveniente tener en cuenta que la inicialización en la construcción es más eficiente que la asignación. Así si tenemos la siguiente clase: ClaseA{ ClaseA(ClaseB b); protected: ClaseB B; };

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

87

Una implementación del constructor podría hacer: ClaseA::ClaseA(ClaseB b) { B=b; }

Sin embargo es más eficiente: ClaseA::ClaseA(ClaseB b):B(b) { }

5.6. CASOS PRÁCTICOS Se presentan en esta sección algunos ejemplos concretos que permiten profundizar algo más en algunos conceptos, a la vez que proporcionan una idea de escenarios más reales de aplicación.

5.6.1 Algorítmica vs. Matemáticas Ahora se quiere realizar un programa que necesita calcular la suma de los “n” primeros números naturales: n

∑i i=1

La forma que viene inmediatamente a la cabeza del programador es la utilización 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 solución cerrada o analítica a este sumatorio: n

∑i = i=1

n(n + 1) 2

La implementación de esta solución es inmediata: int Suma2(int n) { int sum = (n * (n+1)) / 2; return sum; }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

88

Para contabilizar algún tiempo distinto de cero (para la segunda solución), es necesaria la repetición del cálculo 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 máquina anteriormente descrita es la siguiente: Tiempo= 8.437000 Tiempo= 0.032000

En este caso queda de relevancia una abismal diferencia entre una solución u otra. Además también es importante destacar que esta optimización 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 sólidas bases de matemáticas, física, etc.

5.6.2 Generación de números primos En el apartado anterior se ha convertido una solución algorítmica en una solución cerrada o analítica (matemática). Esto no siempre es posible, y muchas veces una solución algorítmica es totalmente necesaria. No obstante, la importancia de elegir un algoritmo u otro es bien conocida. En problemas típicos, como la ordenación de un vector según algún criterio, se conoce bien que algoritmos funcionan más rápidamente que otros y se puede elegir entre un conjunto el más conveniente para nuestra aplicación. De hecho muchas librerías implementan las distintas opciones para elección del usuario. Sin embargo, en numerosas ocasiones el programador tendrá que desarrollar su propio algoritmo. Imagínese que se necesita programar una aplicación que clasifique los primeros N números enteros en primos y no primos. Por motivos de implementación 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 clasificación realizada. Dicho vector es pasado como parámetro a una función que es la encargada de rellenar dicho vector con los valores adecuados. En una primera aproximación, resulta lógico recorrer los N primeros números 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 números enteros menores que el. A priori se supone que el numero es primo (es_primo[i]=1;). Si al realizar una división, se comprueba que no lo es, se marca como no primo y se termina la comprobación. Un sencillo análisis matemático revela que no es necesario probar la divisibilidad por Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

89

todos los números inferiores al considerado, sino únicamente por los números hasta la raíz cuadrada del número 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 más eficiente, consistente en una solución inversa, en la que en vez de ir analizando cada numero si es o no primo mediante divisiones, vamos a ir eliminando números que sabemos que no son primos. La solución anterior se puede considerar una solución “hacia atrás”, mientras que la propuesta ahora es una solución “hacia delante”. Es decir, si cogemos el número 2, podemos realizar una especie de tabla de multiplicar y concluir rápidamente que los números 4, 6, 8, etc. no son primos. A continuación podemos repetir el razonamiento con el numero 3, concluyendo que los números 6, 9, 12, etc. tampoco son primos. Podríamos proceder así con todos los números, pero más 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 múltiplos (8, 12, 16, etc.) también habrán sido ya marcados como no primos, y por lo tanto seria redundante e innecesario. La implementación de este método quedaría 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 utilización de estos dos métodos, incluyendo vectores de dimensión dinámica, y la comprobación de que ambos resultados son idénticos seria:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida #include #include #include #include #include

90

<stdio.h> <stdlib.h> <math.h> <sys/timeb.h> <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]; //METODO 1 tiempo(); Metodo1(es_primo,n); tiempo(); //METODO 2 Metodo2(es_primo2,n); tiempo(); if(0==memcmp(es_primo,es_primo2,n*sizeof(int))) printf("Iguales\n"); else printf("Error, diferentes\n"); delete [] es_primo; delete [] es_primo2; return 0; }

El resultado de ejecutar este código en la máquina 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 cómputo, ya que el segundo método es unas 20 veces más rápido, gracias al nuevo método.

5.6.3 Pre-computación de datos El sensor LMS200 de SICK es un sensor láser 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, robótica, etc.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

91

Figura 5-1. Sensor láser 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 obstáculos u objetos. Una solución al problema, implementada mediante una función podría 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 más costosas (o lentas) en el código anterior son las funciones trigonométricas de seno y coseno. A primera vista parece que no se puede evitar dicho cálculo, lo que es cierto. Pero también es cierto que entre diferentes llamadas a la función, 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 función. Para ello podemos optar por pre-calcular unos vectores declaramos como variables globales por simplicidad. Téngase en cuenta que una solución real utilizaría algún otro mecanismo mejor desde el punto de vista de la ingeniería del software como variables estáticas, variables miembro de un clase, etc. El calculo de los valores lo realizamos en una función que solo necesitará ser llamada una única vez. La función de cálculo de coordenadas cartesianas utilizará ahora los valores precomputados en lugar de recurrir a las funciones matemáticas 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); } }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

92

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 aproximación numérica ni simplificación del problema. El resultado numérico será idéntico para ambas soluciones. Ejecutamos ambos métodos miles de veces. Téngase en cuenta que esto no difiere mucho de la realidad, ya que en la práctica el sensor esta proporcionando datos de forma continua al computador. #include #include #include #include

<stdio.h> <stdlib.h> <math.h> <sys/timeb.h>

int main(int argc, char* argv[]) { double x[181],y[181],rango[181]; for(int i=0;i<=180;i++) rango[i]=rand()/(float)RAND_MAX;//simular medidas //Metodo 1 tiempo(); for(int j=0;j<10000;j++) Cartesianas1(rango,x,y); //Metodo 2 tiempo(); PrecomputaDatos(); tiempo(); for(j=0;j<10000;j++) Cartesianas2(rango,x,y); tiempo(); return 0; }

En la ejecución se aprecia que el cálculo de las funciones trigonometricas efectivamente tiene un elevado coste asociado. La precomputacion de los valores es prácticamente despreciable, pero supone un gran ahorro de tiempo (unas 20 veces más rápido) 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 solución interpolada, en la que se precomputan unas tablas con valores distribuidos siguiendo unos determinados intervalos. Dichas tablas se utilizan mediante interpolación para cualquier valor intermedio. La solución así programada es una aproximación numérica

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

93

la solución real, con un cierto error. No obstante, la ganancia en velocidad puede suponer una ventaja frente a la precisión numérica. Algunas aplicaciones de gráficos interactivos, videojuegos y tarjetas graficas utilizan técnicas basadas en este concepto.

5.7. OBTENIENDO PERFILES (PROFILING) DEL CÓDIGO Aunque se puede analizar un programa midiendo tiempos de la forma que lo hemos hecho, es poco practico. Para analizar la ejecución de código, existen herramientas (denominadas Profilers) que permiten ejecutar el código, contabilizando las llamadas a las funciones, el tiempo que emplea cada línea del programa, etc. mostrando informes como resultado de dicho análisis. 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 código se puede conseguir un 80% de las optimizaciones posibles. Por tanto no merece la pena diseñar absolutamente todo el código 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 ejecución de nuestro programa. Para activarlo es necesario ir a Project Settings->Link->Enable profiling. A continuación 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%

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

94

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 más avanzados, que permiten un análisis más en profundidad y emiten informes más completos, incluyendo gráficos. Como ejemplo, podemos analizar el siguiente código 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 código, se realiza en la línea de comandos: >> profile on; >> pruebaProfile >> profile off >> profile report El resultado final es el siguiente. Aunque Matlab también tiene un visor especifico para el Profiler, que también dispone de más funcionalidad.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

95

Figura 5-2. Profiling de una función programada en Matlab La figura anterior muestra el tiempo utilizado en cada línea, tanto en tiempo total como en porcentaje. Se aprecia que la solución del sistema Ax=b es lo que más tiempo utiliza. Esta solución se puede hacer de dos formas posibles, mediante la inversa (x1=Inv(A)*b), o mediante eliminación de Gauss (x2=A\b). El profiler demuestra como la segunda opción es más eficiente, lo que refuerza la importancia de la utilización de un algoritmo adecuado para la solución de un problema.

5.8. CONCLUSIONES Aunque en este tema se han presentado varias técnicas de optimización para un código más eficiente, esto no quiere decir que el programador deba perder tiempo en implementar todo su código teniendo en cuenta dicha eficiencia. En este apartado nos gustaría pues resumir algunas ideas importantes: • No ofuscarse en la eficiencia del código. Según Donald Knuth “premature optimization is the root of all evil”. Centrarse en el diseño, la corrección y la ingeniería 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 más rápidas que otras. “Benchmark everything”. Medir tiempos. Utilizar siempre un profiler.

• Reducir código no implica siempre eficiencia. Recuérdese el “loop unrolling” • Si se pueden tener en cuenta algunas optimizaciones típicas y sencillas sobre la marcha, como es el paso de parámetros por referencia constante, que es una practica habitual en buenos programadores C++. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

96

• Muchas veces hay que realizar un balance. El uso de optimizaciones para un código más eficiente a veces es contrapuesto a la buena ingeniería, a la legibilidad y compresión del código, a la encapsulación, a la modularidad. Otras veces, la velocidad puede requerir mucha memoria, y hay que tomar una decisión de compromiso entre eficiencia de ejecución 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 más eficientes para nuestro problema.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

6.

97

SERIALIZACIÓN DE DATOS

6.1. INTRODUCCIÓN La serialización de datos (marshalling en ingles), es el proceso de codificar un conjunto de información o datos (objetos en programación Orientada a Objetos), en una estructura de información lineal o serie de bytes. Este proceso es necesario para almacenar datos en un dispositivo de almacenamiento, enviar datos por mecanismos de comunicación serie (puertos serie, USB, por red TCP/IP). La serie de bytes puede ser utilizada posteriormente para recuperar la información, y volver a generar la estructura de información original. La serialización 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 técnica necesaria en sistemas distribuidos, pero no se restringe a ellos. Aunque muchos lenguajes de programación incluyen soporte nativo para serialización de datos, este soporte puede no ser suficiente en casos de estructuras de información dinámicas creadas por el usuario, o en el caso en que el usuario deba decidir que información es la relevante para ser transmitida o almacenada y cual no. Siguiendo el planteamiento practico de este libro, se propone un ejemplo como guía de este capítulo. Igualmente, este capítulo no pretende ser un análisis riguroso ni una solución completa al problema de la serialización de datos, sino simplemente dar al lector una perspectiva del problema y algunas ideas para abordarlo. No obstante, las metodologías presentadas en el capítulo pueden ser mas que suficientes para abordar programas relativamente simples como la aplicación distribuida propuesta en la segunda parte.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

98

En el ejemplo que se propone se tiene una escena (que podría pertenecer a un juego de ordenador, una simulación, un salva pantallas) consistente en un bosque. Para el dibujo se ha utilizado OpenGL y para la gestión de las ventanas se utiliza la librería GLUT. Dicho bosque esta formado por una serie de árboles en distintas posiciones, con distintas alturas, colores y tamaños de copa. El código correspondiente a la escena de la figura se puede encontrar en el código adjunto a este libro.

Figura 6-1. Representación grafica de la escena cuyos datos se van a serializar La declaración de la clase Bosque es la siguiente. Como se aprecia, todos los atributos de las clases son públicos. En un buen diseño, esto no debería ser así, pero para nuestro caso se prefiere por simplicidad didáctica. #include "Arbol.h" #define MAX_ARBOLES 100 class Bosque { public: Bosque(); void Aleatorio(int num_arboles); void Dibuja(); void PideDatos(); void Imprime(); int numero; Arbol arbol[MAX_ARBOLES]; };

La clase Arbol, aparte de la posición, esta fundamentalmente compuesta por un cilindro (tronco) y una esfera (copa):

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

99

#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 diseño que representa estas clases es el siguiente:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

100

Figura 6-2. Diagrama de Clases de Diseño No obstante, es destacable la estructuración de los objetos. El siguiente diagrama muestra la disposición en árbol de la información. El bosque esta compuesto por una serie de árboles, y cada uno de ellos tiene su propia copa y su propio tronco. También es de relevancia la distribución 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 método Bosque::Dibuja(). Este método a su vez delega, llamando al método 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 aplicación del patrón Experto en Información, ya que cada objeto es responsable de pintarse a si mismo, dado que el tiene la información necesaria para pintarse. Así, se va procediendo, avanzando primero en profundidad y luego en anchura en el árbol de información representado en la figura.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

101

Figura 6-3. Estructura de objetos de la aplicación Así, los métodos correspondientes realizan invocaciones a los métodos de los objetos que los componen, tal y como se muestra (como ejemplo) para la función de dibujo del bosque. void Bosque::Dibuja() { int i; for(i=0;i<numero;i++) { arbol[i].Dibuja(); } }

Asimismo, también 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. Supóngase en este punto que es necesario almacenar toda la información 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 continuación distintas alternativas.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

102

6.2. REPRESENTACIÓN OBJETOS EN MEMORIA Un objeto almacena sus variables de forma contigua en memoria. Supóngase que se declara un objeto de la clase Arbol, y se le solicitan los datos al usuario. Arbol arbol; a.PideDatos();

Al realizar dicha declaración 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 declaración (.h) de la clase.

azul verde rojo radio altura radio y x

copa arbol tronco

Figura 6-4. Almacenamiento en memoria de un objeto tipo “Árbol” Esta propiedad puede ser utilizada para una fácil serialización de los datos. Supóngase que se quiere almacenar los datos de dicho árbol en un fichero, para su posterior recuperación. 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 información inteligible por el humano. Pero si posteriormente se desea recuperar la información de dicho archivo, sobre un objeto de la clase Arbol (que no necesita inicializar ni pedir sus datos, ya que serán 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 podría 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");

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

103

fread(&bosque,sizeof(bosque),1,f);

De igual forma, si hubiéramos deseado enviar los datos del bosque por la red, podríamos haber escrito en un vector de bytes la información, 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 podríamos recuperar la información del vector mediante: memcpy(&bosque,buffer,sizeof(Bosque));

Aunque a primera vista podría parecer que ya hemos resuelto el problema de la serialización, esto no es cierto. En este caso funciona ya que toda la memoria es estática, incluido el vector de tamaño variable del bosque: int numero; Arbol arbol[MAX_ARBOLES];

No obstante, ya hay un problema de eficiencia importante. Siempre se están serializando el número máximo posible de árboles, aunque nuestro bosque tenga muchos menos. Esto implica un mayor tamaño de archivo o un mayor tamaño del buffer para enviar por la red, con el consiguiente despilfarro de recursos del sistema. La implementación ha sido realizada así por simplicidad y evitar la memoria dinámica. Pero en realidad, la capacidad del vector de árboles debería ser gestionada dinámicamente. ¿Que pasaría si esto fuera así? Que la copia de memoria no seria valida, ya que se estaría copiando únicamente un puntero, que posteriormente no será valido. Otro motivo por el que este esquema de serialización 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 común, 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 método anterior se serializan el 100% de las variables miembro de la clase, sean relevantes o necesarias o no. En este ultimo caso, también se esta incurriendo en un gasto innecesario de los recursos del sistema. Para solucionar estos problemas, el programador puede desarrollar su propia estrategia de serialización, que le permita gestionar que variables se serializan y cuales no, así como gestionar adecuadamente la memoria dinámica de los objetos. Se presentan a continuación algunos enfoques típicos.

6.3. SERIALIZACIÓN EN C Aunque la estructura básica de la aplicación es Orientada a Objetos, la serialización también tiene que ser realizada en aplicaciones en C. Se presentan en esta sección algunas técnicas para realizar esta tarea recurriendo únicamente a funciones de C. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

104

Generalmente se puede clasificar la serialización en: •

Con formato (texto). La información se almacena de tal forma que un humano puede leerla, interpretarla e incluso modificarla fácilmente. 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 información se almacena como un vector de bytes en el que se almacenan byte a byte (en codificación binaria) todos los datos, sin necesidad de separarlos por caracteres especiales. El resultado es ininteligible por un humano.

Cuando se utiliza el formato, la representación 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 harían falta 6 bytes. Lo mismo sucede con números de coma flotante como el 0.1 o el 3.1415. Sin embargo en formato binario, los datos ocupan siempre exactamente el mismo tamaño. Por ejemplo un entero puede ocupar siempre 4 bytes, al igual que un float. La conclusión es que en general, el formato binario es mas eficiente (necesita menos espacio), ya que además no necesita separadores, y tiene la ventaja añadida de no tener ninguna perdida de precisión numérica, por redondeos o formatos. Por el contrario presenta la desventaja de no poder ser analizada fácilmente por un humano.

6.3.1 Con formato (texto) La serialización con formato en C es bastante tediosa. Por una parte hay que desarrollar código según se desee serializar a un archivo o a una cadena de texto para su envío por red. También 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 capítulo. Se deja al lector como ejercicio, que poda desarrollar fácilmente una vez leídas y comprendidas las secciones siguientes.

6.3.2 Sin formato (binaria) La serialización 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 (carácter 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 #define #define #define #define

writeChar(x,y,z){x[y++] = z;} writeShort(x,y,z){*((unsigned short*)((char*)&x[y]))=z; y+=2;} writeLong(x,y,z){*((unsigned long *)((char*)&x[y]))=z; y+=4;} writeFloat(x,y,z){*((float *)((char *)&x[y])) = z; y += 4;} writeDouble(x,y,z){*((double *)((char *)&x[y])) = z; y += 8;}

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida #define #define #define #define #define

readChar(x, y, z) {z = x[y++];} readShort(x,y,z){z=*(unsigned short*)((char *)&x[y]); readLong(x,y,z){z=(*(unsigned long*)((char *)&x[y])); readFloat(x, y, z) {z = (*(float *)((char *)&x[y])); readDouble(x, y, z){z = (*(double *)((char *)&x[y]));

105

y y y y

+= += += +=

2;} 4;} 4;} 8;}

Los tres argumentos de la macro son el buffer o vector de bytes, la posición 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 automático del índice según el tamaño de la variable, de tal forma que el usuario de las macros puede despreocuparse de esta cuenta. Para implementar la funcionalidad de serialización y deserializacion, seguimos con la estructura establecida para el dibujo y siguiendo el patrón del Experto en Información, y añadimos a cada una de las clases (Bosque, Arbol, Cilindro, Esfera) los siguientes métodos: void Read(char cad[],int& cont); void Write(char cad[],int& cont);

Nótese que el paso del contador “cont” a las funciones se hace por referencia, de tal forma que la función pueda incrementar dicho contador. La implementación de estos métodos 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); }

Nótese como lo primero que hacen las funciones es gestionar el número de árboles que componen el bosque. Aunque también se pueden plantear otras soluciones que no requieren el almacenamiento explicito de este tamaño, su utilización simplifica mucho la solución. También es importante recordar que con estas funciones, ya no importa si el vector de árboles ha sido creado estática o dinámicamente. 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); }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

106

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 implementación de la de-serialización correspondiente siguiendo el diseño realizado y el patrón Experto en Información, se ubica en el mismo lugar, siendo fácil la comprobación de la necesaria simetría. La serialización 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 implementación, podemos realizar la serialización de un bosque de la siguiente forma: bosque.Aleatorio(50); char buffer[3000]; int cont=0; bosque.Write(buffer,cont);

Nótese que en esta implementación se supone que el buffer tiene capacidad suficiente para almacenar dicha información, y no se realiza ninguna comprobación al respecto. Esto, obviamente, no es una solución ni valida ni completa, ya que el buffer podría ser pequeño y producirse un desbordamiento, con el consiguiente error en tiempo de ejecución. En una solución real se debe al menos comprobar que el tamaño del buffer (que puede ser pasado en otro parámetro) es suficiente, aunque también seria adecuada la posibilidad de consultar primero el espacio necesario, o utilizar Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

107

memoria dinámica redimensionando el buffer cuando sea necesario. Aun así, la solución expuesta describe adecuadamente la naturaleza del diseño adoptado, que puede ser fácilmente extensible a dicha comprobación. La extracción de la información se realizara pues de la siguiente forma: cont=0; bosque.Read(buffer,cont);

Figura 6-5. Propagación de mensajes Write entre los objetos

6.4. SERIALIZACIÓN EN C++ La serialización utilizando un lenguaje de mas alto nivel como es C++ es mas sencilla, no solo por el lenguaje en si mismo, sino por las librerías de soporte del mismo. De especial importancia en este caso es la existencia de streams (flujos, aunque los seguiremos llamando streams) en la librería estándar de C++ Standard Template Library (STL). Recuérdese la peculiaridad de que para incluir las cabeceras de esta librería no se incluye el “.h” #include <iostream> #include <iostream.h>

//Include de la STL, OK //Include de librería IO de C++, NO

Entre las clases pertenecientes a la “IOStream Library”, destacamos las siguientes, que van a ser las utilizadas en nuestro código: •

istream Stream de entrada Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

108

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.

También hay que recordar que a esta librería 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 librería hacen que programar la serialización con y sin formato sea bastante más sencillo.

6.4.1 Con formato (texto) La serialización con formato se realiza fácilmente con los operadores de inserción (<<) y extracción (>>) que ya se encuentra implementado para los tipos básicos (int, float, etc.), y que se puede sobrecargar fácilmente para tipos de datos (clases) programadas por el usuario. Como dichos operadores no son métodos 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 públicos, mantenemos la “amistad” para conseguir una implementación típica. Nótese que tanto la inserción como la extracción admiten un primer parámetro de las clases base istream y ostream, aunque luego se pueden utilizar las clases derivadas según se desee utilizar un fichero o una cadena. #include "Arbol.h" #define MAX_ARBOLES 100 #include <iostream> using namespace std; class Bosque { friend istream& operator>>(istream& s, Bosque& b); friend ostream& operator<<(ostream& s, const Bosque& b); …

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 parámetro es una referencia en el caso de la extracción, ya que el operador deberá modificar el objeto correspondiente. En el caso de la inserción, 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;

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

109

class Arbol { friend istream& operator>>(istream& s, Arbol& a); friend ostream& operator<<(ostream& s, const Arbol& a); …

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 declaración de los operadores para las clases Esfera y Cilindro es totalmente análoga. La implementación de los operadores sigue la filosofía 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 extracción no supone ningún problema, porque en la misma ya se procesan los separadores (recuérdese que es una serialización con formato) como los espacios o retornos de carro. Sin embargo, en la escritura o serialización es el programador el encargado de establecer dichos separadores. Con el objeto endl se consigue un final de línea. 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 línea, 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 serialización de la Esfera y el Cilindro quedarían como sigue: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

110

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 implementación podemos serializar los datos cómodamente 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 línea de código seria similar a lo siguiente, que por otra parte debería coincidir con el contenido del fichero de texto “Bosque.txt”. Se aprecian claramente los valores de los atributos respectivos, valores que se podrían modificar fácilmente. 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 deserialización 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

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

111

6.4.2 Sin formato (binaria) En el apartado anterior, si deseamos hacer una serialización binaria por eficiencia, o simplemente porque no queremos que los datos sean fácilmente visibles, podríamos intentar abrir el fichero en modo binario: ofstream file("Bosque.txt",ios::binary);

Pero esto no es suficiente, ya que los operadores inserción y extracción trabajan sobre los tipos básicos 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 serialización sea completamente binaria, hay que recurrir a las funciones especificas de la IOStream library que hacen estas tareas. Estas funciones se llaman típicamente “read” y “write”. Añadimos a todas nuestras clases unos métodos que se llamen de forma similar, y que admitan una referencia a stream. Gracias a esta referencia, podremos utilizar el polimorfismo, y nuestros métodos funcionarán igual para las clases derivadas correspondientes (fstreams y stringstreams). Los siguientes métodos serán entonces añadidos a las clases Bosque, Arbol, Esfera y Cilindro: void Read(std::istream& str); void Write(std::ostream& str);

La filosofía coincide completamente con la desarrollada anteriormente en lenguaje C, a excepción 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); }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

112

El resto del código se puede encontrar en las carpetas adjuntas. Si ahora se desea guardar los datos del bosque en un archivo binario, bastaría 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 podría hacer: ifstream file("Bosque.txt",ios::binary); bosque.Read(file);

6.5. CONCLUSIONES Se ha presentado en este capítulo la problemática de la serializacion de datos y sus aplicaciones en persistencia (ficheros de datos) o comunicaciones. Asimismo se han introducido algunos ejemplos de técnicas y estrategias que permiten realizar esta tarea de forma ordenada, con el correspondiente código en los lenguajes C y C++. El ejemplo explicado es una aplicación grafica, pero el uso de la serializacion es mucho mas extenso, tanto que los diseñadores de sistemas de desarrollo, librerías 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 técnicas que permiten al usuario realizar la tarea, se aconseja estudiar en detalle el sistema de desarrollo utilizado y librerías de terceros en el caso de proyectos software reales.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

7.

113

BÚSQUEDAS EN UN ESPACIO DE

ESTADOS MEDIANTE RECURSIVIDAD 7.1. INTRODUCCIÓN La búsqueda y la representación del conocimiento son dos de los problemas fundamentales de la Inteligencia Artificial (IA). La búsqueda 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 formalización de un problema de modo que se pueda resolver mediante algún tipo de búsqueda se denomina representación del conocimiento. Un espacio de estados para un problema de búsqueda 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 configuración (o estado) inicial y O la configuración (estado) objetivo a alcanzar. En el caso general, los conjuntos I y O pueden contener más de un estado.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

a

b

1

2

7

8

5

4

6

1

d

2

8 6

7 4

5

3

e

1

2

7

8

5

4

6

3

c

3

1

2

7

8

5

4

6

3

f

114 1 2 7 8 5 4 6 3

1 2 8 5 7 6 3 4

Inicio

Objetivo

1

2

7

8

5

6

3

4

1

2

5

8 6

7

3

4

g

1

2

8

5

7

6

3

4

Figura 1. Ejemplo de un problema de búsqueda: el puzzle-8. En trazo grueso se ha representado el camino solución. En la Figura 1 se muestra un ejemplo de problema de búsqueda extraído del mundo de los juegos. En el puzzle-8, 8 piezas numeradas del 1 al 8 y un hueco comparten una cuadrícula 3x3. El objetivo del juego es obtener una configuración objetivo a partir de una configuración 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 búsqueda generado para encontrar la solución. Los nodos de dicho árbol son las configuraciones intermedias que se atraviesan durante la búsqueda 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 solución. Un aspecto fundamental de cualquier procedimiento de búsqueda es cómo evadir la explosión combinatoria de estados que pueden aparecer. Por ejemplo, en el puzzle-8 una solución tiene de promedio unos 20 pasos. El factor de ramificación (el número de estados descendientes posibles para un nodo cualquiera del árbol de búsqueda) tiene una media ligeramente menor que 3, con lo que el tamaño del espacio de búsqueda está en torno a 320 109 , un número muy considerable teniendo en cuenta la aparente simplicidad del problema. En torno a 109 estados serían, pues, los recorridos por un procedimiento de búsqueda sistemática exhaustivo que, mediante ensayo y error, generara todos los posibles estados intermedios entre el nodo raíz y el objetivo. Este es el procedimiento de control de la búsqueda más sencillo conocido como fuerza bruta. Existen una cantidad importante de algoritmos de control de propósito general para realizar búsquedas exhaustivas, conocidos como técnicas de búsquedas desinformada (o también, búsqueda a ciegas) que conforman un marco genérico para cualquier problema planteado como una búsqueda en un grafo. Esta sección se centra en la Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

115

implementación de uno de ellos: la búsqueda primero en profundidad (Depth-FirstSearch o simplemente DFS) De manera informal, la búsqueda DFS consiste en elegir en todo momento para continuar la búsqueda al candidato que se encuentre a mayor profundidad. En el otro extremo se encuentra la búsqueda 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 más a la izquierda, la selección de nodos sería {a, b, d, e, c, f , g} para la búsqueda DFS y {a, b, c, d, e, f, g} para la búsqueda primero en anchura.

7.2. BÚSQUEDA 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 búsquedas en grafos los algoritmos de control deben tener especial cuidado con la aparición de estados repetidos y ciclos. Sin detección de ciclos es posible que la búsqueda quede atrapada en un bucle infinito (ver figura 2).

b a c d

e

Figura 2: Un grafo de búsqueda que presenta un ciclo. Sin un control de repetición de estados la búsqueda podría quedar atrapada indefinidamente en {a, d, e, c}. Existen problemas donde la aparición de los temidos ciclos simplemente no es posible. En estos casos el algoritmo de control se simplifica considerablemente y es más rápido. A continuación se describe el algoritmo primero-en-profundidad escrito en pseudocódigo:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

116

Procedimiento PRIMERO EN PROFUNDIDAD (INICIO, OBJETIVO) Inicialización: ABIERTOS:={INICIO}, CERRADOS :={} REPETIR hasta alcanzar OBJETIVO o ABIERTOS esté vacío 1. Quitar de ABIERTOS el elemento más a la izquierda y llamarlo X 2. Generar los hijos de X 3. Añadir X a CERRADOS 4. Eliminar aquellos hijos de X que estén en ABIERTOS o en CERRADOS 5. Añadir los hijos de X a ABIERTOS por la izquierda Como puede apreciarse, el algoritmo es completamente independiente del dominio. La búsqueda 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 implícita dicha comprobación, 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 Terminología El término ‘hijo’ en el pseudocódigo hace referencia a un sucesor directo, empleando la analogía entre un árbol de búsqueda y un árbol genealógico. Así, es frecuente utilizar relaciones de parentesco para indicar la profundidad de la relación (abuelo, bisabuelo, nieto etc.) Un nodo raíz del que cuelga un subgrafo será antecesor de todos los nodos de dicho subgrafo. Análogamente, dichos nodos serán descendientes de aquél. La relación 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 búsqueda que no tienen sucesores. La búsqueda no puede continuar por un nodo hoja teniendo que retroceder en el árbol a algún nodo antecesor, lo que se conoce como vuelta-atrás.

7.2.2 Estructuras de datos A pesar de la aparente sencillez del pseudocódigo 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 leído. 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 Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida •

117

Para cada nodo del árbol hay que almacenar la información del camino recorrido. Esto permite recuperar la trayectoria desde el nodo raíz una vez alcanzado un estado OBJETIVO. En el algoritmo propuesto basta con almacenar para cada nodo examinado quién es su padre.

La lista ABIERTOS almacena el conjunto de estados generados en cualquier momento de la búsqueda, pero todavía 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 búsqueda con lo que el procedimiento todavía podría tardar un tiempo exponencial en darse cuenta que ya ha encontrado lo que buscaba. El conjunto de nodos por los que la búsqueda 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 aparición de estados repetidos y ciclos durante la búsqueda). 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 análisis previo del tipo de árbol de búsqueda 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-enprofundidad 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. Nótese que en la figura se ha dibujado el grafo de búsqueda sin estados repetidos (es decir, un verdadero árbol). Operadores de transición entre estados bidireccionales (muy frecuentes en problemas de enrutamiento) generan también ciclos durante la búsqueda. Una vez que se detecte esta posibilidad es necesario almacenar todos los estados de la búsqueda recorridos de manera dinámica, si se quiere garantizar que el procedimiento sea completo (es decir, que encuentre una solución si la hubiere).

7.2.3 Análisis El pseudocódigo no presenta grandes dificultades. En cada iteración de elije un nodo frontera en ABIERTOS (línea 1), se calculan sus sucesores directos (línea 2) y se añade dicho nodo a CERRADOS (línea 3), puesto que ya ha sido analizado. La línea 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 aún no se han examinado) o en CERRADOS, en cuyo caso se expandió con anterioridad en el grafo. Todos los sucesores recién generados se eliminan si están bien en ABIERTOS, bien en CERRADOS y solo los que quedan se añaden a la cola (línea 5). Para conseguir Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

118

que la cola sea LIFO, condición imprescindible para que la búsqueda sea primero en profundidad, se incluye la dirección de carga y descarga, en este caso por el mismo lado. La figura 3 ejemplifica la generación de un árbol de búsqueda primero en profundidad para un espacio de estados dado.

a

a

a b

c

c

b

d

d

a

c b

e d

f f

c

b

d

e

a b

a e

f b

c d

e

a c e

f

Figura 3. Ejemplo de búsqueda primero-en-profundidad para el espacio de estados recuadrado en la figura. A medida que la búsqueda avanza el árbol evoluciona de izquierda a derecha y de arriba abajo. En computación se dice que un algoritmo es correcto si para cualquier solución candidata que genera, dicha solución satisface las especificaciones del problema. Más fuerte es el requisito de completitud. Un algoritmo se dice que es completo cuando si existe una solución la encuentra. Es interesante destacar que, de forma un tanto sorprendente, la búsqueda 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 búsqueda DFS en sentido inverso (es decir, analizando padres, abuelos, bisabuelos etc. con la esperanza de encontrar a Lincoln) una búsqueda DFS podría retrotraerse hasta la prehistoria aún en el caso altamente improbable de que sí fuera descendiente directo. Dicho en otros términos, 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 raíz al nodo actual junto con todos los nodos sucesores generados por ese camino. Así pues, el problema de DFS reside en el tiempo de cómputo pero no en la cantidad de memoria que necesita para su ejecución. 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 búsqueda. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

a b d

c e

119

Nº it.

ABIERTOS

CERRADOS

0

{a}

{}

1

{b, c}

{a}

2

{d, e, c}

{b, a}

3

{e, c}

{d, b, a}

4

{c}

{e, d, b, a}

5

{}

{c ,e, d, b, a}

Figura 4. Traza de una búsqueda primero en profundidad sobre el espacio de estados que aparece a la izquierda.

7.3. BÚSQUEDA PRIMERO EN ANCHURA Aunque esta sección está dedicada a una implementación práctica de la búsqueda DFS, resulta interesante compararla con otra técnica de búsqueda sistemática desinformada denominada primero-en-anchura. En este caso, la estrategia de selección 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 técnica. La implementación primero-en-anchura (BFS, del inglés Breadth-First-Search) es esencialmente idéntica a la búsqueda 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 línea 5 del pseudocódigo DFS para que los sucesores de X se almacenen en ABIERTOS ‘por la derecha’ entonces se transforma en una búsqueda primero-en-anchura. La figura 5 muestra la nueva traza para el mismo espacio de estados empleado en la figura 4.

a b d

c e

Nº it.

ABIERTOS

CERRADOS

0

{a}

{}

1

{b, c}

{a}

2

{c, d, e}

{b, a}

3

{d, e}

{c, b, a}

4

{e}

{d, c, b, a}

5

{}

{e, d, c, b, a}

Figura 5. Traza de una búsqueda primero en anchura sobre el espacio de estados que aparece a la izquierda. Los nodos recién generados se incorporan a ABIERTOS por la derecha. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

120

Al recorrer un espacio de estados primero-en-profundidad, la frontera de la búsqueda pasa a ser el conjunto de nodos en un mismo nivel del árbol y sus posibles sucesores (a diferencia de la búsqueda DFS que almacenada solamente el camino desde la raíz). Este hecho hace que la memoria requerida por BFS sea exponencialmente mayor que la búsqueda DFS. Un ejemplo: el número de nodos hoja de un árbol uniforme con factor de ramificación 5 y profundidad 6 es 56=15625, el orden de magnitud de los nodos que el procedimiento BFS debería mantener en memoria. Un algoritmo DFS equivalente necesitaría 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 solución al problema si ésta existe. La demostración es trivial. Si existe una solución 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 solución en un tiempo finito. Como se explicó en la sección 7.2, el algoritmo primero en profundidad puede perderse en una rama de profundidad infinita y nunca llegar a encontrar la solución (en una profundidad d pero en un camino distinto). En teoría de computación es muy frecuente la dicotomía espacio-tiempo. Un procedimiento que consume mucha memoria es, en la mayoría de los casos, más eficiente que un procedimiento equivalente que consume menos. La dicotomía es perfectamente aplicable a las técnicas BFS y DFS.

7.4. METODOLOGÍA GENERAL DE RESOLUCIÓN DE UN PROBLEMA DE BÚSQUEDA MEDIANTE COMPUTACIÓN Antes de abordar el problema de la implementación es importante destacar que existen un conjunto de consideraciones previas y tareas a realizar para la implementación de un procedimiento eficiente que resuelva un problema de búsqueda genérico. Dicho problema se presupone bien formado. Entre las tareas a realizar destacan: •

Definición del problema de una manera formal: Por ejemplo, para un problema de enrutamiento definir con precisión las reglas de movimiento entre ciudades, para el puzzle-8 el movimiento de las piezas en horizontal y vertical etc.

Análisis: En esta fase se estudia minuciosamente el problema ya formalizado para determinar aquellas características que puedan tener influencia en las técnicas de búsqueda que se van a emplear en su resolución. Por ejemplo, si existe una explosión combinatoria en el número de estados, y no se tiene conocimiento específico del dominio para guiar la búsqueda, entonces BFS no es recomendable por que consume excesiva memoria.

Aislamiento y representación adecuada del conocimiento necesario: Entre otras tareas, resulta extremadamente relevante para la eficiencia global del procedimiento de búsqueda una representación adecuada de Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

121

la noción de estado y de los operadores que permiten la transición 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 cuál de las dos sería más adecuada con vistas a la implementación de un algoritmo de búsqueda para ese problema. •

Elección adecuada de la mejor técnica de búsqueda: A partir de la información adquirida en las etapas anteriores se decide cuál de las numerosas técnicas de búsqueda de propósito general es más adecuada para el problema particular. Si la elección se restringe a una búsqueda DFS o BFS, un tamaño de espacio de estados grande apunta hacia la técnica DFS, a expensas de perder completitud en el caso peor. Para tamaños ‘razonables’ de espacio de estados se aconseja la búsqueda primero en anchura que es completa.

1 8 6

2 3

7 4 5

1 8 6

2 3

7 4 5

Figura 6. Ejemplo de dos representaciones de los operadores de movimiento en el puzzle-8: las piezas hacia el cuadro vacío (derecha) o el cuadro vacío hacia las piezas (izquierda).

7.5. IMPLEMENTACIÓN DE UNA BÚSQUEDA DFS MEDIANTE RECURRENCIA La recurrencia es una técnica de programación que consiste en especificar la ejecución de un proceso mediante su propia definición. Un algoritmo recursivo es, por tanto, aquél que plantea la solución a un problema en términos 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 matemáticas una función recursiva f(x) es f ( x) = 3 f ( x − 3) . En informática el ejemplo típico para ilustrar recurrencia es un algoritmo para computar el factorial de un número como el que se muestra a continuación: //Procedimiento Factorial(n) int factorial(int n) { if(n<2) return 1; return n*factorial(n-1); }

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

122

Como puede apreciarse en el ejemplo, lo que realmente está ocurriendo es que en cada nivel de recursión el problema se va descomponiendo en problemas iguales pero de menor tamaño (el problema original tiene tamaño n, tras la primera llamada recursiva pasa a ser de tamaño n-1 etc.). El problema más pequeño es el factorial de 1 que se resuelve directamente en la segunda línea de la función. Es importante destacar que la línea de código if(n<2) return 1;

no solamente resuelve el factorial de 1 sino que permite salir de la recursión. Sin una sentencia de control de este tipo, el procedimiento quedaría indefinidamente atrapado en un bucle infinito, debido a la circularidad inherente a la técnica. Es fundamental garantizar que la condición 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 condición de salida se va a cumplir siempre en el nivel de recursión n-2 y, por tanto, el procedimiento tiene que terminar. La sencillez del procedimiento Factorial permite analizar fácilmente el flujo de ejecución. En cada nueva llamada a Factorial el flujo entra por la primera línea de la función (justo después de ‘{‘) y puede salir debido a la instrucción return 1;

o bien por la instrucción return n*factorial(n-1);

En ambos casos, el flujo continúa en la función del nivel de recurrencia anterior justo donde se realizó la llamada; es decir, se devuelve el resultado de la operación y el flujo completa le ejecución de la última línea. Esto se puede ver con más claridad añadiendo una variable intermedia al código 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 ejecución cumple con las especificaciones del problema, prestando especialmente atención a la condición 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 ejecución. Este espacio reservado se conoce como ‘área de memoria’ del proceso.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

123

La pila de llamadas es una pila de datos LIFO en el área de memoria de un proceso. La función principal es almacenar el punto donde devolver el control del flujo de ejecución una vez terminada la función (o subrutina) activa en ese momento. De esta manera se pueden invocar funciones dentro de otras funciones sin perder el hilo de ejecución. Cada nueva llamada introduce la dirección de retorno a la función invocante en la pila y empuja al resto de direcciones. Al terminar dicha función se lee la primera dirección 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 función A llame a una función B o se llame a sí misma es irrelevante; basta almacenar en la pila la dirección de la instrucción siguiente a ejecutar una vez termine. En el ejemplo del Factorial, la dirección correspondería a la línea 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 función activa. Estas variables pierden su valor una vez que termina la función. 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 típico error en tiempo de ejecución de desbordamiento de pila (o ‘stack overflow’), bien conocido por los programadores. Como ejemplo compile y ejecute este código escrito en C++: #include <iostream.h> #define MAX_SIZE 100 #define MAX_DEPTH 100 void ProcRecursivo(int k) { int vector[MAX_SIZE][MAX_SIZE]; if(k>=MAX_DEPTH) return; //Salida for(int i=0; i<MAX_SIZE; i++) for(int j=0; j<MAX_SIZE; j++) vector[i][j]=0; cout<<"Nivel: "<<k<<endl; ProcRecursivo(k+1); } void main() { cout<<"Comienzo de recursion"<<endl; try{ ProcRecursivo(0); } catch(...){ cout<<"Stack Overflow"<<endl; } cout<<"Fin de recursion"<<endl; }

Este código dispone de una función ProcRecursivo que se llama de forma recursiva y que tiene como única misión inicializar una matriz de enteros a cero en cada nivel de recurrencia. Obsérvese que, al estar cada matriz declarada localmente, el compilador, por defecto, reservará espacio en memoria en la pila de llamadas. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

124

ProcRecursivo se llama a sí mismo incrementando previamente en una unidad el valor que controla la salida de la recursión. Cuando la recurrencia alcanza el nivel MAX_DEPTH se produce la primera vuelta atrás. 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 función main llama a ProcRecursivo. Las instrucciones try y catch en C++ (y en muchos otros lenguajes de alto nivel) sirven para gestionar la aparición de excepciones en tiempo de ejecución. try encapsula entre llaves aquellas instrucciones susceptibles de producir algún tipo de excepción y catch encapsula entre llaves las tareas a realizar si se producen (los manipuladores de las excepciones). Finalizado el bloque catch, el flujo de ejecución continúa con la siguiente instrucción después del bloque. La sintaxis catch(...)

indica que el bloque contiene los manipuladores para cualquier tipo de excepción (incluyendo excepciones específicas de C). En el ejemplo, la excepción sólo puede producirse por desbordamiento de pila. En tal caso aparecería el mensaje “Stack Overflow” en pantalla para después continuar con la ejecución de la instrucción que muestra la cadena “Fin de recursión” en pantalla. La ejecución del código 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 x100(matriz ) x100(nivel ) x 2(int) = 2 MB produciéndose el desbordamiento. Si se modifican las opciones del compilador y se reserva 10MB de memoria virtual para la pila (opción /stack:0x10000000) ya no se produce la excepción. Es interesante comentar que el desbordamiento de pila tampoco se va a producir si se compila con la opción de ‘máxima 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 opción de compilación activada, el tamaño de la matriz vector deja de constituir un problema para valores de MAX_SIZE muy superiores a 100.

7.5.2 Búsqueda DFS como recursión El algoritmo para un procedimiento genérico de búsqueda primero en profundidad descrito en la sección 7.2 estaba formulado de manera iterativa sobre una estructura de datos LIFO. En cada iteración se va modificando la lista ABIERTOS hasta que, o bien se elige un estado OBJETIVO de dicha lista o bien la lista queda vacía, en cuyo caso el procedimiento termina sin encontrar una solución. Para simplificar, se asume en este apartado que el grafo de búsqueda 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 recursión donde la tarea que se repite en diferentes niveles está formada por las subtareas siguientes: Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida •

Extracción de un nodo de ABIERTOS (y eliminar)

Generación de sus sucesores

Añadir dichos sucesores a la lista de ABIERTOS

125

A su vez, la salida de la recursión (vuelta atrás de la función 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 atrás para continuar por otro camino

Si se estima que la dimensión del espacio de estados es relativamente pequeña, se puede emplear la propia pila para almacenar los estados de ABIERTOS en el nivel del árbol que se corresponde con el nivel de recursión. 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 búsqueda 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é vacío 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 búsqueda. Tomando como ejemplo el espacio de estados de la figura 4, la primera llamada a la función recursiva de búsqueda almacenaría localmente los nodos {b,c}, hijos del nodo raíz. Una nueva llamada pasando como parámetro el nodo b almacenaría en la nueva lista local los nodos {d, e} descendientes directos el nodo actual. Al ser d un nodo hoja, la expansión de dicho nodo provoca que la función termine tras detectarse en la línea 3 que no hay descendencia. Tras la vuelta-atrás, la ejecución continua por la línea 3.c y se selecciona e el último nodo abierto en este nivel de recurrencia (nivel 3 del árbol de búsqueda). Tras sucesivas vueltas atrás se expande el nodo c y finaliza la búsqueda. La ventaja del algoritmo de la figura 7 es que aprovecha la forma en que el Sistema Operativo gestiona la ejecución de procesos en memoria para implementar la lista ABIERTOS en una búsqueda DFS. La frontera de la búsqueda se divide por niveles en el árbol y los nodos en cada nivel se almacenan por separado y de manera local a la correspondiente función. La pila de llamadas se encarga de borrar la estructura de Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

126

datos cuando se produce la vuelta atrás, 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 número puede verse también como una búsqueda 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 número 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 máxima). La solución al problema se encuentra en el propio camino, y se genera durante las sucesivas vueltas atrás.

7.5.2.1.

Generación de una clave

Como ejemplo sencillo de todo lo expuesto se propone como problema a resolver el encontrar la clave de un número de 6 dígitos 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 (106) 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 número de la clave y el bucle más interior (en este caso el sexto) es el que genera la clave completa. La solución en C sería la siguiente: #include <iostream.h> #define TAM_NUMEROS #define TAM_CLAVE

10 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 también enfocarse como un problema de búsqueda y resolverse mediante la exploración de un espacio de estados mediante la técnica de primero en profundidad. Un posible código para la implementación recursiva que genera todas las posibles claves es: #include <iostream.h> #define TAM_CLAVE #define TAM_NUMEROS

6 10

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

127

int clave[TAM_CLAVE]; void FuncRec(int depth) { if(depth == TAM_CLAVE){ //Salida de recursión for(int i=0; i<TAM_CLAVE; i++) cout<<clave[i]; cout<<endl; return; //Vuelta atrás } //Generación 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 función (FuncRec) que de forma recursiva atraviesa el espacio de búsqueda de claves generando las 106 combinaciones. La configuración del estado se almacena, en este ejemplo, en un vector global clave que es el que se va modificando en cada transición. La verdadera clave es el valor de esta estructura de datos en un nodo hoja del árbol de búsqueda. La semántica detrás 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 raíz hasta el nodo actual. El subgrafo que cuelga de dicho nodo conforma el espacio del resto de posibles claves con valores k , k + 1,L , tamaño de clave − 1 . Cuando la profundidad es exactamente 6 la construcción de la clave está completa. Entonces se presenta en pantalla y se produce la vuelta atrás: if(depth == TAM_CLAVE) //Salida de recursión { for(int i=0; i<TAM_CLAVE; i++) cout<<clave[i]; cout<<endl; return; //Vuelta atrás }

La generación de sucesores se lleva a cabo en las líneas de código: clave[depth]=-1; for(int j=0; j<TAM_NUMEROS; j++) clave[depth]+=1;

La primera instrucción inicializa la configuración del estado en el nivel de profundidad siguiente (en el nivel de recursión k se generan los sucesores con valores de 0 a 9 en la posición k-ésima de la clave). En este caso no se almacenan todos los sucesores localmente en cada nivel sino que según se van generando se llama a la función de siguiente nivel. El código completo de generación y llamada es:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

128

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 búsqueda. 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 ramificación del árbol), por lo que en el último nivel hay exactamente 106 nodos hoja que son el número 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 más eficiente en tiempo ya que el algoritmo recursivo tiene que generar no solamente los nodos hoja sino el resto del árbol. El número de nodos totales N de un árbol uniforme con factor de ramificación b y profundidad d es: N = 1 + b + b 2 + b3 + L + b d −1 + b d

En el ejemplo b=10 y d = 6 con 106 hojas y 1+10+100+1000+10000+100000 =111.111 nodos adicionales hasta completar la totalidad del árbol (aproximadamente un 11%). Además los compiladores modernos consiguen buenas optimizaciones de iteraciones pero no de recurrencias. En la parte positiva del código recursivo cabe destacar: •

Es más compacto: El número de sentencias que necesita es claramente más corto y además no depende del tamaño de la clave. Lamentablemente no se puede decir lo mismo de la legibilidad.

Es parametrizable completamente: El algoritmo iterativo permite definir un parámetro TAM_NUMEROS configurable pero no permite definir el parámetro TAM_CLAVE. Esto quiere decir que habrá que añadir tantas sentencias for como números tenga la clave, lo que no ocurre en la versión recursiva.

En cuanto a los requisitos en espacio, ambas implementaciones presentan un buen comportamiento. En el caso de la versión recursiva solamente se emplea la pila de llamadas para pasar el parámetro profundidad que es la única información 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 versión iterativa es más intuitiva porque el problema de desciframiento de claves se presta a ello. Sin embargo, existen muchos otros problemas donde no es fácil, ni mucho menos intuitivo, implementar el control de las iteraciones para conseguir la solución. Para estos problemas y debido al buen comportamiento de la búsqueda primero en profundidad en cuanto al consumo de memoria, el uso de recursión es preferible. Los algoritmos más eficientes para muchos problemas NP-Duros (como por ejemplo el problema del Máximo Clique) se implementan mediante esta técnica.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

7.5.2.2.

129

Permutaciones

Un ejemplo ligeramente más complicado es la generación de permutaciones de N números mediante una búsqueda recursiva primero en profundidad. En este caso el estado se almacena en un vector de N números denominado permuta. En el nodo raíz permuta tiene todos sus elementos a cero y contiene el valor de la permutación 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 números, el árbol de búsqueda tiene profundidad N+1, donde el nivel 0 corresponde al nodo raíz y el nivel N al de las N! hojas solución. La función 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 posición debe ser rellenada en niveles superiores y un valor distinto de cero determina el valor de la permutación en esa posición para cualquier nodo sucesor. De manera intuitiva, la función recursiva genera tantos sucesores como valores a cero (o huecos) tiene permuta en el momento de la invocación. 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 posición con lo que el número de sucesores será N-1, las diferentes posiciones que puede ocupar el 2 en el conjunto de permutaciones posibles fijado ya el 1. La búsqueda continúa expandiendo nodos hasta alcanzar las hojas en el nivel N, en cuyo caso permuta está completa (carece de huecos) y se produce la vuelta atrás. La figura 8 muestra el árbol de estados completo para el procedimiento propuesto con N = 3.

1

1

0

0

0

0

0

0

0

1

0

0

0

1

2

1

2

0

1

0

2

2

1

0

0

1

2

2

0

1

0

2

1

3

1

2

3

1

3

2

2

1

3

3

1

2

2

3

1

3

2

1

Figura 8. Árbol de búsqueda para generar permutaciones de 3 números mediante la técnica de primero en profundidad. Como en el ejemplo anterior, ocurre que el número de estados generados es superior al número de permutaciones solución. Para el árbol de la figura se puede demostrar que el número de nodos computados (llamadas a la función recursiva) será más 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 implementación de la función recursiva que recorre el árbol de la figura 8 primero en profundidad es:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

130

int nivel = -1; void FuncRec(int k) { nivel++; permuta[k] = nivel; if(nivel == N){ //Nodo hoja: Permutación generada Mostrar(); } else{ for (int pos = 0; pos < N; pos++){ if (permuta[pos] == 0) FuncRec(pos); } } nivel--; permuta[k] = 0; }

La función 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 búsqueda. La información pasada en cada llamada es la posición en permuta donde se va a añadir el valor correspondiente al siguiente nivel de profundidad. Nada más entra en la función se determina el primer estado sucesor: nivel++; permuta[k] = nivel;

para después comprobar si se está hoja, en cuyo caso se muestra la permutación 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, corresponderán a valores nulos de permuta. Esta condición se verifica justo antes de la expansión: 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 modificación de permuta para conseguir que el generador de sucesores en dicho nivel funcione correctamente. En el nivel 1 del árbol de búsqueda en la figura 8, esto equivale a borrar el 1 de permuta[0] justo antes de la vuelta atrás al nodo raíz, 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 generarían no serían correctos. El código 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 modificación de permuta. Permuta arranca con todo ceros. El código completo que muestra todas las permutaciones de 4 números en pantalla es el siguiente: #include <iostream.h> #define N 4 int nivel=-1; int permuta[N];

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

131

void Mostrar(){ for (int i = 0; i < N; i++) cout<<permuta[i]; cout<<endl; } void FuncRec(int k){ nivel++; permuta[k] = nivel; if(nivel == N){ //Nodo hoja: Permutación generada Mostrar(); } else{ for (int pos = 0; pos < N; pos++){ if (permuta[pos] == 0) FuncRec(pos); } } nivel--; permuta[k] = 0; } 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 implementación el pseudocódigo descrito en la figura 7. En particular, no se ha empleado la pila de llamadas para almacenar toda la información 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 búsqueda y

En ambos ejemplos se ha generado la información 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 práctica ambas condiciones no son demasiado frecuentes y es más habitual encontrar implementaciones que siguen exactamente el pseudocódigo descrito en la figura 7, con la siguiente salvedad: si el tamaño del espacio de estados es muy grande o si se busca máxima eficiencia, la reserva de espacio en memoria reservado para variables locales a la función recursiva resulta excesivamente lenta ya que se debe asignar y liberar en cada llamada. En estos casos, la solución 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 búsqueda recursivo. En resumen, los identificadores que reservan espacio en memoria para la información de los estados del árbol deben ser globales a la función recursiva si se busca una máxima eficiencia.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

8.

133

EJECUCIÓN DISTRIBUIDA DE TAREAS

8.1. INTRODUCCIÓN La problemática de la ejecución distribuida de tareas está hoy en día plenamente vigente después del gran desarrollo que ha tenido Internet. En la práctica existen innumerables problemas computacionales donde se produce una fuerte explosión combinatoria que no son abordables adecuadamente por una única unidad de proceso. Para estos casos, el rápido desarrollo de Internet está llevando, cada vez más, al empleo de los tiempos muertos de la ingente capacidad de procesamiento conectada a la red para realizar, lo que podría denominarse, supercomputación distribuida. Entre los numerosos ejemplos de este tipo de procesamiento cabe destacar el cómputo del genoma humano. El problema de la computación distribuida o descentralizada está estrechamente ligado con el de la computación paralela. En este caso, los avances tecnológicos han permitido la aparición de nuevos procesadores formados por múltiples núcleos (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 aplicación. Otro ejemplo interesante es el gran avance que han tenido la arquitectura de las tarjetas gráficas modernas, hasta el punto de que muchos cálculos pueden llevarse a cabo ahora más rápidamente por su unidad de procesamiento (conocida como GPU), en comparación con las CPUs tradicionales. Tanto la computación distribuida como la computación en paralelo se basan en la descomposición del procedimiento a realizar en subtareas, lo más independientes posibles, de tal modo que la solución final se pueda generar con cierta facilidad a partir Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

134

de las soluciones de cada una de las partes. Existen problemas fácilmente paralelizables (como por ejemplo la generación de claves o el problema de las NReinas) y otros mucho menos aptos para ello (e.g. muchos problemas no triviales de optimización o el algoritmo de búsqueda mini-max). Las dificultades de trocear un problema en partes adecuadas para su computación por separado se pueden clasificar en 3 grandes grupos. Estos son: •

Necesidad de comunicación entre las unidades de procesamiento, con el consiguiente incremento en el tiempo de cómputo total.

Particiones no independientes: En la mayoría de problemas importantes es casi imposible un fraccionamiento en partes totalmente independientes. En el caso general, aparecen problemas de sincronización derivados de que unas unidades de procesamiento necesitan esperar la finalización de otras para continuar.

Repetición 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 capítulo se muestra detalladamente un ejemplo de computación distribuida para un problema clásico del mundo de los ‘juegos’: el problema de las NReinas.

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 generalización 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 anónima en la revista alemana Schach en el año 1848; posteriormente se le atribuyó a un ajedrecista del momento, Max Bezzel, del que poco más se conoce. Ya en aquel tiempo atrajo la atención de la élite matemática, entre los que se incluía el gran Carl Friedrich Gauss, que intentó enumerar todas las distintas soluciones al problema. Gauss sólo pudo encontrar 72 configuraciones distintas, lo que da una idea de la dificultad de este problema aparentemente sencillo. Solo unos años más 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.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

135

8.2.2 Características El problema de las N-Reinas es un problema ‘teórico’ 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 analíticas 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 solución existe y es trivial). Un ejemplo de solución cerrada se enuncia a continuación: 1. 2. en 3. 4. de

Sea R la parte entera del resto de N/12 Sea L el conjunto de todos los números pares de 2 (incluido) a N orden creciente. Si R es 3 o 9 coloque el 2 al final de la lista Añada a L (empezando por el final) el conjunto de números impares 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 número de L; la segunda reina en la casilla de la segunda fila indicada por el segundo número de L y así sucesivamente.

Se anima a lector a emplear este procedimiento para encontrar una configuración objetivo para valores de N bajos (por ejemplo N=10). Este y otros métodos analíticos permiten afirmar los dos siguientes postulados: 1. El problema tiene solución para N =1 y para todo N mayor 3 2. Se sabe como construir al menos una solución cuando ésta existe Sin embargo, estos métodos puramente analíticos 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 prácticamente todas las técnicas de búsqueda heurística conocidas, dada la sencillez del enunciado y la tremenda explosión combinatoria que genera. Empleando técnicas de mejora iterativa basadas principalmente en minimización de conflictos, Sosic y Gu a principios de los años 90 pudieron ubicar más de 3.000.000 de reinas en un tablero vacío; esta línea de investigación continúa abierta en la actualidad. Éstas y otras técnicas de búsqueda local, sin embargo, no permiten encontrar todas las soluciones del problema. Este último es el escenario más difícil ya que la explosión combinatoria que se produce empleando una búsqueda exhaustiva desinformada (e.g. un procedimiento primero en profundidad) es 2 2 N  N ! , lo que para valores de N mayores de 30 resulta muy difícilmente  = 2  N  N !( N − N )! Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

136

computable en la práctica. Esta cifra puede mejorarse mucho teniendo en cuenta que sólo puede colocarse una reina por fila y por columna. Aún con todo, la búsqueda de todas las posibles configuraciones necesita de heurísticas para atravesar el desierto formado por el gigantesco espacio de búsqueda y encontrar los oasis de soluciones. Una característica singular de las N-reinas es que, si bien la dimensión del espacio de estados es claramente exponencial en el número de reinas a colocar, el número de soluciones también crece exponencialmente con N (ver Tabla 1). Tabla 1. Número 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 distribución no es homogénea en el espacio de estados sino más bien existen enormes zonas vacías salpicadas de grandes concentraciones de soluciones. Intuitivamente esto quiere decir que, a mayor N, no es en absoluto evidente que el problema sea exponencialmente más difícil (es más, todo apunta a que esta afirmación es falsa). A principios de los 90, Kalé encontró una heurística que permitía 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 podría ser uniforme. Recientemente, en una investigación llevada a cabo por los propios autores se ha encontrado una nueva heurística que corrobora esa afirmación y extiende el cómputo 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 número exacto de soluciones que existen para cualquier valor de N: En este enfoque interesa sólo el número exacto de soluciones y no necesariamente su enumeración explícita ni, desde luego, su almacenamiento (lo que sería, por otro lado, imposible dada la explosión combinatoria del número de soluciones).

Enumerar las primeras K soluciones para cualquier valor de N: En este caso se exige el cómputo explícito. El valor de K no suele ser muy grande (por ejemplo 100), pero el suficiente para que el procedimiento de resolución no pueda emplear métodos analíticos cerrados.

Fuera del ámbito de los juegos, es interesante mencionar la aplicación del problema de las N-Reinas en el campo de la optimización, donde constituye un importante modelo teórico en problemas de planificación (scheduling) y de asignación de tareas (task assignment problems).

8.2.3 Estructuras de datos La implementación típica del problema divide el tablero por filas y codifica una solución cualquiera como N números entre 1 y N que representan las casillas ocupadas Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

137

en cada fila, para un total de N! posibles configuraciones (las permutaciones de los N números (ver figura 2)).

F4 F3 F2 F1 1

2

3

4

Figura 2. Solución del problema de las 4-Reinas. Dicha solución 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 números para codificar cualquier estado del espacio de soluciones y un procedimiento de búsqueda sistemático (válido) es cualquier algoritmo que genere permutaciones. Si se pretende abordar la generación explícita de las primeras k soluciones, prácticamente la única alternativa razonable es realizar una búsqueda primero en profundidad guiada por una heurística adeudada. En este caso, la búsqueda no se debe desarrollar en un espacio de soluciones (como en el cálculo 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 solución en los nodos situado a una profundidad N. De manera intuitiva en cada nivel del árbol se añade una reina al tablero hasta alcanzar una solución. Si en un estado concreto no existen casillas libres en la fila o columna correspondiente se produce una vuelta-atrás y la última reina colocada se elimina del tablero. Según se expuso en la sección 7.3, el control de la búsqueda sólo requiere almacenar tanto el camino actual como todos los nodos sucesores directos de dicho camino. Si tomamos como factor de ramificación medio del árbol (b) el valor de N/2, el espacio máximo requerido durante la búsqueda, teniendo en cuenta que la profundidad del árbol (d) no puede exceder de N, será: Espacio máximo =

N N N2 ⋅d = ⋅ N = 2 2 2

lo que no supone mayor problema para los computadores actuales. Respecto a la generación de los nodos sucesores a partir del padre, el mayor coste computacional reside en el cálculo 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 cómputo eficiente de las casillas libres ‘al vuelo’ es necesario añadir nuevas estructuras de datos como por ejemplo registros que llevan la cuenta del Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

138

número total de las casillas no atacadas en filas, columnas y diagonales etc. Éstas y otras estructuras bien elegidas permiten que la determinación de las casillas libres se realice en tiempo constante pero requieren tiempo adicional para su cómputo. Es interesante mencionar que existen codificaciones más o menos ingeniosas que asocian bits con características del dominio de manera que una operación 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 corazón mismo del control de la búsqueda. El algoritmo elegido para implementar las N-Reinas que se describe en esta sección emplea este tipo de codificación.

8.3. IMPLEMENTACIÓN CENTRALIZADA Se presenta en esta sección un procedimiento que permite obtener, al menos en teoría, todas las soluciones distintas del problema de las N-Reinas para valores de N hasta 32 (en la práctica esto no va a ser posible debido al crecimiento fuertemente exponencial de las soluciones con N según muestra la tabla 1). El algoritmo genera explícitamente todas las posibles soluciones y lleva la cuenta del total. La restricción en el valor de N se debe a que, para la codificación 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 búsqueda se realiza mediante la técnica primero en profundidad implementada de forma recursiva (ver sección 7.5). Más concretamente, el tablero se rellena por filas y la colocación de una nueva reina en una fila provoca un cambio de estado; se puede decir, por tanto, que la búsqueda se realiza en un espacio de filas donde cada estado-fila queda determinado por el número de casillas libres que dispone (aquéllas 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 dirección descendente empezando por la parte superior del tablero. Para aclarar estos conceptos, la figura 3 muestra una posible traza del árbol de búsqueda 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 raíz del árbol, por tanto, corresponde al estado-fila extremo superior del tablero que inicialmente está vacío. Los nodos hoja del árbol están marcados con una cruz, con la excepción del nodo hoja solución 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 ubicación de una reina en niveles superiores y provoca una vuelta atrás. Se observa como la realización de la búsqueda en un espacio de filas en lugar de un espacio de soluciones permite podar la búsqueda reduciendo el tamaño de árbol generado.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

139

nSol++

Figura 3. Traza del árbol de búsqueda del problema de las 4-reinas. El nodo raíz representa la fila superior del tablero. Estados marcados con una cruz son nodos hoja no solución. Al encontrara una configuración solución se incrementa el contador nSol y continúa la búsqueda. En el ejemplo, la búsqueda yerra al comenzar colocando una reina en la esquina superior derecha del tablero. Tras producirse la última poda en el nivel 3 (para la configuración de reinas en estados superiores del camino no existen casillas libres en la fila actual) se produce una vuelta atrás. Posteriormente, tras encontrar una configuración solución (estado marcado con el parámetro nsol) se incrementa en una unidad la cuenta de soluciones y la búsqueda continúa hasta que no existen sucesores que explorar o bien, en el caso general, el contador llega a un valor K.

8.3.1 Descripción En la implementación propuesta, el control de la búsqueda obedece íntegramente al pseudocódigo propuesto para búsquedas primero en profundidad en el capítulo 7. Las reinas se colocan por filas; para cada nuevo estado alcanzado se realizan las siguientes tareas en orden: 1. Comprobación si el nuevo estado es solución: 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-atrás para continuar por un nuevo camino. 2. Selección de la siguiente fila no ocupada: Las filas se rellenan de arriba abajo, empezando por la fila superior y terminando por la base del

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

140

tablero. En cada nivel del árbol se coloca una reina en alguna de las casillas libres de la fila correspondiente. 3. Generación 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 más costoso en tiempo de cualquier implementación y las estructuras de datos se eligen para minimizar dicho cómputo. 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 colocación de alguna de las reinas anteriores. Se efectúa entonces una vuelta-atrás al nodo padre para retomar la búsqueda. 4. Selección 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 función encargada del procedimiento de búsqueda.

8.3.2 Estructuras de datos Para optimizar el cómputo de las casillas libres en cada fila se ha empleado una codificación mediante vectores de bits. Este tipo de codificaciones se utilizan con mucha frecuencia para tratar de reducir el tiempo de cómputo aprovechando que los registros de la CPU pueden efectuar un número de operaciones de enmascaramiento de bits en paralelo equivalente al tamaño de los registros de la ALU (típicamente 32 o 64). Intuitivamente, si se consiguen asociar bits a unidades de información acerca del dominio, entonces una sola operación 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 información del tablero, en cada nodo, se reduce a una fila, y más concretamente a las casillas libres (no atacadas) de la fila. Cada fila se codifica como un número 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 posición relativa de los bits indica la posición de la casilla en la fila; el bit más bajo representa la columna más a la derecha del tablero, el segundo bit la columna inmediatamente a la izquierda y así sucesivamente, para un máximo de N bits por fila, el número de columnas del tablero. El inconveniente principal de esta codificación es que sólo es válida para tableros de dimensión 32 x 32 como máximo.

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 sólo es necesario computar las casillas atacadas en la fila correspondiente al estado actual y no las del resto de filas todavía sin rellenar. El procedimiento cómputo se reduce a Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

141

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 explicación más en detalle se expone en la sección siguiente. •

Número de fila del nodo actual: Coincide con el nivel de profundidad del nodo en el árbol de búsqueda 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 través de la pila de llamadas.

Estructuras auxiliares: La configuración de inicial de las casillas libres en una fila se guarda en la variable TODOUNOS. Este valor es constante durante toda la búsqueda y se calcula una vez al inicio. Otras estructuras son: un entero nSOL que lleva la cuenta del número de soluciones encontradas hasta el momento y la constante N que indica la dimensión del tablero.

8.3.3 Control de la búsqueda La función recursiva que controla la búsqueda se denomina FuncRec. Su definición 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); } } }

Según lo ya expuesto, los nodos del árbol de búsqueda 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 actualización se realiza a partir de la información que el nodo padre pasa a su sucesor, los parámetros fila, izq, abajo y dcha. Inicialmente se comprueba si el nuevo estado-fila es un nodo hoja solución; para ello basta con saber si se ha alcanzado la profundidad máxima del árbol N. En caso afirmativo se suma uno al contador de soluciones nSOL y se vuelve atrás en el árbol para continuar la búsqueda. Esta comprobación se realiza en la instrucción if (fila == N)

nSOL++;

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

142

Si la fila actual no es la última, entonces lo primero es generar de forma explícita el estado a partir de la información recibida del nodo padre. Esto se efectúa mediante operaciones de enmascaramiento de bits en la línea de código estado = TODOUNOS & ~(izq | abajo | dcha)

Esta instrucción requiere una explicación más detallada. El operador & en C es el operador binario AND a nivel de bits. Su resultado es un bit a 1 si en esa posición los bits de ambos operandos están también a uno. En caso contrario el bit toma el valor cero. Un ejemplo: c1 = 0x45Æ01000101 c2 = 0x71Æ01110001

c1 & c2 = 0x41Æ01000001

El operador ~ es el operador unario ‘complemento a uno’ en C. Como resultado, el número sobre el que opera intercambia los bits a cero por los bits a uno. La combinación 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 están en la posición ocupada por los bits a 1 de c2. Esta combinación de operadores se conoce comúnmente como ‘borrado de c1’ ya que el segundo operando lleva la información de los bits a borrar en el primero. Un ejemplo: c1 = 0x45Æ01000101 c2 = 0x71Æ01110001

c1 &~ c2 = 0x04Æ00000100

Volvamos ahora al cómputo de las casillas libres en la nueva fila. La instrucción 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 información está contenida en los parámetros izq, abajo y dcha que, de manera intuitiva, se corresponden con las casillas atacadas por todas las reinas ya colocadas, según 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 parámetros con información 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 dirección 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 Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

143

parámetros izq = 01002, abajo = 00102 y dcha = 00012, 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 = 10002 marcada por el cuarto bit a uno (la casilla del extremo izquierdo de la fila). izq = 01002

Fnueva

izq

abajo dcha

abajo = 00102 dcha = 00012 estadonuevo = 10002

Figura 4. Valor de los parámetros 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 operación estado = 11112 &~ (izq | abajo | dcha) = 10002. 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 explicación. La primera línea de código, nada más entrar en el bucle, obtiene el primer bit a 1 de la palabra estado mediante una ingeniosa pero muy conocida operación a nivel de bits, combinación de operadores – y &: sucesor = -estado & estado;

La operación resta vista como operador unario calcula el complemento a 2 del operando al que afecta. La secuencia de operaciones - y & sobre un mismo número borra todos los bits exceptuando el bit a uno más bajo de dicho número. Por ejemplo, -11012 & 11012 devuelve 00012 mientras que -11002 & 11002 devuelve 01002. El lector puede fácilmente comprobar que esta propiedad se cumple para cualquier número. Por tanto, sucesor será un número formado por un único bit a 1, el bit más bajo de estado. La siguiente línea de código dentro del bucle completa el control del mismo. estado ^= sucesor;

El operador ^ en C es la máscara XOR bit a bit, operador binario también 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 Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

144

estado. Intuitivamente, en cada iteración se elige el bit a uno más bajo de estado y después se borra, lo que implica que las reinas se colocan de derecha a izquierda en las casillas libres de cada fila. Cuando estado está vacío finaliza la ejecución del bucle. Es interesante hacer notar que el mismo resultado se obtendría mediante el operador ya visto de borrado: estado &= ~sucesor;

pero sería menos eficiente ya que se necesita una operación más 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 código esto se realiza en la propia instrucción de llamada a la función: FuncRec(fila+1, (izq | sucesor )<<1, abajo | sucesor, (dcha | sucesor)>>1);

El primer parámetro de FuncRec, el número de la fila, siempre se incrementa en una unidad. Su valor inicial es 0, la fila superior del tablero. El segundo parámetro es la actualización de la estructura izq a partir de su valor actual. Este cómputo puede verse como un desplazamiento hacia la izquierda una unidad de un número 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 posición sucesor) según se desprende de la figura 5. En C, el operador ‘desplazamiento a izquierdas’ tiene como símbolo <<. 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 número unidades de desplazamiento de los bits del operando de la izquierda en la dirección apuntada por el símbolo.

1

Factual Fnueva

izqact = 000102

1

estadoact = 111002

1

sucesor = 001002

izqnue = (izqact | sucesor) <<1 = 011002

Figura 5. Actualización de la estructura de datos izq. izqact es el valor en la fila actual (Factual) e izqnue el nuevo valor calculado a partir del anterior. estadoact contiene las casillas libres en la fila actual. De entre éstas, se ha elegido colocar una nueva reina en la casilla central de Factual, lugar que ocupa en la figura, almacenándose su posición en la variable sucesor. Ahora bien, para obtener el nuevo valor de izq no basta con desplazar el antiguo una posición a la izquierda (equivalente a izqnue = izqact <<1) ya que esta operación tiene en cuenta los ataques en esta diagonal de todas las reinas situadas en filas anteriores a Factual pero no incluye la última que se encuentra en sucesor. De ahí que izqnue sea compute a partir de la unión entre sucesor e izqact. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

145

Un razonamiento análogo 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 posición (operador >> de C). En este punto es interesante destacar que la operación de enmascaramiento que genera el estado actual al entrar en FuncRec: TODOUNOS & ~(izq | abajo | dcha)

lleva implícita también la gestión de bordes. Este problema es inherente a los juegos de tablero y debe tenerse muy en cuenta en la selección de las estructuras de datos para codificar el problema. Como ejemplo, considérese el problema de las 4Reinas nuevamente. Si se coloca una reina en la esquina superior izquierda, la codificación de izqnue para la segunda fila sería 100002, 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, 11112 y lleva implícita la información del tamaño del tablero. La máscara & ~ , por tanto, actúa sólo 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 dirección vertical, sentido descendente (variable abajo) coinciden con el valor anterior añadiendo 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. estadoact = 00002

Factual

sucesor = 00102

Fnueva

izq

abajo dcha

izqn = sucesor << 1 = 01002 dchan = sucesor >> 1= 00012 abajon = sucesor | 1 = 00102

estadonuevo = 11112 &~ (00102| 00012 | 00102 ) = 10002

Figura 6. Ejemplo de cómputo de casillas libres. La reina en la figura está codificada en sucesor y provoca la transición a la fila nueva. La fila actual es el borde superior del tablero (nodo raíz del árbol) y los valores de izqact, dchaact y abajoact en ese nodo son 00002. Los valores de izqn, abajon y dchan en la figura representan las casillas atacadas en la nueva fila.

8.3.4 Algoritmo de búsqueda Una vez explicada en detalle la función recursiva principal que dirige la búsqueda, el resto de código no ofrece especial dificultad. El código completo para el problema de las 8-Reinas es:

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida #include <stdio.h> #define N 8

146

//Max 32

const int TODOUNOS =(1 << N) - 1; int nSOL; 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); } } } int main(void) { nSOL = 0; FuncRec(0, 0, 0, 0); printf("N=%d -> %d\n", N, nSOL); return 0; }

El cómputo 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 número de 32 bits con el bit más bajo a uno) N posiciones a la izquierda, con lo que se sitúa en la posición N+1. Debido al acarreo, la operación resta de una unidad convierte a unos todos los ceros a la derecha del uno desplazado. El uno en la posición N+1 actúa como barrera y evita, al ponerse a nivel bajo, la propagación indebida del bit de acarreo más allá de su posición. La llamada inicial a la función de búsqueda se realiza con todos los parámetros a cero (izq, abajo y dcha están a nivel bajo al inicio). Con estos valores, el cómputo del estado-fila en el nodo raíz tiene también valor 0, o visto de otro modo, la primera reina puede emplazarse en cualquier casilla del borde superior del tablero vacío. Por último, cabe destacar que la búsqueda que realiza este procedimiento es desinformada al no incorporar ninguna heurística de decisión. Las reinas se emplazan en filas consecutivas en dirección descendente y se van colocando por columnas de derecha a izquierda (posiciones bajas a posiciones altas de bits a 1 en estado). Por Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

147

esta razón, y a pesar de que la codificación admite tableros de dimensión hasta 32x32, valores de N superiores a 18 difícilmente pueden ser resueltos por un computador comercial con este algoritmo. A partir de N = 20 la tarea es prácticamente imposible.

8.4. IMPLEMENTACIÓN DISTRIBUIDA El problema de las N-Reinas pertenece a la categoría de problemas fácilmente 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 subárbol puede ser resuelto en paralelo sin necesidad de sincronización y con la seguridad de que no se están repitiendo configuraciones solución, un escenario idílico en el marco del cómputo paralelo. Se muestra en esta sección una implementación de esta paralelización tipo del problema de las N-Reinas, en el marco de un sistema distribuido. La implementación 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 más eficiente que un sistema centralizado, al ser capaces de aprovechar el trabajo de múltiples 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 disposición del programador mediante el mecanismo de herencia. En este sentido, la mayor parte de las explicaciones que aparecen en esta sección pueden considerarse multiplataforma.

8.4.1 Arquitectura cliente-servidor La arquitectura distribuida elegida tiene a un cliente que centraliza la distribución de la carga sobre un conjunto de servidores. El cliente se encarga de subdividir el problema en partes que serán resueltas por los diferentes servidores en la red; éstos últimos son los que ejecutan el algoritmo de búsqueda y devuelven como resultado al cliente el número de soluciones encontradas de cada problema parcial. El cliente, por su parte, tras finalizar el reparto de la carga, envía una petición 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 partición 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 ubicación de dicha reina en el tablero. En consecuencia, habrá un máximo de N subproblemas a resolver y podrán existir un máximo de N servidores trabajando en paralelo. La figura 7 muestra la arquitectura descrita.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

148

Servidores

Servidores

+ + + Cliente

Cliente +

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 comunicación La información 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 números enteros: el tamaño del problema (parámetro N) y la posición de la reina en la primera fila (que determina la subtarea a resolver). Este parámetro 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 envío es una cadena de caracteres que tiene la forma siguiente: CABECERA:”Nqueens” DATOS:<Tamaño 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 envío anterior, el cliente central lanza, cada segundo, una petición de resultado a cada servidor y recibe de ellos un entero solución si han terminado su parte. El mensaje de petición de resultado es la cadena de caracteres “Resultado”. Cada servidor devuelve entonces la solución obtenida o -1 si no ha terminado aún. Cuando todos los mensajes de petición han sido contestados satisfactoriamente, el cliente presenta la suma de los resultados en pantalla.

8.4.3 Implementación del cliente En esta sección se describe en detalle todo lo relativo al funcionamiento de la parte del cliente. Como se indicó la comienzo de esta sección, se van a omitir la mayoría 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 disposición. En cambio, sí se describirá en detalle la manera de hacer uso de esta clase mediante el mecanismo de herencia.

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

8.4.3.1.

149

Comunicación con el servidor

Las comunicaciones entre cliente y servidor se realizan a través de Sockets. A efectos del ejemplo, bastará saber que existe una clase Socket que encapsula la recepción y envío de mensajes y que tiene (entre otras) dos funciones miembro públicas: 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 función SendMsg permite enviar una cadena de tamaño length (medido en bytes) mientras que ReceiveMsg recibe una cadena de tamaño máximo size. Ambos servicios devuelven un 0 si la comunicación se ha efectuado con éxito. Para el ejemplo se ha creado una clase cliente MyLiveClient que hereda estos servicios de comunicaciones mediante derivación pública 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 envía la información de la partición del problema (el tamaño del tablero y la posición de la reina en la fila superior) y la segunda realiza la petición de resultado, ambas siguiendo el protocolo descrito en la sección anterior. El código de la función envío 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 envío 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

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

150

través del parámetro de retorno. La función strlen empleada dentro del segundo parámetro del servicio SendMsg devuelve el número de caracteres de la cadena argumento excluyendo el carácter nulo al final de la cadena. Este es el motivo por el que en la instrucción if(0!=SendMsg(cad,strlen(cad)+1)) return -1;

hay que añadir una unidad al resultado de strlen. La implementación de la función que pide y recibe el resultado es la siguiente: int MyLiveClient::RecibirResultado() { // 0 OK, -1 ERROR char cad[100]; int max_size=10, res=-1; sprintf(cad,"%s","Resultado"); if(0!=SendMsg(cad,strlen(cad)+1)) return -1; if(0!=ReceiveMsg(cad,&max_size)) return -1; sscanf(cad,"%d",&res); if(res>=0){ cout<<"Recibido resultado correcto: "<<res<<endl; return res; } return -1; }

De nuevo el código no requiere demasiada explicación. Una vez enviada la petición mediante el mensaje “Resultado” la instrucción if(0!=ReceiveMsg(cad,&max_size)) return -1;

recibe en la cadena de caracteres cad la posible solución numérica. Tras formatear la cadena como número (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 función devuelve -1 para indicar que la tarea no ha finalizado. Nótese que se acepta el valor cero como resultado porque pueden existir subproblemas sin ninguna configuración solución (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 envía cada subproblema a los servidores remotos. Para ello es necesario inicializar un recurso cliente por cada partición del problema y la comunicación 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 tamaño el número de particiones del problema. Para el ejemplo, se ha definido un parámetro global NUM_PARTES que, en tiempo de compilación, proporciona las particiones deseadas. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

151

El código completo del hilo principal se detalla a continuación. #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.

//Enviar particiones for(i=0; i<NUM_PARTES; i++) client_array[i].EnviarNReinas(N ,i); //Recoger resultados cada segundo bool b_terminado; int sol[NUM_PARTES]; while(1){ Sleep(1000); //1 segundo por petición 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; } //Cálculo de la solución int total=0; for( i=0; i<NUM_PARTES; i++) total+=sol[i]; //Presentación de la solución 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 ejecución del hilo principal mediante la función 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 función Init requiere dos argumentos, la dirección IP del servidor remoto y el puerto. Como es lógico, ambos deben coincidir con el servicio de establecimiento de conexión en el servidor remoto. En el ejemplo, se emplea la dirección genérica IP

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

152

que existe en toda máquina para poder realizar pruebas en modo local y lo que cambia para cada Socket cliente es la configuración del puerto (empezando por el 12.000). Posteriormente, el hilo principal del cliente realiza, cada segundo, la petición del resultado a todos los servidores remotos mediante un bucle del que sólo se sale si todos los servidores han finalizado. Esta funcionalidad se ha implementado de la manera más sencilla posible y es manifiestamente mejorable (por ejemplo, no se distingue entre servidores que han finalizado el cómputo y los que no). El control de esta operación se lleva a cabo mediante el flag b_terminado. Tras la recepción de las soluciones parciales, el cliente calcula la suma total y muestra el resultado en pantalla. Finalmente, la función miembro Close es invocada para cada objeto MyLiveClient liberando el recurso Socket en memoria y cerrando su hilo de ejecución. Esta función, al igual que Init, es heredada de la clase Socket mediante derivación. La figura 8 muestra la traza de la sesión del cliente para el problema de las 8-Reinas con la primera reina en la esquina derecha como única partición (en este caso solo hay 4 soluciones). La comunicación se establece localmente en el puerto 12000. La explicación de la sesión es la siguiente: • La línea “Connection” indica que se ha establecido comunicación con el servidor. • Tras el envío 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 función miembro EnviarNReinas de MyLiveClient. • Una llegada de una solución mayor o igual que cero tras la petición de resultado (mediante la función 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 “Número de reinas: 4”). • El cierre del socket cliente provoca una advertencia en pantalla de desconexión.

Figura 8. Traza de la sesión cliente para el problema de las 4-Reinas con la primera reina en la esquina derecha como única partición. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

153

8.5. IMPLEMENTACIÓN DEL SERVIDOR En el lado del servidor es donde se encuentra el procedimiento de búsqueda para el problema de las N-Reinas que llega desde el lado del cliente. El servidor empleado es sencillo y sólo 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 sólo está previsto un único Socket cliente por sesión que pasa la información del problema y recoge el resultado. Esto sólo 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 práctica, sin embargo, raras veces se dará esta circunstancia y sería más lógico una arquitectura que permitiera a los servidores un acceso simultáneo a varios clientes.

8.5.1 Comunicación con el cliente De forma similar al caso del cliente, se dispone de la clase Socket que encapsula los servicios de comunicación. Para la gestión del protocolo y el lanzamiento del algoritmo de búsqueda se ha desarrollado una clase MyLiveServer que hereda públicamente de aquélla. 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 más desacoplada posible de la implementación del procedimiento de búsqueda; la relación se establece a través del dato miembro privado m_pNQ que es un puntero a la clase NQueen que encapsula el algoritmo recursivo descrito con anterioridad. La dirección del objeto búsqueda se pasa en el momento de la llamada al constructor: MyLiveServer(NQueen* pq);

Para la gestión del protocolo, MyLiveServer dispone de una función miembro OnMsg que es llamada cuando llega cualquier petición del lado del cliente. Esta función 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 implementación de OnMsg es la siguiente: int MyLiveServer::OnMsg(char* cad,int length) { //LLamada a función heredada Socket::OnMsg() //Muestra el mensaje en pantalla cout<<"Ha llegado el siguiente mensaje: "<<cad<<endl; //Deserialización int N, posq; char message[100]=""; char nombre[100];

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

154

sscanf(cad,"%s %d %d",nombre, &N, &posq); //Protocolo if(strcmp(nombre,"Nqueens")==0){ //Recepción 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){ //Envío 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 información del mensaje prevista en el protocolo; se asigna a la variable local nombre la cabecera del mensaje, a la variable local N el tamaño del tablero y a posq la posición de la reina en la primera fila. Posteriormente se analiza la información recibida. Si la cabecera es “Nqueens” la petición se reconoce como un envío if(strcmp(nombre,"Nqueens")==0){…}

mientras que si es una petición de resultado se envía la información relativa a la solución if(strcmp(nombre,"Resultado")==0{…}

En ambos casos se emplea la función strcmp que devuelve un cero si la cadena del argumento primero es exactamente igual que la del segundo. Detectada la petición de ejecutar una tarea, se comprueban posibles errores en los parámetros y se actualizan los valores de la instancia de la clase NQueen que se encarga del procedimiento de búsqueda. Esta instancia se pasó como puntero en el constructor del objeto MyLiveServer. La actualización de los datos se realiza en las instrucciones: m_pNQ->Set(N); m_pNQ->SetReinaPrimeraFila(posq);

Caso de recibir una petición de resultado, se llama a la función miembro GetCount() de la clase NQueen para obtener dicho valor y se envía como cadena al cliente: sprintf(message,"%d",m_pNQ->GetCount()); if( 0!=SendMsg(message,10) ) return -1;

El significado de los parámetros de la función SendMsg es el mismo que en el caso del cliente, por lo que no se añade ningún comentario adicional. Por último, destacar que si se detecta cualquier error en la transmisión de datos entre cliente y servidor, OnMsg retorna -1; si no de detecta ningún problema la función devuelve 1. Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

8.5.1.1.

155

Procedimiento de búsqueda

El algoritmo recursivo para las N-Reinas ya descrito tiene que modificarse ligeramente para recibir como parámetro la posición de la reina en la primera fila. Para una gestión ordenada del procedimiento de búsqueda 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 información 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 añade ahora m_posq que contiene la posición de la reina en la primera fila, punto de partida de la búsqueda. La función miembro privada FuncRec lanza el procedimiento recursivo de búsqueda y es idéntica a la ya descrita e el caso general. El interfaz de la clase consta de la función GetSol, que devuelve el valor solución almacenado en m_posq, diversas funciones de inicialización y el proceso que gestiona el inicio de la búsqueda SolveQ. El código 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 parámetros 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 Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

156

(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:

8.5.1.2.

pos=(1<<m_posq): Traduce la posición relativa de la reina a la máscara con un único bit a uno correspondiendo a esa posición. La operación 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 dirección 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 dirección 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.

Hilo principal del servidor

Una vez que se lanza el hilo de comunicaciones mediante la función heredada server.Init, el hilo principal del servidor entra en un bucle infinito y comprueba cada segundo si existe una búsqueda que completar. Para ello se ha elegido el valor del parámetro m_posq como elemento de comprobación. Si recibe una tarea correcta, m_posq toma un valor mayor que cero. Si la tarea recibida no es correcta o ha terminado la búsqueda actual, m_posq toma el valor -1. La función 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 búsqueda } Sleep(1000); //Esperar 1 segundo } }

Los parámetros de la función server.Init() son la dirección IP y el puerto donde está escuchando el servidor. Los parámetros que figuran permiten realizar pruebas con la arquitectura cliente-servidor en una sola máquina, para el código del cliente descrito en la sección anterior. El servicio Sleep (Win32) suspende la ejecución del proceso que lo ejecuta durante el tiempo que figura como argumento (medido en milisegundos). Al terminar la búsqueda, la instrucción

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009. Programación Avanzada, Concurrente y Distribuida

157

queen.SetReinaPrimeraFila(-1)

asigna el valor -1 al dato miembro m_posq. De esta manera se consigue que el hilo principal de ejecución no entre en el bucle hasta que haya una nueva petición del cliente ya que queen.GetPos() devuelve ahora como resultado -1. La figura 9 muestra la traza de la sesión del cliente para el problema de las 8-Reinas con una reina situada en la esquina derecha del tablero como única partición. La comunicación se establece localmente en el puerto 12000. La explicación de la sesión es la siguiente: •

Las dos primeras líneas de la sesión “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 función miembro heredada Init. La aparición de ambos mensajes pertenece también a la funcionalidad heredada.

Tras la llegada del mensaje con el problema a resolver, se llama a la función miembro OnMsg implementada en MyLiveServer. Esta función llama, a su vez, a la función OnMsg miembro de la jerarquía heredada (mensajes “Client connected from: …” y “Connection”) y posteriormente muestra los datos recibidos en pantalla

Al terminar el hilo principal el procedimiento de búsqueda recursivo, se muestra la solución en pantalla cout<<"Solucion Encontrada: 4"<<queen.GetSol()<<endl

Al llegar una petición de resultado la función OnMsg muestra el mensaje en pantalla (“Ha llegado el siguiente mensaje: Resultado”).

Al detectarse la desconexión del cliente lanza un mensaje de error y elimina el Socket de comunicación abierto para él, quedando a la espera de la llegada de mensajes de nuevos clientes.

Figura 9. Traza de la sesión del servidor remoto correspondiente a la traza del lado del cliente mostrada en la Figura 8.

Universidad Politécnica de Madrid -UPM

Programacionavanzada2