ObjectSquare [2009 年 10 月号]

[技術講座]


3.boost1.39.0における問題と、その解析


株式会社 オージス総研
組み込みソリューション部
近藤 貴俊

目次

1. 現象の再確認
2. 仮説ベースでの原因の絞り込み
3. 現象を簡潔に表現するコードの作成
4. デバッガによるライブラリ内部の解析
5. まとめ

 1.現象の再確認

前回の記事では、クラスDownloadContentを、クラスAbstractContent経由で、シリアライズした後、デシリアライズしてから、メンバ関数を呼び出すと異常終了してしまうことを確認しました。クラスDownloadContentは、クラスDownloadObjectとクラスAbstractContentをこの順で多重継承していますが、多重継承順序を逆にすると、なぜか現象が発生しなくなることも確認しました(このあたりを参照)。

まずは簡単に、異常終了が発生するケースにおいて、シリアライズ前のオブジェクトとシリアライズ後のオブジェクトの状態をデバッガで確認してみました。

現象発生(シリアライズ前)

現象発生(シリアライズ前)

現象発生(シリアライズ後)

現象発生(シリアライズ後)

内容を確認したところ、シリアライズ後のオブジェクトの内容がシリアライズ前と異なっており、しかも、内容が破壊されているようです。破壊の内容を、詳細に解析すると、仮想関数テーブルへのポインタ、__vfptrの位置がずれてしまっているようです。

現象発生(仮想関数テーブルのずれ)

同様に、多重継承順序を逆にした場合のシリアライズ前のオブジェクトとシリアライズ後のオブジェクトの状態をデバッガで確認してみました。

多重継承順序逆(シリアライズ前)

多重継承順序逆(シリアライズ前)

多重継承順序逆(シリアライズ後)

多重継承順序逆(シリアライズ後)

内容を確認したところ、シリアライズ後のオブジェクトの内容がシリアライズ前と一致するため、正しくデシリアライズされていると推測できます。

 2. 仮説ベースでの原因の絞り込み

さて、ここまで解析したところで、どうやら仮想関数テーブルへのポインタのずれが、異常終了の原因となっていること、さらに、その現象が発生するのは、多重継承の2番目以降の親クラスを経由してデシリアライズを行った場合であるらしいことが分かりました。

さらに、weak_ptrをシリアライズ/デシリアライズするケースとしないケースでの比較も行いました。その結果、weak_ptrをシリアライズ/デシリアライズした場合にのみ、本現象が発生することが分かりました。ちなみに、weak_ptrをシリアライズ/デシリアライズしないアプローチは、コラムで取り上げた、enable_shared_from_thisを用いることで実現しました。ソースは、srz_abscontent_esft.cppです。

これらの現象から、自己参照weak_ptrを持つクラスを、多重継承している2番目以降の親クラス経由で、シリアライズ後、デシリアライズすると、正常にデシリアライズされない、という仮説を立てました。

 3. 現象を簡潔に表現するコードの作成

これらの仮説を確かめるために、可能な限りシンプルにした現象再現コードを作成します。

#include <cassert>
#include <fstream>
#include <boost/shared_ptr.hpp>
#include <boost/weak_ptr.hpp>
#include <boost/serialization/serialization.hpp>
#include <boost/archive/xml_oarchive.hpp>
#include <boost/archive/xml_iarchive.hpp>
#include <boost/serialization/nvp.hpp>
#include <boost/serialization/export.hpp>
#include <boost/serialization/shared_ptr.hpp>
#include <boost/serialization/weak_ptr.hpp>

struct Base1 {
    virtual ~Base1() {}
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive & /*ar*/, const unsigned int /* file_version */) {}
};

struct Base2 {
    virtual ~Base2() {}
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive & /*ar*/, const unsigned int /* file_version */) {}
};

struct Sub:public Base1, public Base2 {
    virtual ~Sub() {}
    boost::weak_ptr<Sub> wp_;

    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */)
    {
        ar & BOOST_SERIALIZATION_BASE_OBJECT_NVP(Base1);
        ar & BOOST_SERIALIZATION_BASE_OBJECT_NVP(Base2);
        ar & BOOST_SERIALIZATION_NVP(wp_); // *1
    }
};

BOOST_CLASS_EXPORT(Sub)

int main()
{
    {
        // serialize
        boost::shared_ptr<Sub> s(new Sub);
        boost::shared_ptr<Base2> pb2(s);
        s->wp_ = s; // set weak_ptr. If not set, deserialize success.
        std::ofstream ofs("output.xml");
        assert(ofs);
        boost::archive::xml_oarchive oa(ofs);
        oa << boost::serialization::make_nvp("Base2", pb2);
    }
    {
        // de-serialize
        std::ifstream ifs("output.xml");
        assert(ifs);
        boost::archive::xml_iarchive ia(ifs);
        boost::shared_ptr<Base2> pb2;
        ia >> boost::serialization::make_nvp("Base2", pb2);
        // check
        // pb2's vptr is broken.
        assert(dynamic_cast<Sub *>(pb2.get()));
    }
}

このコードは、シンプルに、2つのベースクラスをそれぞれ、Base1、Base2とし、これらを多重継承する派生クラスをSubとしています。クラスSubは、自身を指すweak_ptr型のメンバ変数 wp_ を持ちます。そして、Base2のshared_ptrを経由して、シリアライズ、デシリアライズを行います。Boostライブラリに関するバグを報告する場合、このようなシンプルな現象再現コードを添付することが推奨されます。なお、このコードは、sp_mlt_base.cppからダウンロード可能です。

さて、このコードを実行すると、正常動作しませんでした。これは推測通りの振る舞いです。具体的には、コード最後のassertが失敗します。このassertでは、親クラス経由でデシリアライズしたクラスを、元の(子の)クラスに戻せるかどうかをdynamic_castでテストしています。

 4. デバッガによるライブラリ内部の解析

シンプルな現象再現コードも準備できたので、いよいよ、ライブラリの中で何が発生しているのか、そしてなぜこの現象に至るのかを解析していきます。

シリアライズ結果のファイル、output.xmlを見る限り、シリアライズは正常に行われているようです。では、デシリアライズを疑ってみましょう。これまでの仮説から、weak_ptrに関連するところが怪しそうです。というわけで、

        ar & BOOST_SERIALIZATION_NVP(wp_); // *1

にブレークポイントを張って、前後の振る舞いを確認してみます。まず、このブレークポイントは、シリアライズ時とデシリアライズ時にそれぞれ呼ばれます。これは、&演算子のオーバーロードによって、saveとloadに処理を振り分けているためです。問題となるのはデシリアライズとなるケースなので、2回目にブレークポイントに引っかかったタイミングから解析します。ブレークポイントに引っかかったら、バックトレースを確認します。すると、こんな感じ(main関数からブレークポイントの位置まで、上に行くほど最近の呼び出し)なので、逐一説明するのは相当大変です。そこで、今回の問題に関係するところだけピックアップして説明します。

この段階では、まだ、クラスSubのインスタンスを、親クラスBase2経由で保持するshared_ptrはデシリアライズ開始はしていますが、完了はしていません。完了前に、クラスSubのweak_ptrメンバ変数wp_がデシリアライズされることになります。しかし、weak_ptrはその性質上、(オブジェクトを指し示している場合は)shaerd_ptr無しで存在できません。そこで、boost::serializationライブラリでは、weak_ptrをいったんshared_ptrとしてデシリアライズし、それを元にweak_ptrを作り出しています。以下に示す関数テンプレートがその部分です。ちなみに、先ほどのブレークポイントから、下記のコードの部分までに16段の関数呼び出しがあります(多くの部分は、インライン展開されます)。

boost/serialization/weak_ptr.hpp (version 1.39.0)

template<class Archive, class T>
inline void load(
    Archive & ar,
    boost::weak_ptr<T> &t,
    const unsigned int /* file_version */
){
    boost::shared_ptr<T> sp;
        ar >> boost::serialization::make_nvp("shared_ptr", sp);
    t = sp;
}

まず、1行目でshared_ptrを定義し、2行目で、アーカイブからの情報を元にshared_ptrをデシリアライズし、3行目で、weak_ptrにshared_ptrを代入することで、weak_ptrを設定しています。shared_ptr型の変数spは、この関数終了と共に消滅してしまいます。これでは、weak_ptrが無効化されてしまいます。そうならないように、2行目の処理の内部で、shared_ptrを一時的に保持する仕組みが実現されています。この仕組みは、weak_ptrを有効に保つ目的だけでなく、複数のshaerd_ptrやweak_ptrが同一オブジェクトを指すようにする目的でも利用されます。

2行目のshared_ptrのデシリアライズでは、以下の関数テンプレートが呼び出されます。

boost/serialization/shared_ptr.hpp (version 1.39.0) 改行は筆者

template<class Archive, class T>
inline void load(
    Archive & ar,
    boost::shared_ptr<T> &t,
    const unsigned int file_version
){
    // The most common cause of trapping here would be serializing
    // something like shared_ptr<int>.  This occurs because int
    // is never tracked by default.  Wrap int in a trackable type
    BOOST_STATIC_ASSERT((tracking_level<T>::value != track_never));
    T* r;
    #ifdef BOOST_SERIALIZATION_SHARED_PTR_132_HPP
    if(file_version < 1){
        //ar.register_type(static_cast<
        //    boost_132::detail::sp_counted_base_impl<T *, 
        //    boost::checked_deleter<T> > *
        //>(NULL));
        ar.register_type(static_cast<
            boost_132::detail::sp_counted_base_impl<T *, 
            boost::archive::detail::null_deleter > *
        >(NULL));
        boost_132::shared_ptr<T> sp;
        ar >> boost::serialization::make_nvp("px", sp.px);
        ar >> boost::serialization::make_nvp("pn", sp.pn);
        // got to keep the sps around so the sp.pns don't disappear
        ar.append(sp);
        r = sp.get();
    }
    else    
    #endif
    {
        ar >> boost::serialization::make_nvp("px", r);
    }
    ar.reset(t,r);
}

ポイントは最後の行です。引数arのメンバ関数resetが呼び出されてます。ここで、arは、テンプレートパラメタArchiveですが、実際はクラスxml_iarchiveです。そして、xml_iarchiveの定義は、以下のように、クラスshared_ptr_helperを継承しています。

boost/archive/xml_iarchive.hpp (version 1.39.0)

class xml_iarchive : 
    public xml_iarchive_impl<xml_iarchive>,
    public detail::shared_ptr_helper
{
public:
    xml_iarchive(std::istream & is, unsigned int flags = 0) :
        xml_iarchive_impl<xml_iarchive>(is, flags)
    {}
    ~xml_iarchive(){};
};

そして、クラスshared_ptr_helperは、以下に示すように、メンバ関数テンプレートresetを持っています。これが呼び出されているわけです。

boost/archive/shared_ptr_helper.hpp (version 1.39.0)

class shared_ptr_helper {
    typedef std::map<const void *, shared_ptr<void> > collection_type;
    typedef collection_type::const_iterator iterator_type;
    // list of shared_pointers create accessable by raw pointer. This
    // is used to "match up" shared pointers loaded at different
    // points in the archive. Note, we delay construction until
    // it is actually used since this is by default included as
    // a "mix-in" even if shared_ptr isn't used.
    collection_type * m_pointers;

// 略

    // return a void pointer to the most derived type
    template<class T>
    const void * object_identifier(T * t) const {
        const boost::serialization::extended_type_info * true_type 
            = boost::serialization::type_info_implementation<T>::type
                ::get_const_instance().get_derived_extended_type_info(*t);
        // note:if this exception is thrown, be sure that derived pointer
        // is either registered or exported.
        if(NULL == true_type)
            boost::serialization::throw_exception(
                boost::archive::archive_exception(
                    boost::archive::archive_exception::unregistered_class
                )
            );
        const boost::serialization::extended_type_info * this_type
            = & boost::serialization::type_info_implementation<T>::type
                    ::get_const_instance();
        const void * vp = void_downcast(
            *true_type, 
            *this_type, 
            static_cast<const void *>(t)
        );
        return vp;
    }
public:
    template<class T>
    void reset(shared_ptr<T> & s, T * r){
        if(NULL == r){
            s.reset();
            return;
        }
        // get pointer to the most derived object.  This is effectively
        // the object identifer
        const void * od = object_identifier(r);

        if(NULL == m_pointers)
            m_pointers = new collection_type;

        iterator_type it = m_pointers->find(od);

        if(it == m_pointers->end()){
            s.reset(r);
            m_pointers->insert(collection_type::value_type(od,s));
        }
        else{
            s = static_pointer_cast<T>((*it).second);
        }
    }
    // 略
};

このresetの中では、object_identifier メンバ関数テンプレートによって、T型のポインタ r から、実際のインスタンスの型の void ポインタを取得して変数 od に格納しています。このケースでは、いずれもクラスSubのポインタとなります。この od をキーに、コレクション m_pointers から既に登録済みの shared_ptr があるか検索します。m_pointers は、キーがconst void * 値がshared_ptr<void>の map です。今回は初めての reset 呼び出しのため、od をキーとした検索にヒットしません。そこで、引数 r をshared_ptr型の引数 s に設定します。また、od と s のペアをコレクション m_pointers に追加しています。この際、s は void の shared_ptr型として格納されます。これは任意の型の shared_ptr が入り交じる形で格納する必要があるためです。ここまでの流れを下図に示します。

reset処理の様子(weak_ptrデシリアライズ時)

ここまでは、うまく動作しています。shared_ptr が正しく記憶され、この関数を抜けた先で、weak_ptr も正しく設定されます。次に、クラスBase2経由でのshared_ptrのデシリアライズの続きの処理が行われます。この中でも、同様に先ほどのresetが呼び出されます。今回は、既に対応するshared_ptrがm_pointersに登録済みの状況となるので、処理の流れが、先ほどとは異なります。下図に示すように、odをキーに、m_pointersを検索した結果、対応する要素が見つかるため、そのvalueを取得します。(*it).secondとして参照できます。これを元に、引数 s を設定します。

reset処理の様子(shared_ptrデシリアライズ時)

ここで、問題が発生します。valueの型はshared_ptr<void>ですが、実際の型はshared_ptr<Sub>です。これを、以下のようにキャストして、引数 s に代入しています。テンプレートパラメタ T の型は Base2 です。

            s = static_pointer_cast<T>((*it).second);

クラスBase2のポインタで、クラスSubのオブジェクトを指す場合は、本来のオブジェクトのレイアウトよりも、deltaだけ進んだ位置を指すように調整されます。しかし、今回のケースでは、クラスBase2のポインタに代入するのが、void* となります(実際は、クラスBase2を継承しているクラスSubのインスタンスなのですが、その情報は失われています)。そのため、下図の赤い点線で示すように何の調整もなされず、void*指すインスタンスは、純粋に、クラスBase2のポインタと見なされます。

static_ptr_castによるずれ

これが、デシリアライズ時に、仮想関数テーブルへのポインタのずれが発生している原因です。もし、多重継承順序が逆ならば、ずれは、発生しないことになり、現象の説明もつきます。

 5.まとめ

問題を整理しましょう。

これが、今起こっていることです。苦労自慢のようになってしまいますが、ここまで解析を進めるのは、なかなか大変でした。問題の原因となっているresetの部分でブレークをかけたときのバックトレースは、このように71段にも及びます。また、boost::mplといったテンプレートメタプログラミングを支援するライブラリも多用されています。しかし、そのおかげで、リフレクションの無いC++でも、ここまでのシリアライズが実現できているわけです。

次回は、この問題をどうやって解決したのかについて、説明したいと思います。


© 2009 OGIS-RI Co., Ltd.
Prev Index Next
Prev Index Next