Interoperabilidad software: acelerando lenguajes de programación productivos de alto nivel con lenguajes eficientes de bajo nivel (Java-C/C++)


Desarrollo Software Interoperabilidad Lenguajes de programación Productividad JNI FFI Flexibilidad Adaptabilidad Eficiencia Rendimiento Optimización Programación paralela Abstracción POO Programación Orientada a Objetos Arquitectura Software

En muchas ocasiones nos encontramos ante dos tipos de problemas a la hora de desarrollar y trabajar con nuestro código, ambos de severa gravedad. Este ocurre tanto al implementar un programa CLI de procesamiento, como al elaborar una aplicación con soporte GUI típica de escritorio o incluso en un aplicativo web a modo de servidor de servicios.

El primero surge como inconveniencia a la hora de utilizar un tipo de funcionalidad, limitando la expresividad de nuestra aplicación ante un dominio del problema específico. Esto ocurre porque tenemos que implementar de cero dicha funcionalidad o porque no es el mejor lenguaje o framework para desarrollar ese tipo de usos. Esto es muy común cuando desarrollamos un aplicativo en un lenguaje cualquiera, y eventualmente necesitamos implementar lógica de negocio fuertemente impulsada, por ejemplo, por machine learning e inteligencia artificial, viéndose nuestro lenguaje limitado en librerías y capacidades para elaborar toda la maquinaria necesaria, o requiriendo de grandes esfuerzos para llegar a tener algo mínimamente decente. En cambio, siguiendo este ejemplo, con otros lenguajes con fuerte peso en data science, como python y sus paquetes comunitarios, tenemos infinidad de facilidades para hacer ese tipo de aplicaciones, ahorrando enormes tiempos ingenieriles. Esto ocurre con todos, pues no hay un solo lenguaje que abarque todos los dominios del saber, ya que al fin y al cabo acaban especializándose en algunas áreas concretas. Quien proporciona una infinidad de librerías para generación de gráficos, peca de buenas herramientas matemáticas y físicas; los que facilitan la generación de un sistema distribuido basado en un modelo de actores, no disponen de buenas herramientas para procesar streams e inodos del sistema; quien es bueno y hace cómodo realizar programación probabilística y otorga mecanismos de inferencia y lógica, complica mucho la exposición de servicios y protocolos de comunicación; y así con cualquier lenguaje y framework.

El segundo tipo se da cuando necesitamos alcanzar funcionalidad de bajo nivel, acceso a dispositivos hardware, manipulación directa de controladores del sistema operativo, o generalmente, la más habitual, al requerir un mayor rendimiento o eficiencia energética. Este último caso suele venir propiciado por las implementaciones en un lenguaje de más alto nivel, concentrándose en regiones cuello de botella a ser optimizadas.

En este artículo nos centramos en este último caso, la aceleración de nuestra aplicación por medio del acceso a un lenguaje que proporcione un mayor rendimiento al que es ofrecido por la implementación actual del sistema completo. No obstante, todos los casos antes mencionados son solventados por medio del mismo mecanismo, la interoperabilidad entre lenguajes, es decir, conectar unos con otros por medio de mecanismos habitualmente denominados foreign function interface (FFI) o language bindings. Cada lenguaje dispone de una o varias formas (si es más sofisticado) para realizar este tipo de operaciones, pudiendo llegar a ser realmente complejos y permitir incluso la llamada a objetos (bajo el prisma del Paradigma de Orientación a Objetos - POO), el intercambio de estructuras o tipos no triviales e incluso la exposición y utilización de funcionalidades del runtime del lenguaje. Aquí nos vamos a centrar en exponer un caso en donde la totalidad de la aplicación se ha implementado en Java, y queremos dotar de mayor funcionalidad y eficiencia a unos módulos mediante el acceso a C/C++ y OpenMP, el framework portable para explotar la computación paralela.

El siguiente diagrama muestra las capas Java y Nativa, cómo se relacionan y posibilitan la interoperabilidad a través del mecanismo Java Native Interface (JNI). La figura está compuesta de tres regiones, la superior, con el proceso de compilación; la intermedia, mostrando el espacio del proceso de la Java Virtual Machine (JVM); y la inferior, con el sistema operativo.

Vista general de la interoperabilidad entre JVM y la ejecución Nativa mediante JNI

Diagrama de las capas Java y Nativa mostrando la interoperabilidad entre JMV y la ejecución Nativa mediante JNI.

En primer lugar, el usuario compila los códigos fuente. Por un lado, mediante javac se produce la compilación de los bytecodes (.class). Por otro lado, los códigos fuentes en C y C++ se compilan por medio de cualquiera de los compiladores que dispongamos, como icc, g++ o clang++, produciendo los ficheros objeto a modo de librerías dinámicas (.so/.dll). Los ficheros objeto que sirven de puente a la conexión nativa se construyen como unidad de compilación independiente (bridge-object file .so/.dll). Ya que se hace uso de librerías externas previamente construidas, denominadas group4layers libs, también se enlaza a las mismas durante el proceso de compilación (libraries-object files .so/.dll).

Una vez compilados, los bytecodes son integrados por medio del subsistema de carga, alojándolos en memoria, interpretándolos y procesándolos considerando el resto de piezas necesarias proporcionadas por el runtime Java. Tal y como se muestra en la región de datos del runtime, existen múltiples áreas para almacenar las variables, estructuras y objetos, como ocurre en un proceso del sistema, pero siendo la propia JVM quien controla su localización. Los bytecodes cargados también solicitan y liberan memoria, interaccionando con las distintas áreas. Finalmente, el motor de ejecución es el encargado de compilar el bytecode a instrucciones máquina, aplicando sofisticadas estrategias de optimización. Una de las ventajas de Java y el runtime JVM es que nos permite crear nuevas clases dinámicamente, compilarlas y ejecutarlas al vuelo, ampliando la flexibilidad. Es en este punto donde entra en juego la conexión por medio de JNI. Gracias a este mecanismo de interoperabilidad, el motor de ejecución es capaz de descargar la ejecución al interfaz nativo (Native Interface) a través del puente (Bridge) previamente compilado (bridge-object file). Este, a su vez, usará funcionalidad de las librerías nativas (Native Library) externas, enlazadas y proporcionadas junto con el puente (Library). Como vemos, todos estos interfaces y librerías nativas forman parte del proceso de la JVM.

Por último, en la capa inferior, se representan diferentes componentes del sistema operativo, desde los controladores de dispositivos y servicios del sistema, hasta la gestión de la memoria o el planificador de procesos e hilos del kernel. Ya que en este artículo nos centramos en mejorar el rendimiento y realizamos una gestión nativa de múltiples hilos del sistema, con el objetivo de aprovechar los cores disponibles, se puede ver cómo tanto el motor de ejecución como la librería nativa hacen uso directo de funcionalidades proporcionadas por el propio sistema operativo (Processes & Threads Implementation). Por un lado, JVM proporciona sus mecanismos de concurrencia para escalar la aplicación y aprovechar los recursos del sistema de forma transparente para los programadores. Por otro lado, las librerías nativas compiladas hacen un uso explícito de los cores y procesadores disponibles, explotan estrategias de afinidad entre hilos y cores, así como la optimización de caches y el aprovechamiento de la jerarquía de memoria, interactuando directamente con el hardware disponible y evitando cualquier restricción impuesta por la JVM.

A continuación se muestra el diagrama exponiendo la interoperabilidad software llevada a cabo en este artículo para un ejemplo concreto, considerando los reinos Java y Nativo. Como se ha adelantado, utilizamos JNI para realizar la conexión entre módulos Java y las unidades de compilación en C/C++.

Ejemplo de interoperabilidad software llevado a cabo entre los reinos Java y Nativo (C-C++) mediante JNI

Diagrama exponiendo la interoperabilidad software llevada a cabo vía JNI y detallada en este artículo, destacando los reinos Java y Nativo junto con sus módulos y unidades de compilación.

El usuario comienza ejecutando la clase principal del sistema, denominada Entrypoint a modo de ejemplo intuitivo y simplificado. La clase Entrypoint representa el inicio de una aplicación, compuesta por cientos o miles de clases, conteniendo una funcionalidad que debe ser optimizada, denominada Core. Fruto de años de trabajo, esta clase está implementada de forma adecuada en Java, pero sigue siendo cuello de botella, por lo que es necesario ofrecer una implementación más eficiente. Para ello, se realiza una implementación nativa de dicha funcionalidad, utilizando el JNI como mecanismo de bridging.

La implementación nativa explota el interfaz JNI mediante la clase Bridge, que actúa como conector con el reino nativo. Se comunica realizado transferencia de llamadas y parámetros, a la vez que puede recibir resultados y callbacks desde la parte nativa. Es importante que cumpla la especificación (signature match) con el interfaz nativo, de forma que se puedan traducir las llamadas entre Java y C/C++.

Dentro del reino nativo, hay una región a modo de librería nativa, compuesta de dos piezas: la librería dinámica que actúa como puente (libbridge), proporcionado por Bridge.cpp; y el conjunto de librerías de Group4Layers, previamente compiladas y enlazadas, denominadas libNativeCore. Estas últimas implementan la funcionalidad de Core, pero son altamente optimizadas y basadas en ejecución nativa acelerada sin dependencia alguna con JVM o Java.

La clase Bridge facilita la comunicación con los módulos nativos, dividiéndose en una parte Java y otra nativa, materializado esta última en la librería libbridge. Dependiendo de las decisiones que tome la arquitectura, podrá usar Core tanto con la vertiente basada en Java, como la optimizada nativa, pues está compuesta de ambas implementaciones. La ventaja de este mecanismo es que se dispone de un portado incremental, dotando de dichas funcionalidades de forma independiente y opcional.

A continuación se exponen los ficheros previamente indicados para conseguir la funcionalidad indicada en el ejemplo superior, simplificando los fragmentos con lo mínimo necesario para facilitar su comprensión, el funcionamiento y sus relaciones.

El fichero Entrypoint.java representa la simplificación de la aplicación real en Java, bien sea un servidor, un demonio (proceso) o una aplicación de escritorio. En algún punto de toda la arquitectura software se decidirá si se ejecuta el Core de Java o el Core nativo, aquí representado como Core.compute y Core.efficient_compute, respectivamente.

Es importante mencionar que realmente el Core podría ser dividido entre diversos componentes, pero por facilitar el ejemplo, aquí se presenta en una sola unidad. Ya que vamos a extender el comportamiento, sería mejor usar patrones adecuados para ellos y poder especializarlos, como por ejemplo mediante los patrones de diseño Strategy/Policy o incluso Template method.

import Core;

public class Entrypoint {
   // Exposing a simple example, but the Core functionality would be better as
   // part of a Behavioral Pattern, like Strategy/Policy or Template method.
   public static void main(String[] args) {
     // Java impl. computation
     Core.compute(/* send args */);
     // rest of the high-level software until we need the efficient computation:
     Core.efficient_compute(/* send args */);
   }
}

La clase Core.java dispone de ambos métodos que implementan la funcionalidad cuello de botella, incluyendo el cómputo eficiente. Este segundo método necesita la clase Bridge, al que propaga sus argumentos. Como hemos mencionado, lo suyo sería independizarlo y poder componer las clases y comportamientos.

La ventaja de esta interoperabilidad y portado incremental es que el Core nativo podría reutilizar funciones del propio Core (en Java). Por ejemplo, si 5 métodos realizan la funcionalidad de compute, y únicamente 2 son cuellos de botella, se podrían seguir 3 de la implementación de Java. Por supuesto, será necesario realizar profiling para ver los overheads en realizar llamadas y conversiones entre Java y C++, evaluando el impacto real, pues podría ocurrir que igual es más conveniente realizar toda la implementación en C++, es decir, los 5 métodos, sin sufrir penalizaciones por conversiones entre tipos y runtimes.

import Bridge;

public class Core {
  // rest of the Core functionality which can also be reused in the NativeCore
  public static void compute(/* args */){ /* java implementation */ }
  public static void efficient_compute(/* args */){
     (new Bridge()).compute(/* propagate args */);
  }
}

La clase Bridge.java actúa como puente con la clase Bridge.cpp, mediante el uso del método nativo native void compute y su delegación de argumentos a C/C++. Es importante especificar la librería dinámica que implementa dicha funcionalidad. Para ello, es necesario hacer uso de la carga estática, a nivel de clase, de dicho código objeto, quien proporciona e implementa dichas funciones manteniendo el código máquina ejecutable con independencia de la posición de carga en memoria (PIC). Esto se reflejará posteriormente durante las etapas de compilación.

public class Bridge {
  static {
    // Load native library libbridge.so (Unix) or bridge.dll (Windows)
    System.loadLibrary("bridge");
  }

  // delegated (to native) functionality
  public native void compute(/* java to c/c++ args */);
}

La librería que implementa la funcionalidad nativa está encapsulada como parte de otra unidad de compilación, siendo NativeCore.cpp como punto de entrada, con sus propias dependencias y código modular. Como no es importante para el propósito de este artículo, simplemente se da un esbozo de lo que podría ser un uso más sofisticado y eficiente. Para ello, en este caso, se ha optado por hacer uso de una librería de group4layers para la aceleración de código secuencial, basada en operaciones vectoriales (SIMD), mediante intrinsics, proporcionando macros, tipos empaquetados y funciones para exprimir al máximo los procesadores multi-core existentes en todo tipo de arquitecturas. Además, en esta librería se hace uso de la tecnología de programación paralela OpenMP, para explotar al máximo el paradigma de memoria compartida, aprovechando no solo las unidades vectoriales sino también los núcleos del procesador.

Como se ha visto en los diagramas anteriores, con estos mecanismos estaremos interaccionando directamente con el sistema operativo, de forma que nuestra funcionalidad hace de baipás, levantando y gestionando más hilos, todos ellos independientes al runtime JVM y su procesamiento (potencialmente) concurrente. Hacemos uso de una flag a modo de heurística para determinar de forma explícita el número de hilos creados, o bien crearlos dinámicamente en base a los requerimientos observados. En cualquier caso, en el módulo nativo somos libres para hacer cualquier cosa, desde controladores de bajo nivel para interfaces de red, periféricos y dispositivos de E/S, hasta todo tipo de estrategias de aceleración y procesamiento aprovechando los recursos físicos del sistema, expuestos generalmente por el propio sistema operativo.

#include <omp.h>
#include <g4l_simd_acc.h>
#include "NativeCore.h" /* my custom lib to implement the core */

extern "C" {
  void NativeCore_compute(/* args: heuristic_cond, Am, Tx, R, r, nthreads, ... */){
     int i, j;
     heuristic_cond ? omp_set_num_threads(nthreads) : omp_set_dynamic(true);
     #pragma omp parallel for collapse(2) schedule(guided, dimX/(dimY*2)) \
                 default(none) shared(Am, Tx, R, r) private(i, j)
     for (i=0; i<dimX; ++i) {
       for (j=0; j<dimY; j+=(j_sep + G4L_PREF_STRIDE_SIMD)) {
         // group4layers libs: SIMD intrinsics - convenient helpers
         // ... continue impl.
       }
     }
  }
}

El último fichero necesario para establecer la conexión entre lenguajes es Bridge.cpp, formando parte del puente desde C/C++ mediante JNI. Como se puede observar, requiere de las macros, atributos y tipos proporcionados por jni.h, además de las cabeceras del propio puente (Bridge.h), generadas a raíz del fichero Bridge.java. Y por último, toda la funcionalidad nativa implementada, detallada previamente.

Este fichero requiere la función Java_Bridge_compute, además de un conjunto de argumentos, algunos obligatorios por el propio mecanismo JNI, y otros, a nuestra propia discreción. En este ejemplo no hemos delegado ninguno, pero los situaríamos a continuación de los dos obligatorios.

La función que comienza por c_cpp_entrypoint se ha establecido como mecanismo para realizar la adaptación de argumentos y tipos entre lenguajes, típicamente llamados conversores y operaciones de marshalling/unmarshalling, así como otras labores de preparación y organizativas antes de desplegar toda la maquinaria de cómputo eficiente. No obstante, se podría haber llamado directamente desde la función principal a nuestras librerías e implementaciones nativas. Esto representa un ejemplo mínimo para entender el funcionamiento, pero la idea es encapsular el comportamiento nativo y que pueda ser reutilizado desde otros lugares, de forma que toda conversión y utilización entre Java-C/C++ es recomendable que se encuentre aislada, tal y como aquí se muestra.

#include <jni.h>
#include "NativeCore.h" /* my custom lib to implement the core */
#include "Bridge.h"

void c_cpp_entrypoint_efficient_functionality(/* raw native args */) {
  // call any c/c++ libs/funcs...
  // adapting the Java arguments to our custom native core implementation
  // initializing data structures, setting up variables, ...
  NativeCore_compute(/* passing prepared args */);
}

JNIEXPORT void JNICALL Java_Bridge_compute(JNIEnv *env, jobject thisObj,
                                           /* rest of the arguments from Java */) {
  // (un)marshalling and transforming of args to native types
  c_cpp_entrypoint_efficient_functionality(/* raw native args */);
  return;
}

Finalmente, se muestran los pasos para compilar y ejecutar este caso. Se parte con las librerías nativas de group4layers ya compiladas, englobando toda la funcionalidad nativa y mecanismos de aceleración de código. De esta forma, se tiene g4l_custom_libs con las librerías dinámicas compiladas (códigos objeto) y los ficheros de cabecera.

En el primer paso creamos las cabeceras de C para Bridge.java, siendo necesarios para completar correctamente el tercer paso, de forma que las firmas entre funciones concuerden entre Java y C. El segundo paso compila a bytecode los códigos Java. En tercer lugar, se construye el puente como librería dinámica libbridge.so, requiriendo toda la especificación de JNI, las dependencias NativeCore y librerías de group4layers. Una vez hecho este paso, disponemos de la librería a ser cargada por el puente de Java (System.loadLibrary("bridge")). Por último, inicializamos nuestra aplicación Java partiendo de nuestro punto de entrada, siendo importante indicar al cargador del runtime JVM la ruta base para localizar las librerías nativas.

Con estos simples pasos, todo el sistema se estará aprovechando de la funcionalidad nativa, aumentando la flexibilidad y eficiencia de nuestra aplicación Java. Además, es un mecanismo de conexión dinámico y opcional, por lo que si realizamos un buen diseño de arquitectura software, podremos alternar entre código puramente en Java, únicamente nativo, o incluso de forma híbrida, con lo mejor de ambos.

# Precondition) group4layers' native core library is built
# and located in `g4l_custom_libs` with the headers

# Step 1) create Bridge.java, get headers/JNI mock
javac -h . Bridge.java

# Step 2) compile Java code
javac Bridge.java; javac Core.java; javac Entrypoint.java;

# Step 3) create Bridge.cpp (linked to JNI/jni.h), get dynamic library
$CXX_COMPILER -fPIC -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" \
              -I"g4l_custom_libs/include" -L"g4l_custom_libs/lib" -shared \
              -o libbridge.so -lNativeCore Bridge.cpp

# Step 4) run java + native libs
java -Djava.library.path=. Entrypoint

Hemos visto la importancia de poder conectar lenguajes diferentes por medio de los mecanismos FFI de interoperabilidad ofrecidos (JNI en Java), dotando de una gran versatilidad a las aplicaciones. El acceso a lenguajes más eficientes ha permitido acelerar los cuellos de botella encontrados en regiones del código Java, descargando dicha funcionalidad a código C y C++ dotado de directivas OpenMP y librerías de aceleración vectorial para facilitar su portado paralelo y eficiente. Sin embargo, existe un inconveniente. Si nos fijamos en el primer diagrama del artículo, podemos ver cómo nuestro código C/C++ reside dentro del espacio del proceso de la JVM. De esta forma, si al desarrollar la funcionalidad nativa cometemos algún fallo suficientemente crítico, podremos hacer que todo el sistema caiga. De igual manera, no estamos protegidos por los mecanismos de seguridad, pues nos encontramos dentro del entorno JVM, y no siendo aislados en una sandbox, por lo que tenemos que tener especial cuidado a la hora de desarrollar este tipo de funcionalidades. Este es un problema que se ve en multitud de mecanismos FFI, aunque hay lenguajes que ofrecen diversos mecanismos de conexión y podrían disponer de uno que no forme parte del proceso del runtime, como en el caso de Erlang. En cualquier caso, cuantas mayores garantías podamos ofrecer sobre nuestro código nativo, mejor. Por ejemplo, estableciendo programación defensiva y un buen sistema de control de errores y aserciones, realizando baterías de tests de todo tipo o incluso el análisis estático y la verificación formal. Otra alternativa es utilizar lenguajes más estrictos y que doten de mayores garantías en la corrección de las funcionalidades nativas, bien por el tipado fuerte y el control de overflows, bien por la gestión de la memoria y las primitivas de sincronización, como pueden darse en lenguajes como Rust o Ada.

En definitiva, la posibilidad de explotar mecanismos de interoperabilidad, el acceso a librerías especializadas, funcionalidad extra y lenguajes más eficientes es realmente importante para dotar de la suficiente flexibilidad y adaptabilidad a las aplicaciones. Sin embargo, debemos ser conscientes de cómo se hace esta interoperabilidad entre lenguajes, sus fundamentos y mecanismos, con el objetivo de proporcionar las mejores garantías para que se consiga el rendimiento o funcionalidad mejorada sin poner en riesgo el sistema completo.

Este sitio web emplea cookies propias y de terceros para analizar el tráfico y ofrecerle una mejor experiencia. Al navegar o utilizar nuestros servicios el usuario está aceptando su uso.Más información.