🦔

C++のグローバル変数の扱い方

2022/12/19に公開約12,600字

C++のグローバル変数の扱い方

この記事はKMC Advent Calendar 2022の5日目[1]の記事です。
執筆者はKMC ID: hatsusatoといいます。社会人になってもKMC部員です。

皆さんはC++のグローバル変数をきちんと扱えていますか?
この記事では、C++のグローバル変数の扱い方について、まとめてみたいと思います。

TL;DR

グローバル変数は直接用いるのではなく、代わりに静的ローカル変数の参照を返す関数を導入して代替しましょう。

グローバル変数の特徴

そもそもグローバル変数とは何でしょうか?
グローバル変数の特徴を構文(syntax)と意味(semantics)の両面から確認しておきます。

グローバル変数の構文

グローバル変数とは、名前空間スコープで定義される変数のことです。

スコープ(scope)とは、名前が見える(visible)範囲のことで、次のような種類があります。

  • ブロックスコープ
    • 関数定義の内側の複合文のブロック{}からなるスコープ
  • 関数パラメータスコープ
    • 関数の仮引数がもつスコープ
  • 名前空間スコープ
    • 関数定義の外側のスコープ
    • 名前空間ごとにさらに細分される
      • とくに、どのnamespaceブロックの内側でもない、翻訳単位のトップレベルスコープをグローバル名前空間スコープ[2]と呼ぶ
  • クラススコープ
    • クラス定義の内側のスコープ
  • 列挙スコープ
    • 列挙型定義の内側のスコープ
  • テンプレートパラメータスコープ
    • テンプレート引数のスコープ

つまり、グローバル変数とは、関数定義の外側で定義される変数です。

また、グローバル変数は定義の仕方によって、結合の仕方を変更することもできます。

結合(linkage)とは、同じ名前をもつ実体(entity)[3]をどこまで同一視するかについての分類で、次のような種類があります。

  • 結合なし(no linkage)
    • 同じスコープの同じ名前が同じ実体を指します
    • 異なるスコープで定義される同じ名前がそれぞれ別の実体を指します
  • 内部結合(internal linkage)
    • 同じ翻訳単位の同じ名前が同じ実体を指します
    • 異なる翻訳単位で定義される同じ名前がそれぞれ別の実体を指します
  • 外部結合(external linkage)
    • プログラム全体で同じ名前がただ一つの実体を指します
  • モジュール結合(module linkage)[4]
    • 同じモジュールの同じ名前が同じ実体を指します
    • 異なるモジュールで定義される同じ名前がそれぞれ別の実体を指します

いわゆるグローバル変数と言ったら、プログラム全体で1つの実体を表すような、外部結合をもつ変数をイメージしがちですが、内部結合をもつグローバル変数もありえます。

グローバル変数の意味

グローバル変数は、静的記憶域期間をもちます。

記憶域期間(storage duration)とは、オブジェクトが生存する期間のことで、次のような種類があります。

  • 自動(automatic)記憶域期間
    • オブジェクトが定義されてから、そのスコープが終わるまでの期間
    • ローカル変数などが該当
  • 静的(static)記憶域期間
    • main関数の始まりから終わりまでの期間
    • グローバル変数などが該当
  • 動的(dynamic)記憶域期間
    • new式で生成されてから、delete式で解放されるまでの期間
    • ヒープ領域上のオブジェクトが該当
  • スレッド(thread)記憶域期間[5]
    • スレッドが開始してから、そのスレッドが終了するまでの期間
    • thread_localキーワードがついた変数が該当

つまり、グローバル変数の値はプログラムの最初から最後まで有効なのであって、途中で寿命が尽きたりしないということです。

グローバル変数とその仲間たち

スコープ・記憶域期間・結合に基づいて、グローバル変数やその他の言語要素[6]をいくつか分類してみました。

言語要素 スコープ 記憶域期間 結合
ローカル変数 ブロック[7] 自動 なし
静的ローカル変数 ブロック 静的 なし
(外部結合をもつ)グローバル変数 名前空間 静的 外部
静的グローバル変数 名前空間 静的 内部
無名名前空間のグローバル変数 名前空間 静的 内部
静的メンバ変数 クラス 静的 外部
  • ローカル変数はブロックスコープで定義され、結合をもちません
    • デフォルトで自動記憶域期間をもちます
    • staticキーワードをつけると静的記憶域期間をもたせることができます
  • グローバル変数は名前空間スコープで定義され、静的記憶域期間をもちます
    • デフォルトで外部結合をもちます
    • 次の2通りの方法で内部結合をもたせることができます
      • staticキーワードをつける
      • 無名名前空間の内側で定義する
  • 静的メンバ変数は外部結合をもつグローバル変数と同じ記憶域期間・結合をもちますが、スコープが異なります

グローバル変数利用のベストプラクティス

これがこの記事最大のメッセージです。どういうことなのか、具体例で見てみましょう。

次のように、int型のグローバル変数globalや静的メンバ変数X::memberを扱いたいとします。

int global;

class X {
  static inline int member;  // C++17からinlineキーワードを用いて外部結合をもつ変数の宣言と定義を統合できる
};

それぞれに対応するように、次のようなint &を返す関数global_func()member_func()を定義してみましょう。

int global;
int &global_func() {
  static int data;
  return data;
}

class X {
  static inline int member;
  static int &member_func() {
    static int data;
    return data;
  }
};

このとき、変数globalと関数global_func()は同じスコープに定義され、関数global_func()の呼び出し式が示すオブジェクトの記憶域期間は変数globalと同じ静的記憶域期間をもちます。したがって、プログラム中の変数globalの使用を、関数global_func()の呼び出し式で置換することができます。メンバ変数X::memberとメンバ関数X::member_func()との間にも同様の議論ができます。

このように、グローバル変数や静的メンバ変数を定義するのではなく、代わりに静的ローカル変数の参照を返す関数を定義して用いることができます。

実は、このような回りくどい置換はしなければならない理由があり、それは初期化順序の問題に関係しています。

グローバル変数の初期化順序

プログラム全体で複数のグローバル変数があるとき、それらの初期化はどの順序で行われるでしょうか。
例えば、次のようなソースファイル群を考えてみましょう。

A.cpp
int x = 1, a = 2;
B.cpp
extern int a;
int b = a;
C.cpp
extern int b;
int c = b;

1つの翻訳単位の中では、前から見ていって、グローバル変数の定義が現れた順に初期化されます。つまり、A.cppにおいては、xのあとにaが初期化されます。一方で、異なる翻訳単位に存在するグローバル変数の間の初期化順序は定められていません。つまり、a, b, cのどれが先に初期化されるかはわかりません。コンパイル時には他の翻訳単位の事情を知る方法がないので、この仕様は当然の帰結でしょう。

しかし、この仕様のために、cが正しく初期化される保証ができません。cの初期化c = bのあとでbの初期化b = aが実行される可能性があり、このときcの値は2ではなく、未初期化のbの値0になる[8]可能性があります。
今回の例ではグローバル変数がint型なので初期化後の値が異なるだけの結果ですが、一般には、コンストラクタ引数の実引数が未初期化のオブジェクトになるので、Segmentation Faultなどの未定義動作につながります。

これをグローバル変数の初期化順序問題(Static Initialization Order Fiasco)といいます。ここではグローバル変数を例に述べましたが、静的メンバ変数にも同様の問題があります。

C言語ではグローバル変数の初期値は定数式[9]である必要があり、他のグローバル変数を用いて初期化できないので問題にはなりません。一方で、C++では一般にオブジェクトの初期化がコンストラクタ呼び出しによって行われるため、グローバル変数の初期値に対してそのような制限がありません。これによりオブジェクトの初期化は柔軟になりますが、初期値の依存関係とその順序に注意する必要があります。

この問題を解決するために、静的ローカル変数を用います。

静的ローカル変数の初期化

静的ローカル変数の初期化は、その静的ローカル変数を含む関数の最初の呼び出しにおいて、その変数の定義に到達した時点で実行されることになっています。グローバル変数の初期化順序はコントロールできなくても、関数呼び出しの順序はコントロールできます。グローバル変数を静的ローカル変数で置き換えることで、適切なタイミングで初期化を行い、初期化順序の問題を解決することができます。

先程の例A.cppを次のように変更します[10]

A.cpp
int &get_a() {
  static int a = 2;
  return a;
}

関数get_a()は静的ローカル変数aへの参照を返す関数です。静的ローカル変数aは関数get_a()の初回の呼び出しの際に初期化されるので、関数get_a()は常に初期化済みの値への参照を返します。
同様に、B.cpp, C.cppも変更します。

B.cpp
int &get_a();
int &get_b() {
  static int b = get_a();
  return b;
}
C.cpp
int &get_b();
int &get_c() {
  static int c = get_b();
  return c;
}

関数get_b()内の静的ローカル変数bは、初期値としてget_a()の戻り値を使います。get_a()は初期化済みの値を返すので、bは正しく初期化されます。この初期化のあとでbへの参照を返すので、get_b()は初期化済みの値への参照を返します。

関数get_c()内の静的ローカル変数cは、初期値としてget_c()の戻り値を使います。get_b()は初期化済みの値を返すので、cは正しく初期化されます。この初期化のあとでcへの参照を返すので、get_c()は初期化済みの値への参照を返します。

このように、静的ローカル変数を用いるようにすれば、静的記憶域期間をもつ値の初期化順序を適切に制御できます。これで初期化順序の問題が解決しました。

グローバル定数の初期化

ここまでをまとめると、グローバル変数や静的メンバ変数の代わりに静的ローカル変数を用いるのは、翻訳単位をまたいだ初期化順序をコントロールするためという話でした。ですので、初期値がコンパイル時定数の場合など、初期化順序が問題にならない場合は、必ずしもベストプラクティスに従わなくても問題は発生しません。したがって、とくに constexpr修飾されたグローバル定数や静的メンバ定数はベストプラクティスの対象外です。

一方で、コンパイル時定数でない、単にconstなグローバル定数や静的メンバ定数であれば、ベストプラクティスに沿って静的ローカル定数を用いるようにしておく方がよいと思います。いくら現状では初期値が明らかであっても、コードを変更していくうちに初期値の依存関係が複雑になっていき、初期化順序問題が発生するようになる可能性があります。

struct X {
  static const X &instance() {
    static const X data;
    return data;
  }
};

静的ローカル変数初期化の実装

静的ローカル変数の初期化処理はどのように実装されるでしょうか?
一般のクラスTに対して、次の関数get_tにおける静的ローカル変数の初期化処理について考えます。

class T { /* ... */ };
T &get_t() {
  static T data;
  return data;
}

このとき、コンパイラは関数get_t()を次のようなコードへと変換します[11]

namespace {
alignas(T) std::byte storage[sizeof(T)];
bool done;
}
T &get_t() {
  const auto ptr = reinterpret_cast<T *>(storage);
  if (!done) {
    new (ptr) T;
    done = true;
  }
  return *ptr;
}
  • storage
    • 関数get_t()の静的ローカル変数の値を保持する領域
    • 静的記憶域期間をもたせるためにグローバル変数を用いる
      • 静的ローカル変数を他の表現でどう実装するかという話なので、グローバル変数を用いるしかない
    • コンストラクタ呼び出しによる初期化を遅延できるように、T型のグローバル変数を直接用いずに、単なるバイト列として確保する
  • done
    • 初回のget_t()の呼び出しでのみ静的ローカル変数を初期化するためのフラグ
    • グローバル変数なのでゼロ初期化、つまりfalseで初期化される
    • 初回のget_t()の呼び出しの中でtrueに変更されて以降はtrueのまま
  • new (ptr) T
    • placement newによりTのデフォルトコンストラクタを呼び出す
      • 一般には、ここで任意のコンストラクタを呼び出せる
    • 初回のget_t()の呼び出しのときにだけ入るif文の内側で実行する

基本的にはこれでよい[12]ように思われますが、それはあくまでシングルスレッドの場合だけで、一般にはさらに排他制御を追加する必要があります。

マルチスレッド環境において、静的ローカル変数を含む関数が複数のスレッドによって呼び出されるとき、静的ローカル変数の初期化においてデータ競合が起きる可能性があります。これを防ぐためには排他制御が必要です。

実際、C++11以降では、コンパイラがデフォルトで排他制御を用いたスレッドセーフな初期化コードを生成してくれます[13]。g++/clang++では-fthreadsafe-staticsオプションがこの仕様に相当します。

一般に、一度だけ行いたい初期化処理を排他制御したいとき、その効率的な実装方法として、Double-Checked Locking (Pattern)という手法が知られています。現代のコンパイラが生成するスレッドセーフな初期化コードも、基本的にはDouble-Checked Lockingと同様のアイデアを用いています。この記事の残りでは、このDouble-Checked Lockingの紹介をしたいと思います。

Double-Checked Locking

先程の(排他制御の考慮がされていない)静的ローカル変数の初期化処理に対して、単に排他制御を追加すると次のようになります。

  namespace {
  alignas(T) std::byte storage[sizeof(T)];
  bool done;
+ std::mutex mutex;
  }
  T &get_t() {
    const auto ptr = reinterpret_cast<T *>(storage);
+   {
+     std::lock_guard<std::mutex> lock{mutex};
      if (!done) {
        new (ptr) T;
        done = true;
      }
+   }
    return *ptr;
  }

これでとりあえずはスレッドセーフになりますが、get_t()を呼び出すたびにロックを取得することになるので、非常に効率が悪い[14]です。そこで、ロックを取る前に簡単なチェックを追加して、ロックを取る頻度を減らしてみます。

  namespace {
  alignas(T) std::byte storage[sizeof(T)];
  bool done;
  std::mutex mutex;
  }
  T &get_t() {
    const auto ptr = reinterpret_cast<T *>(storage);
-   {
+   if (!done) {
      std::lock_guard<std::mutex> lock{mutex};
      if (!done) {
        new (ptr) T;
        done = true;
      }
    }
    return *ptr;
  }

初回のget_t()の呼び出しでdonetrueになると、2回目以降のget_t()の呼び出しでは、最初のif文の内側へは入りません。これはつまり、2回目以降の呼び出しではロックの取得ごとスキップするということになり効率的です。

これがDouble-Checked Lockingの基本アイデアです。

基本はこれでよいのですが、このままでは、アウト・オブ・オーダー実行によって変数の読み書き順序が前後する可能性に対処できていません。具体的には、2つ目のif文の内側における次の2文の順序が入れ替わった場合に問題があります。

  • new (ptr) T;
  • done = true;

この2文はお互いに依存がないので、少なくともシングルスレッド環境においては、どちらを先に実行しても結果は変わりません。しかし、アウト・オブ・オーダー実行によってこれらの実行順序が並び替えられると、危険な未定義動作へとつながる可能性があります。

例えば、2つのスレッドA, Bがget_t()を同時に呼び出す次のシナリオを考えてみます。このとき、最初のロックの取得に成功するのはスレッドAの方であると仮定しても一般性を失いません。

  1. スレッドA, Bがそれぞれget_t()を呼び出す
  2. スレッドAが1つ目のif文の中へ進み、ロックを取得し、2つ目のif文の中へ入る
  3. スレッドAがアウト・オブ・オーダー実行によって、まずdone = trueの方を実行する
  4. スレッドBが1つ目のif文に差し掛かるも、スレッドAによってdonetrueになったためにif文の中へ入らない
  5. スレッドBが*ptrを返す
    • ここで未初期化T型オブジェクトへの参照を返す
  6. スレッドAがアウト・オブ・オーダー実行によって、コンストラクタ呼び出しnew (ptr) Tを実行してstorageを初期化する
  7. スレッドAがロックを解放して*ptrを返す

このようなデータ競合を回避するためには、次のように適切にメモリバリアを挿入して、実行順序を制御する必要があります。

  namespace {
  alignas(T) std::byte storage[sizeof(T)];
- bool done;
+ std::atomic<bool> done;
  std::mutex mutex;
  }
  T &get_t() {
    const auto ptr = reinterpret_cast<T *>(storage);
-   if (!done) {
+   if (!done.load(std::memory_order_acquire)) {  // (1)
      std::lock_guard<std::mutex> lock{mutex};
-     if (!done) {
+     if (!done.load(std::memory_order_relaxed)) {  // (2)
        new (ptr) T;
-       done = true;
+       done.store(true, std::memory_order_release);  // (3)
      }
    }
    return *ptr;
  }

上のように修正すると、スレッドA,Bがいずれも確実に初期化済みのstorageへの参照を返すことが次の議論[15]からわかります。ただし、(1),(2),(3)は上のソースコード中のコメントの位置を指します。

  • スレッドAのとき
    1. 最初の仮定より、ロックを取得できる
    2. したがって、(1)におけるdoneの値はfalseであったことがわかる
    3. doneへの唯一の書き込み(3)がロックの内側にあるので、ロックを取得したスレッドA自身がdoneへ書き込まない限り、doneの値はロック取得前の値から変化しない
    4. したがって、(2)におけるdoneの値もfalseであることがわかる
    5. 2つ目のif文の内側まで進み、コンストラクタ呼び出しnew (ptr) Tによってstorageを初期化する
    6. (3)においてdonetrueをatomicに書き込む
      • (3)におけるメモリバリアがreleaseなので、ソース上で(3)より前にあるnew (ptr) Tは、必ず(3)の書き込みより前に実行される
    7. ロックを解放したのち、初期化済みのstorageへの参照*ptrを返す
  • スレッドBのとき
    • (1)においてdonetrueのとき
      1. スレッドAがrelease storeで書き込んだ値がスレッドBのacquire loadで読み込めたとき、そのstoreとloadとの間にhappens before関係が成立する
      2. スレッドAの(3)におけるdoneへの書き込みとスレッドBの(1)におけるdoneの読み込みとの間に、happens before関係が成立する
        • doneの初期値はfalseであり、doneへの書き込みはロックの内側の(3)だけなので、donetrueになっているのはスレッドAの(3)におけるdoneへの書き込みが原因
        • スレッドAの(3)におけるメモリバリアがrelease
        • スレッドBの(1)におけるメモリバリアがacquire
      3. したがって、スレッドAの(3)より前に実行された操作は、スレッドBの(1)より前に完了していることが保証される
      4. スレッドAにおけるstorageの初期化はdoneへの書き込みより前に実行されるので、スレッドBの(1)におけるdoneの読み込みの時点で初期化は完了している
      5. donetrueなので1つ目のif文の内側へ入ることなく、初期化済みのstorageへの参照*ptrを返す
    • (1)においてdonefalseのとき
      1. 最初の仮定より、スレッドBはスレッドAがロックを解放するまで待機する
      2. スレッドBがロックを取得できたとき、すでにstorageは初期化され、donetrueになっている
        • スレッドAによるstorageの初期化とdoneへのtrueの書き込みは、ロックのメモリバリアにより、必ずロックの解放より前に実行される
      3. (2)においてdoneを読み込むとtrueであるため、if文の内側へは入らない
        • (2)におけるメモリバリアはrelaxedであるが、ロックの内側にあるので、ロックのメモリバリアによって、ロック取得の後で(2)の操作が実行される
        • したがって、(2)において読み込まれるdoneの値は必ずロック取得後の値である
      4. ロックを解放したのち、初期化済みのstorageへの参照*ptrを返す

これでスレッドセーフなDouble-Checked Lockingの実装になりました。

ちなみにC++11以降では、上述のようなスレッドセーフに一度だけ実行したいような処理は、std::once_flagstd::call_onceを用いて実装するのが簡単です。

namespace {
std::optional<T> storage;
std::once_flag once;
}
T &get_t() {
  std::call_once(once, [](){ storage.emplace(); });
  return *storage;
}

これだけで、storageを初期化するラムダ式がスレッドセーフに一度だけ実行されます。アーキテクチャとstd::once_flag/std::call_onceの実装によっては、緩いメモリバリアを明示的に用いてチューニングするほうが効率がよい可能性がありますが、少なくともこちらの方がとてもお手軽です。

脚注
  1. 12/5にはこの記事の下書きスライドを用いてKMC内で発表したのですが、記事として投稿するにあたり、文言の正確を期すための加筆修正をしていたら2週間遅刻しました ↩︎

  2. 名前空間の外だけど名前空間スコープの仲間 ↩︎

  3. プログラム中でソースコードによって表現される値とか関数とか型とかを表す言葉 ↩︎

  4. C++20から追加 ↩︎

  5. C++11から追加 ↩︎

  6. どういう用語で呼ぶとよいのかわかりません ↩︎

  7. または関数パラメータ ↩︎

  8. グローバル変数はまずゼロ初期化されています ↩︎

  9. または文字列リテラル ↩︎

  10. グローバル変数xは今後の議論に無関係なので省略しています ↩︎

  11. あくまでC++の構文を用いて変換後を表現するとこういうイメージというだけです ↩︎

  12. 本当はデストラクタ呼び出しのケアも必要ですが省略しています ↩︎

  13. C++11以前では、そもそも言語仕様レベルでマルチスレッド環境サポートが貧弱でしたので、自前で排他制御を実装するしかありません ↩︎

  14. ロックの取得が重い処理であるということについて、ここでは説明しません ↩︎

  15. これが正しい議論なのかどうかをどうやって確認すればよいのでしょうか ↩︎

Discussion

ログインするとコメントできます