はじめに
以前、「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...");
// CancellationToken を Task.Delay に渡すことで、キャンセル時に即時終了できる
try {
await Task.Delay(500, token);
} catch (OperationCanceledException) {
Console.WriteLine("Delay canceled.");
break;
}
}
Console.WriteLine("Stopped");
}, token);
await Task.Delay(2000);
cts.Cancel(); // キャンセル要求
try {
await task; // タスク終了を待つ
} catch (OperationCanceledException) {
// Task.Run 自体がキャンセルで終了した場合の例外処理 (通常は不要なことが多い)
Console.WriteLine("Task was canceled.");
}
}
}
ポイント
- 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);
std::cout << "Reader: ";
for (auto v : data) {
std::cout << v << " ";
}
std::cout << "\n";
}
void writer(int x) {
// ライター・ロック(排他ロック)の例
std::unique_lock lock(g_sharedMutex);
data.push_back(x);
std::cout << "Writer added: " << x << "\n";
}
int main() {
data = {1, 2, 3};
std::thread t1(reader);
std::thread t2(writer, 4);
std::thread t3(reader);
t1.join(); t2.join(); t3.join();
}
2.2 C# の排他制御
C# では lock
キーワード (実態は Monitor.Enter/Exit
) が最もシンプルで使用頻度の高い排他制御機構です。
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
private static object _lockObj = new object();
private static List<int> data = new List<int>();
static void Reader()
{
lock (_lockObj)
{
Console.Write("Reader: ");
foreach (var v in data)
{
Console.Write(v + " ");
}
Console.WriteLine();
}
}
static void Writer(int x)
{
lock (_lockObj)
{
data.Add(x);
Console.WriteLine($"Writer added: {x}");
}
}
static void Main()
{
data = new List<int> { 1, 2, 3 };
var t1 = new Thread(Reader);
var t2 = new Thread(() => Writer(4));
var t3 = new Thread(Reader);
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
}
}
また、以下のようなクラスでより高度な制御が可能です。
-
Mutex
… プロセス間でも利用できるミューテックス -
SemaphoreSlim
… 同時に許可するスレッド数を制限可能 -
ReaderWriterLockSlim
… リーダー・ライターロック -
ManualResetEventSlim
,AutoResetEvent
… イベント駆動型の待機・通知
比較のポイント
- C++ では
std::shared_mutex
が標準で提供され、リーダー・ライターロックを直接使えます。C# ではReaderWriterLockSlim
という専用クラスを使う形です。 - 簡単な排他制御だけなら C# の
lock
が非常に分かりやすく記述量も少ないです。C++ でも RAII を利用した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 <vector>
#include <numeric> // std::iota
#include <algorithm>
#include <execution>
#include <iostream>
#include <chrono>
int main() {
std::vector<int> v(10000000);
std::iota(v.begin(), v.end(), 0); // 0から始まる連番で初期化
auto start = std::chrono::high_resolution_clock::now();
// 並列ソート
std::sort(std::execution::par, v.begin(), v.end());
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> duration = end - start;
std::cout << "Parallel sort took: " << duration.count() << " ms\n";
// 比較のため逐次実行
std::iota(v.begin(), v.end(), 0); // 再初期化
start = std::chrono::high_resolution_clock::now();
std::sort(std::execution::seq, v.begin(), v.end());
end = std::chrono::high_resolution_clock::now();
duration = end - start;
std::cout << "Sequential sort took: " << duration.count() << " ms\n";
}
-
std::execution::par
を指定すると、処理系が可能であれば並列化します。 - ただし、対応するアルゴリズムの種類や、並列化による実際の性能向上効果は処理系(コンパイラと標準ライブラリ実装)に依存する部分が大きく、C++17で導入されたばかりということもあり、まだ発展途上と言えます。
4.2 C#:Task Parallel Library (TPL), PLINQ
C# には Task Parallel Library (TPL) と PLINQ (Parallel LINQ) が充実しており、手軽に並列化を実現できます。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
class Program
{
// 重い計算処理をシミュレート
static long SomeHeavyCompute(int x)
{
// 簡単な例として、少し時間をかける処理
long result = 0;
for (int i = 0; i < 10000; ++i) {
result += (long)Math.Sqrt(x * i);
}
return result;
}
static void Main()
{
var data = Enumerable.Range(0, 10000).ToList();
var sw = new Stopwatch();
Console.WriteLine("Using Parallel.ForEach:");
sw.Start();
// Parallel.ForEach
var results1 = new System.Collections.Concurrent.ConcurrentBag<long>(); // スレッドセーフなコレクション
Parallel.ForEach(data, x =>
{
results1.Add(SomeHeavyCompute(x));
});
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms. Count: {results1.Count}");
sw.Reset();
Console.WriteLine("\nUsing PLINQ:");
sw.Start();
// PLINQ
var results2 = data.AsParallel()
.Select(SomeHeavyCompute)
.ToList();
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms. Count: {results2.Count}");
sw.Reset();
Console.WriteLine("\nUsing Sequential LINQ (for comparison):");
sw.Start();
var results3 = data.Select(SomeHeavyCompute).ToList();
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms. Count: {results3.Count}");
}
}
- 内部的には .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. 表面的な記述(co_await
/await
)やステートマシン自動生成の点は似ていますが、両者の設計思想と役割は異なります。C# の async/await
は .NET ランタイムの Task
やタスクスケジューラと密接に統合されており、スレッドプール利用や非同期 I/O との連携が容易です。一方、C++ のコルーチンは、中断・再開を可能にする言語機能の基盤を提供するもの であり、特定の非同期モデルや実行コンテキスト(どのスレッドで動かすか、I/O完了をどう待つか等)とは結びついていません。これらの非同期処理の実行メカニズムは、Boost.Asio, cppcoro, folly::coro といったライブラリやフレームワーク側で提供される必要があります。
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(Resource Acquisition Is Initialization)によるリソース管理が基本であり、スマートポインタ (e.g. std::unique_ptr
, std::shared_ptr
) を使うことでメモリ管理を安全かつ効率的に行えます。
マルチスレッド環境でも RAII を徹底し、共有リソースへのアクセスには適切な排他制御(std::mutex
など)やアトミック操作 (std::atomic
) を用いることで、GC がなくても安全なマルチスレッドプログラミングが可能です。std::shared_ptr
自体はスレッドセーフな参照カウント管理を行いますが、指し示す先のオブジェクトへのアクセスは別途保護する必要があります。
Q9. C# でロックフリーのデータ構造を実装できますか?
A9. 可能ですが難易度は高い です。C# でも Interlocked
クラスを利用すればアトミックな操作を行えます。これらを使ってロックフリーのアルゴリズムを実装すること自体は可能です。ただし、C++ のようにハードウェアレベルに近いメモリオーダーを細かく制御する標準機能はないため、実装可能なアルゴリズムの種類やパフォーマンスチューニングの自由度は C++ と比較すると制限される場合があります。高度なロックフリー実装には深い知識と慎重な検証が必要です。
Q10. C++ と C#、どちらを選べばよいのでしょうか?
A10. プロジェクト要件次第です。組み込み・ゲーム・高パフォーマンスコンピューティング(HPC)・OS開発など、ハードウェアに近い制御や極限のパフォーマンスが求められる分野では、C++ の低レベル制御や効率性が強力な武器になります。一方、Windows デスクトップアプリケーション、Web アプリケーション (ASP.NET Core)、クラウドサービスなど、.NET エコシステム上で迅速に開発を進めたい場合や、生産性を重視する場合は C# が適していることが多いです。チームのスキルセット、既存のコードベース、利用可能なライブラリやツールなども含めて総合的に判断しましょう。
8. まとめ
-
C# は .NET ランタイムが並列実行環境をしっかり管理 しており、開発者は高レベルの API (
Task
,Parallel
,PLINQ
,async/await
) を使うだけで手軽に並列処理や非同期処理を実装できます。スレッドプール、排他制御、キャンセル処理なども標準で整備されており、開発効率と安全性が高いという大きな利点があります。 -
モダンC++ (C++11~23) は 低レベルから厳密に定義された並行プログラミング機構 を備えており、ネイティブスレッド、メモリモデル、アトミック操作、ロックフリー技法などを直接的かつ詳細に制御できます。これにより、最大限のパフォーマンスを引き出したり、システムレベルでの細かな要求に応えたりすることが可能です。ただし、標準ライブラリにはスレッドプールや高レベルのタスクスケジューラがまだ整備されておらず、これらが必要な場合は外部ライブラリや自前実装で補うのが一般的です。
結論としては、生産性や .NET エコシステムとの親和性を重視するなら C#、パフォーマンスの極限追求や低レベル制御、クロスプラットフォームなネイティブコードが重要なら C++ が有力な選択肢となります。プロジェクトの具体的な要件、性能目標、開発チームの経験などを総合的に考慮して、最適な言語を選択することが重要です。両言語とも進化を続けており、今後の動向にも注目していく価値があります。
9. 補足情報:C# 13 の概要
C# 13が、.NET 9 とともに2024年11月13日(日本時間)に正式リリースされました。本記事の主題である並行プログラミング機能に直接的な変更はありませんが、C# 言語自体の進化も続いているため、参考情報として C# 13 の主な新機能に触れておきます。
C# 13 では、並行プログラミングに直接関連する新機能の追加はありませんが、言語全体の機能強化が行われています。主な新機能は以下のとおりです。
- 新しいエスケープシーケンス `\e` の導入
- エスケープ文字 (Unicode U+001B) を表す新しいエスケープシーケンス `\e` が追加されました。
- メソッドグループでの自然型の改善
- オーバーロード解決時のメソッドグループにおける自然型の推論が改善され、コンパイラの最適化が進みました。
- オブジェクト初期化子での暗黙的なインデクサーアクセス
- オブジェクト初期化子内でインデクサーを暗黙的に使用できるようになり、コードの簡潔さと可読性が向上しました。
- `params` コレクション
- `params` キーワードを配列だけでなく、`List` や `Span` など、特定のコレクション型に対しても使用できるようになりました。
これらの機能強化により、C# の表現力と開発者の生産性が向上しています。
参考リンク
- cppreference - Concurrency support library
- Microsoft Docs - Task Parallel Library (TPL)
- Microsoft Docs - Parallel LINQ (PLINQ)
- Microsoft Docs - C# 13 の新機能
以上