嵌入 Julia

正如我們在 呼叫 C 和 Fortran 程式碼 中所看到的,Julia 有種簡單且有效率的方式來呼叫以 C 編寫的函式。但有些情況需要相反的方式:從 C 程式碼呼叫 Julia 函式。這可以用來將 Julia 程式碼整合到更大的 C/C++ 專案中,而不需要將所有內容都改寫成 C/C++。Julia 有個 C API 可以讓這件事成真。由於幾乎所有的程式語言都有呼叫 C 函式的方法,Julia C API 也可以用來建立進階的語言橋接(例如從 Python、Rust 或 C# 呼叫 Julia)。即使 Rust 和 C++ 可以直接使用 C 嵌入式 API,但兩者都有套件可以協助處理,對於 C++,Jluna 很有用。

高階嵌入

注意:本節說明在類 Unix 作業系統中將 Julia 程式碼嵌入 C。要在 Windows 上執行此操作,請參閱本節之後的 使用 Visual Studio 在 Windows 上進行高階嵌入

我們從一個簡單的 C 程式開始,它會初始化 Julia 並呼叫一些 Julia 程式碼

#include <julia.h>
JULIA_DEFINE_FAST_TLS // only define this once, in an executable (not in a shared library) if you want fast code.

int main(int argc, char *argv[])
{
    /* required: setup the Julia context */
    jl_init();

    /* run Julia commands */
    jl_eval_string("print(sqrt(2.0))");

    /* strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    */
    jl_atexit_hook(0);
    return 0;
}

為了建置這個程式,你必須將 Julia 標頭的的路徑新增到包含路徑中,並連結到 libjulia。例如,當 Julia 安裝在 $JULIA_DIR 時,可以使用 gcc 編譯上述測試程式 test.c

gcc -o test -fPIC -I$JULIA_DIR/include/julia -L$JULIA_DIR/lib -Wl,-rpath,$JULIA_DIR/lib test.c -ljulia

或者,查看 Julia 原始碼樹中 test/embedding/ 資料夾中的 embedding.c 程式。檔案 cli/loader_exe.c 程式是另一個簡單的範例,說明如何在連結到 libjulia 的同時設定 jl_options 選項。

在呼叫任何其他 Julia C 函式之前,必須先初始化 Julia。這是透過呼叫 jl_init 來完成的,它會嘗試自動判斷 Julia 的安裝位置。如果你需要指定自訂位置,或指定要載入哪個系統映像,請改用 jl_init_with_image

測試程式中的第二個陳述式使用對 jl_eval_string 的呼叫來評估 Julia 陳述式。

強烈建議在程式終止前呼叫 jl_atexit_hook。上述範例程式在從 main 回傳前呼叫此函式。

注意

目前,與 libjulia 共用函式庫進行動態連結需要傳遞 RTLD_GLOBAL 選項。在 Python 中,這看起來像

>>> julia=CDLL('./libjulia.dylib',RTLD_GLOBAL)
>>> julia.jl_init.argtypes = []
>>> julia.jl_init()
250593296
注意

如果 julia 程式需要存取主執行檔中的符號,除了下面由 julia-config.jl 產生的旗標之外,可能需要在 Linux 上的編譯時間加入 -Wl,--export-dynamic 連結器旗標。在編譯共用函式庫時不需要這麼做。

使用 julia-config 自動決定建置參數

建立指令碼 julia-config.jl 是為了協助決定使用嵌入式 Julia 的程式需要哪些建置參數。此指令碼使用呼叫它的特定 Julia 發行版的建置參數和系統組態,來匯出嵌入式程式與該發行版互動所需的必要編譯器旗標。此指令碼位於 Julia 共用資料目錄中。

範例

#include <julia.h>

int main(int argc, char *argv[])
{
    jl_init();
    (void)jl_eval_string("println(sqrt(2.0))");
    jl_atexit_hook(0);
    return 0;
}

在命令列上

此腳本的簡單用法是從命令列。假設 julia-config.jl 位於 /usr/local/julia/share/julia,它可以直接在命令列上呼叫,並採用三種旗標的任意組合

/usr/local/julia/share/julia/julia-config.jl
Usage: julia-config [--cflags|--ldflags|--ldlibs]

如果上述範例程式碼儲存在檔案 embed_example.c 中,則以下命令會將其編譯成 Linux 和 Windows (MSYS2 環境) 上的可執行程式。在 macOS 上,將 gcc 替換為 clang

/usr/local/julia/share/julia/julia-config.jl --cflags --ldflags --ldlibs | xargs gcc embed_example.c

使用於 Makefiles

一般來說,嵌入式專案會比上述範例複雜,因此以下也允許一般的 makefile 支援 – 由於使用 shell 巨集擴充,因此假設為 GNU make。此外,雖然 julia-config.jl 通常位於 /usr/local 目錄中,但如果沒有,則 Julia 本身可用於尋找 julia-config.jl,而 makefile 可以利用此功能。上述範例已延伸為使用 makefile

JL_SHARE = $(shell julia -e 'print(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia"))')
CFLAGS   += $(shell $(JL_SHARE)/julia-config.jl --cflags)
CXXFLAGS += $(shell $(JL_SHARE)/julia-config.jl --cflags)
LDFLAGS  += $(shell $(JL_SHARE)/julia-config.jl --ldflags)
LDLIBS   += $(shell $(JL_SHARE)/julia-config.jl --ldlibs)

all: embed_example

現在建置命令僅為 make

使用 Visual Studio 在 Windows 上進行高級嵌入

如果尚未設定 JULIA_DIR 環境變數,請在啟動 Visual Studio 之前使用「系統」面板新增它。JULIA_DIR 下的 bin 資料夾應位於系統路徑中。

我們從開啟 Visual Studio 和建立新的「主控台應用程式」專案開始。開啟「stdafx.h」標頭檔,並在結尾新增以下列

#include <julia.h>

然後,將專案中的 main() 函式替換為此程式碼

int main(int argc, char *argv[])
{
    /* required: setup the Julia context */
    jl_init();

    /* run Julia commands */
    jl_eval_string("print(sqrt(2.0))");

    /* strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    */
    jl_atexit_hook(0);
    return 0;
}

下一步是設定專案以尋找 Julia 包含檔和函式庫。了解 Julia 安裝是 32 位元或 64 位元非常重要。在繼續之前,移除任何與 Julia 安裝不對應的平台組態。

使用專案的「屬性」對話方塊,前往 C/C++ | 一般,並將 $(JULIA_DIR)\include\julia\ 新增至「其他包含目錄」屬性。然後,前往 連結器 | 一般 區段,並將 $(JULIA_DIR)\lib 新增至「其他函式庫目錄」屬性。最後,在 連結器 | 輸入 下,將 libjulia.dll.a;libopenlibm.dll.a; 新增至函式庫清單。

此時,專案應可建置並執行。

轉換型別

實際應用程式不僅需要執行表達式,還需要將其值傳回給主機程式。jl_eval_string 會傳回 jl_value_t*,這是指向堆疊配置 Julia 物件的指標。以這種方式儲存 Float64 等簡單資料型別稱為「封裝」,而擷取儲存的原始資料稱為「解封」。我們改良的範例程式會在 Julia 中計算 2 的平方根,並在 C 中讀回結果,其主體現在包含以下程式碼

jl_value_t *ret = jl_eval_string("sqrt(2.0)");

if (jl_typeis(ret, jl_float64_type)) {
    double ret_unboxed = jl_unbox_float64(ret);
    printf("sqrt(2.0) in C: %e \n", ret_unboxed);
}
else {
    printf("ERROR: unexpected return type from sqrt(::Float64)\n");
}

為了檢查 ret 是否為特定 Julia 型別,我們可以使用 jl_isajl_typeisjl_is_... 函式。在 Julia shell 中輸入 typeof(sqrt(2.0)),我們可以看到傳回型別為 Float64(在 C 中為 double)。若要將封裝的 Julia 值轉換為 C double,請在上述程式碼片段中使用 jl_unbox_float64 函式。

對應的 jl_box_... 函式用於進行反向轉換

jl_value_t *a = jl_box_float64(3.0);
jl_value_t *b = jl_box_float32(3.0f);
jl_value_t *c = jl_box_int32(3);

正如我們接下來將看到的,封裝是呼叫具有特定引數的 Julia 函式所必需的。

呼叫 Julia 函式

雖然 jl_eval_string 允許 C 取得 Julia 表達式的結果,但它不允許將在 C 中計算出的參數傳遞給 Julia。為此,您需要使用 jl_call 直接呼叫 Julia 函數

jl_function_t *func = jl_get_function(jl_base_module, "sqrt");
jl_value_t *argument = jl_box_float64(2.0);
jl_value_t *ret = jl_call1(func, argument);

在第一步中,透過呼叫 jl_get_function 來擷取 Julia 函數 sqrt 的句柄。傳遞給 jl_get_function 的第一個參數是指向定義 sqrtBase 模組的指標。然後,使用 jl_box_float64 將雙值打包。最後,在最後一步中,使用 jl_call1 呼叫函數。jl_call0jl_call2jl_call3 函數也存在,用於方便地處理不同數量的參數。若要傳遞更多參數,請使用 jl_call

jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs)

它的第二個參數 argsjl_value_t* 參數的陣列,而 nargs 是參數的數量。

還有一種替代且可能更簡單的呼叫 Julia 函數方法,那就是透過 @cfunction。使用 @cfunction 允許您在 Julia 端執行類型轉換,這通常比在 C 端執行更容易。上述 sqrt 範例使用 @cfunction 會寫成

double (*sqrt_jl)(double) = jl_unbox_voidpointer(jl_eval_string("@cfunction(sqrt, Float64, (Float64,))"));
double ret = sqrt_jl(2.0);

在這裡,我們首先在 Julia 中定義一個 C 可呼叫函數,從中擷取函數指標,最後呼叫它。

記憶體管理

如我們所見,Julia 物件在 C 中表示為類型為 jl_value_t* 的指標。這引發了一個問題,即誰負責釋放這些物件。

通常,Julia 物件是由垃圾收集器 (GC) 釋放,但 GC 不會自動知道我們正在從 C 持有對 Julia 值的參照。這表示 GC 可以從您底下釋放物件,導致指標無效。

GC 僅會在配置新的 Julia 物件時執行。像 jl_box_float64 的呼叫會執行配置,但配置也可能發生在執行 Julia 程式碼的任何時間點。

在編寫嵌入 Julia 的程式碼時,通常可以在 jl_... 呼叫之間使用 jl_value_t* 值(因為 GC 僅會由這些呼叫觸發)。但為了確保值可以在 jl_... 呼叫中存活,我們必須告訴 Julia 我們仍持有 Julia 值的參考,這個程序稱為「GC 根化」。根化值將確保垃圾收集器不會意外地將此值識別為未使用,並釋放支援該值的記憶體。這可以使用 JL_GC_PUSH 巨集來完成

jl_value_t *ret = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret);
// Do something with ret
JL_GC_POP();

JL_GC_POP 呼叫會釋放由先前的 JL_GC_PUSH 建立的參考。請注意,JL_GC_PUSH 會將參考儲存在 C 堆疊中,因此在離開範圍之前,它必須與 JL_GC_POP 精確配對。也就是說,在函式傳回之前,或控制流程離開呼叫 JL_GC_PUSH 的區塊。

可以使用 JL_GC_PUSH2JL_GC_PUSH6 巨集一次推入多個 Julia 值

JL_GC_PUSH2(&ret1, &ret2);
// ...
JL_GC_PUSH6(&ret1, &ret2, &ret3, &ret4, &ret5, &ret6);

若要推入 Julia 值陣列,可以使用 JL_GC_PUSHARGS 巨集,其使用方法如下

jl_value_t **args;
JL_GC_PUSHARGS(args, 2); // args can now hold 2 `jl_value_t*` objects
args[0] = some_value;
args[1] = some_other_value;
// Do something with args (e.g. call jl_... functions)
JL_GC_POP();

每個範圍只能呼叫 JL_GC_PUSH* 一次,且應僅與單一 JL_GC_POP 呼叫配對。如果您要根化的所有必要變數無法透過單一呼叫 JL_GC_PUSH* 推入,或者有超過 6 個變數要推入,且使用引數陣列不是一種選擇,那麼可以使用內部區塊

jl_value_t *ret1 = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret1);
jl_value_t *ret2 = 0;
{
    jl_function_t *func = jl_get_function(jl_base_module, "exp");
    ret2 = jl_call1(func, ret1);
    JL_GC_PUSH1(&ret2);
    // Do something with ret2.
    JL_GC_POP();    // This pops ret2.
}
JL_GC_POP();    // This pops ret1.

請注意,在呼叫 JL_GC_PUSH* 之前,不必具有有效的 jl_value_t* 值。將它們初始化為 NULL,傳遞給 JL_GC_PUSH*,然後建立實際的 Julia 值是可以的。例如

jl_value_t *ret1 = NULL, *ret2 = NULL;
JL_GC_PUSH2(&ret1, &ret2);
ret1 = jl_eval_string("sqrt(2.0)");
ret2 = jl_eval_string("sqrt(3.0)");
// Use ret1 and ret2
JL_GC_POP();

如果需要在函數(或區塊範圍)之間保留變數指標,則無法使用 JL_GC_PUSH*。在這種情況下,有必要建立並保留對 Julia 全域範圍內變數的參照。達成此目的的一種簡單方法是使用全域 IdDict,它將保留參照,避免 GC 釋放配置。但是,此方法僅適用於可變類型。

// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_function_t* setindex = jl_get_function(jl_base_module, "setindex!");

...

// `var` is the variable we want to protect between function calls.
jl_value_t* var = 0;

...

// `var` is a `Vector{Float64}`, which is mutable.
var = jl_eval_string("[sqrt(2.0); sqrt(4.0); sqrt(6.0)]");

// To protect `var`, add its reference to `refs`.
jl_call3(setindex, refs, var, var);

如果變數不可變,則需要將其包裝在等效的可變容器中,或最好在將其推送到 IdDict 之前包裝在 RefValue{Any} 中。在此方法中,必須使用 C 程式碼建立或填入容器,例如使用函數 jl_new_struct。如果容器是由 jl_call* 建立的,則需要重新載入要在 C 程式碼中使用的指標。

// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_function_t* setindex = jl_get_function(jl_base_module, "setindex!");
jl_datatype_t* reft = (jl_datatype_t*)jl_eval_string("Base.RefValue{Any}");

...

// `var` is the variable we want to protect between function calls.
jl_value_t* var = 0;

...

// `var` is a `Float64`, which is immutable.
var = jl_eval_string("sqrt(2.0)");

// Protect `var` until we add its reference to `refs`.
JL_GC_PUSH1(&var);

// Wrap `var` in `RefValue{Any}` and push to `refs` to protect it.
jl_value_t* rvar = jl_new_struct(reft, var);
JL_GC_POP();

jl_call3(setindex, refs, rvar, rvar);

GC 可以透過使用函數 delete!refs 中移除對變數的參照來釋放變數的配置,前提是沒有其他任何地方保留對該變數的參照

jl_function_t* delete = jl_get_function(jl_base_module, "delete!");
jl_call2(delete, refs, rvar);

對於非常簡單的情況,可以另建一個 Vector{Any} 類型的全域容器,並在必要時從中擷取元素,甚至可以使用下列方式為每個指標建立一個全域變數

jl_module_t *mod = jl_main_module;
jl_sym_t *var = jl_symbol("var");
jl_binding_t *bp = jl_get_binding_wr(mod, var);
jl_checked_assignment(bp, mod, var, val);

更新 GC 管理物件的欄位

垃圾收集器也假設它知道每個指向較新世代物件的較舊世代物件。任何時候,如果指標更新會破壞此假設,都必須使用 jl_gc_wb(寫入屏障)函數通知收集器,如下所示

jl_value_t *parent = some_old_value, *child = some_young_value;
((some_specific_type*)parent)->field = child;
jl_gc_wb(parent, child);

通常無法預測哪些值在執行階段會變舊,因此必須在所有明確儲存後插入寫入屏障。一個值得注意的例外是,如果 parent 物件才剛配置,而且自那之後沒有執行垃圾收集。請注意,大多數 jl_... 函數有時會呼叫垃圾收集。

當直接更新指標陣列的資料時,也需要寫入屏障。例如

jl_array_t *some_array = ...; // e.g. a Vector{Any}
void **data = (void**)jl_array_data(some_array);
jl_value_t *some_value = ...;
data[0] = some_value;
jl_gc_wb(some_array, some_value);

控制垃圾收集器

有一些函數可以控制 GC。在一般使用情況下,這些函數並非必要。

函數說明
jl_gc_collect()強制執行 GC
jl_gc_enable(0)停用 GC,傳回先前的狀態為 int
jl_gc_enable(1)啟用 GC,傳回先前的狀態為 int
jl_gc_is_enabled()傳回目前的狀態為 int

使用陣列

Julia 和 C 可以共用陣列資料而不需複製。以下範例將說明其運作方式。

Julia 陣列在 C 中由資料類型 jl_array_t* 表示。基本上,jl_array_t 是一個包含以下內容的結構:

  • 資料類型的資訊
  • 指向資料區塊的指標
  • 陣列大小的資訊

為了簡化起見,我們從 1D 陣列開始。建立一個包含長度為 10 的 Float64 元素的陣列,可以這樣做

jl_value_t* array_type = jl_apply_array_type((jl_value_t*)jl_float64_type, 1);
jl_array_t* x          = jl_alloc_array_1d(array_type, 10);

或者,如果您已經配置陣列,您可以產生一個薄封裝器來包覆其資料

double *existingArray = (double*)malloc(sizeof(double)*10);
jl_array_t *x = jl_ptr_to_array_1d(array_type, existingArray, 10, 0);

最後一個引數是一個布林值,表示 Julia 是否應取得資料的所有權。如果這個引數非零,當陣列不再被參照時,GC 會對資料指標呼叫 free

為了存取 x 的資料,我們可以使用 jl_array_data

double *xData = (double*)jl_array_data(x);

現在我們可以填入陣列

for(size_t i=0; i<jl_array_len(x); i++)
    xData[i] = i;

現在讓我們呼叫一個對 x 執行就地運算的 Julia 函式

jl_function_t *func = jl_get_function(jl_base_module, "reverse!");
jl_call1(func, (jl_value_t*)x);

透過列印陣列,可以驗證 x 的元素現在已反轉。

存取傳回的陣列

如果一個 Julia 函式傳回一個陣列,jl_eval_stringjl_call 的傳回值可以轉型為 jl_array_t*

jl_function_t *func  = jl_get_function(jl_base_module, "reverse");
jl_array_t *y = (jl_array_t*)jl_call1(func, (jl_value_t*)x);

現在可以使用 jl_array_data 存取 y 的內容,就像之前一樣。務必在使用陣列時保留對它的參照。

多維陣列

Julia 的多維陣列儲存在記憶體中的欄位優先順序。以下是建立一個 2D 陣列並存取其屬性的程式碼

// Create 2D array of float64 type
jl_value_t *array_type = jl_apply_array_type((jl_value_t*)jl_float64_type, 2);
jl_array_t *x  = jl_alloc_array_2d(array_type, 10, 5);

// Get array pointer
double *p = (double*)jl_array_data(x);
// Get number of dimensions
int ndims = jl_array_ndims(x);
// Get the size of the i-th dim
size_t size0 = jl_array_dim(x,0);
size_t size1 = jl_array_dim(x,1);

// Fill array with data
for(size_t i=0; i<size1; i++)
    for(size_t j=0; j<size0; j++)
        p[j + size0*i] = i + j;

請注意,雖然 Julia 陣列使用從 1 開始的索引,但 C API 使用從 0 開始的索引(例如在呼叫 jl_array_dim 時),以便作為慣用的 C 程式碼來讀取。

例外

Julia 程式碼可能會擲出例外。例如,考慮

jl_eval_string("this_function_does_not_exist()");

這個呼叫看起來什麼都不做。然而,可以檢查是否擲出例外

if (jl_exception_occurred())
    printf("%s \n", jl_typeof_str(jl_exception_occurred()));

如果您使用支援例外狀況的語言(例如 Python、C#、C++)從 Julia C API 呼叫,則將每個呼叫包裝到 libjulia 中,並使用檢查是否擲回例外狀況的函式,然後在主機語言中重新擲回例外狀況,是有意義的。

擲回 Julia 例外狀況

撰寫 Julia 可呼叫函式時,可能需要驗證引數並擲回例外狀況以指出錯誤。典型的類型檢查看起來像

if (!jl_typeis(val, jl_float64_type)) {
    jl_type_error(function_name, (jl_value_t*)jl_float64_type, val);
}

可以使用函式擲回一般例外狀況

void jl_error(const char *str);
void jl_errorf(const char *fmt, ...);

jl_error 會取得 C 字串,而 jl_errorf 的呼叫方式類似於 printf

jl_errorf("argument x = %d is too large", x);

在此範例中,假設 x 為整數。

執行緒安全性

一般而言,Julia C API 並非完全執行緒安全。在多執行緒應用程式中嵌入 Julia 時,需要小心不要違反下列限制

  • jl_init() 在應用程式生命週期中只能呼叫一次。jl_atexit_hook() 也適用相同規則,且只能在 jl_init() 之後呼叫。
  • jl_...() API 函式只能從呼叫 jl_init() 的執行緒呼叫,或從 Julia 執行階段啟動的執行緒呼叫。不支援從使用者啟動的執行緒呼叫 Julia API 函式,且可能會導致未定義的行為和當機。

上述第二個條件表示您無法安全地從未由 Julia 啟動的執行緒呼叫 jl_...() 函式(呼叫 jl_init() 的執行緒為例外)。例如,下列內容不受支援,而且很可能會造成區段錯誤

void *func(void*)
{
    // Wrong, jl_eval_string() called from thread that was not started by Julia
    jl_eval_string("println(Threads.threadid())");
    return NULL;
}

int main()
{
    pthread_t t;

    jl_init();

    // Start a new thread
    pthread_create(&t, NULL, func, NULL);
    pthread_join(t, NULL);

    jl_atexit_hook(0);
}

相反地,從同一個使用者建立的執行緒執行所有 Julia 呼叫會有效

void *func(void*)
{
    // Okay, all jl_...() calls from the same thread,
    // even though it is not the main application thread
    jl_init();
    jl_eval_string("println(Threads.threadid())");
    jl_atexit_hook(0);
    return NULL;
}

int main()
{
    pthread_t t;
    // Create a new thread, which runs func()
    pthread_create(&t, NULL, func, NULL);
    pthread_join(t, NULL);
}

從 Julia 本身啟動的執行緒呼叫 Julia C API 的範例

#include <julia/julia.h>
JULIA_DEFINE_FAST_TLS

double c_func(int i)
{
    printf("[C %08x] i = %d\n", pthread_self(), i);

    // Call the Julia sqrt() function to compute the square root of i, and return it
    jl_function_t *sqrt = jl_get_function(jl_base_module, "sqrt");
    jl_value_t* arg = jl_box_int32(i);
    double ret = jl_unbox_float64(jl_call1(sqrt, arg));

    return ret;
}

int main()
{
    jl_init();

    // Define a Julia function func() that calls our c_func() defined in C above
    jl_eval_string("func(i) = ccall(:c_func, Float64, (Int32,), i)");

    // Call func() multiple times, using multiple threads to do so
    jl_eval_string("println(Threads.threadpoolsize())");
    jl_eval_string("use(i) = println(\"[J $(Threads.threadid())] i = $(i) -> $(func(i))\")");
    jl_eval_string("Threads.@threads for i in 1:5 use(i) end");

    jl_atexit_hook(0);
}

如果我們使用 2 個 Julia 執行緒執行此程式碼,我們會取得下列輸出(注意:輸出會因執行和系統而異)

$ JULIA_NUM_THREADS=2 ./thread_example
2
[C 3bfd9c00] i = 1
[C 23938640] i = 4
[J 1] i = 1 -> 1.0
[C 3bfd9c00] i = 2
[J 1] i = 2 -> 1.4142135623730951
[C 3bfd9c00] i = 3
[J 2] i = 4 -> 2.0
[C 23938640] i = 5
[J 1] i = 3 -> 1.7320508075688772
[J 2] i = 5 -> 2.23606797749979

如您所見,Julia 執行緒 1 對應於 pthread ID 3bfd9c00,而 Julia 執行緒 2 對應於 ID 23938640,顯示在 C 層級確實使用了多個執行緒,而且我們可以安全地從這些執行緒呼叫 Julia C API 常式。