不同的开发语言适宜不同的领域,比如Python适宜做数据剖析,C++适宜做系统的底层开发,如果它们须要用到相同功能的基础组件,组件使用多种语言分别开发的话,除了降低了开发和维护成本,但是不能确保多种语言间在处理疗效上是一致的。本文述说在Linux系统下跨语言调用的实践总结,即开发一次C++语言的组件,其他语言通过跨语言调用技术调用C++组件。
1背景
查询理解(QU,QueryUnderstanding)是美团搜索的核心模块,主要职责是理解用户查询,生成查询意图、成分、改写等基础讯号,应用于搜索的召回、排序、展示等多个环节,对搜索基础体验至关重要。该服务的线上主体程序基于C++语言开发,服务中会加载大量的词表数据、预估模型等,这种数据与模型的离线生产过程有好多文本解析能力须要与线上服务保持一致,因而保证疗效层面的一致性,如文本归一化、分词等。
而这种离线生产过程一般用Python与Java实现。假如在线、离线用不同语言各自开发一份,则很难维持策略与疗效上的统一。同时这种能力会有不断的迭代,在这些动态场景下,不断维护多语言版本的疗效打平,给我们的日常迭代带来了极大的成本。为此,我们尝试通过跨语言调用动态链接库的技术解决这个问题,即开发一次基于C++的so,通过不同语言的链接层封装成不同语言的组件库,并投入到对应的生成过程。这些方案的优势十分显著,主体的业务逻辑只须要开发一次,封装层只须要极少量的代码,主体业务迭代升级,其它语言几乎不须要改动,只须要包含最新的动态链接库64位linux,发布最新版本即可。同时C++作为更底层的语言,在好多场景下,它的估算效率更高,硬件资源借助率更高,也为我们带来了一些性能上的优势。
本文对我们在实际生产中尝试这一技术方案时,碰到的问题与一些实践经验做了完整的梳理,希望能为你们提供一些参考或帮助。
2方案概述
为了达到业务方开箱即用的目的,综合考虑C++、Python、Java用户的使用习惯,我们设计了如下的协作结构:
图1
3实现详情
Python、Java支持调用C插口,但不支持调用C++插口,因而对于C++语言实现的插口,必须转换为C语言实现。为了不更改原始C++代码,在C++插口下层用C语言进行一次封装,这部份代码一般被称为“胶水代码”(GlueCode)。具体方案如右图所示:
图2
本章节各部份内容如下:
3.1功能代码3.1.1C++代码
作为示例,实现一个复印字符串的功能。为了模拟实际的工业场景,对以下代码进行编译,分别生成动态库libstr_print_cpp.so、静态库libstr_print_cpp.a。
str_print.h
#pragma once
#include
class StrPrint {
public:
void print(const std::string& text);
};
str_print.cpp
#include
#include "str_print.h"
void StrPrint::print(const std::string& text) {
std::cout << text << std::endl;
}
3.1.2c_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的跨语言调用,我们须要对封装好的插口生成动态库,生成动态库的方法有以下三种
g++ -o libstr_print.so str_print.cpp c_wrapper.cpp -fPIC -shared
g++ -o libstr_print.so c_wrapper.cpp -fPIC -shared -L. -lstr_print_cpp
g++ c_wrapper.cpp libstr_print_cpp.a -fPIC -shared -o libstr_print.so
上述三种形式,各自有适用场景和优劣点。在我们本次的业务场景下,由于工具库与封装库均由我们自己开发,才能获取到源码,因而选择第一种方法,业务方依赖愈发简单。
3.1.4Python接入代码
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.5Java接入代码
Java调用Clib有JNI与JNA两种形式,从使用方便性来看,更推荐JNA形式。
3.1.5.1JNI接入
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
#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
#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/include/ -I/include/linux
编译运行。
java -Djava.library.path= JniDemo
JNI机制通过一层C/C++的桥接,实现了跨语言调用合同。这一功能在Android系统中一些图形估算相关的Java程序下有着大量应用。一方面才能通过Java调用大量操作系统底层库,极大的降低了JDK上的驱动开发的工作量,另一方面就能更充分的借助硬件性能。并且通过3.1.5.1中的描述也可以见到,JNI的实现方法本身的实现成本还是比较高的。尤其桥接层的C/C++代码的编撰,在处理复杂类型的参数传递时,开发成本较大。为了优化这个过程,Sun公司主导了JNA(JavaNativeAccess)开源工程的工作。
3.1.5.2JNA接入
JNA是在JNI基础上实现的编程框架,它提供了C语言动态转发器,实现了Java类型到C类型的手动转换。为此,Java开发人员只要在一个Java插口中描述目标nativelibrary的函数与结构,不再须要编撰任何Native/JNI代码,极大的增加了Java调用本地共享库的开发难度。
JNA的使用方式如下:
在Java项目中引入JNA库。
com.sun.jna
jna
5.4.0
申明与动态库对应的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.1Python包发布
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.2Java插口
对于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包中。
maven-assembly-plugin
false
jar-with-dependencies
make-assembly
package
assembly
3.3业务使用3.3.1Python使用
安装strprint包。
pip install strprint==1.0.0
使用示例:
# -*- coding: utf-8 -*-
import sys
from strprint import *
str_print('Hello py')
3.3.2Java使用
pom引入JAR包。
com.jna.demo
jnademo
1.0
使用示例:
JnaDemo jnademo = new JnaDemo();
jnademo.str_print("hello jna");
3.4易用性优化3.4.1Python版本兼容
Python2与Python3版本的问题,是Python开发用户仍然非议的槽点。由于工具面向不同的业务团队,我们没有办法强制要求使用统一的Python版本,并且我们可以通过对工具库做一下简单处理,实现两个版本的兼容。Python版本兼容里,须要注意两方面的问题:
Python代码的封装里,基本不牵涉句型兼容问题,我们的工作主要集中在数据编码问题上。因为Python3的str类型使用的是unicode编码,而在C中linux 系统调用表,我们须要的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.XXnotfound的问题,这时须要我们提供指定版本的libstdc.so与libstdc++.so.6。
为了实现开箱即用的目标,在依赖并不复杂的情况下,我们会将那些依赖也一并打包到发挎包里,随工具包一起提供。对于这种间接依赖红旗linux系统,在封装的代码里,并不须要显式的load,由于Python与Java的实现里,加载动态库,最终调用的都是系统函数dlopen。这个函数在加载目标动态库时,会手动的加载它的间接依赖。所以我们所须要做的,就只是将那些依赖放置到dlopen才能查找到路径下。
dlopen查找依赖的次序如下:
从dlopen调用方ELF(ExecutableandLinkableFormat)的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的中系统库linux 系统调用表,更为通用的做法是,在服务初始化时将so文件从JAR包中拷贝至本地某个目录,并指定LD_LIBRARY_PATH包含该目录。
4.原理介绍4.1为何须要一个c_wrapper
实现方案一节中提及Python/Java不能直接调用C++插口,要先对C++中对外提供的插口用C语言的方式进行封装。这儿根本缘由在于使用动态库中的插口前,须要依照函数名查找插口在显存中的地址,动态库中函数的轮询通过系统函数dlsym实现,dlsym是严格依照传入的函数名轮询。
在C语言中,函数签名即为代码函数的名称,而在C++语言中,由于须要支持函数重载,可能会有多个同名函数。为了保证签名惟一,C++通过namemangling机制为相同名字不同实现的函数生成不同的签名,生成的签名会是一个像__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的显存由栈区、堆区、方法区构成,在较为详尽的资料中,都会提及nativeheap与nativestack,虽然这个问题,我们不从JVM的角度去看,而是从操作系统层面出发来理解会更为简单直观。以Linux系统下为例,首先JVM名义上是一个虚拟机,并且其本质就是跑在操作系统上的一个进程,因而这个进程的显存会存在如下左图所示界定。而JVM的显存管理实质上是在进程的堆上进行重新界定,自己又“虚拟”出Java世界里的堆栈。如下图所示,native的栈区就是JVM进程的栈区,进程的堆区一部份用于JVM进行管理,剩余的则可以给native方式进行分配使用。
图3
4.2.2调用过程
前文提及,native方式调用前,须要将其所在的动态库加载到显存中,这个过程是借助Linux的dlopen实现的,JVM会把动态库中的代码片断放在NativeCode区域,同时会在JVMBytecode区域保存一份native方式名与其所在NativeCode里的显存地址映射。
一次native方式的调用步骤,大致分为四步:
从JVMBytecode获取native方式的地址。打算方式所需的参数。切换到native栈中,执行native方式。native方式出栈后,切换回JVM方式,JVM将结果拷贝至JVM的栈或堆中。
图4
由上述步骤可以看出,native方式的调用同样涉及参数的拷贝,但是其拷贝是构建在JVM堆栈和原生堆栈之间。
对于原生数据类型,参数是通过值拷贝方法与native方式地址一起入栈。而对于复杂数据类型,则须要一套合同,将Java中的object映射到C/C++中能辨识的数据字节。缘由是JVM与C语言中的显存排布差别较大,不能直接显存拷贝,这种差别主要包括:
图5
上图展示了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编码的格式,拷贝到nativeheap中,并将char*表针传递给native方式使用。
整个调用过程,形成的显存拷贝,Java中的对象由JVM的GC进行清除,NativeHeap中的对象假如是由JNI框架分配生成的,如上文JNI示例中的参数,均由框架进行统一释放。而在C/C++中新分配的对象,则须要用户代码在C/C++中自动释放。简而言之,NativeHeap中与普通的C/C++进程一致,没有GC机制的存在,但是秉持着谁分配谁释放的显存整治原则。