Linux下跨语言调用C++实践
总第504 篇
2022年 第021篇
-
1 背景介绍
-
2 方案概述
-
3 实现详情
-
3.1 功能代码
-
3.2 打包发布
-
3.3 业务使用
-
3.4 易用性优化
-
4. 原理介绍
-
4.1 为什么需要一个c_wrapper
-
4.2 跨语言调用如何实现参数传递
-
4.3 扩展阅读(JNA直接映射)
-
4.4 性能分析
-
5 应用案例
-
5.1 离线任务中的应用
-
5.2 在线服务中的应用
-
6 总结
1 背景
2 方案概述
3 实现详情
-
【功能代码】部分,通过打印字符串的例子来讲述各语言部分的编码工作。 -
【打包发布】部分,介绍如何将生成的动态库作为资源文件与Python、Java代码打包在一起发布到仓库,以降低使用方的接入成本。 -
【业务使用】部分,介绍开箱即用的使用示例。 -
【易用性优化】部分,结合实际使用中遇到的问题,讲述了对于Python版本兼容,以及动态库依赖问题的处理方式。
3.1 功能代码
3.1.1 C++代码
#pragma once#include <string>class StrPrint { public: void print(const std::string& text);};
#include <iostream>#include "str_print.h"void StrPrint::print(const std::string& text) { std::cout << text << std::endl;}
3.1.2 c_wrapper代码
#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 生成动态库
-
方式一 :源码依赖方式,将c_wrapper和C++代码一起编译生成libstr_print.so。这种方式业务方只需要依赖一个so,使用成本较小,但是需要获取到C++源码。对于一些现成的动态库,可能不适用。
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接入代码
# -*- coding: utf-8 -*-import ctypes# 加载 C liblib = ctypes.cdll.LoadLibrary("./libstr_print.so")# 接口参数类型映射lib.str_print.argtypes = [ctypes.c_char_p]lib.str_print.restype = None# 调用接口lib.str_print('Hello World')
3.1.5 Java接入代码
3.1.5.1 JNI接入
import java.lang.String;public class JniDemo { public native void print(String text);}
javah JniDemo
#include <jni.h>#ifdef __cplusplusextern "C" {#endifJNIEXPORT void JNICALL Java_JniDemo_print (JNIEnv *, jobject, jstring);#ifdef __cplusplus}#endif
#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
3.1.5.2 JNA接入
<dependency> <groupId>com.sun.jna</groupId> <artifactId>jna</artifactId> <version>5.4.0</version></dependency>
public interface CLibrary extends Library { void str_print(String text); // 方法名和动态库接口一致,参数类型需要用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); }}
3.2 打包发布
3.2.1 Python 包发布
. ├── MANIFEST.in #指定静态依赖 ├── setup.py # 发布配置的代码 └── strprint # 工具库的源码目录 ├── __init__.py # 工具包的入口 └── libstr_print.so # 依赖的c_wrapper 动态库
# -*- 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)
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'] }, )
include strprint/libstr_print.so
python setup.py sdist upload
3.2.2 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); } }
<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使用
pip install strprint==1.0.0
# -*- coding: utf-8 -*- import sys from strprint import * str_print('Hello py')
3.3.2 Java使用
<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版本兼容
-
语法兼容 -
数据编码
# -*- coding: utf-8 -*-import ctypesimport osimport sysdirname, _ = 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 = Nonedef is_python3(): return sys.version_info[0] == 3def encode_str(input): if is_python3() and type(input) is str: return bytes(input, encoding='utf8') return inputdef decode_str(input): if is_python3() and type(input) is bytes: return input.decode('utf8') return inputdef str_print(text): lib.str_print(encode_str(text))
3.4.2 依赖管理
glibc_X.XX not found
的问题,这时需要我们提供指定版本的
libstdc.so
与
libstdc++.so.6
。
-
从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权限,生产中,很少使用。
4. 原理介绍
4.1 为什么需要一个c_wrapper
4.2 跨语言调用如何实现参数传递
-
在内存的栈空间中为被调函数分配一个栈帧,用来存放被调函数的形参、局部变量和返回地址。 -
将实参的值复制给相应的形参变量( 可以是指针、引用、值拷贝 )。 -
控制流转移到被调函数的起始位置,并执行。 -
控制流返回到函数调用点,并将返回值给到调用方,同时栈帧释放。
4.2.1 内存管理
4.2.2 调用过程
-
从JVM Bytecode获取native方法的地址。 -
准备方法所需的参数。 -
切换到native栈中,执行native方法。 -
native方法出栈后,切换回JVM方法,JVM将结果拷贝至JVM的栈或堆中。
-
类型长度不同,比如char在Java里为16比特,在C里面却是8个比特。 -
JVM与操作系统的字节顺序( Big Endian还是Little Endian )可能不一致。 -
JVM的对象中,会包含一些meta信息,而C里的struct则只是基础类型的并列排布,同样Java中没有指针,也需要进行封装和映射。
typedef unsigned char jboolean;typedef unsigned short jchar;typedef short jshort;typedef float jfloat;typedef double jdouble;typedef jint jsize;
typedef _jobject *jobject;typedef _jclass *jclass;typedef _jthrowable *jthrowable;typedef _jstring *jstring;typedef _jarray *jarray;
class _jobject {};class _jclass : public _jobject {};class _jthrowable : public _jobject {};class _jarray : public _jobject {};class _jcharArray : public _jarray {};class _jobjectArray : public _jarray {};
GetStringUTFChars
能够将JVM中的字符串中的文本内容,按照utf8编码的格式,拷贝到native heap中,并将char*指针传递给native方法使用。
4.3 扩展阅读(JNA直接映射)
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))); }}
//DoubleByReferencepublic class DoubleByReference extends ByReference { public DoubleByReference(double value) { super(8); setValue(value); }}// ByReferencepublic abstract class ByReference extends PointerType { protected ByReference(int dataSize) { setPointer(new Memory(dataSize)); }}
@Overrideprotected 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 性能分析
-
Python与Java语言都是解释执行类语言,在运行时期,需要先把脚本或字节码翻译成二进制机器指令,再交给CPU进行执行。而C/C++编译执行类语言,则是直接编译为机器指令执行。尽管有JIT等运行时优化机制,但也只能一定程度上缩小这一差距。 -
上层语言有较多操作,本身就是通过跨语言调用的方式由操作系统底层实现,这一部分显然不如直接调用的效率高。 -
Python与Java语言的内存管理机制引入了垃圾回收机制,用于简化内存管理,GC工作在运行时,会占用一定的系统开销。这一部分效率差异,通常以运行时毛刺的形态出现,即对平均运行时长影响不明显,但是对个别时刻的运行效率造成较大影响。
-
对于JNA这种由动态代理实现的跨语言调用,在调用过程中存在堆栈切换、代理路由等工作。 -
寻址与构造本地方法栈,即将Java中native方法对应到动态库中的函数地址,并构造调用现场的工作。 -
内存映射,尤其存在大量数据从JVM Heap向Native Heap 进行拷贝时,这部分的开销是跨语言调用的主要耗时所在。
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服务因为网络抖动等因为,调用成功率不能达到100%,影响任务执行效果。 -
离线任务需引入RPC调用相关代码,在Python脚本等轻量级计算任务里,这部分的代码往往因为一些基础组件的不完善,导致接入成本较高。
-
不再调用线上服务,流量隔离,对线上安全不产生影响。 -
对于1000万条以上的离线任务,累计节省至少10小时以上的网络开销时间。 -
消除网络抖动导致的请求失败问题。 -
通过上述章节的工作,提供了开箱即用的本地化工具,极大的简化了使用成本。
5.2 在线服务中的应用
6 总结
7 参考文献
-
JNI内存相关文档 -
JNI类型映射 -
JNA开源地址 -
Linux dlopen -
Linux dlclose -
Linux dlsym -
CPython源码 -
CPython中ctypes的介绍 -
CTypes Struct实现 -
Python项目分发打包 -
本文所涉及的例子源码 -
C与C++函数签名 -
JNI,JNA与JNR性能对比
8 本文作者
阅读更多