こんにちは。田原です。

前回はboostライブラリに含まれるスタック・トレース機能の基本的な使い方を解説しました。前回の使い方は通常のコンテキストで使用できます。しかし、スタック・トレースはもっとクリティカルなコンテキストでも使いたいです。特に「ぬるぽ」などの不正メモリ・アクセスが発生した時がその筆頭と思います。これはSIGSEGV等のシグナルで捕まえることができますが、このシグナル・ハンドラーのコンテキストはかなり特殊でできないことが盛りだくさんです。その中でのスタック・トレースのとり方について解説します。

1.まずはboostのドキュメントから

Handle terminates, aborts and Segmentation Faultsにシグナル発生時のスタック・トレースのとり方が解説されています。

ポイントは、シグナルは非同期に発生する「割り込み」処理なのでスタック・トレースの処理を「非同期シグナル・セーフ」に行う必要があることです。この制限がなければ、前回解説したようにstd::cout << boost::stacktrace::stacktrace();でスタック・トレースを取れます。

さて、この「非同期シグナル・セーフ」が何なのか?ですが、基本的な考え方はスレッド・セーフとそこそこ近いです。同じメモリを複数のスレッドから更新しようとすると異常動作することがあります。そのような動作を防ぐことが「スレッド・セーフ」です。「非同期シグナル・セーフ」もこれと基本は同じですが、更に厳しいです。まずはこの点について詳しく解説します。

1-1.スレッド・セーフでない例

例えば、変数aをスレッドXとスレッドYがほぼ同時にインクリメントしようとした場合を仮定します。
変数aをインクリメントする場合、CPUは①変数aをレジスタへ読み出し、②それをインクリメントして、③元の変数aへ書き込みます。(このような処理はRMW(Read-Modify-Write)処理と呼ばれます。)
現在はマルチコアですから、真に2つのスレッドが2つのコア(CPU)で同時に走ることがあります。通常のコンピュータにおいてはメモリ・アクセスできるコア(CPU)は1度に1つだけですので、複数のコア(CPU)が真に同時に同じメモリへアクセスしようとすると、ハードウェアによりどちらか一方のCPUの処理はもう一つのCPUの処理が終わるまで待たされます。
ですので、変数aを2つのスレッド(CPU)が真に同時にインクリメントしようとすると、例えば、次のような動作となります。(最初変数aの値が100だったとします。)

①-1)CPU1が変数aを読み出す(100)
①-2)CPU2が変数aを読み出す(100)
②CPU1/2が読み出した値(100)をそれぞれでインクリメントする(101)
③-1)CPU1が変数aへ書き込む(101)
③-2)CPU2が変数aへ書き込む(101)

2つのスレッドがそれぞれ1回づつインクリメントするので通常は102になるのですが、同時に同じメモリをアクセスした結果、101になりました。これは期待通りの動作ではないでしょう。
他にも危険な例は多数ありますので、これは代表的なスレッド・セーフでないケースの1つです。
このような不具合が発生しないように実装されていることをスレッド・セーフと呼びます。

以上のようなことはめったに起きませんが、安心しては行けません。起きる可能性が有る限り、いつか必ず起きます。そして再現できないのでデバッグが非常に困難なのです。その時に非常に有力なツールの1つがスタック・トレースです。


なお、近代的なCPUはRead-Modify-Write処理中にメモリ・バスやキャッシュをロックして、他のCPUに処理を割り込ませないような機能を持っているものもあります。ただし、このようなメモリ・アクセスは速度が低下するのでクリティカルなメモリ・アクセスだけに限定する必要がありますし、あまり大きなサイズのメモリに対しては保護できないという制約もあります。

1-2.スレッド・セーフにするための一般的な手法

上記のようにRMW処理を行う際の不具合を回避するためによく使われる手法は、①②③の処理全てが終わるまで他のCPUの処理を待たせることです。そのためにはミューテックスやセマフォなどのOSが提供する「同期オブジェクト」を用いることが多いです。

A)ミューテックス獲得
B)変数aのインクリメント
C)ミューテックス開放

ミューテックスを獲得できるスレッドは1度に1つだけです。既に他のスレッドがミューテックスをA)獲得している場合、後からA)獲得しようとしたスレッドは、既に獲得しているスレッドがC)開放するまで待たされます。
ですので、CPU1(スレッド1)とCPU2(スレッド2)が同時に変数aをインクリメントしようとしてもどちらか先にミューテックスを獲得した方のインクリメント処理が終わるまで、もう一つのスレッドは待たされます。従って、後からミューテックスを獲得したスレッドは前のスレッドがインクリメントした結果の101を更にインクリメントするので結果は102となり、めでたく期待通りに動作します。

1-3.非同期シグナル・セーフとは

上記とほぼ同じ概念ですが、1点異なります。
スレッド・セーフにするための一般的な方法はミューテックス等の同期オブジェクトを使って排他制御(同時に複数のスレッドがアクセスできないように)することなのですが、非同期シグナルの場合はこれが使えません。(これがマジで痛い)

何故なら、非同期シグナルは別スレッドで処理されるのではなく、発生元と同じスレッドのまま「割り込み」にて処理される場合があるからです。

この特性は困った特性なのですが、デバッグには非常に有用です。
例えば、不正なメモリを読み出すような命令を実行した時に、ソフトウェア割り込みがかかり、シグナル・ハンドラーが呼ばれます。この時、戻りアドレスがスタックにプッシュされるため、シグナル・ハンドラーでスタック・トレースを取ることで、不正な命令がどれなのか分かるという寸法です。

さて、先述のA)でミューテックスを獲得後、B)で変数aをアクセスしようとした時にそのアクセス先がバグっていて不正メモリ・アクセスが発生したと仮定します。これによりSIGSEGVシグナルが発生し、シグナル・ハンドラーが呼ばれます。
このシグナル・ハンドラー内で変数aをアクセスしていなければ問題ないのですが、ここではアクセスするものと想定します。すると、シグナル・ハンドラーでも排他制御するために、A)のミューテックス獲得処理を行います。

このような時、処理系によって結果が異なりますが、決して意図通りの動作になることはありません。
元の処理でミューテックスを既にA)獲得しています。シグナル・ハンドラーでも新たにミューテックスをA)獲得しようとしますが、既に獲得されているので開放待ちとなります。すると、そのまま開放待ちするので、割り込み元の処理へ戻ることがありません。ということは、元の処理も永遠にミューテックスを開放しません。結果として永遠にミューテックス獲得待ちとなりハングアップします。これをデッドロック(Deadlock)と呼びます。

boostの解説にある1つ目の Warningにある下記の記述はこのことを指していると思います。
Only a few system calls allowed in signal handlers, so there’s no cross platform way to print a stacktrace without a risk of deadlocking.

処理系によって結果が異なります
ミューテックスは処理系により振る舞いが異なりますので、デッドロックするとは限りません。しかし、以下のどれかの動作となりました。どの動作の場合も期待した動作ではなく不具合が発生します。
1. デッドロック(C++11で追加されたstd::mutexをMinGWで使った時)
2. 異常終了(同std::mutexをVisual C++ 2017で使った時)
3. 排他制御無し(同std::mutexをgccで使った時)

1-4.対策は

さて、その対策の選択肢は大変少ないのですが、いくつかあります。

  1. シグナル・ハンドラー内で他と変数を共有しない
    ローカル変数だけしかアクセスしない等の方法が考えられます。

  2. 待たない方法で排他制御する
    例えば、排他制御が必要なメモリ等のリソースが使われていた場合、シグナル・ハンドラー側はやりたかった処理を断念することも考えられます。空くのを待てないので諦めるわけです。

なお、ヒープは一般にヒープ領域を管理する変数がグローバル変数となっており、それをスレッド・セーフにアクセスします。これらは多くの場合、ミューテックス等の同期オブジェクトにより排他制御されているため、管理領域の排他制御のためにロックしている間に、SIGSEGV等が発生するとシグナルハンドラー内でメモリ獲得しようとするとデッドロックします。
ですので、シグナル・ハンドラー内でのメモリ獲得はリスキーです。前回解説した方法は、スタック・トレースを獲得する際、および、それを読めるようにする際にメモリ獲得するのでリスキーなのです。

1-5.boostのお勧めは

さて、boostお勧めの対策は上記の1.です。
サンプル・ソースを纏めてみました。(30行目の*p=0;でSIGSEGVを意図的に発生させています。)

#include <iostream>
#include <fstream>
#include <signal.h>
#include <stdio.h>

#ifdef _WIN32
#else
    #define BOOST_STACKTRACE_USE_ADDR2LINE
#endif
#include <boost/stacktrace.hpp>

void my_signal_handler(int signum)
{
    ::signal(signum, SIG_DFL);
//  std::cout << "my_signal_handler(" << signum << ")" << std::endl;
//  std::cout << boost::stacktrace::stacktrace();
    boost::stacktrace::safe_dump_to("./backtrace.dump");
    ::raise(SIGABRT);
}

void sub(int level)
{
    if (0 < level)
    {
        sub(level-1);
return;
    }

    int*    p=nullptr;
    *p=0;
}

int main()
{
    std::ifstream ifs("./backtrace.dump", std::ios_base::binary);
    if (ifs)
    {
        boost::stacktrace::stacktrace st = boost::stacktrace::stacktrace::from_dump(ifs);
        std::cout << "Previous run crashed:\n" << st << std::endl;

        // cleaning up
        ifs.close();
    	remove("./backtrace.dump");
    }

    ::signal(SIGSEGV, &my_signal_handler); 
    ::signal(SIGABRT, &my_signal_handler);

    sub(5);
}

(CMakeLists.txtやビルド方法は前回と同じです。)

また、今は15, 16行目は忘れて下さい。リスキーな操作ですが、ちょっと後で使います。

1-6.その結果は

ubuntu 16.04LTCでは期待通り動作しました。1回めの起動で backtrace.dump ファイルが生成され、次の起動時、その内容が表示されます。ただし、共有ライブラリ(.so)のアドレスはソース名と行番号へは変換されずアドレスがそのまま表示されました。これはセキュリティのためのASLRが影響しているのだろうと思います。(stack_trace.cpp側はASLRが影響しないのに .soのみ影響する理由は把握していません。この当たりは色々複雑そうです。)

Windowsではそもそもbacktrace.dumpが生成されません。謎です。そこで、safe_dump_to()関数のソースを追いかけてみました。これは最終的にboost/stacktrace/detail/safe_dump_win.ippファイルにあるstd::size_t dump(const char*, ...)関数を呼び出しています。
そして、その中身は、
#if 0 // This code causing deadlocks on some platforms. Disabled
#endif
とコメントアウトされてました。ソースを読む限りWindows APIを直接呼び出してスタック・トレースを取得しファイルへ保存しているだけなので、これらのWindows APIがスレッド・セーフでない、もしくは、シグナルを許可していない限り問題は出ない筈なのですが。

Boost 1.67.0リリースノートによると、「Windows OS における Async safe な ファイルダンプ機能は、いくつかのプラットフォームでハングするため、このバージョンで無効とされた。」そうです。ここからリンクされている GitHub #33 を見る限り、特に議論もなくテストがfailしたからディセーブルしているようにも見えます。ちょっと悔しいです。SIGSEGVはかなり悩ましいバグの1つですから、できるだけ拾いたいですし。

2.独自対策

1-4.に書いた 「2. 待たない方法で排他制御する」の対策を検討しています。実は使えそうです。
シグナル・ハンドラー内でSIGSEGVやSIGABRT等のソフトウェア割り込みが再発した時に問題が発生するリスクがあります。それは事実上下記のどちらかでのみ発生すると思います。(他にコンパイラや標準ライブラリがバグっていても発生しますが、これは対策のしようがありませんので諦めます。)

①シグナル・ハンドラー自体がバグっている
②シグナル・ハンドラーはバグってないが、他のユーザプログラムのせいでヒープが壊れている

この2つの状況も可能であればスタック・トレースを残したいですが、この2つを断念することで他の状況のスタック・トレースを残せるのであれば、boostの方針に従って全てを断念するよりかなりましと思います。

そこで、まずは先のstack_trace.cppのコメントアウトしていた15, 16行目を有効にして走らせてみました。

> stack_trace.exe
my_signal_handler(11)
 0# boost::stacktrace::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt;::init at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:75
 1# boost::stacktrace::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt;::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt; at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:128
 2# my_signal_handler at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:16
 3# seh_filter_exe in ucrtbased
 4# `__scrt_common_main_seh'::`1'::filt$0 at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:299
 5# _C_specific_handler in VCRUNTIME140D
 6# _chkstk in ntdll
 7# RtlWalkFrameChain in ntdll
 8# KiUserExceptionDispatcher in ntdll
 9# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:30
10# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
11# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
12# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
13# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
14# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
15# main at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:49
16# invoke_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:79
17# __scrt_common_main_seh at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:283
18# __scrt_common_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:326
19# mainCRTStartup at f:\dd\vctools\crt\vcstartup\src\startup\exe_main.cpp:17
20# BaseThreadInitThunk in KERNEL32
21# RtlUserThreadStart in ntdll
my_signal_handler(22)
 0# boost::stacktrace::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt;::init at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:75
 1# boost::stacktrace::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt;::basic_stacktrace&lt;std::allocator&lt;boost::stacktrace::frame&gt; &gt; at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:128
 2# my_signal_handler at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:16
 3# raise in ucrtbased
 4# my_signal_handler at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:19
 5# seh_filter_exe in ucrtbased
 6# `__scrt_common_main_seh'::`1'::filt$0 at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:299
 7# _C_specific_handler in VCRUNTIME140D
 8# _chkstk in ntdll
 9# RtlWalkFrameChain in ntdll
10# KiUserExceptionDispatcher in ntdll
11# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:30
12# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
13# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
14# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
15# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
16# sub at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:26
17# main at c:\cpp-school2\13.stack_trace2\stack_trace.cpp:49
18# invoke_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:79
19# __scrt_common_main_seh at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:283
20# __scrt_common_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:326
21# mainCRTStartup at f:\dd\vctools\crt\vcstartup\src\startup\exe_main.cpp:17
22# BaseThreadInitThunk in KERNEL32
23# RtlUserThreadStart in ntdll

いい感じに出ていますね。(SIGSEGVを発生させたstack_trace.cppの30行目がちゃんとでています。)

さて、2回でているのがちょっと気になります。
Windowsのシグナルの値は、
– SIGSEGV=11
– SIGABRT=22
でしたので、最初はSIGSEGVで2回目はSIGABRTです。
SIGSEGVを処理している時はSIGABRTを有効にしたまま無効にしていないのでmy_signal_handler()の最後の::raise(SIGABRT);でSIGABRTシグナルが発生しているということですね。コード通りの動作でした。

しかし、std::coutはグローバル変数ですから、スレッド・セーフでさえないですし、ましてメモリ獲得しているので「非同期シグナル・セーフ」でもありません。

2-1.待たない方法で排他制御する

スレッド・セーフ化する一般的な方法はミューテックス等の同期オブジェクトを使うのですが、今回はそのような「待ち」を発生させる方法を使えません。
このような時はアトミック(std::atomic)が有用です。

このような単純な目的にはstd::atomic_flagが適しています。
これはメンバ関数test_and_set()を持ってます。これは「①変数値の取り出し、②変数にtrueを設定、③取り出した値の返却」を「アトミック」に実行します。これは①②③の間に他の処理が割り込まないことを意味します。
更にロック・フリーですので変数をアクセスする際にミューテックス等による待ちが発生しません。待ちが発生しないということは、シグナル・ハンドラー内で使ってもデッドロックする心配がありません。

boost::stacktraceのコンストラクタにてスタック・トレースを獲得し、それをstd::coutではなくファイルへ出力するようにしてみました。

#include <iostream>
#include <fstream>
#include <signal.h>
#include <stdio.h>

#include <atomic>

#ifdef _WIN32
#else
    #define BOOST_STACKTRACE_USE_ADDR2LINE
#endif
#include <boost/stacktrace.hpp>

void my_signal_handler(int signum)
{
    ::signal(signum, SIG_DFL);

    static /*thread_local*/ std::atomic_flag excluding=ATOMIC_FLAG_INIT;
    if (!excluding.test_and_set())
    {
        std::ofstream ofs("./backtrace.log");
        ofs << "my_signal_handler(" << signum << ")" << std::endl;
        ofs << boost::stacktrace::stacktrace();
    }
    ::raise(SIGABRT);
}

void sub(int level)
{
    if (0 < level)
    {
        sub(level-1);
return;
    }

    int*    p=nullptr;
    *p=0;
}

int main()
{
    std::ifstream ifs("./backtrace.log");
    if (ifs)
    {
        std::cout << "Previous run crashed:\n" << ifs.rdbuf();

        // cleaning up
        ifs.close();
        remove("./backtrace.log");
    }

    ::signal(SIGSEGV, &my_signal_handler); 
    ::signal(SIGABRT, &my_signal_handler);

    sub(5);
}

(CMakeLists.txtやビルド方法は前回と同じです。)

18行目でstd::atomic_flagを獲得してクリアしています。(ATOMIC_FLAG_INITで初期化すると「クリア」になるようです。なお、excluding(ATOMIC_FLAG_INIT)と記述するとVC++でエラーになるため、このように記述しています。)
19行目で「excludingがfalseだったらtrueに設定」してthen節を実行しますので、スタック・トレースをbacktrace.logファイルへ出力しています。この19行目のtest_and_set()処理は割り込まれることはありませんので、先にtest_and_set()に着手した方が有効になります。後からtest_and_set()を始めた方は既にtrueになっているため、then節を実行することはありません。
これにより、プロセス内で最初に実行された指定のシグナル・ハンドラーだけがスタック・トレースを出力し、そのままSIGABRTしてプロセス停止します。

ところで、std::ofstreamは他のスレッドと共有しないので他のスレッドとのデータ競合は発生しません。
また、boost/stacktrace/stacktrace.hppにあるbasic_stacktraceのコメントを見るとAllocatorが非同期シグナル・セーフなら、basic_stacktraceのコンストラクタも非同期シグナル・セーフなということです。boost::stacktraceはbasic_stacktrace<std::allocator>ですから、恐らくboost::stacktraceもスレッドセーフと思いますので、18行目のコメントアウトしているthread_localを生かしても大丈夫だろうと思いますが、ちょっと自信がないのでコメントアウトしています。そもそもSIGSEGVやSIGABRTは発生しては行けないものですから、複数のスレッドでほぼ同時にSIGSEGVやSIGABRTが発生する可能性はかなり低い筈です。

ところで、std::atomicの使い方はたいへん難しいです
そもそもstd::atomicを使った排他制御は使える場面が限定されますし、使い方も非常に難しいです。
排他制御は本当にそれに成功していることの検証が難しいです。(意図的に発生させることができれば対策で回避出来ていることを検証できるのですが、たまたまアクセスがぶつかった時のみ問題が発生するため、意図的に発生させることが難しい場合が多いのです。)
ですので、同期オブジェクトを普通に使える場合にまでstd::atomicで排他制御するのはあまりお勧めできません。今回のように同期オブジェクトを使えないケースや、同期オブジェクトのオーバーヘッドが無視できずそれを削減しなければならないような特殊なケースに限定して使った方が良いです。

2-2.その他の対策

先述したようにAllocatorとして非同期シグナル・セーフなAllocatorを指定すれば、boost::basic_stacktraceを安全に使えるとboostのソースに記載されています。
非同期シグナル・セーフということはミューテックスによるロックを使えませんので、その制限下でメモリ・アロケータを作ることも不可能ではないそうです。しかし、難易度がかなり高そうです。正直なところ、私にはとても無理っぽいので断念しました。

3.まとめ

実は他の件でboost stacktraceをWindowsとMac向けに使いました。Macでは更に制限がきつく、前回の通常モードでのスタック・トレースにてソース・ファイル名と行番号への展開も無理で関数名しか出てきませんでした。teratailで尋ねてみたのですが、残念ながら対処できる方法は見つかりませんでした。更に、boostのシグナル対応サンプル・コードでは関数名さえほとんどでてこないという結果でなかなか悲しい結果となりました。

まだまだC++のスタック・トレース環境は厳しいことを実感しています。プログラムのバグを撲滅することが事実上不可能であることは周知の事実ですので、お客様への納品後にバグが顕在化する可能性がどうしても残ります。(可能な限り撲滅するべきですが、完全は無理という話です。)
そして、ログ機能とスタック・トレース機能は顕在化してしまったバグの修正に強力なツールとなります。お客様の満足度を改善するためにこれらの機能の実装は事実上必須と考えています。
それだけに、C++の標準化にてこれらの機能が強化されることを期待しつつ、今回はこれにて終わります。
お疲れ様でした。