反編譯C#代碼來看看協程是什麼

原始代碼:

public class NewBehaviourScript : MonoBehaviour{ IEnumerator Start () { int i = 0; Debug.Log(i.ToString()); yield return new WaitForEndOfFrame(); Debug.Log(i + 1); }}

轉換後代碼:

public class NewBehaviourScript : MonoBehaviour{ private IEnumerator Start() { Anonymous iterator = new Anonymous(0); iterator.parent = this; return iterator; } private sealed class Anonymous : IEnumerator<object>, IEnumerator, IDisposable { public Anonymous(int state) { this.state = state; } bool IEnumerator.MoveNext() { int num = this.state; if (num == 0) { this.state = -1; this.i = 0; Debug.Log(this.i.ToString()); this.current = new WaitForEndOfFrame(); this.state = 1; return true; } else if (num == 1) { this.state = -1; Debug.Log(this.i + 1); return false; } } object IEnumerator<object>.Current { get{return this.current;} } void IEnumerator.Reset() { throw new NotSupportedException(); } object IEnumerator.Current { get{return this.current;} } private int state; private object current; public NewBehaviourScript parent; private int i; }}

協程部分被轉換成了一個迭代器,本質是一個狀態機。函數體從yield return的位置被切分開,在MoveNext函數內,針對不同的協程狀態執行不同的分支,每走一次分支狀態值加1。

yield return後面的部分則被設置成current,也就是迭代器的當前值,最終被返回。

MoveNext的返回值為false時,表示執行到了協程的終點。

每次執行協程,都會在堆上生成一個迭代器對象,這是GC部分的來源。

協程里使用的臨時變數都會被轉成類欄位,而且至少有一個父級對象的引用。這就是協程GC的大小。

但是,如果把臨時變數移動到協程外:

public class NewBehaviourScript : MonoBehaviour{ private int i = 0; IEnumerator Start () { Debug.Log(i.ToString()); yield return new WaitForEndOfFrame(); Debug.Log(i.ToString()); }}

也很自然的不會在迭代器內生成i這個欄位,而是直接從parent屬性調用,也就減少了每次協程GC的量,但缺點就是不會自動重置了。

yield break;

會跳過設置current屬性,在MoveNext中直接return false,結束協程。

public class NewBehaviourScript : MonoBehaviour{ IEnumerator Start () { Debug.Log("START"); for (int i = 0;i < 10;i++) { yield return new WaitForEndOfFrame(); Debug.Log(i.ToString()); } Debug.Log("END"); }}

使用循環時,MoveNext內變為:

bool IEnumerator.MoveNext(){ int num = this.state; if (num == 0) { this.state = -1; Debug.Log("START"); this.i = 0; this.current = new WaitForEndOfFrame(); this.state = 1; } else { this.state = -1; Debug.Log(this.i.ToString()); this.i++; if (this.i < 10) { this.current = new WaitForEndOfFrame(); this.state = 1; return true; } else { Debug.Log("END"); return false; } }}

在循環節內,state值並不會增加,會一直重複執行一直到循環結束。

這個操作自然並不會有內存的積累。

嵌套協程:

public class NewBehaviourScript : MonoBehaviour{ IEnumerator Start () { Debug.Log("START"); for (int i = 0;i < 10;i++) { yield return Coroutine2(); Debug.Log(i.ToString()); } Debug.Log("END"); } IEnumerator Coroutine2() { Debug.Log("IN"); yield return new WaitForEndOfFrame(); Debug.Log("OUT"); }}

結果是,原來的this.current = new WaitForEndOfFrame()部分會被轉換成

Coroutine2 iterator= new Coroutine2(0);iterator.parent = this.parent;this.current = iterator;

也就是說,子協程每次執行,都必須創建一個新的迭代器,並不會復用。這會導致大量GC。

所以可以的話,不要在循環內套用協程。

可能會有人想到這種寫法:

IEnumerator Start () { Debug.Log("START"); var subCoroutine = Coroutine2(); for (int i = 0;i < 10;i++) { yield return subCoroutine; Debug.Log(i.ToString()); } Debug.Log("END"); }

但這樣是不行的,因為subCoroutine執行完一次後,其state值會變成1,想再次正常使用必須將其重置為0,但C#創建的這個迭代器是沒有Reset方法的,所以不可能重用。

但如果你像這樣把迭代器的實例保存下來再執行,就可以用StopCoroutine(IEnumerator routine)來中斷它,並不一定要保存Coroutine實例。

對了,如果在上面你用的是yield return StartCoroutine(Coroutine2()),除了上面的成本不說,還需要在C++那邊創建一個新的Coroutine對象,幾乎等於在另一個MonoBehaviour上開啟一個新協程,而這種做法並沒有任何意義。只有StartCoroutine(Coroutine2())單獨用才有意義,這樣會開一個新的分叉,和當前協程並行執行。

上面寫的yield return new WaitForSeconds()只是圖個方便,從代碼中可以看到,每次new WaitForSeconds()都會創建新對象,所以這是非常有必要緩存起來的,像這樣:

WaitForSeconds wait = new WaitForSeconds(1f);IEnumerator Coroutine2(){ Debug.Log("IN"); yield return wait; Debug.Log("OUT");}

雖然WaitForSeconds其實是個純數據對象,只有一個表示秒數的浮點,GC產生的內存量很少。但C#創建一個空class就需要16B,這點免不掉。

另外yield return null其實表示的是WaitForUpdate,它是在Update函數之後執行的(WaitForEndOfFrame的執行時機要更晚),直接返回null當然是不會有GC的。

因為這些對象沒有狀態,是可以設成靜態常量來複用的。這樣就能免去GC成本。

自定義協程有WaitUtil,WaitWhile這兩個東西,他們都簡單派生於CustomYieldInstruction,但其實都沒什麼意義。

CustomYieldInstruction本質上就是

keepWaiting = true;while (keepWaiting){ 自定義邏輯代碼 yield return null;}

所以你不如乾脆while (XXXX) {yield return null}算了,反正檢測間隔都是1幀1次,只能做到這樣。

不過相比一般的IEnumerator,CustomYieldInstruction這個迭代器的Reset函數是可以重寫的,所以可以用它來重置你的自定義迭代器以便下次復用(但估計沒人有這需求吧)


上面講的都是協程的C#部分。但C#部分其實也就是個迭代器,本身是沒協程功能的,只是提供了一個MoveNext的返回值表示結束標誌,還留下來一個current屬性供外部參考。

實際產生效果的是Unity runtime,C++那邊的邏輯。

根據有Unity源碼的人提供的代碼,StartCoroutine(以及諸如IEnumerator Start()這些系統自動調用StartCoroutine的地方)會先調用一次迭代器的MoveNext,然後根據其current的值來生成一個C++端的delayCall對象塞進MonoBehaviour的數組裡。如果你的current值是WaitForSeconds,就會設置成「按時間等待」的枚舉,記錄開始時間和時長。然後如果是null或者其他任意值,就會設置成「下一幀執行」枚舉。

然後MonoBehaviour的各個事件階段(Update,FixUpdate什麼的)里,會遍歷一下這個delayCall列表,挑出符合類型的delayCall對象。如果有時間限制就檢查一下時間,通過後執行對應迭代器的MoveNext方法,然後再重複一遍上面的邏輯,直到MoveNext返回false。這就是協程延時執行的原理。

(發現有人作死在網上貼了一部分源碼,cocoachina.com/bbs/3g/r,不過和我看的有點不同,老版本吧)

所以你說協程慢不慢?起碼不比Invoke慢,畢竟不用處理字元串。成本就是每次執行都要生成的delayCall對象,執行隊列的管理,C++和C#之間的穿透成本,迭代器自生的幾條代碼……慢是慢點,但和Update應該是同等級的。

很多人都想做攜程的合併以優化性能。但從原理看,硬把所有攜程放到一個MonoBehaviour並沒有用,因為每個Coroutine其實都是獨立的,在不在同一個MonoBehaviour沒啥區別。

有意義的是把所有IEnumerator迭代器扔到一個Coroutine里,但這樣就不能並行了。

所以合併是沒法優化性能的。

但是:

  • 禁用MonoBehaviour並不會暫停或者終止協程。
  • 不知道為啥,禁用GameObject卻能終止協程,這裡要格外注意。
  • 協程只能終止不能暫停(但是基於Update的等待,還是可以用過timeScale=0來「暫停」)

禁用GameObject錯誤終止協程這個事情讓人很噁心,很容易產生意外的BUG,所以把協程放在另一個一直存在的GameObject上確實會保險一點。但為了避免協程執行期間MonoBehaviour銷毀產生的空引用調用錯誤,就要記得在OnDestory里及時停止攜程。管理類是有必要的。

另外完全拋棄掉協程的c++部分也是另一回事。

public class GlobalCoroutine : MonoBehaviour{ public static MonoBehaviour globalBehaviour; List<IEnumerator> coroutines = new List<IEnumerator>(); public new void StartCoroutine(IEnumerator routine) { coroutines.Add(routine); globalBehaviour.StartCoroutine(routine); } public new void StopCoroutine(IEnumerator routine) { coroutines.Remove(routine); globalBehaviour.StopCoroutine(routine); } public new void StopAllCoroutines() { foreach (var v in coroutines) { globalBehaviour.StopCoroutine(v); } coroutines.Clear(); } protected virtual void OnDestroy() { StopAllCoroutines(); }}


很久以前的姊妹篇:

flashyiyi:反編譯C#代碼來看看閉包到底是什麼?

zhuanlan.zhihu.com圖標
推薦閱讀:

GMS2中遊戲暫停的實現(2/2)
神谷英樹和他弟弟的遊戲回憶錄
本車十次碰撞,成績九勝一平:《火爆狂飆》與Criterion瘋狂
基礎1——遊戲物體和腳本——造一個鍾
[Unity]面試題整理(一)

TAG:Unity遊戲引擎 | 遊戲開發 |