总第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 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')
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 __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_JniDemo_print
(JNIEnv *, jobject, jstring);
#ifdef __cplusplus
}
#endif
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方法。
#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 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 依赖管理
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)));
}
}
//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));
}
}
@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 性能分析
-
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 总结
本文例子的源代码请访问:
GitHub。7 参考文献
-
JNI内存相关文档 -
JNI类型映射 -
JNA开源地址 -
Linux dlopen -
Linux dlclose -
Linux dlsym -
CPython源码 -
CPython中ctypes的介绍 -
CTypes Struct实现 -
Python项目分发打包 -
本文所涉及的例子源码 -
C与C++函数签名 -
JNI,JNA与JNR性能对比
8 本文作者
阅读更多
原文始发于微信公众号(美团技术团队):Linux下跨语言调用C++实践
免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论