Programación para redes y concurrencia (I)

Este es el inicio de una serie de artículos introductorios sobre programación para redes (usando sockets) y programación concurrente. El objetivo es presentar una serie de conceptos que iremos explorando progresivamente:

  • Programación cliente/servidor usando sockets: Cómo desarrollar programas que se comuniquen a través de la red para realizar distintas tareas.
  • Protocolos de comunicación: Explorar los principios básicos de los protocolos de aplicación (alto nivel) utilizados para intercambiar información.
  • Concurrencia: Cómo construir programas que se ejecuten paralelamente y resolver algunos de los problemas que nos plantea la concurrencia.

Nota del 19/11/2006: Ahora puede descargar el tutorial completo.

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.

El problema

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:

  • fecha: Muestra la fecha y hora actual del servidor.
  • usuarios: Muestra los usuarios activos del sistema (asumiremos que el servidor se ejecuta en un sistema compatible con UNIX y que posee el comando who).

El protocolo

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):

  1. Al recibir la conexión del cliente, el servidor enviará el mensaje «Bienvenido.«.
  2. El cliente enviará el comando deseado («fecha» o «usuarios«).
  3. En caso de tratarse de un comando válido, el servidor responderá con «OK» seguido del resultado de la ejecución del mismo (el cual puede constar de varias líneas) y luego el mensaje «FIN«.
  4. En caso de recibir un comando erróneo, el servidor responderá con el mensaje «ERR«.
  5. En cualquier caso, el servidor volverá a esperar otro comando.
  6. Si el cliente envía el comando «salir» el servidor responderá con el mensaje de despedida «Adios.» y cerrará la conexión.
  7. La conexión puede ser cerrada por el cliente en cualquier momento, sin que esto afecte el estado del servidor.

Algunos conceptos preliminares

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:

  • El proceso servidor abre un socket asociado a determinado puerto TCP.
  • El servidor permanece «escuchando» en dicho puerto, a la espera de la apertura de una conexión.
  • Un cliente abre un socket conectándose al servidor haciendo referencia a su dirección IP y al número de puerto.
  • Una vez establecida la conexión entre ambos procesos, ya no existe más diferencia entre cliente y servidor.
  • El socket puede ser visto, por ambos procesos, como un archivo de lectura/escritura abierto.
  • Ambos procesos intercambian información de acuerdo al protocolo de aplicación establecido.
  • Para finalizar la conexión, cualquiera de los dos procesos cierra el socket.

Primera solución: un servidor secuencial

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 parte de este artículo implementaremos un servidor que pueda recibir varias conexiones concurrentes.)

servidor_secuencial


#!/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);
}

Descargar el código fuente

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.

procesar.pl


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);
    print "[Conexión finalizada desde $ip]n";
}

1;

Descargar el código fuente

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/rn|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 «rn«).

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.

Probando nuestro servidor

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.

Un cliente simple para nuestro protocolo

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 "$comandon";
$estado = <$conexion>;
if ($estado eq "OKn") {
    $salida = <$conexion>;
    while ($salida ne "FINn") {
        print $salida;
        $salida = <$conexion>;
    }
} elsif ($estado eq "ERRn") {
    print "Comando no válidon";
} else {
    print "Error en el servidorn";
}
print $conexion "salirn";
$mensaje = <$conexion>;
$conexion->shutdown(2);

Descargar el código fuente

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:~$

Concluyendo

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.

Próximas entregas

  • 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.
  • Cuarta parte: El uso de recursos compartidos y los problemas
    de la concurrencia.

30 comentarios sobre “Programación para redes y concurrencia (I)

  1. Pingback: meneame.net
  2. Un usuario de Meneame me hizo las siguientes preguntas:

    1. ¿Qué ocurre si el cliente no cierra la sesión? ¿Debe el servidor encargarse de cerrar las sesiones sin actividad durante un tiempo?
    2. ¿Dónde se define la constante SOMAXCONN? ¿Es una constante ya prefenida por el SO o se puede cambiar?

    Mis respuestas fueron:

    1. Si. en una situación real, lo más apropiado sería que el servidor cierre las conexiones inactivas pasado un tiempo.
    2. SOMAXCONN está definido en el SO (include/linux/socket.h)

  3. Hola. Muchas gracias por el artículo. A mi la idea de que lo hayas explicado en Perl me parece muy acertado. Es el lenguaje que mejor se integra con los UNIX hoy en dia. Felicidades por tu artículo y ya estoy esperando la siguiente entraga.

  4. Gracias Javi por este tutorial!!! Recien termino con la parte 1 y me parecio excelente!!!

    Lastima que en ciertas universidades profesores sin dos dedos de frente, los cuales no distinguen una placa de red de una de video, enseñen regla de tres simple en las materias de redes, haciendonos perder el tiempo con imbecilidades (quiza por sus propias limitaciones), en lugar de enseñar los conceptos necesarios.

    Pero bue, lo que la vida te quita… lo ganas por otro lado gracias a personas como vos. God save Javi!!!

    Un abrazo,
    damian.

  5. Mario:

    En este momento no dispongo del tiempo necesario, pero desde siempre fue mi idea empaquetar el tutorial una vez terminado y hacerlo disponible en varios formatos. Espero pronto poder hacerlo.

    Con respecto a los agradecimientos, el agradecido soy yo. Me alegra mucho que esto sea de utilidad.

  6. Saludos desde España. Soy algo novato en Perl y necesitaba justamente este ejemplo para acoplar -con alguna que otra modificación- a una aplicación que estoy haciendo y poder acabar mis estudios. Gracias por el código.

  7. Pingback: programame.net
  8. hola javier gracias por este tutorial ,me cayo como anillo al dedo. ademas esta muy bien explicado y se centra en los conceptos realmente importantes para programar sockets en unix.Espero que sigas escribiendo tutoriales tan bien explicados y utiles como este.
    Saludos desde colombia.
    Suerte y Gracias. :)

  9. buenas tardes; necesito tu ayuda para entender la programacion concurrente usando PHP, necesito ver como incrementar y decrementar un numero sin que se me haga negativo. no puedo tener dos usuarios a la misma vez haciendo la operacion, saludos y gracias por tu orientacion.

  10. Hola Javier:
    Estoy tratando de llevar este excelente código a un hosting gratuito, he visto que hay algunos que ofrecen PHP, pero
    quisiera me puedas recomendar alguno.

    Saludos…

  11. Hola:
    Quise correr el servidor_secuencial.pl y obtuve el siguiente Mensaje de error:

    Error al iniciar el servidor at /opt/lampp/htdocs/xampp/prl/servidor_secuencial.pl line 5

    Alguno me puede ayudar a encontrar el problema y como solucionarlo?

    Gracias.

  12. Hola:

    Ahora ya funciona, lo que sucede es que al iniciar el servicio No vi el mensaje: Aceptando conexiones en puerto 2222, y lo ejecutaba de nuevo, lo cual provocaba
    el mensaje de Error. Pero luego me di cuenta de esto y
    pase a hacer el telnet, que funciona correctamente.

    Saludos.

  13. Hola.
    quiero probar el servidor_secuencial pero desde el msdos de windows.
    Ejecuto servidor.pl y va bien.
    Pero desde otra consola msdos (para conectarme como cliente) pongo ‘telnet localhost 2222’ y no me reconoce el comando.

    ¿Cómo lo hago?

    saludos!!!

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *