domingo, 13 de julio de 2014

ART: nuevo entorno de ejecución Java en Android para mejorar el rendimiento (nuevo y mejorado)

Android es el sistema operativo de Google para sistemas móviles. Este SO utiliza un Linux seriamente modificado como núcleo del sistema (modificaciones son por ejemplo el método de comunicación entre procesos, mencionado en una entrada anterior). Antes de entrar en lo que mejora ART primero explicaré un poco por encima cómo funciona Java y los pasos que se siguen desde que se tiene el código en archivos .java hasta que ese código es ejecutado y qué es lo que se hace durante su ejecución para así entender qué es lo que ART cambia y dónde.

Todo SO tiene una biblioteca para poder usar el núcleo. A día de hoy esta biblioteca está escrita en C. Como con muchas otras, hay varias formas de implementar dicha biblioteca de acceso a las funciones del núcleo (funciones tales como crear o abrir un fichero, reservar memoria, etcétera) y Android en concreto no utiliza la biblioteca C de GNU (glibc) o derivados para dispositivos empotrados como eglibc. Emplea una biblioteca mucho más reducida llamada Bionic con comportamientos que pueden desviarse bastante del esperado en un sistema GNU/Linux, aparte de las modificaciones que tiene el Linux de Android. Decir también que no se debe confundir la biblioteca C del núcleo con la biblioteca estándar de C. Mientras que la primera es una biblioteca del núcleo escrita en C, la segunda es una biblioteca del lenguaje disponible para todas las plataformas en las que el lenguaje de programación esté disponible. Lo que ocurre es que en el caso de sistemas Unix la biblioteca estándar de C suele implementar también funciones de acceso al núcleo, establecidas por el estándar POSIX. Bibliotecas como la GNU (Glibc) no incorporan sólo lo que especifica POSIX, también añaden extensiones propias e incluso el terminal estándar que para POSIX se basaron en Korn Shell ha sido extendido en la variante GNU del mismo con funciones adicionales a las especificadas por POSIX.

Además del acceso «nativo»1 con código C/C++ compilado a ensamblador del procesador del móvil, Android incluye una máquina virtual Java distinta de la que usa Oracle en su JRE, llamada Dalvik. Dalvik coge los programas escritos en Java para Android y los ejecuta, haciendo las funciones de traducción o comunicación entre la aplicación Java y el entorno nativo1 anteriormente descrito, usando Bionic y otros (como la biblioteca estándar de C/C++ «stdlib» que internamente termina llamando a la «libc» del núcleo -Bionic, Glibc, ...-). Estas abstracciones de Java (tener una interfaz común sea cual sea el sistema al que vaya destinado la aplicación) tienen unos contratiempos en cuanto a rendimiento se refiere debido sobre todo a las particularidades de ejecución con bytecode de por medio.
Para que Dalvik pueda ejecutar un programa escrito en Java primero se compila (traduce) a un lenguaje intermedio llamado bytecode. Este bytecode es leído por un proceso de máquina virtual (Dalvik) que lo ejecuta. Por cada programa en bytecode se ejecuta un proceso Dalvik que lo interpreta y partes del mismo las compila al lenguaje (ensamblador) del procesador del dispositivo en el que está instalado el entorno de ejecución/máquina virtual Dalvik (otro sinónimo de máquina virtual muchas veces es runtime), es decir, el móvil. Hay que mencionar que antes de llegar al código bytecode que Dalvik entiende, se realizan dos transformaciones de bytecode. La primera es la que también se realiza para obtener bytecode de Oracle a partir del código en Java. La segunda pasa el bytecode compatible con la máquina de Oracle al bytecode de Dalvik. Esto permite entre otras cosas una compatibilidad casi total con el Java para la máquina virtual de Oracle, ya que se puede coger el bytecode generado para ésta y transformarlo al bytecode de Dalvik. El porqué de este paso adicional viene dado por el hecho de que Dalvik representa una máquina virtual con una arquitectura de procesador distinta. Mientras que la de Oracle realiza operaciones con los operandos en una pila la de Android mantiene los operandos en registros.
La Pirámide de Java

Quizá con el diagrama piramidal de arriba quede más claro. Partiendo de un código fuente en Java que usa las funciones que la máquina virtual ofrece, éste se pasa a Bytecode resultando en archivos con extensión .class o a archivos .dex si se va a ejecutar en Android. Estos archivos .class o .dex se pasan a manos de la máquina virtual Java correspondiente. Ésta leerá dichos archivos y los ejecutará como un proceso más funcionando en nuestro ordenador o móvil bajo un sistema operativo con núcleo Linux o basado en Linux, como es el caso de Android.
Otra de las cosas que quiero expresar con la pirámide es que a medida que vamos bajando en los niveles de abstracción la cantidad de código aumenta. El código Java incluye sólo lo que el programador ha escrito.2 Cuando se pasa a bytecode al código del programador se añade toda la funcionalidad que ofrece el entorno de ejecución Java correspondiente (Dalvik en Android). Cuando se quiere ejecutar el .dex con Dalvik, Dalvik lee el .dex, lo carga en memoria y comienza a interpretarlo. Durante el transcurso de dicha interpretación realizará una compilación en tiempo de ejecución para transformar las partes críticas del programa en código dependiente del hardware que puede usar directamente el núcleo Linux mediante la biblioteca C de acceso correspondiente.
El ejecutable o código en memoria resultante, como ya hemos dicho antes, es realmente un proceso Dalvik cuyo «main» hemos escrito nosotros y que al estar vinculado con las funciones que se le suponen a Java, durante su ejecución realiza ciertas operaciones adicionales que un programa escrito en código nativo1 no hace salvo que el programador haya añadido manualmente.
Por último, el hecho de que la biblioteca C de acceso al núcleo esté en parte en Linux y en parte en la máquina virtual es porque la biblioteca C está dividida en dos mitades, una que reside en el núcleo y otra en el espacio de los programas de usuario.3


En un ordenador de sobremesa la ejecución de uno o varios .class (compilados previamente con el comando «javac») se realiza mediante el comando «java» (o haciendo doble clic sobre un .class o .jar que contenga la función «main» y «java» lo ejecutará, aglutinando todos los .class). En resumidas cuentas, cada una de nuestras aplicaciones Java de cara al sistema operativo es un proceso de máquina virtual Java independiente de otros programas Java en ejecución, como ocurre con Android. Lo que el comando «java» hace es crear un proceso con el código de nuestro programa ya en bytecode (los archivos .class) enlazado con toda la funcionalidad que Java provee: con respecto a un programa normal sin entornos en tiempo de ejecución se añade el entorno de ejecución Java con sus bibliotecas, gestión de memoria, gestión de entrada/salida, etcétera. Este entorno de ejecución es el peso que arrastra un programa escrito en Java, ya que es el encargado de hacer todas esas cosas que se dice que Java tiene como la gestión automática de memoria, una misma interfaz de uso para cualquier sistema operativo o el sistema de comprobación de tipos. Y ese mismo peso es el que también arrastran las aplicaciones para Android.


En la siguiente figura se puede apreciar la estructura de carpetas de un paquete APK de Android:
APK de Fennec, la versión pre-beta de Firefox

Se puede ver claramente un archivo llamado «classes.dex». En ese archivo están los .class transformados en código bytecode que Dalvik puede entender. La carpeta «res» contiene cosas como iconos, imágenes y otros recursos usados por el programa. La carpeta «META-INF» contiene las firmas del paquete para comprobar su integridad y origen.
El siguiente diagrama más completo, sacado del Google IO de 2014 muestra el ciclo de vida de un paquete APK (clic para ampliar):

Con ART se genera un ELF enlazado con bibliotecas y el entorno de ejecución Art. Con Dalvik se genera un Odex enlazado con el entorno de ejecución Dalvik y mediante JIT algunas partes del Odex se convierten en ensamblador del procesador. El ELF que genera ART ya es ensamblador, es un ejecutable nativo1. El Odex en cambio ha de ser pasado por JIT, algo que se hace en tiempo de ejecución (Just In Time).

Con todo esto de los distintos bytecodes y máquinas virtuales una de las conclusiones que se puede sacar es que el rendimiento de una aplicación Android por norma general depende al 90% (dejemos un 10% para el buen hacer del programador, suponiendo código correcto sin desastres) de la máquina virtual Java de Android, Dalvik. Digo por norma general porque el uso de Bionic y otras bibliotecas escritas en C/C++ u otros lenguajes es posible gracias al NDK (Native Development Kit), que permite crear aplicaciones 100% nativas o llamar a bibliotecas nativas desde tu código en Java mediante la JNI (Java Native Interface), una serie de funciones para llamar a código compilado externo. Esto es algo muy usado en videojuegos y otras aplicaciones que tengan funciones con altos requisitos de cálculo. El paquete APK mostrado arriba es un ejemplo de aplicación con código enlazado mediante JNI: la carpeta «assets» contiene bibliotecas en código dependiente del hardware que son usadas mediante JNI desde el código Java de las clases contenidas en el archivo .dex. Eso sí, no puedo evitar decir que el bombo que se le da al NDK es muy escaso y su integración no pienso que esté igual de bien rematada que el SDK. Otro buen culpable del menor uso del NDK es Bionic, biblioteca bastante diferente de una más tradicional empleada en un núcleo Linux normal.

A pesar de los avances actuales en Java como por ejemplo la compilación a código nativo1 en tiempo de ejecución (compilación JIT, Just In Time) desde Java 1.2 (Java 2 para entendernos) y que ha ido mejorando con el tiempo con la adición de optimización adaptativa gracias a Java HotSpot, el rendimiento de Java sigue siendo notablemente peor con respecto a programas que no dependen de un entorno de ejecución (runtime) como la máquina virtual Java en el caso de aplicaciones interactivas o de tiempo real. Esta forma de ejecución de Java requiere además mayores cantidades de memoria por la máquina virtual así como por el «recolector de basura», una función de la máquina virtual (pero no exclusiva de máquinas virtuales) que se encarga de gestionar la memoria de manera automática.
Esto termina en ciertos sobrecostes que a día de hoy en Dalvik están bastante pulidos. El paso de Android 2.x a Android 4.x supuso una gran mejora al respecto y con la última versión de Android, KitKat, hay un modo de compatibilidad para dispositivos con poca memoria RAM (512MB como mínimo). Sin embargo no fue suficiente y Google ha estado trabajando en compilación en tiempo de instalación y no de ejecución. Esto es, cuando se instala la aplicación se compila el programa al código nativo1 (ensamblador) del dispositivo en el que se está instalando. Esto permitiría mejores optimizaciones ya que no hace falta que la compilación sea rápida o tener un complejo sistema de posposición de compilaciones para cuando se pueda o sean necesarias, algo que al estar igualmente restringido por el tiempo (el usuario requiere de capacidad de respuesta) siguen sin dar resultados lo suficientemente óptimos. Al no tener que hacer compilación en el momento de ejecución, la transformación a código nativo1 puede ser mucho más exhaustiva.
Tiempo de ejecución para la ordenación de un vector de 1.000 a 100.000 enteros con algoritmo QuickSort bajo plataforma móvil Android. Más comparaciones Java Oracle y Java Android vs. C/C++ aquí.
Como se puede ver en el gráfico de arriba ART puede ser hasta un 100% más rápido (el doble) que Dalvik, la máquina virtual usada actualmente en Android. Aún así sigue siendo hasta el doble de lenta que código nativo1 ejecutándose sin máquina virtual (JNI), por lo que el uso del NDK de Android seguirá siendo recomendado en las mismas situaciones (basta con echar un vistazo a los números que se sacan con un simple filtro de imagen).
Sin embargo en los casos más flagrantes de aplicaciones muy recargadas la nueva ART debería ofrecer una considerablemente mejor experiencia de usuario. Además cabe decir que fue introducida en Android 4.4 (KitKat) y se prevé que sea la máquina virtual por defecto  para el año que viene, por lo que todavía tiene bastante recorrido y margen para mejorar el rendimiento.
Este cambio de máquina virtual (o entorno de ejecución) es posible gracias a otra de las ventajas de Java: el código fuente puede ser perfectamente el mismo ya que lo que se genera no es ensamblador para ser ejecutado directamente por un procesador sino bytecode para ser ejecutado por el comando «java» que instancia dicho bytecode en forma de una «máquina virtual Java», según considere oportuno y aplicando las optimizaciones oportunas, por lo que se puede cambiar de máquina virtual siempre y cuando siga entendiendo el mismo bytecode o simplemente recompilando el código Java generando nuevo bytecode para la nueva máquina. Y esto también es posible dado que en Java no existen llamadas de bajo nivel ni posibilidad de insertar ensamblador por lo que el «alto nivel» queda totalmente garantizado, siendo posible generar siempre bytecode independientemente de la plataforma hardware sobre la que se vaya a ejecutar.

En el último Google IO se ha presentado ART como la máquina virtual Java por defecto para la nueva versión de Android: L. ART, que significa Android RunTime, además de incorporar las mejoras por compilar en tiempo de instalación, en el Google IO se han desvelado novedades en los algoritmos de gestión automática de la memoria, mejorando sustancialmente la interactividad al reducir los tiempos en los que una aplicación se congela para poder eliminar o añadir memoria según sea necesario. Es más, una aplicación enlazada con ART ya no usa malloc directamente para pedir memoria a Linux sino que Google ha implementado otra función dentro de ART llamada rosalloc, que para su caso de uso les permite una mejora de hasta 10x en velocidad de reserva de memoria. ART tiene datos acerca de los objetos Java que hay en memoria y de los hilos Java que están ejecutándose de manera que rosalloc permite reservar memoria con una granularidad mayor (por objetos e hilos Java) evitando bloqueos innecesarios a la hora de reservarla.

Mejoras en los tiempos de pausa para reclamar memoria
que ya no se usa (recolección de basura)


También se añade soporte para procesadores de 64 bits y además los ejecutables generados por ART son ejecutables nativos1 para el núcleo Linux de Android (recordemos que al Linux incluido en Android se accede mediante Bionic y no mediante la Glibc como en el Linux incluido en GNU o BusyBox, así pues no esperéis poder ejecutar los ELF Android generados por ART en un sistema GNU, no son compatibles).

Mejoras al usar aplicaciones convertidas a 64 bits,
incluyendo la mejora de código nativo (NDK)


Los contras que se pueden esperar de ART son tiempos de instalación más longevos (hay que compilar el programa a código nativo1 en la instalación) y quizá ocupen algo más de espacio de almacenamiento. Sin embargo esa posibilidad de compilación en la instalación puede permitir unos refinados mucho mejores y los nuevos métodos de gestión de memoria automática permiten mejorar mucho la interactividad, algo mucho más importante en dispositivos móviles que obtener el máximo rendimiento en cuestiones de cálculo.

Fuente de los datos de QuickSort.




1 Cuando se habla de algo nativo, se habla de código dependiente de la plataforma. Esto usualmente es código escrito en C o C++ que utiliza instrucciones específicas del procesador sobre el que va a correr mediante ensamblador en línea. Sin embargo que un código sea nativo no significa que no pueda tener la misma funcionalidad que se ofrece con la máquina virtual Java como la gestión automática de la memoria (C++ smart pointers, C++11 de un compilador que implemente un método de recolección de basura, activable a voluntad en el momento de compilar el programa) o una interfaz de acceso al sistema operativo independiente. Véase como ejemplo la propia biblioteca estándar de C (stdlib) con funciones para crear archivos común a todos los sistemas operativos. Los compiladores también permiten usar instrucciones hardware de forma independiente generando el ensamblador adecuado para cada procesador; esto se hace para funcionalidad común como la de acceso a variables compartidas entre varios hilos o para transferencias de memoria. La diferencia con Java es que Java hace esto de forma automática. La máquina virtual recibe un bytecode y lo transforma en el ensamblador adecuado en tiempo de ejecución. ART como se explica en el artículo, hace una compilación completa sin intervención del usuario antes de la ejecución. Por todo ello, muchas veces cuando se habla de código nativo se suele hacer referencia a código que es precompilado, y usualmente está escrito en C o C++ pero no necesariamente es dependiente de la plataforma, que es cuando se puede considerar realmente nativo o al menos parcialmente. El mismo núcleo Linux es independiente de la plataforma, las funciones que tienen ensamblador en línea disponen varias versiones, una para cada plataforma hardware soportada y cuando se compila se genera el núcleo Linux adecuado para la plataforma escogida. Y muchas otras funciones que deberían tener ensamblador en línea llaman a funciones del compilador que implementan dicha funcionalidad, generando el ensamblador necesario.
 
2 Los programas usan bibliotecas. Cuando se compila, esas bibliotecas (también compiladas, ya en lenguaje ensamblador) deben añadirse al código del programador compilado para que el programa ensamblador resultante pueda usar el código disponible en las bibliotecas (que son un repositorio de funciones escritas en el lenguaje que sea). La máquina virtual Java es un entorno de ejecución o runtime y también una máquina virtual que ejecuta un ensamblador no nativo, el bytecode de Java, y dicho entorno de ejecución o runtime está disponible a modo de biblioteca que el comando «javac» o «dx» (compiladores a bytecode del Java de Oracle y del Java de Google Dalvik, respectivamente) agrega al bytecode del código Java que el programador ha escrito.
 
3 Los programas de usuario llaman a las funciones de su lado y luego internamente la biblioteca C de acceso al núcleo llama a instrucciones del procesador para poder ejecutar el código que reside en el núcleo. Ese código está entonces protegido por el procesador y el cambio requerido para usar ese código y que hace necesario el uso de las instrucciones del procesador es lo que se llama «elevación de privilegios». Cuando hay una vulnerabilidad por elevación de privilegios es porque un programa ha podido ejecutarse con los privilegios del núcleo del sistema, algo que permite hacer absolutamente todo lo que se quiera, incluso quemar el ordenador aumentando los voltajes que la placa base suple a los distintos chips. Habitualmente los sistemas operativos disponen de un usuario, root en sistemas Unix, administrador en sistemas Windows, que permite ejecutar código con privilegios de núcleo sin que el núcleo del sistema rechiste. Por ende una «elevación de privilegios» implica haberse hecho previamente con el acceso a dichos usuarios privilegiados.

No hay comentarios:

Publicar un comentario