Talk about the implementation principle behind the Unity coroutine

Talk about the implementation principle behind the Unity coroutine

Unity development inevitably needs to use coroutines. The feature of coroutine synchronous code for asynchronous tasks allows programmers to get rid of the previous coding method of asynchronous operations and callbacks, making the code logic more coherent and easy to read. However, while being surprised at the usefulness and magic of the coroutine, because I don't know the implementation principle behind the coroutine, I always feel that I cannot fully grasp the coroutine. such as:

  1. MonoBehaviour.StartCoroutine
    Why is the received parameter
    IEnumerator
    ,
    IEnumerator
    What does it have to do with coroutine?
  2. Since the return value declaration of the coroutine function is
    IEnumerator
    , Why in the function
    yield return
    Are there different types of return values?
  3. yield
    What is common
    yield return
    ,
    yield break
    What does it mean and what's the difference?
  4. Why used
    yield return
    Can the code "stop" there, and after a certain condition is reached, it can continue to execute from the "stopped" place?
  5. specific,
    yield return new WaitForSeconds(3)
    ,
    yield return webRequest.SendWebRequest()
    , Why can wait for a specified time or wait for the request to complete and then execute the following code?

If you have the same questions as me, you may wish to read this article, I believe it will definitely answer your doubts.

What is IEnumerator

According to the description of the official Microsoft documentation , IEnumerator is the base interface of all non-generic enumerators. In other words, IEnumerator defines an iteration method suitable for arbitrary collections. As long as any collection implements its own IEnumerator, its users can iterate the elements in the collection through IEnumerator instead of using different iterative methods for different collections.

The definition of IEnumerator is as follows

public interface IEnumerator { object Current { get ;} bool MoveNext () ; void Reset () ; } Copy code

The IEnumerator interface consists of one attribute and two methods

  1. The Current property can get the element at the current iteration position in the collection
  2. The MoveNext method advances the current iteration position to the next position, and returns true if it successfully advances to the next position, otherwise returns false if it has advanced to the end of the collection
  3. The Reset method can set the current iteration position to the initial position (the position is before the first element in the collection, so when the Reset method is called, the MoveNext method is called, and the Curren value is the first element of the collection)

For example, we often use the foreach keyword to traverse the collection. In fact,

foreach
It's just syntactic sugar provided by C#

foreach ( var item in collection) { Console.WriteLine(item.ToString()); } Copy code

Essentially

foreach
The loop also uses IEnumerator to traverse the collection. At compile time, the compiler will
foreach
The loop is converted to code similar to the following

{ var enumerator = collection.GetEnumerator(); try { while (enumerator.MoveNext()) //Determine whether to successfully advance to the next element (can be understood as whether there are any elements available for iteration in the collection) { var item = enumerator.Current; Console.WriteLine(item.ToString()); } } finally { //dispose of enumerator. } } Copy code

What is the relationship between yield and IEnumerator

Yield is a keyword of C#, which is actually syntactic sugar for quickly defining iterators. only if

yield
The method appearing in it will be automatically compiled into an iterator by the compiler, and such a function can be called an iterator function. The return value of the iterator function is an object of the automatically generated iterator class

Try to imagine if not

yield
Keyword, every time we define an iterator, we must create a class to achieve
IEnumerator
Interface, the attributes and methods contained in the interface must be implemented correctly, is it troublesome? And use
yield
Keyword, just a few simple lines of code below, you can quickly define an iterator. Such as the creation of iterator classes,
IEnumerator
The implementation of the interface is done for you by the compiler

//The iterator defined by the iterator function IEnumerator Test () { yield return 1 ; Debug.Log( "Surprise" ); yield return 3 ; yield break ; yield return 4 ; } Copy code
  1. yield return
    The statement can return a value that represents the current element obtained by the iteration
  2. yield break
    The statement can be used to terminate the iteration, indicating that there are no elements that can be iterated

As shown below, the elements can be traversed through the iterator defined in the above code

IEnumerator enumerator = Test(); //Directly calling the iterator function will not execute the body of the method, but return the iterator object bool ret = enumerator.MoveNext(); Debug.Log(ret + "" + enumerator.Current); //(1) Print: True 1 ret = enumerator.MoveNext(); //(2) Print: Surprise Debug.Log(ret + "" + enumerator.Current); //(3) Print: True 3 ret = enumerator.MoveNext(); Debug.Log(ret + "" + enumerator.Current); //(4) Print: False 3 Copy the code

(1)(3)(4) There is no problem in printing, (1)(3) correctly prints the returned value, (4) is because the iteration is

yield break
Terminated, so
MoveNext
Returned false

Pay attention to (2) the position of printing, which is the second call

MoveNext
Triggered after the function, that is, if you don t call the second
MoveNext
, (2) Printing will not be triggered, which also means
Debug.Log("Surprise")
This code will not be executed. Performance-wise
yield return 1
It seems to "stop" the code, when it is called again
MoveNext
After the method, the code continues to execute from where it "stopped"

Why can yield return "stop" the code

If you want to figure out the principle of code "stopping" and restoring in situ, you must go to IL to find the answer. However, the IL generated by compilation is an intermediate language similar to assembly language, which is relatively low-level and obscure. So I used Unity's IL2CPP, which converts the IL generated by C# compilation into C++ language. The curve can be researched through the realization of C++ code

yield return
The realization principle

For example, the following C# class, in order to facilitate the location of the variable in the function, so the variable name is more complicated

public class Test { public IEnumerator GetSingleDigitNumbers () { int m_tag_index = 0; int m_tag_value = 0; while (m_tag_index < 10) { m_tag_value += 456; yield return m_tag_index++; } } }

Test.cpp

//Test/<GetSingleDigitNumbers>d__0 struct U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A : public RuntimeObject { public: //System.Int32 Test/<GetSingleDigitNumbers>d__0::<>1__state int32_t ___U3CU3E1__state_0; //System.Object Test/<GetSingleDigitNumbers>d__0::<>2__current RuntimeObject * ___U3CU3E2__current_1; //Test Test/<GetSingleDigitNumbers>d__0::<>4__this Test_tD0155F04059CC04891C1AAC25562964CCC2712E3 * ___U3CU3E4__this_2; //System.Int32 Test/<GetSingleDigitNumbers>d__0::<m_tag_index>5__1 int32_t ___U3Cm_tag_indexU3E5__1_3; //System.Int32 Test/<GetSingleDigitNumbers>d__0::<m_tag_value>5__2 int32_t ___U3Cm_tag_valueU3E5__2_4; public: inline int32_t get_U3CU3E1__state_0() const { return ___U3CU3E1__state_0; } inline void set_U3CU3E1__state_0(int32_t value) { ___U3CU3E1__state_0 = value; } inline RuntimeObject * get_U3CU3E2__current_1() const {return ___U3CU3E2__current_1;} inline void set_U3CU3E2__current_1(RuntimeObject * value) { ___U3CU3E2__current_1 = value; Il2CppCodeGenWriteBarrier((void**)(&___U3CU3E2__current_1), (void*)value); } inline int32_t get_U3Cm_tag_indexU3E5__1_3() const {return ___U3Cm_tag_indexU3E5__1_3;} inline void set_U3Cm_tag_indexU3E5__1_3(int32_t value) { ___U3Cm_tag_indexU3E5__1_3 = value; } inline int32_t get_U3Cm_tag_valueU3E5__2_4() const {return ___U3Cm_tag_valueU3E5__2_4;} inline void set_U3Cm_tag_valueU3E5__2_4(int32_t value) { ___U3Cm_tag_valueU3E5__2_4 = value; } }; Copy code

can be seen

GetSingleDigitNumbers
The function is indeed defined as a class
U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A
, And local variables
m_tag_index
with
m_tag_value
Are respectively defined as member variables of this class
___U3Cm_tag_indexU3E5__1_3
with
___U3Cm_tag_valueU3E5__2_4
, And generated corresponding get and set methods for them.
___U3CU3E2__current_1
Member variable correspondence
IEnumerator
of
Current
Attributes. Pay attention to the extra generated
___U3CU3E1__state_0
A member variable can be understood as a state machine. Through the different state values it represents, it determines how the entire function logic should be executed. We will see how it works later.

The Test System.Boolean The///<GetSingleDigitNumbers> :: d__0 the MoveNext () IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR BOOL U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB (U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A __this *, const RuntimeMethod Method *) { static BOOL s_Il2CppMethodInitialized; IF (s_Il2CppMethodInitialized!) { il2cpp_codegen_initialize_method (U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB_MetadataUsageId); s_Il2CppMethodInitialized = true ; } int32_t V_0 = 0 ; int32_t V_1 = 0 ; bool V_2 = false ; { int32_t L_0 = __this-> get_U3CU3E1__state_0 (); V_0 = L_0; int32_t L_1 = V_0; if (!L_1) { goto IL_0012; } } { goto IL_000c; } IL_000c: { int32_t L_2 = V_0; if (((( int32_t )L_2) == (( int32_t ) 1 ))) { goto IL_0014; } } { goto IL_0016; } IL_0012: { goto IL_0018; } IL_0014: { goto IL_0068; } IL_0016: { return ( bool ) 0 ; } IL_0018: { __this-> set_U3CU3E1__state_0 (( -1 )); //int m_tag_index = 0; __this-> set_U3Cm_tag_indexU3E5__1_3 ( 0 ); //int m_tag_value = 0; __this-> set_U3Cm_tag_valueU3E5__2_4 ( 0 ); GOTO IL_0070; } IL_0030: { //m_tag_value += 456; int32_t L_3 = __this-> get_U3Cm_tag_valueU3E5__2_4 (); __this-> set_U3Cm_tag_valueU3E5__2_4 ((( int32_t ) il2cpp_codegen_add (( int32_t ) L_3, ( int32_t ) (( int32_t ) 456 )))); //return the yield m_tag_index ++; int32_t L_4 are __this- => get_U3Cm_tag_indexU3E5__1_3 (); V_1 = L_4; int32_t L_5 = V_1; __this-> set_U3Cm_tag_indexU3E5__1_3 ((( int32_t ) il2cpp_codegen_add (( int32_t ) L_5, ( int32_t ) . 1 ))); int32_t L_6 = V_1; int32_t L_7 = L_6; RuntimeObject * L_8 = Box (Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var, &L_7); __this-> set_U3CU3E2__current_1 ( L_8 ); __this-> set_U3CU3E1__state_0 ( 1 ); return ( bool ) 1 ; } IL_0068: { __this-> set_U3CU3E1__state_0 (( -1 )); } IL_0070: { //while (m_tag_index <10) int32_t L_9 = __this-> get_U3Cm_tag_indexU3E5__1_3 (); V_2 = ( bool )(((( int32_t )L_9) <(( int32_t )(( int32_t ) 10 )))? 1 : 0 ); bool L_10 = V_2; if (L_10) { goto IL_0030; } } { //) return ( bool ) 0 ; } } Copy code

and

U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB
Member method corresponds
IEnumerator
of
MoveText
method. Its implementation uses the goto statement, and this method is the key to code "stop" and recovery

Let's look at it step by step, according to the logic of c# code, the first call

moveNext
Function, the following code should be executed

int m_tag_index = 0 ; int m_tag_value = 0 ; if (m_tag_index < 10 ) { m_tag_value += 456 ; return m_tag_index++; } Copy code

The corresponding executed c++ code is shown below. After IL_0030 is executed, it will return true, indicating that there are more elements. The state at this time is 1

//Initially, __U3CU3E1__state_0 value is 0 goto IL_0012; goto IL_0018; //IL_0018 internally initializes m_tag_index and m_tag_value to 0. At the same time, set __U3CU3E1__state_0 value to -1 goto IL_0070; //Determine whether m_tag_index is less than 10 goto IL_0030; //internally m_tag_index value plus 1, and sets the value of current m_tag_index value, and the value is set to 1 ___U3CU3E1__state_0 duplicated code

2.call

moveNext
Function, the corresponding C# code is

if (m_tag_index < 10 ) { m_tag_value += 456 ; return m_tag_index++; } Copy code

The corresponding c++ code is

//At this time the value of ___U3CU3E1__state_0 is 1, and enter IL_000c according to the judgment. goto IL_000c; goto IL_0014; goto IL_0068; //Set __U3CU3E1__state_0 to -1 IL_0070 //Determine whether m_tag_index is less than 10 goto IL_0030; //Return 1, which means true, there can be iterative elements copy the code

When the 11th call

moveNext
Function,
m_tag_index
The value of is already 10, at this time the function should end. The return value should be false, indicating that there are no more elements that can be returned. So the corresponding C++ code is

//___U3CU3E1__state_0 value is 1 goto IL_000c; goto IL_0014; goto IL_0068 IL_0070 //Determine that m_tag_index is not less than 10, so it will not enter IL_0030 { //} return ( bool ) 0 ; } Copy code

At this point, I think the mystery of code "stopping" and recovery has finally been unveiled. In summary, taking the place where you can "stop" as the dividing line, the compiler will generate corresponding code blocks for different partitioned statements according to functional logic.

yield
The statement is this dividing line. If you want the code to "stop", the code block corresponding to the following statement will not be executed. If you want the code to recover, then the code block corresponding to the following statement will be executed. The saving of the scheduling context is realized by defining all the variables that need to be saved as member variables.

The realization principle of Unity's coroutine mechanism

Now we can discuss

yield return
The relationship with the coroutine, or the relationship between IEnumerator and the coroutine

The coroutine is a more lightweight existence than the thread, and the coroutine can be completely controlled and scheduled by the user program. The coroutine can schedule and transfer the execution right through the yield method, and the context must be saved during scheduling, and it must be restored when the scheduling comes back. Is this similar to the execution effect of "stopping" the code above and then restoring it in place? Yes, Unity implements the principle of coroutines through

yield return
Generated
IEnumerator
Cooperate to control when to trigger
MoveNext
To realize the scheduling of execution rights

Specifically, every time Unity passes

MonoBehaviour.StartCoroutine
Start a coroutine, you will get a
IEnumerator
(
StartCoroutine
The parameters are
IEnumerator
, The parameter is the overloaded version of the method name, and the corresponding method will be obtained through reflection
IEnumerator
). And in its game loop, according to the conditions to determine whether to execute
MoveNext
method. And this condition is based on
IEnumerator
of
Current
Property obtained, namely
yield return
The value returned.

When starting a coroutine, Unity will first call the obtained

IEnumerator
of
MoveNext
Once to get
IEnumerator
of
Current
value. So every time a coroutine is started, the coroutine function will be executed immediately to the first
yield return
Then "stop".

For different

Current
Type (usually
YieldInstruction
Subclass of), Unity has already done some default processing, such as:

  • in case

    Current
    Yes
    null
    , It is equivalent to doing nothing. In the next game loop, it will call
    MoveNext
    . and so
    yield return null
    It played the role of waiting for a frame

  • in case

    Current
    Yes
    WaitForSeconds
    Type, Unity will get its waiting time, each time the game loop will determine whether the time is up, and it will be called only when the time is up
    MoveNext
    . and so
    yield return WaitForSeconds
    Played the role of waiting for the specified time

  • in case

    Current
    Yes
    UnityWebRequestAsyncOperation
    Type, it is
    AsyncOperation
    Subcategories, and
    AsyncOperation
    Have
    isDone
    Attribute, indicating whether the operation is complete, only
    isDone
    When it is true, Unity will call
    MoveNext
    . for
    UnityWebRequestAsyncOperation
    In other words, only when the request is completed will the
    isDone
    The property is set to true.

    Therefore, we can use the following synchronous code to complete the originally asynchronous network request operation.

    using (UnityWebRequest webRequest = UnityWebRequest.Get( "https://www.cnblogs.com/iwiniwin/p/13705456.html" )) { yield return webRequest.SendWebRequest(); if (webRequest.isNetworkError) { Debug.Log( "Error " + webRequest.error); } else { Debug.Log( "Received " + webRequest.downloadHandler.text); } } Copy code

Realize your own Coroutine

Unity s coroutine is bound to MonoBehavior and can only be passed

MonoBehavior.StartCoroutine
Open the coroutine, and in development, some classes that do not inherit MonoBehavior cannot use the coroutine. In this case, we can encapsulate a coroutine by ourselves. After figuring out the implementation principle of Unity coroutines, it is not difficult to realize your own coroutines. Interested students should act quickly.

Here is an implementation that has been encapsulated in Remote File Explorer , which is used to make the Editor tool when MonoBehavior cannot be used and you want to use the coroutine. Remote File Explorer is a cross-platform remote file browser that allows users to operate the directory files on the platform where the application is running through the Unity Editor. The internal message communication part uses a lot of coroutines, which is to understand the synchronization code of coroutines to realize asynchronous tasks. Good example of features

Of course, if you use coroutines under the Unity Editor, Unity also provides related packages, you can refer to Editor Coroutines