Go está ganando una popularidad increíble. Una de las razones principales es la concurrencia simple y liviana en forma de goroutines y chanels que ofrece.
Pero porque las gorutines son livianas, simple porque no son threads. A ver, a ver, vamos por parte, porque un thread es pesado o mejor qué es un thread?
La concurrencia ha existido desde hace mucho tiempo en forma de hilos o threads que se utilizan en casi todas las aplicaciones en estos días. Un hilo es solo una secuencia de instrucciones que un procesador puede ejecutar independientemente. Los threads son más ligeros que el proceso, por lo que puede generar muchos de ellos.
Un servidor web generalmente está diseñado para manejar múltiples solicitudes al mismo tiempo. Y estas solicitudes normalmente no dependen unas de otras.
Por lo tanto, se puede crear un thread (o un pool de threads) y se pueden delegar cada request a un thread para lograr la concurrencia.
Los procesadores modernos pueden ejecutar varios threads a la vez (multi-threading) y también alternar entre threads para lograr paralelismo.
Los threads pueden verse como algo liviano, dado que:
- Los threads comparten memoria y no necesitan crear un nuevo espacio de memoria virtual cuando se crean y, por lo tanto, no requieren un cambio de contexto MMU (unidad de administración de memoria)
- La comunicación entre threads es más simple ya que tienen una memoria compartida, mientras que los procesos requieren varios modos de IPC (comunicaciones entre procesos) como semáforos, colas de mensajes, tuberías, etc.
Esto no siempre garantiza un mejor rendimiento que los procesos en este mundo de procesadores multinúcleo. p.ej. Linux no distingue entre hilos y procesos y ambos se denominan tareas. Cada tarea puede tener un nivel mínimo a máximo de uso compartido cuando se clona.
Hay tres cosas que hacen que los hilos sean lentos:
- Los hilos consumen mucha memoria debido a su gran tamaño de pila (≥ 1 MB). Por lo tanto, crear miles de hilos significa que ya necesita 1 GB de memoria.
- Los hilos necesitan restaurar muchos registros, algunos de los cuales incluyen AVX (extensión de vector avanzada), SSE (extensión SIMD de transmisión), registros de punto flotante, contador de programa (PC), puntero de pila (SP) que perjudica el rendimiento de la aplicación.
- La configuración de los subprocesos y el desmontaje requieren una llamada al sistema operativo para obtener recursos (como la memoria) que es lento.
Goroutines existe solo en el espacio virtual del runtime de go y no en el sistema operativo. Por lo tanto, se necesita un planificador Go Runtime que gestione su ciclo de vida. Go Runtime mantiene tres estructuras C para este propósito:
- La estructura G: representa una rutina única con sus propiedades, como el puntero de la pila, la base de la pila, su ID, su caché y su estado.
- La estructura M: Esto representa un hilo del sistema operativo. También contiene un puntero a la cola global de goroutines ejecutables, la goroutine actual en ejecución y la referencia al planificador
- La estructura programada: es una estructura global y contiene las colas libres y esperando gorutinas, así como subprocesos.
Entonces, en el inicio, go runtime inicia varias rutinas para GC, planificador y código de usuario. Se crea un subproceso OS para manejar estas gorutinas. Estos hilos pueden ser como máximo iguales a GOMAXPROCS.
Se crea una rutina con solo 2 KB iniciales de tamaño de pila. Cada función en go ya tiene una comprobación si se necesita más pila o no y la pila se puede copiar a otra región en la memoria con el doble del tamaño original. Esto hace que la gorutina sea muy liviana en recursos.
Si una rutina se bloquea en una llamada al sistema, bloquea su hilo en ejecución. Pero otro hilo se toma de la cola de espera de Scheduler (la estructura Sched) y se usa para otras goroutines ejecutables. Sin embargo, si te comunicas usando canales in go que solo existen en el espacio virtual, el sistema operativo no bloquea el hilo. Tales goroutinas simplemente van en estado de espera y otras goroutinas ejecutables (de la estructura M) están programadas en su lugar.
Runtime de go hace una programación cooperativa, lo que significa que otra rutina solo se programará si la actual está bloqueando o terminada. Algunos de estos casos son:
- Operaciones de envío y recepción de canales, si esas operaciones se bloquean.
- La declaración de Go, aunque no hay garantía de que la nueva rutina se programará de inmediato.
- Bloqueo de llamadas al sistema como operaciones de archivos y redes.
- Después de ser detenido por un ciclo de recolección de basura.
Más allá de un montón de tecnicismos un conjunto de go rutines puede vivir en un o varios hilos y todo esta manejado por el runtime de go que los administra.