[C++]WG21月次提案文書を眺める(2022年08月)

文書の一覧

全部で27本あります。

N4914 WG21 2022-07 Admin telecon minutes

WG21の各作業部会の管理者ミーティングの議事録。

前回から今回の会議の間のアクティビティの報告がされています。

N4915 Business Plan and Convener's Report: ISO/IEC JTC1/SC22/WG21 (C++)

ビジネスユーザ向けのC++およびWG21の現状報告書。

N4916 WG21 2022-07 Virtual Meeting Minutes of Meeting

2022年2月7日(北米時間)に行われた、WG21全体会議の議事録。

CWG/LWG/LEWGの投票の様子などが記載されています。

P0843R5 static_vector

静的な最大キャパシティを持ちヒープ領域を使用しないstd::vectorであるstatic_vectorの提案。

static_vector<T, N>std::vectorstd::arrayのキメラのようなコンテナで、Nに指定した値を最大キャパシティとして、スタック領域(グローバル変数として使用する場合は静的ストレージ)を用いてstd::vectorのような可変長配列を実現するものです。

主たる性質は

  • 動的メモリ確保を必要としない
  • スタックor静的ストレージを使用する
  • キャパシティ最大値がコンパイル時に指定される
  • 要素は後から挿入/削除可能
  • 要素のストレージはstatic_vectorオブジェクト内に配置される
  • contiguous_rangeコンセプトを満たす
  • 要素型Tトリビアルであれば、全ての操作が定数式で可能

などで、static_vectorは次のような場合に有用です

  • 動的メモリ確保を行えない
    • 例えば、組み込み環境など
  • 動的メモリ確保のコストが高くつく
    • 例えば、メモリ確保に伴うレイテンシに敏感なプログラム
  • 静的ストレージ上に、変則的な生存期間をもつオブジェクトを構築したい
  • デフォルト構築できない型の配列など、std::arrayが選択肢にならない
  • constexpr関数内で可変長配列を使用したい
    • これはC++20以降std::vectorでも可能
  • static_vectorの要素のストレージはstatic_vectorオブジェクト自体が内包している必要がある

この提案のstatic_vectorは既存実装であるboost::container::static_vectorをベースとして設計されており、インターフェースや性質はstd::vectorとの共通化が図られています。

#include <static_vector> // <vector>になるかもしれない

int main() {
  std::static_vector<int, 8> sv = {1, 2, 3, 4};

  std::println("{}", sv); // [1, 2, 3, 4]

  sv.push_back(5);
  sv.emplace_back(6);

  std::print("{}", sv); // [1, 2, 3, 4, 5, 6]
}

なお、最大キャパシティを超えて要素を挿入しようとした場合、全ての操作において事前条件違反として未定義動作となります。例外を投げたリabortしたりするのかは実装の選択とされます。

P1255R9 A view of 0 or 1 elements: views::maybe

std::optionalやポインタ等のmaybeモナドな対象を、その状態によって要素数0か1のシーケンスに変換するRangeアダプタviews::maybeの提案。

以前の記事を参照

このリビジョンでの変更は

  • maybe_viewからnullable_viewを分離
  • maybe_view/nullable_viewT&(左辺値参照型)の特殊化を追加
  • maybe_viewにモナディックインターフェース追加
    • and_then/or_else/transform
  • フリースタンディングライブラリ機能として指定

などです。

このリビジョンで新たに追加されたnullable_viewviews::nullable)は、nullable_objectviewに変換するものです。nullable_objectとは文脈的なbool変換が可能であり間接参照が可能な型のことで、voidではないポインタ型やstd::optionalstd::expectedなどが該当します。イテレータ型は通常単体でbool変換できないためnullable_objectではありません。

#include <ranges>

using std::ranges::nullable_view;

int main() {
  std::optional o{4};

  nullable_view m{o};
  for (int k : m) {
    cout << k;  // "4"が出力
  }

  o = std::nullopt;
  nullable_view m2{o};
  for (int k : m2) {
    cout << k;  // 実行されない(ループが回らない)
  }
}

nullable_viewは、nullable_object専用のmaybe_viewです。

これによって、maybe_viewviews::maybe)はより一般の型のオブジェクトをviewに変換するものとなり、maybe_viewが空になるのはデフォルト構築された時です。

#include <ranges>

using std::ranges::maybe_view;

int main() {
  int i{4};

  maybe_view m{i};
  for (int k : m) {
    cout << k;  // "4"が出力
  }

  maybe_view<int> m2{};
  for (int k : m2) {
    cout << k;  // 実行されない(ループが回らない)
  }
}

maybe_viewnullable_viewも共に長さ0もしくは1のシーケンスになりますが、maybe_viewは値を渡して構築された時にのみ長さ1になるのに対して、nullable_viewは渡されたnullable_objectの状態によって長さが決まります。

nullable_viewはこの提案の元々のmaybe_viewであり、nullable_objectでは無い型についてmaybe_viewを拡張しようとした結果、1つのクラスで実装するのは色々問題があったため、2つのクラス(とさらに2つの部分特殊化)に分離されたようです。

P2019R1 Usability improvements for std::thread

std::thread/std::jthreadにおいて、そのスレッドのスタックサイズとスレッド名を実行開始前に設定できるようにする提案。

現在のstd::thread/std::jthreadには、スタックサイズを設定する方法もスレッド名を設定する方法も用意されていません。一方で、この2つのクラスが使用している実行環境のスレッドAPIでは、広くこの2つのスレッドプロパティの設定が可能となっています。

環境 スタックサイズの設定 スレッド名の設定
Linux, QNX pthread_attr_setstacksize() pthread_setname_np()
Windows CreateThread() SetThreadDescription()
Darwin pthread_attr_setstacksize() pthread_setname_np()
Fuchsia zx_thread_create()
Android pthread_attr_setstacksize() JavaVMAttachArgs()
FreeBSD, OpenBSD, NetBSD pthread_attr_setstacksize() pthread_setname_np()
RTEMS pthread_attr_setstacksize() pthread_setname_np()
FreeRTOS xTaskCreate() xTaskCreate()
VxWorks taskSpawn() taskSpawn()
eCos cyg_thread_create() cyg_thread_create()
Plan 9 threadcreate() threadsetname()
Haiku spawn_thread()
Keil RTX osThreadNew()
WebAssembly

※ 空白はなし、スレッド名の設定は一部事後的にしか行えないものがある

また、他のプログラミング言語C++ライブラリのスレッドAPIにおいても、これらに対応した機能を提供している場合が多くみられます。

スタックサイズの設定は、次のような場合に必要となります

  • 特定のスタックサイズで実行できるように設計されているアプリケーションの移植性と信頼性のために、プラットフォーム間で一貫したスタックサイズを指定する
  • プラットフォームのデフォルトよりも小さいスタックサイズを使用する
    • Windows : 1MB, Unix 2MB
    • 多数のスレッドを起動したときにメモリ消費を抑えられる(仮想メモリのないシステムにおいて)
  • 一部のアプリケーションでは、メインスレッドに大きなスタックトレースを設定し、そこから起動されたスレッドにも継承させるものがある。これが望ましくない場合がある
  • 有名なゲームや大規模アプリケーションなどでは、デフォルトよりも大きいスタックサイズを使用することがある

スレッド名はデバッガを始めとする外部ツールにおいて有用で、主にデバッグに活用できます

  • デバッガーにおける利用
  • 各種クラッシュダンプや実行トレースツール
  • タスク/プロセスモニタ
  • プロファイル/トレース/診断ツール
  • Windows Performance Analyzer, ETW tracing

これらのことが欠けている事によって、std::threadstd::jthreadを使用することができず、ほぼ同等のクラスを再実装したり、基底のAPIを直接使用したりしなければならないケースがあります。また、筆者の方は、「スタックサイズのサポートがないためにstd::threadを使うことができず、std::threadは語彙型として失敗している」という話をゲーム開発者の人々から聞いているようです。

この提案は、それらの既存のプラクティスを標準化することで、現在std::thread/std::jthreadを使用できていない所で使用可能にしようとするものです。

ここで提案されているAPIは、プロパティ指定クラスをstd::thread_attributeから継承させて定義した上で、そのオブジェクトをstd::threadのコンストラクタで受け取るようにするものです。

namespace std {
  // スレッドプロパティ指定識別用基底クラス
  class thread_attribute {};

  // スレッド名を指定するクラス
  class thread_name : thread_attribute {
  public:
    constexpr thread_name(std::string_view name) noexcept;
    constexpr thread_name(std::u8string_view name) noexcept;
  private:
    implementation-defined __name[implementation-defined]; // 説明専用
  };

  // スタックサイズを指定するクラス
  class thread_stack_size : thread_attribute {
  public:
    constexpr thread_stack_size(std::size_t size) noexcept;
  private:
    constexpr std::size_t __size; // 説明専用
  };


  class thread {
    ...

    // デフォルトコンストラクタ(元からある
    thread() noexcept;

    // 処理とその引数を受け取るコンストラクタ(元からある
    template<class F, class... Args>
    explicit thread(F&& f, Args&&... args);

    // 処理とプロパティ指定を受け取るコンストラクタ(この提案で追加
    template<class F, class... Attrs>
      requires (sizeof...(Attrs) != 0) &&
               ((is_base_of_v<thread_attribute, Attrs>) && ...) &&
               ...
    explicit thread(F&& f, Attrs&&... attrs);

    ...
  };

  // jthreadも同様(略

}

std::thread_name/std::thread_stack_sizeが渡された設定値をどのように保持して取り出せるようにするかは実装定義とされています。

これらのものを次のように使用してスレッドに設定します。

void f();

int main() {
  // スレッド名とスタックサイズの指定
  std::jthread thread(f, std::thread_name("Worker"),
                         std::thread_stack_size(512*1024));
  return 0;
}

指定されたプロパティが設定可能でない場合(例えばWASM環境など)、実装はこの指定を無視することができます。

このプロパティ指定の方法はこの2つのプロパティ以外にも拡張可能で、例えばスレッドのアフィニティや優先度を指定可能とすることも将来的には可能なようにされています。とはいえ、この提案ではスタックサイズとスレッド名の2つのプロパティのみを提案しています。

P2164R6 views::enumerate

元のシーケンスの各要素にインデックスを紐付けた要素からなる新しいシーケンスを作成するRangeアダプタviews::enumrateの提案。

以前の記事を参照

このリビジョンでの変更は、enumerate_resultを説明専用コンセプトtuple-likeを満たす型のリストに追加したこと、enumerate_view::iterator::operator*は値(prvalue)を返すためCpp17ForwardIteratorコンセプトを満たす事ができず、それに応じてイテレータカテゴリを調整したことなどです。

P2264R4 Make assert() macro user friendly for C and C++

assertマクロをC++の構文に馴染むように置き換える提案。

このリビジョンでの変更は、Cでの採用に伴う文書の調整などです。

P2477R3 Allow programmers to control coroutine elision

コルーチンの動的メモリ確保を避ける最適化を制御するAPIを追加する提案。

以前の記事を参照

このリビジョンでの変更は、

  • promise_type::must_elide(P0, ..., Pn)が最初に評価されるようにすることを提案
  • コンパイラが処理(最適化)できない非同期ケースの例を追加
    • この提案のAPIが必要であるケース
  • 最適化が起きたことを検出するAPIを提案から外した
  • 最適化とメモリ使用量についての議論を追加
  • 例を追加

などです。

P2511R2 Beyond operator(): NTTP callables in type-erased call wrappers

std::move_only_fuction及びstd::functionを、呼び出し可能なNTTP値を型消去するCallable Wrapperへ拡張する提案。

以前の記事を参照

このリビジョンでの変更は、機能テストマクロを追加したこと、実装経験を追加したことです。

P2517R1 Add a conditional noexcept specification to std::apply

std::applynoexcept指定を行う提案。

以前の記事を参照

このリビジョンでの変更は、提案する文言の修正とベースとなるワーキングドラフトの更新です。

この提案は既に、2022年7月の全体会議で承認され、C++23ワーキングドラフト入りしています。

P2537R1 Relax va_start Requirements to Match C

可変長引数関数を0個の引数で宣言できるようにする提案。

以前の記事を参照

このリビジョンでの変更は、C23の提案(N2975、C23に導入済み)に仕様を整合させたことです。

P2581R1 Specifying the Interoperability of Built Module Interface Files

ビルド済みモジュールを扱う際に、ビルドシステムがそのビルド済みモジュールファイルを直接扱うことができるかどうかを調べられるようにする提案。

以前の記事を参照

このリビジョンでの変更は

  • Binary Module Interfaceという用語をBuilt Module Interfaceに置き換えた
  • モジュールをインポートする翻訳単位と、モジュールのインターフェースの翻訳単位の間で、独立したパースコンテキストを持つことについての調査に関するセクションを追加
  • 読みやすさ向上のための調整

  • P2581 進行状況

P2587R2 to_string or not to_string

std::to_string浮動小数点数出力を修正する提案。

以前の記事を参照

このリビジョンでの変更は、LEWG投票の結果を追記した事、std::formatによって将来的に他の型にstd::to_stringを拡張できることを追記、コードベースの調査結果に関して追記したことなどです。

この提案の変更を受けるのは浮動小数点数用のstf::to_string()だけですが、筆者の方の調査によればそれはあまり使われておらず、ロケールに関するバグがあるものもあったとのことです。

P2611R0 2022-07 Library Evolution Poll Outcomes

2022年の7月に行われた、LEWGでの全体投票の結果。

次の提案が、LWGに転送されることが承認されました

C++23向けの提案の一部は、7月の全体会議でC++23ワーキングドラフト入りが承認されたものが含まれています(全体会議ではこれについてLWGの座長が苦言を呈していたようですが・・・)。

また、投票にあたって寄せられたコメントが記載されています。

P2620R1 Lifting artificial restriction on universal character names

ユニコード文字名によって指定するユニバーサルキャラクタ名(名前付文字エスケープ)を識別子に使用した時の制限を解除する提案。

以前の記事を参照

このリビジョンでの変更は、タイポ修正と提案する文言の改善のみです。

P2621R1 UB? In my Lexer?

字句解析するだけで未定義動作を引き起こすものについて、未定義ではなくする提案。

以前の記事を参照

このリビジョンでの変更は、タイポ修正のみです。

P2623R1 implicit constant initialization

一時オブジェクトへの参照によるダングリング発生を削減する提案。

以前の記事を参照

このリビジョンでの変更は

  • 既存資料からの参照の明確化
  • 説明の明確化
  • Why not beforeセクションを削減
  • constを使用していないstd::initializer_listの例(この提案によって安全になる)を追加

などです。

P2625R0 Slides: Life without operator() (P2511R1 presentation)

P2511(std::nontype)の解説スライド。

P2511はstd::function, std::move_only_function(将来的にはstd::function_refにも)にオーバーヘッドなく簡易なメンバポインタサポートを入れようとする提案です。詳細は以前の記事参照

このスライドはLEWGのメンバに向けてP2511のモチベーションや利点、使用感について説明するものです。

P2626R0 charN_t incremental adoption: Casting pointers of UTF character types

互換性のあるユニコード文字表現を持つ異なる型の間での合法的なポインタキャストを行うライブラリ機能の提案。

char8_t, char16_t, char32_t(以降、ユニコード文字型)などの登場前、C++では任意のユニコード文字(列)を表すのにcharを使用してバイト単位でそれらを扱うことがよく行われていました。しかし、charはそれ自身が文字型であり、しばしばそのエンコーディングUTF-8ではなく、整数型やバイト型と区別されずに使用されています。charは文字、特にユニコード文字を表す型としては適切ではありません。

そのような背景もあってC++11でchar16_t, char32_tが、C++20でchar8_tが導入され、これらを用いて型によって文字エンコーディングを表現することができるようになりました。

しかし、C++20あるいはC++11以前からあるコードでは前述のcharを用いたプラクティスが残っており、またwchar_t等別の方法でユニコード文字(列)を表現するコードが多数存在しています。それらの方法で表現されているユニコード文字列とユニコード文字型のシーケンスによるユニコード文字列との間でコピーや未定義動作を伴うことなく変換する方法がないことから、それらのレガシーなインターフェースにユニコード文字型をアダプトできず、それによってユニコード文字型の使用が妨げられています。

この提案は、ユニコード文字型と既存のchar文字列やwchar_t文字列などとの間で、エンコーディングを仮定しそれを保持したまま変換するための関数を標準ライブラリに追加することで、そのような問題を解決しようとするものです。

ここで提案されているのは、次の2つの関数です

  • std::cast_as_utf_unchecked() : std::byte, char, wchar_tあるいはサイズNの符号なし整数型のポインタから、charN_tのポインタへ変換する
  • std::cast_utf_to<T>() : charN_tのポインタからTのポインタへ変換する
    • Tstd::byte, char, wchar_tあるいはサイズNの符号なし整数型
    • std::cast_as_utf_unchecked()の逆変換

この2つの関数はstd::start_lifetime_as_array()を適切にその文字列に対して適用するのと似たセマンティクスを持ち、ソース領域のオブジェクトのライフタイムを終了させ、指定された型のオブジェクトのライフタイムをその値を保ったまま開始させます。ただし、単なるconstexpr std::start_lifetime_as_array()ではなく、適切な文字エンコーディングサポートのために、追加のコンパイラサポートを必要とします。

有効な変換の例

char8_t char16_t char32_t
char
unsigned char
uit_least_16_t
uit_least_32_t
wchar_t ❗️ ❗️ ❗️
std::byte

行要素->列要素 : std::cast_as_utf_unchecked() 列要素->行要素 : std::cast_utf_to<T>()

表中の❗️は実装定義であることを表します。

これらの関数はその変換にあたって実際にその変換が有効なのかとか、文字がきちんと変換先のエンコーディングになっているのかなどのチェックを一切行いませんが、これらの関数を使用した変換はその意図をコードに反映させる意味も持ちます。例えば、std::cast_as_utf_unchecked()によってchar8_tに変換する場合、変換後の文字列はUTF-8の正しいシーケンスでありそうなっていなければなりません。このように、文字エンコーディングについてのプログラマの暗黙的な仮定をコード上で明確にする役割を担ってもいます。

// 現在よく試みられる方法
void before() {
  // 入力文字列(wchar_tのエンコーディングをUTF-16とする)
  const wchar_t* str = L"Hello 🌎";

  // 多くのプログラマ: ill-formed
  const char16_t* u = static_cast<const char16_t*>(str);

  // 経験10年のプログラマ: UB
  const char16_t* u = reinterpret_cast<const char16_t*>(str);

  // Cのアプローチ: UB in C++
  const char16_t* u = (const char16_t*)(str);

  // rangesの利用: O(n)かかる、contiguous_rangeでなくなる
  auto v = std::wstring_view(str) | std::views::transform(std::bit_cast<char16_t, wchar_t>);

  // 別のメモリにコピー: O(n)、動的確保
  auto v = std::wstring_view(str) | std::views::transform(std::bit_cast<char16_t, char16_t>) 
                                  | std::to<std::vector>;

  // launderの利用: まだUB
  const char16_t* u = std::launder(reinterpret_cast<const char16_t*>(str));

  // エキスパート: constexprではない
  const char16_t* u = std::start_lifetime_as_array(reinterpret_cast<const char16_t*>(str), std::char_traits<wchar_t>::length(str));
}

// この提案
void after() {
  // コピーなし、UBなし、constexpr、明確なセマンティクスを持ち実際には何もしない
  constexpr std::u16_string_view v = std::cast_as_utf_unchecked(L"Hello"sv);
}

利便性向上のため、std::cast_as_utf_unchecked()std::cast_utf_to<T>()にはそれぞれ、string_viewspanを受け取るオーバーロードが用意されます。

P2627R0 WG21 2022-07 Virtual Meeting Record of Discussion

2022/07の全体会議の議事録。

N4916のものよりも、誰がどのような発言をしたかが詳しく記録されています。

P2628R0 Extend barrier APIs with memory_order

std::barrierの各操作に、メモリオーダーを指定できるようにする提案。

std::barrierC++20で追加された、複数スレッドの進行管理に使用することのできる同期プリミティブです。その実体はカウンタであり、.arrive()によってカウンタ減算を、.wait()によってカウンタリセットまでの待機を行います。ほかにも、それらの複合操作である.arrive_and_wait()、同期するスレッドのグループから途中離脱するための.arrive_and_drop()も用意されています。

これらのstd::barrierAPIは全てメモリオーダーを指定するものではなく、.arrive()よりも前に行われた全てのメモリ操作は、.wait()による待機解除後に可視になることが(すべてのスレッドに渡って)保証されます。

この保証はかなり強いもので、場合によってはこの保証が望ましくない場合があります

  1. C++プログラムの外部とやり取りをする場合
    • 例えば、バリアに参加するすべてのスレッドがそれぞれファイルを開いて読み書きする時、.arrive_and_wait(1, memory_order_relaxed)によってすべてのスレッドがファイルを閉じたことを同期する。
      • この場合、メモリの可視性(ファイルクローズの可視性)はファイルシステムによって確保される
    • 例えば、すべてのスレッドはvolatile操作によって実行環境(マシン)の設定を行ってから、その中の1つのスレッドが環境をスタートさせるような場合。これは、memory_order_relaxed.arrive()/.wait()操作と、std::barrierの完了関数によって実現できる。
      • この場合、volatileな書き込みが環境開始時(待機解除後)に可視になっていればよく、それはvolatile操作によって保証される。
  2. オブジェクトフェンス(P2535)を利用する場合
    • 一部のメモリ操作についてのみ可視になればよく、すべてのメモリ操作にその保証は必要ない

この提案は、これらの場合などに、std::barrierを用いたより効率的な同期を可能とするために、std::barrierAPIstd::memory_orderを追加で受け取れるように拡張しようとするものです。

上記2のケースのサンプル

現在 この提案
// Thread 0:
x = 1;
atomic_object_fence(memory_order_release, x);
bar.arrive(); // release fence

// Thread 1
bar.arrive_and_wait(); // acquire fence
atomic_object_fence(memory_order_acquire, x);
assert(x == 1);
// Thread 0:
x = 1;
atomic_object_fence(memory_order_release, x);
bar.arrive(1, memory_order_relaxed); // no fence

// Thread 1
bar.arrive_and_wait(memory_order_relaxed); // no fence
atomic_object_fence(memory_order_acquire, x);
assert(x == 1);

現在の例では、この場合のatomic_object_fenceは意味がありません(std::barrierがより強い同期を保証しているため)。この提案後、memory_order_relaxedと共にstd::barrierを使用することでstd::barrierの保証がほぼなくなり、atomic_object_fenceによって特定の変数のメモリ可視性のみが保証されるようになります(それによって、同期のコスト低減が可能となりうる)。

この提案の内容はstd::latchをはじめとした他の同期プリミティブにも適用可能ですが、この提案では現在のところ、std::barrierにのみ焦点を絞っています。

P2629R0 barrier token-less split arrive/wait

std::barrierをデータパイプラインの実装においてより効率的に使用できるように拡張する提案。

データパイプラインはデータ処理のモデルの一つで、データ処理の1連の流れを並列化可能なブロックに区切り、それら処理ブロックをパイプライン化することで高速化を図るものです。データパイプラインによる処理は例えば、ディープラーニングやHPCアプリケーションにおいて一般的に行われいます。

そのようなところでは典型的に、各パイプラインステージにおいてproducer-consumerパターンによるリソース共有が行われます。producerとconsumerの2つのスレッドグループは、バリアのペアを使用して共有リソースへのアクセスを同期します。

consumerスレッドはconsumerバリアによってリソース読み取りを待機(wait)し、バリアが解除されるとリソースを使用し、使用終了するとproducerバリアに到着(arrive)して共有リソースの再利用(更新)が安全であることをproducerに通知します。

producerスレッドはproducerバリアによってリソース更新を待機(wait)し、バリアが解除されるとリソースを更新し、更新終了するとconsumerバリアに到着(arrive)して共有リソースの使用(読み取り)が安全であることをconsumerに通知します。

この同期パターンでは、consumerスレッドはconsumerバリアに到着することなく待機し、producerバリアには待機することなく到着します(producerスレッドの場合はこの逆)。

このパターンのバリアにstd::barrierを使用しようとすると、std::barrierの同期がバリア上で待機する前に到着することを要求するため、同期が非効率になってしまいます。

#include <barrier>

// 共有リソース
// 1つのオブジェクトを使いまわす
int shared_resource = 0;

// producerバリア
// consumer -> producer への読み込み終了通知
std::barrier producer_barrier{2};
// consumerバリア
// producer -> consumer への書き込み終了通知
std::barrier consumer_barrier{2};

// リソースを更新する関数
void produce(int&);
// リソースを消費する関数
void consume(const int&);

// producer本体
void producer() {
  // consumerバリアのカウントを1にしておく(デッドロック回避のため)
  std::ignore = consumer_barrier.arrive();

  while(true) {
    // consumerスレッドの読み込み終了を待機
    producer_barrier.arrive_and_wait();       // A
    // リソース更新
    produce(shared_resource);
    // consumerスレッドへ書き込み終了を通知
    std::ignore = consumer_barrier.arrive();  // B
  }
}

// consumer本体
void consumer() {
  while(true) {
    // producerスレッドの書き込み終了を待機
    consumer_barrier.arrive_and_wait();       // A
    // リソース利用
    consume(shared_resource);
    // producerスレッドへ読み込み終了を通知
    std::ignore = producer_barrier.arrive();  // B
  }
}

int main() {
  std::jthread th{producer};
  consumer();
}

問題があるのはサンプル中のAB(コメント)のところです。Aのところでは到着(.arrive())は必要なく待機(.wait())だけが必要であり、Bのところではarrive()によって発行されるバリアトークンが不用で、[[nodiscard]]による警告を消すために余計なコードが必要になります。

このAで起こる不要なarrive()Bで起こる不要なバリアトークンの発行がこのようなパターンの実装時のオーバーヘッドとなり、しかも現在のstd::barrierAPIでは回避できません。

この提案は、この問題の解決(このようなパターンの効率実装)のために、std::barrierに「到着しない待機」と「待機しない到着」を行うためのAPIを追加する提案です。提案では、「到着しない待機」のために.wait_parity(bool)、「待機しない到着」のために.arrive_and_discard()を追加することを提案しています。これらを用いると上記のサンプルは次のように書くことができます

#include <barrier>

// 共有リソース
// 1つのオブジェクトを使いまわす
int shared_resource = 0;

// producerバリア
// consumer -> producer への読み込み終了通知
// producerスレッドにおける到着を考慮する必要が無くなり、同期スレッド数は-1される
std::barrier producer_barrier{1};
// consumerバリア
// producer -> consumer への書き込み終了通知
// consumerスレッドにおける到着を考慮する必要が無くなり、同期スレッド数は-1される
std::barrier consumer_barrier{1};

// リソースを更新する関数
void produce(int&);
// リソースを消費する関数
void consume(const int&);

// producer本体
void producer() {
  // consumerバリアのカウントを0にしておく(デッドロック回避のため)
  // ここで最初のバリアフェーズが完了する(0 -> 1)
  consumer_barrier.arrive_and_discard();

  bool phase = false;
  while(true) {
    // consumerスレッドの読み込み終了を待機
    producer_barrier.wait_parity(phase);    // A
    // バリアフェーズの更新
    phase = !phase;
    // リソース更新
    produce(shared_resource);
    // consumerスレッドへ書き込み終了を通知
    consumer_barrier.arrive_and_discard();  // B
  }
}

// consumer本体
void consumer() {
  bool phase = false;
  while(true) {
    // producerスレッドの書き込み終了を待機
    // 1番最初は、フェーズ1に対してフェーズ0で待機するため、すぐブロック解除
    consumer_barrier.wait_parity(phase);    // A
    // バリアフェーズの更新
    phase = !phase;
    // リソース利用
    consume(shared_resource);
    // producerスレッドへ読み込み終了を通知
    producer_barrier.arrive_and_discard();  // B
  }
}

int main() {
  std::jthread th{producer};
  consumer();
}

Aの点では.wait_parity()によってバリアのカウントに触ることなく待機し、Bの点では.arrive_and_discard()によって待機せず(バリアトークンを発行せず)に到着しています。これによって、現在のAPIでは回避できなかった不要な到着と待機(バリアトークン発行)を回避することができており、なおかつそれは実装に通知されるためより効率的な同期となることが期待できます。

また、これによってそれぞれのスレッドにおいての不要な到着を管理するために本来不要な同期数(std::barrierのコンストラクタに渡している値)の+1が必要なくなってもいます(もっとも、これは実際のスレッド数と一致する以外の恩恵はなさそうですが)。

.wait_parity()に渡しているboolphaseは、ある連続した2回のバリアフェーズ(std::barrierによる1回の同期)を識別するためのもので、true/falseによって異なるバリアフェーズに対する.wait()を行うものです。これは、.wait_parity()による待機がどのバリアフェーズに対するものなのかを識別するためのものです。

std::barrierによる同期では、ある同期ポイント(.arrive_and_wait())に指定した数のスレッドが到達した後、完了関数を実行してから内部カウンタをリセットし待機しているスレッドグループを再開、という流れを繰り返すものです。この1つの流れ(同期ポイントから同期ポイントの間の処理)のことをバリアフェーズと呼び、std::barrierの保証によって各バリアフェーズは時間方向に順番に実行されるため最初を0としてインデックスを振ることができます。

そのようなバリアフェーズのインデックスについて、trueの場合に奇数インデックスfalseの場合に偶数インデックスとしてbool値を対応づけて、このbool値のことをパリティと呼びます。

バリアフェーズの進行(インデックス)とパリティの関係

phase  :   0   ->   1  ->   2   ->   3  ->   4   ->   5  -> ...
parity : false -> true -> false -> true -> false -> true -> ...

.wait_parity()に渡すbool値は、待機対象のパリティ(すなわちバリアフェーズ)を指定するもので、指定したパリティtrue/false)から次のパリティへ変化するフェーズを待機します。例えば、wait_parity(false)とした場合は偶数インデックスのバリアフェーズを待機し、待機が解除されたら奇数インデックスのバリアフェースが開始しています。もし現在のフェーズのパリティと指定されたパリティが異なる場合、それは1つ前のフェーズに対する待機だとして処理(すなわちすぐブロック解除)されます。

P2630R0 Submdspan

std::mdspanの部分スライスを取得する関数submdspan()の提案。

この提案は以前はstd::mdspanの提案(P0009)に含まれていましたが、std::mdspanC++23に間に合わせるための時間の制約から切り離され、個別の提案として議論していくことになりました。

mdspanとそこでのsubmdspanについては以前の記事を参照

submdspanによってstd::mdspanから部分領域をスライスとしてstd::mdspanで取り出すことができると、std::mdspanを受け取るように実装されたより小さい問題を効率的に解決する関数を再利用することができるようになります(行列積における、各要素ごとの内積計算など)。

提案文書より、長方体内の3次元格子を表すstd::mdspanの表面領域を0クリアするサンプル

// 2次元領域をゼロクリアする
template<class T, class E, class L, class A>
void zero_2d(mdspan<T, E, L, A> grid2d) {
  // ランク2(2次元)のmdspan
  static_assert(grid2d.rank() == 2);

  for(int i = 0; i < grid2d.extent(0); ++i) {
    for(int j = 0; j < grid2d.extent(1); ++j) {
      grid2d[i,j] = 0;
    }
  }
}

// 長方体表面をゼロクリアする
template<class T, class E, class L, class A>
void zero_surface(mdspan<T, E, L, A> grid3d) {
  // ランク3(3次元)のmdspan
  static_assert(grid3d.rank() == 3);

  // 6つの表面毎に、2次元平面をゼロクリアするzero_2d()を再利用
  zero_2d(submdspan(grid3d, 0, full_extent, full_extent));
  zero_2d(submdspan(grid3d, full_extent, 0, full_extent));
  zero_2d(submdspan(grid3d, full_extent, full_extent, 0));
  zero_2d(submdspan(grid3d, grid3d.extent(0) - 1, full_extent, full_extent));
  zero_2d(submdspan(grid3d, full_extent, grid3d.extent(1) - 1, full_extent));
  zero_2d(submdspan(grid3d, full_extent, full_extent, grid3d.extent(2) - 1));
}

submdspan()std名前空間に定義されるフリー関数です。1つ目の引数にstd::mdspanを取り、2つ目以降の引数でスライス指定を次元毎に受け取ります。スライス指定は、1つ目の引数をx、対応する次元の番号をdとして[0, x.extent(d))の範囲の値のどの要素が返されるstd::mdspanに含まれているかを指定します(std::pair{4, 10}のようにして任意の範囲を指定できます)。

この提案ではP0009から切り離したsubmdspan()を個別提案として再構成するとともに、次の変更を加えています

  • スライス指定として指定可能なものにstrided_index_range型を追加
    • 以前は、整数値(先頭からの要素数)、2要素tuple-like(範囲指定)、full_extent_t(全体の指定)
    • strided_index_rangeは、開始インデックスと長さを指定することで範囲指定ができるほか、ストライド(要素間隔)の指定も可能
  • ユーザー定義のレイアウトポリシーを使用可能なようにカスタマイゼーションポイントを追加
    • 入力mdspan<T, E, L, A>L(レイアウトマッピングクラス)をカスタムするためにsubmdspan_mapping()submdspan_offset()を追加
    • submdspan()内部ではこの2つの関数をLのオブジェクトを用いたADLによって呼ぶことで、この2つの関数をL毎にカスタム可能にする
    • これらの結果を用いて、返すmdspanのレイアウトポリシーとアクセサ、データハンドル(領域を参照する方法、通常はポインタ)を取得する
  • スライスをコンパイル時の値として指定する機能を追加
    • スライス指定が定数値で指定されたときに、それを返すmdspanのエクステントテンプレートパラメータに埋め込む

submdspan()の実装イメージ

template<class T, class E, class L, class A,
         class ... SliceArgs>
constexpr auto submdspan(const mdspan<T, E, L, A>& src, SliceArgs... args) {
  // 部分mdspanのオフセット取得
  size_t sub_offset = submdspan_offset(src.mapping(), args...); // ADLによるカスタマイズポイント
  // レイアウトマッピングの取得
  auto sub_map = submdspan_mapping(src.mapping(), args...);     // ADLによるカスタマイズポイント
  // アクセサの取得
  typename A::offset_policy sub_acc(src.accessor());  // A::offset_policy入れ子型によるカスタマイズポイント
  // データハンドルの取得
  typename A::offset_policy::data_handle_type         // A::offset_policy::data_handle_type入れ子型によるカスタマイズポイント
    sub_handle = src.accessor().offset(src.data_handle(), sub_offset);

  return mdspan(sub_handle, sub_map, sub_acc);
}

この提案はわざわざP0009から分離したこともあり、おそらくC++23には間に合わないと思われます。

P2633R0 thread_local_inherit: Enhancing thread-local storage

呼び出し元でのスレッドローカル変数の値を引き継いで初期化されるスレッドローカル変数を作成するための、thread_local_inherit指定の提案。

スレッドローカル変数はスレッド生成時に呼び出し元のスレッドの対応する変数が持っている値とは無関係に初期化されます。しかし、呼び出し元スレッドの対応するスレッドローカル変数の値を引き継ぎつつ、後からその値を更新したい場合があるようです。そのような変数はスレッドで実行する関数の引数として渡すこともできますが、スレッド起動時点ではその変数を用意できない場合があるほか、スレッドを起動しているプログラマ(コード)が呼び出すスレッドに関しての情報を持っていない場合もあります。スレッドにデータを渡す他の方法は面倒であり、スレッド起動側/スレッド内処理側のプログラマがそれぞれどのようなデータを受け渡す必要があるかを知っている必要があります。

呼び出されたスレッドに必要なデータを必要な時点で必要な場所に自動的に供給可能な言語機能にはメリットがあり、それを実現可能なthread_local_inheritという新しいスレッドローカルストレージ指定を導入しようとする提案です。

thread_local_inherit変数は、初期化周りのこととtrivially copyableな型にしか指定できないこと以外はthread_local変数と同じ性質を持ちます。

メインスレッドにおけるthread_local_inherit変数はthread_local変数と同様に初期化され、子スレッドのthread_local_inherit変数は呼び出し元スレッドの変数の値を単純にコピーすることによって静的初期化されます。thread_local_inherit変数に対する動的初期化はその変数がまだ初期化されていない場合にのみ起こり、これはおそらくメインスレッドでの最初の初期化時にのみ起こります。

#include <future>
#include <iostream>

thread_local int th1 = 0;
thread_local_inherit int th2 = 0;

void f() {
  std::cout << "thread_local : " << th1 << "\n";
  std::cout << "thread_local_inherit : " << th2 << "\n";
}

int main() {
  std::ignore = std::async(f);
  th1 = 1;
  th2 = 1;
  std::ignore = std::async(f);
  th1 = 2;
  th2 = 2;
  std::ignore = std::async(f);
}
thread_local : 0
thread_local_inherit : 0
thread_local : 0
thread_local_inherit : 1
thread_local : 0
thread_local_inherit : 2

P2634R0 Allow qualifiers in constructor declarations

コンストラクタに、const/volatileおよび参照修飾を行えるようにする提案。

オブジェクトがconstで構築される時とそうでない時、あるいは左辺値で構築される時と右辺値で構築される時、これらによって呼び出すコンストラクタを分けると便利な場合があります。メンバ関数では、CV/参照修飾によって呼び出される関数をオブジェクトの状態毎に定義することができますが、コンストラクタにはそれらの修飾が行えないため、現在はこのようなことはできません。

この提案は、コンストラクタにCV/参照修飾できるようにすることでコンストラクタを構築のされ方によって呼び分けることができるようにしようとするものです。

struct S {
  S() &;        // #1、左辺値として構築する際のコンストラク
  S() &&;       // #2、右辺地として構築する際のコンストラク
  S() const &;  // #3、const左辺値として構築する際のコンストラク
};

S x;          // #1が呼ばれる
S();          // #2が呼ばれる
const S y;    // #3が呼ばれる
new const S;  // #3が呼ばれる

これは例えば、deleteと共に使用して右辺値としての構築を禁止したり、constとして構築された時にだけコンストラクタの処理を単純化する、などの用法がありそうです。

P2635R0 Enhancing the break statement

break文を拡張して、ネストしたループにおける利便性を改善する提案。

ネストしたループにおいて内側のループをbreakで終了させる時、終了直後に何かしたい(特に、外側のループの継続に関して)場合があります。このような場合、典型的にはフラグを使用するかgotoを使用する必要があります。

void before1(std::ranges::range auto&& range_i) {
  for (auto&& range_j : range_i) {
    bool broke = false;

    for (auto j : range_j) {
      if (cond(j)) {
        // ループを継続する処理
        ...
      } else {
        // ループを中断する
        broke = true;
        break;
      }
    }
    
    if (broke) {
      continue;
    } else {
      // 内側ループが完了した後の処理
      ...
    }
  }
}

void before2(std::ranges::range auto&& range_i) {
  for (auto&& range_j : range_i) {
    for (auto j : range_j) {
      if (cond(j)) {
        // ループを継続する処理
        ...
      } else {
        // ループを中断する
        goto broke;
      }
    }
    
    // 内側ループが完了した後の処理
    ...

    // 内側ループの中断先
    broke: ;
  }
}

他にも関数にするとかラムダ式を使うとかありますが、いずれにせよこのような場合にbreakの効果は不十分でした。

この提案は、breakを拡張してこのような場合に余分なコードを最小にしつつ同じことを達成できるようにするものです。

提案では、break statement;という構文を許可するようにし、break直後にstatementを実行させるようにします。

void after(std::ranges::range auto&& range_i) {
  for (auto&& range_j : range_i) {
    for (auto j : range_j) {
      if (cond(j)) {
        // ループを継続する処理
        ...
      } else {
        // ループを中断し、外側ループではcontinueする
        break continue;
      }
    }

    // 内側ループが完了した後の処理
    ...
  }
}

break statement;statementにはreturnとかbreakそのものもおく事ができで、多重ループからの脱出にはbreak break ...;と書く事ができます

void after(std::ranges::range auto&& range_i) {
  for (auto&& range_j : range_i) {
    for (auto j : range_j) {
      if (cond(j)) {
        // 条件を満たしたら2重ループから脱出
        break break;
      }
    }
  }
}

おわり

この記事のMarkdownソース