How exactly does the StartCoroutine/yield return pattern work in Unity?

Detailed explanation of Unity3D coroutine

Many processes in the game occur over the course of multiple frames. You have "intensive" processes, like pathfinding, that are worked hard every frame, but are broken up into multiple frames so as not to affect the frame rate too much. You have "sparse" processes, such as game triggers, that do nothing most frames but are occasionally called upon to do critical work. There are various processes in between.

Whenever you create a process that will proceed over multiple frames (no need for multithreading), you need to find some way to break the work into chunks that can run one per frame. This is fairly obvious for any algorithm with a central loop: for example, the A* pathfinder can be constructed so that it maintains its node list semi-permanently, processing only a handful of nodes from the open list each frame, rather than trying to do it all in one go All work. There's some balancing required to manage latency - after all, if you lock your framerate at 60 or 30 frames per second, your process will only execute 60 or 30 steps per second, which may cause the process to just take overall too long . A clean design might provide the smallest possible unit of work at one level - such as processing a single A* node - and layer on top a way to group the work into larger chunks - such as continuing to process A* nodeX milliseconds. (Some people call this "time slicing", but I don't think so).

Still, allowing work to be broken up in this way means you have to transfer state from one frame to the next. If you are decomposing an iterative algorithm, then you must retain all state shared between iterations, as well as a way to keep track of which iteration to perform next. This isn't usually too bad - the "A* Pathfinder class" design is pretty obvious - but there are other cases where it's less pleasant. Sometimes you'll be faced with long calculations that do different types of work from frame to frame; the object capturing its state may end up with a bunch of semi-useful "local variables" that are used to For passing data from one frame to the next. If you're dealing with sparse processes, you often end up having to implement a small state machine just to keep track of when the work should be completed.

Wouldn't it be neat if instead of having to explicitly track all of this state across multiple frames, or use multithreading and manage synchronization and locking, etc., you could just write the function as a single block of code? Mark specific locations where a function should "pause" and resume later?

Unity, as well as many other environments and languages, provide this in the form of coroutines.

How do they look? In "Unityscript" (Javascript):

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

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

}
in C#:

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

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

}
How do they work? Suffice it to say, I don't work for Unity Technologies. I haven't seen the Unity source code yet. I've never seen the internals of Unity's coroutine engine. But I'd be very surprised if they implemented it in a completely different way than what I'm about to describe. If anyone from UT wants to chime in and talk about how this actually works, that would be great.

The big clue is in the C# version. First, note that the return type of this function is IEnumerator. Second, notice that one of the statements is yield return. This means that yield must be a keyword, and since Unity's C# support is vanilla C# 3.5, it must be a vanilla C# 3.5 keyword. In fact, it's in MSDN - talks about something called an "iterator block". what happens?

First, there is the IEnumerator type. The IEnumerator type acts like a cursor on a sequence, providing two important members: Current, which is a property that gives you the element the cursor is currently on, and MoveNext(), a function that moves to the next element in the sequence. Because IEnumerator is an interface, it does not specify how these members are implemented; MoveNext() can just add one to Current, or it can load a new value from a file, or it can download an image from the Internet and hash it, The new hash is then stored in Current...or it could even do one thing on the first element of the sequence, and something completely different on the second element. You can even use it to generate infinite sequences if you want. MoveNext() calculates the next value in the sequence (returns false if there are no more values), and Current retrieves the value it calculated.

Normally, if you want to implement an interface, you have to write a class, implement the members, and so on. Iterator blocks are a convenient way to implement an IEnumerator without all the hassle - you just follow a few rules and the IEnumerator implementation will be automatically generated by the compiler.

An iterator block is a regular function that (a) returns an IEnumerator, and (b) uses the yield keyword. So what does the yield keyword actually do? It declares what the next value in the sequence is - or that there are no more values. The point at which the code encounters a yield return X or a yield break is the point where IEnumerator.MoveNext() should stop; a yield return .

Now, here's the trick. It doesn't matter what the actual value returned by the sequence is. You can call MoveNext() repeatedly and ignore Current; the calculation will still be performed. Each time MoveNext() is called, the iterator block runs to the next "yield" statement, regardless of what expression it actually yields. So you can write something like this:

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;
}
What you are actually writing is an iterator block that generates a long list of null values, but what matters is the side effect of its work of counting null values. You can run this coroutine using a simple loop like this:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Or, more usefully, you can It's mixed in with other work:

IEnumerator e = TellMeASecret();
while(e.MoveNext())
{ // If they press 'Escape', skip the cutscene if(Input.GetKeyDown(KeyCode.Escape)) { break; }< a i=5> } As you can see, each yield return statement must provide an expression (such as null) so that the iterator block has something to actually assign to the IEnumerator. Current. A long list of null values ​​isn't exactly useful, but we're more interested in the side effects. Aren't we?



Actually, we can do some convenient things with this expression. What if instead of just producing null and ignoring it, we produced something that indicates when we need to do more work? Of course, we usually need to continue directly to the next frame, but not always: many times we want to continue after the animation or sound has finished playing, or after a certain time has passed. Those while(playingAnimation) returns null; the construction is a bit tedious, don't you think?

Unity declares the YieldInstruction base type and provides some concrete derived types to indicate specific types of waits. You have WaitForSeconds which resumes the coroutine after a specified time. You have WaitForEndOfFrame which resumes the coroutine at a specific point later in the same frame. You've got the coroutine type itself, when coroutine A spawns coroutine B, it pauses coroutine A until coroutine B completes.

What does this look like from a runtime perspective? As I said, I don't work for Unity, so I've never seen their code; but I imagine it might look a little like this:

List unblockedCoroutines;
List shouldRunNextFrame;
List 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;
It is not difficult to imagine how to add more YieldInstruction subtypes to handle other cases - for example, one could add engine-level support for signals and use WaitForSignal(" SignalName")YieldInstruction supports it. By adding more YieldInstructions, the coroutine itself can become more expressive - if you ask me, yield return new WaitForSignal("GameOver") is easier to read than while(!Signals.HasFired("GameOver")) in fact , doing this in the engine may be faster than doing it in a script.

Some Unobvious Consequences There are some useful things about all this that people sometimes overlook that I thought I should point out.

First, yield return just yields an expression—any expression—while YieldInstruction is a regular type. This means you can do the following:

YieldInstruction y;

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

yield return y;
The specific lines yield return new WaitForSeconds(), yield return new WaitForEndOfFrame(), etc. are common, but they are not special forms in themselves.

Second, because these coroutines are just iterator blocks, you can iterate over them yourself if you want - you don't have to let the engine do it for you. I've used this before to add break conditions to coroutines:

IEnumerator DoSomething()
{
/* … */
}

IEnumerator DoSomethingUnlessInterrupted()
{ IEnumerator e = DoSomething(); bool interrupted = false; while(!interrupted) { e.MoveNext(); yield return e.Current; interrupted = HasBeenInterrupted();< a i=9> } } Third, the fact that you can yield on other coroutines allows you to implement your own YieldInstructions, although the performance is not as good as The engine implements it. For example:









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

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

IEnumerator SomeTask()
{ /* … / yield return UntilTrue(() => _lives < 3); / … */ } However, I really don’t recommend this - start a The cost of coroutines is a bit heavy for me.




Conclusion I hope this clarified some of what actually happens when you use coroutines in Unity. C#'s iterator blocks are a nifty little construct, and even if you don't use Unity, maybe you'll find it useful to utilize them in the same way.

Guess you like

Origin blog.csdn.net/qq_42980269/article/details/134013779