不同的开发语言适宜不同的畛域,例如 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
#pragma once
#include <string>
class StrPrint {
public:
void print(const std::string& text);
};
str_print.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,应用老本较小,然而须要获取到源码。对于一些现成的动静库,可能不实用。
g++ -o libstr_print.so str_print.cpp c_wrapper.cpp -fPIC -shared
- 形式二:动静链接形式,这种形式生成的
libstr_print.so
,公布时须要携带上其依赖库libstr_print_cpp.so
。这种形式,业务方须要同时依赖两个 so,应用的老本绝对要高,然而不用提供原动静库的源码。
g++ -o libstr_print.so c_wrapper.cpp -fPIC -shared -L. -lstr_print_cpp
- 形式三:动态链接形式,这种形式生成的
libstr_print.so
,公布时无需携带上libstr_print_cpp.so
。这种形式,业务方只需依赖一个 so,不用依赖源码,然而须要提供动态库。
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 关键字,用以申明这是一个本地办法。
import java.lang.String;
public class JniDemo {public native void print(String text);
}
通过 javah 命令,将代码中的 native 办法生成对应的 C 语言的头文件。这个头文件相似于前文提到的 c_wrapper 作用。
javah JniDemo
失去的头文件如下(为节俭篇幅,这里简化了一些正文和宏):
#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
#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);
}
编译生成动静库。
g++ -o libJniDemo.so JniDemo.cpp str_print.cpp -fPIC -shared -I<$JAVA_HOME>/include/ -I<$JAVA_HOME>/include/linux
编译运行。
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 库。
<dependency>
<groupId>com.sun.jna</groupId>
<artifactId>jna</artifactId>
<version>5.4.0</version>
</dependency>
申明与动静库对应的 Java 接口类。
public interface CLibrary extends Library {void str_print(String text); // 办法名和动静库接口统一,参数类型须要用 Java 里的类型示意,执行时会做类型映射,原理介绍章节会有具体解释
}
加载动态链接库,并实现接口办法。
JnaDemo.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 公共仓库中。具体操作办法如下:
创立目录。
.
├── 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
打包公布。
python setup.py sdist upload
3.2.2 Java 接口
对于 Java 接口,将其打包成 JAR 包,并公布至 Maven 仓库中。
编写封装接口代码JnaDemo.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 包中。
<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 包。
pip install strprint==1.0.0
应用示例:
# -*- coding: utf-8 -*-
import sys
from strprint import *
str_print('Hello py')
3.3.2 Java 应用
pom 引入 JAR 包。
<dependency>
<groupId>com.jna.demo</groupId>
<artifactId>jnademo</artifactId>
<version>1.0</version>
</dependency>
应用示例:
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 内存里的值拷贝至栈帧的形参地位):
typedef unsigned char jboolean;
typedef unsigned short jchar;
typedef short jshort;
typedef float jfloat;
typedef double jdouble;
typedef jint jsize;
Java 简单类型与 C 简单类型的映射(通过指针传递。首先依据根本类型一一映射,将组装好的新对象的地址拷贝至栈帧的形参地位):
typedef _jobject *jobject;
typedef _jclass *jclass;
typedef _jthrowable *jthrowable;
typedef _jstring *jstring;
typedef _jarray *jarray;
注:在 Java 中,非原生类型均是 Object 的派生类,多个 object 的数组自身也是一个 object,每个 object 的类型是一个 class,同时 class 自身也是一个 object。
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
示例
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 源码定义如下(仅截取相干代码):
//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 机制,遵循谁调配谁开释的准则是统一的。
@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 年货】等关键词,可查看美团技术团队历年技术文章合集。
| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至 tech@meituan.com 申请受权。