En esta última entrega analizaremos algunos de los problemas que se plantean a la hora de desarrollar programas concurrentes.
La programación concurrente (implementada a través de procesos separados o de threads) plantea una serie de inconvenientes respecto del uso de recursos o datos compartidos, que abren un campo de investigación interesantísimo y con muchos puntos aún no resueltos. Presentaremos aquí solamente una introducción a esta problemática, ejemplificando cada situación con nuestro servidor concurrente.
Nota del 19/11/2006: Ahora puede descargar el tutorial completo.
Entregas anteriores
Esta es la última parte de una serie de cuatro artículos.
- Primera parte: Planteo del problema, implementación de un servidor secuencial y de un cliente simple.
- Segunda parte: Implementación de un servidor concurrente mediante procesos y mediante threads.
- Tercera parte: Un cliente en PHP para el acceso a múltiples servidores.
Condiciones de carrera
Supongamos que tenemos un trozo de código como el siguiente:
$x = 10;sub decrementar {
if ($x>0) {
$x--
}
}
En un entorno de ejecución secuencial, la variable $x nunca tomaría valores negativos, independientemente de cuántas veces sea ejecutada la función decrementar.
La situación cambia sensiblemente si la función decrementar se ejecuta en varios threads distintos y $x es una variable compartida. Supongamos que tenemos dos threads llamados t1 y t2. el valor actual de $x es «1» y se presenta la siguiente situación:
- El thread t1 evalúa la condición del if. Esta resulta verdadera, por lo tanto procederá a la ejecución del cuerpo.
- En ese momento se interrumpe t1 y comienza la ejecución de t2.
- t2 también evalúa la condición del if como verdadera y procede a la ejecución del cuerpo.
- Se interrumpe la ejecución de t2, continuando la de t1.
- t1 decrementa el valor de $x, quedando este en «0«.
- Se interrumpe la ejecución de t1, continuando la de t2.
- t2 decrementa el valor de $x, quedando este en «-1«.
El resultado de esta secuencia de ejecución es que $x queda con el valor «-1«. Como podemos ver, el valor final es dependiente de la forma en que se van ejecutando las sentencias de los distintos threads.
Cuando el resultado de la ejecución concurrente de varios procesos o threads depende de la secuencia que se siga, se dice que existe una «condición de carrera» («race condition«, en inglés).
La solución, como veremos más adelante, es garantizar la exclusión mutua, impidiendo que un proceso sea interrumpido en medio de un bloque que deba ser considerado «atómico«. En el ejemplo anterior, la sentencia if completa (su condición y su cuerpo) debería ser ejecutada completamente para garantizar la corrección del resultado.
Bloqueo mutuo (deadlock)
Esta es una situación (también llamada «abrazo mortal«) en la cual un conjunto de procesos se encuentran bloqueados (ninguno puede avanzar en su ejecución) debido a que todos esperan recursos que están tomados por otro proceso del conjunto. Este problema es planteado de forma genérica de varias maneras (la variante más conocida es la de los «filósofos comensales» o «cena de los filósofos«).
La siguiente representación gráfica (aunque lejana a la programación) ilustra de forma muy clara este tipo de situaciones:
Supongamos, por ejemplo, que al abrir un archivo para escritura este queda bloqueado, impidiendo a cualquier otro proceso su apertura. Si tenemos dos programas como los siguientes:
Programa p1
open(ARCH1, ">archivo1");
open(ARCH2, ">archivo2");
# Hacer algo con ARCH1 y ARCH2
close(ARCH2);
close(ARCH1);
Programa p2
open(ARCH2, ">archivo2");
open(ARCH1, ">archivo1");
# Hacer algo con ARCH1 y ARCH2
close(ARCH1);
close(ARCH2);
Puede ocurrir que la ejecución de p1 se detenga inmediatamente después de abrir el archivo1 y comience la ejecución de p2. Este último podrá abrir el archivo2, pero al intentar abrir el archivo1, como este está bloqueado por p1, quedará suspendido hasta que este se libera (pero permanecerá bloqueando a archivo1).
La ejecución de p1 no podrá continuar, porque necesita abrir un archivo que está siendo bloqueado por p2, y viceversa).
(Una nota curiosa: aunque parezca muy poco probable la ocurrencia de este tipo de situaciones, suelen darse en la realidad, generalmente ante la incredulidad del desprevenido programador que ve cómo todas las estaciones de trabajo que acceden a su sistema se quedan congeladas hasta que se reinicia una de ellas…)
Aunque en este ejemplo la forma de evitar el bloqueo mutuo es que ambos programas abran los archivos archivo1 y archivo2 en el mismo orden, en general este tipo de situaciones es bastante difícil de evitar. Para ello existen diversas técnicas, tales como el algoritmo del banquero, propuesto por Edsger W. Dijkstra.
Complicando nuestro problema: añadiendo un recurso compartido
Volviendo a nuestro problema original, vamos a añadir un recurso compartido para ver cómo resolvemos el problema de su acceso por parte de los distintos procesos o threads, según el caso.
Añadiremos un archivo en el cuál se irán contando las conexiones atendidas por el servidor. Supondremos que el conteo debe hacerse al finalizar la conexión, por lo cual deberá ser realizado por el proceso o thread encargado del tratamiento de la misma.
Podríamos definir una función llamada registrar como sigue:
sub registrar {
open (REG, "+<accesos.log");
$accesos = <REG>;
$accesos++;
seek(REG, 0, 0);
print REG $accesos;
close REG;
}
Esta función abre el archivo «accesos.log«, lee su contenido (inicialmente debería ser 0) asignándoselo a la variable $accesos. Luego incrementa el valor de esta última, posiciona el archivo al comienzo y escribe en él el nuevo valor.
Si añadiéramos a nuestra función atender invocación a registrar, podría darse el caso de que dos o más procesos (o threads) accedieran al contenido del archivo leyendo el mismo valor. Esto causaría un error en el valor posteriormente escrito (vulgarmente se dice que los procesos lo «pisarían«). Esto es, evidentemente una condición de carrera.
A continuación analizaremos las dos versiones de nuestro servidor concurrente, ejemplificando dos soluciones diferentes para nuestro problema.
Solución en el servidor con fork
La solución más simple aplicable a nuestro servidor concurrente basado en procesos independientes, es la utilización de la función de bloqueo de archivos que nos provee el sistema operativo. A través de ella, podemos lograr que cuando un proceso requiera la apertura del archivo «accesos.log«,lo bloquee impidiendo su apertura por parte de otro proceso (el que será suspendido hasta que el primero lo libere).
De esta forma, el servidor quedaría como puede verse a continuación:
servidor_fork_lock
#!/usr/bin/perl -wuse IO::Socket;
use Fcntl ':flock';
require "procesar_lock.pl";
inic_reg();
require "procesar.pl";
# Ignora la señal de terminación de los hijos
$SIG{CHLD} = 'IGNORE';
$puerto = 2222;
$servidor = IO::Socket::INET->new(Proto =>'tcp',
LocalPort=>$puerto,
Listen =>SOMAXCONN,
Reuse =>1)
or die "Error al iniciar el servidor";print "[Aceptando conexiones en puerto $puerto]n";
while ($conexion = $servidor->accept()) {
if (fork() == 0) {
# Proceso hijo
close $servidor;
atender($conexion);
exit;
} else {
# Proceso padre
close $conexion;
}
}
procesar_lock.pl
sub inic_reg {
open (REG, ">accesos.log");
print REG "0";
close REG;
}sub registrar {
open (REG, "+<accesos.log");
flock(REG, LOCK_EX);
$accesos = <REG>;
$accesos++;
seek(REG, 0, 0);
print REG $accesos;
flock(REG, LOCK_UN);
close REG;
}sub ejecutar($$) {
($comando, $conexion) = @_;
$resultado = '';
if ($comando eq 'fecha') {
$resultado = localtime() . "n";
} elsif ($comando eq 'usuarios') {
$resultado = `who`;
}
if ($resultado) {
print $conexion "OKn";
print $conexion $resultado;
print $conexion "FINn";
} elsif ($comando eq 'salir') {
print $conexion "Adios.n";
} else {
print $conexion "ERRn";
}
}sub atender($) {
$conexion = shift;
$ip = $conexion->peerhost;
print "[Conexión establecida desde $ip]n";
print $conexion "Bienvenido.n";
do {
if ($comando = <$conexion>) {
$comando =~ s/rn|n//g;
ejecutar($comando, $conexion);
} else {
$comando = 'salir';
}
} until ($comando eq 'salir');
$conexion->shutdown(2);
registrar;
print "[Conexión finalizada desde $ip]n";
}1;
La sentencia «flock(REG, LOCK_EX)» solicita un bloqueo exclusivo (LOCK_EX). De ejecutarse exitosamente, el sistema operativo denegará a cualquier otro proceso un bloqueo sobre este archivo. En caso contrario, el proceso será suspendido hasta que pueda realizarse el bloqueo exitosamente. Por contraparte, la sentencia «flock(REG, LOCK_UN)» libera el bloqueo del archivo (LOCK_UN), permitiendo su obtención por parte de otro proceso.
De esta manera nos aseguramos que, una vez que un proceso ha abierto el archivo y obtiene un bloqueo sobre él, ningún otro pueda leerlo hasta que el primero haya escrito el nuevo valor y lo libere.
Uso de semáforos para la sincronización
Si bien el bloqueo de archivos es una buena solución, hay otros casos (como el de variables compartidas u operaciones complejas) para los que no es aplicable. Una alternativa muy utilizada es el uso de semáforos.
Un semáforo es una variable especial protegida que se utiliza para permitir o denegar el acceso a recursos compartidos. Esta técnica fue inventada por Edsger Dijkstra.
En su forma más simple (semáforo binario) el semáforo puede estar en dos estados: alto o bajo. Incialmente el semáforo se encuentra en estado alto. Un proceso que desee acceder al recurso compartido, debe bajarlo (si ya se encuentra bajo el proceso será suspendido hasta que vuelva a estar alto). Cuando ha terminado de utilizar el recurso compartido, el proceso deberá levantar nuevamente el semáforo.
(Si pensamos en el ejemplo del cruce de 2 calles, la idea resulta evidente.)
Solución en el servidor con threads
Aunque aquí también podríamos utilizar el bloqueo de archivos, vamos a ejemplificar la solución utilizando un semáforo. El código del servidor con threads quedaría como sigue:
servidor_threads_sem
#!/usr/bin/perl -wuse threads;
use Thread::Semaphore;
use IO::Socket;
require "procesar_sem.pl";$puerto = 2222;
$servidor = IO::Socket::INET->new(Proto =>'tcp',
LocalPort=>$puerto,
Listen =>SOMAXCONN,
Reuse =>1)
or die "Error al iniciar el servidor";print "[Aceptando conexiones en puerto $puerto]n";
inic_reg();
$semaforo = new Thread::Semaphore;while ($conexion = $servidor->accept()) {
threads->create(&atender, $conexion, $semaforo)
}
procesar_sem.pl
sub inic_reg {
open (REG, ">accesos.log");
print REG "0";
close REG;
}sub registrar($) {
$semaforo = shift
$semaforo->down;
open (REG, "+<accesos.log");
flock(REG, LOCK_EX);
$accesos = <REG>;
$accesos++;
seek(REG, 0, 0);
print REG $accesos;
flock(REG, LOCK_UN);
close REG;
$semaforo->up;
}sub ejecutar($$) {
($comando, $conexion) = @_;
$resultado = '';
if ($comando eq 'fecha') {
$resultado = localtime() . "n";
} elsif ($comando eq 'usuarios') {
$resultado = `who`;
}
if ($resultado) {
print $conexion "OKn";
print $conexion $resultado;
print $conexion "FINn";
} elsif ($comando eq 'salir') {
print $conexion "Adios.n";
} else {
print $conexion "ERRn";
}
}sub atender($$) {
($conexion, $semaforo) = @_;
$ip = $conexion->peerhost;
print "[Conexión establecida desde $ip]n";
print $conexion "Bienvenido.n";
do {
if ($comando = <$conexion>) {
$comando =~ s/rn|n//g;
ejecutar($comando, $conexion);
} else {
$comando = 'salir';
}
} until ($comando eq 'salir');
$conexion->shutdown(2);
registrar($semaforo);
print "[Conexión finalizada desde $ip]n";
}1;
El módulo de Perl «Thread::Semaphore» nos permite utilizar semáforos. El programa principal inicializa el semáforo «$semaforo«, que luego es pasado como parámetro a la función «atender» en cada uno de los threads disparado por este. Como todos los threads comparten el mismo semáforo, cada vez que uno de ellos proceda a actualizar el contenido del archivo «accesos.log«, impedirá a los otros la ejecución de esta tarea, hasta haberla finalizado.
Concluyendo
En esta última entrega, hemos realizado una breve introducción a algunos de los problemas que se presentan a la hora de construir programas concurrentes o paralelos.
Hemos añadido un recurso compartido a nuestro sistema y explorado dos técnicas que nos permiten sincronizar los procesos o threads para evitar condiciones de carrera.
Nota final del autor
Es mi deseo que esta serie de artículos haya contribuido a iniciar a lector en dos áreas muy útiles e interesantes de la programación como lo son el desarrollo de programas utilizando sockets como mecanismo de comunicación y la construcción de aplicaciones concurrentes.
Todos los comentarios (sobre todo críticas y sugerencias) son más que bienvenidos. Espero, en un futuro no muy lejano, seguir ampliando algunos temas específicos de estas áreas.
Desde ya, mi más sincero agradecimiento por el tiempo dedicado a la lectura de este artículo.
Un tutorial cojonudo tío, espero ansioso proximas entregas sobre este tipo de entresijos que son explicados de forma oscura y malévola por palurdos profesores de instituto y universidad por darle más importancia a lo que enseñan.
Un aplauso.
Gracias, la verdad es que no sabía por dónde empezar con la programación de sockets en perl y mira tú por donde, nos has sido un profesor cojonudo ;) . Sencillo y clarito, lo mejor para empezar. Ahora ya podré seguir con la configuración de la red, ya que tenía parado el desarrollo de unos scripts por culpa de esto. Mil gracias!! ;) y ojalá sigas aportando buenos tutoriales como estos.
El mejor con diferencia,codigo facil,todo explicado y
conceptos extras.
Muchisimas gracias!!!!
Veo que te encanta Edsger Dijkstra, yo lo estudio en Matematica Discreta y parece un tio genial.
PS QUE MAL QUE NO VENGA LA INFORMACION QUE ES REQUERIDA POR EL USUARIOQ UE VISITA ESTO
buenisimo me gusto la forma simple en la que se explicaron temas muy poderosos como son las aplicaciones clientes y servidores. Ademas del uso de protocolo. pero me quedo una duda en caso de no seguirse el protocolo ya sea malintencionadamente o por algun error, de que forma podrias digamos matarse la conexion. Supongamos que quiero ver la hora de un sistema y mi cliente lleva 5 minutos y no recive respuesta. que mecanismo se usa.
gran aporte felicitaciones