Preface: This article does not require you to have a deep technical background. If you know "what the Go language is" and can understand a tiny bit of code, you will be able to finish reading this article and figure out how the underlying layer of a complex system actually runs. If you have never heard of Go, that's fine too; I will use real-life analogies to explain all the technical concepts in this article.
In the previous article, I introduced GopherGraph, a graph database library I wrote. It resolves the problem of "how to store and query complex relationship networks."
But today's article will tackle a more challenging question: How do we make hundreds or thousands of "residents" in this world move at the same time without fighting each other?
Chapter 1: What Are We Doing?
Imagine you are making a game like The Sims, or an open world with many NPCs. There are 1,000 residents in the world, and each of them has their own behavioral logic:
- Xiao Wang goes to the market to buy groceries today.
- Xiao Li is fishing by the river.
- Xiao Zhao is chatting with someone in the tavern and happens to run into Xiao Wang.
They are active in the world at the same time, influencing and interacting with each other.
Now the question arises: How does your program run the behavioral logic of these 1,000 people simultaneously?
This is the core problem we are going to solve today, and its professional term is: High-Concurrency Entity Scheduling.
Chapter 2: The Most Intuitive Approach, and Why It Collapses
2.1 The Most Naive Idea: A Big Loop
When we first started writing the prototype, we used the simplest and most brutal way:
go
for _, entity := range allEntities {
entity.Update() // Update the state of each entity one by one
}
This is like a teacher calling on each student one by one to "report what you did today."
This approach has a fatal flaw: sequential execution. With 1,000 residents, the first one must finish updating before the second one gets a turn. In reality, the actions of these 1,000 people occur one after another in the program—it's not "simultaneous" at all; it's just an illusion simulated by the CPU.
As the number of entities grows, or the logic of each entity becomes more complex, the "clock" of the entire world slows down, eventually lagging like a PowerPoint presentation.
2.2 A Bit Smarter: Open a Thread for Each Resident
Since sequential execution doesn't work, let's go parallel. For each entity, we spin up a Goroutine (a lightweight thread in Go, which can be understood as an independent execution channel) so they can truly run concurrently.
go
for _, entity := range allEntities {
go entity.Update() // Concurrent! Every resident executes at the same time
}
This looks beautiful, but new problems arise.
2.3 The New Disaster of Concurrency: Deadlocks
Xiao Wang goes to the market to buy groceries and needs to access the "Market" shared resource. At the same time, Xiao Li also goes to the market to sell fish, and also needs to access the "Market."
In the program, this "Market" is a piece of shared memory. When two Goroutines read and write to the same data at the same time, a race condition occurs, making the program's results completely unpredictable.
To prevent this problem, we added a lock (Mutex) to each shared resource:
"I am using the market. Everyone else wait. You can use it only after I'm done."
But locking brings a new problem—Deadlock.
Let me explain deadlocks using a real-life analogy:
Xiao Wang says: "I want to buy fish, but I need to go to the warehouse to get money first. I can only go to the market to buy fish after I get the money."
Xiao Li says: "I want to store fish, but I need to check the market for an empty slot first. I will only go to the warehouse to get a basket after I confirm there is an empty slot."
As a result:
- Xiao Wang locks the warehouse and waits for the market.
- Xiao Li locks the market and waits for the warehouse.
Both are waiting for each other, and neither can move. The program gets stuck forever.
This is not a joke; our early versions did encounter this situation, and the debugging process was extremely painful. A deadlock does not throw an error; the program just quietly goes "braindead," and you have no idea where it is stuck.
In addition to deadlocks, there is also Lock Contention: when 500 residents want to access the same area at the same time, and this area has only one lock, 500 people have to queue up, completely offsetting the advantage of concurrency.
This is why we need a brand-new architectural approach.
Chapter 3: Go's Philosophy, and How It Changed Our Thinking
The creators of Go have a widely known proverb:
"Do not communicate by sharing memory; instead, share memory by communicating."
— Rob Pike
This sounds a bit abstract. Translated into plain English, it means:
Do not let everyone fight for the same thing; instead, let everyone "send messages" to each other.
This became the underlying philosophy of our subsequent architecture.
Chapter 4: The Actor Model—Giving Each Resident a "Dedicated Mailbox"
The new architecture references a classic concurrency model in software engineering: the Actor Model.
The core ideas of the Actor Model are simple:
- Each Actor (e.g., a resident) is completely independent and maintains its own private state.
- Actors must never directly read or write to another Actor's state (you can't just barge into someone else's house and rummage through their things).
- The only way Actors communicate is by sending messages (dropping a note into the other person's mailbox).
In Go, each resident's mailbox is implemented using a Channel. Channels are the native concurrent communication mechanism in Go. They are thread-safe by design and do not require manual locking.
We redesigned the internal structure of each entity (resident):
go
type Entity struct {
id string
mailbox chan *Event // Private mailbox (buffered Channel)
state *EntityState // Private state, modifiable only by oneself
}
Each entity has its own dedicated mailbox Channel. The outside world can only "post messages" into it but cannot directly manipulate the state.
Then, each entity has its own "life cycle loop":
go
func (e *Entity) Start(ctx context.Context) {
// Open a dedicated Goroutine for each entity so it operates independently
go func() {
ticker := time.NewTicker(100 * time.Millisecond) // Autonomously act every 100ms
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// The world has ended, or this resident has "died", exit gracefully
e.cleanup()
return
case event := <-e.mailbox:
// Someone sent me a message! Handle it.
// Note: Modifying state here requires absolutely no locks,
// because only this single Goroutine is writing to it.
e.handleEvent(event)
case <-ticker.C:
// Active trigger: time is up, do what needs to be done (e.g., walk, talk)
e.doAction()
}
}
}()
}
In this code, the most critical part is the select statement, which acts like a "multi-way listener," listening to three things at once:
- 有人给我发消息 (Someone sent me a message): Process it with priority.
- 时间滴答到了 (The timer ticked): Actively trigger own behaviors.
- 系统通知我退出 (The system notified me to exit): Clean up and terminate.
Since each entity's state can only be modified by its own Goroutine, we have completely said goodbye to Mutexes and deadlocks.
Chapter 5: The Event Bus—The "Postal System" of the Virtual World
Now that the residents have their own mailboxes, who delivers the messages?
This is where the Event Bus comes in.
5.1 What Is an Event?
In our system, anything that "happens" is abstracted as an Event.
For example:
| Event Type | Sender | Receiver | Content |
|---|---|---|---|
| Move | Xiao Wang | System | "I want to go to coordinates (10, 20)" |
| Talk | Xiao Li | Xiao Wang | "The weather is nice today" |
| Trade | Xiao Wang | Xiao Li | "I want to buy your fish" |
| Area Broadcast | System | All residents in Area A | "It is starting to rain in Area A" |
Each event is a clean data structure:
go
type Event struct {
Type EventType // Event type (Move? Talk? Trade?)
SourceID string // Who sent it
TargetID string // Who it is sent to (Topic name if it's a broadcast)
Payload any // Specific data carried by the event
Priority int // Priority (high-priority events are processed first)
}
5.2 How Does the Event Bus Work?
You can imagine the Event Bus as a delivery system in the real world:
- Xiao Wang (Entity A) cannot directly barge into Xiao Li's house (cannot directly modify the other's state).
- Xiao Wang fills out a "delivery slip" (creates an
Event), specifying the recipient and the content. - Xiao Wang drops the delivery slip off at the courier company (publishes to the
EventBus). - The courier company delivers the package to Xiao Li's mailbox (the
mailboxChannel) according to the address. - Xiao Li opens his mailbox and processes the event during his own execution time.
This process is completely asynchronous: Xiao Wang does not need to wait for Xiao Li to finish processing before continuing his own business. It's just like placing an order online; you don't need to watch the delivery guy the whole time—he will eventually get it there.
5.3 Internal Structure of the Event Bus
Inside our Event Bus, there is a multi-worker concurrent dispatcher:
go
type EventBus struct {
workers int // Number of worker Goroutines
queue chan *Event // Global event queue
registry map[string]*Entity // ID -> Entity registration registry
}
func (bus *EventBus) Publish(event *Event) {
// Non-blocking write to the queue
// If the queue is full, decide the strategy: drop? retry? or alert?
select {
case bus.queue <- event:
// Successfully enqueued
default:
// Queue is full, trigger degradation strategy
bus.handleOverflow(event)
}
}
func (bus *EventBus) startWorkers() {
for i := 0; i < bus.workers; i++ {
go func() {
for event := range bus.queue {
// Find the target entity in the registry
if target, ok := bus.registry[event.TargetID]; ok {
// Deliver to the entity's mailbox
// Also non-blocking to prevent a slow entity from bottlenecking the system
select {
case target.mailbox <- event:
default:
// The entity's mailbox is also full, handle based on event priority
bus.handleEntityOverflow(target, event)
}
}
}
}()
}
}
Notice the recurring select { case ...: default: } pattern in the code—this is the standard Go idiom for non-blocking Channel operations. It means: if the Channel can receive now, send it immediately; if not, go to the default branch for alternative handling, never wait indefinitely. This is the key to preventing cascading blockage and ensuring system robustness.
Chapter 6: Preventing System Collapse from "Stampede Accidents"
After introducing the Event Bus, we quickly encountered a new challenge: Event Floods.
Imagine a scenario: it suddenly starts pouring rain in a certain area, and the system needs to notify all 500 residents in this area. If we write data to 500 Channels at the exact same moment, what will happen?
Each resident's mailbox (buffered Channel) might overflow, and the system will instantly be overwhelmed by a massive wave of write operations, causing response latency to spike and, in severe cases, triggering a system-wide cascade failure.
6.1 Distinguishing "Important Messages" from "Notification Messages"
Real-world courier systems also have similar tiered routing: express delivery for same-day delivery, standard mail for 3-day delivery, and junk flyers that can be tossed straight into the bin.
We classified events into two tiers:
- Reliable Events: e.g., "Trade Confirmation", "Critical State Change". These cannot be lost. If the mailbox is full, they are placed in a retry queue to be redelivered after a delay.
- Lossy Events: e.g., "Background Ambient Sound Notification", "Ordinary Move Broadcast". It doesn't matter if these are lost. If the mailbox is full, they are discarded immediately to protect the overall stability of the system.
This concept is known as Backpressure Control in distributed systems. It essentially means actively discarding non-critical work when the system is overloaded to ensure core functions keep running.
6.2 AOI Grid Algorithm—Notifying Only "People Nearby"
This is the most elegant solution to resolve event floods, inspired by a classic algorithm in game server development: AOI (Area of Interest).
The core idea is incredibly simple: If something happens in Area A, notify only the people in Area A and its immediately adjacent areas; people far away do not need to know.
Specific implementation: Divide the entire virtual world into a Grid. Each grid cell maintains a list of "residents currently here." When a broadcast event occurs, only query the current grid cell and its surrounding 8 grid cells (a 3x3 grid neighborhood), and deliver the event to the residents inside them.
text
+---+---+---+
| ↖ | ↑ | ↗ |
+---+---+---+
| ← | ★ | → | ★ = Location of the event
+---+---+---+
| ↙ | ↓ | ↘ |
+---+---+---+
Only notify residents in these 9 grid cells
This small change directly reduces the complexity of "notifying everyone" from O(N) (where N is the total number of residents in the world) to nearly constant time O(1)—no matter how many residents there are in the world, the cost of a broadcast remains basically constant.
Chapter 7: Integrating the Dynamic World with the Static Graph—The Closed Loop
Now the execution logic of the entire virtual world is clear:
- Each resident is an independent Actor with a private state and mailbox.
- Residents communicate via events without directly interfering with one another.
- The Event Bus handles efficient dispatching, and the AOI algorithm controls the broadcast range.
But there is one more question: all states run in memory; how do we persist those complex relationships? For example, "who is friends with whom", "who owns which piece of land", "who once appeared where"—these relationship data are exactly what graph databases are best at handling.
This is where GopherGraph, introduced in the previous article, comes in handy.
We adopted a pattern called Event Sourcing: while the Event Bus dispatches events, it also asynchronously writes all key events into GopherGraph.
text
Real-time Memory Layer (The engine in this article)
│
▼ (Continuous asynchronous event stream logging)
Persistent Graph Layer (GopherGraph)
In this way, the memory layer is responsible for millisecond-level real-time interaction and state updates, while the graph layer handles the settled relationship data, providing complex historical query and data analysis capabilities. The two layers perform their respective duties without interfering with each other.
Conclusion: What This Architecture Resolves and What It Sacrifices
Every architectural design comes with trade-offs. Let's make an honest summary:
✅ What We Solved:
- Deadlock Issues: The Actor model + Channels completely eliminates Mutexes, reducing the risk of deadlocks to a minimum.
- Performance Bottlenecks: True parallel execution, fully utilizing multi-core CPUs.
- Code Coupling: Event-driven design completely decouples each module. Adding a new resident behavior does not require modifying any existing code.
- Scalability: Decoupling the Event Bus by region makes it theoretically possible to scale horizontally across multiple servers.
⚠️ The Cost We Paid:
- Increased Debugging Complexity: The call chain of asynchronous events is very long. When a bug occurs, it is sometimes difficult to trace which step went wrong. We specifically developed an event log tracing system for this.
- Increased Cognitive Load: Developers must always think in an "everything is asynchronous" mindset. Shifting from synchronous calls requires a learning curve.
- Eventual Consistency: Due to asynchronous communication, different entities may see slightly different states of the world at any given moment. The business logic must accept "eventual consistency" rather than "strong consistency".
Go provides the natural soil for this architecture. Thousands of Goroutines are a breeze for the Go Runtime, the channel and select syntax make concurrent communication write elegantly and naturally, and the context package makes lifecycle management extremely straightforward.
I hope this article gives you a new perspective on concurrent system design. If you have any questions about the Actor model, event-driven design, or the specific implementation of GopherGraph, please feel free to leave a comment in the discussion section. See you next time!