Teoría de Programación de Redes
Arquitecturas de Software de Red
Arquitectura Cliente/Servidor
- Cliente: Equivalente a la capa de interacción de usuario en la arquitectura de tres capas. Su función principal es aceptar la entrada del usuario y mostrar información. Las aplicaciones en teléfonos móviles y ciertos programas informáticos que requieren red son ejemplos de clientes.
- Servidor: Lugar donde se procesan y almacenan los datos, capaz de atender a muchos clientes simultáneamente (alta concurrencia). Algunos software también permiten que las computadoras de los clientes se conviertan en servidores pequeños.
Arquitectura Navegador/Servidor
- Navegador: Tiene la misma función que el cliente. Los sitios web y las versiones web de aplicaciones populares son ejemplos de esta arquitectura.
Diferencias y Similitudes entre C/S y B/S
Tanto el cliente como el navegador cumplen funciones en la capa de interacción con el usuario, pero siguen diferentes protocolos en la capa de aplicación. El navegador debe seguir protocolos como HTTP o HTTPS para funcionar en entornos web. En cambio, el cliente puede usar una variedad más amplia de protocolos para satisfacer necesidades específicas que HTTP no puede cubrir, como gráficos avanzados.
Conceptos Fundamentales de Redes
Núcleo y Bordes en Redes
La parte边缘 (bordes) se refiere a la comunicación entre usuarios, mientras que la parte central incluye componentes de conexión como cables de fibra óptica, estaciones de tránsito, routers y conmutadores.
Conmutadores
Conectan todas las computadoras que se conectan a ellos, permitiendo la comunicación dentro de una red local.
Difusión (Broadcast)
Modo de comunicación "uno a todos" entre hosts. La red copia y reenvía sin condiciones las señales emitidas por cada host, permitiendo que todos reciban toda la información. Los costos de red pueden ser bajos debido a la falta de selección de ruta. Las redes de cable son ejemplos típicos de redes de difusión.
Tormenta de Difusión
Grave fallo de red causado por múltiples computadoras que emiten difusiones simultáneamente, provocando una paralización de la red. Aunque las difusiones están permitidas en las redes de datos, están limitadas al ámbito de las redes de área local conmutadas a nivel 2, y se prohíbe que los datos de difusión crucen los routers para evitar afectar a un gran número de hosts.
Unicast
Modo de transmisión punto a punto, generalmente utilizado con el protocolo TCP. Antes de establecer un unicast, se utiliza la difusión para determinar la conexión, y se requiere un apretón de manos de tres vías para establecer la conexión y cuatro vías para desconectarla.
Redes de Área Local (LAN), Redes de Área Extensa (WAN) e Internet
Una LAN es un grupo de computadoras en una región específica que pueden comunicarse entre sí. Las LAN se conectan a través de routers para formar WAN que pueden comunicar dispositivos en un área más amplia. Las diversas LAN y WAN componen nuestra Internet. Aunque todas las redes están conectadas, existen puertas de enlace entre diferentes redes y los computadores tienen firewalls, por lo que no toda la información puede fluir libremente.
Modelo OSI de Siete Capas
Capa de Aplicación -- Capa de Presentación -- Capa de Sesión -- Capa de Transporte -- Capa de Red -- Capa de Enlace de Datos -- Capa Física
Las tres primeras capas a menudo se combinan en una sola capa de aplicación.
- Capa Física: Transmite solo binarios, en Python es de tipo bytes.
- Capa de Enlace de Datos: Sigue el protocolo Ethernet. Cada dispositivo electrónico tiene una tarjeta de red con una dirección MAC única (12 dígitos hexadecimales: 6 primeros del fabricante, 6 últimos del número de serie).
- Capa de Red: Sigue el protocolo IP. Asigna dinámicamente direcciones IP a los dispositivos cuando se conectan a una LAN y puede identificar su posición única en la red.
- Capa de Transporte: Sigue el protocolo de puertos. El número de puerto es el número de aplicación en una computadora.
- Capa de Aplicación: Sigue varios protocolos como HTTP para arquitecturas B/S.
Tres Apretos de Manos y Cuatro Despedidas
- El cliente inicia activamente la conexión, estado SYN-SENT.
- El servidor recibe pasivamente la conexión, inicialmente en estado LISTEN, después de recibir la solicitud de conexión envía una señal y entra en estado SYN-RECV.
- El cliente recibe la señal de respuesta + solicitud del servidor, entra en estado ESTABLISHED y responde al servidor.
- El servidor recibe la respuesta y también entra en estado ESTABLISHED, ambas partes pueden enviar y recibir datos punto a punto continuamente.
- El cliente desea desconectar, envía una solicitud de desconexión, entra en estado FIN-WAIT-1.
- El servidor recibe la solicitud, responde y entra en estado CLOSE-WAIT (el servidor aún puede enviar unicast unidireccionalmente al cliente).
- El cliente recibe la respuesta, entra en estado FIN-WAIT-2.
- Después de enviar los últimos datos, el servidor finaliza el estado CLOSE-WAIT, envía una solicitud de desconexión al cliente y entra en estado LAST-ACK.
- El cliente recibe la solicitud de desconexión, devuelve una respuesta y cierra el socket después de un tiempo.
- El servidor recibe la respuesta y cierra el socket.
Módulo Socket
Capa de Abstracción Socket
Los protocolos desde la capa física hasta la capa de transporte son bastante estables, por lo que no necesitamos reescribir el contenido de los protocolos superiores a la capa de transporte. El módulo socket lo hace por nosotros. Podemos ver que sobre la capa de transporte hay una capa de abstracción socket que gestiona todo el contenido de los protocolos inferiores, permitiendo que nuestros datos se transmitan a través de la red.
Implementación de TCP con el módulo socket
Servidor
# Importar el módulo socket
import socket
# 1. Crear un objeto socket especificando la versión de comunicación y protocolo (TCP)
servidor = socket.socket() # Sin parámetros, por defecto es TCP protocolo familia=AF_INET basado en red tipo=SOCK_STREAM protocolo de flujo que es TCP
# 2. Enlazar una dirección fija (condición necesaria para el servidor)
servidor.bind(('127.0.0.1', 8080)) # 127.0.0.1 es la dirección de bucle local, solo se puede acceder desde la propia computadora
# 3. Establecer la semiconexión pool (ignorar por ahora)
servidor.listen(5)
# 4. Esperar conexiones
conexion, direccion = servidor.accept() # return conexion, direccion tres apretos de manos
print(conexion, direccion) # conexion es el canal bidireccional, direccion es la dirección del cliente
# 5. Servir al cliente
datos = conexion.recv(1024) # Recibir mensaje del cliente 1024 bytes
print(datos.decode('utf8'))
conexion.send('Soy el servidor~hola'.encode('utf8')) # Enviar mensaje al cliente, debe ser bytes
# 6. Cerrar el canal bidireccional
conexion.close() # cuatro despedidas
# 7. Cerrar el servidor
servidor.close() # Cerrar el servidor (generalmente no necesario)
Cliente
# Importar el módulo socket
import socket
# 1. Generar objeto socket especificando tipo y protocolo
cliente = socket.socket()
# 2. Conectar al servidor mediante la dirección del servidor
cliente.connect(('127.0.0.1', 8080))
# 3. Enviar mensaje directamente al servidor
cliente.send('Soy el cliente~adios'.encode('utf8'))
# 4. Recibir mensaje del servidor
datos = cliente.recv(1024)
print(datos.decode('utf8'))
# 5. Desconectar del servidor
cliente.close()
Métodos del módulo socket
socket(): Crea un objeto socket especificando la versión de comunicación y protocolo.bind((ip, puerto)): Enlaza una IP y puerto fijos, esencial para el servidor.listen(5): Establece el semiconexión pool con capacidad 5.connect((ip, puerto)): Envía solicitud de conexión al servidor y recibe señal de respuesta (tres apretos de manos).accept(): Espera solicitud de conexión del cliente y envía señal de respuesta (tres apretos de manos). Devuelve un canal bidireccional y la dirección del cliente.(datos binarios): Permite que ambas partes establecidas envíen mensajes.recv(bytes): Permite que ambas partes establecidas reciban mensajes.
Fenómeno de paquetes pegajosos en TCP
Debido a que TCP es un protocolo de flujo, todos los paquetes de datos están pegados. Al recibir, no se sabe cuántos bytes de datos llegarán, por lo que no se pueden recibir los paquetes deseados con precisión.
Encabezados y Encabezados Secundarios
El método recv puede especificar una longitud de bytes para recibir, por lo que el receptor primero puede recibir datos de longitud fija que contengan información sobre la longitud real de nuestro paquete. Luego, usando esta información, recibimos el paquete de datos real.
El módulo struct puede empaquetar un entero en datos de longitud fija de 4 bytes mediante el modo 'i', cumpliendo con la condición anterior. Sin embargo, el rango de los enteros tiene un límite superior, por lo que necesitamos usar encabezados secundarios: convertir la información que contiene la longitud del archivo en un paquete de longitud fija de 4 bytes usando el módulo struct. Luego, el receptor recibe primero el encabezado secundario de longitud fija de 4 bytes, y según la longitud descomprimida, recibe el encabezado primario, y finalmente según la longitud del encabezado primario, recibe los datos reales.
Implementación de UDP con el módulo socket
Servidor
import socket
servidor = socket.socket(type=socket.SOCK_DGRAM) # Establece conexión con protocolo UDP
servidor.bind(('127.0.0.1', 8080)) # Puede enlazar a un puerto específico
while True:
datos, direccion = servidor.recvfrom(1024) # recvfrom recibe mensajes UDP, devuelve información y dirección del remitente
print(f'Mensaje de {direccion}: {datos.decode("utf8")}')
servidor.sendto('Recibido'.encode('utf8'), direccion) # sendto(mensaje, dirección del receptor)
Cliente
import socket
conexion = socket.socket(type=socket.SOCK_DGRAM) # Establece conexión con protocolo UDP
conexion.sendto('Envíe un mensaje, ¿lo recibiste?'.encode(), ('127.0.0.1', 8080))
datos, direccion = conexion.recvfrom(1024)
print(f'Respuesta de {direccion}: {datos.decode("utf8")}')
Comparación entre TCP y UDP
El código de UDP es más simple. El servidor no tiene listen, accept y el cliente no tiene connect, es decir, no hay proceso de apretón de manos. Los métodos de envío y recepción también son diferentes:
- TCP envía
conexion.enviar(datos:bytes), ya que el canal conexion tiene información de dirección enlazada. - TCP recibe
conexion.recibir(1024). - UDP envía
conexion.enviar_a(datos:bytes, direccion:(ip,puerto)). - UDP recibe
datos,direccion = conexion.recibir_desde(1024), ya que el socket UDP no tiene información de dirección, se necesita pasar adicionalmente.
Teoría de Programación Concurrente
Desarrollo de Sistemas Operativos
Desde tarjetas perforadas hasta sistemas de procesamiento por lotes en línea y luego fuera de línea, se ha mejorado constantemente la utilización de la CPU.
Lo anterior se conoce como tecnología de canal único tradicional, donde la CPU debe esperar la lectura y salida de los programas, es decir, esperar operaciones de E/S.
Para mejorar aún más la utilización de la CPU, los sistemas operativos desarrollaron la tecnología de canales múltiples. Cuando un proceso encuentra una E/S, se interrumpe automáticamente y se pasa el derecho de ejecución de la CPU a otros procesos. La interrupción de procesos ayuda a los procesos a guardar el estado del programa y cambiar entre ellos. El cambio entre procesos permite que la CPU siempre esté en estado de ejecución de programas, mientras que las operaciones de E/S son completadas por componentes como memoria y disco duro, y la CPU solo necesita emitir instrucciones de E/S.
Procesos
Paralelismo y Concurrencia
- Paralelismo: Requiere múltiples CPU para implementar, estado donde múltiples programas se ejecutan simultáneamente.
- Concurrencia: Puede lograrse mediante canales múltiples. A través de los espacios de E/S de los programas, múltiples programas parecen ejecutarse simultáneamente.
Algoritmos de Planificación de Procesos
La tecnología de canales múltiples inicialmente adoptó estrategias de "primero en llegar, primero en ejecutar" y "trabajo corto primero". Sin embargo, pronto se descubrió que ciertos tipos de trabajos tenían dificultades para obtener el derecho de ejecución de la CPU. Más tarde se desarrollaron estrategias de "round-robin con cuantos de tiempo" y "colas multinivel con retroalimentación".
- Round-robin con cuantos de tiempo: Además de las operaciones de E/S, se introduce un cuantos de tiempo fijo. Cuando se encuentra una E/S o el cuantos de tiempo termina, ocurre el cambio de proceso. Esto permite que todos los programas usen la CPU con la mayor frecuencia posible.
- Colas multinivel con retroalimentación: Asigna longitudes de cuantos de tiempo según el número de veces que un proceso cambia debido a que el cuantos de tiempo termina, adaptándose a las características de diferentes trabajos y haciendo la asignación de CPU más flexible y razonable.
Tres Estados de un Proceso
- Listo: Estado de espera para ser ejecutado por la CPU.
- Ejecución: Estado de ser ejecutado actualmente por la CPU.
- Bloqueado: Estado de E/S.
El cambio de CPU se centra en la alternancia del estado de ejecución de los procesos. El proceso en ejecución encuentra una E/S y entra en estado bloqueado. Debido a que el cuantos de tiempo termina, entra en estado listo. El proceso en estado bloqueado completa la E/S y vuelve a esperar la ejecución de la CPU, entrando en estado listo.
Síncrono y Asíncrono
La tecnología de canal único tradicional es síncrona, porque cada programa sigue al siguiente, y sus estados pueden basarse en la finalización del programa anterior. Asíncrono significa que los procesos pueden seguir su propio ritmo al mismo tiempo. La ventaja es usar una CPU limitada para completar más programas. Sin embargo, la ejecución asíncrona tiene un problema: si los procesos necesitan resultados o datos de otros procesos, la ejecución asíncrona puede causar desorden en el programa. En este caso, un proceso debe esperar a que se cumplan sus condiciones previas, convirtiendo asíncrono en síncrono.
Bloqueante y No Bloqueante
Si un proceso entra frecuentemente en estado bloqueado, su eficiencia es baja porque debe esperar los resultados de su E/S para volver al estado listo. Si un proceso se ejecuta frecuentemente en estado no bloqueante, su eficiencia es alta porque puede obtener más tiempo de ejecución de la CPU. Los juegos se acercan más a un estado asíncrono no bloqueante, requieren colaboración multiproceso asíncrona y rara vez entran en estado bloqueado debido a E/S u otras esperanzas, consumiendo más CPU pero con mayor eficiencia del programa.
Módulo Multiprocessing
Creación de Procesos en Python
Método 1: Usar Process directamente
from multiprocessing import Process
import time
def tarea(nombre):
print('La tarea se está ejecutando', nombre)
time.sleep(3)
print('La tarea ha terminado', nombre)
if __name__ == '__main__':
p1 = Process(target=tarea, args=('leethon',)) # Parámetros posicionales, forma de tupla
p1.start() # Iniciar proceso
time.sleep(1)
print('Proceso principal') # El proceso actual continúa ejecutando su programa
Notas sobre la creación de procesos
En Windows, la creación de procesos subyacente ejecutará el archivo py donde se encuentra la tarea del proceso como si importara un módulo. Por lo tanto, cuando usamos una función en un archivo como tarea de proceso, deberíamos agregar if __name__ == '__main__': para evitar entrar en un ciclo de proceso. En sistemas Mac y Linux, el sistema reconocerá los nombres de las variables necesarias en la tarea de función y la ejecutará.
Método 2: Heredar de la clase Process
from multiprocessing import Process
import time
class MiProceso(Process): # Heredar de la clase Process
def __init__(self, nombre, edad): # Para pasar parámetros, de esta forma, se activa al llamar a la clase
super().__init__() # Derivar el método original
self.nombre = nombre # Agregar nuevos atributos después de crear un proceso (o modificar atributos existentes)
self.edad = edad
def run(self): # Esto es la tarea en el método 1, start ejecutará su código
print('run se está ejecutando', self.nombre, self.edad)
time.sleep(3)
print('run ha terminado', self.nombre, self.edad)
if __name__ == '__main__':
obj = MiProceso('leethon', 18)
obj.start() # Iniciar subproceso
time.sleep(1)
print('Proceso principal')
Método join de Procesos
El método join de un subproceso hace que el proceso principal espere a que este subproceso termine antes de continuar ejecutando operaciones posteriores. Este proceso de espera es convertir asíncrono en síncrono, haciendo que el programa espere a un estado para alcanzar un estado síncrono.
def tarea(nombre, n):
print('%s se está ejecutando' % nombre)
time.sleep(n)
print('%s ha terminado' % nombre)
#########main
p = Process(target=tarea, args=('leethon1', 1))
p.start() # Asíncrono
p.join() # El código del proceso principal espera a que el código del subproceso termine antes de ejecutarse
print('Principal')
Aislamiento y Comunicación entre Procesos
Los nombres de los procesos pueden entenderse como variables globales en diferentes módulos, que no pueden afectarse entre sí. Sin embargo, muchos programas multiproceso que colaboran necesitan operar los mismos datos, como software de compra de boletos.
La comunicación IPC entre procesos se puede lograr mediante la cola de mensajes Queue del módulo multiprocessing, o mediante archivos.
Cola de Mensajes
La cola de mensajes puede ser operada por el proceso principal y cualquier subproceso:
- Generar una cola:
q = Cola(n), n es la capacidad máxima de mensajes. - Insertar datos en la cola:
q.poner(datos), cuando la cola está llena, espera en su lugar hasta que se retiren datos de la cola. - Obtener datos de la cola:
q.obtener(), cuando la cola está vacía, espera en su lugar hasta que lleguen nuevos datos a la cola.
La inserción y exrtacción de datos en la cola son mutuamente por defecto, no habrá dos procesos retirando los mismos datos. La cola sigue el principio FIFO (primero en entrar, primero en salir).
Archivos como Comunicación Interproceso
Los archivos como datos independientes también pueden ser operados por varios procesos:
- Los datos de un archivo bajo la operación de múltiples procesos del mismo programa pueden ser repetidamente operados en los espacios de tiempo de inserción y extracción, lo que puede resultar en pérdida de datos y operaciones redundantes.
- Deberíamos bloquear los datos del archivo antes y después de la operación. El bloqueo se puede lograr usando la clase Lock del módulo multiprocessing. Los objetos de clase Lock también se pueden transmitir entre diferentes procesos.
Modelo Productor-Consumidor
En resumen, los productores generan datos, los consumidores consumen datos. Aunque parece una relación directa correspondiente, necesitamos prestar atención a la dimensión del tiempo. Los datos generados por los productores necesitan un búfer para que los consumidores los consuman, por lo que se necesitan datos como colas de mensajes y archivos como intermediarios.
Otros Métodos del Módulo Multiprocessing
Verificación de ID de Proceso, Terminación y Estado
# 1. Cómo verificar el ID del proceso
from multiprocessing import Process, current_process
proceso_actual = current_process() # <_MainProcess name='MainProcess' parent=None started
pid_actual = proceso_actual.pid # 18792
import os # El módulo os también puede verificar
pid_sistema = os.getpid() # 18792
pid_padre = os.getppid() # 2020 # PID del proceso padre
# 2. Terminar proceso
p1.terminate()
'''
ps: Los sistemas operativos tienen comandos correspondientes para terminar procesos directamente
Por ejemplo, en Windows, se puede usar la instrucción taskkill para terminar procesos, y se puede ver el uso detallado de la instrucción win con help nombre_de_instrucción
'''
# 3. Verificar si el proceso está vivo
p1.is_alive()
Procesos Demonio
Antes de iniciar el subproceso, si se cambia su atributo daemon a True, puede hacerse pasando un parámetro de palabra clave inicial en el paréntesis del objeto Process o modificando el atributo del objeto de proceso después de la inicialización.
### 1
p1 = Process(target=tarea, args=('leethon',))
p1.daemon = True # Poner antes de iniciar el subproceso, indica que es un demonio del proceso principal
p1.start()
### 2
p1 = Process(target=tarea, args=('leethon',), daemon = True)
p1.start()
Si un subproceso se establece como demonio, cuando el proceso principal termina, el subproceso también termina simultáneamente.
Procesos Huérfanos y Procesos Zombie
- Proceso Zombie: Después de que un proceso termina, no destruirá inmediatamente todos sus datos. Algunos datos se conservan brevemente, como el ID del proceso, el tiempo de ejecución del proceso, el consumo de energía del proceso, etc., para que el proceso padre pueda verlos.
- Proceso Huérfano: El subproceso se ejecuta normalmente, el proceso padre muere accidentalmente, el sistema operativo se encargará de los procesos huérfanos.