Efficient client and server
This section guides you through creating a jobified client and server. It extends and modifies the minimal client and server example to use jobs to leverage parallel code execution.
Before reading this tutorial, you should understand how the C# Job System works.
Create a jobified client
This section shows how to create a jobified client to handle inputs from the network. Because you only handle one client at a time, use IJob
as your job type. You need to pass the driver and the connection to the job to handle updates within the job’s Execute method.
struct ClientUpdateJob: IJob
{
public NetworkDriver driver;
public NativeArray<NetworkConnection> connection;
public NativeArray<byte> done;
public void Execute() { ... }
}
The data inside the ClientUpdateJob
is copied. If you want to use the data after completing the job, you must have your data in a shared container, such as a NativeContainer
.
You should update the NetworkConnection
and the done variables inside the job because you might receive a disconnect message. Verify you can share the data between the job and the caller. In this case, use a NativeArray
.
You can only use blittable types in a NativeContainer. In this case, because bool
isn't blittable, you must use a byte
instead of bool
.
Move the code from the Update method (from ClientBehaviour.cs
) to the Execute method.
You need to change any call to m_Connection
to connection[0]
to refer to the first element inside the NativeArray
. The same goes for your done variable; you must call done[0]
when you refer to the done variable. See the following code snippet:
public void Execute()
{
if (!connection[0].IsCreated)
{
// Remember that its not a bool anymore.
if (done[0] != 1)
Debug.Log("Something went wrong during connect");
return;
}
DataStreamReader stream;
NetworkEvent.Type cmd;
while ((cmd = connection[0].PopEvent(driver, out stream)) != NetworkEvent.Type.Empty)
{
if (cmd == NetworkEvent.Type.Connect)
{
Debug.Log("We are now connected to the server");
var value = 1;
driver.BeginSend(connection[0], out var writer);
writer.WriteUInt(value);
driver.EndSend(writer);
}
else if (cmd == NetworkEvent.Type.Data)
{
uint value = stream.ReadUInt();
Debug.Log("Got the value = " + value + " back from the server");
// And finally change the `done[0]` to `1`
done[0] = 1;
connection[0].Disconnect(driver);
connection[0] = default(NetworkConnection);
}
else if (cmd == NetworkEvent.Type.Disconnect)
{
Debug.Log("Client got disconnected from server");
connection[0] = default(NetworkConnection);
}
}
}
Update the client MonoBehaviour
When you have a job, you must verify that you can execute the job. The following code sample shows the changes to ClientBehaviour
:
- Change
m_Done
andm_Connection
to typeNativeArray
. - Add a JobHandle to track ongoing jobs.
public class JobifiedClientBehaviour : MonoBehaviour
{
public NetworkDriver m_Driver;
public NativeArray<NetworkConnection> m_Connection;
public NativeArray<byte> m_Done;
public JobHandle ClientJobHandle;
public void OnDestroy() { ... }
public void Start() { ... }
public void Update() { ... }
}
Start method
The Start method looks similar to the one in the simple client example; the most significant update is that the jobified version verifies that you create a NativeArray
.
void Start () {
m_Driver = NetworkDriver.Create();
m_Connection = new NativeArray<NetworkConnection>(1, Allocator.Persistent);
m_Done = new NativeArray<byte>(1, Allocator.Persistent);
var endpoint = NetworkEndPoint.LoopbackIpv4;
endpoint.Port = 9000;
m_Connection[0] = m_Driver.Connect(endpoint);
}
OnDestroy
method
In the OnDestroy
method, dispose of all NativeArray
objects. Then add a ClientJobHandle.Complete()
call to ensure the jobs complete before you clean up and destroy the data they might be using.
public void OnDestroy()
{
ClientJobHandle.Complete();
m_Connection.Dispose();
m_Driver.Dispose();
m_Done.Dispose();
}
Client Update
loop
Update the core game loop. Ensure the last frame completes before running the new frame. Instead of calling m_Driver.ScheduleUpdate().Complete()
, use the JobHandle
, then call ClientJobHandle.Complete()
.
void Update()
{
ClientJobHandle.Complete();
...
}
Next, create a job struct to chain the job:
var job = new ClientUpdateJob
{
driver = m_Driver,
connection = m_Connection,
done = m_Done
};
To schedule the job, pass the JobHandle
dependency returned from the m_Driver.ScheduleUpdate
call in the Schedule function of your IJob
. Start by invoking the m_Driver.ScheduleUpdate
without a call to Complete
, and pass the returning JobHandle
to the saved ClientJobHandle
.
Pass the returned ClientJobHandle
to the job you created, returning a newly updated ClientJobHandle
.
ClientJobHandle = m_Driver.ScheduleUpdate();
ClientJobHandle = job.Schedule(ClientJobHandle);
You now have a JobifiedClientBehaviour
that looks like this.
Create a jobified server
The jobified server is similar to the simple server in the earlier example. The only necessary modification is to create the jobs you need, then update the usage code.
The NetworkDriver
has a ScheduleUpdate
method that returns a JobHandle
that populates the internal buffers of the NetworkDriver
and provides the PopEvent
/PopEventForConnection
method. You can use the IJobParallelFor
job type to create a job to run the processing code for all connected clients in parallel.
There’s another IJobPrarallelFor
job type you can use: IJobParallelForDefer
. This job type suits the jobified server because you don’t know the exact number of requests the server might receive or the number of connections the server might need to process.
struct ServerUpdateJob : IJobParallelForDefer
{
public void Execute(int index)
{
throw new System.NotImplementedException();
}
}
However, you can only run some of your code in parallel.
The jobified client begins by cleaning up closed connections and accepting new ones, which you can't do in parallel. Instead, you must create a connection job.
Create a ServerUpdateConnectionJob
job, then pass both the driver and connections to the connection job. This job should clean up connections and accept new connections:
struct ServerUpdateConnectionsJob : IJob
{
public NetworkDriver driver;
public NativeList<NetworkConnection> connections;
public void Execute()
{
// Clean up connections
for (int i = 0; i < connections.Length; i++)
{
if (!connections[i].IsCreated)
{
connections.RemoveAtSwapBack(i);
--i;
}
}
// Accept new connections
NetworkConnection c;
while ((c = driver.Accept()) != default(NetworkConnection))
{
connections.Add(c);
Debug.Log("Accepted a connection");
}
}
}
The code above is nearly the same as the non-jobified server code from the simple server example.
With the ServerUpdateConnectionsJob
done, implement the ServerUpdateJob
using IJobParallelFor
:
struct ServerUpdateJob : IJobParallelForDefer
{
public NetworkDriver.Concurrent driver;
public NativeArray<NetworkConnection> connections;
public void Execute(int index)
{
...
}
}
The ServerUpdateJob
has two significant differences compared with the ServerUpdateConnectionJob
job:
- The
ServerUpdateJob
uses theNetworkDriver.Concurrent
type, which allows you to call theNetworkDriver
from multiple threads. This is precisely what you need for theIParallelForJobDefer
. - The
ServerUpdateJob
passes aNativeArray
of typeNetworkConnection
instead of aNativeList
. TheIParallelForJobDefer
doesn't accept any otherUnity.Collections
type than aNativeArray
.
Execute method
The only difference in the Execute method between the simple server code and the jobified server code is that you remove the top-level for loop in the jobified server:
for (int i = 0; i < m_Connections.Length; i++)
The jobified server doesn’t need this top-level for loop because it calls the Execute function for each connection, and passes in the index to that available connection.
public void Execute(int index)
{
DataStreamReader stream;
Assert.IsTrue(connections[index].IsCreated);
NetworkEvent.Type cmd;
while ((cmd = driver.PopEventForConnection(connections[index], out stream)) !=
NetworkEvent.Type.Empty)
{
if (cmd == NetworkEvent.Type.Data)
{
uint number = stream.ReadUInt();
Debug.Log("Got " + number + " from the Client adding + 2 to it.");
number +=2;
driver.BeginSend(connections[index], out var writer);
writer.WriteUInt(number);
driver.EndSend(writer);
}
else if (cmd == NetworkEvent.Type.Disconnect)
{
Debug.Log("Client disconnected from server");
connections[index] = default(NetworkConnection);
}
}
}
The top-level while
loop uses the index of the available connection:
while ((cmd = driver.PopEventForConnection(connections[index], out stream)) != NetworkEvent.Type.Empty`
Use the index of the connection (passed into the Execute
method) to iterate over all the connections.
You now have two jobs:
- The first job,
ServerUpdateConnectionJob
, updates the connection status by adding new connections and removing old connections. - The second job,
ServerUpdateJob
, parses theNetworkEvent
for each connected client.
Update the server MonoBehaviour
Next, access the jobified server MonoBehaviour
and start updating the server.
public class JobifiedServerBehaviour : MonoBehaviour
{
public NetworkDriver m_Driver;
public NativeList<NetworkConnection> m_Connections;
private JobHandle ServerJobHandle;
void Start () { ... }
public void OnDestroy() { ... }
void Update () { ... }
}
The only change between the simple server and the jobified server variable declaration is that the jobified server adds a JobHandle to keep track of ongoing jobs.
Server Start
method
You don’t need to change the Start
method from the simple server example:
void Start ()
{
m_Connections = new NativeList<NetworkConnection>(16, Allocator.Persistent);
m_Driver = new NetworkDriver.Create();
var endpoint = NetworkEndPoint.AnyIpv4;
endpoint.Port = 9000;
if (m_Driver.Bind(endpoint) != 0)
Debug.Log("Failed to bind to port 9000");
else
m_Driver.Listen();
}
Server OnDestroy
method
Remember to call ServerJobHandle.Complete
in the OnDestroy
method to clean up the code:
public void OnDestroy()
{
// Make sure we run our jobs to completion before exiting.
if (m_Driver.IsCreated)
{
ServerJobHandle.Complete();
m_Connections.Dispose();
m_Driver.Dispose();
}
}
Server Update
loop
Call Complete
on the JobHandle
in the Update method to force the jobs to complete before you start a new frame:
void Update ()
{
ServerJobHandle.Complete();
var connectionJob = new ServerUpdateConnectionsJob
{
driver = m_Driver,
connections = m_Connections
};
var serverUpdateJob = new ServerUpdateJob
{
driver = m_Driver.ToConcurrent(),
connections = m_Connections.ToDeferredJobArray()
};
ServerJobHandle = m_Driver.ScheduleUpdate();
ServerJobHandle = connectionJob.Schedule(ServerJobHandle);
ServerJobHandle = serverUpdateJob.Schedule(m_Connections, 1, ServerJobHandle);
}
Use the following process to chain the jobs:
NetworkDriver.Update
ServerUpdateConnectionsJob
ServerUpdateJob
Start by populating the ServerUpdateConnectionsJob
with the NetworkDriver
and the connections:
var connectionJob = new ServerUpdateConnectionsJob
{
driver = m_Driver,
connections = m_Connections
};
Next, create the ServerUpdateJob
. Remember to call ToConcurrent
on the NetworkDriver
to ensure you’re using a concurrent driver for the IParallelForJobDefer
:
var serverUpdateJob = new ServerUpdateJob
{
driver = m_Driver.ToConcurrent(),
connections = m_Connections.ToDeferredJobArray()
};
Verify the NativeArray
is populated with the correct size. You can verify the NativeArray
size using a DeferredJobArray
. DeferredJobArray
verifies that the connections array is populated with the same number of items in the connections list.
The size of the connections list might change when you first run ServerUpdateConnectionsJob
.
Create the job chain and call Scheduele
as follows:
ServerJobHandle = m_Driver.ScheduleUpdate();
ServerJobHandle = connectionJob.Schedule(ServerJobHandle);
ServerJobHandle = serverUpdateJob.Schedule(m_Connections, 1, ServerJobHandle);
In the code above, you:
- Scheduled the
NetworkDriver
job. JobHandle
is returned as a dependency on theServerUpdateConnectionJob
.- The final link in the chain is the
ServerUpdateJob
. It must run afterServerUpdateConnectionsJob
to invoke theIJobParallelForDeferExtensions
.m_Connections
NativeList
passed to theSchedule
method, which updates the connections count before starting the job. It fans out and runs allServerUpdateConnectionJobs
in parallel.
You now have a fully functional jobified server.
Use Burst for extra performance
All the jobs in the example code adhere to the subset of C# supported by Burst. Burst is a compiler that pre-compiles Unity jobs into highly-performant native code. Unity Transport takes advantage of Burst and uses mostly Burst-friendly data structures (like NetworkDriver
).
You can make a job Burst-compiled by adding the [BurstCompile
] attribute to its definition. For example:
[BurstCompile]
struct ClientUpdateJob : IJob
{
...
}
Refer to the Burst documentation for more details on how to use it.