Вы находитесь на странице: 1из 157

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. INTRODUCCIÓN 11
1.2. LOGIN EN MODO TEXTO 12
1.3. MANEJO DE ARCHIVOS Y DIRECTORIOS EN MODO TEXTO. 12
1.4. EL EDITOR DE TEXTO 15
1.5. DESARROLLO C/C++ EN LINUX EN MODO TEXTO 15
1.6. EL PROCESO DE CREACIÓN DE UN EJECUTABLE 16
1.7. LAS HERRAMIENTAS DE DESARROLLO 17
1.8. EL COMPILADOR GCC 17
1.9. MAKEFILE Y LA HERRAMIENTA MAKE 19
1.10. TIPOS DE ERROR 20
1.11. DEPURACIÓN DE LA APLICACIÓN. 21
1.12. CREACIÓN DE UN SCRIPT 22
1.13. DESARROLLO EN UN ENTORNO GRAFICO 23
1.14. EJERCICIO PRÁCTICO 24
1.15. EJERCICIO PROPUESTO 25

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

2.1. OBJETIVOS 27
2.2. SISTEMA DISTRIBUIDO 28
2.3. SERVICIOS DE SOCKETS EN POSIX 29
2.3.1 PROGRAMA CLIENTE 30
2.3.2 SERVIDOR 32
2.4. ENCAPSULACIÓN DE UN SOCKET EN UNA CLASE C++ 35
2.4.1 ENVÍO DE MÚLTIPLES MENSAJES 36
2.4.2 CONEXIONES MÚLTIPLES. 38
2.5. ESTRUCTURA DE FICHEROS 42
2.6. TRANSMITIENDO EL PARTIDO DE TENIS 44
2.6.1 CONEXIÓN 44
2.6.2 ENVÍO DE DATOS 45
2.7. EJERCICIOS PROPUESTOS 45

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. INTRODUCCIÓN 47
3.2. REQUISITOS 49
3.3. FUNCIONAMIENTO DE GLUT 49
3.3.1 LANZANDO UN HILO 50
3.4. ESTRUCTURA DEL SERVIDOR 51
3.5. MÚLTIPLES CONEXIONES SIMULTANEAS 52
3.6. MOSTRAR LOS CLIENTES CONECTADOS 53
3.7. RECEPCIÓN COMANDOS MOVIMIENTO 55
3.8. GESTIÓN DESCONEXIONES 56
3.9. FINALIZACIÓN DEL PROGRAMA 56
3.10. EJERCICIO PROPUESTO 57

4. COMUNICACIÓN Y SINCRONIZACIÓN INTERPROCESO 59

4.1. INTRODUCCIÓN 59
4.2. EL PROBLEMA DE LA SINCRONIZACION 60
4.3. COMUNICACIÓN INTERPROCESO 61
4.4. TUBERÍAS CON NOMBRE 62
4.5. MEMORIA COMPARTIDA 64
4.6. EJERCICIOS PROPUESTOS 68

PARTE II. Programación avanzada


5. PROGRAMACIÓN DE CÓDIGO EFICIENTE 73

5.1. INTRODUCCIÓN 73
5.2. MODOS DE DESARROLLO 77
5.3. TIPOS DE OPTIMIZACIONES 77
5.4. VELOCIDAD DE EJECUCIÓN 78
5.5. ALGUNAS TÉCNICAS 79
5.5.1 CASOS FRECUENTES 79
5.5.2 BUCLES 80
5.5.3 GESTIÓN DE MEMORIA 83
5.5.4 TIPOS DE DATOS 85
5.5.5 TÉCNICAS EN C++ 86
5.6. CASOS PRÁCTICOS 87
5.6.1 ALGORÍTMICA VS. MATEMÁTICAS 87
5.6.2 GENERACIÓN DE NÚMEROS PRIMOS 88
5.6.3 PRE-COMPUTACIÓN DE DATOS 90
5.7. OBTENIENDO PERFILES (PROFILING) DEL CÓDIGO 93
5.8. CONCLUSIONES 95

Universidad Politécnica de Madrid -UPM


Rodríguez-Losada & San Segundo, 2009.
Programación Avanzada, Concurrente y Distribuida 5
6. SERIALIZACIÓN DE DATOS 97

6.1. INTRODUCCIÓN 97
6.2. REPRESENTACIÓN OBJETOS EN MEMORIA 102
6.3. SERIALIZACIÓN EN C 103
6.3.1 CON FORMATO (TEXTO) 104
6.3.2 SIN FORMATO (BINARIA) 104
6.4. SERIALIZACIÓN EN C++ 107
6.4.1 CON FORMATO (TEXTO) 108
6.4.2 SIN FORMATO (BINARIA) 111
6.5. CONCLUSIONES 112

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

7.1. INTRODUCCIÓN 113


7.2. BÚSQUEDA PRIMERO EN PROFUNDIDAD 115
7.2.1 TERMINOLOGÍA 116
7.2.2 ESTRUCTURAS DE DATOS 116
7.2.3 ANÁLISIS 117
7.3. BÚSQUEDA PRIMERO EN ANCHURA 119
7.4. 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
7.5.1 LA PILA DE LLAMADAS 122
7.5.2 BÚSQUEDA DFS COMO RECURSIÓN 124

8. EJECUCIÓN DISTRIBUIDA DE TAREAS 133

8.1. INTRODUCCIÓN 133


8.2. EL PROBLEMA DE LAS N-REINAS 134
8.2.1 HISTORIA 134
8.2.2 CARACTERÍSTICAS 135
8.2.3 2ESTRUCTURAS DE DATOS 136
8.3. IMPLEMENTACIÓN CENTRALIZADA 138
8.3.1 DESCRIPCIÓN 139
8.3.2 ESTRUCTURAS DE DATOS 140
8.3.3 CONTROL DE LA BÚSQUEDA 141
8.3.4 ALGORITMO DE BÚSQUEDA 145
8.4. IMPLEMENTACIÓN DISTRIBUIDA 147
8.4.1 ARQUITECTURA CLIENTE-SERVIDOR 147
8.4.2 PROTOCOLO DE COMUNICACIÓN 148
8.4.3 IMPLEMENTACIÓN DEL CLIENTE 148
8.5. IMPLEMENTACIÓN DEL SERVIDOR 153
8.5.1 COMUNICACIÓN CON EL CLIENTE 153

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
| | |------->archivo11.txt
| |------->archivo1.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 Acción Opciones


pwd muestra el directorio actual
ls muestra el contenido del -a (muestra todos los
directorio actual archivos, incluidos ocultos)
–l, muestra detalles de los
archivos
mkdir [directorio] crea el directorio con el
nombre dado
cd [ruta] cambia al directorio que
indica la ruta
correspondiente
cat [fichero] concatena el fichero a salida „-„ significa entrada
estándar estándar. Para crear un
archivo se puede
redireccionarla de la
siguiente forma cat -
>”nombre_fichero.txt”

chmod usuario+permiso cambia los permisos


[fichero] (r=read, w=write,
x=execute) a usuario (a=all,
o=others, u=user, g=group)
rm [archivo] Borra el archivo -r = borra recursivamente el
directorio seleccionado
(OJO, usar con mucha
precaución)
cp [origen] [destino] Copia el archivo o archivos
origen al destino
seleccionado
mv [origen] [destino] Mueve el archivo o archivos También sirve para
origen al destino renombrar un archivo
seleccionado
rmdir [directorio] Borra el directorio, que
previamente debe estar
vacío
exit o logout Termina la sesión (salir)

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

. Directorio actual Opcion


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

Tabla 1-4. Ayuda

Comando función Opcion


man [comando] Muestra las paginas “man”
del comando seleccionado
comando Muestra una ayuda breve --help -h
del comando al que se
aplica
info [comando] Muestra las paginas “info”
del comando al que se
aplica
whatis [comando] Busca en una base de datos
descripciones cortas del
comando

Tabla 1-5. Ayudas del shell bash

Teclas función Opcion


Tab Autocompletar, rellena el
nombre del comando o
archivo según las posibles
opciones que conozca
Tab+Tab Muestra todas las opciones
que tiene autocompletar
Arrow Up Sube en la historia de
comandos
Arrow Down Baja en la historia de
comandos
Una vez creada la estructura, quitar el permiso de escritura al archivo11.txt e
intentar concatenarle la cadena “Muy bien gracias”. Volver a reinstaurar el permiso y
repetir la 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 17

Fichero fuente A Fichero fuente B


“.c, .cpp” “.c, .cpp”

COMPILADOR

Biblioteca Biblioteca
Modulo objeto A Modulo objeto B estática A estática B
“.o” “.o” “.a” “.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 Comando Acción


running run comienza la depuración del
programa
step ejecuta un paso, entrando en
funciones
next ejecuta un paso, sin entrar en
funciones
finish termina la ejecución del programa
continue continua la ejecución del programa,
hasta el siguiente breakpoint
data display [exp] muestra el contenido de la variable
“exp” cada vez que el programa se
para
undisplay [exp] quita el comportamiento anterior
print [exp] Muestra el contenido de “exp”
breakpoint break [num_linea] inserta un punto de parada o
“Breakpoint” en la línea
correspondiente
clear [num_linea] Eliminan el breakpoint de la línea
correspondiente
delete break Pregunta si se desea eliminar todos
los breakpoints
status info [opcion] Muestra información acerca de la
opción elegida, por ejemplo “ info
break” muestra los breakpoints.
ninguno quit 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 24

1.14. 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 x1,y1;
float x2,y2;
float r,g,b;
protected:
float 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 InitGL();
void OnKeyboardDown(unsigned char key, int x, int y);
void OnTimer(int value);
void OnDraw();
};

Se solicita al alumno que complete la clase Mundo para obtener el juego del
tenis funcional. Se debe escribir un Makefile para la 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 N posibles
“clientes”
que se
“Retransmisión”
conectan
partido
al servidor
Servidor, en el que para ver el
juegan los dos partido
jugadores con el
mismo teclado

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:

Cliente TCP/IP Servidor


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

Se le asigna una
dirección y un bind()
puerto y se pone
a la escucha listen()

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

Cuando el cliente se conecta al


Se conecta a la socket de conexión que esta
connect() dirección del “Aceptando”, este devuelve un
servidor socket de conexión que es con el
que se realiza la comunicación

send() send()
Comunicación Comunicación
recv() recv()

shutdown()
Cierre Cierre shutdown()
close()
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

El socket de
conexión se
Cliente queda bloqueado
Se crea el socket a la espera
de conexión y “Aceptando una
comunicación conexión”

Conexión Socket de
conexion

Comunicación Comunicación

Cierre
Cierre del socket
de comunicacion

SI
¿Seguir
aceptando
clientes?

NO

Cierre del socket


de conexion

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 El socket de
conexión se
Cliente1 Se crea el socket queda bloqueado
de conexión y a la espera
Se crea comunicación
el socket “Aceptando una
de conexión y conexión”
comunicación

Conexion
Socket de
conexiónN
Conexión
Comunicacion Socket de
conexión 1

Comunicación
Cierre
¿Seguir SI
Cierre aceptando
clientes?

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 47

3. 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 //hilo de
//aceptacion de
OnTimer() //nuevos clientes
{
//tareas while(1)
//animacion {
//accept()
//envio }
//datos
}

//hilo de //hilo de
//recepcion de //recepcion de
//comandos del //comandos del
//jugador1 //jugador2
while(1) while(1)
{ {
//recv() //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 59

4. 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 63
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc,char* argv[])


{
mkfifo("/ruta/MiFifo1",0777);

int pipe=open("/ruta/MiFifo1",O_WRONLY);

char cad[150]=”Hola que tal”;


int ret=write(pipe,cad,strlen(cad)+1);

close(pipe);
unlink("/ruta/MiFifo1");

return 0;
}

Donde:
mkfifo("/ruta/MiFifo1",0777);

crea un archivo con un icono especial en forma de 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 <sys/types.h>
#include <stdio.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>

int main(void)
{
int datos[10]={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 <sys/types.h>
#include <stdio.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>

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

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

int main(int argc,char* argv[])


{
int i;
key_t mi_key=ftok("/bin/ls",12);
int shmid=shmget(mi_key,sizeof(DatosMemCompartida),0x1ff);
DatosMemCompartida* dat
=(DatosMemCompartida*)shmat(shmid,0,0x1ff);
while(1)
{
usleep(25000);
if(dat->jugador==0)

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 73

5. 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:
x x2 x3 xn
ex 1 ...
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:
xn x xn 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:
Nm =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:
Nm =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
n(n 1)
i
i=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 90
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/timeb.h>
#include <memory.h>

int main(int argc, char* argv[])


{
printf("Introduce n=");
int n=0;
scanf("%d",&n);
int* es_primo=new int[n];
int* es_primo2=new int[n];

//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 <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <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 97

6. 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 copa
rojo
radio arbol
altura
tronco
radio
y
x

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 writeChar(x,y,z){x[y++] = z;}
#define writeShort(x,y,z){*((unsigned short*)((char*)&x[y]))=z; y+=2;}
#define writeLong(x,y,z){*((unsigned long *)((char*)&x[y]))=z; y+=4;}
#define writeFloat(x,y,z){*((float *)((char *)&x[y])) = z; y += 4;}
#define writeDouble(x,y,z){*((double *)((char *)&x[y])) = z; y += 8;}

Universidad Politécnica de Madrid -UPM


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

#define readChar(x, y, z) {z = x[y++];}


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

Los tres argumentos de la macro son el buffer o vector de bytes, la 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 de la STL, OK
#include <iostream.h> //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 113

7. 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 114
1 2 7 1 2
1 2 7 8 5 4 8 5 7
8 5 4 6 3 6 3 4
a 6 3 Inicio Objetivo

1 2 7 1 2 7

b 8 5 4 c 8 5
6 3 6 3 4

1 2 7 1 2 7 1 2 7 1 2
8 4 8 5 4 8 5 8 5 7
d 6 5 3
e 6 3
f 6 3 4
g 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-First-
Search 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-en-
profundidad para decidir la mejor jugada en este caso no necesita comprobar si cada
estado nuevo ya ha sido generado con anterioridad con lo que la lista CERRADOS es
innecesaria
El caso del puzzle-8 (figura 1) es el caso opuesto. En cada turno es posible
realizar un movimiento que genera el nodo padre (en el ejemplo de la figura, el
movimiento de la pieza 3 a la izquierda en el estado b, genera el nodo inmediatamente
antecesor a. 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
a
b c c b c
b
d e d e
a
a f
b c a
b c b c
d e
d e d e
f
f 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 119
Nº it. ABIERTOS CERRADOS
a 0 {a} {}

b c 1 {b, c} {a}

2 {d, e, c} {b, a}
d e
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.
Nº it. ABIERTOS CERRADOS
a 0 {a} {}

b c 1 {b, c} {a}

2 {c, d, e} {b, a}
d e
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 5 6=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 2 7 1 2 7
8 4 8 4
6 3 5 6 3 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 ) x2(int) 2MB 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 125
Extracción de un nodo de ABIERTOS (y eliminar)
Generación de sus sucesores
Añadir dichos sucesores a la lista de ABIERTOS
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 (10 6) y bombardear al
servidor. Se considera aquí solamente la rutina generadora de claves posibles.
Este problema puede abordarse de forma trivial mediante un procedimiento
iterativo empleando bucles anidados.; cada bucle genera un 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 10
#define TAM_CLAVE 6

void main()
{
int clave[TAM_CLAVE];

for(int i=0; i< TAM_NUMEROS; i++)


for(int j=0; j< TAM_NUMEROS; j++)
for(int k=0; k< TAM_NUMEROS; k++)
for(int l=0; l< TAM_NUMEROS; l++)
for(int m=0; m< TAM_NUMEROS; m++)
for(int n=0; n< TAM_NUMEROS; n++){
//Generando clave
clave[0]=i;
clave[1]=j;
clave[2]=k;
clave[3]=l;
clave[4]=m;
clave[5]=n;
cout<<i<<j<<k<<l<<m<<n<<endl;
}
}

Este problema puede 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 6
#define TAM_NUMEROS 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, , 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 bd 1
bd
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 129
7.5.2.2. 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.
0 0 0 0

1 1 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 133

8. 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 N-
Reinas) 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 N-
Reinas.

8.2. EL PROBLEMA DE LAS N-REINAS

8.2.1 Historia
El problema de las 8-Reinas consiste en colocar en un tablero de ajedrez de
dimensiones 8x8, ocho reinas tal que ninguna se ataque entre sí de acuerdo con las
reglas del ajedrez. La 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. Sea R la parte entera del resto de N/12
2. Sea L el conjunto de todos los números pares de 2 (incluido) a N
en orden creciente.
3. Si R es 3 o 9 coloque el 2 al final de la lista
4. Añada a L (empezando por el final) el conjunto de números impares
de 1 a N de acuerdo a las siguientes reglas:
a. Si R es 8 intercambie parejas (por ejemplo
3,1,7,5,11,9,15,13...)
b. Si R es 2 intercambie las posiciones del 1 y el 3 y coloque
el 5 al final de L
c. Si R es 3 ó 9 coloque 1 y 3 al final de la lista
manteniendo el orden
5. Coloque la primera reina en la casilla de la primera fila que
indica el primer 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
N N !( N 2 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á:
N N N2
Espacio máximo 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 = 1000 2
marcada por el cuarto bit a uno (la casilla del extremo izquierdo de la fila).

izq = 01002

abajo = 00102
Fnueva izq abajo dcha
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.

izqact = 000102
Factual 1 estadoact = 111002

Fnueva 1 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 4-
Reinas 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 146
#include <stdio.h>
#define N 8 //Max 32

const int TODOUNOS =(1 << N) - 1;


int nSOL;

void FuncRec(int fila, int izq, int abajo, int dcha)


{
int estado, sucesor;

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 149
8.4.3.1. 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 155
8.5.1.1. 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:
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.

8.5.1.2. 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

Вам также может понравиться