Implementación básica de servicios TCP/IP
Cuando hablamos de servicios TCP/IP nos referimos los programas que implementan la parte de servidor en las tres capas altas del modelo OSI o, lo que es equivalente, la capa más alta de la pila TCP/IP: la capa de aplicación. Por simplicidad, trabajaremos sobre la capa TCP de transporte punto a punto fiable.
En este artículo vamos a repasar las posibilidades que nos ofrece nuestro sistema operativo para desarrollar aplicaciones que admitan conexiones remotas y se comuniquen a traves de la red.
El clásico inetd
La forma más sencilla de desarrollar un servicio TCP/IP es mediante el uso del clásico Internet superserver o inetd. Esta herramienta proporciona toda la preparación y negociación inicial de las conexiones con los clientes, para luego proporcionarlo a nuestro programa de una forma muy natural: la comunicación desde el cliente se recibía por la entrada estándar y la comunicación hacia el cliente por la salida estándar. Tómese el siguiente script de shell como ejemplo:
#!/bin/sh echo "Hola!" while read linea do echo "Veo que has escrito: $linea" done
Este script no tiene nada de especial, y se puede probar en la línea de comandos. Simplemente repite cada línea que lee. Pues bien, con una única línea de configuración del fichero /etc/inetd.conf podemos comunicar nuestro script con el exterior, sin modificación de código alguna:
# <service_name> <sock_type> <proto> <flags> <user> <server_path> <args> 8484 stream tcp nowait ijurado /home/ijurado/echo_test.sh
Debido a que nuestro programa no tiene por qué preocuparse de si la entrada estándar viene de un teclado o de un host remoto, inetd ofrece opciones de configuración adicionales para ajustar los parámetros de las conexiones. No obstante, estas están fuera del objetivo de este artículo.
Esquema básico de un servidor
El recurso que nos proporciona el sistema operativo, y que nos sirve para tratar con conexiones a través de la red, es el socket, que representa el estado de una conexión. Siempre que queramos comunicarnos con otro host, lo haremos utilizando sockets. Debido al diseño del protocolo TCP, cada extremo de la conexión debe asumir un rol diferente: un extremo será el cliente y el otro será el servidor. Nosotros nos vamos a centrar en la parte de servidor, como ya se ha comentado.
Los servidores son siempre entidades pasivas, que ofrecen servicio bajo demanda. Para implementar un servidor, necesitamos abrir o escuchar en un puerto TCP. Esto se hace en 3 pasos, crear un socket, asociarlo a una dirección y puerto y ponerlo en estado de escucha. El siguiente fragmento de Python muestra este procedimiento:
import socket bsk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) bsk.bind(("", 8484)) bsk.listen(16)
La cadena vacía en la tupla proporcionada al parámetro bind() indica que hay que escuchar en todas las interfaces de red, en caso de que el host esté conectado a varias redes. El valor proporcionado al método listen() no tiene demasiada importancia, aparte de que debe ser un valor positivo. Ahora que el puerto 8484 ya está abierto y en escucha, tendremos que comenzar a aceptar conexiones entrantes:
sk, addr = bsk.accept() f = sk.makefile("r+", 0) f.write("Hola!\n") for linea in f: f.write("Veo que has escrito: " + linea)
El método accept() bloquea el proceso hasta que llega una nueva conexión. Una vez esto sucede, nos devuelve una tupla con un nuevo objeto socket que representa una conexión directa y exclusiva con el cliente cuya dirección viene indicada por el segundo elemento de la tupla.
Este ejemplo concreto tiene el gran inconveniente de que únicamente trata con un cliente. Cuando el cliente se desconecta, el servidor termina. Para solucionar esto basta con aceptar nuevos clientes sucesivamente:
while True: sk, addr = bsk.accept() f = sk.makefile("r+", 0) f.write("Hola!\n") for linea in f: f.write("Veo que has escrito: " + linea)
Todavía tenemos otro inconveniente sin resolver. No podemos atender a más de un cliente simultáneamente. Obviamente, un servidor debe lidiar con multitud de clientes a la vez. Existen diversas formas de resolver este problema que comentaremos en otro artículo más adelante. De momento procederemos igual que lo hacía el inetd por nosotros en el ejemplo anterior. El ejemplo completo sería así:
import os, sys import errno import signal import socket def limpia_zombies(signum, frame): try: while os.waitpid(-1, os.WNOHANG) != (0, 0): pass except OSError: pass signal.signal(signal.SIGCHLD, limpia_zombies) bsk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) bsk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) bsk.bind(("", 8484)) bsk.listen(16) while True: try: sk, addr = bsk.accept() except socket.error as err: if err.errno == errno.EINTR: continue else: raise if os.fork() == 0: f = sk.makefile("r+", 0) f.write("Hola!\n") for linea in f: f.write("Veo que has escrito: " + linea) sys.exit()
Vaya, esto está bastante cambiado, repasemos los añadidos. En primer lugar, centrémonos en el final, a partir de la línea 28. Para cada cliente conectado, estamos clonando el proceso servidor en uno nuevo, idéntico, que únicamente se encarga de atender al cliente que se acaba de conectar. De esta forma, el proceso padre (el que recibe de os.fork() un valor positivo) procede con la siguiente iteración y se bloquea hasta que se conecta un nuevo cliente. A medida que se vayan conectando nuevos clientes, se irán creando nuevos procesos.
Es importante tener presente que la única finalidad de cada proceso hijo es atender a su cliente. Cuando el cliente se desconecta, el proceso hijo ya no tiene ningún otro motivo para existir y debe terminar. De no hacerlo, el proceso hijo continuaría ejecutando la siguiente iteración del bucle, pasando a competir con el proceso padre por aceptar nuevos clientes.
Llegados a este punto, debemos admitir que con hijos todo se complica y, como en la vida real, crear procesos hijo acarrea una serie de responsabilidades adicionales. Cuando un proceso hijo termina, se queda en un estado conocido como zombie pero sus recursos no se liberan. La existencia de este estado permite, por ejemplo, que los intérpretes de línea de comandos puedan consultar si un comando terminó correctamente o sucedió algún error durante la ejecución.
Aunque a nosotros no nos interese conocer el estado de salida de un proceso hijo, debemos indicarle al sistema que libere los recursos del proceso zombie. Esto lo conseguimos con las llamadas os.wait(), os.waitpid(), os.wait3() o os.wait4(). Cada vez que un hijo termina, el sistema nos manda la señal SIGCHLD; por lo tanto, también tenemos que estar atentos a cuando llegue esta señal.
La función limpia_zombies() podía simplemente haber sido una llamada a os.wait(), pero el número de señales SIGCHLD emitidas por el sistema no siempre se corresponde perfectamente con el número de hijos muertos. Por ejemplo, si dos hijos terminan casi simultáneamente es posible que únicamente nos llegue una única señal SIGCHLD o que las dos señales lleguen tan seguidas que la segunda llegue mientras estamos atendiendo a la primera, y por tanto quede enmascarada. En nuestro ejemplo, no sabemos de antemano cuántos clientes se conectarán, ni con qué intervalos de tiempo entre conexión y conexión. Debido a que las conexiones de clientes son los eventos que provocan la creación de hijos, tenemos que contemplar cualquier posibilidad en la terminación de hijos. Este hecho destaca la importancia de la robustez en un servidor de aplicación.
Cuando un proceso está bloqueado y recibe una señal, el sistema lo despierta para que pueda atender a la señal. Una de las consecuencias principales es que la llamada que lo había bloqueado termina con un error. En particular, el método accept() que antes se bloqueaba hasta que se conectara un cliente, ahora se bloquea hasta que se conecta un cliente o un proceso hijo termina. En este último caso se lanza una excepción como consecuencia del error. En la línea 23 se comprueba si la llamada fue interrumpida por una señal y, en tal caso, volvemos a empezar.
No se ha comentado antes, pero el lector intrépido se habrá encontrado en los ejemplos anteriores con el siguiente error:
socket.error: [Errno 98] Address already in use
Esto es debido a que el sistema mantiene el puerto bloqueado durante un tiempo cuando el proceso que estaba atendiéndolo termina de alguna forma inesperada. El código de la línea 15 obliga al sistema a comprobar mejor si el puerto en el que queremos escuchar está realmente siendo usado por algún proceso que está corriendo.
Resumen
Con todo lo expuesto, simplemente hemos escarbado en la superficie de la programación de aplicaciones sobre TCP. No obstante ya nos da un perfil muy común ya que, en esencia, todas aplicaciones que hacen de servidor tienen que seguir los mismos pasos. Desarrollar un sencillo servidor sobre TCP se puede considerar sencillo, pues todo el mérito se lo lleva el sistema operativo que es quien realiza todo el trabajo difícil Desarrollar un sencillo servidor sobre TCP se puede considerar sencillo, pues todo el mérito se lo lleva el sistema operativo que es quien realiza todo el trabajo difícil.
También ha habido muchos detalles que se han pasado por alto y cuya comprensión se dejan a la voluntad del lector.
Donde existe algo más de creatividad es a la hora de tratar con múltiples clientes a la vez. En un próximo artículo, trataremos el tema con algo más de detalle y comentando ejemplos reales de cada alternativa.
- Isaac Jurado's blog
- Log in or register to post comments
