2 puntos por GN⁺ 3 시간 전 | 1 comentarios | Compartir por WhatsApp
  • 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 execfd o la ruta absoluta filename, 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 de posix_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, y exec() 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 inmediatamente exec(), y exec() descarta toda la memoria copiada para el hijo
  • Ha habido intentos de optimización como vfork(), pero el patrón fork() seguido de exec() 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() y exec()
  • 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);
  • Esta llamada devuelve un descriptor de archivo que representa una plantilla de ejecutable
  • El ejecutable debe especificarse mediante el descriptor de archivo execfd o la ruta absoluta filename, 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_args
    • argv es un puntero a la lista de argumentos que se pasarán al programa
    • envp es un puntero al entorno del programa
    • actions es un puntero a un arreglo de spawn_template_action que transmite cambios de descriptores de archivo y de manejo de señales
    Publicidad
  • spawn_template_action está compuesto por los campos type, flags, fd, newfd y arg
    • Si en el hijo debe cerrarse el descriptor de archivo 4, type se establece en SPAWN_TEMPLATE_ACTION_CLOSE y fd en 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
  • 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);
  • 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ón fork()/exec()
  • La implementación actual oculta internamente fork() y exec(), 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

 
GN⁺ 3 시간 전
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 operativos
    En 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

    • La razón de que fork()+exec() terminara así fue permitir ejecutar programas demasiado grandes para caber en memoria junto con el programa padre
      La 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 a exec()
      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 .so que lo inicia; el cargador de programas corre en espacio de usuario sin privilegios. Probablemente este enfoque se acerca más a la forma correcta
    • Es interesante que la creación de procesos en Windows, que es el sistema operativo “grande” más usado que no emplea fork(), sea muy lenta
      Estoy de acuerdo en que debería existir una primitiva distinta de fork(), pero no estoy seguro de que el rendimiento sea el mejor argumento
    • Este artículo también es bueno, y la referencia [29] fue especialmente buena porque trata los aspectos sutiles de las interfaces escalables, incluyendo fork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • La discusión de entonces está aquí: https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 comments)
    • fork() es excelente para el patrón zygote
      Cuesta 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

    • Normalmente quieres comunicarte con ese proceso, así que necesitas configurar cosas como descriptores de archivo y pasar información del proceso padre
    • ¿Eso no se resuelve con O_CLOEXEC?
    • Si con “una forma de expresar directamente lo segundo” te refieres a eso, ¿no es precisamente para eso posix_spawn?
    • ¿Qué significa exactamente “un proceso completamente nuevo”?
  • 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 a fork() le sigue inmediatamente exec(), lo que desecha toda la memoria copiada cuidadosamente para el hijo” y no mencionar copy-on-write
    Esa es la optimización que evita copiar realmente toda la memoria, y aquí falta

    • El artículo lo trató de forma implícita, pero aquí la copia del estado del proceso se refiere a las estructuras de administración de memoria. Principalmente tablas de páginas y VMA
      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
    • Redis es un tipo de proceso en el que este costo importa mucho. fork() no copia la memoria en sí, pero aún debe copiar las tablas de páginas
      Si 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 .rdb o reescribe el AOF de logging binario
      Ya 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.xlarge usando 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áginas
      Sorprende 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
    • Para el público al que va dirigido este tipo de artículo, copy-on-write probablemente se omite por darse por sabido
    • Incluso con copy-on-write, 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 ejecute exec()
    • El texto dice “estado”. Incluso con copy-on-write, aunque no se copien los contenidos, sigue habiendo un costo proporcional al número de entradas en la tabla de páginas
      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 de fork(), se puede usar la API normal tal cual para hacer todo tipo de configuraciones
    Hasta 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

    • No estoy del todo de acuerdo, pero le veo utilidad. Aunque fork()/exec() puede ser útil en algunos casos, parecería bastante bien que las APIs aceptaran un argumento pidfd. El 0 podría significar el proceso actual
      El problema serían más bien los binarios setuid/setgid; en ese caso quizá sería mejor manejarlo de forma especial en exec
      Por ejemplo, se podría crear un proceso detenido con pidfd_t ps = spawn(); y configurarlo con setuid(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 hilos
      Aun 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 usuario
    • Pienso exactamente lo contrario. El gran error del modelo tipo UNIX es que al crear un proceso se conserva demasiado estado
      Por 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 correcta
    • Llamarlo elegante es dependencia de la trayectoria de la historia de fork()+exec()
      En otro mundo donde fork()+exec() nunca hubiera existido, muchas de esas “APIs generales” tendrían un argumento pid explí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
    • La forma correcta de eliminar fork() es que las APIs generales que cambian el estado del proceso acepten un handle de proceso explícito
      Entonces 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
    • El orden debería ser spawn, configure, exec
      Si el proceso empezara con ptrace conectado 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 ficticio
  • Es sorprendentemente común el malentendido de que fork() es barato, pero es O(N) respecto al tamaño del proceso, y siempre lo ha sido
    Sí, 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() y exec() internamente como hace la implementación actual”

    • Parece que hay interés no en esa implementación concreta, sino en el concepto mismo
  • 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 relacionada
    Como 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. Reiniciar git una 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ú mismo
      De 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
    • Como alguien que empezó en Windows, el modelo fork()+exec() nunca me hizo sentido. Ahora sé que es solo una rareza histórica, pero todavía hay gente que actúa como si fork()+exec() realmente fuera algo bueno
    • Está libgit2. Uno podría imaginar una comunicación por tuberías o sockets con algún gitd, pero no veo por qué sería una buena idea. Si no, hay que lanzar un proceso
  • La razón por la que es difícil reemplazar exec/fork es 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, configurar seccomp y ajustar privilegios
    Pero 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 spawn podrí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ía exec() del programa principal
    Así se podría evitar forkar la memoria y mantener la API existente, aunque igual habría que duplicar descriptores de archivo y otras cosas

    • Por suerte, parece que alguien viajó en el tiempo, vio este artículo y lo agregó a POSIX.1-2001 :)
      Perdón si no era broma, pero posix_spawn() ya existe y en glibc fork es simplemente un alias de clone()
      Aunque no sea exactamente igual a la propuesta original, fork()/exec() de verdad ya está cerca de ser legado
  • Si fork y exec pudieran 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 diferida

  • Ha habido muchas discusiones sobre esta antigua API en Hacker News; por ejemplo, https://news.ycombinator.com/item?id=31739794