Sistemas de compilación basados en tareas

En esta página, se describen los sistemas de compilación basados en tareas, cómo funcionan y algunas de las complicaciones que pueden ocurrir. Después de las secuencias de comandos de shell, los sistemas de compilación basados en tareas son la próxima evolución lógica de la compilación.

Explicación de los sistemas de compilación basados en tareas

En un sistema de compilación basado en tareas, la unidad de trabajo fundamental es la tarea. Cada tarea es una secuencia de comandos que puede ejecutar cualquier tipo de lógica, y las tareas especifican otras tareas como dependencias que deben ejecutarse antes. La mayoría de los principales sistemas de compilación que se usan hoy en día, como Ant, Maven, Gradle, Grunt y Rake, se basan en tareas. En lugar de secuencias de comandos de shell, la mayoría de los sistemas de compilación modernos requieren que los ingenieros creen archivos de compilación que describan cómo realizar la compilación.

Toma este ejemplo del manual de Ant:

<project name="MyProject" default="dist" basedir=".">
   <description>
     simple example build file
   </description>
   <!-- set global properties for this build -->
   <property name="src" location="src"/>
   <property name="build" location="build"/>
   <property name="dist" location="dist"/>

   <target name="init">
     <!-- Create the time stamp -->
     <tstamp/>
     <!-- Create the build directory structure used by compile -->
     <mkdir dir="${build}"/>
   </target>
   <target name="compile" depends="init"
       description="compile the source">
     <!-- Compile the Java code from ${src} into ${build} -->
     <javac srcdir="${src}" destdir="${build}"/>
   </target>
   <target name="dist" depends="compile"
       description="generate the distribution">
     <!-- Create the distribution directory -->
     <mkdir dir="${dist}/lib"/>
     <!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
     <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
   </target>
   <target name="clean"
       description="clean up">
     <!-- Delete the ${build} and ${dist} directory trees -->
     <delete dir="${build}"/>
     <delete dir="${dist}"/>
   </target>
</project>

El archivo de compilación se escribe en XML y define algunos metadatos simples sobre la compilación, junto con una lista de tareas (las etiquetas <target> del XML). Ant usa la palabra target para representar una tarea y usa la palabra task para hacer referencia a los comandos. Cada tarea ejecuta una lista de comandos posibles definidos por Ant, que en este caso incluyen la creación y eliminación de directorios, la ejecución de javac y la creación de un archivo JAR. Este conjunto de comandos se puede extender con complementos proporcionados por el usuario para cubrir cualquier tipo de lógica. Cada tarea también puede definir las tareas de las que depende mediante el atributo de dependencia. Estas dependencias forman un grafo acíclico, como se muestra en la Figura 1.

Gráfico acrílico que muestra las dependencias

Figura 1: Un grafo acíclico que muestra dependencias

Los usuarios realizan compilaciones proporcionando tareas a la herramienta de línea de comandos de Ant. Por ejemplo, cuando un usuario escribe ant dist, Ant sigue estos pasos:

  1. Carga un archivo llamado build.xml en el directorio actual y lo analiza para crear la estructura del grafo que se muestra en la Figura 1.
  2. Busca la tarea denominada dist que se proporcionó en la línea de comandos y descubre que tiene una dependencia de la tarea denominada compile.
  3. Busca la tarea denominada compile y descubre que tiene una dependencia de la tarea denominada init.
  4. Busca la tarea llamada init y descubre que no tiene dependencias.
  5. Ejecuta los comandos definidos en la tarea init.
  6. Ejecuta los comandos definidos en la tarea compile dado que se ejecutaron todas las dependencias de esa tarea.
  7. Ejecuta los comandos definidos en la tarea dist dado que se ejecutaron todas las dependencias de esa tarea.

Al final, el código que ejecuta Ant cuando ejecuta la tarea dist es equivalente a la siguiente secuencia de comandos de shell:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

Una vez que se quita la sintaxis, el archivo de compilación y la secuencia de comandos de compilación no difieren demasiado. Pero ya ganamos mucho por esto. Podemos crear nuevos archivos de compilación en otros directorios y vincularlos. Podemos agregar con facilidad tareas nuevas que dependen de tareas existentes de maneras arbitrarias y complejas. Solo necesitamos pasar el nombre de una tarea a la herramienta de línea de comandos de ant y esta determina todo lo que se debe ejecutar.

Ant es una antigua pieza de software, que se lanzó originalmente en 2000. Otras herramientas, como Maven y Gradle, mejoraron Ant durante los años y, en esencia, lo reemplazaron con funciones como la administración automática de dependencias externas y una sintaxis más limpia sin XML. Sin embargo, la naturaleza de estos sistemas más nuevos sigue siendo la misma: permiten a los ingenieros escribir secuencias de comandos de compilación como tareas de manera modular y de principios y proporcionan herramientas para ejecutarlas y administrar las dependencias entre ellas.

El lado oscuro de los sistemas de compilación basados en tareas

Debido a que estas herramientas básicamente permiten a los ingenieros definir cualquier secuencia de comandos como una tarea, son extremadamente potentes y te permiten hacer casi cualquier cosa que puedas imaginar con ellas. Sin embargo, esa potencia tiene desventajas, y trabajar con sistemas de compilación basados en tareas puede ser difícil a medida que sus secuencias de comandos de compilación se vuelven más complejas. El problema con estos sistemas es que, en realidad, terminan proporcionando demasiada energía a los ingenieros y no suficiente energía al sistema. Debido a que el sistema no tiene idea de lo que hacen las secuencias de comandos, el rendimiento se ve afectado, ya que debe ser muy conservador en cuanto a la forma en que programa y ejecuta los pasos de compilación. Además, el sistema no tiene forma de confirmar que cada secuencia de comandos hace lo que debe hacer, por lo que estas suelen ser más complejas y terminar siendo otro elemento que necesita depuración.

Dificultad de paralelizar pasos de compilación

Las estaciones de trabajo de desarrollo modernas son muy potentes, con varios núcleos capaces de ejecutar varios pasos de compilación en paralelo. Sin embargo, los sistemas basados en tareas a menudo no pueden paralelizar la ejecución de tareas, incluso cuando parece que deberían poder hacerlo. Supongamos que la tarea A depende de las tareas B y C. Debido a que las tareas B y C no dependen unas de otras, ¿es seguro ejecutarlas al mismo tiempo para que el sistema pueda llegar más rápido a la tarea A? Tal vez, si no tocan ninguno de los mismos recursos. Pero tal vez no. Quizás ambos usen el mismo archivo para realizar un seguimiento de sus estados y los ejecuten al mismo tiempo causen un conflicto. El sistema no tiene forma general de que lo sepa, por lo que debe arriesgarse a estos conflictos (lo que genera problemas de compilación poco frecuentes, pero muy difíciles de depurar), o debe restringir toda la compilación para que se ejecute en un solo subproceso en un solo proceso. Esto puede ser un gran desperdicio de una máquina de desarrollador potente, y descarta por completo la posibilidad de distribuir la compilación en varias máquinas.

Dificultad para realizar compilaciones incrementales

Un buen sistema de compilación permite a los ingenieros realizar compilaciones incrementales confiables, de modo que un cambio pequeño no requiera que toda la base de código se vuelva a compilar desde cero. Esto es muy importante si el sistema de compilación es lento y no puede paralelizar los pasos de compilación por los motivos mencionados anteriormente. Pero, desafortunadamente, los sistemas de compilación basados en tareas también tienen dificultades aquí. Debido a que las tareas pueden hacer cualquier cosa, no hay forma en general de verificar si ya se hicieron. Muchas tareas simplemente toman un conjunto de archivos de origen y ejecutan un compilador para crear un conjunto de objetos binarios. Por lo tanto, no es necesario volver a ejecutarlos si los archivos de origen subyacentes no cambiaron. Sin embargo, sin información adicional, el sistema no puede decirlo con certeza. Tal vez la tarea descargue un archivo que podría haber cambiado o tal vez escribe una marca de tiempo que podría ser diferente en cada ejecución. Para garantizar la precisión, el sistema generalmente debe volver a ejecutar cada tarea durante cada compilación. Algunos sistemas de compilación intentan habilitar compilaciones incrementales permitiendo que los ingenieros especifiquen las condiciones en las que se debe volver a ejecutar una tarea. A veces, esto es factible, pero a menudo es un problema mucho más complicado de lo que parece. Por ejemplo, en lenguajes como C++, que permiten que otros archivos incluyan archivos de forma directa, es imposible determinar todo el conjunto de archivos que se deben observar para detectar cambios sin analizar las fuentes de entrada. Los ingenieros suelen terminar tomando atajos que pueden dar lugar a problemas poco frecuentes y frustrantes en los que se reutiliza el resultado de una tarea, incluso cuando no debería serlo. Cuando esto sucede con frecuencia, los ingenieros adquieren el hábito de ejecutar aplicaciones limpias antes de cada compilación para obtener un estado nuevo, lo que anula por completo el propósito de tener una compilación incremental en primer lugar. Comprender cuándo se debe volver a ejecutar una tarea es sorprendentemente sutil, y es un trabajo mejor manejado por máquinas que por humanos.

Dificultad para mantener y depurar secuencias de comandos

Por último, las secuencias de comandos de compilación impuestas por los sistemas de compilación basados en tareas a menudo son difíciles de trabajar. Si bien suelen recibir menos escrutinio, las secuencias de comandos de compilación son código igual que el sistema que se está compilando y son lugares fáciles para ocultar los errores. Estos son algunos ejemplos de errores que son muy comunes cuando se trabaja con un sistema de compilación basado en tareas:

  • La tarea A depende de la tarea B para producir un archivo en particular como resultado. El propietario de la tarea B no se da cuenta de que otras tareas dependen de ella, por lo que la cambia para producir resultados en una ubicación diferente. Esto no se puede detectar hasta que alguien intenta ejecutar la tarea A y descubre que falla.
  • La tarea A depende de la tarea B, que depende de la tarea C, que produce un archivo en particular como salida que se necesita para la tarea A. El propietario de la tarea B decide que ya no necesita depender de la tarea C, lo que hace que la tarea A falle incluso si a la tarea B no le importa en absoluto la tarea C.
  • El desarrollador de una tarea nueva hace por accidente una suposición sobre la máquina que ejecuta la tarea, como la ubicación de una herramienta o el valor de variables de entorno particulares. La tarea funciona en su máquina, pero falla cada vez que otro desarrollador la intenta.
  • Una tarea contiene un componente no determinista, como descargar un archivo de Internet o agregar una marca de tiempo a una compilación. Ahora, las personas obtienen resultados potencialmente diferentes cada vez que ejecutan la compilación, lo que significa que los ingenieros no siempre podrán reproducir y solucionar las fallas o las fallas que ocurran en un sistema de compilación automatizado.
  • Las tareas con múltiples dependencias pueden crear condiciones de carrera. Si la tarea A depende tanto de la tarea B como de la C, y las tareas B y C modifican el mismo archivo, la tarea A obtiene un resultado diferente dependiendo de cuál de las tareas B y C finaliza primero.

No hay una forma de uso general de resolver estos problemas de rendimiento, corrección o mantenimiento dentro del framework basado en tareas que se presenta aquí. Siempre que los ingenieros puedan escribir un código arbitrario que se ejecute durante la compilación, el sistema no tendrá suficiente información para poder ejecutar compilaciones de forma rápida y correcta. Para resolver el problema, necesitamos quitar algo de energía de las manos de los ingenieros, devolverla a las manos del sistema y reconceptualizar la función del sistema no como tareas en ejecución, sino como producción de artefactos.

Este enfoque condujo a la creación de sistemas de compilación basados en artefactos, como Blaze y Bazel.