Unity 的多线程、协程、纤程

  • 在这个降低入门门槛的大环境下,Unity 因为考虑到降低门槛,设计之初就是一个单线程,不允许在另外的线程中进行渲染等等的工作,不然又要增加很多机制去处理这个问题,会给新来的人徒增很多烦恼。
  • Unity 考虑到 跨平台的特性 和引入 异步 的操作,所以提供了另一种异步的手段,就是协程(Coroutine),通过反编译,它本质上还是在主线程上的优化手段,并不属于真正的 多线程(Thread)
  • 多线程(Thread)是C#带来的特性
  • 多线程其实不难,但同步数据是最麻烦的
  • 协程与纤程都是主线程上的优化手段,规避了异步编程中状态机的复杂性,使程序逻辑更加简洁直观。一个进程可以创建上万个协程。消耗小、切换快。个人认为协程与纤程其实本质上是一样的。
  • 如果你的应用不需要一些耗时的操作,比如网络请求,IO操作,AI等,那么尽量不要使用多线程(Thread),因为跨线程访问UI控件是禁止的,并且数据同步问题往往也是很棘手的,很容易滥用 lock 导致主线 block 或者 deadlock。反之,如果应用程序很复杂,那么势必在需要去分担主线程的压力,那么使用异步线程是个很好的主意。同时,我们也不能滥用线程,过多的使用线程会造成CPU运算的下降,建议使用线程池 ThreadPool 或者利用 GC 来回收线程。

Thread 多线程

线程启动

在Unity中创建一个异步线程是非常简单的,直接使用类 System.Threading.Thread 就可以创建一个线程,线程启动之后毕竟要帮我们去完成某件事情。在编程领域,这件事就可以描述了一个方法,所以需要在构造函数中传入一个方法的名称。

Worker workerObject = new Worker();
Thread workerThread = new Thread(workerObject.DoWork)
workerThread.Start();

线程终止

线程启动很简单,那么线程终止呢,是不是调用 Abort 方法。不是,虽然 Thread 对象提供了
Abort 方法,但并不推荐使用它,因为它并不会马上停止,如果涉及非托管代码的调用,还需要等待非托管代码的处理结果。

一般停止线程的方法是为线程设定一个条件变量,在线程的执行方法里设定一个循环,并以这个变量为判断条件,如果为false则跳出循环,线程结束。

public class Worker
{
    public void DoWork()
    {
        while (!_shouldStop)
        {
            Console.WriteLine("worker thread: working...");
        }
        Console.WriteLine("worker thread: terminating gracefully.");
    }
    public void RequestStop()
    {
        _shouldStop = true;
    }
    private volatile bool _shouldStop;
}

所以,你可以在应用程序退出(OnApplicationQuit)时,将_shouldStop设置为true来到达线程的安全退出。

共享数据处理

多线程最麻烦的一点就是共享数据的处理了,想象一下A,B两个线程同一时刻处理一个变量,它最终的值到底是什么。所以一般需要使用lock,但C#提供了另一个关键字 volatile,告诉CPU不读缓存直接把最新的值返回。所以_shouldStop被volatile修饰。

Dispatcher 调度员

是不是觉得多线程好简单,好像也没想象的那么复杂,当你愉快的在多线程中访问UI控件时,Duang~~~,一个错误告诉你,不能在异步线程访问UI控件。这是肯定的,跨线程访问UI控件是不安全的,理应被禁止。那怎么办呢?

注意

  • UnityEngine 的 API 不能在分线程运行
  • UnityEngine 定义的基本结构(int, float, struct 定义的数据类型)可以在分线程计算,如 Vector3(struct)可以, 但 Texture2d(class,根父类为 Object) 不可以。
  • UnityEngine 定义的基本类型的函数可以在分线程运行

所以,我们使用 消息通知者生产者-消费者模式 的方式告诉一个在主线程上的 Dispatcher ,来控制 Unity 的组件。
需要把握住几个关键点:

  • 自己的Dispatcher一定是一个MonoBehaviour,因为访问UI控件需要在主线程上
  • 什么时候去更新呢,考虑 生产者-消费者模式,有任务来了,我就是更新到UI上
  • 在Unity中有这么个方法可以轮询是不是有任务要更新,那就是 Update 或者 FixedUpdate 方法,可以根据需要控制执行的周期

生产者-消费者模式:
自定义的 UnityDispatcher 提供一个 BeginInvoke 方法,并接送一个 Action

public void BeginInvoke(Action action){
    while (true) {
        //以原子操作的形式,将 32 位有符号整数设置为指定的值并返回原始值。
        if (0 == Interlocked.Exchange (ref _lock, 1)) {
            //acquire lock
            _wait.Enqueue(action);
            _run = true;
            //exist
            Interlocked.Exchange (ref _lock,0);
            break;
        }
    }
}

这是一个生产者,向队列里添加需要处理的Action。有了生产者之后,还需要消费者,Unity中的
Update 就是一个消费者,每一帧都会执行,所以如果队列里有任务,它就执行

 void Update(){

    if (_run) {
        Queue<Action> execute = null;
        //主线程不推荐使用lock关键字,防止block 线程,以至于deadlock
        if (0 == Interlocked.Exchange (ref _lock, 1)) {
        
            execute = new Queue<Action>(_wait.Count);

            while(_wait.Count!=0){

                Action action = _wait.Dequeue ();
                execute.Enqueue (action);

            }
            //finished
            _run=false;
            //release
            Interlocked.Exchange (ref _lock,0);
        }
        //not block
        if (execute != null) {
        
            while (execute.Count != 0) {
            
                Action action = execute.Dequeue ();
                action ();
            }
        }
    
    }
}

值得注意的是,Queue不是线程安全的,所以需要锁,我使用了Interlocked.Exchange,好处是它以原子的操作来执行并且还不会阻塞线程,因为主线程本身任务繁重,所以我不推荐使用lock。

协程和纤程

Unity 协程的内部原理

对于Unity应用程序而言,还提供了另外一种『异步方式』:Coroutine 。Coroutine 也就是协程的意思,只是看起来像多线程,它实际上并不是,还是在主线程上操作。

Coroutine实际上由 IEnumerator 接口以及一个或者多个的 yield 语句构成的迭代器(iterator)块构成。

枚举器接口 IEnumerator 包含3个方法:

  • Current:返回集合当前位置的对象
  • MoveNext: 把枚举器位置移到集合的下一个元素,它返回一个 bool 值,表示新的位置是否超过索引
  • Reset:把位置重置为初始状态

yield 是个比较晦涩的技术,原因是编译器帮我们做了太多的工作(CompilerGenerate),导致我们无法理解到内部的实现。如果你去翻阅汉英词典,你会对 yield 一头雾水。我个人倾向将其翻译成中断和产出比较好,这也是 yield 单词包含的意思,我下面也会阐述为什么要翻译成这两个意思。

深究 yield 之前,我觉得应该略微了解一下为什么我们能 foreach 遍历一个数组?

原因很简单,数组 Array 它是一个可枚举的类 (enumerable),一个可枚举类提供了一个枚举器
(enumerator),枚举器可以依次访问数组里的元素,也就是之前提过的 Current 属性返回集合当前位置的对象。所以,我可以模拟 foreach 的实现,实际上 foreach 内部实现也大致相似。

static void Main(string[] args)
{
    string[] animals = {"dog", "cat", "pig"};
    //获取枚举器
    var ie = animals.GetEnumerator();
    //移到下一项,默认的index=-1
    while (ie.MoveNext())
    {
        //获得当前项
        Console.WriteLine(ie.Current);
    }
    Console.ReadLine();
}

假设你是个C#新手,你得好好消化一下上述的逻辑,因为这是拨开迷雾的第一层:了解为什么能够枚举一个集合。当然我们也可以创建自己的可被枚举的类,需要为它提供自定义的枚举器,只需实现 IEnumerator 接口即可。值得注意的事,自建的可枚举类同时也要实现 IEnumerable 接口,该接口只提供一个方法:GetEnumerator(),用来返回枚举器。

创建自定义的枚举类AnimalSet:

class AnimalSet : IEnumerable
{
    private readonly string[] _animals = {"the dog", "the pig", "the cat"};
    public IEnumerator GetEnumerator()
    {
        return new AnimalEnumerator(_animals);
    }
}

需要为AnimalSet提供自定义的枚举器AnimalEnumerator

class AnimalEnumerator : IEnumerator
{
    private string[] _animals;
    private int _index = -1;

    public AnimalEnumerator(string[] animals)
    {
        _animals=new string[animals.Length];

        for (var i = 0; i < animals.Length; i++)
        {
            _animals[i] = animals[i];
        }
    }

    public bool MoveNext()
    {
        _index++;
        return _index<_animals.Length;
    }

    public void Reset()
    {
        _index = -1;
    }

    public object Current
    {
        get { return _animals[_index]; }
    }
}

你可能会觉得奇怪,这和 yield 又有什么关系呢?要解惑 yield 这是第二个阶段:能知道枚举器是怎样工作的。

如果你很清楚上诉两个阶段的内部原理之后,要理解 Unity 中的 Coroutine 是非常简单的,你会了解为什么它是 伪的 “多线程”
这是一段非常普通的代码,司空见惯。

void Start()
{
    StartCoroutine(MyEnumerator());
    Debug.Log("finish");
}

private IEnumerator MyEnumerator()
{
    Debug.Log("wait for 1s");
    yield return new WaitForSeconds(1);
    Debug.Log("wait for 2s");
    yield return new WaitForSeconds(2);
    Debug.Log("wait for 3s");
    yield return new WaitForSeconds(3);
}

注意到 MyEnumerator 方法的放回类型了吗?没错,返回的就是枚举器,你会疑问,你没有定义一个枚举器并且实现了 IEnumerator 接口啊!别急,问题就出在 yield 上,C#为了简化我们创建枚举器的步骤,你想想看你需要先实现 IEnumerator 接口,并且实现 Current, MoveNext,
Reset 步骤。C#从2.0开始提供了有yield组成的迭代器块。编译器会自动更具迭代器块创建了枚举器。不信,反编译看看:

public class Test : MonoBehaviour
{
    private IEnumerator MyEnumerator()
    {
        UnityEngine.Debug.Log("wait for 1s");
        yield return new WaitForSeconds(1f);
        UnityEngine.Debug.Log("wait for 2s");
        yield return new WaitForSeconds(2f);
        UnityEngine.Debug.Log("wait for 3s");
        yield return new WaitForSeconds(3f);
    }

    private void Start()
    {
        base.StartCoroutine(this.MyEnumerator());
        UnityEngine.Debug.Log("finish");
    }

    [CompilerGenerated]
    private sealed class <MyEnumerator>d__1 : IEnumerator<object>, IEnumerator, IDisposable
    {
        private int <>1__state;
        private object <>2__current;
        public Test <>4__this;

        [DebuggerHidden]
        public <MyEnumerator>d__1(int <>1__state)
        {
            this.<>1__state = <>1__state;
        }

        private bool MoveNext()
        {
            switch (this.<>1__state)
            {
                case 0:
                    this.<>1__state = -1;
                    UnityEngine.Debug.Log("wait for 1s");
                    this.<>2__current = new WaitForSeconds(1f);
                    this.<>1__state = 1;
                    return true;

                case 1:
                    this.<>1__state = -1;
                    UnityEngine.Debug.Log("wait for 2s");
                    this.<>2__current = new WaitForSeconds(2f);
                    this.<>1__state = 2;
                    return true;

                case 2:
                    this.<>1__state = -1;
                    UnityEngine.Debug.Log("wait for 3s");
                    this.<>2__current = new WaitForSeconds(3f);
                    this.<>1__state = 3;
                    return true;

                case 3:
                    this.<>1__state = -1;
                    return false;
            }
            return false;
        }

        object IEnumerator.Current
        {
            [DebuggerHidden]
            get
            {
                return this.<>2__current;
            }
        }

        //...省略...
    }
}

有几点可以确定:

  • yield是个语法糖,编译过后的代码看不到yield
  • 编译器在内部创建了一个枚举类 <MyEnumerator>d__1
  • yield return 被声明为枚举时的下一项,即Current属性,通过MoveNext方法来访问结果

OK,通过层层推进,想必你对 Untiy中的协程 有一定的了解了。再回过头来,我将 yield翻译成了 中断产出,谈谈我的理解。

中断:传统的方法代码块执行流程是从上到下依次执行,而yield构成的迭代块是告诉编译器如何创建枚举器的行为,反编译得到的结果可以看到,它们的执行并不是连续的,而是通过switch来从一个状态(state)跳转到另一个状态
产出:yield 是和return连用, yield return之后的语句被编译器赋值给current变量,最终通过Current属性产出枚举项

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容

  • 写在开始,讲在结尾。 妈妈,你年轻的时候,那么年轻。 有人爱慕你年轻时,有人倾心那一段。 你都这么开心的占有着,或...
    随风潜夜阅读 342评论 0 0
  • 先来一张大合照~ 不知不觉,21天的课程就要结束了! 跟着心蓝老师学到非常详细的彩铅插画基础知识,从排线,叠色等等...
    陈少琼阅读 737评论 2 2
  • 时间太瘦,指缝太宽 我们抓不住金沙,摊开手掌 流年已不见。 我将光阴写在闰年的脚上 踏足 经年碎响 响声叫不醒我 ...
    考拉家的老王子阅读 396评论 0 0
  • 人无百日好,花无百日红。 路边一晃而过的紫薇颠覆了这个说法。 这种花我们这儿原来没有这种花,后来引种过来。一到夏天...
    海深深阅读 677评论 0 1