Linux下跨語言呼叫C++實踐

美團技術團隊發表於2022-04-29
不同的開發語言適合不同的領域,例如Python適合做資料分析,C++適合做系統的底層開發,假如它們需要用到相同功能的基礎元件,元件使用多種語言分別開發的話,不僅增加了開發和維護成本,而且不能確保多種語言間在處理效果上是一致的。本文講述在Linux系統下跨語言呼叫的實踐總結,即開發一次C++語言的元件,其他語言通過跨語言呼叫技術呼叫C++元件。

1 背景介紹

查詢理解(QU, Query Understanding)是美團搜尋的核心模組,主要職責是理解使用者查詢,生成查詢意圖、成分、改寫等基礎訊號,應用於搜尋的召回、排序、展示等多個環節,對搜尋基礎體驗至關重要。該服務的線上主體程式基於C++語言開發,服務中會載入大量的詞表資料、預估模型等,這些資料與模型的離線生產過程有很多文字解析能力需要與線上服務保持一致,從而保證效果層面的一致性,如文字歸一化、分詞等。

而這些離線生產過程通常用Python與Java實現。如果線上、離線用不同語言各自開發一份,則很難維持策略與效果上的統一。同時這些能力會有不斷的迭代,在這種動態場景下,不斷維護多語言版本的效果打平,給我們的日常迭代帶來了極大的成本。因此,我們嘗試通過跨語言呼叫動態連結庫的技術解決這個問題,即開發一次基於C++的so,通過不同語言的連結層封裝成不同語言的元件庫,並投入到對應的生成過程。這種方案的優勢非常明顯,主體的業務邏輯只需要開發一次,封裝層只需要極少量的程式碼,主體業務迭代升級,其它語言幾乎不需要改動,只需要包含最新的動態連結庫,釋出最新版本即可。同時C++作為更底層的語言,在很多場景下,它的計算效率更高,硬體資源利用率更高,也為我們帶來了一些效能上的優勢。

本文對我們在實際生產中嘗試這一技術方案時,遇到的問題與一些實踐經驗做了完整的梳理,希望能為大家提供一些參考或幫助。

2 方案概述

為了達到業務方開箱即用的目的,綜合考慮C++、Python、Java使用者的使用習慣,我們設計瞭如下的協作結構:

圖 1

3 實現詳情

Python、Java支援呼叫C介面,但不支援呼叫C++介面,因此對於C++語言實現的介面,必須轉換為C語言實現。為了不修改原始C++程式碼,在C++介面上層用C語言進行一次封裝,這部分程式碼通常被稱為“膠水程式碼”(Glue Code)。具體方案如下圖所示:

圖 2

本章節各部分內容如下:

  • 【功能程式碼】部分,通過列印字串的例子來講述各語言部分的編碼工作。
  • 【打包釋出】部分,介紹如何將生成的動態庫作為資原始檔與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.solibstdc++.so.6

為了實現開箱即用的目標,在依賴並不複雜的情況下,我們會將這些依賴也一併打包到釋出包裡,隨工具包一起提供。對於這些間接依賴,在封裝的程式碼裡,並不需要顯式的load,因為Python與Java的實現裡,載入動態庫,最終呼叫的都是系統函式dlopen。這個函式在載入目標動態庫時,會自動的載入它的間接依賴。所以我們所需要做的,就只是將這些依賴放置到dlopen能夠查詢到路徑下。

dlopen查詢依賴的順序如下:

  1. 從dlopen呼叫方ELF(Executable and Linkable Format)的DT_RPATH所指定的目錄下尋找,ELF是so的檔案格式,這裡的DT_RPATH是寫在動態庫檔案的,常規手段下,我們無法修改這個部分。
  2. 從環境變數LD_LIBRARY_PATH所指定的目錄下尋找,這是最常用的指定動態庫路徑的方式。
  3. 從dlopen呼叫方ELF的DT_RUNPATH所指定的目錄下尋找,同樣是在so檔案中指定的路徑。
  4. 從/etc/ld.so.cache尋找,需要修改/etc/ld.so.conf檔案構建的目標快取,因為需要root許可權,所以在實際生產中,一般很少修改。
  5. 從/lib尋找, 系統目錄,一般存放系統依賴的動態庫。
  6. 從/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++函式呼叫的標準過程如下:

  1. 在記憶體的棧空間中為被調函式分配一個棧幀,用來存放被調函式的形參、區域性變數和返回地址。
  2. 將實參的值複製給相應的形參變數(可以是指標、引用、值拷貝)。
  3. 控制流轉移到被調函式的起始位置,並執行。
  4. 控制流返回到函式呼叫點,並將返回值給到呼叫方,同時棧幀釋放。

由以上過程可知,函式呼叫涉及記憶體的申請釋放、實參到形參的拷貝等,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方法進行分配使用。

圖 3

4.2.2 呼叫過程

前文提到,native方法呼叫前,需要將其所在的動態庫載入到記憶體中,這個過程是利用Linux的dlopen實現的,JVM會把動態庫中的程式碼片段放到Native Code區域,同時會在JVM Bytecode區域儲存一份native方法名與其所在Native Code裡的記憶體地址對映。

一次native方法的呼叫步驟,大致分為四步:

  1. 從JVM Bytecode獲取native方法的地址。
  2. 準備方法所需的引數。
  3. 切換到native棧中,執行native方法。
  4. native方法出棧後,切換回JVM方法,JVM將結果拷貝至JVM的棧或堆中。

圖 4

由上述步驟可以看出,native方法的呼叫同樣涉及引數的拷貝,並且其拷貝是建立在JVM堆疊和原生堆疊之間。

對於原生資料型別,引數是通過值拷貝方式與native方法地址一起入棧。而對於複雜資料型別,則需要一套協議,將Java中的object對映到C/C++中能識別的資料位元組。原因是JVM與C語言中的記憶體排布差異較大,不能直接記憶體拷貝,這些差異主要包括:

  • 型別長度不同,比如char在Java裡為16位元組,在C裡面卻是8個位元組。
  • JVM與作業系統的位元組順序(Big Endian還是Little Endian)可能不一致。
  • JVM的物件中,會包含一些meta資訊,而C裡的struct則只是基礎型別的並列排布,同樣Java中沒有指標,也需要進行封裝和對映。

圖 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編碼的格式,拷貝到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機器,我們得到如下結果:

圖 6

圖 7

由實驗資料可知,執行效率依次是 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指令碼等輕量級計算任務裡,這部分的程式碼往往因為一些基礎元件的不完善,導致接入成本較高。

圖 8

將RPC呼叫改造為跨語言本地化呼叫後,上述問題得以解決,收益明顯。

  • 不再呼叫線上服務,流量隔離,對線上安全不產生影響。
  • 對於1000萬條以上的離線任務,累計節省至少10小時以上的網路開銷時間。
  • 消除網路抖動導致的請求失敗問題。
  • 通過上述章節的工作,提供了開箱即用的本地化工具,極大的簡化了使用成本。

圖 9

5.2 線上服務中的應用

查詢理解作為美團內部的基礎服務平臺,提供分詞詞性、查詢糾錯、查詢改寫、地標識別、異地識別、意圖識別、實體識別、實體連結等文字分析,是一個較大的CPU密集型服務,承接了公司內非常多的本文分析業務場景,其中有部分場景只是需要個別訊號,甚至只需要查詢理解服務中的基礎函式元件,對於大部分是通過Java開發的業務服務,無法直接引用查詢理解的C++動態庫,此前一般是通過RPC呼叫獲取結果。通過上述工作,在非C++語言的呼叫方服務中,可以將RPC呼叫轉化為跨語言本地化呼叫,能夠明顯的提升呼叫端的效能以及成功率,同時也能有效減少服務端的資源開銷。

圖 10

6 總結

微服務等技術的發展使得服務建立、釋出和接入變得越來越簡單,但是在實際工業生產中,並非所有場景都適合通過RPC服務完成計算。尤其在計算密集型和耗時敏感型的業務場景下,當效能成為瓶頸時,遠端呼叫帶來的網路開銷就成了業務不可承受之痛。本文對語言本地化呼叫的技術進行了總結,並給出一些實踐經驗,希望能為大家解決類似的問題提供一些幫助。

當然,本次工作中還有許多不足,例如因為實際生產環境的要求,我們的工作基本都集中在Linux系統下,如果是以開放庫形式,讓使用方可以自由使用的話,可能還需要考慮相容Windows下的DLL,Mac OS下的dylib等等。本文可能還存在其他不足之處,歡迎大家指留言指正、探討。

本文例子的原始碼請訪問:GitHub

7 參考文獻

8 本文作者

林陽、朱超、識瀚,均來自美團平臺/搜尋與NLP部/搜尋技術部。

閱讀美團技術團隊更多技術文章合集

前端 | 演算法 | 後端 | 資料 | 安全 | 運維 | iOS | Android | 測試

| 在公眾號選單欄對話方塊回覆【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。

相關文章