Beruflich Dokumente
Kultur Dokumente
Concurrente y Distribuida
PRÓLOGO 7
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.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
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.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
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
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
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.
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
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.
Figura 1-1. Objetivo del capítulo: Desarrollo del juego del Tenis en modo local
#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
#endif //_MIS_FUNC_H_INCLUDED
/*
* archivo: misfunc.c
*/
#include “misfunc.h”
#include <math.h>
COMPILADOR
Biblioteca Biblioteca
Modulo objeto A Modulo objeto B estática A estática B
“.o” “.o” “.a” “.a”
LINKADO
EJECUCION
Proceso en
ejecución
Figura 1-2. Proceso de creación de un ejecutable
CC=gcc
CFLAGS= -g
LIBS= -lm
OBJS=misfunc.o principal.o
prueba: $(OBJS)
$(CC) $(OBJS) $(LIBS) –o prueba
clean:
rm –f *.o prueba
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.
A partir de aquí comienzan las reglas, cada regla tiene la siguiente estructura:
objetivo (target): prerequisitos o dependencias
comando
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
Vector2D centro;
Vector2D velocidad;
float radio;
#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();
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.
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
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
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”
send() send()
Comunicación Comunicación
recv() recv()
shutdown()
Cierre Cierre shutdown()
close()
close()
#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;
//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;
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;
int r=recv(socket_conn,cad,length,0);
std::cout<<"Rec: "<<r<<" contenido: "<<cad<<std::endl;
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;
//escucha
bind(socket_server,(struct sockaddr *) &server_address,len);
// Damos como maximo 5 puertos de conexion.
listen(socket_server,5);
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;
}
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);
private:
int sock;
};
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;
}
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;
}
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;
}
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;
}
}
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;
}
}
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.
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
int main()
{
Socket servidor;
servidor.InitServer("127.0.0.1",12000);
while(1)
{
Socket conn=servidor.Accept();
conn.Send(cad,length);
conn.Close();
}
servidor.Close();
return 1;
}
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
for(i=0;i<5;i++)
conexiones[i].Send(cad,length);
for(i=0;i<5;i++)
conexiones[i].Close();
servidor.Close();
return 1;
}
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;
server.InitServer("127.0.0.1",12000);
conn=server1.Accept();
}
client.Connect("127.0.0.1",12000);
}
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);
}
}
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.
return 0;
}
jugador1.Mueve(0.025f);
jugador2.Mueve(0.025f);
esfera.Mueve(0.025f);
…
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
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()
} }
Socket servidor;
std::vector<Socket> conexiones;
void GestionaConexiones();
…
};
Socket servidor;
std::vector<Socket> conexiones;
std::vector<std::string> nombres;
void GestionaConexiones();
int puntos[2];
};
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);
}
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);
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.
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
Memoria
compartida
TCP/IP
Logger
FIFO Bot
RED
Servidor Cliente
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
int pipe=open("/ruta/MiFifo1",O_WRONLY);
close(pipe);
unlink("/ruta/MiFifo1");
return 0;
}
Donde:
mkfifo("/ruta/MiFifo1",0777);
char cad[150];
read(pipe,cad,sizeof(cad));
printf("Cadena=%s\n",cad);
close(pipe);
return 1;
}
#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);
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);
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);
int main(void)
{
int datos[10];
while(1)
{
memcpy(datos,punt,sizeof(datos));
for(i=0;i<10;i++)
printf("%d ",datos[i]);
printf("\n");
}
shmdt(punt);
return 1;
}
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
};
DatosMemCompartida* datos;
int shmid;
…
};
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"
}
if(dat->jugador==1)
{
}
}
shmdt(dat);
return 0;
}
Ejercicio: Completar el “bot” para que tome una decisión del movimiento a realizar
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!
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);
}
tiempo();
for(int i=0;i<100000;i++)
exponencial(num);
for(i=0;i<100000;i++)
exponencial2(num);
tiempo();
printf("la exp.de %lf es %lf\n",num,exponencial2(num));
return 0;
}
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;
}
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
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
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;
}
Three3DType a,b;
...
b = a;
memset(a,0,sizeof(a));
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:
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;
};
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]);
}
}
//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;
}
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);
}
}
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>
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;
}
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
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
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++.
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.
class Bosque
{
public:
Bosque();
void Aleatorio(int num_arboles);
void Dibuja();
void PideDatos();
void Imprime();
int numero;
Arbol arbol[MAX_ARBOLES];
};
class Arbol
{
public:
void Dibuja();
void PideDatos();
void Imprime();
float x;
float y;
Cilindro tronco;
Esfera copa;
};
float radio;
unsigned short verde,rojo,azul;
};
class Cilindro
{
public:
void Dibuja();
void Imprime();
void PideDatos();
Cilindro();
virtual ~Cilindro();
float radio;
float altura;
};
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.
azul
verde copa
rojo
radio arbol
altura
tronco
radio
y
x
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");
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));
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.
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);
}
char buffer[3000];
int cont=0;
bosque.Write(buffer,cont);
#include <iostream>
using namespace std;
class Bosque
{
friend istream& operator>>(istream& s, Bosque& b);
friend ostream& operator<<(ostream& s, const Bosque& b);
…
class Arbol
{
friend istream& operator>>(istream& s, Arbol& a);
friend ostream& operator<<(ostream& s, const Arbol& a);
…
-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
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.
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.
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
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:
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.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
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.
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}
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}
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).
return n*factorial(n-1);
}
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.
void ProcRecursivo(int k)
{
int vector[MAX_SIZE][MAX_SIZE];
void main()
{
cout<<"Comienzo de recursion"<<endl;
try{
ProcRecursivo(0);
}
catch(...){
cout<<"Stack Overflow"<<endl;
}
cout<<"Fin de recursion"<<endl;
}
void main()
{
int clave[TAM_CLAVE];
int clave[TAM_CLAVE];
void main()
{
FuncRec(0);
}
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.
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
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;
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;
int nivel=-1;
int permuta[N];
void main(){
for (int i = 0; i < N; i++)
permuta[i] = 0;
FuncRec(0);
}
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
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.
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.
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
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
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++;
c2 = 0x7101110001
c1 & c2 = 0x4101000001
c2 = 0x7101110001
c1 &~ c2 = 0x0400000100
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);
}
izqact = 000102
Factual 1 estadoact = 111002
estadoact = 00002
Factual
sucesor = 00102
Fnueva izq abajo dcha
izqn = sucesor << 1 = 01002
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;
}
+
+
+
Cliente
Cliente
+
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.
int max_size=100;
if(0!=ReceiveMsg(cad,&max_size))
return -1;
cout<<cad<<endl;
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;
}
int main()
{
MyLiveClient client_array[NUM_PARTES];
//Enviar particiones
for(i=0; i<NUM_PARTES; i++)
client_array[i].EnviarNReinas(N ,i);
//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;
}
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.
//Deserialización
int N, posq;
char message[100]="";
char nombre[100];
//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;
}
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)
};
m_sol = 0;
m_TODOUNOS = (1 << m_N) - 1;
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
}
}
Figura 9. Traza de la sesión del servidor remoto correspondiente a la traza del lado
del cliente mostrada en la Figura 8.