La arquitectura de Neovim: un hilo, un bucle, cero congelaciones
Neovim ejecuta todo tu Lua en un único hilo, y sin embargo compila, indexa, habla con servidores LSP y lanza procesos sin quedarse tieso. La pieza que lo permite es libuv y su bucle de eventos. Fases del bucle, el pool de hilos que absorbe el trabajo bloqueante, la UI como cliente RPC, y la ley que gobierna todo el nivel: bloquear el bucle es congelar el editor.
Neovim ejecuta tu código Lua en un único hilo. Esa frase, que suena a limitación, es el cimiento de todo lo demás: como solo hay un hilo tocando el estado del editor —buffers, ventanas, opciones, registros—, no existen carreras de datos, ni candados, ni estructuras que proteger. El precio de esa simplicidad es brutalmente concreto: ese hilo no puede detenerse jamás, y quien lo detiene congela el editor entero. La pieza que reconcilia ambas cosas —trabajo asíncrono real sin concurrencia sobre el estado— se llama libuv, y es la misma biblioteca que sostiene Node.js.
- Describir el bucle de eventos de
libuvcomo el motor que despacha todo el trabajo de Neovim. - Distinguir el hilo principal del
poolde hilos que atiende las operaciones bloqueantes. - Explicar por qué una función Lua lenta congela la interfaz por completo.
- Situar la UI como un cliente
RPCque solo dibuja lo que el bucle le envía.
El hilo principal y su bucle
Cuando arrancas Neovim, el proceso entra en un bucle infinito que no es tuyo: es el bucle de libuv. Su trabajo consiste en preguntarle al sistema operativo, una y otra vez, “¿ha pasado algo?”. Ese algo puede ser una tecla pulsada, un byte llegando por un socket de un servidor LSP, un timer que ha vencido, un proceso hijo que ha terminado, o un fichero que ha cambiado en disco. Por cada suceso, el bucle localiza el callback registrado para él y lo ejecuta en el hilo principal, uno detrás de otro, sin solaparse nunca.
Esa última cláusula es la que define toda la arquitectura. Dos callbacks jamás corren a la vez. No hay un instante en el que dos porciones de código Lua estén a medias de modificar el mismo buffer. La asincronía de Neovim es concurrencia sin paralelismo: muchas cosas están en marcha, pero solo una se está ejecutando.
De ahí se sigue una consecuencia que conviene enunciar ya, porque explica el resto del nivel: en Neovim la unidad de trabajo no es la tarea, es el callback. Cada callback que registras es una promesa de devolver el control rápidamente, y el bucle es un contrato de cooperación entre todos los que la firman. Nadie te puede interrumpir, y por eso nadie puede rescatarte si te alargas.
flowchart TB os[Sistema operativo] -->|teclas sockets procesos| loop[Bucle de eventos libuv] loop --> cb[Callbacks en el hilo principal] cb --> estado[Estado del editor buffers ventanas] cb --> loop pool[Pool de hilos] -->|resultado| loop loop -->|trabajo bloqueante| pool ui[Interfaz TUI o GUI] -->|RPC entrada| loop loop -->|RPC redibujado| ui style loop fill:#cba6f7,color:#11111b style cb fill:#a6e3a1,color:#11111b style pool fill:#89b4fa,color:#11111b
Las fases del bucle y el pool de hilos
Una vuelta del bucle no es un bloque monolítico: libuv recorre fases en un orden fijo, y saber cuál es cuál explica muchos comportamientos que de otro modo parecen caprichosos.
| Fase | Qué atiende |
|---|---|
timers |
callbacks cuyo plazo ya ha vencido |
pending |
callbacks de entrada y salida diferidos de la vuelta anterior |
poll |
espera en el sistema operativo por actividad de descriptores |
check |
trabajo que debe correr justo después del poll |
close |
callbacks de cierre de handles que se están destruyendo |
La fase poll es donde el proceso duerme cuando no hay nada que hacer: por eso Neovim inactivo consume prácticamente cero CPU. No está girando en vacío, está bloqueado en una llamada del núcleo —epoll en Linux, kqueue en macOS y BSD, IOCP en Windows— esperando a que algo ocurra. Ese bloqueo es el único legítimo de toda la arquitectura, porque el sistema operativo puede despertarlo en el instante exacto en que llega el primer byte.
El orden de las fases explica también los plazos. Un timer programado a 10 milisegundos no dispara a los 10 milisegundos exactos, sino en la primera vuelta cuya fase timers ocurra después de ese plazo. libuv garantiza que no dispara antes, nunca que dispare puntualmente; si el callback anterior tardó 300 milisegundos, tu timer de 10 llegará con 290 de retraso y no habrá nada roto. Todos los plazos de este nivel son suelos, no citas.
Hay además una fase implícita que no aparece en la tabla y que a ti te importa especialmente: entre vuelta y vuelta, Neovim vacía su propia cola de trabajo diferido, la que alimenta vim.schedule. Ese es el punto en el que el editor está en reposo y su estado es consistente, y por tanto el único momento en el que la API puede tocarse con seguridad. Toda la lección tercera de este nivel se sostiene sobre ese hueco.
Pero hay operaciones que el sistema operativo no sabe hacer de forma asíncrona —leer un fichero, resolver un nombre de dominio, calcular un hash—. Para esas, libuv mantiene un pool de hilos aparte. El hilo principal encarga la tarea, sigue con su bucle, y cuando el hilo del pool termina, deposita el resultado en la cola del bucle para que el callback se ejecute, de nuevo, en el hilo principal. El trabajo pesado sí ocurre en paralelo; la respuesta a ese trabajo, nunca.
Un hilo del pool de libuv es un hilo de C puro: no ve tu estado Lua, no puede llamar a vim.api y no comparte el lua_State del hilo principal. Solo mueve bytes. Esta separación es exactamente lo que hace segura toda la arquitectura, y es la raíz técnica de la regla de oro que estudiarás en la tercera lección.
Bloquear el bucle es congelar el editor
Ahora la consecuencia práctica. Mientras tu callback de Lua está corriendo, el bucle no avanza: no hay fase poll, no se leen teclas, no se procesa la entrada del LSP, no se redibuja nada. El editor no está roto ni colgado; está esperando cortésmente a que devuelvas el control.
-- Bloqueante: el hilo principal se detiene hasta que el proceso acaba
local salida = vim.fn.system({ "sleep", "3" })
-- Durante esos 3 segundos Neovim no dibuja, no lee teclas, no responde
vim.notify(salida)
La versión correcta encarga el trabajo a libuv y devuelve el control de inmediato. El callback se ejecutará mucho después, cuando el bucle vuelva a pasar por la fase que corresponda.
-- No bloqueante: libuv arranca el proceso y el bucle sigue girando
vim.system({ "sleep", "3" }, { text = true }, function(obj)
vim.schedule(function()
vim.notify("codigo de salida: " .. obj.code)
end)
end)
La diferencia entre ambos fragmentos no es de estilo ni de elegancia: es la diferencia entre un editor que responde y uno que no. Todo plugin que se sienta rápido lo es porque su autor entendió esta distinción; todo plugin que produce microcongelaciones al escribir la ha ignorado en algún punto.
vim.uv.hrtime() devuelve nanosegundos monótonos y es la forma barata de auditar un callback: guarda el instante al entrar, réstalo al salir y avisa si el resultado supera tu presupuesto. Para cazar al culpable de una sesión lenta, :profile start perfil.log seguido de :profile func * y :profile dump te dice qué función se está comiendo el hilo, sin necesidad de adivinar.
Conviene fijar el orden de magnitud. El ojo percibe una pausa a partir de unos 100 milisegundos, y la escritura fluida se rompe mucho antes, cerca de los 16 milisegundos que dura un fotograma. Un callback que tarde 50 milisegundos y se dispare en cada pulsación no produce un cuelgue visible, produce algo peor de diagnosticar: una sensación difusa de que el editor “va pastoso” y que nadie sabe atribuir a un plugin concreto.
La interfaz, por su parte, ni siquiera vive dentro de ese razonamiento. La TUI de Neovim es un proceso cliente que habla con el núcleo por RPC sobre msgpack: envía las teclas que recibe y pinta las celdas que el núcleo le manda. Si el bucle se detiene, la UI no se cuelga —sigue viva, sigue redibujando lo último que sabía—, simplemente deja de recibir instrucciones. Esa es la razón de que un Neovim bloqueado se vea congelado en lugar de desaparecer, y de que una GUI externa pueda conectarse al mismo núcleo sin que este se entere de con quién habla.
Quién habita el bucle en un Neovim real
En una sesión cualquiera hay decenas de handles registrados que jamás creaste tú. Reconocerlos ayuda a entender que la asincronía no es una técnica avanzada reservada a los plugins exóticos, sino el modo normal de funcionar de todo el editor.
Clientes LSP
Cada servidor de lenguaje es un proceso hijo con tuberías. Neovim escribe peticiones y lee respuestas en la fase de poll sin detenerse nunca a esperarlas.
Treesitter
El análisis incremental se reparte en trozos que ceden el control al bucle entre uno y otro, para que un fichero enorme no bloquee la escritura.
Procesos y trabajos
Formateadores, linters y comandos de compilación viven como handles de proceso con callbacks de salida y de cierre.
Timers de plugins
Refrescos de la barra de estado, debounce de diagnósticos y reintentos de conexión son todos timers conviviendo en la misma cola.
Puedes asomarte a esa población desde dentro del propio editor. libuv sabe enumerar todos los handles que tiene registrados, y verlo con tus ojos convierte la explicación en un hecho comprobable.
-- Cuenta los handles vivos del bucle, agrupados por tipo
local cuenta = {}
vim.uv.walk(function(handle)
local tipo = handle:get_type()
cuenta[tipo] = (cuenta[tipo] or 0) + 1
end)
print(vim.inspect(cuenta)) -- timer, process, pipe, tcp, async, signal...
La lista es también un mapa de responsabilidades. Cuando el editor se atasca, el culpable está casi siempre en una de esas cuatro casillas, y la pregunta útil no es “¿qué plugin es lento?” sino “¿qué callback está tardando demasiado en devolver el control?”. Son preguntas distintas: un plugin puede hacer un trabajo enorme y no molestar a nadie si lo trocea, y otro puede hacer un trabajo trivial y arruinar la escritura si lo repite en cada tecla.
Ninguno de estos actores se pisa con los demás, porque ninguno se ejecuta a la vez que otro. Comparten el hilo por turnos, y ese reparto es tan barato que un Neovim con quince plugins activos y tres servidores LSP conectados sigue consumiendo casi nada mientras no tocas el teclado. La eficiencia no viene de hacer más cosas en paralelo, sino de no hacer nada mientras no hay nada que hacer.
Compara esta arquitectura con la alternativa obvia. Un editor multihilo con acceso concurrente al estado tendría que proteger cada buffer, cada tabla de marcas, cada lista de ventanas con algún candado; cada plugin de terceros —escrito por alguien que jamás leyó el código del núcleo— tendría que respetar esa disciplina o corromper el editor de formas irreproducibles. La superficie de error sería inmensa y estaría distribuida entre miles de autores. Neovim escoge lo contrario: un único hilo posee todo el estado, y por tanto ninguna sincronización es necesaria en el código de usuario. El coste se paga en un solo punto, y es un coste que puede enunciarse en una frase que cabe en la cabeza: no bloquees el hilo. A cambio, un plugin de mil líneas escrito por un desconocido no puede provocar una carrera de datos ni un deadlock, porque no existe nada con lo que competir. Es la misma apuesta que hizo Node.js una década antes y por las mismas razones, y explica por qué libuv —una biblioteca nacida para un runtime de JavaScript— acabó siendo el corazón de un editor de texto. Fíjate además en el orden causal: libuv no está en Neovim para hacerlo asíncrono, sino para hacerlo asíncrono sin dejar de ser de un solo hilo. Cualquier biblioteca de hilos habría dado lo primero; solo un bucle de eventos da las dos. Todo lo que estudiarás en este nivel —timers, handles, vim.schedule, debounce, corrutinas— son consecuencias de esta única decisión arquitectónica, no técnicas independientes que casualmente conviven.
Neovim corre tu Lua en un solo hilo gobernado por el bucle de eventos de libuv, que recorre fases fijas —timers, poll, check, close— y despacha callbacks de uno en uno, sin solapamiento. El trabajo genuinamente bloqueante se delega a un pool de hilos de C que no ve el estado Lua y solo devuelve resultados a la cola del bucle. La UI es un cliente RPC separado que dibuja lo que el núcleo le manda. De ahí la ley que rige todo el nivel: mientras tu código corre, el bucle no gira, y un bucle detenido es un editor congelado.
- Ejecuta
:lua vim.fn.system({ "sleep", "3" })e intenta pulsar teclas durante la espera; describe qué ocurre con lo que escribiste al recuperar el control. - Repite el experimento con la variante de
vim.systemque recibe uncallbacky compara la sensación de uso. - Con
:lua print(vim.uv.uptime())observa el reloj interno del bucle antes y después de una operación bloqueante. - Explica por qué un
callbackde Lua que tarda 50 milisegundos y se dispara en cada pulsación produce una escritura perceptiblemente pastosa. - Argumenta qué garantía perderías si Neovim ejecutase los
callbacksdelibuven hilos delpoolen lugar de en el hilo principal.