Llevo varios días atascado con un problema en el desarrollo del juego con Golang que me traigo entre manos. Como ya os decía en otro post, el servidor está implementado en Golang usando el motor de físicas Box2D.
El tema es que suele ser consenso entre programadores de juegos que la concurrencia hace perder rendimiento. De echo, la mayoría de juegos corren en un único hilo, a lo sumo en dos o tres. Uno para la física, otro para comunicaciones y alguno más para tareas secundarias.
Como yo soy un cabezón y tengo una fe ciega en la concurrencia que ofrece go con sus goroutinas, me he empeñado en abusar de ella todo lo que pueda. Y ya me he dado con la primera en la frente. Mi juego funcionaba bien con pocos clientes conectados pero se volvía totalmente inestable en cuanto el número de conexiones se acercaba a las 500, volviéndose totalmente injugable a partir de esos parámetros.
Después de días haciendo pruebas y refactorizando, incluso pensando que la implementación de Box2D para Go de ByteArena era inestable, por fin he dado con el problema.
Paralelismo no es desorden, sino coordinación. Paralelismo es descomponer un problema en subproblemas y resolver cada uno de esos subproblemas simultaneamente para llegar a una solución final de manera más rápida.
Y yo no estaba haciendo paralelismo, sino concurrencia. Mis tareas no colaboraban entre ellas, más bien competían por conseguir los resultados de una de ellas.
En mi caso, tengo un hilo de ejecución que va calculando lo que ocurre en el mundo. Básicamente, calcula lo que pasa en un pequeño lapso de tiempo y luego duerme un ratito (un frame).
Por otro lado, tengo N hilos de ejecución, uno por cliente conectado, que estaban consultando el mundo y enviando actualizaciones a los clientes. No se puede consultar el estado del mundo mientras este está actualizándose, por lo que decidí proteger el mundo con un mutex. De esa manera, mientras se actualiza el mundo no se puede consultar. Sólo después de actualizar el mundo, esa goroutina duerme un rato liberando el mutex y permitiendo que otras goroutinas consulten el estado de este.
¿Cual era el problema? Que esto no es paralelismo. Es concurrencia. 501 gorutinas compitiendo por el acceso al mutex de manera caótica. No entraban en el orden esperado, y algunas incluso no eran capaces de entrar en el tiempo necesario, provocando fuertes desincronizaciones entre el cliente y el servidor.
La culpa es obviamente mía y de mi manera de pensar. Pensé que la anarquía podría resolver por si sola todo el problema, pero eso no es cierto cuando tienes tiempos precisos de actualización de los clientes y una carga muy elevada en el servidor.
La solución. Aplicar uno de los mantras de la comunidad go, explicada muy bien en el artículo enlazado.
Do not communicate by sharing memory; instead, share memory by communicating.
En lugar de compartir memoria (el mundo) es el mundo el que debe actualizar las vistas de los clientes cuando sea conveniente. Los clientes, que son goroutinas, simplemente deben esperar a que el mundo les notifique que algo ha cambiado. Por lo general, escuchando por un channel.
Creo que es la primera vez que le veo sentido auténtico a la frase «do not communicate by sharing memory….» . Me jode tener que haberlo descubierto en mis carnes con tantas horas de desarrollo, pero mejor tarde que nunca.
NOTA: Por no extenderme, he decidido no incluir nada de pseudocódigo. Creo que la explicación se entiende relativamente bien. Si algún lector lo pidiese, podría ampliar un poco el post con código o incluso enlazar a la Pull Request con los cambios.