4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

レガシーなCOMライブラリをRustでラップした話

4
Last updated at Posted at 2024-09-23

はじめに

COM (Component Object Model) で書かれたレガシーなライブラリをRustから扱う機会があったので、それに関するメモを残します。このような対応が必要になる場面は滅多にないかもしれませんが、似たようなことを行う場合に、何かしらの参考になるかもしれません。

COMを扱うならC++やC#を使うことが多いかと思いますが、今回はより堅牢なものを作りたいという考えがあったため、Rustを採用しました。

方針

大まかには以下の方針です。

  • COMの呼び出しはC++で行い、それをCスタイルのAPIとしてにFFI用に公開する

    • この箇所はCOMを呼び出すだけの実装とし、それ以外のロジックは持たせない
  • C++とRustの接続にはbindgenを使う

  • COMのアパートメントを考慮したスレッドプールをRust側で作り、COMオブジェクトの操作はそのスレッド内で行う

WindowsではC++向けにATL (Active Template Library) というCOMを扱いやすくするライブラリが用意されているため、これを使います。一応、マイクロソフトがRust向けに公開しているwindowsというクレートでもCOMを扱うことはできるのですが、マイクロソフト製以外のCOMライブラリを扱う際に困難がありそうだったので、COMの呼び出しはRustでなくC++を使います。

アパートメントについては後述します。

What is COM?

そもそもCOMって何?という人も多いかもしれません。私自身も、COMの経験は既存プログラムのメンテナンスのみで、新規開発で使ったことはないという立場なので、十分な知識を有しているわけではないということを予めご了承ください。

COMはWindowsのやや古い技術で、言語非依存のオブジェクト指向のコンポーネントを提供する技術です。dllやexeのようなバイナリ形式でありながらオブジェクト指向 (インターフェス指向) かつ言語非依存のインターフェースを提供するというのは挑戦的ではありますが、現代の開発で新規にCOMを書くことはほぼ無いかと思います。

COMが使われなくなった背景は私も理解していませんが、理由の一つとしてDLL地獄と呼ばれる問題がありそうです。COMのDLLはregsvr32コマンドを使ってシステムに登録する必要がありますが、同じコンポーネントはシステム上に一つしか登録できないため、DLLの互換性が問題になることがありました。例えば foo.dll ver.1.0 を使用するアプリAがインストールされているPCに、foo.dll ver.1.2 を使用するアプリBをインストールするとします。すると、アプリBのインストールによりシステム上の foo.dll は ver.1.2 に置き換わり、これがアプリAの動作に問題を引き起こすことがあります。当時はディスクの容量を気にする時代だったため、複数のプログラムが使用するDLLは共通化すべきという思想があったのかもしれません。

COM自体が複雑だったのも理由の一つかもしれません。バイナリ表現を標準化しつつインターフェース指向を実現するために、内部ではメソッドの呼び出し規約やVTableのメモリレイアウトなどの面倒を見る必要があります。また、COMを扱うにはアパートメントという特有の概念を理解する必要があります。一般的なCスタイルのDLLに比べると、COMは扱うのが難しいです。

実装編

COMインターフェース

ここでは説明のために、以下のような架空のインターフェースを持ったCOM DLLがあるとします。COMのインターフェスは本来はIDLという形式で書くと思いますが、説明のため、ここではC++のクラス風に記述します。

class IGreeter {
    int greet(int value, _bstr_t message) = 0;
};

class IGreeterEvent {
    void on_response(BSTR reply);
};

class Greeter : public IGreeter {
    int greet(int value, BSTR message) = { /**/ }
}

COM DLLであるgreeter.dllはIGreeter, IGreeterEventというインターフェースと、IGreeterを実装したGreeterクラスを提供します。IGreeterEventはイベントを受け取るためのもので、インターフェースはライブラリ側が提供しますが、クラスの実装は利用者側で行います。

BSTRはCOMが使う文字列型です。これはCOMの呼び出しにのみ使用し、C APIとしてラップする層ではconst wchar_t*に変換して使います。

C++側

ヘッダー

一般的なCのAPIのスタイルに従い、ハンドルを使うAPIとします。ヘッダーの定義は以下のようになります。

greeter_ffi.hpp
using GreeterHandle = void*;
using OnResponseCallback = void(*)(GreeterHandle, const wchar_t*);

extern "C" {

// COMオブジェクトの生成・破棄
GreeterHandle create_handle();
void destroy_handle(GreeterHandle handle);

// イベントを受け取るコールバックの登録
void register_on_response(GreeterHandle handle, OnResponseCallback callback);

// メソッド呼び出し
int greet(GreeterHandle handle, int value, const wchar_t* message);

}  // extern "C"

usingやextern "C"で気づくかもしれませんが、このヘッダーはCではなくC++のものです。bindgenはC++の構文も一部扱うことができ、ヘッダーのファイル名が *.hpp であればC++として解釈するため、これらを使用しても問題ありません。ABIを考慮すると、インターフェースにはC互換の関数や構造体のみを使う方が無難だと思いますが、型エイリアスはCのtypedefよりもC++のusingの方が分かりやすいため、こちらを使っています。

イベントについては、コールバック関数の第一引数でハンドルを渡すようにします。CのAPIだとコールバックは関数ポインタで渡す必要があり、C++のstd::functionやラムダ式のように、状態をキャプチャした関数を渡すことはできません。そのため、イベントとCOMオブジェクトの対応関係はコールバックの引数で判断する必要があります。

実装

イベントを受け取るため、IGreeterEventを実装したクラスを自作します。COMのイベントを受け取るクラスは一般的にEvent Sinkと呼ばれるようです。

event_sink.hpp
#include <atlbase.h>
#include <atlcom.h>
#include <atlcomcli.h>
#include <atldef.h>

#import "greeter.dll"

constexpr auto TYPE_LIB_UUID = _uuidof(GreeterLib::GreeterLib);
constexpr auto EVENT_UUID = _uuidof(GreeterLib::IGreeterEvent);
constexpr uint32_t SINK_OBJECT_ID = 1;

// Event Sink の実装
class ATL_NO_VTABLE GreeterEventSink : public CComObjectRootEx<CComSingleThreadModel>
  , public IDispEventImpl<SINK_OBJECT_ID, GreeterEventSink, &EVENT_UUID, &TYPE_LIB_UUID, 1, 0> {
private:
    GreeterHandle handle_ = {};
    OnResponseCallback on_response_ = nullptr;

public:
    GreeterEventSink(GreeterHandle handle) = default;
    ~GreeterEventSink() = default;

    void init(GreeterHandle handle) {
        // 設計の都合上、インスタンス生成時にハンドルを渡せないため、
        // 生成後に init 関数を呼ぶ形にしています。
        handle_ = handle;
    }

    void register_on_response(OnResponseCallback callback) {
        on_response_ = callback;
    }

    BEGIN_COM_MAP(GreeterEventSink)
    COM_INTERFACE_ENTRY_IID(EVENT_UUID, GreeterEventSink)
    END_COM_MAP()

    BEGIN_SINK_MAP(GreeterEventSink)
    SINK_ENTRY_EX(SINK_OBJECT_ID, EVENT_UUID, 1, on_response)
    END_SINK_MAP()

    // IGreeterEventが提供するインターフェスが複数ある場合は]
    // 以下のように追加していきます。
    //
    //   BEGIN_SINK_MAP(GreeterEventSink)
    //   SINK_ENTRY_EX(SINK_OBJECT_ID, EVENT_UUID, 1, on_response)
    //   SINK_ENTRY_EX(SINK_OBJECT_ID, EVENT_UUID, 2, on_event_2)
    //   SINK_ENTRY_EX(SINK_OBJECT_ID, EVENT_UUID, 3, on_event_3)
    //   END_SINK_MAP()
    //
    // 第三引数の数値はCOMのイベントインターフェース側で定められた値で、
    // メソッドごとに一意の値が割り当てられます。
    // この例では、 on_response や on_event_2 といったメソッドに
    // 対応する値 (1, 2, 3) を事前に知っているものとします。

private:
    void __stdcall on_response(BSTR reply) {
        if (on_response_) {
            auto wc_reply = static_cast<const wchar_t*>(strVal);
            on_response_(handle_, wc_reply);
        }

        // 文字列の解放の責務がEvent Sink側にある場合はSysFreeStringを呼ぶ。
        ::SysFreeString(reply);
    }

    // SINK_ENTRY_EXで追加した分だけイベントハンドラを実装します。
    // void __stdcall on_event_2(args...) {}
    // void __stdcall on_event_3(args...) {}
};

上記のコードをコンパイルするにはgreeter.dllがシステムに登録されている必要がありますが、greeter.dllは説明のための架空のDLLであるため、このコードを実際にコンパイルすることはできません。あくまでも例であることをご了承ください。

説明の都合上、実装を全てヘッダーファイルに書いていますが、実際はcppファイルに分けています。

クラスが継承しているIDispEventImplや、実装中にあるBEGIN_COM_MAPマクロなどはATLが提供するものです。これを使って、COMライブラリ側で発生したイベントを受け取った際の動作を実装します。

GreeterLib::GreeterLibやGreeterLib::IGreeterEventの定義は、 #import "greeter.dll" を含むコードをコンパイルすることで得られます。このコードをコンパイルすると、コンパイラはgreeter.dllからインターフェースの情報を読み出し、それを*.tlhファイルとしてビルドの出力先に生成します。この*.tlhはC++のヘッダーファイルのようなものであり、この中にGreeterLib::GreeterLib等の定義が書かれます。

イベントが複数種ある場合は、各イベント用のコールバック登録関数を作るよりも、関数ポインタをまとめた構造体を作り、それを渡す方が良いかと思います。

using OnResponseCallback = void(*)(GreeterHandle, const wchar_t*);
using OnEventACallback = void(*)(GreeterHandle, int);
using OnEventBCallback = void(*)(GreeterHandle, double);

// こんな感じの構造体を作る
struct Callbacks {
    OnResponseCallback on_response;
    OnEventACallback on_event_a;
    OnEventBCallback on_event_b;
};

class GreeterEventSink {
private:
    Callbacks callbacks_ = {};
public:
    // Event Sinkに渡す
    void register_callbacks(Callbacks& callbacks) {
        callbacks_ = callbacks;
    }
};

次に、FFI用のAPIを実装します。先ほど定義した``greeter_ffi.hpp`の実装部分です。

greeter_ffi.cpp
#include "greeter_ffi.hpp"

#include <cassert>
#include <atlbase.h>
#include <atlcom.h>
#include <atlcomcli.h>
#include <atldef.h>

#import "greeter.dll"

class DummyAtlModule : public CAtlExeModuleT<DummyAtlModule> {};

// このオブジェクトはATLのグローバルな状態を初期化するために生成しています。
// このオブジェクト自体は使いませんが、インスタンスは生成されている必要があるため、
// static変数としています。
[[maybe_unused]]
static DummyAtlModule _module:

struct GreeterHandleImpl {
    CComPtr<GreeterLib::IGreeter> greeter = nullptr;
    CComObject<GreeterEventSink> event_sink = nullptr;
}

// オブジェクト生成
GreeterHandle create_handle() {
    auto* impl = new GreeterHandleImpl();

    // Greeterのインスタンスを生成
    auto hr = impl->greeter.CoCreateInstance(_uuidof(GreeterLib::Greeter));
    if (!SUCCEEDED(hr)) {
        ... // エラー処理 (具体的な内容は省略, 以降も同様)
    }

    auto handle = reinterpret_cast<void*>(impl);

    // Event Sinkオブジェクトを生成
    auto event_sink = static_cast<CComObject<GreeterEventSink>*>(nullptr);
    hr = CComObject<GreeterEventSink>::CreateInstance(&event_sink);
    if (!SUCCEEDED(hr)) {
        ... // エラー処理
    }
    event_sink->AddRef();
    event_sink->init(handle);

    // Event SinkとCOMオブジェクトの紐づけ
    hr = event_sink->DispEventAdvise(impl->greeter);
    if (!SUCCEEDED(hr)) {
        ... // エラー処理
    }
    impl->event_sink = std::move(*event_sink);

    return handle;
}

// オブジェクト破棄
void destroy_handle(GreeterHandle handle) {
    auto* impl = reinterpret_cast<GreeterHandleImpl*>(handle);
    assert(impl != nullptr);
    impl->event_sink.DispEventUnadvise(impl->greeter);
    impl->event_sink.Release();
    impl->greeter.Release();
    delete impl;
}

// コールバックの登録
void register_on_response(GreeterHandle handle, OnResponseCallback callback) {
    auto* impl = reinterpret_cast<GreeterHandleImpl*>(handle);
    assert(impl != nullptr);

    impl->event_sink.register_on_response(callback);

    // NOTE:
    // コールバックの登録とイベント発生が同時に起こりえる場合は排他をかけること。
    // このライブラリでは以下のように想定しているため、排他をかけていない。
    //   - Greeter は greet() が呼ばれない限りイベントを発生させない
    //   - このラッパーは create_handle() の直後に register_on_response() が
    //     呼ばれる想定であり、コールバックを登録する前にイベントを発生させる操作が
    //     行われないことを前提としている。
}

// メソッド呼び出し
int greet(GreeterHandle handle, int value, const wchar_t* message) {
    auto* impl = reinterpret_cast<GreeterHandleImpl*>(handle);
    assert(impl != nullptr);

    auto bstr = bstr_t(message);
    return impl->greeter->greet(bstr);

    // NOTE:
    // bstr_tはBSTR用のスマートポインタであり、スコープを抜けると破棄される。
    // bstr_tを使わない場合は以下のようにして文字列の確保と破棄を行う。
    //
    //  BSTR bstr = ::SysAllocString(message);
    //  auto ret = impl->greeter->greet(bstr);
    //  ::SysFreeString(bstr);
    //  return ret;
}

だいぶ説明を端折りましたが、とりあえず上記のような形で、COMをラップしたCスタイルのAPIを作ることができます。

bindgen

Rustから呼べるよう、上記のCスタイルのAPIをbindgenでビルドします。

ファイル構成は以下のようになります。

workspace/
+-- greeter_ffi/     # FFI用のクレート
|   +-- build.rs
|   +-- Cargo.toml
|   +-- src/
|       +-- lib.rs
|       +-- cpp/
|           +-- CMakeLists.txt
|           +-- greeter_ffi.hpp
|           +-- greeter_ffi.cpp
|           +-- event_sink.hpp
|           +-- event_sink.cpp
|
+-- my_project/      # 利用側のクレート
    +-- Cargo.toml
    +-- src/
        +--- lib.rs  # アプリの場合は main.rs
        ...

cmakeとbindgenをgreeter_ffi側のCargo.tomlにbuild-dependenciesとして追加します。

Cargo.toml
[build-dependencies]
cmake = "0.1"
bindgen = "0.69"

CMakeLists.txtの内容は以下の通りです。

CMakeLists.txt
cmake_minimum_required(VERSION 3.28)
project(greeter_ffi CXX)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_library(greeter_ffi)
target_compile_features(greeter_ffi PUBLIC cxx_std_20)
target_compile_options(greeter_ffi PUBLIC /W4 /utf-8)

target_sources(greeter_ffi
    PRIVATE
    greeter_ffi.cpp
    event_sink.cpp
)

install(TARGETS greeter_ffi DESTINATION .)

lib.rsの内容は以下の通りです。

lib.rs
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

build.rsは以下の通りです。

build.rs
use std::env;
use std::path::PathBuf;

fn main() {
    let dst = cmake::Config::new("src/cpp")
        .define("CMAKE_MSVC_RUNTIME_LIBRARY", "MultiThreadedDLL")
        .build();

    println!("cargo:rustc-link-search=native={}", dst.display());
    println!("cargo:rustc-link-lib=static=greeter_ffi");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());

    bindgen::Builder::default()
        .header("./src/cpp/greeter_ffi.hpp")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("failed to generate bindings")
        .write_to_file(out_path.join("bindings.rs"))
        .expect("failed to write bindings");
}

Rust側

アパートメントについて

説明を後回しにしていましたが、ここでCOMのアパートメントという概念について簡単に触れます。アパートメントはCOMのオブジェクトが所属する部屋のようなもので、STA (Single Thread Apartment) と MTA (Multi Thread Apartment) があります。また、プロセスのメインスレッドと紐づいたSTAは特別扱いされ、Main STAなどと呼ばれます。

アパートメントとスレッドの間には、「アパートメントにスレッドが所属する」という関係があります。STAには1つのスレッドのみが所属し、MTAには複数のスレッドを所属させることができます。

COMオブジェクトは自身が所属するアパートメントのスレッドからのみ呼び出されます。アパートメント外のスレッドからの呼び出しは、メッセージキューによってマーシャリングされるため、呼び出しにコストがかかります。原則として、COMのメソッド呼び出しは、そのインスタンスが所属するアパートメントのスレッドから行うようにするべきです。

COMオブジェクトの所属先のアパートメントは、コンポーネントのスレッドモデルおよびインスタンスを生成したスレッドの所属アパートメントによって決まります。MTAに所属するスレッドがCOMインスタンスを生成したとしても、COMコンポーネントのスレッドモデルによってはインスタンスがSTAに所属するといったことがあります。

今回私が関わったCOMライブラリでは、インスタンスが常にSTAに所属するモデルが使われていました。そのため、ここでの説明は全てSTAを前提としたものとなります。

CoInitializeExについて

スレッドをアパートメントに紐づけるには、CoInitializeExというAPIを使います。このAPIですが、これはプロセスの初期化ではなく、スレッドの初期化関数であるという点に注意が必要です

COMを内部的に使うライブラリを提供する場合、CoInitializeExの呼び出しには注意が必要です。ライブラリ側では呼び出し側のスレッドの状態を知ることができないためです。アプリ側は、他のCOMライブラリを使うために既にCoInitializeExを呼んでいるかもしれませんし、マルチスレッドのプログラムかもしれません。ライブラリの初期化時にCoInitializeExを呼ぶ設計は、例えば以下のような問題を起こします。

ライブラリ側のコード

namespace foo_lib {
    // ライブラリの初期化: 内部的にCoInitializeExを呼ぶ
    void init() {
        ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
    }

    // 内部的にCOMを使うAPI
    void do_something() {
        ...
    }
}

アプリ側のコード

// 問題例1. 呼び出し元で既にCoInitializeExが設定されている
int main() {
    ::CoInitializeEx(NULL, COINIT_MULTITHREADED);

    // 既にCoInitializeExが呼ばれている上に、
    // foo_lib::init() 内ではアプリ側の設定 (COINIT_MULTITHREADED) と
    // 異なる設定でCoInitializeExを呼ぼうとしているため、失敗する
    foo_lib::init();
}

// 問題例2. ライブラリを初期化したスレッドと別のスレッドでAPIが呼ばれる
int main() {
    foo_lib::init();
    auto t = std::thread([] {
        // このスレッドではCoInitializeExが呼ばれていない
        foo_lib::do_something();
    })
}

このような問題があるため、COMをライブラリ内部で使う場合は、アプリ側にCoInitializeExを呼ばせるか、ライブラリ内部で生成したスレッドに対してCoInitializeExを呼ぶようにします。前者の場合は、CoInitializeExを呼ぶ責務がアプリ側にあることをドキュメント等で明示する必要があります。

今回の対応では、Rust側のラッパー内でスレッドプールを用意し、プール内の各スレッドに対してCoInitializeExを呼ぶ設計としました。

スレッドプール

COMの呼び出しを行うスレッドプールを作ります。通常のスレッドプールと違うのは、オブジェクトごとに実行スレッドが固定されている点です。前述した通り、COMのメソッドはインスタンスが所属するアパートメントのスレッド (STAの場合は、そのインスタンスを生成したスレッド) で呼ぶ必要があるため、一般的なスレッドプールのように「空いているスレッドが仕事を行う」ことはしません。

Rustでスレッドプールを扱う場合はRayonクレートを使うのが一般的かと思いますが、これは「空いているスレッドに仕事をさせる」方式であり、プール内の特定のスレッド (= COMインスタンスを生成したスレッド) を指定して仕事をさせることができません。今回の目的でRayonを使う場合は、スレッド数が1のスレッドプールを複数用意するといった実装になると思います。

スレッドプールでなく、COMのインスタンスごとにスレッドを作っても良いかもしれません。スレッドのスイッチングコストは増えそうですが、管理上はその方が楽だと思います。

CoInitializeExのためにwindowsクレートを使うので、Cargo.tomlに以下を追加します。

Cargo.toml
[dependencies]
windows = { version = "0.58.0", features = ["Win32_System_Com"] }

また、COMスレッド側で重たい処理を行う場合を想定し、非同期版のAPIも用意します。これにはtokioのチャネルを使うので、tokioもCargo.tomlに追加します。このスレッドプールだけであればfeaturesにfullを指定する必要はありませんが、他の機能もプロジェクトで使うので、ここではfullを指定します。

Cargo.toml
[dependencies]
tokio = { version = "1.38.0", features = ["full"] } # <- 追加
windows = { version = "0.58.0", features = ["Win32_System_Com"] }

スレッドプールの実装は以下の通りです。

com_thread_pool.rs
use windows::Win32::System::Com;
use std::sync::Arc;

enum Message {
    Command(Box<dyn FnOnce() + Send>),
    Terminate,
}

pub(crate) struct ComThread {
    join_handle: Option<std::thread::JoinHandle<()>>,
    tx: std::sync::mpsc::Sender<Message>,
}

impl ComThread {
    pub(crate) fn new() -> Self {
        let (tx, rx) = std::sync::mpsc::channel::<Message>();

        // スレッドを生成
        let join_handle = std::thread::spawn(move || {
            unsafe {
                // スレッドをSTAで初期化
                // (MTAを使う場合は代わりに Com::COINIT_MULTITHREADED を使う)
                let _ = Com::CoInitializeEx(None, Com::COINIT_APARTMENTTHREADED);
            };

            // メッセージを受信し続ける。
            // 関数を渡されたらそれを実行し、終了メッセージが来たら抜ける。
            for message in rx.iter() {
                match message {
                    Message::Command(f) => f(),
                    Message::Terminate => break,
                }
            }

            unsafe {
                // COMの終了
                Com::CoUninitialize();
            };
        });

        Self {
            join_handle: Some(join_handle),
            tx,
        }
    }

    // 渡された関数を CoInitializeEx されたスレッド上で実行する (同期版)
    pub(crate) fn call<R>(&self, f: impl FnOnce() -> R + Send + 'static) -> R
    where
        R: Send + 'static,
    {
        // 「関数fを実行し、その戻り値をチャネルを使って返す」関数を用意し、
        // それをmessageとしてスレッドに送信する。
        let (tx, rx) = std::sync::mpsc::sync_channel(1);
        let message = Message::Command(Box::new(move || tx.send(f()).unwrap()));

        self.tx.send(message).unwrap();
        rx.recv().unwrap()
    }

    // 渡された関数を CoInitializeEx されたスレッド上で実行する (非同期版)
    pub(crate) async fn call_async<R>(&self, f: impl FnOnce() -> R + Send + 'static) -> R
    where
        R: Send + 'static,
    {
        // std::sync::mpsc の代わりに tokio::sync::mpsc を使う
        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
        let message = Message::Command(Box::new(move || tx.blocking_send(f()).unwrap()));

        self.tx.send(message).unwrap();
        rx.recv().await.unwrap()
    }
}

impl Drop for ComThread {
    fn drop(&mut self) {
        self.tx.send(Message::Terminate).unwrap();
        if let Some(handle) = self.join_handle.take() {
            handle.join().unwrap();
        }
    }
}

// (使用しているCOMオブジェクト数, スレッド) のタプル
type CountingComThread = (usize, Arc<ComThread>);

struct ComThreadPool {
    threads: Vec<CountingComThread>,
}

impl ComThreadPool {
    pub(crate) fn new(num_threads: usize) -> Self {
        let threads = (0..num_threads).map(|_|
            CountingComThread(0, Arc::new(ComThread::new()))
        ).collect();
        Self { threads }
    }

    pub(crate) fn assign_thread(&mut self) -> (usize, Arc<ComThread>) {
        // 割り当てられているCOMオブジェクトの数が小さいスレッドを見つける
        let (index, thread) = self
            .threads
            .iter_mut()
            .enumerate()
            .min_by_key(|(_, x)| x.0)
            .unwrap();

        # カウントを増やす
        thread.0 += 1;

        # (プール内のインデックス, スレッドへの参照) を返す
        (index, thread.1.clone())
    }

    pub(crate) fn remove_reference(&mut self, index: usize) {
        // カウントを減らす
        self.threads[index].0 -= 1;
    }
}

スレッド間のデータ受け渡しにはmpsc::channelとクロージャーを使います。個人的にはこの辺りの処理を書いていてRustらしい書き味を感じました。

スレッドプールは、スレッドごとに使用数をカウントし、使用数が均等になるようにします。例えばスレッドプール内に2つのスレッドA, Bがある場合に、最初に生成するCOMインスタンスにはスレッドAを、次に生成するCOMインスタンスにはスレッドBを、3つ目のインスタンスにはスレッドAを...と割り当てます。COMインスタンスを破棄した際は、そのインスタンスに紐づいていたスレッドの使用数を1つ減らします。

UTF-16文字列の変換

ラッパーのコードを示す前に、文字列の変換を用意します。C側は文字列をwchar_t*で扱っており、Rustの文字列との変換が必要なためです。

utf16_conversion.rs
// NULL終端されたUTF-16文字列をStringに変換する
pub(crate) fn string_from_wchar(ptr: *const u16) -> String {
    unsafe {
        let len = (0..).take_while(|&i| *ptr.offset(i) != 0).count();
        let slice = std::slice::from_raw_parts(ptr, len);
        String::from_utf16(slice).unwrap_or_default()
    }
}

// NULL終端されたUTF-16文字列をVec<u16>として返す
pub(crate) fn str_to_utf16(value: &str) -> Vec<u16> {
    value.encode_utf16().chain(Some(0)).collect::<Vec<_>>()
}

ラッパー構造体

Greeterをラップした構造体を作ります。コード中のgreeter_ffiは、bindgenで生成されたFFI用のクレートを指します。スレッドプールはグローバルなオブジェクトにしています。

なお、ここで使っているLazyLockは2024年7月にリリースされたRust 1.80で安定化されたものであり、比較的新しい機能です。

use std::sync::{LazyLock, Mutex, Arc};
use greeter_ffi::GreeterHandle;
use com_thread_pool::*;

// スレッドプール (グローバル)
static COM_THREAD_POOL: LazyLock<Mutex<ComThreadPool>> = LazyLock::new(|| {
    let num_threads = std::thread::available_parallelism().unwrap().get();
    let thread_pool = ComThreadPool::new(num_threads);
    Mutex::new(thread_pool)
});

// Rust側のイベントハンドラ
#[no_mangle]
extern "C" fn on_response(handle: GreeterHandle, reply: *const u16) {
    let reply = string_from_wchar(reply);
    ...  // do something
}

struct Greeter {
    handle: GreeterHandle,
    thread_pool_index: usize,
    thread: Arc<ComThread>,
}

impl Greeter {
    pub(crate) fn new() -> Self {
        // 実行スレッドを割り当て
        let (i, thread) = COM_THREAD_POOL.lock().unwrap().assign_thread();

        // COMオブジェクトのインスタンス化およびイベントハンドラの登録
        let handle = thread.call(|| unsafe {
            let handle = greeter_ffi::create_handle();
            greeter_ffi::register_on_response(handle, Some(on_response));
            handle
        });

        Self {
            handle,
            thread_pool_index: i,
            thread
        }
    }

    // GreeterHandleを引数にとる関数を実行 (同期版)
    pub(crate) async fn call<R>(&self, f: impl FnOnce(GreeterHandle) -> R + Send + 'static) -> R
    where
        R: Send + 'static,
    {
        let handle = self.handle;  // GreeterHandleの実体はvoid*なのでclone()せずコピー可能
        self.thread.call(move || {
            f(handle)
        })
    }

    // GreeterHandleを引数にとる関数を実行 (非同期版)
    pub(crate) async fn call_async<R>(&self, f: impl FnOnce(GreeterHandle) -> R + Send + 'static) -> R
    where
        R: Send + 'static,
    {
        let handle: GreeterHandle = self.handle;
        self.thread.call_async(move || {
            f(handle)
        }).await
    }
}

impl Drop for Greeter {
    fn drop(&mut self) {
        // COMオブジェクトを破棄する
        self.call(|handle| unsafe {
            greeter_ffi::destroy_handle(handle);
        });

        // オブジェクトと紐づいていたスレッドの使用数を減らす
        COM_THREAD_POOL.lock().unwrap().remove_reference(self.thread_pool_index);
    }
}

利用側のコードは以下のようになります。

let greeter = Greeter::new();

// Greeterに対して行いたい処理を呼ぶ
let result = greeter.call(|handle| unsafe {
    // 渡したクロージャーはCOMインスタンスが所属する
    // アパートメントのスレッドで実行される
    let msg = str_to_utf16("hello");
    greeter_ffi::greet(handle, msg.as_ptr())
});

// 複数のAPIを呼び出しても良い
let result = greeter.call(|handle| unsafe {
    let msg = str_to_utf16("hello");
    let _ = greeter_ffi::greet(handle, msg.as_ptr());

    let msg = str_to_utf16("hello");
    greeter_ffi::greet(handle, msg.as_ptr())
});

// 非同期に行う場合
let result = thread.call_async(|handle| unsafe {
    let msg = str_to_utf16("in async");
    greeter_ffi::greet(greeter_handle, msg.as_ptr())
}).await;

このラッパーは、COMインスタンスに割り当てられたスレッド (アパートメントのスレッド) で行いたい処理をクロージャーとして渡して使います。COMのメソッドと1対1対応するメソッドは用意していません。そのようなメソッドを用意しても構いませんが、その場合、メソッドを呼び出すたびにスレッド間の待ちが発生します。

impl Greeter {
    // このようなメソッドを用意すると ...
    fn greet(&self, message: &str) -> i32 {
        let handle = self.handle;
        let message = str_to_utf16(message);
        self.thread.call(move || unsafe {
            greeter_ffi::greet(handle, message)
        })
    }
}

fn main() {
    let greeter = Greeter::new();

    // 以下の呼び出しで
    // 「スレッドに処理を投げる -> 応答を待つ」
    // が2回発生する
    greeter.greet("hello");
    greeter.greet("world");
}

また、上記のようなメソッドを用意する場合、COM側のメソッドと同じ数だけメソッドを定義する必要があります。今回はgreet()の一つだけですが、実際のメソッドはもっと多いはずです。それらを実装する手間を考えると、ラッパー側のメソッドは関数を受け取るものだけでも十分かと思います。ただ、上記の書き方の場合、呼び出し側はCのFFIのインターフェースを意識しなくて済むというメリットもあるため、意味がないわけではありません。

以上がCOMのラッパーの概要です。実際の開発ではこれを元にRust側で実装を進めていくことになりますが、記事としてはここで終わります。

終わりに

この記事が誰かの役に立つ場面が果たしてあるのだろうか

COMの資産をRustで扱う機会はあまり無いかと思います。そういう、おそらく珍しいであろう作業を行う機会があったので、記事にしてみました。誰かの役に立つ可能性は低いかもしれませんが、この経験を私個人の中だけに留めておくのも惜しいと思ったので、これを書いた次第です。

COMはやはり難しいと思いました。COMの仕組みそのものの複雑さもあると思いますが、知識を学ぶこと自体が難しいと感じます。それでも何とかなった、というのが今回の感想です。

4
5
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?