Sistemas de compilación basados en tareas

En esta página, se abordan los sistemas de compilación basados en tareas, su funcionamiento y algunas de las complicaciones que pueden ocurrir con los sistemas basados en tareas. 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.

Información sobre 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 de ellas. La mayoría de los sistemas de compilación principales que se usan en la actualidad, 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 está escrito en XML y define algunos metadatos simples sobre la compilación junto con una lista de tareas (las etiquetas <target> en el XML). (Ant usa la palabra target para representar una tarea y la palabra task para referirse a comandos). Cada tarea ejecuta una lista de comandos posibles definidos por Ant, que 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 abarcar 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 ve en la Figura 1.

Gráfico de acrílico que muestra 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 realiza los siguientes pasos:

  1. Carga un archivo llamado build.xml en el directorio actual y lo analiza para crear la estructura del gráfico 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 en la tarea llamada compile.
  3. Busca la tarea llamada compile y descubre que tiene una dependencia en la tarea llamada init.
  4. Busca la tarea 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/*

Cuando se quita la sintaxis, el archivo de compilación y la secuencia de comandos de compilación en realidad no son muy diferentes. Pero ya logramos mucho con esto. Podemos crear archivos de compilación nuevos en otros directorios y vincularlos. Podemos agregar fácilmente tareas nuevas que dependen de tareas existentes de formas arbitrarias y complejas. Solo necesitamos pasar el nombre de una sola tarea a la herramienta de línea de comandos de ant, y determina todo lo que se debe ejecutar.

Ant es una antigua pieza de software que se lanzó originalmente en el año 2000. Otras herramientas, como Maven y Gradle, mejoraron en Ant en los años intermedios y, básicamente, la reemplazaron por agregar funciones como administración automática de dependencias externas y una sintaxis más limpia sin ningún XML. Sin embargo, la naturaleza de estos sistemas más nuevos sigue siendo la misma: permiten que los ingenieros escriban secuencias de comandos de compilación de forma modular y como principios de las tareas, y proporcionan herramientas para ejecutarlas y administrar dependencias.

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

Estas herramientas básicamente permiten que los ingenieros definan cualquier secuencia de comandos como una tarea, ya que son muy potentes, lo que te permite hacer casi cualquier cosa que te imagines con ellas. Sin embargo, esa potencia tiene algunos inconvenientes, y es posible que sea difícil trabajar con los sistemas de compilación basados en tareas a medida que la secuencia de comandos de compilación se vuelve más compleja. El problema con estos sistemas es que, en realidad, terminan brindando 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 programación y ejecución de pasos de compilación. Además, no hay forma de que el sistema confirme que cada secuencia de comandos hace lo que debería; por lo tanto, las secuencias de comandos tienden a aumentar en complejidad y terminan siendo otra cosa que necesita depuración.

Dificultad para paralelizar pasos de compilación

Las estaciones de trabajo modernas de desarrollo son bastante potentes, ya que cuentan con varios núcleos capaces de ejecutar varios pasos de compilación en paralelo. Sin embargo, con frecuencia los sistemas basados en tareas no pueden paralelizar la ejecución de las 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 una de otra, ¿es seguro ejecutarlas al mismo tiempo para que el sistema pueda llegar a la tarea A más rápido? Tal vez, si no tocan ninguno de los mismos recursos. Pero tal vez no. Tal vez ambos usen el mismo archivo para hacer un seguimiento de sus estados y ejecutarlos al mismo tiempo cause un conflicto. El sistema no tiene manera de saberlo, por lo que debe arriesgarse a estos conflictos (lo que lleva a problemas de compilación poco comunes pero muy difíciles de depurar), o bien debe restringir la compilación completa para que se ejecute en un solo subproceso en un único proceso. Esto puede ser un desperdicio enorme de una máquina de desarrollo potente y descarta completamente 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 pequeño cambio no requiera que toda la base de código se vuelva a compilar desde cero. Esto es particularmente importante si el sistema de compilación es lento y no puede paralelizar los pasos de la compilación por las razones antes mencionadas. Pero, lamentablemente, los sistemas de compilación basados en tareas también tienen dificultades aquí. Como las tareas pueden realizar cualquier acción, en general no hay forma de verificar si ya se realizaron. Muchas tareas simplemente toman un conjunto de archivos fuente 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 total certeza. Esto puede deberse a que la tarea descarga un archivo que podría haber cambiado o a una escritura en una marca de tiempo que podría ser diferente en cada ejecución. Para garantizar la correcció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 se puede hacer, pero suele ser un problema mucho más complicado de lo que parece. Por ejemplo, en lenguajes como C++ que permiten que otros archivos incluyan directamente los archivos, es imposible determinar todo el conjunto de archivos que deben observarse para detectar cambios sin analizar las fuentes de entrada. A menudo, los ingenieros toman atajos, y estos atajos pueden causar problemas poco frecuentes y frustrantes, en los que se vuelve a usar un resultado de tarea incluso cuando no debería estarlo. Cuando esto sucede con frecuencia, los ingenieros se acostumbren a ejecutar una limpieza antes de cada compilación para obtener un estado nuevo, lo que vence por completo el propósito de tener una compilación incremental en primer lugar. Comprender el momento en que debe volver a ejecutarse una tarea es sorprendentemente sutil y es un trabajo mejor manejado por las máquinas que los seres 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 suelen ser difíciles de usar. Si bien suelen recibir menos análisis, las secuencias de comandos de compilación son código como el sistema que se compila y son lugares fáciles para ocultar errores. Estos son algunos ejemplos de errores 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 intente ejecutar la tarea A y descubra que falla.
  • La tarea A depende de la tarea B, que depende de la tarea C, que produce un archivo en particular como resultado que necesita la tarea A. El propietario de la tarea B decide que ya no necesita depender de la tarea C, lo que hace que falle la tarea A aunque la tarea B no se preocupe en absoluto
  • El desarrollador de una tarea nueva hace una suposición accidentalmente 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 prueba.
  • 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 pueden obtener resultados diferentes cada vez que ejecutan la compilación, lo que significa que los ingenieros no siempre podrán reproducir y corregir las fallas o los errores que ocurran en los sistemas de compilación automáticos.
  • Las tareas con varias dependencias pueden crear condiciones de carrera. Si la tarea A depende de la tarea B y de la tarea C, y las tareas B y C modifican el mismo archivo, la tarea A obtiene un resultado diferente según cuál de las tareas B y C finaliza primero.

No existe una manera 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 código arbitrario que se ejecute durante la compilación, el sistema no puede tener suficiente información para poder ejecutar compilaciones de manera rápida y correcta. Para resolver el problema, necesitamos quitarle la mano a los ingenieros y ponerla en las manos del sistema, y volver a conceptualizar la función del sistema, no como tareas en ejecución, sino como producir artefactos.

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