red de computadorasEn su artículo Whither sockets? George V. Neville-Neil examina algunas condiciones que existían cuando se creó el API de sockets, reflexionando sobre cómo esas condiciones le dieron forma a la manera en la que se escrbe código de red. Luego nos explica las diferentes estrategias que usaron los desarrolladores para superar algunas limitaciones inherentes al API, para terminar reflexionando sobre el futuro de los sockets en un mundo conectado que cambia y evoluciona día a día.

Una de las interfaces de software más duraderas y longevas es el API de sockets. El API de sockets fue desarrollado por Grupo de Investigación de Sistemas de Computación de la Universidad de California en Berkeley, y publicado por primera vez como parte del sistema operativo VSD 4.1c en el año 1982. Aunque existen APIs más antiguas (por ejemplo, las de I/O de Unix) no deja de sorprender que un API siga en uso y casi sin cambios durante 27 años. El único cambio mayor que tuvo el API de sockets fue una extensión para soportar las direcciones más largas de IPv6.2.

Internet y el mundo de las redes en general pasaron por cambios importantes desde que apareció por primera vez el API de sockets, pero de muchas maneras este API tuvo el efecto de restringir el modo en el que los desarrolladores piensan y escriben aplicaciones de red.

Las dos grandes diferencias entre las redes de 1982 y 2009 son la topología y la velocidad. En mayor parte las personas notan más el aumento de velocidad que los cambios en la topología. El ancho de banda máximo comercialmente disponible en 1982 era de 1.5Mbps. Las redes Ethernet LAN que se instalaban tenían una velocidad de 10 Mbps. Un usuario hogareño - y había muy pocos de estos - era afortunado si lograba tener una conexión de 300 bps sobre una línea telefónica. El lag entre dos máquinas en una red local se medía en decenas de milisegundos, y entre sistemas a través de Internet en cientos de milisegundos, por supuesto dependiendo de la ubicación y de la cantidad de saltos que tenía que hacer un paquete para llegar al destino.

A su vez, la topología de las redes en esa época era muy simple. La mayoría de las computadoras tenía una única conexión a una red de área local; la LAN se conectaba a un router primitivo que podía tener algunas pocas conexiones a otras LAN y una única conexión a Internet. De aplicación a aplicación la conexión se hacía a través de la LAN, o pasando por uno o más routers, llamada IMPs (Internet message passing).

Historia de los sockets

El modelo de programación distribuida que sería popularizado por el API de sockets era el modelo cliente/servidor, en donde hay un servidor y un grupo de clientes. Los clientes envian mensajes al servidor pidiéndole que haga trabajo en su nombre, esperan que el servidor realice el trabajo pedido, y en algún momento posterior reciben una respuesta. Hoy en día este modelo computacional es tan utilizado que a menudo es el único modelo con el que muchos ingenieros de software están familizarizados. Sin embargo, en el momento que fue diseñado, era visto como una manera de extender el modelo de I/O de Unix a través de una red. Otro factor que llevó al API de sockets hacia el modelo cliente/servidor fue que el protocolo más popular que soportaba, TCP, era un modelo comunicacional 1:1.

El API de sockets hizo que el modelo cliente/servidor fuera facil de implementar, porque el programador podía usar una pequeña cantidad de llamadas de sistema y agregarlas a su código existente ("desconectado") para aprovechar otros recursos computacionales. Aunque era posible usar otros modelos, el API de sockets hizo que el modelo cliente/servidor dominara al mundo de las redes.

El API de sockets tiene varios puntos de entrada, pero los siguientes cinco son los principales que diferencian al API de sockets de una I/O típica sobre archivos:

socket()      Crea un endpoint de comunicación 
bind()        Relaciona el endpoint a un conjunto de parámetros de la capa de red
listen()      Establece un límite para la cantidad de peticiones
accept()      Acepta una o más peticiones de trabajo del cliente
connect()     Contacta un servidor para enviar una petición de trabajo

El realidad la función socket() debería haber sido eliminada y reemplazada con una variante de open(), pero no se hizo en su momento. Las llamadas socket() y open() devuelven lo mismo: un descriptor de archivos único por proceso, que se utiliza en todas las operaciones siguientes con el API. La popularidad del API es el resultado de su simpleza, pero esta popularidad también provocó que se limitara el desarrollo de alternativas o APIs mejoradas que pudieran ayudar a desarrollador otros tipos de programas distribuidos.

El modelo cliente/servidor tenía muchas ventajas en el momento que fue creado. Le permitía a muchos usuarios compartir recursos, como grandes almacenes de datos o facilidades de impresión costosas, y a la vez mantener estas facilidades bajo el control del mismo departamente que en otro tiempo había mantenido los centros de mainframes. Con este modelo para compartir era posible incrementar la utilización de lo que en ese momento eran recursos costosos.

El desafío

Hay tres áreas de red que no están bien resueltas por el API de sockets: aplicaciones de baja latencia o de tiempo real; aplicaciones con gran ancho de banda; aplicaciones multihomed - es decir, aquellas aplicaciones con múltiples interfaces de red. Muchas personas confuden el incrementar el ancho de banda con mejor rendimiento, pero incrementar el ancho de banda no necesariamente disminuye la latencia. El desafío para el API de sockets es darle a las aplicaciones acceso más rápido a los datos en red.

Los programas utilizan el API de sockets enviando y recibiendo datos a través de llamadas al sistema operativo. Todas estas llamadas tienen una cosa en común: el programa invocante tiene que pedir repetidamente al servidor cada vez que quiere datos. En el mundo del cliente/servidor estas peticiones constantes tienen sentido, porque el servidor no puede hacer nada sino es a través de una petición del cliente. No tiene sentido que un servidor de impresoras llame al cliente a menos que el cliente tuviera algo que quisiera imprimir. Sin embargo, ¿qué pasa si el servidor es de música o de distribución de videos? En un servicio de distribución multimedia puede haber más de un origen de datos y muchos receptores. Mientras el usuario está escuchando o viendo un archivo multimedia, lo más seguro es que la aplicación va a querar cualquier dato que llegue. Pedir datos específicos es una pérdida de tiempo y recursos por parte de la aplicación. El API de sockets no tiene una forma para que el programador diga "ni bien haya datos para mi, invocame para que los procese directamente".

En cambio, los programas de sockets se ven desde el punto de vista de la escacez de los datos. Los programas en red están tan acostumbrados a esperar por los datos que utilizan una llamada separada, select(), de manera de poder escuchar a múltiples orígenes de datos sin quedar bloqueados en una única petición. El bucle típico de procesamiento de un programa basado en sockets no es read(), process(), read(), sino más bien select(), read(), process(), select().  Podría parecer poca cosa agregar esta llamada al bucle, pero no es así. Cada llamada requiere que se serialicen y copien los datos al kernel, y a la vez ocasiona que el sistema bloquee al proceso invocante y gestione otro. Si había datos disponibles para el invocante cuando se invoca select(), entonces todo el trabajo que se hizo para cruzar la frontera usuario/kernel fue un desperdicio, porque una llamada a read() hubiera devuelto los datos inmediatamente. Este ciclo constante de comprobar/leer/comprobar es un desperdicio a menos de que el tiempo entre peticiones sucesivas sea bastante largo.

Para resolver este problema hay que invertir el modelo comunicacional entre la aplicación y el sistema operativo. Se hicieron varios intentos de brindar un API que le permita al kernel invocar directamente al programa, pero ninguna propuesta ganó aceptación general - y por varias razones. Los sistemas operativos que existían cuando se desarrolló el API de sockets eran, salvo muy raras excepciones, de un único hilo y se ejecutaban en computadoras con un solo procesador. Si se modificaba al kernel para que tuviera un API que le permitiera invocar a los programas, aparecía el problema sobre en qué contexto ejecutar esta llamada. Era inaceptable hacer que todo el trabajo en el sistema se detuviera porque el kernel estaba realizando una invocación a la aplicación, especialmente en sistemas donde se compartía el tiempo de procesamiento entre cientos de usuarios. El único lugar en donde esta arquitectura tuvo aceptación fue en los sistemas embebidos y routers de red, en donde no había usuarios ni memoria virtual.

El tema de la memoria virtual se suma a los problemas de implementar un mecanismo de llamadas desde el kernel a la aplicación. La memoria reservada para un proceso de usuario es memoria virtual, pero la memoria utilizada por los dispositivos como las interfaces de red es memoria fisica. Hacer que el kernel haga un mapeo de la memoria física del dispositivo hacia un espacio de programa de usuario rompe una de las protecciones fundamentales que brindan los sistemas de memoria virtual.

Problemas de rendimiento

Se propusieron e implementaron algunos mecanismos diferentes en varios sistemas operativos para superar los problemas de rendimiento presentes en el API de sockets. Uno de estos mecanismos son los sockets zero-copy (de "copia cero"). Cualquiera que haya trabajado con una pila de red sabe que copiar datos mata al rendimiento de los protocolos de red. Por lo tanto, para mejorar la velocidad de las aplicaciones de red que están más interesadas en un gran ancho de banda y no tanto en baja latencia, el sistema operativo puede quitar cuanta copia de datos sea posible.

Tradicionalmente, los sistemas operativos realizan dos copias por cada paquete que recibe el sistema. La primer copia la realiza el controlador de red desde la memoria del dispositivo de red hacia la memoria del kernel, y la segunda la realiza la capa de sockets en el kernel cuando los datos son leídos por el programa de usuario. Cada una de estas operaciones de copia es costosa porque ocurren por cada mensaje que recibe el sistema. De manera similar, cuando el programa quiere enviar un mensaje, los datos se copian desde el programa del usuario hacia el kernel por cada mensaje enviado; luego los datos se copia hacia los buffers que utiliza el dispositivo para transmitirlo por la red.

La mayoría de los diseñadores de sistemas operativos y desarrolladores saben que copiar datos atenta contra el rendimiento, y trabajan para minimizar dichas copias dentro del kernel. La manera más simple para que el kernel evite copiar datos es hacer que el controlador del dispositivo copie los datos directamente desde y hacia la memoria del kernel. En los dispositivos modernos de red esta es la forma en cómo se estructura la memoria. El controlador y el kernel comparten dos áreas de descritptores de paquetes - uno para transmitir y otro para recibir - en donde cada descriptor tiene un único puntero a memoria. El controlador del dispositivo de red inicialmente llena estas áreas con memoria desde el kernel. Cuando se reciben datos, el dispositivo establece un flag en el dispositivo de recepción y le dice al kernel, usualmente a través de una interrupción, que hay datos esperando. El kernel luego vacia el buffer del área de recepción y lo reemplaza con un nuevo buffer para que el dispositivo llene. El paquete, en su forma de buffer, se mueve a través de la pila de protocolos hasta llegar a la capa del socket, en donde se copia fuera del kernel cuando el programa de usuario invoca la función read(). Los datos enviados por el programa se manejan de forma similar por el kernel.

Todo este trabajo en el kernel no solucionan el problema de la "segunda copia"; hubo varios intentos para extender el API de sockets y quitar esta operación de copia. El problema sigue siendo en cómo hacer para compartir memoria de manera segura entre el kernel y el programa. El kernel no puede darle su memoria al programa de usuario, porque en ese momento perdería control sobre la memoria. Un programa de usuario que se cuelga podría dejar al kernel sin una porción importante de memoria, llevando a degradación del sistema. También hay problemas de seguridad inherentes al compartir buffers de memoria entre el kernel y los programas de usuario. Al día de hoy no hay solución para cómo hacer que un programa de usuario logre más ancho de banda usando el API de sockets.

Se pudo hacer todavía menos para los programadores que están más interesados en la latencia que en el ancho de banda. La única mejora significativa para los programas que están esperando algún evento de red fue agregar algunos eventos en el kernel a los que el programa puede escuchar. Los eventos del kernel, o kevents(), son una extensión del mecanismo select() para agrupar a cualquier evento posible que el kernel pudiera avisarle a un programa. Antes del kevents(), un programa de usuario podía llamar a select() sobre cualquier descriptor de archivos - por ejemplo, leyendo de la red y escribiendo al disco - la sentencia select() era suficiente, pero una vez que el programa quería comprobar otros eventos, como timers y señales, el select() dejaba de ser útil. El problema de las aplicaciones de baja latencia es que el kevents() no entrega datos; sólo entrega una señal diciendo que hay datos listos, al igual que hace el select(). El paso lógico siguiente sería tener un API basada en eventos que también entregue datos. No hay ninguna razón para que una aplicación tenga que cruzar esta frontera entre kernel/usuario dos veces para obtener los datos que el kernel sabe que la aplicación quiere.

El futuro es el multihome

El API de sockets no sólo tiene problemas de rendimiento para quien escribe la aplicación, sino que también limita el tipo de comunicación que puede ocurrir. El paradigma cliente/servidor es una comunicación inherentemente del tipo 1:1. Aunque un servidor puede atender muchas peticiones de distintos clientes, cada cliente tiene una sola conexión a un único servidor para una petición o grupo de peticiones. Este paradigma tenía sentido en un mundo en el cual cada computadora tenía sólo una interfaz de red. La conexión entre un cliente y el servidor se identifica por la cuaterna [IP Origen, Puerto origen, IP Destino, Puerto destino]. Como los servicios tienen puertos de destino conocidos (por ejemplo, puerto 80 para HTTP), el único valor que puede variarse facilmente es el puerto de origen, ya que las direcciones IP son fijas.

En la Internet de 1982 cada máquina que no era router tenía una única interfaz de red, por lo que para identificar un servicio, como una impresora remota, la computadora cliente necesitaba una única dirección y puerto de destino, y tenía sólo una dirección y puerto de origen para trabajar. Era muy complicado y dificil de implementar la idea de que una computadora tuviera múltiples formas de contactar un servicio. Dadas estas restricciones, no había ningún motivo para que el API de sockets le expusiera al programador la habilidad de escribir programas multihome - uno que pudiera gestionar qué interfaces o conexiones le importaban. Estas características, cuando se implementaban, formaban parte del software de routing dentro del sistema operativo. Los programas sólo podía acceder a esta funcionalidad a través de un conjunto de APIs oscuras y no estándard del kernel.

En un sistema con múltiples interfaces de red no es posible escribir una aplicación multihome con el API de sockets de manera simple - es decir, crear una aplicación que utilice todas las interfaces de red y no pierda conexión con el servidor aunque una conexión falle o se rompa la routa principal por la que viajaban los paquetes.

El recientemente desarrollado SCTP 4 (Stream Control Transport Protocol) incorpora soporte para multihome a nivel del protocolo, pero es imposible exportarlo a través del API de sockets. Al día de hoy este es el único protocolo que tiene la capacidad y demanda de usuarios para multihome, aunque el API está estandarizado para sólo unos pocos sistemas operativos.

El problema es que los sockets son tan exitosos y ampliamente usados que resulta muy dificil cambiar el API existente por miedo a confundir a los usuarios, o a los programas preexistentes que lo usan.

A medida que los sistemas tengan más interfaces de red incorporadas, va a ser una necesidad absoluta poder escribir aplicaciones que aprovechen el multihome. Hoy ya podemos imaginar el uso de esta tecnología en los smartphones, que ya tienen tres interfaces de red: su conexión principal a la red celular, una interfaz WiFi, y usualmente una interfaz Bluetooth. No hay ningún motivo para que una aplicación pierda conexión si al menos una de estas interfaces de red está funcionando. El problema para los desarrolladores de aplicaciones radica en que quieren escribir su código para que funcione, sin cambios, en una enorme cantidad de dispositivos: celulares, notebooks, PC de escritorio, etc. Si tuvieramos un API correctamente definido podríamos quitar la barrera artificil que impide esto. Esta problemática todavía no fue resuelta debido a la misma historia del API de los sockets, y a que hasta la fecha fueron "suficientemente buenos".

El gran ancho de banda, la baja latencia y el multihome están guiando al desarrollo de alternativas para el API de sockets. Las redes LAN están alcanzando los 10 Gbps, y para muchas aplicaciones el modelo de comunicaciones cliente/servidor es demasiado ineficiente para el ancho de banda disponible. El paradigma comunicacional que soporta el API de sockets tiene que expandirse para permitir compartir memoria a través de la frontera del kernel, y también incorporar mecanismos de baja latencia para entregar datos a las aplicaciones. El multihome se convertirá en una característica central de un futuro API de sockets porque cada vez hay más dispositivos que tienen múltiples interfaces activas, y son cada vez más populares en todos los sistemas conectados.

Traducido de Whither sockets? George V. Neville-Neil.

Inspiración.

"Si tú tienes una manzana y yo tengo una manzana e intercambiamos las manzanas, entonces tanto tú como yo seguiremos teniendo una manzana cada uno. Pero si tú tienes una idea y yo tengo una idea, e intercambiamos las ideas, entonces ambos tendremos dos ideas"

Bernard Shaw