Más allá de `fork()` + `exec()`
(lwn.net)- plantillas de spawn es una propuesta de creación de procesos para el kernel de Linux con la que el kernel busca cachear la información de un ejecutable en aplicaciones que ejecutan repetidamente el mismo binario, para acelerar así los inicios posteriores de procesos
- fork() debe copiar todo el estado del proceso, incluida la memoria, para el proceso hijo, y cuando justo después llega
exec(), esa memoria muchas veces se descarta, lo que genera ineficiencias en el patrón tradicional - spawn_template_create() devuelve un descriptor de archivo de plantilla especificando el ejecutable mediante
execfdo la ruta absolutafilename, y el kernel abre ese archivo y cachea la información necesaria para una ejecución rápida - spawn_template_spawn() funciona de una manera cercana a la ruta normal de
fork()/exec(), mantiene las verificaciones que se aplican al ejecutar un archivo nuevo, y los benchmarks de la carta de presentación registran una mejora de alrededor de 2% {p:2} - la creación de procesos vacíos basada en pidfd y la configuración con
pidfd_config()se considera un mejor enfoque, y el objetivo es dar soporte a una implementación deposix_spawn()en espacio de usuario
Límites del modelo de creación de procesos de Unix
- Desde los inicios de Unix,
fork()ha sido la llamada al sistema orientada a procesos que crea un proceso hijo como copia del padre, yexec()ejecuta un nuevo programa en lugar del proceso actual - En el kernel de Linux, la misma funcionalidad central es más conocida como clone() y execve()
- Este modelo de creación de procesos tiene tanto elegancia como desventajas, y la propuesta de spawn templates de Li Chen, aunque no será aceptada en el kernel de Linux en su forma actual, podría conducir a nuevos primitivos de creación de procesos en el futuro
fork()es una llamada al sistema relativamente costosa porque debe copiar todo el estado del proceso, incluida la memoria, para crear el proceso hijo- A lo largo de los años ha habido varias optimizaciones, pero
fork()sigue siendo una operación intrínsecamente costosa - Con frecuencia, a una llamada a
fork()le sigue inmediatamenteexec(), yexec()descarta toda la memoria copiada para el hijo - Ha habido intentos de optimización como vfork(), pero el patrón
fork()seguido deexec()sigue siendo una estructura más costosa de lo que podría ser
Plantillas de spawn (Spawn templates)
- El conjunto de parches de Li Chen se enfoca en aplicaciones que ejecutan repetidamente el mismo binario para optimizar el patrón
fork()yexec() - Un ejemplo es un programa que necesita ejecutar Git repetidamente para obtener información sobre el contenido de un repositorio
- En esos casos, el programa puede crear una plantilla para repartir el costo de preparación entre múltiples ejecuciones y acelerar las invocaciones con esa plantilla
- La creación de la plantilla usa la llamada al sistema
spawn_template_create()- con una firma de la forma
int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
- con una firma de la forma
- Esta llamada devuelve un descriptor de archivo que representa una plantilla de ejecutable
- El ejecutable debe especificarse mediante el descriptor de archivo
execfdo la ruta absolutafilename, y no se pueden usar ambos al mismo tiempo - El kernel abre el archivo especificado y cachea diversa información necesaria para ejecutarlo más rápido después
- Cada ejecución puede tener distintos argumentos, entorno, cambios de descriptores de archivo y cambios en el manejo de señales
- La información concreta de ejecución se coloca en la estructura
spawn_template_spawn_argsargves un puntero a la lista de argumentos que se pasarán al programaenvpes un puntero al entorno del programaactionses un puntero a un arreglo despawn_template_actionque transmite cambios de descriptores de archivo y de manejo de señales
spawn_template_actionestá compuesto por los campostype,flags,fd,newfdyarg- Si en el hijo debe cerrarse el descriptor de archivo 4,
typese establece enSPAWN_TEMPLATE_ACTION_CLOSEyfden 4 - Otras acciones admiten duplicación de descriptores de archivo, apertura de archivos, cambio de directorio de trabajo y cambios en el manejo de señales
- Si en el hijo debe cerrarse el descriptor de archivo 4,
- Una vez completada la información de ejecución, se lanza un nuevo proceso con
spawn_template_spawn()- con una firma de la forma
int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
- con una firma de la forma
- Su funcionamiento interno es cercano a la ruta normal de
fork()/exec() - Todas las verificaciones normales que se aplican al ejecutar un archivo nuevo se mantienen intactas
- La información cacheada en la plantilla acelera todo el flujo de creación
- Los resultados de benchmark de la carta de presentación muestran una mejora de alrededor de 2%, una cifra que podría marcar diferencia para aplicaciones que encajen con el patrón previsto {p:2}
Hacia posix_spawn()
- Mateusz Guzik evaluó que “todo el modismo fork + exec es terrible y debería desaparecer”
- Un punto extraño del conjunto de parches es que deja intacta la parte de
fork(), que es donde se considera que está la mayor parte del costo - La optimización debería eliminar la copia del proceso actual y crear un “proceso prístino”
- Christian Brauner sostuvo que la idea de una builder API para
exec“no es tan extraña” - Sin embargo, prefiere un enfoque en el que la nueva API se construya sobre la abstracción existente de pidfd
- No hay detalles concretos todavía, pero el enfoque correcto sería agregar a pidfd_open() una opción para crear un proceso vacío
- Luego se llamaría varias veces a una nueva llamada al sistema
pidfd_config()para aplicar al nuevo proceso la configuración deseada, como el entorno y la imagen a ejecutar pidfd_config()cumple un papel similar al de fsconfig()- Un objetivo importante de la nueva interfaz es dar soporte en espacio de usuario a la implementación de posix_spawn()
posix_spawn()encaja bien como alternativa al patrónfork()/exec()- La implementación actual oculta internamente
fork()yexec(), mientras que una implementación nativa tendría una estructura distinta - Li Chen coincidió en que la API esbozada en términos amplios por Brauner parece mejor, y planea orientar el trabajo futuro en esa dirección
- Las spawn templates no llegarán al kernel de Linux, pero si el trabajo futuro da frutos, Linux podría terminar teniendo una implementación adecuada de
posix_spawn()
3 comentarios
Opiniones en Lobste.rs
No me queda muy claro por qué fork+exec existe en primer lugar
Si diseñara llamadas al sistema de gestión de procesos sin saber mucho de desarrollo de kernel, probablemente pensaría que el caso común es “crear un nuevo proceso hijo limpio” y que el caso excepcional es “copiar el proceso que se está ejecutando actualmente”
Lo segundo parecería algo que se usaría solo en lugares como servidores web que lanzan muchas copias del mismo proceso, así que me da curiosidad el contexto histórico de por qué fork fue diseñado de esa manera
fork()+exec()está explicada en detalle en un hilo en Orange SiteEn muchos sistemas, la cantidad de procesos hijos y de hilos creados puede ser mayor que la cantidad de llamadas a
execve()No conozco la historia antigua, pero la semántica en sí es mucho más vieja que Linux y, lógicamente, como de todos modos se necesitaba
fork(), parece que construir encima de eso era más simple y componible que tener una API separada tipoexec_in_a_new_process()que duplicara parte de la lógica de creación de procesosAdemás, la estructura separada permite usar solo
execve()sinfork()para reemplazar el proceso actual por otro programa, y en algunos casos eso es una optimización aceptableEs decir, si tu trabajo ya terminó y quieres pasarle la estafeta a otro programa, puedes hacerlo sin seguir ocupando una entrada en la tabla de procesos ni memoria
En aplicaciones donde el rendimiento importa, incluso solo
fork()oexecve()ya pueden ser lentos, así que una API más concisa no resuelve todos los problemasPor eso aparecen optimizaciones como el proceso Zygote precargado de Android o los workers de servidor web preforkeados, y con ese enfoque basta con un cambio de contexto
execpuedes cambiar libremente el entorno, grupo, usuario, descriptores de archivo, máscara de señales y cosas asíSi miras
posix_spawn, una API moderna para “crear un nuevo proceso hijo limpio”, tiene decenas de flags y opciones, y también es mucho más difícil de extenderforkes el subshellCuando se crea un subshell desde el shell padre, tiene exactamente el mismo entorno, así que en la práctica es
fork()Cuando portearon
asha Windows, tuvieron que serializar todos los datos hacia el nuevo proceso de subshellCreo que estaría bien esperar una o dos semanas antes de publicar contenido de LWN
Ellos merecen conseguir suscriptores, y sus suscriptores también merecen disfrutar de lo bueno
Una ventaja de
fork()es que las capacidades de los procesos pueden ampliarse sin que todos los programas tengan que cambiar su código de creación de procesosPor ejemplo, con
chrootun proceso obtiene la capacidad de tener un directorio raíz, con el control de trabajos obtiene una terminal de control, o se le agregan namespaces de redEn
fork, el nuevo proceso hereda naturalmente las nuevas capacidades del padre, así que las funciones nuevas se comportan de forma consistente incluso en programas antiguosSolo los programas que manipulan esa nueva función necesitan conocerla
En cambio, si un proceso nuevo fuera realmente vacío y hubiera que configurar todo con algo como
pidfd_config(), entonces habría que actualizar todos los programas que crean otros procesos para que configuren correctamente cada función nuevaEn sistemas no Unix de este tipo, quizá habría que tener un objeto concreto separado del proceso para representar, en sentido amplio, el entorno del proceso, incluyendo directorio de trabajo, namespaces, propietario y grupos, entre otras cosas
Pero entonces sería difícil trazar la línea entre qué parte es estado del entorno y desde dónde empieza el estado del proceso
Incluso da para pensar si el entorno, en vez de ser la parte heredable del estado del proceso como en Unix, debería ser un contenedor compartido por varios procesos
Creo que todo esto podría resolverse mejor con
clone/execEl núcleo del problema de
forkestá en cortar el uso compartido de memoria, y cuando entran en juego los hilos y muchas entradas de la tabla de páginas, ese modelo no encaja bienHacer exec desde un hilo de memoria compartida resuelve buena parte de esto
Parece que habría que experimentar un poco
Parece una optimización a nivel de kernel que puede crear rápidamente procesos finales como
child_process.Comentarios en Hacker News
Hay un artículo relacionado, A fork() in the road: https://www.microsoft.com/en-us/research/wp-content/uploads/...
En el resumen se sostiene que, al contrario de la idea común de que la combinación
fork()+exec()es un diseño inspirado, fue un hack ingenioso para las máquinas y programas de los años 70, pero ahora es una mala abstracción para los programadores modernos y además limita la implementación de los sistemas operativosEn vez de mantenerlo como una primitiva de primer nivel del sistema operativo, plantean que debería enseñarse como un artefacto histórico y no ser la primera forma de creación de procesos que aprenden los estudiantes
fork()+exec()terminara así fue permitir ejecutar programas demasiado grandes para caber en memoria junto con el programa padreLa implementación original, al llamar a
fork(), intercambiaba a disco el programa que hacía el fork y, antes de devolverle el control, duplicaba y ajustaba la entrada de la tabla de procesos para que existieran un proceso en memoria y otro intercambiado a disco; el que quedaba en memoria recibía el control y podía llamar aexec()Gracias a este método se podían ejecutar programas grandes incluso en máquinas PDP-11 pequeñas, y era necesario en una época en la que la memoria era muy cara
Curiosamente, en QNX la carga de programas no está dentro del sistema operativo sino en una biblioteca. Lee el encabezado del ejecutable, asigna memoria, carga el programa, lo prepara para ejecutarse y lo enlaza con un
.soque lo inicia; el cargador de programas corre en espacio de usuario sin privilegios. Probablemente este enfoque se acerca más a la forma correctafork(), sea muy lentaEstoy de acuerdo en que debería existir una primitiva distinta de
fork(), pero no estoy seguro de que el rendimiento sea el mejor argumentofork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()es excelente para el patrón zygoteCuesta imaginar una optimización tan eficiente y elegante como esa
Hace poco tuve un bug oscuro causado por tener que cerrar más descriptores de archivo en un proceso forkeado
En mi experiencia, es mucho más común querer “un proceso completamente nuevo” que “una copia del proceso actual”, y se siente raro que no haya una forma de expresar directamente lo segundo y que solo pueda aproximarse clonando primero y arreglando después
O_CLOEXEC?posix_spawn?Es raro decir que “
fork()es una llamada al sistema relativamente costosa, y tiene que copiar todo el estado del proceso, incluida la memoria, para el proceso hijo. Ha habido muchas optimizaciones a lo largo de los años, pero en esencia sigue siendo una operación cara. Peor aún, en muchos casos afork()le sigue inmediatamenteexec(), lo que desecha toda la memoria copiada cuidadosamente para el hijo” y no mencionar copy-on-writeEsa es la optimización que evita copiar realmente toda la memoria, y aquí falta
Aunque la memoria apuntada por las páginas reales se comparta, igual hay que asignar páginas nuevas para guardar copias de esas estructuras. Y recorrerlas todas para copiarlas sigue siendo costoso
fork()no copia la memoria en sí, pero aún debe copiar las tablas de páginasSi se trata de un proceso con decenas de GB de RAM,
fork()puede tardar bastante, y eso ocurre cada vez que Redis hace un volcado del archivo.rdbo reescribe el AOF de logging binarioYa en 2012 había una publicación que mostraba el alto costo de esta operación: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
En una
m2.xlargeusando unos 25 GB de RAM,fork()tardó 5.67 segundos. Considerando que los clientes de Redis normalmente experimentan latencias de un solo dígito de milisegundos en la mayoría de las operaciones, es una pausa muy larga. Y eso es solo el tiempo de copia de las tablas de páginasSorprende que no se mencione huge page, porque aquí parece una consideración clave. Catorce años después el hardware será más rápido, pero probablemente las instancias de Redis también usen más RAM, así que sería interesante repetir ese benchmark
fork()tiene que pagar el costo de configurarlo. Si el proceso padre tiene muchos hilos ocupados, por ejemplo en Java, puede producirse mucho copy-on-write innecesario antes de que se ejecuteexec()Que forkar programas con tamaños grandes de memoria virtual es lento es un problema bien conocido
La elegancia del modelo
fork()+exec()está en que, después defork(), se puede usar la API normal tal cual para hacer todo tipo de configuracionesHasta ahora, las alternativas de llamada combinada que he visto parecían fundamentalmente pobres, porque hay que agregar todas las opciones de configuración como parámetros de la llamada y además hacer que se puedan ampliar más adelante sin que se vuelva un desastre
fork()/exec()puede ser útil en algunos casos, parecería bastante bien que las APIs aceptaran un argumentopidfd. El 0 podría significar el proceso actualEl problema serían más bien los binarios
setuid/setgid; en ese caso quizá sería mejor manejarlo de forma especial enexecPor ejemplo, se podría crear un proceso detenido con
pidfd_t ps = spawn();y configurarlo consetuid(ps, 33);,capset(ps, ...);,socket(ps, ...);,mmap(ps, ...);,process_vm_writev(ps, ...);,exec(ps, ...);,signal(ps, SIGCONT);También es una crítica a que la API habitual de llamadas al sistema no considera lo suficiente la pregunta “¿y si quiero hacer esta operación sobre otro proceso al que sí tengo acceso?”. Así,
fork()también podría ganar algo en seguridad de hilosAun así, sí estoy de acuerdo en que un enfoque tipo
CreateProcess, con una enorme cantidad de parámetros, no es excelente como API de espacio de usuarioPor ejemplo, existen APIs para hacer que cierto objeto quede como descriptor de archivo número 4, y luego puedes ejecutar un programa esperando que encuentre ese objeto en el descriptor 4. Eso es raro
Windows, con todos sus defectos, no usa
fork()+exec()y en cambio ofrece principalmente opciones sobre cómo crear el proceso. No era elegante, pero iba en la dirección correctafork()+exec()En otro mundo donde
fork()+exec()nunca hubiera existido, muchas de esas “APIs generales” tendrían un argumentopidexplícito para poder cambiar la configuración de otro proceso. Fuchsia funciona más o menos asíEse mundo tiene muchas ventajas. La más obvia es que no hace falta inventar mágicamente un mecanismo IPC aparte para reportar errores de configuración, y también sería bastante útil poder tener un proceso administrador que ajuste los atributos del hijo. A los depuradores en particular probablemente les encantaría
fork()es que las APIs generales que cambian el estado del proceso acepten un handle de proceso explícitoEntonces la misma API serviría para configurar un proceso vacío y también se podría combinar con otros métodos como IPC o depuración
Si el proceso empezara con
ptraceconectado y sin hilos, durante la etapa de configuración se le podría obligar a ejecutar llamadas al sistema. Como Linux ni siquiera tiene el concepto de “proceso sin hilos”, probablemente haría falta un hilo ficticioEs sorprendentemente común el malentendido de que
fork()es barato, pero es O(N) respecto al tamaño del proceso, y siempre lo ha sidoSí, es copy-on-write. Pero hay una relación lineal entre el tamaño del proceso y la cantidad de entradas de tabla de páginas necesarias para representarlo
No sorprende que hayan rechazado el parche de Chen. Es un caso de uso demasiado específico y vale poco la pena soportarlo
Desde la perspectiva de quien desarrolla shells, coincido con la conclusión de que “es probable que los desarrolladores reciban bien una implementación nativa que no esconda
fork()yexec()internamente como hace la implementación actual”Desde la primera vez que aprendí
fork(), me pareció conceptualmente horrible. Si quieres hacer una sola tarea, es decir, iniciar un proceso, no deberías tener que pasar por el hechizo enigmático de forkar el proceso actual, que es otra tarea distinta y no relacionadaComo en el ejemplo del artículo, me pregunto cuál sería la mejor forma de manejar una situación donde un proceso lanza muchos subprocesos de
git. Reiniciargituna y otra vez desde cero mientras la tarea padre sigue ejecutándose no parece tener sentido; ¿cuál sería una abstracción de bajo costo que produzca el mismo resultado?fork()es conceptualmente simple. Sin traer otras capas, empiezas un proceso con lo único cuya existencia sabes con certeza: tú mismoDe otro modo, harían falta varios pasos: crear el proceso, llenarlo con algo que ejecutar y ponerlo a correr. O habría que fusionarlo permanentemente con otras capas como el sistema de archivos, el cargador de objetos y el enlazador, como en Win32
fork()+exec()nunca me hizo sentido. Ahora sé que es solo una rareza histórica, pero todavía hay gente que actúa como sifork()+exec()realmente fuera algo buenolibgit2. Uno podría imaginar una comunicación por tuberías o sockets con algúngitd, pero no veo por qué sería una buena idea. Si no, hay que lanzar un procesoLa razón por la que es difícil reemplazar
exec/forkes que normalmente hay que configurar el proceso nuevo. Por ejemplo, hay que ajustar handlers de señales, cerrar o abrir descriptores de archivo, cambiar de namespace, configurarseccompy ajustar privilegiosPero las llamadas al sistema para eso solo se aplican al proceso actual, así que hace falta otro mecanismo. La propuesta del artículo era crear una API nueva para eso
En mi opinión, una nueva llamada al sistema como
spawnpodría crear un proceso vacío, cargar dentro un loader liviano y luego pasarle datos arbitrarios de configuración. El loader configuraría el proceso y luego haríaexec()del programa principalAsí se podría evitar forkar la memoria y mantener la API existente, aunque igual habría que duplicar descriptores de archivo y otras cosas
Perdón si no era broma, pero
posix_spawn()ya existe y en glibcforkes simplemente un alias declone()Aunque no sea exactamente igual a la propuesta original,
fork()/exec()de verdad ya está cerca de ser legadoSi
forkyexecpudieran mostrar un comportamiento persistente y algebraico más allá de su naturaleza copy-on-write, serían no solo más útiles sino también más interesantes de usar. Por ejemplo, podrían servir para evaluación diferidaHa habido muchas discusiones sobre esta antigua API en Hacker News; por ejemplo, https://news.ycombinator.com/item?id=31739794