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()
1 comentarios
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