W jaki sposób wzór zwrotu StartCoroutine / yield naprawdę działa w jedności?

Rozumiem zasadę koroutines. Wiem jak zdobyć standard StartCoroutine / yield return pattern to work in C# in Unity, np. invoke a method returning IEnumerator via StartCoroutine and in that method do something, do yield return new WaitForSeconds(1); to wait a second, then do something else.

Moje pytanie brzmi: co tak naprawdę dzieje się za kulisami? Co tak naprawdę robi StartCoroutine? Co IEnumerator to WaitForSeconds powrót? W jaki sposób StartCoroutine zwraca kontrolę do części" coś innego " wywołanej metody? Jak to wszystko oddziałuje z modelem współbieżności Unity(gdzie wiele rzeczy dzieje się w tym samym czasie bez użycia corutyn)?
Author: Ghopper21, 2012-10-17

4 answers

Oft odwołałUnity3D koroutines w szczegółach link jest martwy. Ponieważ jest to wspomniane w komentarzach i odpowiedziach, zamierzam opublikować treść artykułu tutaj. Ta zawartość pochodzi z tego lustra .


Unity3D coroutines in detail

Wiele procesów w grach odbywa się w trakcie wielu klatek. Masz "gęste" procesy, takie jak wyszukiwanie ścieżek, które ciężko pracują nad każdą klatką, ale są podzielone na wiele klatek, tak aby aby nie wpływać zbyt mocno na framerate. Masz "rzadkie" procesy, takie jak wyzwalacze rozgrywki, które nie robią nic w większości klatek, ale od czasu do czasu są wzywani do pracy krytycznej. I masz różne procesy między nimi.

Gdy tworzysz proces, który będzie się odbywał na wielu klatkach-bez wielowątkowości - musisz znaleźć sposób na rozbicie pracy na kawałki, które można uruchomić po jednej na klatkę. Dla każdego algorytmu z pętlą centralną jest to dość oczywiste: na przykład wyszukiwarka ścieżek A* może być skonstruowana w taki sposób, że utrzymuje swoje listy węzłów w połowie na stałe, przetwarzając tylko kilka węzłów z otwartej listy każdej klatki, zamiast próbować wykonać całą pracę za jednym zamachem. W końcu, jeśli blokujesz liczbę klatek na sekundę na poziomie 60 lub 30 klatek na sekundę, twój proces zajmie tylko 60 lub 30 kroków na sekundę, a to może spowodować, że proces będzie trwał zbyt długo. Schludny design może oferować najmniejszą możliwą jednostkę pracy na jednym poziomie – np. przetwarzać pojedynczy węzeł A* – i warstwę na górze sposób grupowania pracy razem w większe kawałki – np. przetwarzać węzły a* przez milisekundy X. (Niektórzy nazywają to "timeslicing", choć ja nie).

Mimo to, pozwolenie na podział pracy w ten sposób oznacza, że musisz przenieść stan z jednej klatki do następnej. Jeśli łamiesz algorytm iteracyjny, musisz zachować cały wspólny stan przez iteracje, a także sposób śledzenia, która iteracja ma być wykonana dalej. Zazwyczaj nie jest tak źle – konstrukcja klasy "A * pathfinder" jest dość oczywista – ale są też inne przypadki, które są mniej przyjemne. Czasami będziesz musiał zmierzyć się z długimi obliczeniami, które wykonują różne rodzaje pracy od klatki do klatki; obiekt przechwytujący ich stan może skończyć się wielkim bałaganem półprzydatnych "mieszkańców", przechowywanych do przesyłania danych z jednej klatki do drugiej. A jeśli handlujesz w przypadku rzadkiego procesu często trzeba zaimplementować małą maszynę stanową, aby śledzić, kiedy w ogóle należy wykonać pracę.

Czy nie byłoby fajnie, gdyby zamiast jawnie śledzić cały ten stan w wielu klatkach, zamiast wielowątkowego odczytu i zarządzania synchronizacją i blokowaniem itd., można było po prostu napisać swoją funkcję jako pojedynczy fragment kodu i zaznaczyć konkretne miejsca, w których funkcja powinna "wstrzymać" i kontynuować w późniejszym czasie?

Unity – wraz z wieloma innymi środowiskami i językami-zapewnia to w formie Koroutinów. Jak wyglądają? W "Unityscript" (Javascript):
function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

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

W C#:

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

        // Pause here and carry on next frame
        yield return null;
    }
}
Jak one działają? Powiem szybko, że nie pracuję dla Unity Technologies. Nie widziałem kodu źródłowego Unity. Nigdy nie widziałem wnętrzności silnika coroutine Unity. Jeśli jednak wdrożyli go w sposób radykalnie inaczej niż to, co zaraz opiszę, będę zaskoczony. Jeśli ktoś z UT chce porozmawiać o tym, jak to działa, to byłoby świetnie.

Duże wskazówki są w wersji C#. Po pierwsze, zauważ, że typem zwracanym dla funkcji jest IEnumerator. A po drugie, zauważ, że jedno ze stwierdzeń to yield / align = "left" / Oznacza to, że yield musi być słowem kluczowym, a ponieważ Obsługa języka C # Unity to vanilla C # 3.5, musi to być vanilla C# 3.5. Rzeczywiście, tutaj jest w MSDN - mówi o czymś, co nazywa się 'blokami iteratora./ Co się dzieje?

Po pierwsze, jest taki rodzaj Ienumeratora. Typ IEnumerator działa jak kursor nad sekwencją, zapewniając dwa znaczące elementy: Current, który jest właściwością dającą element, nad którym obecnie znajduje się kursor, oraz MoveNext (), funkcję, która przenosi się do następnego elementu w sekwencji. Ponieważ IEnumerator jest interfejsem, nie określa dokładnie, w jaki sposób te elementy są implementowane; MoveNext () może po prostu dodać jedną toCurrent, lub może załadować nową wartość z pliku, lub może pobrać obraz z Internetu i hash go i zapisać nowy hash w Current ... lub może nawet zrobić jedną rzecz dla pierwszego elementu w sekwencji, a coś zupełnie innego dla drugiego. Możesz nawet użyć go do wygenerowania nieskończonej sekwencji, jeśli sobie tego życzysz. MoveNext () oblicza następną wartość w sekwencji (zwraca false, jeśli nie ma więcej wartości), a Current pobiera wartość obliczona.

Zazwyczaj, jeśli chcesz zaimplementować interfejs, musisz napisać klasę, zaimplementować członków i tak dalej. Bloki iteratora są wygodnym sposobem implementacji Ienumeratora bez żadnych kłopotów – wystarczy przestrzegać kilku zasad, a implementacja Ienumeratora jest generowana automatycznie przez kompilator.

Blok iteratora jest regularną funkcją, która (a) zwraca IEnumerator i (b) używa słowa kluczowego yield. Więc co daje słowo kluczowe rzeczywiście zrobić? Deklaruje, jaka jest następna wartość w sekwencji – lub że nie ma więcej wartości. Punkt, w którym kod napotyka wydajność return X lub yield break jest punktem, w którym IEnumerator.MoveNext () powinna zostać zatrzymana; zwraca yield x powoduje, że MoveNext () zwraca true i przypisuje wartość X, podczas gdy yield przerwa powoduje, że MoveNext() zwraca false.

Oto sztuczka. Nie musi mieć znaczenia, jakie rzeczywiste wartości zwracane przez sekwencja jest. MoveNext() można wywoływać wielokrotnie i ignorować funkcję Current; obliczenia nadal będą wykonywane. Za każdym razem, gdy wywołana jest metoda MoveNext (), blok iteratora przechodzi do następnej instrukcji 'yield', niezależnie od tego, jakie wyrażenie faktycznie daje. Więc możesz napisać coś w stylu:
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;
}

I to, co napisałeś, to blok iteratora, który generuje długą sekwencję wartości null, ale ważne są efekty uboczne pracy, którą wykonuje, aby je obliczyć. Coroutine można uruchomić za pomocą prostej pętli w ten sposób:

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

Lub, bardziej użyteczne, można go mieszać z innymi pracami:

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

It ' s all in the timing Jak już zauważyłeś, każda instrukcja yield return musi zawierać wyrażenie (np. null), tak aby blok iteratora miał coś do przypisania do Ienumeratora.Aktualne. Długa sekwencja Nulli nie jest do końca przydatna, ale bardziej interesują nas skutki uboczne. Prawda?

Jest coś przydatnego, co możemy zrobić z tym wyrażeniem. A co jeśli zamiast po prostu dawać null i ignorując to, otrzymaliśmy coś, co wskazywało, kiedy oczekujemy, że będziemy musieli wykonać więcej pracy? Często będziemy musieli przejść bezpośrednio do następnej klatki, oczywiście, ale nie zawsze: będzie wiele razy, kiedy będziemy chcieli kontynuować po zakończeniu odtwarzania animacji lub dźwięku lub po upływie określonego czasu. Those while (playingAnimation) yield return null; konstrukty są trochę nudne, nie sądzisz?

Unity deklaruje typ bazowy yieldinstruction i podaje kilka konkretnych typów pochodnych, które wskazują konkretne rodzaje oczekiwania. Masz czas oczekiwania, który wznawia koronę po upływie wyznaczonego czasu. Masz WaitForEndOfFrame, który wznawia coroutine w określonym punkcie później w tej samej ramce. Masz sam typ Coroutine, który, gdy coroutine A daje coroutine B, zatrzymuje coroutine a, aż po zakończeniu coroutine B.

Jak to wygląda z punktu widzenia runtime? Jak już mówiłem, nie pracuję dla Unity, więc nigdy nie widziałem ich kodu, ale wyobrażam sobie, że może wyglądać trochę tak: {]}

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;

Nietrudno sobie wyobrazić, jak więcej podtypów YieldInstruction można dodać do obsługi innych przypadków – na przykład wsparcie dla sygnałów na poziomie silnika, z obsługą WaitForSignal("SignalName")yieldinstruction. Przez dodając więcej YieldInstructions, same koroutines mogą stać się bardziej wyraziste-plon return new WaitForSignal ("GameOver") jest ładniejszy do czytania niżwhile (!Sygnały.HasFired ("GameOver")) yield return null, jeśli O mnie chodzi, pomijając fakt, że robienie tego w silniku może być szybsze niż robienie tego w skrypcie.

Kilka nieoczywistych konsekwencji Jest w tym wszystkim kilka przydatnych rzeczy, których ludzie czasem nie dostrzegają, na które powinienem zwrócić uwagę.

Po pierwsze, yieldinstruction jest tylko wyrażeniem-dowolnym wyrażeniem-a yieldinstruction jest typem regularnym. Oznacza to, że możesz robić takie rzeczy jak:

YieldInstruction y;

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

yield return y;

Poszczególne linie yield zwracają new WaitForSeconds (), yield return new WaitForEndOfFrame (), itd., są powszechne, ale w rzeczywistości nie są specjalnymi formami.

Po drugie, ponieważ te coroutines są tylko blokami iteratora, możesz je samodzielnie iterować, jeśli chcesz – nie silnik musi to zrobić za Ciebie. Używałem tego do dodawania warunków przerwania do koroutine wcześniej:

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

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

Po Trzecie, fakt, że można ustąpić innym koroutinom, może w pewnym sensie pozwolić na wdrożenie własnych struktur YieldInstructions, choć nie tak wydajnie, jak gdyby zostały zaimplementowane przez silnik. Na przykład:

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

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

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

Jednak nie polecałbym tego – koszt uruchomienia Koroutine jest trochę ciężki jak na moje upodobania.

Wniosek Mam nadzieję, że to wyjaśni trochę, co naprawdę się dzieje, gdy używasz Koroutine w jedności. Bloki iteratora w C#są groovy Little construct, i nawet jeśli nie używasz Unity, może okaże się przydatne, aby wykorzystać je w ten sam sposób.

 76
Author: James McMahon,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-05-05 04:04:09

Pierwszy nagłówek poniżej jest prostą odpowiedzią na pytanie. Dwa nagłówki po są bardziej przydatne dla codziennego programisty.

Prawdopodobnie nudne szczegóły implementacji Koroutines

Koroutiny są wyjaśnione w Wikipedii i gdzie indziej. Tutaj podam tylko kilka szczegółów z praktycznego punktu widzenia. IEnumerator, yield, itd. są cechy języka C # , które są używane w nieco innym celu w Unity.

Mówiąc bardzo po prostu IEnumerator twierdzi, że posiada zbiór wartości, o które można poprosić jeden po drugim, coś w rodzaju List. W C# Funkcja z podpisem zwracającym IEnumerator nie musi faktycznie tworzyć i zwracać, ale może pozwolić C# dostarczyć implicit IEnumerator. Funkcja może wtedy dostarczyć zawartość zwracanego IEnumerator w przyszłości w sposób leniwy, za pomocą yield return poleceń. Za każdym razem, gdy wywołujący pyta o inną wartość z tego implicit IEnumerator, funkcja wykonuje do następnego yield return polecenie, które dostarcza następną wartość. Jako produkt uboczny tego, funkcja zatrzymuje się, aż do zażądania następnej wartości.

W Unity, nie używamy ich do dostarczania przyszłych wartości, wykorzystujemy fakt, że funkcja pauzuje. Z powodu tego wyzysku wiele rzeczy o koroutinach w Unity nie ma sensu (Co IEnumerator mA z czymkolwiek wspólnego? Co to jest yield? Dlaczego new WaitForSeconds(3)? itd.). Co dzieje się "pod maską" jest to, że wartości podane przez IEnumerator są wykorzystywane przez StartCoroutine() aby zdecydować, kiedy poprosić o następną wartość, która określa, kiedy twój coroutine ponownie się wycofa.

Twoja gra jest Jednowątkowa (*)

Koroutiny to, a nie wątki. Istnieje jedna główna pętla jedności i wszystkie te funkcje, które piszesz, są wywoływane przez ten sam główny wątek w kolejności. Możesz to sprawdzić, umieszczając while(true); W dowolnej funkcji lub koroutinach. To zamrozi całą sprawę, nawet edytor jedności. Jest to dowód na to, że wszystko działa w jednym głównym wątku. ten link , o którym Kay wspomniał w swoim powyższym komentarzu, jest również świetnym źródłem informacji.

(*) Unity wywołuje funkcje z jednego wątku. Tak więc, jeśli nie tworzysz wątku samodzielnie, kod, który napisałeś, jest jednowątkowy. Oczywiście Unity wykorzystuje inne wątki i możesz je tworzyć samodzielnie, jeśli chcesz.

[[33]}praktyczny opis Coroutines dla programistów gier

W zasadzie, kiedy dzwonisz StartCoroutine(MyCoroutine()), to jest dokładnie tak, jak regularne wywołanie funkcji MyCoroutine(), aż do pierwszego yield return X, gdzie X jest czymś w rodzaju null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, itd. Wtedy zaczyna się ona różnić od funkcji. Unity "zatrzymuje" tę funkcję tuż przy tej linii yield return X, kontynuuje inne sprawy i niektóre klatki przechodzą, a kiedy nadejdzie czas, Unity wznowi tę funkcję zaraz po tej linii. Zapamiętuje wartości dla wszystkich zmiennych lokalnych w funkcji. W ten sposób możesz mieć pętlę for, która zapętla się co dwie sekundy, na przykład.

Kiedy jedność wznowi Twój koroutine zależy od tego, co X było w Twoim yield return X. Na przykład, jeśli użyłeś yield return new WaitForSeconds(3);, zostanie ono wznowione po upływie 3 sekund. Jeśli użyłeś yield return StartCoroutine(AnotherCoroutine()), wznawia się po zakończeniu AnotherCoroutine(), co umożliwia zagnieżdżenie zachowań w czasie. Jeśli użyłeś yield return null;, wznawia się w następnej klatce.

 85
Author: Gazi Alankus,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-09-21 20:49:28

To nie może być prostsze:

[[3]}Unity (i wszystkie silniki gry) są oparte na ramkach . Cały punkt, Cała raison d ' etre jedności, polega na tym, że jest oparta na ramie. silnik robi rzeczy "każda klatka" dla Ciebie. (animuje, renderuje obiekty, wykonuje fizykę i tak dalej.) Możesz zapytać .. "To świetnie. Co jeśli chcę, aby silnik robił coś dla mnie w każdej klatce? Jak kazać silnikowi robić takie A takie w ramie?"

Odpowiedź brzmi ...

Właśnie po to jest "coroutine". To takie proste. I rozważ to....

Znasz funkcję "Update". Po prostu wszystko, co tam włożysz, jest zrobione w każdej klatce . Jest dosłownie dokładnie taki sam, bez żadnej różnicy, od składni coroutine-yield.

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;
 }
Nie ma żadnej różnicy.

Przypis: jak wszyscy zauważyli, jedność po prostu nie posiada wątków. "Ramki" w Unity lub w jakimkolwiek silniku gry nie mają żadnego związku z wątkami w żaden sposób.

Coroutines / yield to po prostu sposób dostępu do ramek w Unity. To wszystko. (I rzeczywiście, jest to absolutnie to samo, co funkcja Update() dostarczana przez Unity. To wszystko, to takie proste.

 6
Author: Fattie,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-02-08 23:13:53

Grzebałem w tym ostatnio, napisałem post tutaj - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d / - które rzucają światło na wewnętrzne (z gęstymi przykładami kodu), podstawowe IEnumerator interfejs, a także sposób jego wykorzystania w koroutinach.

Używanie do tego celu enumeratorów kolekcji nadal wydaje mi się trochę dziwne. Jest odwrotnością tego, do czego enumeratory czują się zaprojektowane. Punktem enumeratorów jest zwracana wartość przy każdym dostępie, ale punktem Coroutines jest kod znajdujący się pomiędzy zwracanymi wartościami. W tym kontekście zwracana wartość jest bezcelowa.

 4
Author: Geri,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2015-09-04 13:43:53