Edited at
C++Day 8

thread_localとローカルに定義されたstd::vectorの組み合わせ

More than 1 year has passed since last update.


はじめに

「C++アドベントカレンダーが埋まらない〜」という現在進行系の悲鳴も聞こえてきたので追加投下します。


マルチスレッドでローカルに定義されたstd::vector

さて、突然ですが、こんなコードを書いてみます。


test.cpp

#include <cstdio>

#include <vector>

void
func(void){
std::vector<int> v(100);
printf("0x%x\n",v.data());
}

int
main(void){
#pragma omp parallel for
for(int i=0;i<24;i++){
func();
}
}


OpenMPによりマルチスレッド環境下で呼ばれる関数func内に、ローカル変数としてstd::vectorが宣言されており、確保したメモリ領域の先頭アドレスを表示するプログラムです。

さらに、上記のプログラムを以下のように修正してみます。


test2.cpp

#include <cstdio>

#include <vector>

void
func(void){
thread_local std::vector<int> v(100); //スレッドローカル修飾子をつけた
printf("0x%x\n",v.data());
}

int
main(void){
#pragma omp parallel for
for(int i=0;i<24;i++){
func();
}
}


関数func内部に出てきたstd::vectorthread_localで修飾したものです。

実行前に以下の点について考えてみてください。



  • thread_localをつける前と後で結果が変わるか?

  • 変わるとしたらなぜか?

これを即答できるような人は続きを読む必要はありません。

そもそも関数内のスタック領域はスレッドごとに別になっているので、既にここにでてくるstd::vector<int> vそのものはスレッドローカルな変数になっていると言えなくもありません。しかもstd::vectorは領域確保にmallocを呼ぶため、それはスレッドローカルなvectorから呼ばれようがそうでないvectorから呼ばれようが変わらない気が・・・しません?僕はしました。

以下、上記のソースについて簡単に説明してみようと思います。


std:vectorとmalloc

この記事を読んでるほとんどの人にとっては釈迦に説法だと思いますが、std::vectorが内部で確保するメモリはヒープに取られます。

つまり、

void

func(void){
int a[100];
}

とすると、aはスタック領域に確保されますが、

void

func(void){
std::vector<int> v(100);
}

とすると、vそのものはスタックに積まれますが、vが管理するデータはヒープに取られます。念のため、std::vectorが内部でmallocを呼んでいることを確認しましょう。


test3.cpp

#include <vector>

int
main(void){
std::vector<int> v(100);
}

gdbで見てみましょう。一度mainにブレークポイントを置いて、そこまで実行してからmallocにブレークポイント置いて、continueしてみます。

$ g++ -g test3.cpp

$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x400839: file test3.cpp, line 4.
(gdb) r
Breakpoint 1, main () at test3.cpp:4
4 std::vector<int> v(100);
(gdb) b malloc
Breakpoint 2 at 0x2aaaaaac03c0 (2 locations)
(gdb) c
Continuing.

Breakpoint 2, 0x00002aaaab51a180 in malloc () from /lib64/libc.so.6
(gdb)

ちゃんとmallocが呼ばれました。このようにstd::vectorの内部からはmallocが呼ばれ、内部で管理するデータを確保しています。

このstd::vectorの内部をちょっと見てみましょう。ソースを見るのは正直タルいので、gdbで追いかけます。こんなコードを書いてみましょう。


test4.cpp

#include <cstdio>

#include <vector>

int
main(void){
std::vector<int> v(100);
printf("0x%x\n",v.data());
}

std::vectorが確保したデータ領域v.data()の先頭アドレスを表示しています。実行するとこうなります。あとでgdbで追いかけるため-gをつけておきます。

$ g++ -g test4.cpp

$ ./a.out
0x603010

まず、普通に実行すると、v.data()のアドレスとして0x603010が表示されました。次にgdbで追いかけます。

$ gdb ./a.out

(gdb) b 7
Breakpoint 1 at 0x4008c1: file test4.cpp, line 7.
(gdb) r
Breakpoint 1, main () at test4.cpp:7
7 printf("0x%x\n",v.data());
(gdb) p v
$1 = {<std::_Vector_base<int, std::allocator<int> >> = {
_M_impl = {<std::allocator<int>> = {<__gnu_cxx::new_allocator<int>> = {<No data fields>}, <No data fields>}, _M_start = 0x603010, _M_finish = 0x6031a0,
_M_end_of_storage = 0x6031a0}}, <No data fields>}

std::vector<int> vをgdbで表示してみました。_M_startはmallocで確保した領域の先頭アドレス(つまりv.data()が返すアドレス)、_M_finishがデータの最後(v.end()が指すところ)、そして_M_end_of_storagestd::vectorが確保している領域の最後のアドレスです。要するに_M_finish_M_end_of_storageはそれぞれv.size()v.capacity()に対応していると思えばわかりやすいかと思います。ちゃんと_M_start0x603010と、先程表示されたアドレスと同じところを指しているのがわかると思います。


マルチスレッドでローカルに定義されたstd::vector

さて、冒頭のコードを再掲します。


test.cpp

#include <cstdio>

#include <vector>

void
func(void){
std::vector<int> v(100);
printf("0x%x\n",v.data());
}

int
main(void){
#pragma omp parallel for
for(int i=0;i<24;i++){
func();
}
}


これを24スレッドで実行してみましょう。

$ export OMP_NUM_THREADS=24 

$ g++ -fopenmp test.cpp
$ ./a.out
0x607b60
0x607ea0
0x608040
0x608380
0x6086c0
0x608a00
0x6086c0
0x608a00
0x6081e0
0x607b60
0x607b60
0x607ea0
0x608520
0x608860
0x607d00
0x608380
0x608040
0xb0000a50
0x6079c0
0x607820
0x6086c0
0x608a00
0x6081e0
0xb00008b0

同じアドレスが何度も出てきました。sort -uしてみましょう。

$ ./a.out | sort -u

0x607820
0x6079c0
0x607b60
0x607d00
0x607ea0
0x608040
0xb00008b0
0xb0000a50
0xb40008b0

24回mallocが呼ばれたはずですが、確保されたメモリアドレスの種類は9個しかありません(実行のたびに変わります)。ちなみに、0xb00008b0みたいに明らかに異質なアドレスは、ヒープではなくmmapで確保されたものです。詳しくはmallocの動作を追いかける(マルチスレッド編)を参照してください。

これは、一度mallocで確保された領域がfreeにより解放され、次にmallocで呼ばれた時に再利用されるからです。24スレッドがいっきにmallocとfreeをしにいきますが、タイミングによっては先行したスレッドのfreeが終わっており、別のスレッドがmallocしようとした時にその解放された領域がリサイクルされます。


thread_local指定

さて、複数のスレッドから同時に呼ばれる関数func内にローカルに定義されたstd::vectorがヒープに確保した領域について、複数のスレッドから同じメモリを読み書きするのは、例えばキャッシュの競合などを起こしそうでイヤです。そこで、thread_local修飾子をつけてみましょう。thread_local修飾子は、その名の通りスレッドローカルな変数を宣言するためのものです。それが冒頭のtest2.cppになります。


test2.cpp

#include <cstdio>

#include <vector>

void
func(void){
thread_local std::vector<int> v(100); //スレッドローカル修飾子をつけた
printf("0x%x\n",v.data());
}

int
main(void){
#pragma omp parallel for
for(int i=0;i<24;i++){
func();
}
}


実行すると、今度はアドレスに全く重複がなくなります。

$ g++ -fopenmp -std=c++11 test2.cpp 

$ ./a.out | sort -u
0x607990
0x607b30
0x607cd0
0x607e70
0x608030
0x6081d0
0x608370
0x608510
0x6086d0
0x608890
0x608a50
0x608c10
0x608dd0
0x608f70
0x609130
0x6092f0
0x6094b0
0x609650
0x609850
0xb00008b0
0xb0000ad0
0xb0000c90
0xb0000e70
0xb0001030


なぜこういうことがおきたか?

別に謎ってほどでも無いのですが、この動作の違いはローカル変数にthread_local修飾子がつくと、暗黙にstaticがつくからです。

つまり、

void

func(void){
thread_local std::vector<int> v(100);
...
}

は、

void

func(void){
static thread_local std::vector<int> v(100);
...
}

と等価です。staticで宣言されているため、プログラム終了までstd::vectorのデストラクタが呼ばれません。デストラクタが呼ばれないためmallocで確保されたメモリが解放されず、解放されないメモリは再利用されない、ということです。

これだけだと「それだけ?」と思うかもしれませんが、これはわりと深い闇がある気がします。まず、プログラマが意図しないstatic宣言はバグのもとです。例えばこの関数funcの引数に、std::vectorが必要とする要素数が含まれていた場合、つまり

void

func(int size){
thread_local std::vector<int> v(size);
...
}

みたいな形になっていた場合、vは最初に呼ばれた時にだけsizeで初期化され、二回目から特にresizeされないのでバグります。もちろんこういうのがなくても、次に呼ばれた時に、前の処理の結果を保持しているの意図しない挙動でしょう。なので、

void

func(int size){
thread_local std::vector<int> v;
v.clear();
v.resize(size);
...
}

みたいに書く必要があります。

ちなみにthread_localが定義される前は__thread修飾子が使われていました。こちらは暗黙のstatic宣言を伴いません。なのでローカル変数に__threadをつけると警告が出ます。


test5.cpp

#include <vector>


int
main(void){
__thread std::vector<int> v(100);
}

$ g++ test5.cpp

test5.cpp: In function 'int main()':
test5.cpp:5:30: warning: function-scope 'v' implicitly auto and declared '__thread' [enabled by default]
__thread std::vector<int> v(100);
^

関数スコープのローカル変数はもともとautoなので、デフォルトでスレッドローカルだからつけても意味ないよ、みたいな警告がでます。ちなみにclang++は警告ではなくエラーを吐きます。

$ clang++ test5.cpp 

test5.cpp:5:2: error: '__thread' variables must have global storage
__thread std::vector<int> v(100);
^
1 error generated.

僕もこれはエラーで落としてしまって良い気がします。


まとめ

一応冒頭の答えを書いておくと



  • thread_localをつけると振る舞いが変わる(メモリアドレスの重複がなくなる)

  • 理由は暗黙のstatic宣言がつくから

です。

この、ローカル変数にthread_localをつけると、暗黙にstaticがつく奴、知らないと結構ハマる気がします。っていうか僕が__threadからthread_localへの機械的な書き換えをしてバグを入れました。

なぜthread_localにこの仕様が導入されたのかよく知りませんが、暗黙のstatic宣言なんてバグるに決まってるんで、__threadみたいに暗黙のstatic宣言を伴わないか、もしくはせめて-Wallとかつけたら「暗黙にstatic宣言するけどいい?もしこの警告消したきゃ自分でstaticつけな」くらい言ってくれてもいいのに、と思います。


参考

mallocについては、僕が書いた一連のmalloc記事も参考にしてみてください。