Документация Engee

Внедрение кода Julia

Как уже было отмечено в главе Вызов кода C и Fortran, в Julia существует простой и эффективный способ вызова функций, написанных на C. Но возникают ситуации, когда требуется обратное: вызвать функции Julia из кода C. Эта возможность используется для интеграции кода Julia в более крупный проект на C или C++ без необходимости переписывать все на C или C++. Для ее реализации в Julia доступен API для C. Поскольку почти во всех языках программирования можно вызывать функции на C, API Julia для C можно также использовать для формирования взаимодействий и с другими языками (например, вызов Julia из Python, Rust или C#). Хотя в Rust и C++ можно использовать API внедрения кода C напрямую, для обоих языков есть пакеты, упрощающие эту задачу. Для C++ будет полезен пакет Jluna.

Высокоуровневое внедрение

Примечание. В этом разделе приводятся сведения о внедрении кода Julia на C в операционных системах, подобных Unix. Сведения о выполнении этой задачи в ОС Windows см. в разделе Высокоуровневое внедрение в Windows с помощью Visual Studio.

Начнем с простой программы на 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, можно скомпилировать приведенную выше тестовую программу test.c с gcc, используя следующую строку:

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

Или же взглянем на программу embedding.c в дереве исходного кода Julia в папке test/embedding/. Файл программы cli/loader_exe.c представляет собой еще один простой пример задания параметров jl_options при компоновке с libjulia.

Первое, что необходимо сделать перед вызовом любой другой функции C в Julia, — инициализировать Julia. Для этого нужно вызвать метод jl_init, который пытается автоматически определить расположение установки Julia. Если необходимо указать пользовательское расположение или указать, какой образ системы следует загрузить, используйте jl_init_with_image.

Второй оператор в тестовой программе вычисляет оператор Julia с помощью вызова jl_eval_string.

Перед завершением программы настоятельно рекомендуется вызвать jl_atexit_hook. В приведенном выше примере команды вызов выполняется непосредственно перед возвратом из main.

В настоящее время для динамической компоновки с общей библиотекой libjulia требуется передача параметра RTLD_GLOBAL. На Python это выглядит следующим образом.

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

Если программе Julia необходим доступ к символам из основного исполняемого файла, может потребоваться добавить флаг компоновщика -Wl,--export-dynamic во время компиляции в Linux дополнительно к тем, которые были созданы с помощью скрипта julia-config.jl, описанного ниже. Это необязательное действие при компиляции общей библиотеки.

Использование 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 следует заменить clang на gcc.

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

Использование в файлах makefile

В целом внедрение проектов будет более сложным процессом по сравнению с приведенным выше примером, поэтому поддерживается также общий makefile при применении утилиты GNU make в связи с использованием расширений макроса оболочки. Кроме того, хотя файл julia-config.jl обычно находится в каталоге /usr/local, если его там нет, для поиска julia-config.jl можно использовать саму среду Julia, что может применяться в 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.

Высокоуровневое внедрение в Windows с помощью Visual Studio

Если переменная среды JULIA_DIR отсутствует, добавьте ее с помощью панели «Система» перед запуском Visual Studio. Папка bin в JULIA_DIR должна находиться в системной переменной PATH.

Начнем с открытия 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++ | General и добавьте $(JULIA_DIR)\include\julia\ в свойство «Дополнительные каталоги включаемых файлов». Затем перейдите в раздел Linker | General и добавьте $(JULIA_DIR)\lib в свойство «Дополнительные каталоги включаемых файлов». Наконец, в разделе Linker | Input добавьте libjulia.dll.a;libopenlibm.dll.a; в список библиотек.

На этом этапе проект должен быть собран и запущен.

Преобразование типов

Реальные приложения должны будут не только выполнять выражения, но и возвращать их значения основной программе. jl_eval_string возвращает значение jl_value_t*, которое является указателем на выделяемый в куче объект Julia. Хранение таким способом простых типов данных типа Float64 называется boxing, а извлечение сохраненных примитивных данных — unboxing. Улучшенный пример программы, которая вычисляет квадратный корень из двух на Julia и возвращает результат на 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_isa, jl_typeis или jl_is_.... При вводе typeof(sqrt(2.0)) в оболочке Julia можно увидеть, что возвращаемым типом является Float64 (double в C). Для преобразования упакованного значения 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. Для этого потребуется вызывать функции Julia напрямую, используя jl_call:

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);

На первом шаге дескриптор для функции Julia sqrt извлекается путем вызова jl_get_function. Первый аргумент, передаваемый функции jl_get_function, является указателем на модуль Base, в котором определена функция sqrt. Затем десятичное значение двойной точности упаковывается с помощью jl_box_float64. На последнем шаге функция вызывается с помощью jl_call1. Также существуют функции jl_call0, jl_call2 и jl_call3, позволяющие эффективно обрабатывать любое количество аргументов. Для передачи большого количества аргументов следует использовать функцию jl_call:

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

Ее второй аргумент, args, является массивом аргументов jl_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);

Здесь мы сначала определяем вызываемую функцию C в Julia, затем извлекаем из нее указатель функции и, наконец, вызываем ее.

Управление памятью

Как мы уже видели, объекты Julia представлены в языке C в виде указателей типа jl_value_t*. В связи с этим возникает вопрос о том, кто несет ответственность за освобождение этих объектов.

Обычно объекты Julia освобождаются сборщиком мусора, но ему изначально неизвестно о хранящейся ссылке на значение Julia из C. Это означает, что сборщик мусора может освобождать объекты, делая указатели недействительными.

Сборщик мусора будет работать только при размещении новых объектов Julia в памяти. Выделение выполняется с помощью вызовов типа jl_box_float64, но оно может также произойти в любой момент выполнения кода Julia.

При написании кода с внедренными функциями Julia, как правило, безопасно будет использовать значения jl_value_t* между вызовами jl_... (так как сборщик мусора активируется только этими вызовами). Но чтобы значения гарантированно могли сохраняться после вызовов jl_..., необходимо сообщить Julia о наличии ссылки на значения Julia, требующие прав суперпользователя. Этот процесс называется предоставлением сборщику мусора прав суперпользователя. Получение прав суперпользователя для значения гарантирует, что сборщик мусора случайно не определит его как неиспользуемое и не освободит память, в которой оно хранится. Это можно сделать с помощью макроса 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_PUSH2--JL_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* или необходимо отправить более шести переменных, а применение массива аргументов не является подходящим решением, можно использовать внутренние блоки:

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_value_t* перед вызовом JL_GC_PUSH* не является обязательным. Допустимо инициализировать часть переменных со значением 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, который будет хранить ссылки, запрещая сборщику мусора освобождать их. Однако этот метод будет работать правильно только с изменяемыми данными.

// 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);

Если переменная неизменяема, ее необходимо заключить в эквивалентный изменяемый контейнер или, что предпочтительнее, в RefValue{Any} до ее отправки в IdDict. При таком подходе контейнер должен быть создан или заполнен с помощью кода 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);

Чтобы разрешить сборщику мусора освобождать переменную, следует удалить ссылку на нее из refs с помощью функции delete! при условии, что никакая другая ссылка на переменную не хранится в каком-либо другом месте:

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

В качестве альтернативы для очень простых случаев можно просто создать глобальный контейнер типа Vector{Any} и извлекать из него элементы, когда это необходимо. Или даже можно создать одну глобальную переменную для каждого указателя, используя пример ниже.

jl_binding_t *bp = jl_get_binding_wr(jl_main_module, jl_symbol("var"), 1);
jl_checked_assignment(bp, val);

Обновление полей объектов, управляемых сборщиком мусора

При работе сборщика мусора также предполагается, что ему известно о каждом объекте старого поколения, указывающем на объект более нового поколения. При каждом обновлении указателя предположение ликвидируется, на что должно быть указано сборщику с помощью функции 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);

Управление сборщиком мусора

Существует несколько функций для управления сборщиком мусора. При обычном использовании они не нужны.

Функция Описание

jl_gc_collect()

Принудительно запускает сборщик мусора

jl_gc_enable(0)

Отключает сборщик мусора, возвращает предыдущее состояние как начальное

jl_gc_enable(1)

Включает сборщик мусора, возвращает предыдущее состояние как начальное

jl_gc_is_enabled()

Возвращает текущее состояние как начальное

Работа с массивами

Julia и C могут совместно использовать данные массивов без копирования. В следующем примере будет показано, как это работает.

Массивы Julia представлены в C типом данных jl_array_t*. По сути jl_array_t — это структура, которая содержит:

  • информацию о типе данных;

  • указатель на блок данных;

  • информацию о размерах массивов.

Для простоты начнем с одномерного массива. Создать массив, содержащий элементы Float64 длиной 10, можно следующим образом.

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. Если этот аргумент ненулевой, сборщик мусора вызовет 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;

Вызовем функцию Julia, которая выполняет операцию с x на месте:

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

При выводе массива можно убедиться, что теперь элементы x расположены в обратном порядке.

Доступ к возвращаемым массивам

Если функция Julia возвращает массив, возвращаемое значение jl_eval_string и jl_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);

Теперь к содержимому y можно получить доступ, как и раньше, используя jl_array_data. Не забудьте сохранить ссылку на массив во время его использования.

Многомерные массивы

Многомерные массивы Julia хранятся в памяти по столбцам. Ниже приведен код, который создает двумерный массив и получает доступ к его свойствам.

// 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, API для C использует индексирование с отсчетом от 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()));

Если вы используете API Julia для C из языка, поддерживающего исключения (например, Python, C#, C++), имеет смысл заключить каждый вызов 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 в этом примере считается целым числом.

Потокобезопасность

В общем случае интерфейс API C Julia не является полностью потокобезопасным. При внедрении кода Julia в многопоточное приложение будьте осторожны, чтобы не нарушить следующие ограничения.

  • jl_init() может вызываться только один раз за время существования приложения. То же самое относится к функции jl_atexit_hook(), которая может вызываться только после jl_init().

  • Функции API jl_...() могут вызываться только из потока, в котором была вызвана функция jl_init(), или из потоков, запущенных средой выполнения Julia. Вызов функций API Julia из запущенных пользователем потоков не поддерживается и может привести к неопределенному поведению и сбоям.

Из этого второго условия следует, что невозможно безопасно вызывать функции jl_...() из потоков, запущенных не средой Julia (исключением является поток, вызывающий 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);
}

Пример вызова API C Julia из потока, запущенного самой средой Julia:

#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);
}

Если запустить этот код с двумя потоками 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 3bfd9c00, а поток Julia 2 — идентификатору 23938640. Это свидетельствует о том, что на уровне C на самом деле используется несколько потоков и что из них можно безопасно вызывать подпрограммы API C Julia.