はじめに
こんにちは。
今日はこれまで暖めていて、やれてなかったネタをやります。
RubyのC拡張をCrystal言語で記述して、Rubyから呼び出したいと思ったことはないでしょうか?
Crystalを使い始めた頃、これを何度も想像しました。でも不思議なことに、Crystal言語で作られたRubyのGemはまず見かけませんよね。
それには理由があります。実は、Crystal公式は、Crystalを作って作成したライブラリを他の言語から呼び出すことを推奨してないのです。
いったいどうしてでしょうか。
また、本当にCrystalは他の言語から呼べないのでしょうか?
公式は他言語からのCrystalの利用を推奨していない
Crystal Forumのスレッド Embeddable / Interoperable with ruby の asterite さんと beta-ziliani さんのやりとりから推測されるCrystal公式の見解は次のようになります。
- Crystal言語で作成した共有ライブラリを他の言語から呼び出すことは推奨されない
- けれども、プリミティブな型を用いるAPIを作成を利用する共有ライブラリを「同時にひとつだけ」呼び出すことは可能
詳細を見る前に、Crystalで共有ライブラリを生成する方法を確認しておきましょう。
Crystalによる共有ライブラリの作り方
Crystalを用いた共有ライブラリの作成方法に関しては、@theanineさんがQiitaの記事にまとめています。
記事からコードを引用します。
fun crystal_init : Void
# GC初期化が必要
GC.init
# Crystalの「main」関数を呼び出す必要があります。
# トップレベルのコードを実行する関数です
# 引数はargcとargv.今回はないので0とnullを渡す
LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null)
end
# cから呼び出す関数の定義
fun sum(a : Int32, b : Int32): Int32
puts "Calculate sum"
a + b
end
次のようにしてビルドします
crystal build sum.cr --single-module --link-flags="-shared" -o libsum.so
libsum.so
が生成されます。
この共有ライブラリをRubyから呼び出せるか確かめてみましょう。ここでは標準ライブラリの fiddle を使います。注意点は、先に crystal_init()
を呼び出すことです。これをしないとSegmentation falt が発生します。
require 'fiddle/import'
module M
extend Fiddle::Importer
dlload './libsum.so'
extern 'void crystal_init()'
extern 'int sum(int a, int b)'
end
M.crystal_init # 先に呼び出す
puts M.sum(ARGV[0].to_i, ARGV[1].to_i)
このスクリプトを実行すると
ruby m.rb 2 3
次のように表示されます。うまくいきました。
Calculate sum
5
Crystalで共有ライブラリを作成し、それを呼び出すことができました。
このように、一見するとCrystalを他の言語から呼び出すことは問題ないように見えます。
では、なぜ公式は非推奨にしているのでしょうか?
Crystal Forum のスレッドにおける議論
@asterite (Ary Borenszweig) さんは、Crystalの中心メンバーの一人です。(そして驚いたことにQiitaのアカウントを持っています!)彼がスレッドでCrystalで共有ライブラリを作って呼び出すべきではない理由を説明しています。
asterite さんのコメントをChatGPTで翻訳しました。
このコードを使ってみましょう:
def foo(x) if x.is_a?(Int32) 1 else 2 end end foo(1 || 'a') foo('a' || 1)
それを次のようにコンパイルします:
crystal build --prelude=empty --emit llvm-ir foo.cr
これにより、そのファイルのLLVM IRが得られます。特に生成されたfoo関数についてです。
生成されたコードは、xがInt32であるかどうかをどのようにチェックするのでしょうか。答えは、Crystalがそれぞれの型に一意のID(整数)を割り当てるという事実にあります。
これが生成されたfoo関数のLLVM IRコードです:
define internal i32 @"*foo<(Char | Int32)>:Int32"(%"(Char | Int32)" %x) #0 !dbg !21 { alloca: %x1 = alloca %"(Char | Int32)", align 8, !dbg !22 br label %entry entry: ; preds = %alloca store %"(Char | Int32)" %x, %"(Char | Int32)"* %x1, align 8, !dbg !22 %0 = getelementptr inbounds %"(Char | Int32)", %"(Char | Int32)"* %x1, i32 0, i32 0, !dbg !23 %1 = load i32, i32* %0, align 4, !dbg !23 %2 = getelementptr inbounds %"(Char | Int32)", %"(Char | Int32)"* %x1, i32 0, i32 1, !dbg !23 %3 = icmp eq i32 11, %1, !dbg !23 br i1 %3, label %then, label %else, !dbg !23 then: ; preds = %entry br label %exit, !dbg !23 else: ; preds = %entry br label %exit, !dbg !23 exit: ; preds = %else, %then %4 = phi i32 [ 1, %then ], [ 2, %else ], !dbg !23 ret i32 %4, !dbg !23 }
難解ですが、次の行があります:
%3 = icmp eq i32 11, %1, !dbg !23 br i1 %3, label %then, label %else, !dbg !23
もし"何か"が11なら"then"ラベルにジャンプし、そうでなければ"else"ラベルにジャンプします。この11は、CrystalがInt32型に割り当てたIDです。
次に、オリジナルのプログラムをわずかに変更しましょう:
# この型は前回はなかった! class Foo end def foo(x) if x.is_a?(Int32) 1 else 2 end end foo(1 || 'a') foo('a' || 1)
再コンパイルし、生成されたLLVM IRをチェックすると、fooのところが次のようになります:
define internal i32 @"*foo<(Char | Int32)>:Int32"(%"(Char | Int32)" %x) #0 !dbg !21 { alloca: %x1 = alloca %"(Char | Int32)", align 8, !dbg !22 br label %entry entry: ; preds = %alloca store %"(Char | Int32)" %x, %"(Char | Int32)"* %x1, align 8, !dbg !22 %0 = getelementptr inbounds %"(Char | Int32)", %"(Char | Int32)"* %x1, i32 0, i32 0, !dbg !23 %1 = load i32, i32* %0, align 4, !dbg !23 %2 = getelementptr inbounds %"(Char | Int32)", %"(Char | Int32)"* %x1, i32 0, i32 1, !dbg !23 %3 = icmp eq i32 12, %1, !dbg !23 br i1 %3, label %then, label %else, !dbg !23 then: ; preds = %entry br label %exit, !dbg !23 else: ; preds = %entry br label %exit, !dbg !23 exit: ; preds = %else, %then %4 = phi i32 [ 1, %then ], [ 2, %else ], !dbg !23 ret i32 %4, !dbg !23 }
比較の部分が今どうなっているかわかりますか?
%3 = icmp eq i32 12, %1, !dbg !23 br i1 %3, label %then, label %else, !dbg !23
今度はコンパイラがInt32の型IDに12を割り当てています。おそらくは型がアルファベット順に並べられ、FooがInt32の前にきているからでしょう。まあ、それが理由かどうかは分かりませんが、重要なのはある型の型IDが異なるコンパイル間で同じであるとは限らないということです。
もし私たちが2つのCrystalプログラムを共有ライブラリにコンパイルして同時に読み込もうとすると、一つのfooが勝ってもう一つを上書きします。それをしたときに、一つのプログラムのロジックが壊れて、もし私たちがそのプログラムにInt32を渡せば、x.is_a?(Int32)のチェックが偽になり、おそらくセグフォが起こるでしょう。
これが、現在Crystalで共有ライブラリを作成できない主な理由です。まあ、一つだけの共有ライブラリを使えば可能ですよ…というのが私の意見ですが、それはあまり有用とは言えません。
ですので、これを試してセグフォを経験したり、不思議な挙動を見つけたら…その理由を知っておいてください!あなたが何かを間違えたわけではありません:それはただ単に全く動作しないようになっているだけです。
これを動作させるためには、まず型IDが変わる問題を解決しなければならないでしょう。
asteriteさんはここで次のことを説明しています。
- Crystalでは、それぞれの型には一つの型IDが割り当てられます。それはInt32です。
- 型IDは(おそらく)アルファベット順に割り当てられます。
- だからコンパイルAと、コンパイルBでは、同じ型であっても型IDが異なるためコンフリクトを生じます。
- もしも、2つ以上の共有ライブラリを利用する場合は、片方で使われている関数は、別の関数で上書きされます。IDは不整合を起こすため正常に実行されません。
その後のスレッドの動きを追うと、
プリミティブな型を用いるAPIを作成を利用する共有ライブラリを「同時にひとつだけ」呼び出すことは可能
という議論になっていきます。つまりCrystalで作られた共有ライブラリを1つだけ使っていても問題は起きませんが、2つ以上の共有ライブラリを利用すると問題が起きてしまうらしいのです。
(さらにフォーラムでは、関数の名前マングリングをすることによって、2つの共有ライブラリを完全に独立にしてしまえば問題がないのではないかと議論が進んでいきます)
再現してみる
では、本当に asterite さんが言っているように不具合が起きるのでしょうか。検証していきましょう。
今回はいかにも問題が簡単に発生しそうなコードを用意します。これをライブラリとして使うことにしましょう。仮に common.cr
と名付けます。(ただし、この記事の最後の方で、この努力があまり役に立たなかったことが判明します。)
def kata(x)
case x
when Int32
puts "#{x} is Int32"
when Char
puts "#{x} is Char"
end
end
ここでは、x
のクラスに応じて、条件分岐するようにしています。
次に、このcommon.cr
を利用する2つの共有ライブラリを用意します。
fun crystal_init : Void
GC.init
LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null)
end
require "./common"
class NekoDoNothing
end
fun k1
puts "k1"
kata('a' || 1)
kata(1 || 'a')
end
crystal build k1.cr --single-module --link-flags="-shared" -o libk1.so
fun crystal_init : Void
GC.init
LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null)
end
require "./common"
fun k2
puts "k2"
kata('a' || 1)
kata(1 || 'a')
end
crystal build k2.cr --single-module --link-flags="-shared" -o libk2.so
2つのファイルの違いは、クラス NekoDoNothing
が定義されているかどうか。それだけです。k1.cr
では NekoDoNothing
が定義されていますが、k2.cr
では NekoDoNothing
が定義されていません。
それでは、Rubyから呼び出してみます。
require 'fiddle/import'
module K1
extend Fiddle::Importer
dlload './libk1.so'
extern 'void crystal_init()'
extern 'void k1()'
end
K1.crystal_init
K1.k1
ruby k1.rb
# k1
# a is Char
# 1 is Int32
想定通りに実行されます。
require 'fiddle/import'
module K2
extend Fiddle::Importer
dlload './libk2.so'
extern 'void crystal_init()'
extern 'void k2()'
end
K2.crystal_init
K2.k2
ruby k2.rb
# k2
# a is Char
# 1 is Int32
問題ありません。
それでは、両方のライブラリを呼び出してみましょう。まずは k1 を先に呼ぶ場合。
cat k1.rb k2.rb | ruby
セグフォしてしまいました。
k1
a is Char
1 is Int32
Invalid memory access (signal 11) at address 0x8
[0x7fa27c44e3c6] ?? +140335846319046 in ./libk1.so
[0x7fa27c44e246] ?? +140335846318662 in ./libk1.so
[0x7fa297e42910] ?? +140336309741840 in /lib/x86_64-linux-gnu/libc.so.6
[0x7fa27c44f6e6] ?? +140335846323942 in ./libk1.so
[0x7fa27c3b8b05] __crystal_once +37 in ./libk1.so
[0x7fa27b1c085b] ?? +140335826864219 in ./libk2.so
[0x7fa27b1d7e20] ?? +140335826959904 in ./libk2.so
[0x7fa27b1d44f0] k2 +16 in ./libk2.so
[0x7fa297cfb8b6] ?? +140336308402358 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7fa297cf834d] ?? +140336308388685 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7fa297cfaf33] ffi_call +291 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7fa2980166cc] ?? +140336311658188 in /home/kojix2/.rbenv/versions/3.3.0-dev/lib/ruby/3.3.0+0/x86_64-linux/fiddle.so
[0x5563a53cf155] rb_nogvl +181 in ruby
[0x7fa298016db4] ?? +140336311659956 in /home/kojix2/.rbenv/versions/3.3.0-dev/lib/ruby/3.3.0+0/x86_64-linux/fiddle.so
[0x5563a540d944] ?? +93886462613828 in ruby
[0x5563a5413085] ?? +93886462636165 in ruby
[0x5563a542d786] ?? +93886462744454 in ruby
[0x5563a541bf1b] ?? +93886462672667 in ruby
[0x5563a5223065] ?? +93886460604517 in ruby
[0x5563a52250cb] ruby_run_node +139 in ruby
[0x5563a521f736] ?? +93886460589878 in ruby
[0x7fa297e280d0] ?? +140336309633232 in /lib/x86_64-linux-gnu/libc.so.6
[0x7fa297e28189] __libc_start_main +137 in /lib/x86_64-linux-gnu/libc.so.6
[0x5563a521f785] _start +37 in ruby
[0x0] ???
k2を先に呼ぶ場合も同様です
cat k2.rb k1.rb | ruby
セグフォしてしまいました。
k2
a is Char
1 is Int32
Invalid memory access (signal 11) at address 0x8
[0x7f6153ade3c6] ?? +140055992460230 in ./libk2.so
[0x7f6153ade246] ?? +140055992459846 in ./libk2.so
[0x7f616f442910] ?? +140056455293200 in /lib/x86_64-linux-gnu/libc.so.6
[0x7f6153adf6e6] ?? +140055992465126 in ./libk2.so
[0x7f6153a48b05] __crystal_once +37 in ./libk2.so
[0x7f615285085b] ?? +140055973005403 in ./libk1.so
[0x7f6152867e20] ?? +140055973101088 in ./libk1.so
[0x7f61528644f0] k1 +16 in ./libk1.so
[0x7f616f39b8b6] ?? +140056454609078 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7f616f39834d] ?? +140056454595405 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7f616f39af33] ffi_call +291 in /lib/x86_64-linux-gnu/libffi.so.8
[0x7f616f2866cc] ?? +140056453473996 in /home/kojix2/.rbenv/versions/3.3.0-dev/lib/ruby/3.3.0+0/x86_64-linux/fiddle.so
[0x560de76da155] rb_nogvl +181 in ruby
[0x7f616f286db4] ?? +140056453475764 in /home/kojix2/.rbenv/versions/3.3.0-dev/lib/ruby/3.3.0+0/x86_64-linux/fiddle.so
[0x560de7718944] ?? +94617717541188 in ruby
[0x560de771e085] ?? +94617717563525 in ruby
[0x560de7738786] ?? +94617717671814 in ruby
[0x560de7726f1b] ?? +94617717600027 in ruby
[0x560de752e065] ?? +94617715531877 in ruby
[0x560de75300cb] ruby_run_node +139 in ruby
[0x560de752a736] ?? +94617715517238 in ruby
[0x7f616f4280d0] ?? +140056455184592 in /lib/x86_64-linux-gnu/libc.so.6
[0x7f616f428189] __libc_start_main +137 in /lib/x86_64-linux-gnu/libc.so.6
[0x560de752a785] _start +37 in ruby
[0x0] ???
もし通常の発想でCrystalのライブラリをRubyのGemとして作ろうとする場合は、gemを呼び込むたびに crystal_init()
が呼び出されると思うのでこの実験結果でよいと思いますが、ひょっとすると crystal_init()
が2回呼び出されたのがよくないのかもしれません。なので1回だけ呼び出してみますが結果は同じでした。
require 'fiddle/import'
module K1
extend Fiddle::Importer
dlload './libk1.so'
extern 'void crystal_init()'
extern 'void k1()'
end
module K2
extend Fiddle::Importer
dlload './libk2.so'
extern 'void crystal_init()'
extern 'void k2()'
end
K1.crystal_init
K1.k1
K2.k2
もっと手前でコケていた
予想外のセグフォ祭りになってしまいましたが、どこでセグフォしているのか、一応見ておきましょう。
gdb --args $(rbenv which ruby) k12.rb
でデバッガを起動して、バックトレースを見てみましょう。すると、
#1 0x00007fffdc0c8b05 in __crystal_once () at /usr/local/share/crystal/src/crystal/once.cr:50
#2 0x00007fffdbf0d85b in ~STDOUT:read () at /usr/local/share/crystal/src/kernel.cr:25
#3 0x00007fffdbf24e20 in puts () at /usr/local/share/crystal/src/kernel.cr:425
#4 0x00007fffdbf214f0 in k2 () at /home/kojix2/Crystal/tmp/k/k2.cr:9
#5 0x00007ffff790c8b6 in ?? () from /lib/x86_64-linux-gnu/libffi.so.8
__crystal_once
というものでコケていることがわかります。
ChatGPTにこのファイルについて解説してもらいました。
この
once.cr
と名付けられたファイルは、Crystal言語のシステム内部で使われるクラスと関数を定義しています。特に、クラス変数や定数の初期化を一度だけ行うメカニズムを提供しています。 これは、リカーシブな初期化を防ぐ機能を含んでいます。以下、各部分の詳細な説明です:
Crystal::OnceState
クラスは__crystal_once_init
と__crystal_once
関数が依存するクラスです。このクラスは、定数やクラス変数の初期化を一度だけ行うメカニズムを提供しています。@recはBool型のポインターの配列で、初期化が必要なフラグの集合を保持します。定数やクラス変数が初期化されるたびにフラグがこの配列に追加されます。
once
メソッドは、指定されたフラグがまだ初期化されていない場合、初期化関数を実行し、そのフラグを設定します。これはリカーシブな初期化を防ぐための処理も含んでいます。
__crystal_once_init
関数はプログラムの始めに一度だけ実行され、新たにCrystal::OnceState
を生成して返します。
__crystal_once
関数は、初期化が必要な各定数またはクラス変数に対して呼び出され、該当のフラグに対してonce
メソッドを実行します。また、このファイルには並行処理が可能な状態で実行される場合(
preview_mt
フラグが設定されている場合)、@mutex
というミューテックス(排他制御の一種)を使って同時実行制御を行うためのコードも含まれています。
つまり、Crystalには、一回しか行わないことを前提にした初期化があるらしく、その時点でいろいろコケてしまうようです。気合いを入れて必ずエラーになるようにコードの準備などをしましたが、どうやらそれ以前の問題があるということがわかってきました。
おわりに
Crystalを利用してRubyのGemを作るのは、RubyからCrystalの世界に入った人間には夢のようなものです。しかし、実際にはなかなか厳しい現実のハードルがあることがわかりました。実験をするまでは、2つのCrystalの共有ライブラリを読み込んでもそこそこ動くのでは?と期待をしていたのですが、現実は思ったより厳しかったです。
一方で、Crystalの共有ライブラリは同時に1つまでなら利用できますので、そのことをよく理解している人は、Crystalで処理を書いて、それを他の言語から呼び出すことに挑戦されてもいいかもしれません。
この記事は以上です。