読者です 読者をやめる 読者になる 読者になる

高速なイベント駆動IOライブラリ mpio

まだまだ未完成で半分アイディアだけなのですが、なかなか進まないのでとりあえず公開してみます。CodeRepos:/lang/c/mpio/trunk/mp
いわゆるlibeventのようなものなのですが、C++で書かれていて、より使いやすく、より最適化が効きやすいようになっています。


以下のような特徴があります(目指しています)。

  • オーバーヘッドの少ない低水準レイヤー、使いやすい高水準レイヤーという形でレイヤー構造になっている
  • better Cなコードにもある程度なじむ
  • メモリ管理機能を内蔵している
  • ヘッダファイルをincludeするだけで使える(ライブラリをリンクしなくて良い)
  • ライブラリ自体のコードが短く、モジューラブル


低水準なレイヤーから順に、mp::event、mp::dispatch、mp::io、mp::asioがあります。

mp::event

OS依存のIO多重化システムコール(epoll, kqueue)と、selectを抽象化しただけのレイヤーです。add(ファイルディスクリプタの登録)、remove(同削除)、wait(どれかのファイルディスクリプタが読み込める or 書き込めるようになるまでまで待つ)という3つの操作ができます。
IO多重化システムコールはコンパイル対象のOSによって自動的に選択され、Linuxならepoll、FreeBSDMac OS Xならkqueueが使われます。


ファイルディスクリプタを追加するときに、同時に任意のデータを渡すことができます。このデータはそのファイルディスクリプタにイベントが発生したときに、そのまま返されます。
このようなライブラリでは良くvoid*ポインタを渡せるようになっていますが、void*ポインタではせっかくの型情報をキャストして消してしまわなければならず、デバッグがしにくくなります。また、渡すデータを動的に確保する場合は、メモリリークしないように気を遣う必要があります。
mp::eventでは、テンプレート引数にデータ型を指定することで、任意のデータ型(構造体/クラス)を渡せるようになっています。メモリはライブラリ側で管理されるので、ユーザーが管理する必要がありません。一方でデータ型をvoid*型にしておけば、従来のCプログラムのように使うこともできます。


実行時間、メモリのどちらもオーバーヘッドはほぼゼロで、システムコールを抽象化する分だけの恩恵が得られます。
このレイヤーのインターフェイスはほぼ固まっています。

template <typename Data>
class event {
public:
        typedef Data data_t;
        typedef system< event<Data> > system_t;
        typedef typename system_t::backlog backlog;
public:
        event(size_t initial_length = 8);
        int add(int fd, short event, data_t data);    // 追加
        int remove(int fd, short event);    // 削除
        int wait(backlog** rback);     // イベント待ち
        int wait(backlog** rback, int timeout_msec);
        data_t& data(int fd);
        size_t size(void);
private:
        system_t m_system;
        std::vector<data_t> m_events;
private:
        event(const event&);
};

mp::dispatch

ファイルディスクリプタと同時にコールバック関数を登録することで、イベント駆動のプログラムを簡単に書けるようになっています。ファイルディスクリプタにイベントが発生すると、同時に登録したコールバック関数が呼ばれます。コールバック関数には関数ポインタと関数オブジェクトの両方を使うことができます。
コールバック関数を呼ぶ分だけのオーバーヘッドが発生します。


このレイヤーの進捗は、テンプレート引数にデータ型を受け取ってそのデータをライブラリで管理するか、あるいはstd::bind*で関数にbindするようにするか考え中。

template <typename Data>
class dispatch {
public:
        typedef Data data_t;
        typedef std::function<void (int, short, data_t&)> callback_t;
public:
        dispatch(size_t initial_length = 8);
        int add(int fd, short event, callback_t callback, data_t data);  // 追加
        int remove(int fd, short event);  // 削除
        int run(void);    // コールバック開始
private:
        struct cb_t {
                cb_t() {}
                cb_t(callback_t c, data_t d) : data(d), callback(c) {}
                data_t data;
                callback_t callback;
        };
        typedef event<cb_t> event_t;
        event_t m_event;
};

mp::io

mp::dispatchに加えてバッファを内蔵しています。登録したコールバック関数は、イベントが届いただけでは呼ばれず、さらに特定の条件を満たした場合ときに呼ばれます。
「特定の条件」には、一定以上のデータが届いたら(ios_read_at_least)ちょうど何byteのデータが届いたら(ios_read_just)データを全て書き込み終わったら(ios_write_just)新しいクライアントが接続してきたら(ios_accept)などが用意されており、自分で追加することもできます。
さらに、高速な可変長メモリプールが内蔵されています。malloc/freeを繰り返すよりも圧倒的に高速にメモリを確保できます。
テンプレート引数には、コールバック関数間で共有するデータ型を渡せます。たとえばクライアント情報を管理する配列や、サーバーの状態を保持する変数などを入れておき、コールバック関数間で共有できます。


ios_read_justやios_read_at_leastなどのIO関数のインターフェイスの定義と、実際の実装がまだ途中。boost::bindやcomposeなどで関数をつなげていくと、1行で複数クライアント対応のイベント駆動サーバーが書けたりする?

template <typename Data>
class io {
public:
        io(data_t data_ = Data(), size_t initial_length = 8);
        int run(void);
        template <typename IOS>    // 追加(関数オブジェクト)
                int add(int fd, short event, IOS ios);
        template <typename Callback, typename Argument, typename Finalize>  // 追加(関数ポインタ)
                int add(int fd, short event, Callback* ios_callback, Argument ios_obj, Finalize* ios_finalize);
public:
        mempool<> pool;
        data_t data;
// 略…
};


イメージ

struct shared_data {    // コールバック関数間で共有するデータ
        std::vector<int> clients;
};

typedef mp::io<shared_data> mpio;

int main(int argc, char* argv[])
{
        mpio io;    // mp::ioオブジェクトを作る
        mp::ios::ios_accept(io, SOCKET, cb_accept);    // SOCKETでacceptできたらcb_acceptを呼ぶ
        io.run();
}

int cb_accept(mpio& io, int error, int fd)
{
        void* buffer = io.pool.malloc<BUFFER_SIZE>();  // 内蔵メモリプールからメモリを確保
        io.data.clients.push_back(fd);  // 共有データにファイルディスクリプタを追加
        return mp::ios::ios_read(    // ファイルディスクリプタから読み込み
                io,
                fd,
                buffer,
                BUFFER_SIZE,
                CABLLACK_FUNC;
                );
        return 0;
}


メモリプールのインターフェイス(途中で違う言語が入っていますが気にしてはいけません)

template < size_t EstimatedAllocationSize = POOL_DEFAULT_ALLOCATION_SIZE,
           size_t OptimalLotsInChunk = POOL_DEFAULT_LOTS_IN_CHUNK >
class mempool {
public:
        mempool();
        ~mempool();
public:
        inline void* malloc(size_t size);
        inline void free(void* x);
        template <size_t size>
                inline void* malloc();
public:
        template <typename T>
        inline void destroy(T* x) {
                x->~T();
                this->free(x);
        }
<%
def args(n, sep = "", &block)
        Array.new(n) {|i| yield i+1 } .join(sep)
end
-%>
<% (0..16).each do |n| -%>
        template <typename T<%= args(n) {|i| ", typename A#{i}" } %>>
        inline T* construct(<%= args(n, ", ") {|i| "A#{i} a#{i}" } %>) {
                return new( this->malloc<sizeof(T)>() ) T(<%= args(n, ", ") {|i| "a#{i}" } %>);
        }
<% end -%>
private:
// 略…
};

mp::asio

mp::ioとほぼ同じですが、ios_read_at_leastなどのIO関数がスレッドプールで実行され、IOは並列して動作します。コールバック関数はシングルスレッドで実行されますが、コールバック関数が実行されている間でもIO関数は並列して動作し続け、非同期IOになります。
CPU多コア化時代に向けて、多コアを生かした高速なIOができるサーバーを書きたいものの、マルチスレッドはデバッグが面倒、非同期IOはOSに依存する & 実行できない操作があって1つのイベントループで書けない など制約があります。そこで「ボトルネックはIOにあるだろう」という予想に基づいて、IOだけをスレッドプールで実行し、コールバック関数はシングルスレッドで実行します。パフォーマンスと生産性を両立するには、このあたりが妥当なのではないかと思っています。


このレイヤーはまだ手つかずです。




このライブラリを使って次期Partty!を書いているのですが、どうにも進まないのはActionScriptやWikiFormeもやっていたりするから?