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

Unity開發不可避免的要用到協程(Coroutine),協程同步代碼作異步任務的特性使程序員擺脫了曾經異步操做加回調的編碼方式,使代碼邏輯更加連貫易讀。然而在驚訝於協程的好用與神奇的同時,由於不清楚協程背後的實現原理,因此老是感受沒法徹底掌握協程。好比:html

  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(),爲何能夠實現等待指定時間或是等待請求完成再接着執行後面的代碼?

若是你和我同樣也有上面的疑問,不妨閱讀下本文,相信必定能夠解答你的疑惑。c++

IEnumerator是什麼

根據微軟官方文檔的描述,IEnumerator是全部非泛型枚舉器的基接口。換而言之就是IEnumerator定義了一種適用於任意集合的迭代方式。任意一個集合只要實現本身的IEnumerator,它的使用者就能夠經過IEnumerator迭代集合中的元素,而不用針對不一樣的集合採用不一樣的迭代方式。git

IEnumerator的定義以下所示程序員

public interface IEnumerator
{
    object Current { get; }

    bool MoveNext();
    void Reset();
}

IEnumerator接口由一個屬性和兩個方法組成github

  1. Current屬性能夠獲取集合中當前迭代位置的元素
  2. MoveNext方法將當前迭代位置推動到下一個位置,若是成功推動到下一個位置則返回true,不然已經推動到集合的末尾返回false
  3. Reset方法能夠將當前迭代位置設置爲初始位置(該位置位於集合中第一個元素以前,因此當調用Reset方法後,再調用MoveNext方法,Curren值則爲集合的第一個元素)

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

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

本質上foreach循環也是採用IEnumerator來遍歷集合的。在編譯時編譯器會將上面的foreach循環轉換爲相似於下面的代碼c#

{
    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出如今其中的方法就會被編譯器自動編譯成一個迭代器,對於這樣的函數能夠稱之爲迭代器函數。迭代器函數的返回值就是自動生成的迭代器類的一個對象api

試試想象若是沒有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

相關文章
相關標籤/搜索