Вызов кода на Rust из других языков
Для нашего третьего проекта мы собираемся выбрать что-то, что подчеркнёт одну из самых сильных сторон в Rust: фактическое отсутствие среды исполнения.
По мере роста организации, программисты все больше полагаются на множество языков программирования. У каждого языка программирования есть свои сильные и слабые стороны, а знание нескольких языков позволяет использовать определенный язык там, где проявляется его сильные стороны, и использовать другой язык там, где первый не очень хорош.
Существует несколько областей, где многие языки программирования слабы в плане производительности выполнения программ. Часто компромисс заключается в том, чтобы использовать более медленный язык, который взамен способствует повышению производительности программиста. Чтобы решить эту проблему, часть кода системы можно написать на C, а затем вызвать этот код, написанный на C, как если бы он был написан на языке высокого уровня. Это называется «интерфейс внешних функций» (foreign function interface), часто сокращается до FFI.
Rust включает поддержку FFI в обоих направлениях: он легко может вызвать C код, и он так же легко, как и C код, может быть вызван извне. Rust сочетает в себе отсутствие сборщика мусора и низкие требования к среде исполнения, что делает Rust отличным кандидатом на роль вызываемого из других языков, когда нужны некоторые дополнительные возможности.
В этой книге есть целая глава, посвящённая FFI и его специфике, а в этой главе мы рассмотрим именно конкретный частный случай FFI, с тремя примерами, на Ruby, Python и JavaScript.
Проблема
Есть много различных проектов, которые мы могли бы выбрать, но мы хотим подобрать такой пример, который продемонстрирует явное преимущество Rust над многими другими языками: сложные вычисления и многопоточность.
Во многих языках числа размещаются в куче, а не в стеке. Это обеспечивает целостность поведения языка при работе с числами и с другими объектами. Особенно в языках, которые сосредотачиваются на объектно-ориентированном программировании и использовании сборщика мусора, по умолчанию память выделяется из кучи. Иногда, при оптимизации, для конкретных чисел память может выделяться в стеке, но вместо того, чтобы полагаться на работу оптимизации, мы можем захотеть убедиться в том, что мы используем примитивные типы чисел, а не какой-либо тип объекта.
Во-вторых, многие языки имеют «глобальную блокировку интерпретатора» (global interpreter lock), которая ограничивает параллелизм во многих ситуациях. Это делается во имя безопасности, что оказывает положительный эффект, но это также и ограничивает объем работ, который может быть выполнен одновременно, что, в свою очередь, оказывает большой отрицательный эффект.
Чтобы подчеркнуть эти два аспекта, мы собираемся создать небольшой проект, который в значительной степени их использует. Поскольку внимание в этом примере сфокусировано на встраивание Rust в другие языки, а не самой проблеме, мы будем использовать игрушечный пример:
Запустить десять потоков. Внутри каждого потока считать от одного до пяти миллионов. После того как все десять потоков завершатся, напечатать "сделано!".
Мы выбрали пять миллионов руководствуясь тем, сколько времени занимает эта работа на современном компьютере. Вот пример этого кода на Ruby:
threads = []
10.times do
threads << Thread.new do
count = 0
5_000_000.times do
count += 1
end
end
end
threads.each { |t| t.join }
puts "сделано!"
Попробуйте запустить этот пример, и подберите число, которое обеспечит работу в течение нескольких секунд. В зависимости от аппаратного обеспечения компьютера, возможно, придется увеличить или уменьшить это число.
На выбранной нами системе эта программа работает 2.156
секунд. И если мы
воспользуемся какой-нибудь утилитой для мониторинга процессов (например, top
),
то увидим, что она использует только одно ядро. Это GIL делает свое дело.
Хотя это и игрушечная программа, на ее примере можно продемонстрировать много проблем, аналогичных этой, характерных для реального мира. Для наших целей, долго крутящиеся занятые потоки представляют собой параллельные, требующие больших затрат, вычисления.
Библиотека на Rust
Давайте перепишем эту задачу на Rust. Во-первых, давайте сделаем новый проект с помощью Cargo:
$ cargo new embed
$ cd embed
Эту программу легко переписать на Rust:
use std::thread;
fn process() {
let handles: Vec<_> = (0..10).map(|_| {
thread::spawn(|| {
let mut x = 0;
for _ in (0..5_000_000) {
x += 1
}
x
})
}).collect();
for h in handles {
println!("Thread finished with count={}",
h.join().map_err(|_| "Could not join a thread!").unwrap());
}
println!("done!");
Мы уже знакомы с частью этого кода из предыдущих примеров. Мы создаем десять
потоков, собирая их в вектор handles
. Внутри каждого потока мы осуществляем
пять миллионов повторений в цикле, и прибавляем к x
единицу каждый раз.
Наконец, мы воссоединяем все потоки.
Сейчас, однако, это просто библиотека Rust, которая не включает все необходимое для успешного вызова из другого языка. Если мы попытаемся подключить её к другому языку в том виде, в котором она сейчас, то это не будет работать. Нам нужно сделать два небольших изменения, чтобы исправить это. Первое, что мы должны сделать, это изменить начало нашего кода:
#[no_mangle]
pub extern fn process() {
Мы добавили новый атрибут, no_mangle
. В процессе создания библиотеки Rust, в
выходном скомпилированном файле происходит изменение имени функции. Причины
этого выходят за рамки данного руководства, но для того, чтобы и другие языки
знали, как вызвать функцию, мы должны не делать этого. Указанный атрибут
выключает такое поведение.
Другим изменением, которое мы добавили, является pub extern
. pub
означает,
что эта функция может быть вызвана за пределами этого модуля, а extern
говорит, что её возможно вызвать из С. Вот и все! Не так и много изменений.
Второе, что мы должны сделать, это изменить настройки в Cargo.toml
. Добавьте
это в конец файла:
[lib]
name = "embed"
crate-type = ["dylib"]
Это говорит Rust, что мы хотим скомпилировать нашу библиотеку в виде стандартной динамической библиотеки. По умолчанию, Rust компилирует в rlib, Rust- специфичный формат.
Давайте теперь соберем проект:
$ cargo build --release
Compiling embed v0.1.0 (file:///home/steve/src/embed)
Мы ввели команду cargo build --release
, которая выполняет сборку с включенной
оптимизацией. Мы хотим, чтобы код был как можно более быстрым! Вы можете найти
собранную библиотеку в target/release
:
$ ls target/release/
build deps examples libembed.so native
Файл libembed.so
— и есть наша динамическая библиотека (shared object). Мы
можем использовать этот файл также как и любую другую динамическую библиотеку,
написанную на C! Попутно следует отметить, это может быть embed.dll
или
libembed.dylib
, в зависимости от платформы.
Теперь, когда мы получили нашу собранную библиотеку Rust, давайте используем её из нашего кода на Ruby.
Ruby
Откройте файл embed.rb
внутри нашего проекта, и сделайте следующее:
require 'ffi'
module Hello
extend FFI::Library
ffi_lib 'target/release/libembed.so'
attach_function :process, [], :void
end
Hello.process
puts 'сделано!'
Прежде чем мы сможем запустить этот код, нам нужно установить пакет ffi
:
$ gem install ffi # this may need sudo
Fetching: ffi-1.9.8.gem (100%)
Building native extensions. This could take a while...
Successfully installed ffi-1.9.8
Parsing documentation for ffi-1.9.8
Installing ri documentation for ffi-1.9.8
Done installing documentation for ffi after 0 seconds
1 gem installed
И, наконец, мы можем попробовать запустить его:
$ ruby embed.rb
сделано!
$
Ничего себе, это было быстро! На моей системе это заняло 0.086
секунд, а не
две секунды как это было на чистом Ruby. Давайте разберем этот Ruby код:
require 'ffi'
Первый делом, нам надо объявить пакет ffi
. Он предоставляет нам интерфейс для
использования нашей библиотеки на Rust, как библиотеку на C.
module Hello
extend FFI::Library
ffi_lib 'target/release/libembed.so'
Автор пакета ffi
рекомендует использовать модуль, чтобы ограничить область
действия функции, которую мы импортировали из разделяемой библиотеки. Внутри мы
указали extend
, чтобы воспользоваться необходимым модулем FFI::Library
, а
затем вызвали ffi_lib
, чтобы подгрузить нашу библиотеку. Мы просто передаем
путь к библиотеке, который мы уже видели раньше, это
target/release/libembed.so
.
attach_function :process, [], :void
Метод attach_function
предоставляется пакетом FFI
. Здесь соединяются наша
функция process()
, написанная на Rust, и одноименная функция на Ruby. Так как
process()
не принимает аргументов, второй параметр является пустым массивом, и
поскольку функция ничего не возвращает, мы передаем :void
в качестве
завершающего аргумента.
Hello.process
Здесь мы совершаем вызов нашей Rust функции. Сочетание нашего module
и вызова
к attach_function
завершает подготовку. Это выглядит как функция Ruby, но на
самом деле это Rust!
puts 'сделано!'
Наконец, в соответствие с нашими требованиями к проекту, мы пишем сделано!
по
окончанию работы программы.
Вот и все! Как мы увидели, совместить два языка очень просто, и взамен мы получили большую производительность.
Теперь давайте попробуем на Python!
Python
Создайте файл embed.py
в этой директории и поместите в него следующее:
from ctypes import cdll
lib = cdll.LoadLibrary("target/release/libembed.so")
lib.process()
print("сделано!")
Довольно просто! Мы импортируем cdll
из модуля ctypes
. Затем вызваем
LoadLibrary
. И теперь мы можем вызвать process()
.
На моей системе это заняло 0.017
секунд. Быстро!
Node.js
Node — это не язык, но, в настоящее время, это доминирующая реализация исполнения JavaScript на сервере.
Для того, чтобы сделать FFI в Node, нам сначала надо установить библиотеку:
$ npm install ffi
После установки, мы можем ей воспользоваться:
var ffi = require('ffi');
var lib = ffi.Library('target/release/libembed', {
'process': ['void', []]
});
lib.process();
console.log("сделано!");
Пример больше похож на Ruby, чем на Python. Мы используем модуль ffi
, чтобы
получить доступ к ffi.Library()
, который загружает нашу библиотеку. Нам нужно
указать тип возвращаемого значения и типы аргументов функции: void
для
возвращаемого значения и пустой массив для указания отсутствия аргументов. После
этого мы просто вызываем функцию и печатаем результат.
На моей системе это заняло 0.092
секунды.
Заключение
Как вы можете видеть, основы, рассмотренные здесь, являются очень простыми. Конечно, мы могли бы сделать куда больше того, что мы здесь показали. Посмотрите главу FFI для более подробной информации.