聊一聊Unity協程背後的實現原理

iwiniwin發表於2021-07-05

Unity開發不可避免的要用到協程(Coroutine),協程同步程式碼做非同步任務的特性使程式設計師擺脫了曾經非同步操作加回撥的編碼方式,使程式碼邏輯更加連貫易讀。然而在驚訝於協程的好用與神奇的同時,因為不清楚協程背後的實現原理,所以總是感覺無法完全掌握協程。比如:

  1. MonoBehaviour.StartCoroutine接收的引數為什麼是IEnumeratorIEnumerator和協程有什麼關係?
  2. 既然協程函式返回值宣告是IEnumerator,為什麼函式內yield return的又是不同型別的返回值?
  3. yield是什麼,常見的yield returnyield break是什麼意思,又有什麼區別?
  4. 為什麼使用了yield return就可以使程式碼“停”在那裡,達到某種條件後又可以從“停住”的地方繼續執行?
  5. 具體的,yield return new WaitForSeconds(3)yield return webRequest.SendWebRequest(),為什麼可以實現等待指定時間或是等待請求完成再接著執行後面的程式碼?

如果你和我一樣也有上面的疑問,不妨閱讀下本文,相信一定可以解答你的疑惑。

IEnumerator是什麼

根據微軟官方文件的描述,IEnumerator是所有非泛型列舉器的基介面。換而言之就是IEnumerator定義了一種適用於任意集合的迭代方式。任意一個集合只要實現自己的IEnumerator,它的使用者就可以通過IEnumerator迭代集合中的元素,而不用針對不同的集合採用不同的迭代方式。

IEnumerator的定義如下所示

public interface IEnumerator
{
    object Current { get; }

    bool MoveNext();
    void Reset();
}

IEnumerator介面由一個屬性和兩個方法組成

  1. Current屬性可以獲取集合中當前迭代位置的元素
  2. MoveNext方法將當前迭代位置推進到下一個位置,如果成功推進到下一個位置則返回true,否則已經推進到集合的末尾返回false
  3. Reset方法可以將當前迭代位置設定為初始位置(該位置位於集合中第一個元素之前,所以當呼叫Reset方法後,再呼叫MoveNext方法,Curren值則為集合的第一個元素)

比如我們經常會使用的foreach關鍵字遍歷集合,其實foreach只是C#提供的語法糖而已

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

本質上foreach迴圈也是採用IEnumerator來遍歷集合的。在編譯時編譯器會將上面的foreach迴圈轉換為類似於下面的程式碼

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())  // 判斷是否成功推進到下一個元素(可理解為集合中是否還有可供迭代的元素)
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    } finally
    {
        // dispose of enumerator.
    }
}

yield和IEnumerator什麼關係

yield是C#的關鍵字,其實就是快速定義迭代器的語法糖。只要是yield出現在其中的方法就會被編譯器自動編譯成一個迭代器,對於這樣的函式可以稱之為迭代器函式。迭代器函式的返回值就是自動生成的迭代器類的一個物件

試試想象如果沒有yield關鍵字,我們每定義一個迭代器,就要建立一個類,實現IEnumerator介面,介面包含的屬性與方法都要正確的實現,是不是很麻煩?而利用yield關鍵字,只需要下面簡單的幾行程式碼,就可以快速定義一個迭代器。諸如迭代器類的建立,IEnumerator介面的實現工作編譯器通通幫你做了

// 由迭代器函式定義的迭代器
IEnumerator Test()
{
    yield return 1;
    Debug.Log("Surprise");
    yield return 3;
    yield break;
    yield return 4;
}
  1. yield return語句可以返回一個值,表示迭代得到的當前元素
  2. yield break語句可以用來終止迭代,表示當前沒有可被迭代的元素了

如下所示,可以通過上面程式碼定義的迭代器遍歷元素

IEnumerator enumerator = Test();  // 直接呼叫迭代器函式不會執行方法的主體,而是返回迭代器物件
bool ret = enumerator.MoveNext();
Debug.Log(ret + " " + enumerator.Current);  // (1)列印:True 1
ret = enumerator.MoveNext();
// (2)列印:Surprise
Debug.Log(ret + " " + enumerator.Current);  // (3)列印:True 3
ret = enumerator.MoveNext();
Debug.Log(ret + " " + enumerator.Current);  // (4)列印:False 3

(1)(3)(4)處的列印都沒有什麼問題,(1)(3)正確列印出了返回的值,(4)是因為迭代被yield break終止了,所以MoveNext返回了false

重點關注(2)列印的位置,是在第二次呼叫MoveNext函式之後觸發的,也就是說如果不呼叫第二次的MoveNext,(2)列印將不會被觸發,也意味著Debug.Log("Surprise")這句程式碼不會被執行。表現上來看yield return 1好像把程式碼“停住”了,當再次呼叫MoveNext方法後,程式碼又從“停住”的地方繼續執行了

yield return為什麼能“停住”程式碼

想要搞清楚程式碼“停住”又原位恢復的原理,就要去IL中找答案了。但是編譯生成的IL是類似於組合語言的中間語言,比較底層且晦澀難懂。所以我利用了Unity的IL2CPP,它會將C#編譯生成的IL再轉換成C++語言。可以通過C++程式碼的實現來曲線研究yield return的實現原理

比如下面的C#類,為了便於定位函式內的變數,所以變數名就起的複雜點

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;
	}
};

可以看到GetSingleDigitNumbers函式確實被定義成了一個類U3CGetSingleDigitNumbersU3Ed__0_t9371C0E193B6B7701AD95F88620C6D6C93705F1A,而區域性變數m_tag_indexm_tag_value都分別被定義成了這個類的成員變數___U3Cm_tag_indexU3E5__1_3___U3Cm_tag_valueU3E5__2_4,並且為它們生成了對應的get和set方法。___U3CU3E2__current_1成員變數對應IEnumeratorCurrent屬性。這裡再關注下額外生成的___U3CU3E1__state_0成員變數,可以理解為一個狀態機,通過它表示的不同狀態值,決定了整個函式邏輯應該如何執行,後面會看到它是如何起作用的。

// System.Boolean Test/<GetSingleDigitNumbers>d__0::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))));
		// yield return m_tag_index++;
		int32_t L_4 = __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;
	}
}

U3CGetSingleDigitNumbersU3Ed__0_MoveNext_mED8994A78E174FF0A8BE28DF873D247A3F648CFB 成員方法對應了IEnumeratorMoveText方法。它的實現利用了goto語句,而這個方法正是程式碼“停住”與恢復的關鍵所在

我們一步步來看,按照c#程式碼的邏輯,第一次呼叫moveNext函式時,應該執行以下程式碼

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

對應執行的c++程式碼如下所示。執行完畢IL_0030完畢後,將返回true,表示還有元素。此時的state為1

// 初始時,___U3CU3E1__state_0值為0
goto IL_0012;
goto IL_0018;  // IL_0018內部初始化m_tag_index和m_tag_value為0. 同時設定___U3CU3E1__state_0值為-1
goto IL_0070;  // 判斷m_tag_index是否小於10
goto IL_0030;  // IL_0030內部將m_tag_index值加1,並將m_tag_index的值設定為current值,並將___U3CU3E1__state_0值設定為1

第二次呼叫moveNext函式,對應C#程式碼為

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

對應的c++程式碼為

// 此時___U3CU3E1__state_0值為1,根據判斷進入IL_000c
goto IL_000c;
goto IL_0014;
goto IL_0068;  // 設定___U3CU3E1__state_0為-1
IL_0070  // 判斷m_tag_index是否小於10
goto IL_0030;  // 返回1,表示true,還有可迭代元素

當第11次呼叫moveNext函式時,m_tag_index的值已經是10,此時函式應該結束。返回值應該是false,表示沒有再能返回的元素了。
所以對應的C++程式碼為

// ___U3CU3E1__state_0值是1
goto IL_000c;
goto IL_0014;
goto IL_0068
IL_0070  // 判斷m_tag_index是不小於10的,所以不會進入IL_0030
{
	// }
	return (bool)0;  
}

到這裡,我想程式碼“停住”與恢復的神祕面紗終於被揭開了。總結下來就是,以能“停住”的地方為分界線,編譯器會為不同分割槽的語句按照功能邏輯生成一個個對應的程式碼塊。yield語句就是這條分界線,想要程式碼“停住”,就不執行後面語句對應的程式碼塊,想要程式碼恢復,就接著執行後面語句對應的程式碼塊。而排程上下文的儲存,是通過將需要儲存的變數都定義成成員變數來實現的。

Unity協程機制的實現原理

現在我們可以討論下yield return與協程的關係了,或者說IEnumerator與協程的關係

協程是一種比執行緒更輕量級的存在,協程可完全由使用者程式控制排程。協程可以通過yield方式進行排程轉移執行權,排程時要能夠儲存上下文,在排程回來的時候要能夠恢復。這是不是和上面“停住”程式碼然後又原位恢復的執行效果很像?沒錯,Unity實現協程的原理,就是通過yield return生成的IEnumerator再配合控制何時觸發MoveNext來實現了執行權的排程

具體而言,Unity每通過MonoBehaviour.StartCoroutine啟動一個協程,就會獲得一個IEnumeratorStartCoroutine的引數就是IEnumerator,引數是方法名的過載版本也會通過反射拿到該方法對應的IEnumerator)。並在它的遊戲迴圈中,根據條件判斷是否要執行MoveNext方法。而這個條件就是根據IEnumeratorCurrent屬性獲得的,即yield return返回的值。

在啟動一個協程時,Unity會先呼叫得到的IEnumeratorMoveNext一次,以拿到IEnumeratorCurrent值。所以每啟動一個協程,協程函式會立即執行到第一個yield return處然後“停住”。

對於不同的Current型別(一般是YieldInstruction的子類),Unity已做好了一些預設處理,比如:

  • 如果Currentnull,就相當於什麼也不做。在下一次遊戲迴圈中,就會呼叫MoveNext。所以yield return null就起到了等待一幀的作用

  • 如果CurrentWaitForSeconds型別,Unity會獲取它的等待時間,每次遊戲迴圈中都會判斷時間是否到了,只有時間到了才會呼叫MoveNext。所以yield return WaitForSeconds就起到了等待指定時間的作用

  • 如果CurrentUnityWebRequestAsyncOperation型別,它是AsyncOperation的子類,而AsyncOperationisDone屬性,表示操作是否完成,只有isDone為true時,Unity才會呼叫MoveNext。對於UnityWebRequestAsyncOperation而言,只有請求完成了,才會將isDone屬性設定為true。

    也因此我們才可以使用下面的同步程式碼,完成本來是非同步的網路請求操作。

    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);
        }
    }
    

實現自己的Coroutine

Unity的協程是和MonoBehavior進行了繫結的,只能通過MonoBehavior.StartCoroutine開啟協程,而在開發中,有些不是繼承MonoBehavior的類就無法使用協程了,在這種情況下我們可以自己封裝一套協程。在搞清楚Unity協程的實現原理後,想必實現自己的協程也不是難事了,感興趣的同學趕快行動起來吧。

這裡有一份Remote File Explorer內已經封裝好的實現,被用於製作Editor工具時無法使用MonoBehavior又想使用協程的情況下。Remote File Explorer是一個跨平臺的遠端檔案瀏覽器,使使用者通過Unity Editor就能操作應用所執行平臺上的目錄檔案,其內部訊息通訊部分大量使用了協程,是瞭解協程同步程式碼實現非同步任務特性的不錯的例子

當然Unity Editor下使用協程,Unity也提供了相關的包,可以參考Editor Coroutines

相關文章