Introduction
In this article, we’ll continue our in-depth analysis of another high-performance network programming framework: nbio
.
The nbio
project also includes nbhttp
built on top of nbio
, but that’s outside the scope of our discussion.
Like evio
, nbio
adopts the classic Reactor pattern. In fact, many asynchronous network frameworks in Go are designed based on this pattern.
Let’s start by running the nbio
program code.
Server:
|
|
Here, we create a new Engine instance using the nbio.NewGopher()
function. We pass a nbio.Config
struct to configure the Engine instance, including:
Network
: The type of network to use, which is “TCP” in this case.Addrs
: The addresses and ports the server should listen to, here it’s “:8888” (listening on port 8888 of the local machine).MaxWriteBufferSize
: The maximum size of the write buffer, is set to 6MB here.
Other configurations can be explored further. Then, we register a data reception callback function using g.OnData()
the Engine instance. This callback function is invoked when data is received. It takes two parameters: the connection object c
and the received data data
. Inside the callback function, we use c.Write()
a method to write the received data back to the client.
Client:
|
|
At first glance, it might seem a bit cumbersome. Actually, the server and client share the same set of structures.
The client connects to the server through nbio.Dial
, and upon successful connection, it encapsulates into nbio.Conn
. Here, nbio.Conn
implements the net.Conn
interface of the standard library. Finally, it adds this connection via g.AddConn(c)
and writes data to the server. When the server receives the data, its handling logic is to send the data back to the client as is. When the client receives the data, the OnData
callback is triggered. This callback checks if the received data length matches the sent data length, and if so, it closes the connection.
Now, let’s delve into a few key structures.
|
|
The Engine
is essentially the core manager, responsible for managing all listeners, pollers, and worker pollers.
What’s the difference between these two types of pollers?
The difference lies in their responsibilities.
The listener poller is responsible only for accepting new connections. When a new client conn
arrives, it selects a worker poller from pollers
and adds conn
to the corresponding worker poller. Subsequently, the worker poller is responsible for handling the read/write events of this conn
.
Therefore, when we start the program, if only one address is being listened to, the number of polls in the program equals 1 (listener poller) + pollerNum
.
From the fields above, you can customize some configurations and callbacks. For example, you can set a callback function onOpen
when a new connection arrives, or set a callback function onData
when data arrives, etc.
|
|
The Conn
structure represents a network connection. Each conn
belongs to only one poller. writeBuffer
: When data is not completely written at once, the remaining data is first stored in writeBuffer
and waits for the next writable event to continue writing.
|
|
As for the poller
structure, it’s an abstract concept used to manage underlying multiplexed I/O operations (such as epoll on Linux, kqueue on Darwin, etc.).
Pay attention to pollType
, nbio defaults to epoll using level-triggered (LT) mode, but users can also set it to edge-triggered (ET) mode.
After introducing the basic structures, let’s move on to the code flow.
When you start the server code provided above, when you call Start
:
|
|
The code is understandable. It’s divided into four parts:
First part: initialize listener
Based on the g.network
value (e.g., “unix”, “tcp”, “tcp4”, “tcp6”), create a new poller for each address to listen on. This poller mainly manages events on the listening socket. If an error occurs during creation, stop all previously created listeners and return an error.
Second part: initialize a certain number of pollers
Create the specified number of worker pollers ( pollerNum
). These pollers handle read/write events on connected sockets. If an error occurs during creation, stop all listeners and previously created worker pollers, and then return an error.
Third part: start all worker pollers
Assign a read buffer for each worker poller concurrently and start these pollers.
Fourth part: start all listeners
Start all previously created listeners and begin listening for connection requests on respective addresses.
Regarding the startup of pollers:
|
|
It’s divided into two cases. If it’s a listener poller:
|
|
The listener poller waits for new connections and, upon arrival, encapsulates them into nbio.Conn
after acceptance. Then, it adds the conn
to the corresponding worker poller.
|
|
An interesting design here is the management of conns
. The structure is a slice, and the author directly uses conn
’s fd
as the index. This has its benefits:
- With a large number of connections, the burden during garbage collection is smaller compared to using a map.
- It prevents serial number issues.
Finally, the corresponding conn
fd is added to epoll by calling addRead
.
|
|
It’s reasonable not to register the write event here because there’s no data to send on a new connection. This approach avoids some unnecessary system calls, thereby enhancing program performance.
If it’s a worker poller’s startup, its job is to wait for events from the added conns
and handle them accordingly.
|
|
This piece of code is also straightforward. It waits for events to arrive, traverses the event list, and handles each event accordingly.
|
|
In EpollWait
, only msec
is user-modifiable. Usually, we set msec = -1
to make the function block until at least one event occurs; otherwise, it blocks indefinitely. This method is very useful when there are few events because it minimizes CPU usage.
If you want to respond to events as quickly as possible, you can set msec = 0
. This makes EpollWait
return immediately without waiting for any events. In this case, your program may call EpollWait
more frequently, but it can process events immediately after they occur, leading to higher CPU usage.
If your program can tolerate some delay and you want to reduce CPU usage, you can set msec
it to a positive number. This makes EpollWait
waiting for events for the specified time. If no events occur during this time, the function returns, and you can choose to call EpollWait
again later. This method can reduce CPU usage but may result in longer response times.
Nbio adjusts the value msec
according to event counts. If the count is greater than 0, msec
is set to 20.
The code for ByteDance’s netpoll is similar; if the event count is greater than 0, msec
it is set to 0. If the event count is less than or equal to 0, msec
it is set to -1, and then Gosched()
is called to voluntarily yield the current Goroutine.
|
|
However, the code for voluntary switching in nbio has been commented out. According to the author’s explanation in the issue, initially, he referred to ByteDance’s method and added voluntary switching.
However, during performance testing of nbio, it was found that adding or not adding voluntary switching did not significantly affect performance. Therefore, it was ultimately decided to remove it.
The processing part of the event.
If it is a readable event, you can obtain the corresponding buffer through built-in or custom memory allocators, and then call ReadAndGetConn to read the data without needing to allocate a buffer every time.
If it is a writable event, flush will be called to send out the unsent data in the buffer.
|
|
The logic is also very simple, write as much as there is, if it cannot be written, put the remaining data back into the writeBuffer and write again when epollWait triggers.
If writing is completed, then there is no more data to be written, reset the event of this connection to a read event.
That’s basically how the main logic works.
Wait a minute, when we initially mentioned that a new connection comes in, we only registered a read event for the connection and didn’t register a write event. When was the write event registered?
Of course, it is registered when you call conn.Write.
|
|
When conn data arrives, the bottom layer will call back the OnData function after reading the data. At this time, you can call Write to send data to the other end.
|
|
When the data is not completely written, the remaining data is put into writeBuffer, which will trigger the execution of modWrite and register the write event of conn to epoll.
Summary
Compared to Evio, nbio does not have a thundering herd effect.
Evio achieves logical correctness by constantly waking up epoll invalidly. Nbio tries to minimize system calls and reduce unnecessary overhead.
In terms of usability, nbio implements a standard library net.Conn and many settings are configurable, allowing users to customize with high flexibility.
Pre-allocated buffers are used for reading and writing to improve application performance.
In conclusion, nbio is a good high-performance non-blocking network framework.