はじめに
「C++アドベントカレンダーが埋まらない〜」という現在進行系の悲鳴も聞こえてきたので追加投下します。
マルチスレッドでローカルに定義されたstd::vector
さて、突然ですが、こんなコードを書いてみます。
#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
が宣言されており、確保したメモリ領域の先頭アドレスを表示するプログラムです。
さらに、上記のプログラムを以下のように修正してみます。
#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::vector
をthread_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を呼んでいることを確認しましょう。
#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で追いかけます。こんなコードを書いてみましょう。
#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_storage
はstd::vector
が確保している領域の最後のアドレスです。要するに_M_finish
と_M_end_of_storage
はそれぞれv.size()
とv.capacity()
に対応していると思えばわかりやすいかと思います。ちゃんと_M_start
が0x603010
と、先程表示されたアドレスと同じところを指しているのがわかると思います。
マルチスレッドでローカルに定義されたstd::vector
さて、冒頭のコードを再掲します。
#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
になります。
#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
をつけると警告が出ます。
#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記事も参考にしてみてください。