不同的开发语言适宜不同的畛域,例如Python适宜做数据分析,C++适宜做零碎的底层开发,如果它们须要用到雷同性能的根底组件,组件应用多种语言别离开发的话,不仅减少了开发和保护老本,而且不能确保多种语言间在解决成果上是统一的。本文讲述在Linux零碎下跨语言调用的实际总结,即开发一次C++语言的组件,其余语言通过跨语言调用技术调用C++组件。
1 背景介绍
查问了解(QU, Query Understanding)是美团搜寻的外围模块,主要职责是了解用户查问,生成查问用意、成分、改写等根底信号,利用于搜寻的召回、排序、展现等多个环节,对搜寻根底体验至关重要。该服务的线上主体程序基于C++语言开发,服务中会加载大量的词表数据、预估模型等,这些数据与模型的离线生产过程有很多文本解析能力须要与线上服务保持一致,从而保障成果层面的一致性,如文本归一化、分词等。
而这些离线生产过程通常用Python与Java实现。如果在线、离线用不同语言各自开发一份,则很难维持策略与成果上的对立。同时这些能力会有一直的迭代,在这种动静场景下,一直保护多语言版本的成果打平,给咱们的日常迭代带来了极大的老本。因而,咱们尝试通过跨语言调用动态链接库的技术解决这个问题,即开发一次基于C++的so,通过不同语言的链接层封装成不同语言的组件库,并投入到对应的生成过程。这种计划的劣势非常明显,主体的业务逻辑只须要开发一次,封装层只须要极少量的代码,主体业务迭代降级,其它语言简直不须要改变,只须要蕴含最新的动态链接库,公布最新版本即可。同时C++作为更底层的语言,在很多场景下,它的计算效率更高,硬件资源利用率更高,也为咱们带来了一些性能上的劣势。
本文对咱们在理论生产中尝试这一技术计划时,遇到的问题与一些实践经验做了残缺的梳理,心愿能为大家提供一些参考或帮忙。
2 计划概述
为了达到业务方开箱即用的目标,综合思考C++、Python、Java用户的应用习惯,咱们设计了如下的合作构造:
3 实现详情
Python、Java反对调用C接口,但不反对调用C++接口,因而对于C++语言实现的接口,必须转换为C语言实现。为了不批改原始C++代码,在C++接口下层用C语言进行一次封装,这部分代码通常被称为“胶水代码”(Glue Code)。具体计划如下图所示:
本章节各局部内容如下:
- 【性能代码】局部,通过打印字符串的例子来讲述各语言局部的编码工作。
- 【打包公布】局部,介绍如何将生成的动静库作为资源文件与Python、Java代码打包在一起公布到仓库,以升高应用方的接入老本。
- 【业务应用】局部,介绍开箱即用的应用示例。
- 【易用性优化】局部,结合实际应用中遇到的问题,讲述了对于Python版本兼容,以及动静库依赖问题的解决形式。
3.1 性能代码
3.1.1 C++代码
作为示例,实现一个打印字符串的性能。为了模仿理论的工业场景,对以下代码进行编译,别离生成动静库 libstr_print_cpp.so
、动态库libstr_print_cpp.a
。
str_print.h
<code class="cpp">#pragma once #include <string> class StrPrint { public: void print(const std::string& text); };
str_print.cpp
<code class="cpp">#include <iostream> #include "str_print.h" void StrPrint::print(const std::string& text) { std::cout << text << std::endl; }
3.1.2 c_wrapper代码
如上文所述,须要对C++库进行封装,革新成对外提供C语言格局的接口。
c_wrapper.cpp
#include "str_print.h" extern "C" { void str_print(const char* text) { StrPrint cpp_ins; std::string str = text; cpp_ins.print(str); } }
3.1.3 生成动静库
为了反对Python与Java的跨语言调用,咱们须要对封装好的接口生成动静库,生成动静库的形式有以下三种
- 形式一:源码依赖形式,将c_wrapper和C++代码一起编译生成
libstr_print.so
。这种形式业务方只须要依赖一个so,应用老本较小,然而须要获取到源码。对于一些现成的动静库,可能不实用。
<code class="bash">g++ -o libstr_print.so str_print.cpp c_wrapper.cpp -fPIC -shared
- 形式二:动静链接形式,这种形式生成的
libstr_print.so
,公布时须要携带上其依赖库libstr_print_cpp.so
。 这种形式,业务方须要同时依赖两个so,应用的老本绝对要高,然而不用提供原动静库的源码。
<code class="bash">g++ -o libstr_print.so c_wrapper.cpp -fPIC -shared -L. -lstr_print_cpp
- 形式三:动态链接形式,这种形式生成的
libstr_print.so
,公布时无需携带上libstr_print_cpp.so
。 这种形式,业务方只需依赖一个so,不用依赖源码,然而须要提供动态库。
<code class="bash">g++ c_wrapper.cpp libstr_print_cpp.a -fPIC -shared -o libstr_print.so
上述三种形式,各自有实用场景和优缺点。在咱们本次的业务场景下,因为工具库与封装库均由咱们本人开发,可能获取到源码,因而抉择第一种形式,业务方依赖更加简略。
3.1.4 Python接入代码
Python规范库自带的ctypes能够实现加载C的动静库的性能,应用办法如下:
str_print.py
# -*- coding: utf-8 -*- import ctypes # 加载 C lib lib = ctypes.cdll.LoadLibrary("./libstr_print.so") # 接口参数类型映射 lib.str_print.argtypes = [ctypes.c_char_p] lib.str_print.restype = None # 调用接口 lib.str_print('Hello World')
LoadLibrary会返回一个指向动静库的实例,通过它能够在Python里间接调用该库中的函数。argtypes与restype是动静库中函数的参数属性,前者是一个ctypes类型的列表或元组,用于指定动静库中函数接口的参数类型,后者是函数的返回类型(默认是c_int,能够不指定,对于非c_int型须要显示指定)。该局部波及到的参数类型映射,以及如何向函数中传递struct、指针等高级类型,能够参考附录中的文档。
3.1.5 Java接入代码
Java调用C lib有JNI与JNA两种形式,从应用便捷性来看,更举荐JNA形式。
3.1.5.1 JNI接入
Java从1.1版本开始反对JNI接口协议,用于实现Java语言调用C/C++动静库。JNI形式下,前文提到的c_wrapper模块不再实用,JNI协定自身提供了适配层的接口定义,须要依照这个定义进行实现。JNI形式的具体接入步骤为:
Java代码里,在须要跨语言调用的办法上,减少native关键字,用以申明这是一个本地办法。
<code class="java">import java.lang.String; public class JniDemo { public native void print(String text); }
通过javah命令,将代码中的native办法生成对应的C语言的头文件。这个头文件相似于前文提到的c_wrapper作用。
<code class="bash">javah JniDemo
失去的头文件如下(为节俭篇幅,这里简化了一些正文和宏):
<code class="java">#include <jni.h> #ifdef __cplusplus extern "C" { #endif JNIEXPORT void JNICALL Java_JniDemo_print (JNIEnv *, jobject, jstring); #ifdef __cplusplus } #endif
jni.h在JDK中提供,其中定义了Java与C语言调用所必须的相干实现。
JNIEXPORT和JNICALL是JNI中定义的两个宏,JNIEXPORT标识了反对在内部程序代码中调用该动静库中的办法,JNICALL定义了函数调用时参数的入栈出栈约定。
Java_JniDemo_print
是一个主动生成的函数名,它的格局是固定的由Java_{className}_{methodName}
形成,JNI会依照这个约定去注册Java办法与C函数的映射。
三个参数里,前两个是固定的。JNIEnv中封装了jni.h 里的一些工具办法,jobject指向Java中的调用类,即JniDemo,通过它能够找到Java里class中的成员变量在C的堆栈中的拷贝。 jstring 指向传入参数 text,这是对于Java 中String类型的一个映射。无关类型映射的具体内容,会在后文具体开展。
编写实现Java_JniDemo_print
办法。
JniDemo.cpp
<code class="cpp">#include <string> #include "JniDemo.h" #include "str_print.h" JNIEXPORT void JNICALL Java_JniDemo_print (JNIEnv *env, jobject obj, jstring text) { char* str=(char*)env->GetStringUTFChars(text,JNI_FALSE); std::string tmp = str; StrPrint ins; ins.print(tmp); }
编译生成动静库。
<code class="bash">g++ -o libJniDemo.so JniDemo.cpp str_print.cpp -fPIC -shared -I<$JAVA_HOME>/include/ -I<$JAVA_HOME>/include/linux
编译运行。
<code class="bash">java -Djava.library.path=<path_to_libJniDemo.so> JniDemo
JNI机制通过一层C/C++ 的桥接,实现了跨语言调用协定。这一性能在Android零碎中一些图形计算相干的Java程序下有着大量利用。一方面可能通过Java调用大量操作系统底层库,极大的缩小了JDK上的驱动开发的工作量,另一方面可能更充沛的利用硬件性能。然而通过3.1.5.1中的形容也能够看到,JNI的实现形式自身的实现老本还是比拟高的。尤其桥接层的C/C++代码的编写,在解决简单类型的参数传递时,开发成本较大。为了优化这个过程,Sun公司主导了JNA(Java Native Access)开源工程的工作。
3.1.5.2 JNA接入
JNA是在JNI根底上实现的编程框架,它提供了C语言动静转发器,实现了Java类型到C类型的主动转换。因而,Java开发人员只有在一个Java接口中形容指标native library的函数与构造,不再须要编写任何Native/JNI代码,极大的升高了Java调用本地共享库的开发难度。
JNA的应用办法如下:
在Java我的项目中引入JNA库。
<code class="xml"><dependency> <groupId>com.sun.jna</groupId> <artifactId>jna</artifactId> <version>5.4.0</version> </dependency>
申明与动静库对应的Java接口类。
<code class="java">public interface CLibrary extends Library { void str_print(String text); // 办法名和动静库接口统一,参数类型须要用Java里的类型示意,执行时会做类型映射,原理介绍章节会有具体解释 }
加载动态链接库,并实现接口办法。
JnaDemo.java
<code class="java">package com.jna.demo; import com.sun.jna.Library; import com.sun.jna.Native; public class JnaDemo { private CLibrary cLibrary; public interface CLibrary extends Library { void str_print(String text); } public JnaDemo() { cLibrary = Native.load("str_print", CLibrary.class); } public void str_print(String text) { cLibrary.str_print(text); } }
比照能够发现,相比于JNI,JNA不再须要指定native关键字,不再须要生成JNI局部C代码,也不再须要显示的做参数类型转化,极大地提高了调用动静库的效率。
3.2 打包公布
为了做到开箱即用,咱们将动静库与对应语言代码打包在一起,并主动筹备好对应依赖环境。这样应用方只须要装置对应的库,并引入到工程中,就能够间接开始调用。这里须要解释的是,咱们没有将so公布到运行机器上,而是将其和接口代码一并公布至代码仓库,起因是咱们所开发的工具代码可能被不同业务、不同背景(非C++)团队应用,不能保障各个业务方团队都应用对立的、标准化的运行环境,无奈做到so的对立公布、更新。
3.2.1 Python 包公布
Python能够通过setuptools将工具库打包,公布至pypi公共仓库中。具体操作办法如下:
创立目录。
<code class="bash"> . ├── MANIFEST.in #指定动态依赖 ├── setup.py # 公布配置的代码 └── strprint # 工具库的源码目录 ├── __init__.py # 工具包的入口 └── libstr_print.so # 依赖的c_wrapper 动静库
编写__init__.py, 将上文代码封装成办法。
# -*- coding: utf-8 -*- import ctypes import os import sys dirname, _ = os.path.split(os.path.abspath(__file__)) lib = ctypes.cdll.LoadLibrary(dirname + "/libstr_print.so") lib.str_print.argtypes = [ctypes.c_char_p] lib.str_print.restype = None def str_print(text): lib.str_print(text)
编写setup.py。
from setuptools import setup, find_packages setup( name="strprint", version="1.0.0", packages=find_packages(), include_package_data=True, description='str print', author='xxx', package_data={ 'strprint': ['*.so'] }, )
编写MANIFEST.in。
include strprint/libstr_print.so
打包公布。
<code class="bash">python setup.py sdist upload
3.2.2 Java接口
对于Java接口,将其打包成JAR包,并公布至Maven仓库中。
编写封装接口代码JnaDemo.java
。
<code class="java"> package com.jna.demo; import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; public class JnaDemo { private CLibrary cLibrary; public interface CLibrary extends Library { Pointer create(); void str_print(String text); } public static JnaDemo create() { JnaDemo jnademo = new JnaDemo(); jnademo.cLibrary = Native.load("str_print", CLibrary.class); //System.out.println("test"); return jnademo; } public void print(String text) { cLibrary.str_print(text); } }
创立resources目录,并将依赖的动静库放到该目录。
通过打包插件,将依赖的库一并打包到JAR包中。
<code class="xml"> <plugin> <artifactId>maven-assembly-plugin</artifactId> <configuration> <appendAssemblyId>false</appendAssemblyId> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>assembly</goal> </goals> </execution> </executions> </plugin>
3.3 业务应用
3.3.1 Python应用
装置strprint包。
<code class="bash"> pip install strprint==1.0.0
应用示例:
# -*- coding: utf-8 -*- import sys from strprint import * str_print('Hello py')
3.3.2 Java应用
pom引入JAR包。
<code class="java"> <dependency> <groupId>com.jna.demo</groupId> <artifactId>jnademo</artifactId> <version>1.0</version> </dependency>
应用示例:
<code class="java"> JnaDemo jnademo = new JnaDemo(); jnademo.str_print("hello jna");
3.4 易用性优化
3.4.1 Python版本兼容
Python2与Python3版本的问题,是Python开发用户始终诟病的槽点。因为工具面向不同的业务团队,咱们没有方法强制要求应用对立的Python版本,然而咱们能够通过对工具库做一下简略解决,实现两个版本的兼容。Python版本兼容里,须要留神两方面的问题:
- 语法兼容
- 数据编码
Python代码的封装里,根本不牵扯语法兼容问题,咱们的工作次要集中在数据编码问题上。因为Python 3的str类型应用的是unicode编码,而在C中,咱们须要的char* 是utf8编码,因而须要对于传入的字符串做utf8编码解决,对于C语言返回的字符串,做utf8转换成unicode的解码解决。于是对于上例子,咱们做了如下革新:
# -*- coding: utf-8 -*- import ctypes import os import sys dirname, _ = os.path.split(os.path.abspath(__file__)) lib = ctypes.cdll.LoadLibrary(dirname + "/libstr_print.so") lib.str_print.argtypes = [ctypes.c_char_p] lib.str_print.restype = None def is_python3(): return sys.version_info[0] == 3 def encode_str(input): if is_python3() and type(input) is str: return bytes(input, encoding='utf8') return input def decode_str(input): if is_python3() and type(input) is bytes: return input.decode('utf8') return input def str_print(text): lib.str_print(encode_str(text))
3.4.2 依赖治理
在很多状况下,咱们调用的动静库,会依赖其它动静库,比方当咱们依赖的gcc/g++版本与运行环境上的不统一时,时常会遇到glibc_X.XX not found
的问题,这时须要咱们提供指定版本的libstdc.so
与libstdc++.so.6
。
为了实现开箱即用的指标,在依赖并不简单的状况下,咱们会将这些依赖也一并打包到公布包里,随工具包一起提供。对于这些间接依赖,在封装的代码里,并不需要显式的load,因为Python与Java的实现里,加载动静库,最终调用的都是零碎函数dlopen。这个函数在加载指标动静库时,会主动的加载它的间接依赖。所以咱们所须要做的,就只是将这些依赖搁置到dlopen可能查找到门路下。
dlopen查找依赖的程序如下:
- 从dlopen调用方ELF(Executable and Linkable Format)的DT_RPATH所指定的目录下寻找,ELF是so的文件格式,这里的DT_RPATH是写在动静库文件的,惯例伎俩下,咱们无奈批改这个局部。
- 从环境变量LD_LIBRARY_PATH所指定的目录下寻找,这是最罕用的指定动静库门路的形式。
- 从dlopen调用方ELF的DT_RUNPATH所指定的目录下寻找,同样是在so文件中指定的门路。
- 从/etc/ld.so.cache寻找,须要批改/etc/ld.so.conf文件构建的指标缓存,因为须要root权限,所以在理论生产中,个别很少批改。
- 从/lib寻找, 系统目录,个别寄存零碎依赖的动静库。
- 从/usr/lib寻找,通过root装置的动静库,同样因为须要root权限,生产中,很少应用。
从上述查找程序中能够看出,对于依赖治理的最好形式,是通过指定LD_LIBRARY_PATH
变量的形式,使其蕴含咱们的工具包中的动静库资源所在的门路。另外,对于Java程序而言,咱们也能够通过指定java.library.path
运行参数的形式来指定动静库的地位。Java程序会将java.library.path
与动静库文件名拼接到一起作为绝对路径传递给dlopen,其加载程序排在上述程序之前。
最初,在Java中还有一个细节须要留神,咱们公布的工具包是以JAR包模式提供,JAR包实质上是一个压缩包,在Java程序中,咱们可能间接通过Native.load()
办法,间接加载位于我的项目resources目录里的so,这些资源文件打包后,会被放到JAR包中的根目录。
然而dlopen无奈加载这个目录。对于这一问题,最好的计划能够参考【2.1.3 生成动静库】一节中的打包办法,将依赖的动静库合成一个so,这样毋庸做任何环境配置,开箱即用。然而对于诸如libstdc++.so.6
等无奈打包在一个so的中零碎库,更为通用的做法是,在服务初始化时将so文件从JAR包中拷贝至本地某个目录,并指定LD_LIBRARY_PATH
蕴含该目录。
4. 原理介绍
4.1 为什么须要一个c_wrapper
实现计划一节中提到Python/Java不能间接调用C++接口,要先对C++中对外提供的接口用C语言的模式进行封装。这里根本原因在于应用动静库中的接口前,须要依据函数名查找接口在内存中的地址,动静库中函数的寻址通过零碎函数dlsym实现,dlsym是严格依照传入的函数名寻址。
在C语言中,函数签名即为代码函数的名称,而在C++语言中,因为须要反对函数重载,可能会有多个同名函数。为了保障签名惟一,C++通过name mangling机制为雷同名字不同实现的函数生成不同的签名,生成的签名会是一个像__Z4funcPN4printE这样的字符串,无奈被dlsym辨认(注:Linux零碎下可执行程序或者动静库多是以ELF格局组织二进制数据,其中所有的非动态函数(non-static)以“符号(symbol)”作为惟一标识,用于在链接过程和执行过程中辨别不同的函数,并在执行时映射到具体的指令地址,这个“符号”咱们通常称之为函数签名)。
为了解决这个问题,咱们须要通过extern “C” 指定函数应用C的签名形式进行编译。因而当依赖的动静库是C++库时,须要通过一个c_wrapper模块作为桥接。而对于依赖库是C语言编译的动静库时,则不须要这个模块,能够间接调用。
4.2 跨语言调用如何实现参数传递
C/C++函数调用的规范过程如下:
- 在内存的栈空间中为被调函数调配一个栈帧,用来寄存被调函数的形参、局部变量和返回地址。
- 将实参的值复制给相应的形参变量(能够是指针、援用、值拷贝)。
- 控制流转移到被调函数的起始地位,并执行。
- 控制流返回到函数调用点,并将返回值给到调用方,同时栈帧开释。
由以上过程可知,函数调用波及内存的申请开释、实参到形参的拷贝等,Python/Java这种基于虚拟机运行的程序,在其虚拟机外部也同样恪守上述过程,但波及到调用非原生语言实现的动静库程序时,调用过程是怎么的呢?
因为Python/Java的调用过程基本一致,咱们以Java的调用过程为例来进行解释,对于Python的调用过程不再赘述。
4.2.1 内存治理
在Java的世界里,内存由JVM对立进行治理,JVM的内存由栈区、堆区、办法区形成,在较为具体的材料中,还会提到native heap与native stack,其实这个问题,咱们不从JVM的角度去看,而是从操作系统层面登程来了解会更为简略直观。以Linux零碎下为例,首先JVM名义上是一个虚拟机,然而其本质就是跑在操作系统上的一个过程,因而这个过程的内存会存在如下左图所示划分。而JVM的内存治理本质上是在过程的堆上进行从新划分,本人又“虚构”出Java世界里的堆栈。如右图所示,native的栈区就是JVM过程的栈区,过程的堆区一部分用于JVM进行治理,残余的则能够给native办法进行调配应用。
4.2.2 调用过程
前文提到,native办法调用前,须要将其所在的动静库加载到内存中,这个过程是利用Linux的dlopen实现的,JVM会把动静库中的代码片段放到Native Code区域,同时会在JVM Bytecode区域保留一份native办法名与其所在Native Code里的内存地址映射。
一次native办法的调用步骤,大抵分为四步:
- 从JVM Bytecode获取native办法的地址。
- 筹备办法所需的参数。
- 切换到native栈中,执行native办法。
- native办法出栈后,切换回JVM办法,JVM将后果拷贝至JVM的栈或堆中。
由上述步骤能够看出,native办法的调用同样波及参数的拷贝,并且其拷贝是建设在JVM堆栈和原生堆栈之间。
对于原生数据类型,参数是通过值拷贝形式与native办法地址一起入栈。而对于简单数据类型,则须要一套协定,将Java中的object映射到C/C++中能辨认的数据字节。起因是JVM与C语言中的内存排布差别较大,不能间接内存拷贝,这些差别次要包含:
- 类型长度不同,比方char在Java里为16字节,在C外面却是8个字节。
- JVM与操作系统的字节程序(Big Endian还是Little Endian)可能不统一。
- JVM的对象中,会蕴含一些meta信息,而C里的struct则只是根底类型的并列排布,同样Java中没有指针,也须要进行封装和映射。
上图展现了native办法调用过程中参数传递的过程,其中映射拷贝在JNI中是由C/C++链接局部的胶水代码实现,类型的映射定义在jni.h中。
Java根本类型与C根本类型的映射(通过值传递。将Java对象在JVM内存里的值拷贝至栈帧的形参地位):
<code class="cpp">typedef unsigned char jboolean; typedef unsigned short jchar; typedef short jshort; typedef float jfloat; typedef double jdouble; typedef jint jsize;
Java简单类型与C简单类型的映射(通过指针传递。首先依据根本类型一一映射,将组装好的新对象的地址拷贝至栈帧的形参地位):
<code class="cpp">typedef _jobject *jobject; typedef _jclass *jclass; typedef _jthrowable *jthrowable; typedef _jstring *jstring; typedef _jarray *jarray;
注:在Java中,非原生类型均是Object的派生类,多个object的数组自身也是一个object,每个object的类型是一个class,同时class自身也是一个object。
<code class="cpp">class _jobject {}; class _jclass : public _jobject {}; class _jthrowable : public _jobject {}; class _jarray : public _jobject {}; class _jcharArray : public _jarray {}; class _jobjectArray : public _jarray {};
jni.h 中配套提供了内存拷贝和读取的工具类,比方后面例子中的GetStringUTFChars
可能将JVM中的字符串中的文本内容,依照utf8编码的格局,拷贝到native heap中,并将char*指针传递给native办法应用。
整个调用过程,产生的内存拷贝,Java中的对象由JVM的GC进行清理,Native Heap中的对象如果是由 JNI框架调配生成的,如上文JNI示例中的参数,均由框架进行对立开释。而在C/C++中新调配的对象,则须要用户代码在C/C++中手动开释。简而言之,Native Heap中与一般的C/C++过程统一,没有GC机制的存在,并且遵循着谁调配谁开释的内存治理准则。
4.3 扩大浏览(JNA间接映射)
相比于JNI,JNA应用了其函数调用的根底框架,其中的内存映射局部,由JNA工具库中的工具类自动化的实现类型映射和内存拷贝的大部分工作,从而防止大量胶水代码的编写,应用上更为敌对,但相应的这部分工作则产生了一些性能上的损耗。
JNA还额定提供了一种“间接映射”(DirectMapping)的调用形式来补救这一有余。然而间接映射对于参数有着较为严格的限度,只能传递原生类型、对应数组以及Native援用类型,并且不反对不定参数,办法返回类型只能是原生类型。
间接映射的Java代码中须要减少native关键字,这与JNI的写法统一。
DirectMapping
示例
<code class="java">import com.sun.jna.*; public class JnaDemo { public static native double cos(DoubleByReference x); static { Native.register(Platform.C_LIBRARY_NAME); } public static void main(String[] args) { System.out.println(cos(new DoubleByReference(1.0))); } }
DoubleByReference即是双精度浮点数的Native援用类型的实现,它的JNA源码定义如下(仅截取相干代码):
<code class="java">//DoubleByReference public class DoubleByReference extends ByReference { public DoubleByReference(double value) { super(8); setValue(value); } } // ByReference public abstract class ByReference extends PointerType { protected ByReference(int dataSize) { setPointer(new Memory(dataSize)); } }
Memory类型是Java版的shared_ptr实现,它通过援用引数的形式,封装了内存调配、援用、开释的相干细节。这种类型的数据内存实际上是调配在native的堆中,Java代码中,只能拿到指向该内存的援用。JNA在结构Memory对象的时候通过调用malloc在堆中调配新内存,并记录指向该内存的指针。
在ByReference的对象开释时,调用free,开释该内存。JNA的源码中ByReference基类的finalize 办法会在GC时调用,此时会去开释对应申请的内存。因而在JNA的实现中,动静库中的调配的内存由动静库的代码治理,JNA框架调配的内存由JNA中的代码显示开释,然而其触发机会,则是靠JVM中的GC机制开释JNA对象时来触发运行。这与前文提到的Native Heap中不存在GC机制,遵循谁调配谁开释的准则是统一的。
<code class="java">@Override protected void finalize() { dispose(); } /** Free the native memory and set peer to zero */ protected synchronized void dispose() { if (peer == 0) { // someone called dispose before, the finalizer will call dispose again return; } try { free(peer); } finally { peer = 0; // no null check here, tracking is only null for SharedMemory // SharedMemory is overriding the dispose method reference.unlink(); } }
4.4 性能剖析
进步运算效率是Native调用中的一个重要目标,然而通过上述剖析也不难发现,在一次跨语言本地化的调用过程中,依然有大量的跨语言工作须要实现,这些过程也须要收入对应的算力。因而并不是所有Native调用,都能进步运算效率。为此咱们须要了解语言间的性能差别在哪儿,以及跨语言调用须要消耗多大的算力收入。
语言间的性能差别次要体现在三个方面:
- Python与Java语言都是解释执行类语言,在运行期间,须要先把脚本或字节码翻译成二进制机器指令,再交给CPU进行执行。而C/C++编译执行类语言,则是间接编译为机器指令执行。只管有JIT等运行时优化机制,但也只能肯定水平上放大这一差距。
- 下层语言有较多操作,自身就是通过跨语言调用的形式由操作系统底层实现,这一部分显然不如间接调用的效率高。
- Python与Java语言的内存管理机制引入了垃圾回收机制,用于简化内存治理,GC工作在运行时,会占用肯定的零碎开销。这一部分效率差别,通常以运行时毛刺的状态呈现,即对均匀运行时长影响不显著,然而对个别时刻的运行效率造成较大影响。
而跨语言调用的开销,次要包含三局部:
- 对于JNA这种由动静代理实现的跨语言调用,在调用过程中存在堆栈切换、代理路由等工作。
- 寻址与结构本地办法栈,行将Java中native办法对应到动静库中的函数地址,并结构调用现场的工作。
- 内存映射,尤其存在大量数据从JVM Heap向Native Heap 进行拷贝时,这部分的开销是跨语言调用的次要耗时所在。
咱们通过如下试验简略做了一下性能比照,咱们别离用C语言、Java、JNI、JNA以及JNA间接映射五种形式,别离进行100万次到1000万次的余弦计算,失去耗时比照。在6核16G机器,咱们失去如下后果:
由试验数据可知,运行效率顺次是 C > Java > JNI > JNA DirectMapping > JNA
。 C语言高于Java的效率,但两者十分靠近。JNI与JNA DirectMapping的形式性能基本一致,然而会比原生语言的实现要慢很多。一般模式下的JNA的速度最慢,会比JNI慢5到6倍。
综上所述,跨语言本地化调用,并不总是可能晋升计算性能,须要综合计算工作的复杂度和跨语言调用的耗时进行综合衡量。咱们目前总结到的适宜跨语言调用的场景有:
- 离线数据分析:离线工作可能会波及到多种语言开发,且对耗时不敏感,外围点在于多语言下的成果打平,跨语言调用能够节俭多语言版本的开发成本。
- 跨语言RPC调用转换为跨语言本地化调用:对于计算耗时是微秒级以及更小的量级的计算申请,如果通过RPC调用来取得后果,用于网络传输的工夫至多是毫秒级,远大于计算开销。在依赖简略的状况下,转化为本地化调用,将大幅缩减单申请的解决工夫。
- 对于一些简单的模型计算,Python/Java跨语言调用C++能够晋升计算效率。
5 利用案例
如上文所述,通过本地化调用的计划可能在性能和开发成本上带来一些收益。咱们将这些技术在离线工作计算与实时服务调用做了一些尝试,并获得了比拟现实的后果。
5.1 离线工作中的利用
搜寻业务中会有大量的词表开掘、数据处理、索引构建等离线计算工作。这个过程会用到较多查问了解里的文本处理和辨认能力,如分词、名命体辨认等。因为开发语言的差别,将这些能力在本地从新开发一遍,老本上无奈承受。因而之前的工作中,在离线计算过程中会通过RPC形式调用线上服务。这个计划带来如下问题:
- 离线计算工作的量级通常较大,执行过程中申请比拟密集,会占用占用线上资源,影响线上用户申请,安全性较低。
- 单次RPC的耗时至多是毫秒级,而理论的计算工夫往往十分短,因而大部分工夫实际上节约在了网络通信上,重大影响工作的执行效率。
- RPC服务因为网络抖动等因为,调用成功率不能达到100%,影响工作执行成果。
- 离线工作需引入RPC调用相干代码,在Python脚本等轻量级计算工作里,这部分的代码往往因为一些根底组件的不欠缺,导致接入老本较高。
将RPC调用革新为跨语言本地化调用后,上述问题得以解决,收益显著。
- 不再调用线上服务,流量隔离,对线上平安不产生影响。
- 对于1000万条以上的离线工作,累计节俭至多10小时以上的网络开销工夫。
- 打消网络抖动导致的申请失败问题。
- 通过上述章节的工作,提供了开箱即用的本地化工具,极大的简化了应用老本。
5.2 在线服务中的利用
查问了解作为美团外部的根底服务平台,提供分词词性、查问纠错、查问改写、地标辨认、异地辨认、用意辨认、实体辨认、实体链接等文本剖析,是一个较大的CPU密集型服务,承接了公司内十分多的本文剖析业务场景,其中有局部场景只是须要个别信号,甚至只须要查问了解服务中的根底函数组件,对于大部分是通过Java开发的业务服务,无奈间接援用查问了解的C++动静库,此前个别是通过RPC调用获取后果。通过上述工作,在非C++语言的调用方服务中,能够将RPC调用转化为跨语言本地化调用,可能显著的晋升调用端的性能以及成功率,同时也能无效缩小服务端的资源开销。
6 总结
微服务等技术的倒退使得服务创立、公布和接入变得越来越简略,然而在理论工业生产中,并非所有场景都适宜通过RPC服务实现计算。尤其在计算密集型和耗时敏感型的业务场景下,当性能成为瓶颈时,近程调用带来的网络开销就成了业务不可接受之痛。本文对语言本地化调用的技术进行了总结,并给出一些实践经验,心愿能为大家解决相似的问题提供一些帮忙。
当然,本次工作中还有许多有余,例如因为理论生产环境的要求,咱们的工作根本都集中在Linux零碎下,如果是以凋谢库模式,让应用方能够自在应用的话,可能还须要思考兼容Windows下的DLL,Mac OS下的dylib等等。本文可能还存在其余不足之处,欢送大家指留言斧正、探讨。
本文例子的源代码请拜访:GitHub。
7 参考文献
- JNI内存相干文档
- JNI类型映射
- JNA开源地址
- Linux dlopen
- Linux dlclose
- Linux dlsym
- CPython源码
- CPython中ctypes的介绍
- CTypes Struct实现
- Python我的项目散发打包
- C与C++函数签名
- JNI,JNA与JNR性能比照
8 本文作者
林阳、朱超、识瀚,均来自美团平台/搜寻与NLP部/搜寻技术部。
浏览美团技术团队更多技术文章合集
前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试
| 在公众号菜单栏对话框回复【2021年货】、【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。
| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至[email protected]申请受权。