Pixockets: cómo escribimos nuestra propia biblioteca de red para el servidor de juegos



¡Hola! Conectado Stanislav Yablonsky, desarrollador principal del servidor de Pixonic.

Cuando llegué a Pixonic por primera vez, nuestros servidores de juegos eran aplicaciones basadas en Photon Realtime SDK : un marco multifuncional pero muy pesado. Parece que esta solución fue simplificar el trabajo con el servidor. Así fue, hasta cierto punto.

Photon Realtime nos ató a sí mismo al tener que usarlo para intercambiar datos entre los jugadores y el servidor, y también lo vinculó a Windows, ya que solo puede funcionar en él. Esto nos impuso restricciones tanto desde el punto de vista del tiempo de ejecución (tiempo de ejecución): era imposible cambiar muchas configuraciones importantes de la máquina virtual .NET y del sistema operativo. Estamos acostumbrados a trabajar con servidores Linux, no con Windows. Además, nos cuestan menos.

Además, el uso de Photon golpeó el rendimiento tanto en el servidor como en el cliente, y al generar perfiles, se formó una carga decente en el recolector de basura y una gran cantidad de boxeo / unboxing.

En resumen, la solución con Photon Realtime estuvo lejos de ser óptima para nosotros, y durante mucho tiempo fue necesario hacer algo con ella, pero siempre hubo tareas más urgentes y las manos no llegaron a la solución de problemas con el servidor.

Como era interesante no solo resolver el problema, sino también comprender mejor la red, decidí tomar la iniciativa en mis propias manos e intentar escribir una biblioteca yo mismo. Pero, entiendes, en casa, en casa, en el trabajo, como resultado, el tiempo para desarrollar la biblioteca solo estaba en el transporte. Sin embargo, esto no impidió que la idea se hiciera realidad.

Lo que salió de eso - sigue leyendo.

Ideología de la biblioteca


Dado que estamos desarrollando juegos en línea, es muy importante para nosotros trabajar sin pausas, por lo que los gastos generales bajos se han convertido en el principal requisito para la biblioteca. Para nosotros, esto es, en primer lugar, una carga baja en el recolector de basura. Para lograrlo, traté de evitar las asignaciones, y en los casos en que fue difícil de lograr o no funcionó en absoluto, creamos grupos (para búferes de bytes, estados de conexión, encabezados, etc.).

Por simplicidad y conveniencia de soporte y ensamblaje, comenzamos a usar solo C # y enchufes del sistema. Además, era importante encajar en el presupuesto de tiempo por cuadro, porque los datos del servidor deberían haber llegado a tiempo. Por lo tanto, traté de reducir el tiempo de ejecución, incluso a costa de alguna falta de optimización: es decir, en algunos lugares valió la pena reemplazar los algoritmos y estructuras de datos rápidos y en parte más complejos por otros más simples y predecibles. Por ejemplo, no utilizamos colas sin bloqueo, ya que crearon una carga en el recolector de basura.

Por lo general, para los tiradores de varios jugadores, nuestros datos se envían a través de UDP. Además, se agregó la fragmentación y el ensamblaje de paquetes para enviar datos de un tamaño mayor que el tamaño de la trama, así como una entrega confiable debido al reenvío y al establecimiento de una conexión.

El marco UDP en nuestra biblioteca está predeterminado en 1200 bytes. Los paquetes de este tamaño deben transmitirse en redes modernas con un riesgo bastante bajo de fragmentación, ya que la MTU en la mayoría de las redes modernas es superior a este valor. Al mismo tiempo, por lo general, esta cantidad es suficiente para adaptarse a los cambios que deben enviarse al jugador después de la próxima marca (actualización de estado) en el juego.

Arquitectura


En nuestra biblioteca usamos un socket de dos capas:

  • La primera capa es responsable de trabajar con llamadas al sistema y proporciona una API más conveniente para el siguiente nivel;
  • La segunda capa es trabajar directamente con la sesión, fragmentación / ensamblaje de paquetes, su reenvío, etc.



La clase para trabajar con conexión, a su vez, también se divide en dos niveles:

  • El nivel inferior (SockBase) es responsable de enviar y recibir datos a través de UDP. Es una envoltura delgada sobre un objeto de sistema de socket.
  • Nivel superior (SmartSock) proporciona funcionalidad adicional sobre UDP. Cortar y pegar paquetes, reenviar datos que no ha alcanzado, rechazo de duplicados: todo esto es su área de responsabilidad.

El nivel inferior se divide en dos clases: BareSock y ThreadSock.

  • BareSock funciona en el mismo hilo donde se originó la llamada, enviando y recibiendo datos en modo sin bloqueo.
  • ThreadSock coloca los paquetes en colas y, por lo tanto, crea hilos separados para enviar y recibir datos. Al acceder, solo hay una operación: agregar o eliminar datos de la cola.

BareSock se usa a menudo para trabajar con el cliente, ThreadSock, con el servidor.

Características del trabajo


También escribí dos tipos de zócalos de bajo nivel:

  • El primero es sincrónico de un solo subproceso. En él, obtenemos la sobrecarga mínima para la memoria y el procesador, pero al mismo tiempo, las llamadas al sistema ocurren directamente al acceder al socket. Esto minimiza los gastos generales en general (no es necesario usar colas y almacenamientos intermedios adicionales), pero la llamada en sí misma puede tomar más tiempo que tomar un elemento de la cola.
  • El segundo es asíncrono con hilos separados para leer y escribir. En este caso, obtenemos una sobrecarga adicional para la cola, la sincronización y el tiempo de envío / recepción (en unos pocos milisegundos), ya que en el momento del acceso al socket, el hilo de lectura o escritura está en pausa.

También intentamos usar SocketAsyncEventArgs, quizás la API de red más avanzada en .NET que conozco. Pero resultó que probablemente no funciona para UDP: la pila TCP funciona bien, pero UDP da errores acerca de obtener marcos extrañamente recortados e incluso estrellarse dentro de .NET, como si la memoria en la parte nativa de la máquina virtual estuviera dañada. No encontré ejemplos de la operación de tal esquema.

Otra característica importante de nuestra biblioteca es la pérdida reducida de datos. Tenemos la impresión de que, para deshacernos de los duplicados, muchas bibliotecas descartan los paquetes de datos antiguos, como luego vimos por nuestra propia experiencia. Por supuesto, una implementación de este tipo es mucho más simple, porque en su caso un contador con el número de la última trama recibida es suficiente, pero no nos quedaba muy bien. Por lo tanto, Pixockets utiliza un búfer circular de los números de los últimos cuadros para filtrar los duplicados: los números recién llegados se sobrescriben en lugar de los antiguos, y se buscan duplicados entre los últimos cuadros recibidos.



Por lo tanto, si se envió un paquete antes de la trama actual, pero vino después, aún llegará al destino. Esto puede ser de gran ayuda, por ejemplo, en el caso de la interpolación de posición. En este caso, tendremos una historia más completa.

Estructura del paquete de datos


Los datos en la biblioteca se transmiten de la siguiente manera:



al comienzo del paquete está el encabezado:

  • Comienza con el tamaño del paquete, que a su vez está limitado a 64 kilobytes.
  • El tamaño es seguido por un byte con banderas. La interpretación del resto del título depende de su disponibilidad.
  • El siguiente es el identificador de la sesión o conexión.

Con las banderas apropiadas, obtenemos:

  • Si se establece la bandera con el número de paquete a su vez, el número de paquete se transmite después del identificador de sesión.
  • Siguiéndolo, también en el caso del conjunto de banderas, la cantidad de paquetes confirmados y sus números.

Al final del encabezado hay información sobre el fragmento:

  • identificador de la secuencia de fragmentos, que es necesario para distinguir fragmentos de diferentes mensajes;
  • número de secuencia del fragmento;
  • Número total de fragmentos en el mensaje.

La información sobre el fragmento también requiere establecer la bandera correspondiente.

La biblioteca está escrita. ¿Que sigue?


Para tener información de conexión síncrona más precisa, luego organizamos una conexión explícita. Esto nos ayudó a comprender claramente las situaciones en que un lado piensa que la conexión se establece y no se interrumpe, y el otro, que se interrumpió.

En la primera versión de Pixockets, esto no era así: el cliente no necesitaba llamar al método Connect (host, puerto), simplemente comenzó a enviar datos a una dirección y puerto conocidos. Luego, el servidor llamó al método Listen (puerto) y comenzó a recibir datos de una dirección específica. Los datos de la sesión se inicializaron al recibir / transmitir el paquete.

Ahora, para establecer una conexión, se ha hecho necesario un "apretón de manos" (el intercambio de paquetes especialmente formados) y el cliente debe llamar a Connect.

Además, uno de mis colegas bifurcó la biblioteca, prestó más atención a la seguridad de la red y también agregó algunas características, como la capacidad de reconectarse directamente dentro del zócalo: por ejemplo, al cambiar entre Wi-Fi y 4G, la conexión ahora se restaura automáticamente. Pero hablaremos de esto más tarde.

Pruebas


Por supuesto, escribimos pruebas unitarias para la biblioteca: verifican todas las formas principales de establecer una conexión, enviar y recibir datos, fragmentación y ensamblaje de paquetes, diversas anomalías en el envío y recepción de datos, como duplicación, pérdida, falta de coincidencia en el orden de envío y recepción. Para la verificación inicial del rendimiento, escribí aplicaciones de prueba especiales para pruebas de integración: un cliente de ping, un servidor de ping y una aplicación que sincroniza la posición, el color y el número de círculos de colores en la pantalla a través de la red.

Después de que las aplicaciones de prueba demostraron la eficacia de nuestra biblioteca, comenzamos a compararla con otras bibliotecas: con nuestro antiguo Photon Realtime y con la biblioteca UDP LiteNetLib 0.7.

Probamos una versión simplificada de un servidor de juegos que simplemente recopila información de los jugadores y envía el resultado "pegado". Llevamos a 500 jugadores en salas de 6 personas, la frecuencia de actualización es de 30 veces por segundo.



La carga en el recolector de basura y el consumo del procesador resultaron ser más bajos en el caso de Pixockets, así como el porcentaje de paquetes faltantes, aparentemente debido al hecho de que, a diferencia de otras versiones UDP, no ignoramos los paquetes tardíos.

Después de recibir la confirmación de la ventaja de nuestra solución en las pruebas sintéticas, el siguiente paso fue ejecutar la biblioteca en un proyecto real.

En ese momento, en el proyecto que seleccionamos, los clientes y los servidores del juego se sincronizaron a través de Photon Server. Agregué soporte de Pixockets al cliente y al servidor, lo que hace posible controlar la elección del protocolo desde el servidor de emparejamiento, al que los clientes envían una solicitud para ingresar al juego.

Durante algún tiempo, los clientes jugaron simultáneamente en ambos protocolos, y en ese momento recopilamos estadísticas sobre cómo les iba. Al final de la recopilación de estadísticas, resultó que los resultados no difieren de las pruebas sintéticas: la carga en el recolector de basura y el procesador ha disminuido, la pérdida de paquetes también. Al mismo tiempo, el ping se hizo un poco más bajo. Por lo tanto, la próxima versión del juego ya se lanzó por completo en Pixockets sin usar Photon Realtime SDK.



Planes futuros


Ahora queremos implementar las siguientes características en la biblioteca:

  • Conexión simplificada: ahora no funciona de manera óptima, y ​​después de llamar a Connect en el cliente, debe llamar a Read hasta que cambie el estado de la conexión;
  • Apagado explícito: en este momento, el apagado en el otro lado ocurre solo por temporizador;
  • Pings incorporados para mantener la conectividad;
  • Determinación automática del tamaño de cuadro óptimo (ahora solo se usa una constante).

Puede ver y participar en el desarrollo posterior de Pixockets en la dirección del repositorio.

All Articles