En la entrega anterior abordamos el diseño de un protocolo y la implementación de un servidor secuencial muy simple y un cliente. Haciendo esto, inspeccionamos los conceptos fundamentales de la programación de aplicaciones usando sockets.
En esta segunda parte, desarrollaremos un servidor capaz de recibir conexiones de varios clientes de manera concurrente (en paralelo). Para ello utilizaremos dos técnicas distintas: procesos múltiples e hilos de ejecución (threads).
Nota del 19/11/2006: Ahora puede descargar el tutorial completo.
Entrega anterior
- Primera parte: Planteo del problema, implementación de un servidor secuencial y de un cliente simple.
Procesos y concurrencia
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 hijon";
} else {
print "Soy el proceso padren";
}
print "Fork devolvió: $pidn";
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.
Segunda solución: un servidor concurrente
Modificaremos ahora el servidor desarrollado inicialmente (ver el artículo anterior), 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:
servidor_fork
#!/usr/bin/perl -wuse 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 parte.
Algunas consideraciones sobre la concurrencia
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 entrega de este artículo 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.
Tercera solución: un servidor concurrente 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:
servidor_threads
#!/usr/bin/perl -wuse 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.
Concluyendo
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 abre las puertas a la próxima entrega del presente tutorial…
Próximas entregas
- Tercera parte: Un cliente en PHP para el acceso a múltiples servidores.
- Cuarta parte: El uso de recursos compartidos y los problemas
de la concurrencia.
Aunque soy aún un novato en el mundo de la programación, este acercamiento a la programación concurrente, me ha resultado más que entretenido. La verdad muy bien explicados estos dos últimos artículos.
Espero con ansias las próximas entregas.
Saludos
Hola, tu articulo esta muy bien, me esta siendo de mucha ayuda, aunque perl nunca me ha gustado mucho.
Para mi proyecto tengo que usar C y me he encontrado una librería para usar los sockets mas fácilmente, tal vez le sea util a alguien mas.
Esta en http://solarsockets.solar-opensource.com
Les hace falta algo de documentación pero la libreria esta muy bien.
Felicidades por tu articulo.
Hola Javier,
Lo primero, muy bueno tu serie de artículos.
Me surge un problema con un ejemplo parecido a este, ya que según van terminando los hilos, la memoria no se libera… con lo que es cuestión de tiempo que el sistema operativo (en mi caso Fedora Linux) mate el proceso con un «Out of memory!».
¿Es imprescindible hacer una «join» al finalizar el hilo? Es que me he dado cuenta que si lo «desatas» (detach), no surge este problema.
Gracias,
Fermat.
PD: Varias veces antes he leído artículos tuyos, y me parece que haces una encomiable tarea de divulgación.
Confirmado, es imprescindible ejecutar una «join» sobre los hilos que han terminado (y que no hemos «independizado» mediante el método «detach»).
Curiosamente, versiones anteriores del módulo «threads» no implementa funciones como «is_joinable» o «threads::joinable» (este último para sacar directamente la lista de los hilos que han terminado y no hemos unido). ¡No sé cómo se controlaría antes estas cosas!
Espero que sirva esta respuesta (cantidad de veces me ha salvado leer la respuesta a una pregunta que yo tenía, y que busqué en google xD).
Saludos,
Fermat.
Hola,
He intentado hacer un script en perl, que crea en un bucle infinito threads, el problema que tengo es que no me libera la memoria al hacer el join().
Para crear los threads hago:
threads->new(&inicio)
donde inicio es una funcion.
Para hacer el join, puesto que no me guardo las referencias al crear los threads hago :
my @joinable = threads->list(threads::joinable);
foreach (@joinable)
{
$_->join();
}
Para no tener infinitos hilos los controlo mediante un semaforo, luego compruebo que el proceso tenga el numero de hilos que yo le digo y hay los que le indico al crear el semaforo, pero no me libera la memoria, el join lo hace por que he puesto un print y si lo hace, y el numero de hilos se mantiene constante pero no libera la memoria, que puedo hacer ??
Un saludo.
bien changos soy un novato y deceo aprender mas
I am in fact happy to read this blog posts which includes plenty of useful data, thanks for providing these kinds of information.
I comment when I like a article on a website or if I have something to contribute to the discussion.
Usually it’s caused by the passion displayed in the post I
read. And on this article Programación para redes y
concurrencia (II) | Blog de javier smaldone.
I was actually excited enough to drop a comment ;) I actually do have a couple of questions for you if it’s allright.
Could it be only me or do a few of the remarks look as if they are written by brain dead visitors?
:-P And, if you are posting at additional online sites, I’d like to keep up with everything fresh you have to post.
Would you list the complete urls of all your shared pages like your linkedin profile,
Facebook page or twitter feed?