はじめに
以前、「C++20/23 の導入効果 ~従来との比較~」 という記事を書きました。そこでは C++20 以降の新機能全般について概説し、従来のコードと比較しながらどのように開発効率やコード品質が向上するかを紹介しました。
筆者はたまにC#も使用するので、この記事では、Modern C++(C++11~23) と C# における並行プログラミングの比較をしてみます。
マルチコアが当たり前となった現代のプログラミングでは、複数スレッドを駆使してアプリケーションを効率よく動かす並行プログラミングが重要です。本記事では、Modern C++ (C++11~C++23) と C# における並行プログラミング手法や言語仕様、ライブラリの特徴を比較し、要点を整理します。
注意
本記事で扱う「並行プログラミング」とは、スレッドを使って同時に処理を進めるための仕組みや排他制御、スレッド間の協調などを中心に説明しています。
I/O 処理などの文脈でよく使われる「同期処理 (synchronous) / 非同期処理 (asynchronous)」とは別の意味合いですので混同しないようにご注意ください。
1. スレッドの管理
1.1 C++11~C++17:std::thread
によるネイティブスレッド管理
C++11 で <thread>
ヘッダが標準化され、std::thread
クラスを使ってネイティブスレッドを直接扱えるようになりました。
- スレッドの生成、終了待ち (
join
)、切り離し (detach
) はシンプルな API で利用可能です。 - ただし、スレッドのキャンセル (途中停止) を安全に行う方法は標準には無く、多くの場合「フラグをチェックしてループを抜ける」といった協調的な終了手法が必要でした。
#include <thread>
#include <iostream>
void worker() {
std::cout << "Worker thread\n";
// ここで何らかの処理を実行
}
int main() {
std::thread t(worker);
t.join(); // スレッド終了を待機
}
1.2 C++20~C++23:std::jthread
& std::stop_token
で安全な終了要求
C++20 では、スレッドのスコープ管理やキャンセル要求が容易になるよう、std::jthread
と std::stop_token
が導入されました。
-
std::jthread
はスコープ破棄時に自動でjoin()
を呼ぶため、join
の呼び忘れなどを防げます。 -
std::stop_token
と組み合わせると、呼び出し側がrequest_stop()
を発行し、スレッド側はstop_requested()
をチェックしてループを抜けるといった、協調的な停止 が簡潔に書けます。
#include <thread>
#include <stop_token>
#include <iostream>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
std::cout << "Working...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
std::cout << "Stopped\n";
}
int main() {
std::jthread t(worker);
std::this_thread::sleep_for(std::chrono::seconds(2));
t.request_stop(); // スレッドへ停止要求を送る
}
補足
- C++23 時点でも、標準ライブラリにはスレッドプールのような高レベル機能がありません。大量の並列タスクを効率よく実行するには、Boost などサードパーティ製のフレームワークや自前実装が必要になることがあります。
- 今後の C++ 仕様やライブラリ拡張 (Executors, Concurrency TS など) によって、より高レベルな並行機構が標準化される可能性もあります。
1.3 C#:Task
+ ThreadPool
による高レベルな並列実行
C#/.NET には低レベルの Thread
クラスもありますが、実際には Task
と ThreadPool
を組み合わせるのが一般的です。
-
Task.Run(...)
でランタイムのスレッドプールに仕事を投げると、.NET が適切にスレッドを割り当てて実行してくれます。 - スレッドの強制停止はサポートされていないため、
CancellationToken
を使ってキャンセル要求を出し、タスク側で協調的に終了させるのが基本です。
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var task = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("Working...");
await Task.Delay(500, token);
}
Console.WriteLine("Stopped");
}, token);
await Task.Delay(2000);
cts.Cancel(); // キャンセル要求
await task; // タスク終了を待つ
}
}
ポイント
- C# はスレッドプールが標準搭載されているため、開発者が細かいスレッド生成・破棄を意識する必要があまりありません。
- キャンセルも
CancellationToken
で統一的に扱うため、コードがシンプルかつ安全になりやすいです。
2. 排他制御やスレッド間協調
2.1 C++ の排他制御
C++11 以降、以下のように多種多様な排他オブジェクトが標準ライブラリで利用可能になりました。
-
std::mutex
,std::recursive_mutex
,std::timed_mutex
-
std::shared_mutex
(C++17) … リーダー・ライターロックを実現 -
std::condition_variable
,std::condition_variable_any
… 条件変数による待機・通知
#include <mutex>
#include <shared_mutex>
#include <vector>
#include <thread>
#include <iostream>
std::shared_mutex g_sharedMutex;
std::vector<int> data;
void reader() {
// リーダー・ロック(共有ロック)の例
std::shared_lock lock(g_sharedMutex);
for (auto v : data) {
std::cout << v << " ";
}
std::cout << "\n";
}
void writer(int x) {
// ライター・ロック(排他ロック)の例
std::unique_lock lock(g_sharedMutex);
data.push_back(x);
}
int main() {
data = {1, 2, 3};
std::thread t1(reader), t2(writer, 4), t3(reader);
t1.join(); t2.join(); t3.join();
}
2.2 C# の排他制御
C# では lock
キーワード (実態は Monitor.Enter/Exit
) が最もシンプルで使用頻度の高い排他制御機構です。
private static object _lockObj = new object();
private static List<int> data = new List<int>();
static void Reader() {
lock (_lockObj) {
foreach (var v in data) {
Console.Write(v + " ");
}
Console.WriteLine();
}
}
static void Writer(int x) {
lock (_lockObj) {
data.Add(x);
}
}
また、以下のようなクラスでより高度な制御が可能です。
-
Mutex
… プロセス間でも利用できるミューテックス -
SemaphoreSlim
… 同時に許可するスレッド数を制限可能 -
ReaderWriterLockSlim
… リーダー・ライターロック -
ManualResetEventSlim
,AutoResetEvent
… イベント駆動型の待機・通知
比較のポイント
- C++ では
std::shared_mutex
が標準で提供され、リーダー・ライターロックを直接使えます。C# ではReaderWriterLockSlim
という専用クラスを使う形です。 - 簡単な排他制御だけなら C# の
lock
が非常に分かりやすく記述量も少ないです。C++ でもstd::lock_guard
やstd::unique_lock
を用いれば多少はシンプルになりますが、キーワードとしては提供されていません。
3. メモリモデルとアトミック操作
3.1 C++ のメモリモデル
C++11 で厳密なメモリモデルが定義され、std::atomic<T>
によるアトミック操作や memory_order
を細かく指定できる低レベル制御が可能になりました。
-
std::atomic<int>
などでstore
,load
,compare_exchange_strong
といった操作を行える。 -
memory_order_relaxed
,memory_order_acquire
などの指定でハードウェアの最適化と同期の強さをコントロールできる。 - 正しく使えば高いパフォーマンスが期待できますが、誤用するとバグが発生しやすいため高度な知識が要求されます。
3.2 C# のメモリモデル
C#/.NET も CLR のメモリモデル によってスレッドセーフティが担保されていますが、開発者が直接メモリバリアを細かく制御する仕組みは用意されていません。
-
Interlocked
クラスでアトミック操作を行える (Interlocked.Increment
,Interlocked.CompareExchange
など)。 -
volatile
キーワードで可視性を強制できますが、C++ のような多様なメモリオーダー指定はできません。 - 一般的には
lock
(Monitor) やInterlocked
で十分なスレッドセーフティが得られるため、細かな制御はあまり必要になりません。
4. 並列処理を支援する高レベル API
4.1 C++:Parallel STL
C++17 で Parallel STL が導入され、一部の標準アルゴリズムに並列実行ポリシーを指定できるようになりました。
#include <algorithm>
#include <execution>
#include <vector>
int main() {
std::vector<int> v = { /* ... */ };
// 並列ソート
std::sort(std::execution::par, v.begin(), v.end());
}
-
std::execution::par
を指定すると、処理系が可能であれば並列化します。 - 対応していないアルゴリズムや、並列化の度合いは実装依存であり、まだ成熟途上の面があります。
4.2 C#:Task Parallel Library (TPL), PLINQ
C# には Task Parallel Library (TPL) と PLINQ (Parallel LINQ) が充実しており、手軽に並列化を実現できます。
var data = Enumerable.Range(0, 100000).ToList();
// Parallel.ForEach
Parallel.ForEach(data, x =>
{
// x に対する処理を並列で実行
});
// PLINQ
var result = data.AsParallel()
.Select(x => SomeHeavyCompute(x))
.ToList();
- 内部的には .NET の
ThreadPool
を使い、開発者がスレッド管理をする必要はありません。 -
AsParallel()
の一言で大規模データ処理を並列化できるのは、大きな利点です。
5. 総合比較
5.1 スレッド管理
-
C++: ネイティブスレッド (
std::thread
/std::jthread
) を直接制御。スレッドプールは標準外。停止要求にはstop_token
(C++20~)。 -
C#:
ThreadPool
+Task
が基本。キャンセルはCancellationToken
。スレッド生成や破棄をランタイムが一括管理。
5.2 排他制御
-
C++:
std::mutex
,std::shared_mutex
,std::condition_variable
など豊富な低レベル機能。リーダー・ライターロック (std::shared_mutex
) も標準提供。 -
C#:
lock
(Monitor) やSemaphoreSlim
,ReaderWriterLockSlim
。非常にシンプルに書ける一方、C++ より抽象度が高い実装が中心。
5.3 メモリモデル & アトミック
-
C++: 厳密なメモリモデルで、
std::atomic<T>
とmemory_order
を細かく指定可能。ロックフリー/高パフォーマンス実装に向いている。 -
C#: CLR のメモリモデルがベース。
Interlocked
でアトミック操作を行うが、複雑なバリア指定は行わないのが前提。
5.4 高レベルな並列化支援
- C++ (C++17~): Parallel STL があるが、対象アルゴリズムや並列度は処理系依存。コルーチン (C++20) と組み合わせた非同期ライブラリも増加中。
-
C#: TPL (
Parallel.For
,Parallel.ForEach
,Task
など) や PLINQ で開発者の負担が少なく並列実行可能。スレッドプール活用も自動。
6. 簡易比較表
項目 | Modern C++ (C++11~23) | C# (.NET) |
---|---|---|
スレッド管理 | - std::thread / std::jthread でネイティブスレッドを直接管理- request_stop() / stop_token で協調的停止 (C++20~)- 標準スレッドプールは未整備 |
- .NET ランタイムの ThreadPool を標準搭載- Task.Run(...) でタスク化して実行- CancellationToken でキャンセル要求 |
排他制御 | - std::mutex , std::recursive_mutex , std::timed_mutex - std::shared_mutex (リーダー・ライターロック)- std::condition_variable でスレッド間待機/通知 |
- lock (Monitor) でシンプルに排他- ReaderWriterLockSlim , SemaphoreSlim など豊富- ManualResetEventSlim , AutoResetEvent
|
メモリモデル & アトミック操作 | - 言語仕様レベルで厳密なメモリモデル - std::atomic<T> と memory_order を駆使したロックフリー実装も可能- 高性能と柔軟性を得られるが、難易度は高い |
- CLR がメモリモデルを管理 - Interlocked クラスで基本的なアトミック操作 (Increment , CompareExchange など)- volatile で可視性を保証するが、詳細なバリア指定は不可 |
並列アルゴリズム (高レベル API) | - Parallel STL (std::execution::par ) (C++17~)- 実装やアルゴリズムの対応状況は過渡期 - コルーチン (C++20) と組み合わせた非同期フレームワークも増加中 |
- TPL (Parallel.For , Parallel.ForEach ) で簡単並列化- PLINQ ( AsParallel() ) でデータ処理を並列化- 全て .NET のスレッドプールを自動活用 |
代表的な特徴 | - ローレベル機能を細かく制御可能 - 高いパフォーマンスと柔軟性を実現 - 標準スレッドプールやタスクスケジューラは未整備 |
- 高レベル API ですぐに並列化が可能 - .NET ランタイムがスレッドプールやキャンセル周りを包括的にサポート- 学習コストが低め |
7. よくある質問(FAQ)
Q1. C++ には C# のような標準スレッドプールはないのですか?
A1. ありません(C++23 時点)。C++ は標準でネイティブスレッド (std::thread
/ std::jthread
) を提供するのみで、プールやタスクキューなどの高レベル機能はサポートされていません。
ただし、Boost などサードパーティライブラリを使ったり、自前でスレッドプールを実装したりすることで同様の仕組みを構築することは可能です。
Q2. C# でスレッドを強制終了 (Thread.Abort
) するのはダメですか?
A2. 非推奨 です。Thread.Abort
は非常に危険で、デッドロックやリソースリークなど多くの問題を引き起こす可能性があります。代わりに、CancellationToken
を使ってキャンセル要求を行い、スレッド(タスク)側で協調的に抜けるアプローチが推奨されます。
Q3. C++ のコルーチン (co_await
, co_yield
) は C# の async/await
と同じですか?
A3. 表面的な記述やステートマシン自動生成といった点で似ていますが、C++ のコルーチンはかなり低レベル です。C# の async/await
は .NET の Task
やランタイム機能と統合されており、スレッドプールや非同期 I/O と簡単に連携できます。一方、C++ は言語機能としてコルーチンをサポートするだけで、ランタイムやスケジューラ部分はライブラリ任せとなっています。
Q4. C++ の Parallel STL と C# の TPL (PLINQ) は同じように使えますか?
A4. 目的(アルゴリズムを並列化する)は類似していますが、実装や成熟度が異なります。C# の TPL / PLINQ は .NET
のスレッドプールを自動活用し、高い抽象度で書けるため開発効率が良いです。一方、C++ の Parallel STL は処理系に依存する部分が大きく、すべてのアルゴリズムが並列化されているわけではないなど、まだ成熟途中と言えます。
Q5. C# の lock
と C++ の std::lock_guard
の違いは何ですか?
A5. C# の lock
は言語キーワードであり、Monitor.Enter/Exit
をラップして糖衣構文化しています。一方、C++ の std::lock_guard
は標準ライブラリのクラスです。
いずれも「スコープを抜けたらアンロックする」という点では共通していますが、C# はキーワードとして組み込まれているため記述がよりシンプルです。C++ では RAII(Resource Acquisition Is Initialization)を用いてクラスで実装しています。
Q6. C++ の並列化で I/O 操作を効率的に扱うにはどうすればいいですか?
A6. 標準ライブラリには、並列 I/O をシンプルに書くための仕組みはほとんどありません。通常は Boost.Asio や ASIO 単体ライブラリなどを使い、非同期 I/O とイベントループ を組み合わせるのが一般的です。C# の async/await
は非同期 I/O に最適化されていますが、これは並行プログラミングというより「非同期 I/O」の分野と捉えてください。
Q7. マルチスレッド化すれば必ず性能が上がりますか?
A7. いいえ。マルチスレッド化にはスレッド管理や排他制御のオーバーヘッドが伴います。計算量が少ない場合や、スレッド間のデータ共有が多い場合などは、かえって遅くなるケースもあります。
性能を測定しながら、「並列化する箇所が十分に大きい」「スレッド間の衝突が少ない」といった状況で効果的に導入することが重要です。
Q8. C++ にガーベジコレクション(GC) はありませんが、マルチスレッドと組み合わせるのは大変では?
A8. C++ には標準的な GC はありません。しかし、C++ は RAII によるリソース管理が基本であり、スマートポインタ (e.g. std::unique_ptr
, std::shared_ptr
) を使うことでメモリ管理を安全に行えます。
マルチスレッド環境でも RAII を徹底し、排他制御を適切に行うことで GC がなくても安全かつ効率的にリソース管理が可能です。
Q9. C# でロックフリーのデータ構造を実装できますか?
A9. 可能ですが難易度は高い です。C# でも Interlocked
クラスを利用すればアトミックな操作を行えます。ただし、C++ のように細かいメモリオーダー指定がないため、ロックフリー実装は一般的に C++ より制限が大きいです。高性能が要求される場合は、特別なテクニックや検証が必要になります。
Q10. C++ と C#、どちらを選べばよいのでしょうか?
A10. プロジェクト要件次第です。組み込み・ゲーム・高パフォーマンスコンピューティングなどでは C++ の低レベル制御や効率性が有用な場合が多いです。一方、Windows / .NET 環境で素早くアプリケーションを開発したい場合や、フレームワークが整備された環境で生産性を重視するなら C# が適しています。チームのスキルセットやサポート環境も含めて総合的に判断しましょう。
8. まとめ
-
C# は .NET ランタイムが並列実行環境をしっかり管理 しており、開発者は高レベルの API (
Task
,Parallel
,PLINQ
) を使うだけで手軽に並列化できます。排他制御やキャンセルも整備されているため、開発効率が高い という大きな利点があります。 -
モダンC++ (C++11~23) は 低レベルから厳密に定義された並行プログラミング機構 を備えており、スレッドやメモリモデル、ロックフリー技法などを自在に扱えます。高パフォーマンスやシステムレベルでの微細な制御が必要な現場には強みがあります。ただし、スレッドプールや大規模タスク実行の標準機構がまだなく、外部ライブラリや自前フレームワークで補うケースが一般的です。
結論としては、プロジェクトの要件・性能要件・開発環境・チームのスキル などを考慮して最適な言語を選択するのがベストです。C++ はコルーチン (C++20) と並列アルゴリズムが今後さらに進化していく可能性があり、C# も新バージョンでより洗練されたタスク並列や非同期機構が登場し続けています。両言語ともアップデート動向に注目しておきましょう。
9. 参考:C# 13 の新機能
C# 13が、.NET 9 とともに2024年11月13日(日本時間)に正式リリースされました。以下は、C# 13 における参考情報です。並行プログラミングについては直接的な変更はありませんが、言語機能の強化が行われています。
C# 13 では、並行プログラミングに直接関連する新機能の追加はありませんが、言語全体の機能強化が行われています。主な新機能は以下のとおりです。
- 新しいエスケープシーケンス `\e` の導入
- エスケープ文字 (Unicode U+001B) を表す新しいエスケープシーケンス `\e` が追加されました。
- メソッドグループでの自然型の改善
- オーバーロード解決時のメソッドグループにおける自然型の推論が改善され、コンパイラの最適化が進みました。
- オブジェクト初期化子での暗黙的なインデクサーアクセス
- オブジェクト初期化子内でインデクサーを暗黙的に使用できるようになり、コードの簡潔さと可読性が向上しました。
これらの機能強化により、C# の表現力と開発者の生産性が向上しています。
参考リンク
- cppreference - Concurrency support library
- Microsoft Docs - Task Parallel Library (TPL)
- Microsoft Docs - Parallel LINQ (PLINQ)
以上