the ugly org - backend - fundamentos del Dao De Jing (道德經)

desu

The way you can go
isn’t the real way.
The name you can say
isn’t the real name.

Heaven and earth
begin in the unnamed:
name’s the mother
of the ten thousand things.

So the unwanting soul
sees what’s hidden,
and the ever-wanting soul
sees only what it wants.

Two things, one origin,
but different in name,
whose identity is mystery.
Mystery of all mysteries!
The door to the hidden.
1
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Accept a connection
5. Application logic involving reading/writing/closing to the socket
6. Goto step 4
2
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Accept a connection in a loop
5. In a new thread: Application logic involving reading/writing/closing the socket
6. Goto step 4
3
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Create an event queue
5. Add the server socket to the event queue
6. Wait for events in a loop
7. Application logic involving reading/writing/closing the socket
8. Remove socket from queue when done
9. Goto step 6
4
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Create an event queue
5. Add the server socket to the event queue
6. Create a thread pool
7. Wait for events in a loop
8. Each thread does application logic involving reading/writing/closing the socket
8. Remove socket from queue when done
9. Goto step 6
4
desu

Vamos a empezar con 1.

1 Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Accept a connection
5. Application logic involving reading/writing/closing to the socket
6. Goto step 4

Escribimos:

Y ejecutamos:

El servidor se desconecta. No podemos seguir mandando mensajes y solo respondemos a una conexión.

Añadimos un loop:

Ahora podemos mandar multiples mensajes:

Pero el servidor, no detecta que la conexión ha terminado, y hace un loop infinito para un socket cerrado que siempre devuelve [].

Si leemos la documentación de read, si n == 0, probablemente, podemos cerrar.

Ahora el servidor termina cuando el cliente termina la conexión.

Vamos a arreglar el print en servidor del mensaje, convirtiéndolo a string:

Good. Ahora vamos a mover el loop, para que en lugar de terminar, acepte una nueva conexión.

Y ahora el servidor puede aceptar multiples clientes de manera secuencial.

Seguro? Quizas no, el bucle no esta bien, el codigo espera que el ciente mande un mensaje, se desconecte, se vuelva a conectar, mande un mensaje... no puede leer multiples mensajes.

Añadimos otro loop para leer, donde lo teniamos antes, y voy a añadir un goto para salir, los gotos son geniales. Solo los malos programadores les tienen miedo porque no saben usarlos.

Y ahora si:

Ahora si? Seguro?

Nada es seguro en esta vida excepto la muerte. He cambiado el tamaño del buffer en el que leemos a 2 para que se vea mas facil:

Si nos llega un mensaje mas grande de lo que podemos leer, llenaremos el buffer, lo escribiremos, y volveremos a leer, escribiremos y volveremos a leer.

El motivo por el cual en la terminal nuestro cliente ve el mensaje completo, es por los '\n' y como ncat tiene un buffer. Segun el tipo de mensajes que enviemos este comportamiento es incorrecto. Lo que se conoce como el problema: message boundery. Que es un mensaje? Como se que he recibido un mensaje? Normalmente los protocolos tienen un campo acordado que indica la longitud esperada del mensaje. Y aqui entraria el problema de errores y ataques, que vamos a ignorar.

Problemas:

  • Message boundery, como leer y escribir un socket
  • Como tener multiples sockets
True goodness
is like water.
Water’s good
for everything.
It doesn’t compete.

It goes right
to the low loathsome places,
and so finds the way.
desu

Voy a hacer una solución multi-threading.

2
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Accept a connection in a loop
5. In a new thread: Application logic involving reading/writing/closing the socket
6. Goto step 4

Para la mayoría de aplicaciones CPU-bound esto es mas que suficiente.

Y ya tenemos multiples clientes

Si ahora tiro un benchmark, de clientes que envían peticiones HTTP, creo que mucha gente se sorprendería con el numero de peticiones por segundo que somos capaces de lograr. En eficiencia y rendimiento, menos es mas.

https://github.com/patrykstefanski/async-bench

framework	reqs/s
threads	930,142
Boost.Asio	1,011,690
Go	1,265,821
Tokio	1,120,036
async-std	974,002

Podemos aprovechar este momento para limpiar algunas cosas, el loop con el break, como esta dentro de un thread puede ser un return.

Y Rust te proporciona un iterador lazy sobre los sockets, de esta manera en lugar de un loop infinito podemos tener un for.

Y un problema seria establecer cual es el numero maximo de threads que queremos spawnear. Esto siempre lo debemos fijar como upper limit segundo el numero de CPU y threads fisicos de nuestro sistema. Voy a poner 1 numero maximo de threads. Cuando llegue una peticion que no pueda procesarse, se bloqueara, y mi sistema actuara como un FIFO. Los mensajes igualmente llegan a nuestro socket, pero no los estamos leyendo.

He puesto solo un thread para demostrarlo y fijaros que el buffer vuelve a ser 1024:

desu

Message boundery.

spoiler

Antes de explicar la solución que todo el mundo quiere ver, el famoso async, hago un paréntesis con esta pequeña ayuda que me he hecho para definir conexiones. He escrito unos tests para tratar lecturas y escrituras parciales.

Un socket es un file, como (casi) todo, tansolo necesitamos implementar Read y Write para poder leer y escribir. Asi que una conexión acepta un generico T que sea Read y Write. No tiene ninguna misterio.

Tenemos un buffer como ya hemos visto, en mi he decidido tener un tamaño fijo, y si me llega un mensaje mas grande, no leo mas. Una alternativa habitual es re-alocar y asignar un buffer mas grande. Y una optimización habitual seria tener un pool de buffers para no alocar a cada conexión.

desu

"async, wow, async async async"

3
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Create an event queue
5. Add the server socket to the event queue
6. Wait for events in a loop
7. Application logic involving reading/writing/closing the socket
8. Remove socket from queue when done
9. Goto step 6

Creo que no hay nada que le guste mas a los ignorantes en software que hablar de async. Necesitamos una API async! Compañero, necesito que las llamadas sean asyncronas. Estamos migrando los servidores HTTP a async!

Voy a usar mio, que es una abstracción encima de epoll, kqueue, IOCP. La desgracia que es poll me la voy a saltar, bastante tenemos que sufrir con la porqueria de epoll, de lo peor que se ha escrito en al historia de la informática. Y de io_uring, de momento, mejor no hablemos.

La manera en que funciona una cola es muy simple. Simplificando para nuestro caso de uso, el kernel nos avisara mediante la cola cuando podamos leer o escribir sin bloquear. Imaginaros este caso:

  1. mi socket esta ocupado
  2. trato de leer del socket, como esta ocupado me bloqueo

Lo que vamos a hacer es:

  1. registrar el socket, avisame cuando pueda escribir y leer
  2. si el socket esta ocupado, mi codigo hace otras cosas
  3. la cola me avisa que el socekt ya esta listo, lo uso y no me bloqueo
Wealth, status, pride,
are their own ruin.
To do good, work well, and lie low
is the way of the blessing.

Para usar la cola, necesitamos definir un tamaño máximo como upper bound. Voy a elegir 1024 clientes. Cada uno de los clientes tendra un Token asignado de 0..1023. Y necesitamos además un Token para nuestro server, voy a elegir el 1025.

const MAX_CLIENTS: usize = 1024;
const LISTENER: Token = Token(MAX_CLIENTS + 1);

Asi todos nuestros sockets iran del 0, 1, 2, 3, 4... 1023.
Cada vez que hagamos poll, la cola nos devolvera una lista de eventos que se podra escribir o leer.
Oye el socket 1, 3 y 6 puedes leer, el socket 2 puedes escribir.
Si no me llega ningúna notificaciones es que no puedo realizar operaciones sin bloquear.

Veamos como debemos inicializarlo con mio:

Tenemos:

  • Un servidor, listener, asignado a nuestro puerto 8080
  • Un Poll de mio, abstrae: epoll, kqueue, IOPC
  • Una lista de eventos, necesario para estas estructuras de datos, MAX_CLIENTS + 1, que es el +1? Lo acabamos de ver, el servidor.
  • Un Slab de Connection, (https://crates.io/crates/slab) un slab es una especie de array que nos sera muy util.

Que queremos? Seguiremos los pasos descritos arriba. El primero es que nuestro servidor acepte conexiónes.

Registro el listener como READABLE, cada vez que se pueda leer del listener nos llegara un evento. Esto NO significa que haya llegado una nueva conexión, no lo sabemos, lo que sabemos es que podremos leer de ese socket y la operación funcionara.


El codigo es muy simple, en un loop hacemos poll, este tiene un timeout que bloquea hasta que el kernel nos notifica que hay un evento. Como le hemos pasado toda la lista de eventos que estamos interesados, el kernel nos devolvera los que podemos leer y escribir, tanto servidor como clientes, como hemos visto en el ejemplo anterior.

Cada evento tiene un token que lo identifica, en este caso tenemos nuestro servidor LISTENER, que es el Token(1025) y token para los clientes que van de Token(0) a Token(1023) como hemos visto.

  • Para cada evento de LISTENER vamos a aceptar una conexión y añadirla a nuestros eventos y connections.
  • Para cada evento de un cliente, ya sea leer o escribir, haremos lo que toque.

Aqui es donde el codigo se vuelve loco.

El servidor es muy simple, para cada conexión, la añadimos en nuestro slab, y actualizamos nuestro registry como READABLE. Nos interesa leer de ese cliente.

uff que duro este codigo, y no esta ni al 50%.

hacemos exactamente lo que haciamos antes bloqueando, peor ahora tenemos que actualizar el poll y gestionar los errores.

  1. cliente lee
  2. si lee n == 0, es que no hay nada que hacer, limpia la conexion del registry y del slab
  3. si lee n > 0, es que hay algo que escribir, no sabemos si esto es parcial o no, solo sabemos que hemos leido algo, asique marcamos como WRITEABLE
  4. si se da el caso de que el event es WRITEABLE, escribimos

cuantos bugs tiene este codigo? infinitos. voy a resolver unos cuantos.


Funciona? Hombre, si escribimos a mano con un par de clientes funciona. Pero no esta bien.

En Rust usamos ? cuando queremos devolver un error, estos errores... no se pueden devolver nunca asi como asi... hay que gestionarlos. En el codigo del cliente tenemos 4 ?, en el del server 2?...

Can you keep your soul in its body,
hold fast to the one,
and so learn to be whole?
Can you center your energy,
be soft, tender,
and so learn to be a baby?

Empecemos con el server:

  • Primero que pasa si el listener.accept() falla? Lo voy a ignorar.
  • Segundo que pasa si el registry().register() falla? Lo voy a re-intentar hasta que funcione.

Ahora vamos a seguir con el cliente:

  • Primero que pasa si el connection.read() falla? Lo voy a ignorar.
  • Que pasa si el registry().deregister() falla? Lo voy a re-intentar hasta que funcione.
  • Segundo que pasa si el connection.write() falla? Lo voy a ignorar.

Esta esto bien?

spoiler

Lo suyo es escribir unos tests y bueno, todo funciona con estos, ya hemos testeado a parte que nuestra Connection tenga partial read y partial writes y funcione. El problema aqui esta en los edge cases asyncronos del kernel.

solucion
desu

Preguntas?


Hasta aqui hoy, voy a ir arreglando el codigo y el texto para que se entienda mejor, cuando tenga tiempo.

Me queda el multi-thread + async.

desu

Final. Multi-thread + async. Parte 1.

Esto no seria la ugly org si no os doy una master class. No hagais multi-thread + async. Hacer multi proceso + async. Seguramente esto es suficiente para el 99% de vuestros casos de uso.

Que signifca?

  • Ejecuta multiples servidores :8080, :8081, :8082, :8083
  • Cada servidor correra en un core, y sera async
  • Mete un nginx delante que balance el trafico
  • ???
  • Profit!
Hold fast to the great thought
and all the world will come to you,
harmless, peaceable, serene.

Tendrás algo mejor que Spring Boot + Netty en menos de 500 lineas de código, si no te lo crees, hazlo. Si usas Go lo puedes tener en 50 lineas de código.

1
desu

Final. Multi-thread + async. Parte 2.

4
Steps:
1. Create our socket
2. Bind it to an address
3. Put it in "server" mode (i.e. call listen on it)
4. Create an event queue
5. Add the server socket to the event queue
6. Create a thread pool
7. Wait for events in a loop
8. Each thread does application logic involving reading/writing/closing the socket
8. Remove socket from queue when done
9. Goto step 6
desu

TODO: implement a full runtime

For reference see: tokio, std-async, golang, C#/Java futures, python asyncio, C libuv, Scala finagle...

Hipnos

Me resulta curioso qué tiene que ver la alquimia interna china con programar.

1 respuesta
desu

#10

For being and nonbeing
arise together;
hard and easy
complete each other;
high and low
depend on each other;
note and voice
make the music together;
before and after
follow each other.

Fundamentos.
Escribir y leer de IO. File descriptors.
Y nada mas.


Este hilo no pretende ser un tutorial.
La ultima vez +20 personas votaron que querian que les enseñase un server tcp/http en un proyecto, y pense que hacerlo a mano con las syscall fundamentales es un ejercicio que todo el mundo debe hacer para comprender después lo que pasa.
Aquí esta unos ejemplitos que todo el mundo puede hacer en sus lenguajes preferidos.

2

Usuarios habituales

  • desu
  • Hipnos