36
33

C++erですがCOMに翻弄されています: 再入との戦い

Last updated at Posted at 2023-12-25

C++ Advent Calender

この記事はC++のカレンダー | Advent Calendar 2023 - Qiita の21日目の記事です。

はじめに

2021年に新卒で今の会社に入社して以来、ずっととある一つの製品の開発部門に所属していて、その中のWindowsチームというところにいます。

WindowsチームなのにiOSの要件が来たり、サーバーEoLの要件が来たりとなんだか特殊部隊感があるのですが、きっと気の所為です。

さて、ここまで入社以来ずっと本格的な理解から逃げ続けてきた存在があります。それがCOMです。

ところがついにこの秋から冬にかけての要件ではVisual Studioのバージョンアップを行うのに伴って、コアレイヤーに手を入れることが必要となり、COMとの戦いが幕を開けたのでした。

COMとは

Component Object Modelの略です。著しくgooglabilityが低くて困りますね!

COMの説明の前にC++に立ち返って考えてみます。C++には仮想関数があります。

#include <iostream>
#include <memory>
class IDisposable {
public:
    virtual void dispose() = 0;
};
class IExecutable {
public:
    virtual void Execute() = 0;
};
class Foo : public IDisposable, public IExecutable {
public:
    virtual void dispose() override { std::cout << "disposed" << std::endl; }
    virtual void Execute() override { std::cout << "executed" << std::endl; }
    virtual ~Foo() = default;
};
int main()
{
    std::shared_ptr<IDisposable> d = std::make_shared<Foo>();
    // do something
    d->dispose(); // => disposed
    const auto e = std::dynamic_pointer_cast<IExecutable>(d);
    if (!e) return 1;
    e->Execute(); // => executed
    const auto d2 = std::dynamic_pointer_cast<IDisposable>(e);
    if (!d2) return 1;
    d2->dispose(); // => disposed
}

さて、この仮想関数がどのようにして実装されるかというと、多くの処理系で、vtableを用います。

上の例に示したように、基底クラス間の間でもdynamic_castを用いることでキャストできます。IDisposabledに問い合わせてIExecutableを要求しeを得て、さらにeに問い合わせてIDisposabled2を得られているのがわかると思います。

COMでは純粋仮想関数で表されるインターフェースをより明確にクラスから分離した上で、vtableを一般化します。COMでは各オブジェクトが最低でもIUnknownというインターフェースを実装しています。

interface IUnknown
{
    virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObject) = 0;
    virtual ULONG STDMETHODCALLTYPE AddRef(void) = 0;
    virtual ULONG STDMETHODCALLTYPE Release(void) = 0;
};

dynamic_castに変わってQueryInterfaceを導入し、任意のインターフェスでのポインタをオブジェクトに要求するようになります。
また上の例ではオブジェクトの寿命をstd::shared_ptrに管理させましたが、COMではAddRef/Releaseを呼び出して対応します。またQueryInterfaceを呼び出すと新たなポインタを得るために参照カウントを1増やします。

ATL

インターフェースと実装を明確に切り離したのはいいのですが、COMオブジェクトを全部自力で実装するのはわりかし大変であることが知られています。そこで、古くからATLを用いてCOMオブジェクトを作ることが行われてきました。

ATL::CComPolyObjectとかATL::CComObjectなんかを利用して実装を行います。

私達のプログラムの概形

私達のプログラムはWindows Serviceとして作成されています。Windows Serviceなので、ユーザーがWindowsにログオンするよりも前から動きます。しかもその実行権限はやや特殊です。

いっぽうでWindows Serviceからユーザーに何かを通知したい、あるいはUIを提供したい、という時、Windows Serviceから直接はそういったことはできません。ユーザーセッションで行う必要があります。

ユーザーセッションとはなにか、簡単に知る方法として次のコマンドがあります。

PS C:\Users\yumetodo> query session
 セッション名      ユーザー名               ID  状態    種類        デバイス
 services                                    0  Disc
>console           yumetodo                  1  Active
 7a78855482a04...                        65536  Listen
 rdp-tcp                                 65537  Listen

これは実機でWindowsにログオンしている状態での結果です。session id: 1のconsoleセッションがいるのがわかると思います。

PS C:\Windows\system32> query session
 セッション名      ユーザー名               ID  状態    種類        デバイス
 services                                    0  Disc
                   tarou.yamada              3  Disc
                   ichiro.suzuki             5  Disc
 console                                    10  Conn
>rdp-tcp#0         yumetodo                 13  Active
 rdp-tcp#1         Administrator            40  Active
 rdp-tcp                                 65536  Listen

これはWindows Serverで複数人がRDPしている状態での結果です。session id: 13のrdp-tcp#0とsession id: 40のrdp-tcp#1がいるのがわかると思います。

すなわちユーザーセッションとは1つではありません。そこで、各ユーザーセッションごとに、プロセスを作って対処しています。

具体的には、sessin idとclsidからそれぞれセッションモニカとクラスモニカを作り、それをBindToObjectを用いてClassFactoryを生成し、起点となるCOMオブジェクトを各ユーザーセッションごとに生成しています。この時同時にプロセスも作られます。雰囲気としてはこんなコードです。

IUnknownPtr CreateInstanceInSession(DWORD sessionId, REFCLSID clsid)
{
	return CreateInstance( BindToObject( SessionMoniker( sessionId ) / ClassMoniker( clsid ) ), NULL );
}

ユーザーセッションはユーザーのログオンやログアウト、RDPの切断など様々な要因で変化します。そのため、WTS_SESSION_xxx系イベントや関係しそうな様々なイベントを契機として、次のMSDNをまねて適当なIIDでQueryInterfaceして反応を探るということをしています。

Checking the status of a COM server - ReuvenLax - Site Home - MSDN Blogs

/**
@brief リモートのオブジェクトとの通信が切断されていないことの確認を行う。

戻り値がいずれであれ、`obj`の参照カウントは変化しない。

@param[in] obj プロキシオブジェクト
@retval obj オブジェクトへの接続が有効である。
@retval nullptr オブジェクトへの接続が無効または不明である。
@sa http://blogs.msdn.com/b/reuvenlax/archive/2005/11/08/490565.aspx
@sa https://web.archive.org/web/20151016061936/http://blogs.msdn.com:80/b/reuvenlax/archive/2005/11/08/490565.aspx
*/
_Ret_maybenull_ IUnknown* PingRemoteObject(_In_opt_ IUnknown* obj)
{
	static const GUID IID_UNDEFINED = /*適当なIID*/;

	if (obj != nullptr)
	{
		IUnknownPtr unk;
		HRESULT hr = obj->QueryInterface(IID_UNDEFINED, reinterpret_cast<void**>(&unk));
		switch (hr)
		{
		case S_OK: __fallthrough;
		case E_NOINTERFACE: __fallthrough;
		case RPC_E_SERVERCALL_RETRYLATER:
			return obj;
		default:
			return nullptr;
		}
	}
	else
	{
		return nullptr;
	}
}

前哨戦

さて、COMとATLが多用されたプログラムのVisual Studioのバージョンアップを行っていたときに前哨戦となる問題は起こりました。というか実はすでにそれについては記事を書いてましたってのを今思い出しました。

で触れたDECLARE_POLY_AGGREGATABLEを指定した場合、対象クラスにDECLARE_PROTECT_FINAL_CONSTRUCTを指定しても効果は発揮されないために、::CoCreateInstance呼び出し時に呼ばれるATL::CComPolyObject::FinalConstructで自身のオブジェクトの参照カウントを増やすと次のようにDebug Assertion Failedが出るという問題を踏んでいました。

image.png

集約を使うのをやめてDECLARE_NOT_AGGREGATABLEにすることで解決を見ました

事象発生

そうして一息ついていた私達は、プログラムを長期間起動させていたところ、新たなトラブルに遭遇しました。

先の事象を踏んだCOMクラスは、各ユーザーセッションごとに生成する必要がありました。そしてCOMオブジェクトが生成されるときょうびあまり見なくなったツールバーを表示するという仕様になっています。またタスクトレイにアイコンを追加します。

長時間起動によって、このツールバーとタスクトレイアイコンが増殖していたのです。

image.png

(あくまでイメージ図であって、別にIMEを作ってるわけじゃないです)

再入

社内の有識者の解析の結果、どうも下のブログに解説があるような再入が悪いとわかりました。

STAのメソッド呼び出しを見てみる - イグトランスの頭の中

下に示しているのは先程

ユーザーセッションはユーザーのログオンやログアウト、RDPの切断など様々な要因で変化します。そのため、WTS_SESSION_xxx系イベントや関係しそうな様々なイベントを契機として、次のMSDNをまねて適当なIIDでQueryInterfaceして反応を探るということをしています。

といった処理の具体的なものです。わかりやすいようにいらないところを省いて、高度にリファクタされている箇所を書き下して原始的にしてあります。

class DECLSPEC_NOVTABLE UIAgentManager
// 略
{
	typedef boost::container::flat_map<DWORD, TypeLib::IUIAgentPtr> UIMap;
 	UIMap m_uiElement;
    void UpdateAgent(DWORD sessionId);
    void EraseOldElement();
// 略
};
void UIAgentManager::UpdateAgent(DWORD sessionId)
{
    TypeLib::IUIAgentPtr& agent = m_uiElement[sessionId];

    if (Com::PingRemoteObject(agent) != nullptr)
    {
        // do something
    }
    else
    {
        agent = Helper::CreateSessionObject<TypeLib::UIAgent>(sessionId);
        agent->Initialize(Something());
        // do something
    }
}
void UIAgentManager::EraseOldElement()
{
	for (UIMap::iterator it = m_uiElement.begin(); it != m_uiElement.end(); )
	{
		if (it->second == nullptr)
		{
			it = m_uiElement.erase(it);
		}
		else
		{
			++it;
		}
	}
}

ここでEraseOldElementは非常に様々な契機で呼び出されます。また、UIAgentManagerはSTAで動作しています。

m_uiElement[sessionId]TypeLib::IUIAgentPtrの参照を取得しています。そして、Helper::CreateSessionObject<TypeLib::UIAgent>(sessionId)の結果をそこに書きこんでいます。

このメソッドはSTAであり、Helper::CreateSessionObject<TypeLib::UIAgent>(sessionId);agent->Initialize(Something());は別のアパートメントのため、呼び出しの間メッセージループを回して待機します。

一方で同時に何らかのイベントを契機としてEraseOldElementが割り込むようにして呼び出されます。

  1. UpdateAgentでsession AのTypeLib::IUIAgentPtr参照を取得する
  2. そのポインタはnullptrであったために、UIAgentの生成をsession Aで行う。その間UpdateAgent側ではメッセージループを回す
  3. 何らかのイベントを受信する関数の呼び出しが割り込んでくる
  4. EraseOldElementが呼び出され、session AのTypeLib::IUIAgentPtrを確認するとnullptrであったために、m_uiElementから削除をおこなう。
  5. UIAgentの生成が終わりUpdateAgentに制御が戻ってくる。そして1で取得した参照に書き込もうとするがこれはm_uiElementの管理から外れた無効な参照である。ただしm_uiElementの予約しているメモリ空間であろうことからメモリアクセス違反とはならないと予想される。m_uiElementの管理から外れているということは、デストラクタを呼ばれる契機はないので、リークする。
  6. そのうちsession AでUIAgentの生成が再び行われm_uiElementに登録される

すなわち再入の恐ろしさとは、見た目には気が付きにくいところで勝手にメッセージループを回されてしまうがために勝手に別の処理が割り込んでくる、というところにあります。こんなの初見で気がつけてたまるかシリーズです。

再入との戦い

IMessageFilter → ボツ

再入と戦うに当たってまずは再入を防げないか考えます。

調べてみるとIMessageFilterを用いて、防ぐのが一般的なようです。すなわちクライアントに対して「今忙しいから待って!」ということができるようです。

STAのメソッド呼び出しを見てみる - イグトランスの頭の中

なお、STAではメソッド・ウィンドウメッセージの受け付けをIMessageFilterとCoRegisterMessageFilterである程度制御できます。

ただし「ある程度」と書かれているのが不穏なところです。

他にも下のブログに紹介があるように、どうにもあまり信頼できる手法には見えません。

STA の難しさ

ただ IMessageFilter にも落とし穴があって、「今忙しいからちょっと待って!」と言われたクライアントはどうするかというと、MFC であれば上記のリンク先にあるビジー状態のダイアログを出します。

しかし IMessageFilter に対して特に何もしていない場合は、サーバが「今忙しいからちょっと待って!」と言っているにもかかわらず RPC_E_CALL_REJECTED というエラーを返します。

これは IMessageFilter::RetryRejectedCall のデフォルトの処理がエラーを返すようになっているのが原因なので、クライアント側もちゃんと IMessageFilter を導入し、::CoRegisterMessageFilter で登録してやる必要があります。

MTAにする → 見送り

そもそもMTAにすれば再入は発生しません。すなわち普通のマルチスレッドプログラミング同様、mutexでlockを取ればいいわけです。

ただこれはいろいろ考慮することが増えるので、時間もないので見送りとしました。

そうだ、再入されても大丈夫なようにしよう!

今回の問題は無効となった領域への参照に書き込みをしようとするからboost::container::flat_mapの管理から外れてしまうのが問題なのでした。

というわけで、そもそも参照を取得しなければ再入されても大丈夫じゃないの、ということができます。

TypeLib::IUIAgentPtr UIAgentManager::CreateAgent(DWORD sessionId)
{
	TypeLib::IUIAgentPtr agent = Helper::CreateSessionObject<TypeLib::UIAgent>(m_wts, sessionId);
	agent->Initialize(Something());

	return agent;
}
void UIAgentManager::UpdateAgent(DWORD sessionId)
{
	// この関数の途中でコンテキストスイッチしたときに参照先が消えることがあるので参照を取得してはならない
	// TypeLib::IUIAgentPtr& agent = m_uiElement[sessionId];

	if (Com::PingRemoteObject(m_uiElement[sessionId]) != nullptr)
	{
		//do something
	}
	else
	{
		m_uiElement[sessionId] = CreateAgent(sessionId);
	}
}

DECLARE_NOT_AGGREGATABLEではなくDECLARE_CLASSFACTORY_SINGLETONを用いる

そもそもUIAgentはセッションごとに1つあればいいのでした。ということはDECLARE_CLASSFACTORY_SINGLETONを使えばいいのではないかという指摘が、社内の有識者よりなされました。

ATL object map / singleton creation

でもちょっとまってください。もともと前哨戦の結果DECLARE_NOT_AGGREGATABLEを使うようにしたはずです。それを変えても大丈夫でしょうか?

この時は DECLARE_NOT_AGGREGATABLEが無かったことで ATL::CComPolyObject だったため、InternalFinalConstructRelease が呼び出されずに参照カウンタの警告が発生していたのでした。

atlcom.h

DECLARE_CLASSFACTORY_SINGLETON

#define DECLARE_CLASSFACTORY_SINGLETON(obj) DECLARE_CLASSFACTORY_EX(ATL::CComClassFactorySingleton<obj>)

DECLARE_CLASSFACTORY_EX

#define DECLARE_CLASSFACTORY_EX(cf) typedef ATL::CComCreator< ATL::CComObjectCached< cf > > _ClassFactoryCreatorClass;

ATL::CComCreator

template <class T1>
class CComCreator
{
public:
	static HRESULT WINAPI CreateInstance(
		_In_opt_ void* pv, 
		_In_ REFIID riid, 
		_COM_Outptr_ LPVOID* ppv)
	{
		ATLASSERT(ppv != NULL);
		if (ppv == NULL)
			return E_POINTER;
		*ppv = NULL;

		HRESULT hRes = E_OUTOFMEMORY;
		T1* p = NULL;

ATLPREFAST_SUPPRESS(6014 28197)
		/* prefast noise VSW 489981 */
		ATLTRY(p = _ATL_NEW T1(pv))
ATLPREFAST_UNSUPPRESS()

		if (p != NULL)
		{
			p->SetVoid(pv);
			p->InternalFinalConstructAddRef();
			hRes = p->_AtlInitialConstruct();
			if (SUCCEEDED(hRes))
				hRes = p->FinalConstruct();
			if (SUCCEEDED(hRes))
				hRes = p->_AtlFinalConstruct();
			p->InternalFinalConstructRelease();
			if (hRes == S_OK)
			{
				hRes = p->QueryInterface(riid, ppv);
				_Analysis_assume_(hRes == S_OK || FAILED(hRes));
			}
			if (hRes != S_OK)
				delete p;
		}
		return hRes;
	}
};

DECLARE_CLASSFACTORY_SINGLETON を指定しても InternalFinalConstructRelease() が呼び出されることがわかります。
よって DECLARE_NOT_AGGREGATABLE から DECLARE_CLASSFACTORY_SINGLETON への変更でもデグレはしていないと考えることができます。

(一敗) P0145R3実装前は式の評価順序が不定だった

m_uiElement[sessionId] = CreateAgent(sessionId);という式はm_uiElement[sessionId]operator[]呼び出しとCreateAgent呼び出し、そしてoperator=の呼び出しの3要素で構成されていますが、これはどういう順番になるかが規定されたのはC++17のことでした。残念ながら今回はまだ実装されていなかったのでこうなりました。

-		m_uiElement[sessionId] = CreateAgent(sessionId);
+		m_uiElement.insert_or_assign(sessionId, CreateAgent(sessionId));

(二敗)boost::container::flat_mapではイテレータループ中に再入が起きたときに問題となる

次のサンプルコードを見てください。擬似的に再入を体験するために途中でループをぶった切ってます。

#include <unordered_map>
#include <boost/container/flat_map.hpp>
#include <iostream>
#include <string>
template<typename Map>
void on_map()
{
    Map map = {
        { 2, "arikitari" },
        { 7, "na" },
        { 51, "sekai" },
        { 21, "arikitari" },
        { 73, "na" },
        { 63, "sekai" },
        { 13, "arikitari" },
        { 27, "na" },
        { 12, "sekai" },
    };
    auto it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    std::cout<< map[55] << std::endl;
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
}
int main()
{
    on_map<std::unordered_map<int, std::string>>();
    std::cout << "-----------" << std::endl;
    on_map<boost::container::flat_map<int, std::string>>();
}

27,na
13,arikitari
63,sekai

73,na
21,arikitari
12,sekai
51,sekai
7,na
2,arikitari
-----------
2,arikitari
7,na
12,sekai

13,
21,
27,
51,
63,
73,

boost::container::flat_mapではイテレータを回している最中にinsertが走るとイテレータが無効となってしまうようです。というわけでm_uiElementにはstd::unordered_mapを使うべきでした。

(三敗)std::unordered_mapもrehashされるとバグる。

というわけでm_uiElementにはstd::unordered_mapを使うべきでした。

と書いたな、アレは嘘だっ!次のサンプルコードを見てください。

#include <unordered_map>
#include <iostream>
#include <string>
template<typename K, typename V>
void print_bucket(const std::unordered_map<K, V>& map)
{
    constexpr const char* s = "[,";
    std::cout << "bucket::" << map.bucket_count() << ':';
    for(std::size_t i = 0; i < map.bucket_count(); ++ i) {
        std::cout << s[i != 0] << map.bucket_size(i);
    }
    std::cout << ']' << std::endl;
}
void on_map()
{
    using Map = std::unordered_map<int, std::string>;
    Map map = {
        { 2, "arikitari" },
        { 7, "na" },
        { 51, "sekai" },
        { 21, "arikitari" },
        { 73, "na" },
        { 63, "sekai" },
        { 13, "arikitari" },
        { 27, "na" },
        { 12, "sekai" },
    };
    auto it = map.begin();
    for(auto&& [k, v] : map) std::cout << k << ',' << v << std::endl;
    std::cout << "-----------" << std::endl;
    it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    const auto bc = map.bucket_count();
    print_bucket(map);
    std::cout<< map[55] << std::endl;
    for(int i = 100; map.bucket_count() == bc; ++i) map[i];
    print_bucket(map);
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
}
int main()
{
    on_map();
}

27,na
13,arikitari
63,sekai
73,na
21,arikitari
12,sekai
51,sekai
7,na
2,arikitari
-----------
27,na
13,arikitari
63,sekai
bucket::13:[1,1,1,0,0,0,0,1,2,0,0,1,2]

bucket::29:[0,0,1,0,0,1,0,1,0,0,0,0,1,2,1,2,1,0,0,0,0,1,1,0,0,0,1,1,0]
73,na
102,
27,na
55,
13,arikitari
100,
101,

unordered_mapは要素が増えていってbucketの大きさを超えるとrehashします。このときイテレータは死にます。

というわけでm_uiElementにはstd::mapを使うべきでした。

(四敗)そもそもイテレータループ中に要素削除が起こるとstd::mapといえど死ぬ

#include <map>
#include <iostream>
#include <string>
int main()
{
    std::map<int, std::string> map = {
        { 2, "arikitari" },
        { 7, "na" },
        { 51, "sekai" },
        { 21, "arikitari" },
        { 73, "na" },
        { 63, "sekai" },
        { 13, "arikitari" },
        { 27, "na" },
        { 12, "sekai" },
    };
    auto it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    map[51] = {};
    std::cout<< map[51] << std::endl;
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }

    std::cout << "--------------" << std::endl;
    it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    std::cout<< map[55] << std::endl;
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    std::cout << "--------------" << std::endl;
    it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    map.erase(27);
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    std::cout << "--------------" << std::endl;
    it = map.begin();
    for (int i = 0; i < 3; ++i, ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
    map.erase(13);
    std::cout<< map[13] << std::endl;
    for (; it != map.end(); ++it) {
        std::cout << it->first << ',' << it->second << std::endl;
    }
}

2,arikitari
7,na
12,sekai

13,arikitari
21,arikitari
27,na
51,
63,sekai
73,na
--------------
2,arikitari
7,na
12,sekai

13,arikitari
21,arikitari
27,na
51,
55,
63,sekai
73,na
--------------
2,arikitari
7,na
12,sekai
13,arikitari
21,arikitari
51,
55,
63,sekai
73,na
--------------
2,arikitari
7,na
12,sekai

/opt/wandbox/gcc-13.2.0/include/c++/13.2.0/debug/safe_iterator.h:472:
In function:
    bool gnu_debug::operator==(const 
    _Safe_iterator<std::_Rb_tree_iterator<std::pair<const int, std::
    cxx11::basic_string<char> > >, std::debug::map<int, std::
    cxx11::basic_string<char> >, std::forward_iterator_tag>::_Self&, const 
    _Safe_iterator<std::_Rb_tree_iterator<std::pair<const int, std::
    cxx11::basic_string<char> > >, std::debug::map<int, std::
    cxx11::basic_string<char> >, std::forward_iterator_tag>::_Self&)

Error: attempt to compare a singular iterator to a past-the-end iterator.

Objects involved in the operation:
    iterator "lhs" @ 0x7fff8c0c3dd0 {
      type = std::_Rb_tree_iterator<std::pair<int const, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > (mutable iterator);
      state = singular;
      references sequence with type 'std::debug::map<int, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::less<int>, std::allocator<std::pair<int const, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > >' @ 0x7fff8c0c3e00
    }
    iterator "rhs" @ 0x7fff8c0c4110 {
      type = std::_Rb_tree_iterator<std::pair<int const, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > (mutable iterator);
      state = past-the-end;
      references sequence with type 'std::debug::map<int, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::less<int>, std::allocator<std::pair<int const, std::cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > >' @ 0x7fff8c0c3e00
    }

それはそうみたいな話ですが、挿入にはめっぽう強いstd::mapも削除の前には無力です。

というわけでそもそもイテレータループを回したいときはイテレータループ中での再入を避けるために、予めmapそのものをコピーして、それのループを回すべきでした。

終わりに

再入というのは実に厄介な事象で、特に今回のように真っ向から再入を許容しながら抗う場合には極めて苦しい戦いを強いられます。

多分MTAにしたほうが遠回りでも安心安全だと思います、まる。

あとやはりCOMは人類には早すぎたのではないかと思います。とても辛いです。上に書いたことも多分に知ったかぶりが紛れています。

補足説明

現在進行系でバグと戦っていたときにこの記事を書いたので、だいぶ説明が投げやりでした。なのでもうすこし補足説明をします。

COMを今から学ぶ難しさ

現代、0からCOMを学ぶのは難しくなっています。現役でCOMを書いていた人たちのうち、その殆どはCOMからすでに足を洗っています。DirectXを触るようなゲーム業界の一部ならCOMを学ぶ環境があるのかもしれませんが、そうではない場合、多くは当時発売されたCOMとATLに関する書籍を図書館で探して、あるいは他館回送してもらって、熟読することとなろうかと思います。あるいは偶にAmazonなどで出てくる中古の本を手に入れることでしょうか(自分はこっちでした)。

ネットでの解説は頑張って探せばあります。必読となるのは以下ではないでしょうか。

COMをC++から扱うときに欠かせないのがATLですが、ATLについて学習するのはそれこそ困難を伴うと感じています。今回COMを学ぶに当たっては、なるべくシンプルなCOMオブジェクトをATLで作って、それを::CoCreateInstanceで呼び出してみたり、ひたすらATLの内部実装を読んだり、すでにあるコードをデバッガーでステップ実行して追いかけたりしていましたが、もうすこし体系的な学習手段がほしいところです。今度中古本を探してみようと思います。

再入についての補足

STAにおいて、別のアパートメント(例えば別のスレッドとか別のプロセスとか)のCOMオブジェクトのメソッドを呼び出すことを考えます。

このとき、呼び出し中は処理をブロッキングしてしまうとまずいわけです。例えば自身のアパートメントがGUIを表示させていたとして、ボタンのクリックとかそういうのに応答できないとフリーズしていているように見えてしまいますよね。

そこでSTAにおいては、別のアパートメントのメソッドを呼び出している間、メッセージループを回します。GetMessageしてTranslateMessageしてDispatchMessageしているあれを思い浮かべればいいと思います。

    MSG msg;
    for (;;)
    {
      int ret = GetMessage(&msg, nullptr, 0, 0);
      if (ret == 0 || ret == -1)
        break;
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }

COMのアパートメント (6) アパートメントの種類はどのように決まるのか – kekyoの丼

すると何が起きるかというと、ある種のコンテキストスイッチが起こるわけです。まるでコルーチンみたいですよね。つまり、呼び出し元の処理は一旦中断されて、他の処理を受け付け、呼び出しが終わったことが通知されると、他の処理からメッセージループに遷移したときに再び処理を再開するわけです。

すなわち、シングルスレッドだけれども、シーケンシャルに処理されないことがあるわけです。

ここで他の処理と自身の処理で同一の状態を参照している、具体的には同じ変数に対して操作をしていると、まるでマルチスレッドプログラミングにおける競合状態に対する考慮と同様にことが必要になるわけです。それがまさにこの記事で戦った相手です。

もちろんシングルスレッドなので同じ変数に対して操作していても、直ちにそれが問題にはなりません。しかし、相互に及ぼす影響は整理しなければなりません。

ここで厄介なのはマルチスレッドプログラミングにおいて通用する「とりあえずmutexでロックを確実に取る」ができないということです。なぜならば自身の処理をブロッキングした瞬間、シングルスレッドであるがゆえに、それを抜け出す契機はないからです。

再入に対する対策の選択肢

今回はやりませんでしたが、再入に対する対策の第一選択肢は、STAを捨てることであるべきです。すなわちMTAにするということです。こうすることで、単なるマルチスレッドプログラミングとなり、「とりあえずmutexでロックを確実に取る」を行うだけでよくなります。

第二選択肢は処理をリトライすることだと思います。つまり、SetWaitableTimerなどを用いて、自身の処理を再び呼び出すタイマーを0秒後に設定します。タイマーからのシグナルを受け取るのは別のスレッドでしょうが、そこから自身の処理を呼び出すと、それはSTAのメソッド呼び出しですから、メッセージループへの通知となります。

なぜリトライを選ばなかったか

じつは今回の問題に対して社内の有識者から示された改修方針の第一案はこれでした。すなわち、boolフラグをmutexに見立ててm_uiElementに触る前にtry_lockしだめなら即時発火のタイマーでリトライをかけることでした。タイマーをかませることでその間に再びCOMの再入を誘発させてロックが外れることを期待します。ただタイマーはそれ自体バグを生みやすいものであることから一旦却下しました。

かわりに、EraseOldElementがあちこちから呼ばれるのはそもそも問題を悪化させていたので、一定時間おきのタイマー契機のみでEraseOldElement + UpdateAgentを1ループで回すように書き換えました(下記ReconnectUIProcess)。そこ以外では要素削除をしないで、当該saessinIdの要素にnullptr代入だけにしていました。削除契機を1つに絞り込めば大丈夫だろうと考えたわけです。

void UIAgentManager::ReconnectUIProcess()
{
	for (UIMap::iterator it = m_uiElement.begin(); it != m_uiElement.end(); )
	{
        const auto sessionId = it->first;
        try {
			if (it->second == nullptr)
			{
				it = m_uiElement.erase(it);
				continue;
			}
			if (Com::PingRemoteObject(it->second) == nullptr)
			{
				it->second = nullptr; // CreateAgentが失敗してもいいようにまず空にする
				it->second = CreateAgent(sessionId);
			}
		}
		catch (const std::exception& er) {
			// logging...
		}
		++it;
	}
}

it->secondはユーザーセッションで動くCOMオブジェクトのポインタ(_com_ptr_t)ですが、nullptrということはそのオブジェクトは開放されているわけです(他で参照カウントを増やしてない限り)。ならば削除契機を絞り込んでも、瞬間的に多少m_uiElementが肥大化するくらいで済むというわけです。

三敗目では、m_uiElementに対するイテレータループがそれまでEraseOldElementを呼び出していたのと同じだけたくさんあったことに問題がありました。すなわち削除契機を1契機に絞り込んでもなおイテレータループが壊れうるわけです。なのでstd::mapなら削除でも壊れないと思い込んだのです。しかしそんなはずもなく4敗目に至りました。

こうして再び第一案を検討し始めますが、すぐにリトライに至るケースが多すぎることに頭を抱えます。せっかく削除契機を1契機に絞り込んだのにもったいないです。

そこで考えた第二案はシングルスレッドのRW lockもどきを作って同様に対処することでした。削除契機は大幅削減したので、大抵のケースではロックを取得できるはずです。
しかしながら実装が複雑になる割に第一案の問題を解決しきれません。よって却下しました。

とはいえどうしたものかと、なおも第一、第二案でぐるぐると悩むこと2日、そもそもm_uiElementはユーザーセッションに対応する辞書であることを思い出します。

じつはWindows Serverは開発している製品のサポート対象外でした(少し前までWindows Server2012/R2をサポートしていた)。Server版ではないWindowsではユーザーセッションの数は定数であり十分に少ない(というか1つ程度)です。要素型も_com_ptr_tなのでコピーコストは別に避けなくていいということです

かくして

予めmapそのものをコピーして、それのループを回すべきでした。

となったのでした。

ただ今にして思うと更に別の選択肢があるのではないかと思えてきました。

第二案のRW lockもどきの亜種で、それぞれのイテレータループに入るときにフラグを立てておき、ReconnectUIProcessif (it->second == nullptr)の分岐内でそのフラグをみてフラグが立ってたら削除をスキップするというものです。ReconnectUIProcessはタイマー契機で何度も呼ばれるので、いつかはスキップされずに削除が行われるでしょうから、m_uiElementの肥大化も問題にはならないわけです。

もしWindows Serverをサポート対象OSとする場合、一台のPCに数十ユーザーが頻繁にRDP経由でログインしてくる、なんてケースも考えられますので、m_uiElementはコピーするにはすこし気になるくらい大きくなる可能性もあるかもしれないので、この案のほうが良かったかもしれませんが、まあどうせServerOSはサポートしないのでそこまでしなくていいでしょう。

続編

2024年の今、いかにしてVS2005を捨ててVS2015にする戦いは終わったか、そしてなぜCOMとの苦しい戦いが繰り広げられたか ~再入の悪魔~ - OPTiM TECH BLOG

36
33
2

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
36
33