前書き:この記事を読むにあたり、高度な技術背景は必要ありません。「Go言語とは何か」を知っていて、少しコードが読める方であれば、複雑なシステムが裏側でどのように動いているのかを理解できる内容になっています。Go言語をご存知ない方でも理解できるよう、技術的な概念は日常の例え交えて解説しています。
以前の記事では、私が開発したAIエージェント編成エンジン GopherGraph について紹介しました。これは「複数のAgentによる複雑なワークフローをいかに型安全かつ高並行に編成・実行するか」という課題を解決するものでした。
しかし今回のテーマは、さらにチャレンジングな問題です。
「この世界に存在する数百、数千の『住民(エンティティ)』を、互いに衝突(干渉)させることなく、いかに同時に動かすか?」
第1章:そもそも何を作っているのか
『シムシティ』や『ザ・シムズ』のようなシミュレーションゲーム、あるいは多数のNPCが存在するオープンワールドゲームを想像してみてください。世界には1,000人の住民がおり、各自が独自の行動ロジックを持っています。
- Aさんは今日、市場へ買い出しに行く
- Bさんは川辺で釣りをしている
- Cさんは酒場で人と会話しており、たまたまやってきたAさんと出会う
彼らは世界の中で同時に活動し、相互に影響を与え合い、インタラクションを行います。
ここで問題が生じます。「プログラムは、これら1,000人の行動ロジックをどのように同時に実行すればよいのでしょうか?」
これこそが今回解決する中核的な課題であり、専門用語では**「高並行エンティティ管理(High-concurrency Entity Scheduling)」**と呼ばれます。
第2章:直感的なアプローチと、それが破綻する理由
2.1 最も単純な発想:1つの巨大なループ
プロトタイプを書き始めた当初、最もシンプルで愚直な方法を採用していました。
for _, entity := range allEntities {
entity.Update() // 各エンティティの状態を1つずつ更新
}
これは例えるなら、学校の先生が生徒一人ひとりに「今日何をしたか順番に報告しなさい」と指名していくようなものです。
この方式には決定的な欠点があります。**「シリアル(直列)実行」**である点です。1,000人の住民がいる場合、1人目の処理が終わって初めて2人目の処理に移ります。プログラム上、彼らの行動は実際には「順番に」起きているだけで、「同時」というのは見た目の錯覚に過ぎません。
エンティティの数が増えたり、各エンティティのロジックが複雑化すると、世界の「時計」はどんどん遅くなり、最終的にはカクカクの処理(コマ落ち)になってしまいます。
2.2 少し賢く:住民ごとにスレッド(Goroutine)を割り当てる
直列処理がダメなら、並行処理にすればよいわけです。各エンティティに対して Goroutine(Go言語の軽量スレッド。独立した実行レーンのようなもの)を立ち上げ、本当の意味で同時に実行させます。
for _, entity := range allEntities {
go entity.Update() // 並行実行!各住民が同時に動く
}
一見すると完璧に思えますが、ここで新たな問題が発生します。
2.3 並行処理が引き起こす新たな惨事:デッドロック
Aさんが市場へ買い出しに行くには、「市場」という共有リソースにアクセスする必要があります。同時に、Bさんも市場へ魚を売りに行くため、同じく「市場」にアクセスします。
プログラムにおいて、この「市場」は共有メモリ(Shared Memory)です。2つのGoroutineが同一のデータに対して同時に読み書きを行うと、**データ競合(Race Condition)**が発生し、プログラムの実行結果は完全に予測不能になります。
これを防ぐため、共有リソースごとに「排他ロック(Mutex)」をかけます。
「俺が市場を使っている間は、みんな待ってくれ。使い終わったら交代だ。」
しかし、ロックを導入すると新たな悪夢 —— **デッドロック(Deadlock)**が生まれます。
日常生活の例で説明しましょう。
Aさん:「魚を買いたいが、まず金庫に行って現金を取り出し、お金を手に入れてから市場へ買いに行こう。」
Bさん:「魚を納品したいが、まず市場に行って空きスペースがあるか確認し、空いていたら倉庫へカゴを取りに行こう。」その結果:
- Aさんは金庫をロックしたまま、市場が空くのを待つ
- Bさんは市場をロックしたまま、金庫(倉庫)が空くのを待つ
お互いに相手の解放を待ち続け、誰一人として動けなくなり、プログラムは完全にフリーズします。
これは冗談ではなく、初期のプロトタイプで実際に発生した事態です。しかも、デッドロックのデバッグは非常に苦痛です。エラーログすら出力されず、プログラムが静かに「仮死状態」になるため、どこで止まっているのかの特定が極めて困難なのです。
さらに、デッドロック以外にも**ロック競合(Lock Contention)**という問題があります。500人の住民が同時に同じエリアにアクセスしようとし、そのエリアに1つのロックしかない場合、500人が一列に並んで待つことになり、せっかくの並行処理の優位性が完全に相殺されてしまいます。
だからこそ、私たちは全く新しいアーキテクチャの思考転換を必要としました。
第3章:Go言語の哲学と、それがもたらした思考の転換
Go言語の設計者であるRob Pike氏の有名な言葉があります。
「メモリを共有することによって通信するな。通信することによってメモリを共有せよ。」
—— Rob Pike
少し回りくどい表現ですが、噛み砕いて言えばこういうことです。
「みんなで1つのものを奪い合うのではなく、お互いに『メッセージを送る』ようにしなさい。」
これこそが、私たちが再構築したアーキテクチャの根本的な哲学となりました。
第4章:Actorモデル —— 各住民に「専用ポスト」を与える
新しいアーキテクチャは、ソフトウェア工学における古典的かつ強力な並行処理モデルである Actorモデル を参考にしています。
Actorモデルのコアコンセプトは以下の3点のみです:
- 各Actor(住民)は完全に独立しており、自身のプライベートな状態(State)を持つ。
- Actor同士は、他人の状態を直接読み書きしてはならない(勝手に他人の家に入り込んで物をあさってはいけない)。
- Actor間の唯一のコミュニケーション手段は、相手にメッセージを送ること(相手の郵便ポストに手紙を投函すること)。
Go言語において、各住民の郵便ポストは Channel(チャネル) を使って実装します。ChannelはGo言語が標準でサポートする並行通信機構であり、スレッドセーフ(安全)であるため排他ロックを必要としません。
エンティティ(住民)の内部構造を以下のように再設計しました。
type Entity struct {
id string
mailbox chan *Event // プライベートポスト(バッファ付きChannel)
state *EntityState // プライベート状態(自分だけが変更可能)
}
各エンティティは自分専用の mailbox を持っています。外部からはこのChannelに「メッセージを投入」することしかできず、state を直接操作することはできません。
そして、各エンティティには自身の「ライフサイクル」を持たせます。
func (e *Entity) Start(ctx context.Context) {
// エンティティごとに専用のGoroutineを立ち上げ、独立して動かす
go func() {
ticker := time.NewTicker(100 * time.Millisecond) // 100msごとに自主行動
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// 世界が終了した、あるいはこの住民が「死亡」したため、安全に終了
e.cleanup()
return
case event := <-e.mailbox:
// メッセージが届いた!処理を実行する
// 注:ここでのstate変更にはロックが不要。このGoroutineしか書き込まないため
e.handleEvent(event)
case <-ticker.C:
// 定期実行:時間が来たので自身がすべき行動をとる(歩く、喋るなど)
e.doAction()
}
}
}()
}
このコードの中で最も重要なのは select ステートメントです。これは「マルチチャネルの監視役」として働き、以下の3つの出来事を同時に監視しています。
- 誰かからメッセージが届いた:優先的に処理
- タイマーの時間が来た:自主的なアクションを実行
- システムから終了通知が来た:クリーンに自身を終了
各エンティティの state は自分自身のGoroutineからしか変更されないため、Mutex(排他ロック)とも、デッドロックとも完全に無縁になりました。
第5章:イベントバス —— 仮想世界の「郵便配送システム」
住民たちが専用ポストを持つようになりましたが、では手紙を届ける配達員は誰でしょうか?
ここで登場するのが イベントバス(Event Bus) です。
5.1 イベントとは何か?
私たちのシステムでは、「発生したすべての出来事」を イベント(Event) として抽象化しています。
例えば:
| イベント種別 | 送信元 | 送信先 | 内容 |
|---|---|---|---|
| 移動 | Aさん | システム | 「座標 (10, 20) に移動したい」 |
| 会話 | Bさん | Aさん | 「今日の天気は良いですね」 |
| 取引 | Aさん | Bさん | 「あなたの魚を買いたい」 |
| エリア放送 | システム | エリアAの全住民 | 「エリアAで雨が降り始めました」 |
各イベントは以下のようなクリーンなデータ構造になっています。
type Event struct {
Type EventType // イベントの種類(移動?会話?取引?)
SourceID string // 誰が発行したか
TargetID string // 誰宛か(ブロードキャストの場合はトピック名)
Payload any // イベントに付随する具体データ
Priority int // 優先度(高いイベントから優先処理)
}
5.2 イベントバスはどのように機能するのか?
イベントバス全体を、現実世界の宅配システムとしてイメージしてみてください。
- Aさん(Entity A)は、Bさんの家に勝手に押し入ることはできません(直接状態を変更できない)。
- Aさんは「伝票」を記入(Eventを作成)し、誰に何を届けるかを指定します。
- Aさんは伝票と荷物を宅配会社(Event Bus)に渡します。
- 宅配会社は住所に従い、Bさんのポスト(mailbox Channel)に荷物を届けます。
- Bさんは自分のタイミングでポストを開け、荷物(イベント)を処理します。
このプロセスは**完全な非同期(Asynchronous)**です。AさんはBさんが処理し終わるのを待つ必要はありません。ネットショッピングで商品を注文した後、配達員の到着をドアの前でずっと待つ必要がないのと同じです。
5.3 イベントバスの内部構造
イベントバスの内部は、マルチWorker構成の並行ディスパッチャーになっています。
type EventBus struct {
workers int // Workerの数
queue chan *Event // グローバルイベントキュー
registry map[string]*Entity // ID -> エンティティのレジストリ
}
func (bus *EventBus) Publish(event *Event) {
// ノンブロッキングでキューにイベントを投入
// キューが満杯の場合、ポリシーに従って判断(破棄?再試行?アラート?)
select {
case bus.queue <- event:
// キュー投入成功
default:
// キューが満杯のため、フォールバック処理を実行
bus.handleOverflow(event)
}
}
func (bus *EventBus) startWorkers() {
for i := 0; i < bus.workers; i++ {
go func() {
for event := range bus.queue {
// レジストリから対象エンティティを検索
if target, ok := bus.registry[event.TargetID]; ok {
// エンティティのポストへ投函
// ここもノンブロッキングにし、遅いエンティティが全体を巻き込むのを防ぐ
select {
case target.mailbox <- event:
default:
// エンティティのポストも満杯なため、優先度に応じて対処
bus.handleEntityOverflow(target, event)
}
}
}
}()
}
}
コード内で繰り返し使われている select { case ...: default: } イディオムに注目してください。これはGo言語におけるノンブロッキングChannel操作の定石パターンです。「今すぐ送信できるなら送信し、できないなら即座に default に抜けて別の処理を行う」という意味であり、絶対に永久ブロック(停止)しません。これこそが、連鎖的なボトルネックを防ぎ、システムの堅牢性を担保する鍵です。
第6章:カスケード障害(雪崩式クラッシュ)を防ぐ方法
イベントバスを導入したことで、すぐに新しい課題に直面しました。**イベント洪水(Event Flood)**です。
例えば、あるエリアで急に激しい雨が降り出し、システムがそのエリアにいる500人の住民全員に通知を出さなければならない場面を想像してください。もし瞬時に500個のChannelへ一斉書き込みを行ったらどうなるでしょうか?
各住民のポスト(バッファChannel)から溢れ出し、大量の書き込み圧力によってシステム全体のレスポンス遅延が急上昇、最悪の場合はシステム全体が雪崩式にクラッシュしてしまいます。
6.1 「重要メッセージ」と「通知メッセージ」の分離
現実の郵便システムにも優先度の差別化が存在します(速達、普通郵便、不要なダイレクトメールなど)。
私たちもイベントを以下のように分類しました。
- 信頼性イベント(Reliable Event):「取引の確定」「重要ステータスの変更」など。これらは絶対に損失が許されないため、ポストが満杯の場合はリトライキューに送られ、時間を置いて再配送されます。
- ベストエフォートイベント(Lossy Event):「環境音の通知」「通常の移動ブロードキャスト」など。これらは多少ドロップしても問題ないため、ポストが満杯なら即座に破棄し、システム全体の安定性を優先します。
この考え方は分散システムにおいてバックプレッシャー(Backpressure)制御と呼ばれ、オーバーロード時に重要度の低いタスクを能動的に切り捨てることで、コア機能を死守する技術です。
6.2 AOIアルゴリズム —— 「近くの人」だけに通知する
イベント洪水を防ぐ最もエレガントな解決策が、ゲームサーバー開発の古典的手法である AOI(Area of Interest:注目領域) アルゴリズムの採用です。
基本思想はきわめてシンプルです。**「出来事がエリアAで起きたのなら、エリアAおよびその隣接エリアにいる人にだけ通知すればよく、遠くにいる人には知らせる必要がない」**というものです。
具体的には、仮想世界全体をグリッド(格子)で分割し、各グリッドごとに「今ここにいる住民リスト」を保持します。ブロードキャストイベントが発生した際は、該当するグリッドおよび周囲8マス(計9マス)の住民だけを検索し、イベントを送信します。
+---+---+---+
| ↖ | ↑ | ↗ |
+---+---+---+
| ← | ★ | → | ★ = イベント発生場所
+---+---+---+
| ↙ | ↓ | ↘ |
+---+---+---+
通知対象はこの9マスにいる住民のみ
この小さな工夫により、「全住民に通知する」計算量が O(N)(N=世界全体の全住民数)から O(k)(k=近隣グリッド内の住民数、実質ほぼ定数)へと劇的に削減されました。世界にどれだけ住民が増えようと、ブロードキャストのコストはほとんど変化しません。
第7章:動的世界と静的グラフの統合 —— ループの完結
これで仮想世界のリアルタイムな実行ロジックは完成しました。
- 各住民は独立したActorであり、独自のステートとポストを持つ
- 住民同士はイベントを介して非同期通信し、相互に直接干渉しない
- Event Busが効率的に配送し、AOIアルゴリズムが通知範囲を最適化する
しかし最後にもう1つ課題が残ります。すべての状態がメモリ上で流動的に処理されている中で、「複雑な関係性」をどのように永続化(保存)すべきか? という点です。
「誰と誰が友達か」「誰がどの土地を所有しているか」「誰が過去にどこに滞在したか」——こうした関係性データこそ、グラフデータベースが得意とする領域です。
ここで、冒頭で触れた GopherGraph が真価を発揮します。
私たちは イベントソーシング(Event Sourcing) と呼ばれるパターンを採用しました。イベントバスがイベントを配送すると同時に、重要なイベントログをバックグラウンドで非同期に GopherGraph へと書き込みます。
リアルタイムメモリ層(本記事のイベントエンジン)
↓ イベント流を非同期で永続化
永続グラフ層(GopherGraph)
このように、メモリ層がミリ秒単位のリアルタイムなインタラクションと状態更新を担い、グラフ層が蓄積された関係性データを保持して複雑な履歴クエリや分析機能を提供します。2つの層が役割分担することで、相互に干渉することなくスケール可能な構造が完成しました。
まとめ:このアーキテクチャが得たもの、失ったもの
どのようなアーキテクチャ設計にもトレードオフが存在します。最後に客観的なまとめを行いましょう。
✅ 得られたメリット(解決したこと)
- デッドロックの撲滅:Actorモデル+Channelの採用によりMutexを排除し、デッドロックリスクをほぼゼロに抑え込んだ。
- 高いパフォーマンス:真の並列実行を実現し、マルチコアCPUの性能をフルに引き出せるようになった。
- 疎結合なコード:イベント駆動により各モジュールが完全分離され、新しい住民の行動を追加する際も既存コードの修正が不要になった。
- 優れた拡張性:エリアごとにイベントバスを分割することで、理論上は複数サーバーへの水平分散拡張が可能になった。
⚠️ 代償となったデメリット(犠牲にしたこと)
- デバッグの複雑化:非同期イベントの呼び出しチェーンが長くなるため、バグ発生時に「どこで問題が起きたか」の追跡が難しくなった(専用のイベントトラッキングログを開発して対処)。
- 思考コストの増大:開発者は常に「すべてが非同期である」前提でコードを書く必要があり、従来の同期処理からの発想の転換が求められる。
- 結果的整合性(Eventual Consistency)の容認:非同期通信であるため、特定の一瞬において異なるエンティティが見ている世界のステートにわずかなズレが生じる場合があり、ビジネスロジック側で強整合性ではなく「結果的整合性」を許容する必要がある。
Go言語は、このアーキテクチャを構築する上で最高の土壌を提供してくれました。 数千のGoroutineを立ち上げてもGoのランタイムには大した負荷にならず、Channelと select 文によって並行通信を美しく記述でき、context パッケージによってライフサイクル管理が容易になります。
この記事が、並行システムやイベント駆動アーキテクチャの設計における新たな気づきとなれば幸いです。Actorモデル、イベント駆動、あるいはGopherGraphの実装について質問やご意見がありましたら、ぜひコメント欄でお知らせください!