Como o padrão StartCoroutine / yield return realmente funciona no Unity?


134

Eu entendo o princípio das corotinas. Eu sei como fazer com que o padrão StartCoroutine/ yield returnpadrão funcione em C # no Unity, por exemplo, invoque um método retornando IEnumeratorvia StartCoroutinee nesse método faça alguma coisa, yield return new WaitForSeconds(1);espere um segundo e faça outra coisa.

Minha pergunta é: o que realmente está acontecendo nos bastidores? O que StartCoroutinerealmente faz? O que IEnumeratorestá WaitForSecondsretornando? Como StartCoroutineretornar o controle para a parte "outra coisa" do método chamado? Como tudo isso interage com o modelo de simultaneidade do Unity (onde muitas coisas acontecem ao mesmo tempo sem o uso de corotinas)?


3
O compilador C # transforma métodos que retornam IEnumerator/ IEnumerable(ou os equivalentes genéricos) e que contêm a yieldpalavra - chave. Procure iteradores.
Damien_The_Unbeliever

4
Um iterador é uma abstração muito conveniente para uma "máquina de estado". Entenda isso primeiro e você também receberá as rotinas da Unity. en.wikipedia.org/wiki/State_machine
Hans Passant

2
A tag unity é reservada pelo Microsoft Unity. Por favor, não abuse.
Lex Li

11
Eu encontrei este artigo muito esclarecedor: coroutines Unity3D em detalhes
Kay

5
@ Kay - Eu gostaria de poder comprar uma cerveja para você. Esse artigo é exatamente o que eu precisava. Eu estava começando a questionar minha sanidade mental, pois parecia que minha pergunta nem fazia sentido, mas o artigo responde diretamente à minha pergunta melhor do que eu poderia ter imaginado. Talvez você possa adicionar uma resposta neste link que eu possa aceitar, para o benefício de futuros usuários de SO?
Ghopper21

Respostas:


109

O link de detalhe em detalhe das rotinas detalhadas do Unity3D está morto. Como é mencionado nos comentários e nas respostas, vou postar o conteúdo do artigo aqui. Este conteúdo vem deste espelho .


Rotinas do Unity3D em detalhes

Muitos processos nos jogos ocorrem ao longo de vários quadros. Você tem processos "densos", como a busca de caminhos, que trabalha duro em cada quadro, mas se divide em vários quadros para não afetar muito a taxa de quadros. Você tem processos "esparsos", como gatilhos de jogabilidade, que não fazem mais quadros, mas ocasionalmente são chamados a fazer um trabalho crítico. E você tem processos variados entre os dois.

Sempre que você estiver criando um processo que ocorrerá em vários quadros - sem multithreading -, você precisará encontrar uma maneira de dividir o trabalho em partes que podem ser executadas uma por quadro. Para qualquer algoritmo com um loop central, é bastante óbvio: um descobridor A *, por exemplo, pode ser estruturado de forma a manter suas listas de nós semi-permanentemente, processando apenas alguns nós da lista aberta em cada quadro, em vez de tentar para fazer todo o trabalho de uma só vez. Há algum balanceamento a ser feito para gerenciar a latência - afinal, se você estiver bloqueando a taxa de quadros em 60 ou 30 quadros por segundo, seu processo executará apenas 60 ou 30 etapas por segundo, o que pode fazer com que o processo demore apenas muito longo no geral. Um design elegante pode oferecer a menor unidade de trabalho possível em um nível - por exemplo, processe um único nó A * - e aplique uma camada de agrupamento em partes maiores - por exemplo, continue processando nós A * por X milissegundos. (Algumas pessoas chamam isso de 'timeslicing', embora eu não).

Ainda assim, permitir que o trabalho seja dividido dessa maneira significa que você precisa transferir o estado de um quadro para o outro. Se você estiver quebrando um algoritmo iterativo, precisará preservar todo o estado compartilhado nas iterações, bem como um meio de rastrear qual iteração será executada a seguir. Isso geralmente não é muito ruim - o design de uma 'classe A * pathfinder' é bastante óbvio - mas também há outros casos que são menos agradáveis. Às vezes, você estará enfrentando longos cálculos que estão realizando diferentes tipos de trabalhos de quadro a quadro; o objeto que captura seu estado pode acabar com uma grande confusão de 'locais' semi-úteis, mantidos para passar dados de um quadro para o outro. E se você estiver lidando com um processo esparso, muitas vezes acaba tendo que implementar uma pequena máquina de estado apenas para rastrear quando o trabalho deve ser feito.

Não seria legal se, em vez de rastrear explicitamente todo esse estado em vários quadros, e em vez de multithread e gerenciar a sincronização e o bloqueio e assim por diante, você pudesse escrever sua função como um único pedaço de código e marcar lugares específicos onde a função deve 'pausar' e continuar mais tarde?

A unidade - junto com vários outros ambientes e idiomas - fornece isso na forma de corotinas.

Como eles se parecem? Em "Unityscript" (Javascript):

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

Em c #:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

Como eles funcionam? Deixe-me dizer, rapidamente, que não trabalho para a Unity Technologies. Eu não vi o código fonte do Unity. Eu nunca vi a coragem do mecanismo de rotina da Unity. No entanto, se eles o implementaram de uma maneira radicalmente diferente do que estou prestes a descrever, ficarei surpreso. Se alguém da UT quiser conversar e falar sobre como ele realmente funciona, isso seria ótimo.

As grandes pistas estão na versão C #. Em primeiro lugar, observe que o tipo de retorno para a função é IEnumerator. E segundo, observe que uma das declarações é retorno de rendimento. Isso significa que o rendimento deve ser uma palavra-chave e, como o suporte ao C # do Unity é baunilha C # 3.5, deve ser uma palavra-chave baunilha C # 3.5. De fato, aqui está no MSDN - falando sobre algo chamado 'blocos iteradores'. Então o que está acontecendo?

Em primeiro lugar, há esse tipo de IEnumerator. O tipo IEnumerator atua como um cursor sobre uma sequência, fornecendo dois membros significativos: Current, que é uma propriedade que fornece o elemento sobre o qual o cursor está atualmente, e MoveNext (), uma função que se move para o próximo elemento na sequência. Como o IEnumerator é uma interface, ele não especifica exatamente como esses membros são implementados; MoveNext () poderia apenas adicionar um a Current, ou poderia carregar o novo valor de um arquivo, ou baixar uma imagem da Internet e hash e armazenar o novo hash no Current… ou até mesmo fazer uma coisa pela primeira vez. elemento na sequência e algo totalmente diferente para o segundo. Você pode até usá-lo para gerar uma sequência infinita, se desejar. MoveNext () calcula o próximo valor na sequência (retornando false se não houver mais valores),

Normalmente, se você quiser implementar uma interface, terá que escrever uma classe, implementar os membros e assim por diante. Os blocos do iterador são uma maneira conveniente de implementar o IEnumerator sem todo esse aborrecimento - basta seguir algumas regras e a implementação do IEnumerator é gerada automaticamente pelo compilador.

Um bloco iterador é uma função regular que (a) retorna IEnumerator e (b) usa a palavra-chave yield. Então, o que a palavra-chave yield realmente faz? Ele declara qual é o próximo valor na sequência - ou que não há mais valores. O ponto em que o código encontra um retorno de rendimento X ou quebra de rendimento é o ponto em que IEnumerator.MoveNext () deve parar; um retorno de rendimento X faz com que MoveNext () retorne verdadeiro e atual seja atribuído o valor X, enquanto uma quebra de rendimento faz com que MoveNext () retorne falso.

Agora, aqui está o truque. Não precisa importar quais são os valores reais retornados pela sequência. Você pode chamar MoveNext () repetidamente e ignorar Current; os cálculos ainda serão realizados. Cada vez que MoveNext () é chamado, seu bloco iterador é executado na próxima instrução 'yield', independentemente da expressão que ele produz. Então você pode escrever algo como:

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

e o que você realmente escreveu é um bloco iterador que gera uma longa sequência de valores nulos, mas o que é significativo são os efeitos colaterais do trabalho que faz para calculá-los. Você pode executar essa rotina usando um loop simples como este:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

Ou, mais útil, você pode misturá-lo com outro trabalho:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Está tudo dentro do prazo Como você viu, cada declaração de retorno de rendimento deve fornecer uma expressão (como nula) para que o bloco iterador tenha algo a ser realmente atribuído ao IEnumerator.Current. Uma longa sequência de nulos não é exatamente útil, mas estamos mais interessados ​​nos efeitos colaterais. Não somos?

Há algo útil que podemos fazer com essa expressão, na verdade. E se, em vez de apenas produzir nulo e ignorá-lo, produzimos algo que indicava quando esperamos precisar fazer mais trabalho? Frequentemente, precisamos continuar direto no próximo quadro, com certeza, mas nem sempre: haverá muitas vezes em que queremos continuar após a animação ou som terminar de tocar, ou após um determinado período de tempo. Aqueles while (playingAnimation) produzem retorno nulo; construções são um pouco tediosas, você não acha?

O Unity declara o tipo base YieldInstruction e fornece alguns tipos derivados concretos que indicam tipos específicos de espera. Você tem WaitForSeconds, que retoma a corotina após o tempo designado. Você tem WaitForEndOfFrame, que retoma a corotina em um ponto específico posteriormente no mesmo quadro. Você tem o próprio tipo de corotina que, quando a corotina A produz a corotina B, interrompe a corotina A até que a corotina B termine.

Como isso se parece do ponto de vista do tempo de execução? Como eu disse, não trabalho para o Unity, então nunca vi o código deles; mas eu imagino que possa parecer um pouco com isso:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

Não é difícil imaginar como mais subtipos YieldInstruction poderiam ser adicionados para lidar com outros casos - o suporte de sinais no nível do mecanismo, por exemplo, poderia ser adicionado, com um WaitForSignal ("SignalName") YieldInstruction suportando-o. Ao adicionar mais YieldInstructions, as próprias corotinas podem se tornar mais expressivas - retornar retorno novo WaitForSignal ("GameOver") é mais agradável de ler do que isso (! Signals.HasFired ("GameOver")) retorna retorno nulo, se você me perguntar, bem diferente de o fato de fazê-lo no mecanismo pode ser mais rápido do que fazê-lo no script.

Algumas ramificações não óbvias Há algumas coisas úteis sobre tudo isso que às vezes as pessoas sentem falta que eu pensei que deveria apontar.

Em primeiro lugar, o retorno do rendimento está apenas produzindo uma expressão - qualquer expressão - e YieldInstruction é um tipo regular. Isso significa que você pode fazer coisas como:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

As linhas específicas produzem retorno novo WaitForSeconds (), produzem retorno novo WaitForEndOfFrame (), etc, são comuns, mas na verdade não são formulários especiais.

Em segundo lugar, como essas corotinas são apenas blocos de iteradores, você pode iterar sobre elas, se quiser - não precisa que o mecanismo faça isso por você. Eu usei isso para adicionar condições de interrupção a uma corotina antes:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

Em terceiro lugar, o fato de você poder ceder em outras corotinas pode permitir que você implemente suas próprias YieldInstructions, embora não com o mesmo desempenho que se fossem implementadas pelo mecanismo. Por exemplo:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

no entanto, eu realmente não recomendaria isso - o custo de iniciar uma Coroutine é um pouco pesado para o meu gosto.

Conclusão Espero que isso esclareça um pouco do que realmente está acontecendo quando você usa uma Coroutine no Unity. Os blocos iteradores do C # são uma construção pequena e divertida, e mesmo se você não estiver usando o Unity, talvez seja útil tirar proveito deles da mesma maneira.


2
Obrigado por reproduzir isso aqui. É excelente e me ajudou significativamente.
Naikrovek

96

O primeiro cabeçalho abaixo é uma resposta direta à pergunta. Os dois títulos a seguir são mais úteis para o programador diário.

Possivelmente aborrecidos detalhes de implementação de corotinas

As corotinas são explicadas na Wikipedia e em outros lugares. Aqui, fornecerei alguns detalhes do ponto de vista prático. IEnumerator, yieldetc. são recursos de linguagem C # usados ​​para um propósito diferente no Unity.

Para simplificar, IEnumeratoralega ter uma coleção de valores que você pode solicitar um por um, como um a List. No C #, uma função com uma assinatura para retornar um IEnumeratornão precisa realmente criar e retornar um, mas pode permitir que o C # forneça um implícito IEnumerator. A função pode fornecer o conteúdo retornado IEnumeratorno futuro de forma lenta, através de yield returninstruções. Toda vez que o chamador pede outro valor desse implícito IEnumerator, a função executa até a próxima yield returninstrução, que fornece o próximo valor. Como subproduto disso, a função faz uma pausa até que o próximo valor seja solicitado.

No Unity, não os usamos para fornecer valores futuros, exploramos o fato de que a função pausa. Devido a essa exploração, muitas coisas sobre as corotinas no Unity não fazem sentido (o que IEnumeratortem a ver com alguma coisa? O que é yield? Por que new WaitForSeconds(3)? Etc.). O que acontece "sob o capô" é que os valores que você fornece por meio do IEnumerator são usados StartCoroutine()para decidir quando solicitar o próximo valor, o que determina quando a sua rotina será interrompida novamente.

Seu jogo Unity é de thread único (*)

Corotinas não são threads. Há um loop principal do Unity e todas as funções que você escreve são chamadas pelo mesmo thread principal em ordem. Você pode verificar isso colocando um while(true);em qualquer uma de suas funções ou corotinas. Isso congelará tudo, até o editor do Unity. Isso é evidência de que tudo é executado em um thread principal. Esse link que Kay mencionou no comentário acima também é um ótimo recurso.

(*) O Unity chama suas funções a partir de um segmento. Portanto, a menos que você mesmo crie um thread, o código que você escreveu é único. É claro que o Unity emprega outros tópicos e você pode criar tópicos, se quiser.

Uma descrição prática das corotinas para programadores de jogos

Basicamente, quando você chama StartCoroutine(MyCoroutine()), é exatamente como uma chamada de função regular para MyCoroutine(), até que o primeiro yield return X, em que Xé algo como null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, etc. Isto é, quando ele começa a diferindo de uma função. O Unity "pausa" essa função exatamente nessa yield return Xlinha, continua com outros negócios e alguns quadros passam e, quando chega a hora, o Unity retoma essa função logo após essa linha. Ele lembra os valores para todas as variáveis ​​locais na função. Dessa forma, você pode ter um forloop que faz loop a cada dois segundos, por exemplo.

Quando o Unity retomará a sua rotina depende do que Xestava na sua yield return X. Por exemplo, se você usou yield return new WaitForSeconds(3);, ele será retomado após 3 segundos. Se você usou yield return StartCoroutine(AnotherCoroutine()), o currículo é retomado após a AnotherCoroutine()conclusão completa, o que permite aninhar comportamentos a tempo. Se você acabou de usar a yield return null;, ele será retomado no próximo quadro.


2
Que pena, o UnityGems parece estar desativado por um tempo agora. Algumas pessoas no Reddit conseguiram obter a última versão do arquivo: web.archive.org/web/20140702051454/http://unitygems.com/…
ForceMagic

3
Isso é muito vago e corre o risco de estar incorreto. Veja como o código realmente compila e por que funciona. Além disso, isso também não responde à pergunta. stackoverflow.com/questions/3438670/…
Louis Hong

Sim, acho que expliquei "como funcionam as corotinas no Unity" da perspectiva de um programador de jogos. O quastion real estava perguntando o que está acontecendo sob o capô. Se você puder apontar partes incorretas da minha resposta, ficarei feliz em corrigi-la.
Gazihan Alankus

4
Eu concordo com o retorno do rendimento falso, eu o adicionei porque alguém criticou minha resposta por não tê-la e eu estava com pressa de revisar se isso era útil, e simplesmente adicionei o link. Eu o removi agora. No entanto, acho que o Unity sendo único e como as corotinas se encaixam nisso não é óbvio para todos. Muitos programadores iniciantes do Unity com quem conversei têm um entendimento muito vago da coisa toda e se beneficiam dessa explicação. Editei minha resposta para fornecer uma resposta anterior à pergunta. Sugestões são bem-vindas.
Gazihan Alankus

2
A unidade não é um fwiw de thread único. Ele possui um thread principal no qual os métodos do ciclo de vida do MonoBehaviour são executados - mas também possui outros threads. Você é livre para criar seus próprios tópicos.
precisa saber é o seguinte

10

Não poderia ser mais simples:

A unidade (e todos os mecanismos de jogo) são baseados em quadros .

A questão toda, toda a razão de ser da Unidade, é que ela é baseada em estruturas. O mecanismo faz as coisas "cada quadro" para você. (Anima, renderiza objetos, faz física e assim por diante.)

Você pode perguntar .. "Oh, isso é ótimo. E se eu quiser que o mecanismo faça algo para mim em cada quadro? Como digo ao mecanismo para fazer isso em um quadro?"

A resposta é ...

É exatamente para isso que serve uma "corotina".

É simples assim.

E considere isso ....

Você conhece a função "Atualizar". Simplesmente, tudo o que você coloca lá é feito em todos os quadros . É literalmente exatamente o mesmo, sem nenhuma diferença, da sintaxe do rendimento da rotina.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

Não há absolutamente nenhuma diferença.

Nota de rodapé: como todos apontaram, o Unity simplesmente não tem tópicos . Os "quadros" no Unity ou em qualquer mecanismo de jogo não têm absolutamente nenhuma conexão com os threads de forma alguma.

As rotinas / rendimento são simplesmente como você acessa os quadros no Unity. É isso aí. (E, de fato, é absolutamente igual à função Update () fornecida pelo Unity.) Isso é tudo o que existe, é simples assim.


Obrigado! Mas sua resposta explica como usar corotinas - não como elas funcionam nos bastidores.
precisa saber é o seguinte

1
O prazer é meu, obrigado. Entendo o que você quer dizer - essa pode ser uma boa resposta para iniciantes que sempre perguntam quais são as corotinas. Felicidades!
Gordo

1
Na verdade - nenhuma das respostas, mesmo que ligeiramente, explica o que está acontecendo "nos bastidores". (Que é que ele é um IEnumerator que fica empilhado em um programador.)
Fattie

Você disse: "Não há absolutamente nenhuma diferença." Por que o Unity criou as Coroutines quando elas já têm uma implementação de trabalho exata como Update()? Quero dizer, deve haver pelo menos uma pequena diferença entre essas duas implementações e seus casos de uso, o que é bastante óbvio.
Leandro Gecozo

ei @LeandroGecozo - Eu diria mais, que "Update" é apenas uma espécie de simplificação ("boba") que eles adicionaram. (Muitas pessoas nunca usam, apenas usam corotinas!) Eu não acho que exista uma boa resposta para sua pergunta, é exatamente como o Unity é.
Fattie

5

Recentemente, investiguei isso, escreveu um post aqui - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - que esclarece os internos (com exemplos de código densos), a IEnumeratorinterface subjacente , e como é usado para corotinas.

Usar enumeradores de coleções para esse fim ainda me parece um pouco estranho. É o inverso do que os enumeradores se sentem projetados. O ponto dos enumeradores é o valor retornado em todos os acessos, mas o ponto das Coroutines é o código entre os retornos do valor. O valor retornado real é inútil neste contexto.


0

As funções básicas que você obtém automaticamente no Unity são as funções Start () e Update (); portanto, as Coroutine são essencialmente funções como as funções Start () e Update (). Qualquer função antiga func () pode ser chamada da mesma maneira que uma Coroutine pode ser chamada. A unidade obviamente estabeleceu certos limites para as corotinas que as tornam diferentes das funções regulares. Uma diferença é em vez de

  void func()

Você escreve

  IEnumerator func()

para corotinas. E da mesma maneira que você pode controlar o tempo em funções normais com linhas de código como

  Time.deltaTime

Uma corotina tem um controle específico sobre como o tempo pode ser controlado.

  yield return new WaitForSeconds();

Embora essa não seja a única coisa possível dentro de um IEnumerator / Coroutine, é uma das coisas úteis para as quais as Coroutines são usadas. Você precisaria pesquisar a API de script do Unity para aprender outros usos específicos das corotinas.


0

StartCoroutine é um método para chamar uma função IEnumerator. É semelhante a apenas chamar uma função nula simples, a diferença é que você a usa nas funções IEnumerator. Esse tipo de função é exclusivo, pois permite que você use uma função de rendimento especial ; observe que você deve retornar algo. Isso é tanto quanto eu sei. Aqui eu escrevi um jogo simples de flicker Sobre o método de texto em unidade

    public IEnumerator GameOver()
{
    while (true)
    {
        _gameOver.text = "GAME OVER";
        yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
        _gameOver.text = "";
        yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
    }
}

Eu então o chamei do próprio IEnumerator

    public void UpdateLives(int currentlives)
{
    if (currentlives < 1)
    {
        _gameOver.gameObject.SetActive(true);
        StartCoroutine(GameOver());
    }
}

Como você pode ver como eu usei o método StartCoroutine (). Espero ter ajudado de alguma forma. Eu também sou um iniciante; portanto, se você me corrigir ou me apreciar, qualquer tipo de feedback seria ótimo.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.