【Boost.Asio深解】同一ソケットでの同時async_writeはなぜ危険なのか?送信キューだけでは不十分
Boost.Asioを用いた高並行サーバーを開発する際、多くの開発者は以下のようなコードを書く傾向があります。
- 送信キュー + async_writeを連鎖的に送信
- 複数のスレッドからSession::Send()を呼び出し
- mutexでキューを保護
一見すると、
「スレッドセーフ + 送信キュー = 問題ないはず」
のように見えますが、実際にはマルチスレッド環境下で以下のような問題を引き起こす可能性があります。
- クラッシュ(abort)
- IOCPの例外
- ランダムな接続切断
- 未定義動作(UB)
本稿では具体的なプロジェクトの事後分析は扱いません。純粋なAsioの並行モデルに関する基礎知識に焦点を当てます。
最も一般的な誤解:async_writeは「自動的にキューイングされる」
多くの人が(私も最初は)こう考えていました。
boost::asio::async_write(socket, buf1, handler1);
boost::asio::async_write(socket, buf2, handler2);
これは自動的に以下のようになると思っていました。
buf1を先に送信 → 完了後にbuf2を送信
しかし、これは間違った認識です。
Boost.Asioの公式セマンティクスは以下の通りです。
同一ソケット上で、複数の未完了のasync_write操作が同時に存在することは許可されていません。
そうでない場合、動作は未定義(Undefined Behavior)となります。
以下の3つのキーワードに注意してください。
- 未完了(outstanding)
- 同一ソケット
- 未定義動作(エラーではなく、ランダムにクラッシュする)
「同時async_write」とは何か?
典型的なサーバーモデルを見てみましょう。
IOスレッド(io_context.run)
Logicスレッド(ビジネスロジック処理)
Roomスレッド(ブロードキャスト)
そして、あなたの送信関数は以下のようになっているかもしれません。
void Connection::transmit(const std::string& msg)
{
std::lock_guard lock(mutex_);
messageQueue_.push(msg);
if (messageQueue_.size() == 1)
{
async_write(socket_, boost::asio::buffer(msg), handler_); // 書き込みチェーンを開始
}
}
表面を見ると、
- ロック付き ✔
- キュー付き ✔
- サイズが1の時のみ開始 ✔
しかし、問題は以下の通りです。
Send()は複数のスレッドから同時に呼び出される可能性があります。
送信キューがなぜ自然に同時書き込みを防げないのか?
鍵となるポイントは、
キューは「データの順序」を保証するだけで、「書き込み操作のシリアルな開始」を保証しません。
実際の競合時のシーケンスを考えてみましょう。
スレッドA:
push -> size = 1 -> async_writeの準備
スレッドB(ほぼ同時に):
push -> size = 2
もし書き込みチェーンの開始ロジックが同じ実行シーケンス内で行われなければ、
- Aがasync_writeを開始する可能性
- Bもasync_writeを開始する可能性(または間接的にトリガーする)
という結果になります。
ソケット上に同時に2つのasync_writeが存在
= 未定義動作
mutexはデータ構造を保護できますが、ソケットの非同期操作のタイミングを保護することはできません。
Boost.Asioの核心的な並行設計思想
Asioを理解するには、以下の一文を覚えておく必要があります。
Asioはハンドラのシリアル実行を保証しますが、あなたが開始する操作のシリアル性は保証しません。
つまり、
- async_read/async_writeは任意のスレッドから呼び出せる ✔
- しかし、同一リソース(ソケット)に対する操作が同時実行されないように自分で保証する必要がある ❗
これはフレームワークレベルの設計選択であり、バグではありません。
公式推奨の3つの正しいシリアル書き込み方案
方案一:単一スレッドio_context(最もシンプル)
モデル:
io_context.run(); // 単一スレッドのみ
特徴:
- すべてのasync_writeハンドラが同一スレッドで実行される
- 自然に同時書き込みが発生しない
- 非常に安定している
適用先:
- ゲームサーバー
- リアルタイム協調サーバー
- 中小規模の高リアルタイムシステム
方案二:executor/strandを使用する(産業標準)
boost::asio::strand<io_context::executor_type> strand_executor_;
書き込み操作:
boost::asio::post(strand_executor_, [this]{
performWrite();
});
// または
async_write(socket_, buffer,
boost::asio::bind_executor(strand_executor_, handler));
利点:
- io_contextがスレッドプールであっても、同一ソケットの操作をシリアルに実行できる
- 公式で最も推奨される方法
方案三:送信キュー + executorで収束(高性能で一般的)
多くのリアルタイムサーバーで使用されるパターンです。
void enqueueMessage(...)
{
pushToQueue();
if (shouldStartWriting)
{
boost::asio::post(socket_.get_executor(),
[this]{ processWrite(); });
}
}
鍵となる思想:
複数のスレッドからの入隊を許可するが、書き込みチェーンの駆動はexecutorスレッドにのみ許可する。
これにより、以下の両方を同時に満たすことができます。
- 高並行のSend()呼び出し
- 厳密なソケット書き込み操作のシリアル実行
なぜ「executorへの収束」が必須なのか?
executorが保証するのは、
同一のexecutorにバインドされたハンドラは、同時に実行されない(単一のrunスレッドモデル)
です。このように書くと、
post(socket_.get_executor(), handler);
以下と等価になります。
すべての書き込み操作をソケットが属するIO実行シーケンスにディスパッチする
これにより、以下の問題を根本から排除します。
- 複数スレッドによるasync_writeの競合開始
- 書き込みチェーンの再入
- 同時IO操作
より深い誤解:async_writeは「スレッドセーフ」
正確な表現は以下の通りです。
| 行為 | スレッドセーフか? |
|---|---|
| 複数のスレッドからasync_writeを呼び出す | 許可される |
| 同一ソケットで複数のasync_writeが同時に存在する | 未定義動作 |
| ハンドラの同時実行 | executor/strandに依存 |
これが多くのプログラムで、
- 負荷テスト時にクラッシュする
- 単体テストでは正常
- 本番環境でランダムにクラッシュする
といった現象が発生する理由です。
Windows IOCP下の典型的な現象
「単一ソケットのシリアル書き込み」を破ると、以下のような現象が発生することがあります。
- `GetQueuedCompletionStatus`でブロック
- CRT abort()
- ヒープ破損
- ランダムな接続切断
- 明確な論理エラーのログなし
これはビジネスロジックの例外ではなく、IOレイヤーの未定義動作によるものです。
送信キューの正しい位置づけ(非常に重要)
多くの人が送信キューを、
「スレッドセーフなソケット書き込みの解決策」
と考えています。実際には、それは以下の問題のみを解決します。
- 順序問題(FIFO)
しかし、以下のものと組み合わせる必要があります。
- executor
- strand
- または単一スレッドio_context
これらを組み合わせることで、初めて以下のことが実現できます。
スレッドセーフ + IOセーフ
エンジニアリングレベルのベストプラクティスまとめ
チェックリストとして直接使用できます。
- 同一ソケットには、常に一つの未完了のasync_writeのみを許可する。
- マルチスレッドサーバーでは、以下のいずれかを使用する:
- strand、または
- executorによる収束、または
- 単一スレッドio_context
- 複数のスレッドで直接書き込みチェーンを駆動しない。
- 送信キューは、並行セーフの究極の解決策ではない。
最後に一言でまとめると、
Boost.Asioにおいて、本当にシリアル化する必要があるのは「送信キュー」ではなく、「同一ソケットに対する非同期操作のタイミング」です。書き込み操作の実行シーケンスを制御しない限り、コードが完全にスレッドセーフに見えても、未定義動作を引き起こす可能性があります。