Autor: Javier Smaldone
Esta es una recopilación de la serie de cuatro artículos publicados en mi blog (http://blog.smaldone.com.ar). Allí podrá encontrar, además, comentarios de otros lectores.
Este tutorial se distribuye bajo una Licencia Creative Commons Atribución-No Comercial-Compartir Obras Derivadas Igual 2.5 Argentina.
Este es un tutorial introductorio sobre programación para redes (usando sockets) y programación concurrente. El objetivo es presentar una serie de conceptos que iremos explorando progresivamente:
En los ejemplos utilizaremos el lenguaje Perl, por su simplicidad para este tipo de tareas, pero el objetivo es que estos artículos sean de utilidad aún para quienes no conozcan dicho lenguaje. Por esto no nos limitaremos a exponer ejemplos, sino que desarrollaremos cada concepto independientemente del lenguaje de programación.
Implementaremos un servidor que reciba conexiones de clientes a través de la red y permita ejecutar comandos para realizar distintas tareas.
Por simplicidad, definiremos solamente los siguientes comandos:
Como primera medida, deberemos diseñar el protocolo que seguirán el cliente y el servidor para comunicarse e interactuar. Un protocolo puede verse como una secuencia de pasos y convenciones, de acuerdo a las cuales se llevará a cabo la comunicación.
En este caso, estamos diseñando un protocolo de aplicación (de alto nivel, distinto a los protocolos de enlace o transporte de información), que permita que ambas aplicaciones (el cliente y el servidor) intercambien información, ejecuten comandos, etc. En esta situación, siempre es recomendable que el protocolo sea lo más cercano a la comunicación humana: usando mensajes de texto y sin codificaciones extrañas.
La comunicación se llevará a cabo de la siguiente manera (cada envío, del servidor o del cliente, finalizará con un salto de línea):
Se llama proceso a una instancia en ejecución de un programa. El concepto de programa es estático: se refiere al archivo ejecutable, en tanto que el de proceso es dinámico. De esta forma, puede haber varias instancias en ejecución (procesos) del mismo programa.
Un socket es un mecanismo utilizado para comunicar dos procesos a través de la red, usando algún protocolo de transporte. Para la comunicación entre el servidor y el cliente, utilizaremos el protocolo TCP, por ser el más adecuado para este tipo de sistema (a diferencia de UDP, es orientado a conexiones).
La comunicación a través de sockets con TCP se realiza de la siguiente forma:
Nuestra primera solución al problema será a través de un servidor secuencial. El programa servidor abrirá un socket en un puerto TCP determinado y quedará a la espera de conexiones entrantes. Al recibir una conexión, interactuará con el cliente según el protocolo que hemos definido, hasta la desconexión de este último. Una vez finalizada la conexión, volverá al estado de espera hasta recibir una nueva solicitud.
Si el servidor se encuentra interactuando con un cliente y un segundo cliente intenta realizar otra conexión, esta será demorada hasta finalizada la actual. En casi cualquier situación real esto sería inaceptable, pero momentaneamente vamos a asumir esta limitación. (En la segunda sección de este artículo implementaremos un servidor que pueda recibir varias conexiones concurrentes.)
#!/usr/bin/perl -w
use IO::Socket;
require "procesar.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";
while ($conexion = $servidor->accept()) {
atender($conexion);
}
El programa utiliza el módulo (biblioteca) IO::Socket que provee una implementación de sockets para el lenguaje Perl.
La variable $servidor es un socket TCP abierto en el puerto indicado por $puerto (en este ejemplo, 2222). En los sistemas UNIX para utilizar puertos menores a 1025 se requiere permisos de root. La constante SOMAXCONN representa la máxima cantidad de conexiones aún no atendidas que puede "encolar" el sistema operativo.
El cuerpo del ciclo principal se ejecuta cada vez que se recibe una conexión. Esto se logra mediante la ejecución de $servidor->accept(). La función atender está definida en el script procesar.pl, que podemos ver a continuación.
sub ejecutar($$) {
($comando, $conexion) = @_;
$resultado = '';
if ($comando eq 'fecha') {
$resultado = localtime() . "\n";
} elsif ($comando eq 'usuarios') {
$resultado = `who`;
}
if ($resultado) {
print $conexion "OK\n";
print $conexion $resultado;
print $conexion "FIN\n";
} elsif ($comando eq 'salir') {
print $conexion "Adios.\n";
} else {
print $conexion "ERR\n";
}
}
sub atender($) {
$conexion = shift;
$ip = $conexion->peerhost;
print "[Conexión establecida desde $ip]\n";
print $conexion "Bienvenido.\n";
do {
if ($comando = <$conexion>) {
$comando =~ s/\r\n|\n//g;
ejecutar($comando, $conexion);
} else {
$comando = 'salir';
}
} until ($comando eq 'salir');
$conexion->shutdown(2);
print "[Conexión finalizada desde $ip]\n";
}
1;
La función atender toma como parámetro la conexión recibida. Muestra la dirección IP del cliente conectado, envía a este el mensaje de bienvenida e inicia un ciclo que finaliza al recibir el comando "salir" o al cerrarse la conexión. (Nótese que aunque según el protocolo diseñado el cliente debe enviar el comando "salir" antes de finalizar la conexión, debemos tener en cuenta que esta puede cerrarse subrepticiamente.)
La sentencia "$comando = <$conexion>" lee una línea desde el socket de la conexión y la asigna a la variable $comando, devolviendo verdadero si la lectura fue exitosa y falso si ocurrió algún error (por ejemplo, en caso de haberse cerrado la conexión).
La sentencia "$comando =~ s/\r\n|\n//g" utiliza una expresión regular para eliminar el fin de línea al final de la cadena leída (este puede constar del caracter "\n" o de los caracteres "\r\n").
Una vez obtenido el comando a ejecutar, se llama a la función ejecutar que implementa los comandos que hemos definido para nuestro servicio. Si desearamos añadir nuevas funciones a nuestro servidor, bastaría con extender esta función para implementarlas.
Finalmente, la sentencia "$conexion->shutdown(2)" cierra el socket, finalizando la conexión del lado del servidor.
Una aclaración importante: Cuando utilizamos la función print seguida de una cadena, esta es enviada a la salida estándar (normalmente, la consola desde donde ejecutamos el programa), en tanto que si anteponemos a la cadena el socket (la variable "$conexion", en nuestro caso), la cadena es enviada (escrita) a través de él.
Si ejecutamos el servidor (para lo cual previamente deberemos darle permisos de ejecución, por ejemplo "755"), veremos el siguiente texto en la consola:
[Aceptando conexiones en puerto 2222]
La forma más simple de conectarnos al servidor para ejecutar algunos comandos es utilizando un cliente de telnet (si bien no usaremos el servicio telnet, un cliente de este tipo sirve para realizar conexiones tcp a cualquier puerto). En una nueva consola ejecutamos:
telnet localhost 2222
En la consola del servidor veremos el mensaje:
[Conexión establecida desde 127.0.0.1]
En tanto que en la consola donde ejecutamos el cliente telnet veremos lo siguiente:
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Bienvenido.
Las primeras líneas son producidas por el cliente telnet, en tanto que el mensaje "Bienvenido." es el enviado por el servidor al iniciarse la conexión. Podemos probar la ejecución de comandos, por ejemplo (el texto resaltado en itálica es el ingresado por nosotros):
fecha
OK
Mon Nov 6 05:04:02 2006
FIN
usuarios
OK
javier tty1 2006-11-05
15:55
javier pts/0 2006-11-05
16:00 (:0.0)
javier pts/1 2006-11-05
16:41 (:0.0)
FIN
noexiste
ERR
salir
Adios.
Connection closed by foreign host.
Observamos que todo se desarrolla de acuerdo al protocolo de aplicación que hemos diseñado (inclusive la respuesta de error ante un comando inexistente). Si en medio de la sesión intentamos el acceso con otro cliente de telnet, veremos que el servidor no responde hasta que se haya finalizado la conexión actual, tal como lo habíamos previsto.
Desarrollaremos ahora un cliente que implemente el protocolo definido y que haga uso del servidor.
#!/usr/bin/perl
use IO::Socket;
if (! defined $ARGV[2]) {
print STDERR "Uso: cliente <servidor> <puerto> <comando>\n";
exit 1;
}
($servidor, $puerto, $comando) = @ARGV;
$conexion = new IO::Socket::INET(PeerAddr => $servidor,
PeerPort => $puerto,
Proto => 'tcp')
or die "Error al conectar con $servidor";
$mensaje = <$conexion>;
print $conexion "$comando\n";
$estado = <$conexion>;
if ($estado eq "OK\n") {
$salida = <$conexion>;
while ($salida ne "FIN\n") {
print $salida;
$salida = <$conexion>;
}
} elsif ($estado eq "ERR\n") {
print "Comando no válido\n";
} else {
print "Error en el servidor\n";
}
print $conexion "salir\n";
$mensaje = <$conexion>;
$conexion->shutdown(2);
Nuestro cliente toma como parámetros la dirección IP del servidor, el puerto a utilizar y el comando a ejecutar. Primero controla que se le hayan proporcionado los parámetros correctos y luego abre un socket TCP (como cliente) hacia la IP y el puerto especificados.
De aquí en más, el cliente debe seguir la especificación del protocolo. De otra manera no podrá sincronizarse con el servidor para llevar adelante la secuencia de pasos apropiada. Por ejemplo, antes de enviar el comando a ejecutar, deberá recibir el mensaje de bienvenida (aunque luego no haga nada con él). De la misma forma, luego de enviar el comando, deberá leer el estado que informa el servidor ("OK" o "ERR"), para saber si luego deberá leer la salida de su ejecución.
Si el comando produjo el mensaje "OK", luego seguirá el resultado del mismo, hasta la recepción del texto "FIN". En cualquier caso, el cliente deberá cerrar la conexión enviando el comando "salir" y recibiendo el mensaje de despedida (aunque, como ya hemos visto al implementar el servidor, el no cumplimiento de esto no debería significar ningún inconveniente).
A continuación, un ejemplo de la ejecución del cliente con el comando "usuarios":
javier@obelix:~$ ./cliente localhost 2222 usuarios
javier tty1 2006-11-05
15:55
javier pts/0 2006-11-05 16:00
(:0.0)
javier pts/1 2006-11-05 16:41
(:0.0)
javier@obelix:~$
Hasta aquí hemos avanzado sobre los conceptos básicos de la programación de aplicaciones cliente/servidor usando sockets usando el protocolo TCP.
Hemos diseñado un pequeño protocolo de aplicación que se ajusta a los requerimientos de nuestro problema, y construido un servidor secuencial y un cliente muy simples que lo implementan.
Una lección adicional que debemos rescatar: el servidor debe implementar el protocolo a seguir, previendo que el cliente puede no respetarlo (en nuestro caso, por ejemplo, este último podría cerrar la conexión en cualquier momento). A la hora de construir un cliente, debemos apegarnos al protocolo tanto como nos sea posible.
En el próximo artículo de esta serie, implementaremos un servidor capaz de atender a varios clientes de forma simultanea. Con esto nos introduciremos en el mundo de las aplicaciones concurrentes o paralelas.
Como vimos anteriormente, un proceso es una instancia en ejecución de un programa. Cada proceso es identificado por un valor numérico único asignado por el sistema operativo en el momento de su creación, llamado PID (por "process identifier"). En los sistemas operativos derivados de UNIX, la creación de un nuevo proceso se realiza a través de la llamada al sistema fork.
Cuando un proceso invoca la llamada al sistema fork el sistema operativo crea un nuevo proceso, idéntico al anterior (tanto en su código ejecutable como en el valor de sus datos). Luego, fork devolverá como resultado el valor 0 en el nuevo proceso creado (que comenzará su ejecución en la instrucción inmediata a la invocación de fork) y el valor del PID del nuevo proceso en el proceso invocante.
Veamos un ejemplo muy sencillo:
#!/usr/bin/perl
$pid = fork();
if ($pid == 0) {
print "Soy el proceso hijo\n";
} else {
print "Soy el proceso padre\n";
}
print "Fork devolvió: $pid\n";
La salida de la ejecución de este programa sería similar a la siguiente:
Soy el proceso hijo
Fork devolvió: 0
Soy el proceso padre
Fork devolvió: 3839
Como podemos ver, la primera línea ejecuta fork. A partir de allí se crea un nuevo proceso (hijo) idéntico al anterior (padre). Cuando comenza la ejecución del proceso hijo, se asigna a $pid el valor devuelto por fork (0). Al retomarse la ejecución del padre, se asigna a $pid el valor del PID del proceso hijo. (Nótese cómo cada proceso ejecuta la rama correspondiente de la sentencia if y ambos ejecutan el último print.)
En rigor este ejemplo es incorrecto. Al finalizar el proceso hijo, enviará una señal a través del sistema operativo, notificando al padre de este evento para posibilitar que este último pueda examinar su estado final. Por esto el proceso padre debería esperar la finalización del hijo, ya que en caso contrario este último quedaría en un estado llamado "zombie" (se denomina así a un proceso que ha finalizado su ejecución, pero todavía ocupa lugar en la tabla de procesos del sistema operativo). Esto no afecta demasiado en este ejemplo (el proceso padre finaliza enseguida), pero puede impactar seriamente en sistemas de gran envergadura.
Modificaremos ahora el servidor desarrollado inicialmente, para que al recibir una conexión cree un nuevo proceso que se encargue de atender los requerimientos del cliente conectado.
Las funciones agrupadas en el archivo procesar.pl (que realizan el tratamiento y la ejecución de los comandos del cliente) no cambian, ya que no hemos alterado el protocolo. El programa principal del servidor sería el siguiente:
#!/usr/bin/perl -w
use IO::Socket;
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;
}
}
La sentencia "$SIG{CHLD} = 'IGNORE'" permite que el proceso padre ignore las señales de finalización enviadas por los hijos, para no generar procesos "zombies".
Al recibir una conexión, se invoca a fork. El proceso hijo cierra su copia de $servidor (ya que no permanecerá esperando conexiones), atiende la conexión del cliente (como en la versión anterior, a través de la función atender) y luego finaliza su ejecución. El proceso padre cierra su copia del socket $conexion (la conexión será atendida por el hijo) y vuelve al comienzo del ciclo, esperando nuevas conexiones.
De esta forma, tan pronto es creado el proceso hijo, el servidor vuelve a estar disponible para aceptar y procesar nuevas conexiones.
Una forma de observar el nuevo comportamiento es intentar dos o más conexiones simultaneas usando un cliente telnet, tal como lo hicimos en la primera sección.
El uso de fork puede resultar muy costoso. La creación de un nuevo proceso, idéntico al anterior, involucra la copia de los segmentos de código y datos de este. Si el proceso a clonar es demasiado grande, esto puede suponer una gran carga para el sistema (en nuestro ejemplo, además, un retraso en la recepción de la próxima conexión).
Una alternativa al uso de fork para lograr la ejecución concurrente es el uso de los llamados "hilos de ejecución" o "threads". Los threads son secuencias de ejecución de un mismo proceso y, como tales, comparten sus datos (variables, archivos abiertos, sockets, etc.). Esto significa que, por ejemplo, si en un thread se modifica el valor de una variable, los demás threads accederán al valor modificado.
Esta alternativa representa un ahorro considerable de recursos e incrementa sensiblemente la eficiencia en muchos casos, pero puede requerir de grandes cuidados para evitar los problemas del acceso a recursos compartidos. (En la próxima sección entraremos en mayor detalle respecto de esto.)
Por citar un caso real, el servidor web Apache utiliza, en su versión 1.x, un esquema basado en el uso de fork (es decir, para cada nueva conexión, dispara un nuevo proceso). Esto es inaceptable en sistemas de gran escala, por lo cual en la versión 2.x existe la posibilidad de atender cada requerimiento usando threads.
Implementaremos ahora una tercera versión de nuestro servidor, esta vez usando threads para lograr el procesamiento concurrente de las conexiones. Nuevamente, el archivo procesar.pl no se verá alterado. A continuación, el código del servidor:
#!/usr/bin/perl -w
use threads;
use IO::Socket;
require "procesar.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";
while ($conexion = $servidor->accept()) {
threads->create(\&atender, $conexion)
}
Lo primero que podemos apreciar es que esta versión es muy similar al servidor secuencial desarrollado inicialmente. Las diferencias radican en que se ha incluido el módulo threads de Perl y que al recibir una nueva conexión, en vez de ejecutar la función atender se crea un nuevo thread mediante el método threads->create. Este recibe como parámetros una referencia a la función a ejecutar (\&atender) y el parámetro con que se invocará a esta última ($conexion).
La ejecución de la función atender como un nuevo thread continua paralelamente a la ejecución del resto del proceso (que vuelve a esperar una nueva conexión), hasta la finalización de la misma.
Hemos reimplementado nuestro servidor para aceptar conexiones de forma simultánea usando procesos concurrentes. Haciendo esto, hemos analizado los conceptos principales asociados a la creación y ejecución de procesos mediante la llamada al sistema fork.
Luego hemos reimplementado el servidor concurrente, esta vez usando threads, para obtener un programa más eficiente.
En ningún momento ha sido necesaria la modificación de las funciones desarrolladas para procesar los requerimientos de los clientes, ni el pequeño cliente construido anteriormente, ya que no hemos modificado el protocolo ni añadido nuevas funciones al servidor.
Aunque hemos comenzado a explorar el mundo de la programación concurrente, aún no nos hemos topado con niguno de sus problemas. Esto será tratado en la cuarta sección de este tutorial.
Luego de que en la sección anterior complicáramos un poco las cosas introduciendo concurrencia en el servidor, un amigo me hizo notar que quizás sería mejor dar un ejemplo un tanto más "real" de lo que estábamos haciendo.
Es por eso que dejaremos por un rato el camino planificado para jugar un poco explorando las posibilidades con un cliente en PHP (en una especie de "recreo").
Si bien el servicio que hemos diseñado es muy limitado (después de todo, es sólo un ejemplo para desarrollar los temas), podemos utilizarlo para realizar un pequeño sistema de monitoreo remoto.
Supongamos que tenemos el servidor corriendo en varios servidores físicos distintos. Vamos a construir un programa PHP (para satisfacer los deseos de quienes querían ver algo en este lenguaje) que nos permita conectarnos a cualquiera de los servidores y ver la hora del sistema y los usuarios activos.
Recordemos que esto es sólo un ejemplo simple, por lo cual no tendremos en cuenta la seguridad del sistema. (Además, ¡estamos de recreo!).
El uso de sockets en PHP es un poco más complejo que en Perl, por lo cual nos conviene definir funciones auxiliares para establecer la conexión, leer y escribir:
Los sockets en PHP son genéricos. Por esto debemos indicar que usaremos direccionamiento IP (AF_INET), que realizaremos una comunicación bidireccional (SOCK_STREAM) y que usaremos el protocolo TCP (SOL_TCP). Al leer de un socket debemos indicar cuál es la longitud máxima (asumimos 1024). PHP_NORMAL_READ significa que lo leído deberá interpretarse como texto. Al escribir, debemos proporcionar la longitud de la salida.
El programa se conectará al servidor especificado por el usuario y ejecutará los comandos fecha y usuarios, obteniendo las respuestas correspondientes del servidor. Debemos recordar en todo momento cómo funciona el protocolo que hemos definido. En definitiva, no estamos haciendo más que implementar un nuevo cliente.
Por simplicidad, hemos utilizado un puerto fijo para todos los servidores (el 2222), aunque este podría variar, y la lista de servidores solo incluye al localhost (127.0.0.1).
Si comparamos el código PHP con el del cliente original desarrollado en Perl notaremos que hay grandes similitudes entre ambos.
Finalmente, la parte del script que produce la salida HTML con la lista de servidores que pueden ser monitoreados y los datos obtenidos desde el servidor actual.
(Por comodidad y facilidad de visualización he puesto imágenes con el código PHP, pero si lo desea puede descargar el código fuente completo.)
A continuación, una muestra de cómo se vería la interfaz una vez consultado el servidor corriendo en 127.0.0.1:
Hemos tomado un pequeño desvío para ver la implementación de un cliente en PHP, acercándonos a un ejemplo un poco más real del posible uso del servicio que hemos diseñado.
Más allá de los detalles propios de cada lenguaje, hemos podido apreciar las grandes similitudes a la hora de implementar un cliente.
En esta última sección 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.
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 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.
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:
open(ARCH1, ">archivo1");
open(ARCH2, ">archivo2");
# Hacer algo con ARCH1 y ARCH2
close(ARCH2);
close(ARCH1);
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.
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.
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:
#!/usr/bin/perl -w
use 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;
}
}
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 "OK\n";
print $conexion $resultado;
print $conexion "FIN\n";
} elsif ($comando eq 'salir') {
print $conexion "Adios.\n";
} else {
print $conexion "ERR\n";
}
}
sub atender($) {
$conexion = shift;
$ip = $conexion->peerhost;
print "[Conexión establecida desde $ip]\n";
print $conexion "Bienvenido.\n";
do {
if ($comando = <$conexion>) {
$comando =~ s/\r\n|\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.
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.)
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:
#!/usr/bin/perl -w
use 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)
}
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 "OK\n";
print $conexion $resultado;
print $conexion "FIN\n";
} elsif ($comando eq 'salir') {
print $conexion "Adios.\n";
} else {
print $conexion "ERR\n";
}
}
sub atender($$) {
($conexion, $semaforo) = @_;
$ip = $conexion->peerhost;
print "[Conexión establecida desde $ip]\n";
print $conexion "Bienvenido.\n";
do {
if ($comando = <$conexion>) {
$comando =~ s/\r\n|\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.
En esta última sección, 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.
Es mi deseo que este tutorial 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. Para ello puede visitar mi blog en http://blog.smaldone.com.ar. 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.