Com a maneira como abordo as coisas, o multithreading meio que é gratuito e relativamente simples de aplicar em retrospectiva. Mas estou pensando nos dados primeiro. Não sei se isso funciona para todos os domínios, mas tentarei abordar como faço para fazê-lo.
Então, primeiro, trata-se do tipo mais grosseiro de dados necessários para o software que será processado com frequência. Se é um jogo que pode ser algo como malhas, sons, movimento, emissores de partículas, luzes, texturas, coisas desse tipo. E é claro que há muito em que pensar se você detalhar apenas as malhas e pensar em como elas devem ser representadas, mas vamos pular isso por enquanto. No momento, estamos pensando no nível arquitetônico mais amplo.
E meu primeiro pensamento é: "Como unificamos a representação de todas essas coisas para que possamos alcançar um padrão de acesso relativamente uniforme para todos esses tipos de coisas?" E meu primeiro pensamento pode ser armazenar cada tipo de coisa em sua própria matriz contígua com uma maneira gratuita de recuperar espaços vazios. E isso tende a unificar a API para que possamos usar mais facilmente, digamos, o mesmo tipo de código para serializar malhas que realizamos luzes e texturas, pelo menos na medida em que local e como esses componentes são acessados. Quanto mais podemos unificar como tudo é representado, mais o código que acessa essas coisas tende a assumir uma forma uniforme.
Isso é legal. Agora também podemos apontar essas coisas com índices de 32 bits e ocupar apenas metade da memória de um ponteiro de 64 bits. E, ei, podemos definir interseções em tempo linear agora, se pudermos associar um conjunto de bits paralelo, por exemplo. Ah, e esse conjunto de bits pode nos devolver um conjunto de índices classificados para percorrer em ordem seqüencial para melhorar os padrões de acesso à memória, sem precisar recarregar a mesma linha de cache várias vezes em um único loop. Podemos testar 64 bits por vez. Se todos os 64 bits não estiverem definidos, podemos pular mais de 64 elementos de uma vez. Se todos eles estiverem definidos, podemos processá-los todos de uma vez. Se algumas estão definidas, mas não todas, podemos usar as instruções do FFS para determinar rapidamente quais bits estão definidos.
Mas, espere, isso é meio caro se quisermos associar dados a algumas centenas de milhares de coisas. Então, vamos usar uma matriz esparsa, assim:
E ei, agora que temos tudo armazenado em matrizes esparsas e indexadas, seria muito fácil tornar isso uma estrutura de dados persistente.
Agora podemos escrever funções mais baratas, livres de efeitos colaterais, pois elas não precisam copiar profundamente o que não mudou.
E aqui já me foi dada uma dica após aprender sobre os motores ECS, mas agora vamos pensar sobre que tipo de funções gerais devem estar operando em cada tipo de componente. Podemos chamar esses "sistemas". O "SoundSystem" pode processar os componentes "Sound". Cada sistema é uma função ampla que opera em um ou mais tipos de dados.
Isso nos deixa com muitos casos em que, para qualquer tipo de componente, apenas um ou dois sistemas geralmente os acessam. Hmm, com certeza parece que isso ajudaria na segurança do encadeamento e absolutamente reduziria a contenção do encadeamento.
Além disso, tento pensar em como fazer passagens homogêneas sobre os dados. Em vez de gostar:
for each thing:
play with it
cuddle it
kill it
Eu procuro dividi-lo em vários passes mais simples:
for each thing:
play with it
for each thing:
cuddle it
for each thing:
kill it
Às vezes, isso exige o armazenamento de algum estado intermediário para a próxima passagem adiada homogênea ao processo, mas descobri que isso realmente me ajuda a manter e raciocinar sobre o código, sabendo que cada loop tem uma lógica mais simples e uniforme. E, ei, isso parece simplificar a segurança do thread e reduzir a contenção de threads.
E você continua assim até descobrir que possui uma arquitetura realmente fácil de paralelizar com a confiança sobre a segurança e a exatidão do encadeamento, mas inicialmente com o foco de unificar representações de dados, ter padrões de acesso à memória mais previsíveis, reduzindo uso de memória, simplificando o fluxo de controle para passes mais homogêneos, reduzindo o número de funções em seu sistema que causam efeitos colaterais sem incorrer em custos profundos de cópia muito caros, unificando sua API etc.
Quando você combina todas essas coisas, tende a acabar com um sistema que minimiza a quantidade de estado compartilhado em que você se depara com um design realmente amigável à simultaneidade. E, se qualquer estado precisar ser compartilhado, você geralmente acha que não há muita disputa, onde é barato usar alguma sincronização sem causar um congestionamento de tráfego de encadeamento e, além disso, que muitas vezes pode ser tratado por sua estrutura de dados central que unifica a representação de todas as coisas no sistema, para que você não precise aplicar sincronizações de threads em centenas de lugares diferentes, apenas um punhado.
Agora, quando analisamos um dos componentes mais complexos, como malhas, repetimos o mesmo processo de design, começando com o pensamento sobre os dados. E se fizermos isso corretamente, poderemos até paralelizar facilmente o processamento de uma única malha, mas o projeto arquitetônico mais amplo que estabelecemos já nos permite paralelizar o processamento de várias malhas.