C++ Advent Calender
この記事はC++のカレンダー | Advent Calendar 2023 - Qiita の21日目の記事です。
- 20日目: Try to make a try ! by @wx257osn2
- 22日目: C++ コンパイル時「出力」で画像ファイル生成 #C++ - Qiita by @Raclamusi
はじめに
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を用いることでキャストできます。IDisposable
なd
に問い合わせてIExecutable
を要求しe
を得て、さらにe
に問い合わせてIDisposable
なd2
を得られているのがわかると思います。
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が出るという問題を踏んでいました。
集約を使うのをやめてDECLARE_NOT_AGGREGATABLE
にすることで解決を見ました
事象発生
そうして一息ついていた私達は、プログラムを長期間起動させていたところ、新たなトラブルに遭遇しました。
先の事象を踏んだCOMクラスは、各ユーザーセッションごとに生成する必要がありました。そしてCOMオブジェクトが生成されるときょうびあまり見なくなったツールバーを表示するという仕様になっています。またタスクトレイにアイコンを追加します。
長時間起動によって、このツールバーとタスクトレイアイコンが増殖していたのです。
(あくまでイメージ図であって、別に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
が割り込むようにして呼び出されます。
-
UpdateAgent
でsession AのTypeLib::IUIAgentPtr
の参照を取得する - そのポインタはnullptrであったために、
UIAgent
の生成をsession Aで行う。その間UpdateAgent
側ではメッセージループを回す - 何らかのイベントを受信する関数の呼び出しが割り込んでくる
-
EraseOldElement
が呼び出され、session AのTypeLib::IUIAgentPtr
を確認するとnullptrであったために、m_uiElement
から削除をおこなう。 -
UIAgent
の生成が終わりUpdateAgent
に制御が戻ってくる。そして1で取得した参照に書き込もうとするがこれはm_uiElement
の管理から外れた無効な参照である。ただしm_uiElement
の予約しているメモリ空間であろうことからメモリアクセス違反とはならないと予想される。m_uiElement
の管理から外れているということは、デストラクタを呼ばれる契機はないので、リークする。 - そのうちsession Aで
UIAgent
の生成が再び行われm_uiElement
に登録される
すなわち再入の恐ろしさとは、見た目には気が付きにくいところで勝手にメッセージループを回されてしまうがために勝手に別の処理が割り込んでくる、というところにあります。こんなの初見で気がつけてたまるかシリーズです。
再入との戦い
IMessageFilter → ボツ
再入と戦うに当たってまずは再入を防げないか考えます。
調べてみるとIMessageFilter
を用いて、防ぐのが一般的なようです。すなわちクライアントに対して「今忙しいから待って!」ということができるようです。
STAのメソッド呼び出しを見てみる - イグトランスの頭の中
なお、STAではメソッド・ウィンドウメッセージの受け付けをIMessageFilterとCoRegisterMessageFilterである程度制御できます。
ただし「ある程度」と書かれているのが不穏なところです。
他にも下のブログに紹介があるように、どうにもあまり信頼できる手法には見えません。
ただ 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などで出てくる中古の本を手に入れることでしょうか(自分はこっちでした)。
ネットでの解説は頑張って探せばあります。必読となるのは以下ではないでしょうか。
- ChalkTalk CLR – COMのすべて – kekyoの丼
-
COMのカレンダー | Advent Calendar 2014 - Qiita
- @egtra 氏による一人Advent Calender。本記事に関連が深い記事としては以下。
- 参照カウントしないIUnknown - イグトランスの頭の中
- COMにおけるアパートメントの概要 - イグトランスの頭の中
- ATL::CComGITPtrを使ってみる - イグトランスの頭の中
- STAのメソッド呼び出しを見てみる - イグトランスの頭の中
- COM プログラミング入門 - Web/DB プログラミング徹底解説
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);
}
すると何が起きるかというと、ある種のコンテキストスイッチが起こるわけです。まるでコルーチンみたいですよね。つまり、呼び出し元の処理は一旦中断されて、他の処理を受け付け、呼び出しが終わったことが通知されると、他の処理からメッセージループに遷移したときに再び処理を再開するわけです。
すなわち、シングルスレッドだけれども、シーケンシャルに処理されないことがあるわけです。
ここで他の処理と自身の処理で同一の状態を参照している、具体的には同じ変数に対して操作をしていると、まるでマルチスレッドプログラミングにおける競合状態に対する考慮と同様にことが必要になるわけです。それがまさにこの記事で戦った相手です。
もちろんシングルスレッドなので同じ変数に対して操作していても、直ちにそれが問題にはなりません。しかし、相互に及ぼす影響は整理しなければなりません。
ここで厄介なのはマルチスレッドプログラミングにおいて通用する「とりあえず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もどきの亜種で、それぞれのイテレータループに入るときにフラグを立てておき、ReconnectUIProcess
のif (it->second == nullptr)
の分岐内でそのフラグをみてフラグが立ってたら削除をスキップするというものです。ReconnectUIProcess
はタイマー契機で何度も呼ばれるので、いつかはスキップされずに削除が行われるでしょうから、m_uiElement
の肥大化も問題にはならないわけです。
もしWindows Serverをサポート対象OSとする場合、一台のPCに数十ユーザーが頻繁にRDP経由でログインしてくる、なんてケースも考えられますので、m_uiElement
はコピーするにはすこし気になるくらい大きくなる可能性もあるかもしれないので、この案のほうが良かったかもしれませんが、まあどうせServerOSはサポートしないのでそこまでしなくていいでしょう。
続編
2024年の今、いかにしてVS2005を捨ててVS2015にする戦いは終わったか、そしてなぜCOMとの苦しい戦いが繰り広げられたか ~再入の悪魔~ - OPTiM TECH BLOG
さらなる続編
それはCOM STAと並列処理の三体問題との戦いだった ~Optimal Biz Teleworkの機能をOptimal Bizに部分取り込みする~ - OPTiM TECH BLOG