C#进阶:线程和任务

更多
2019年06月16日 11点31分 作者:叶飞 修改

在.NET core的I/O类库中,我们会发现这样的方法:

    public static Task AppendAllLinesAsync(string path, IEnumerable<string> contents, Encoding encoding, CancellationToken cancellationToken = default); 
    public static Task<byte[]> ReadAllBytesAsync(string path, CancellationToken cancellationToken = default);


注意:

  • 方法名被添加了Async后缀(推荐命名规范
  • 方法的返回类型为Task或Task<T>


Task

Task在代码层面上讲,是一个实现了IAsyncResult的类,Task<T>继承了Task。(演示)

从逻辑层面上讲,Task是一份工作/任务,该工作/任务会被异步的执行,并未来某个时间完成。


Action和Func

Task要完成的任务由Action和Func体现:

  • Task:使用Action,该Task没有返回 
  • Task<T>:使用Func,该Task还要返回一个T类型对象

最直观的方式是将Action或Func作为Task的构造函数参数传入:

    Action getup = () =>
    {
        Console.WriteLine("getUp()……");
    };
Task t1 = new Task(getup);

或者:

    Func<int> getup = () =>
    {
        Console.WriteLine("getUp()……");
        return new Random().Next();
    };
    Task<int> t1 = new Task<int>(getup);

Action和Func都可以带一个object类型的传入参数,object的值在new Task()时传入:

    Action<object> getup = (x) =>
    {
        Console.WriteLine($"getUp({x})……");
    };
    Task t = new Task(getup, 23);

@想一想@:如果有多个参数需要传入,怎么办?


异步运行

得到一个Task之后,还需要显式调用Start()才开始运行:

    t1.Start();

为了演示Task如何异步运行,我们在其前后加点代码,运行10次:

    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine($"第{i + 1}次:");
        Console.WriteLine();

        Task t1 = new Task(getup);
        t1.Start();     //new出来的task还需要显式调用Start()才开始运行

        for (int j = 0; j < 5; j++)
        {
            Console.WriteLine($"after:t1.Start(): j={j}");
        }

        Console.WriteLine();
    }

(演示:运行结果)

我们可以看到:getup的运行顺序完全“乱套”:它有时在for循环之前,有时在for循环之中,有时在for循环之后甚至在下一次for循环里面!

但是,这不是“乱套”,这是异步(async)

我们之前学习的,代码按书写顺序,依次执行,前面一行代码没有执行完毕,后面一行代码就无法执行,这就是同步(sync)。以上述代码为例,要同步运行,可以:

  • 直接运行getup:
        getup();
  • 调用Task的RunSynchronously()方法
       t1.RunSynchronously()

(演示:运行结果)

所以什么是异步?最简单的理解:不是同步的,都是异步


其他方法

获得一个已经开始运行的Task实例:

    Task t1 = Task.Run(getup);
    Task t1 = Task.Factory.StartNew(getup);

获得一个会延迟执行的Task:Delay()

    Task t1 = Task.Delay(1000);
    t1.Wait();   //Delay()获得的Task不能Start()    

阻塞:Wait()和Result,他们会让运行停下来,等着Task完成,或者取到Task运行之后返回的值

    Console.WriteLine("t1.Result:" + t1.Result);    //使用Result属性获得返回值


Thread

Task是在.NET 4.0之后引入的,之前.NET提供的是Thread(线程)类。



进程和线程

进程是操作系统分配给应用程序独享的一块资源:

  • 由操作系统管理(调度)
  • 一个应用程序至少(可以有多个)进程
  • 资源包括:内存、CPU、I/O等
  • 进程之间的资源不能共享


线程可以看成是一个“更轻量级”的进程,一个进程中有可以有多个线程。但是,

  • 线程不具备独立完成一个任务的资源,它需要进程的支持
  • 多个线程可共享进程资源



为什么需要?

无论是多进程,还是多线程,本质上都是为了让多个进程/线程同时(或者至少看起来像同时)完成一个工作,从而:

  1. 压榨系统性能。简单理解,一桌菜10个人吃比1个人吃要快(并发后文详述
  2. 提高用户响应。操作系统多进程,所以我们可以一边听歌(一个进程)一边写代码(又一个进程);射击游戏多线程,所以你跑你的,我打我的,不会你跑的时候我就不能开枪,我开枪的时候你就不能跑……(用WinForm做坦克大战就这效果,^_^)

用于ASP.NET的IIS本身就是多线程的。



单线程

在此之前,我们的代码都是运行在一个线程中,这被称之为“单线程编程”。

我们默认使用的这个线程被称为主(primary)线程,或者启动线程。使用Thread.CurrentThread可以获取:

using System.Threading;
    Thread current = Thread.CurrentThread;

然后,可以获取线程的相关信息:

    Console.WriteLine(Thread.GetDomain().FriendlyName);
    Console.WriteLine(current.ManagedThreadId);     //托管线程Id
    Console.WriteLine(current.Priority);            //优先级
    Console.WriteLine(current.ThreadState);         //线程状态
    Console.WriteLine(current.IsThreadPoolThread);  //是否线程池线程

解释:

  • 为什么是managed?操作系统控制“真正的”线程,C#开发人员通过.NET运行时控制操作系统上真正的线程,我们获取的是.NET运行时中的线程ID
  • 优先级:当出现多个线程进行资源争夺时,操作系统按其优先级从高到低进行分配
  • 线程状态:因为线程中的任务无法在一个CPU时钟周期内完成,当CPU被分配去处理其他线程时,当前线程也不能被“销毁”,只需要改变其状态即可
  • 线程池:后文详述

段子方法:

    //项目经理要求写的,以后客户优化好加钱
    Thread.Sleep(1000);


多线程

让代码运行在多个线程中,就被称之为“多线程编程”,又称之为并发

可以new一个工作线程
Thread current = new Thread(Process); 
        public static void Process()
        {
            Console.WriteLine($"ThreadId:{Thread.CurrentThread.ManagedThreadId}");
        }

@想一想@:为什么可以直接传Process?


new出来的Thread需要调用Start()来启动。多线程会带来异步并发的效果:

    for (int i = 0; i < 20; i++)
    {
        //Console.WriteLine($"{i}:ThreadId-{Thread.CurrentThread.ManagedThreadId}");
        new Thread(() =>
        {
            Console.WriteLine($"{i}:ThreadId-{Thread.CurrentThread.ManagedThreadId}");
        }).Start();
    }

演示:


  • 不同的ThreadId
  • i的值飘忽不定



前台和后台

上述线程(主线程和工作线程)都是前台(foreground)线程。


此外还有后台(background)线程。


前后台线程的区别:如果前台线程终止,后台线程也会被结束;反之不成立。


可以改变线程的前后台状态:

            current.IsBackground = true;


控制台可以显示所有前台线程的输出,但后台线程就不一定了:

            for (int i = 0; i < 10; i++)
            {
                Thread thread = new Thread(() =>
                {
                    Thread.Sleep(1);
                    Console.WriteLine($"{i}:ThreadId-{Thread.CurrentThread.ManagedThreadId}");
                });
                thread.IsBackground = true;
                thread.Start();
                Console.WriteLine("main-thread, i =" + i);
            }


演示:后台线程中的控制台输出无法呈现


因为多个线程之间的协调调度是一件非常麻烦而且容易出错(error-prone)的,.NET已经不建议开发人员直接使用Thread,而是使用Task参考:托管线程处理的最佳做法

所以很多线程方法也已经被[Obsolete]了,比如:Suspend()、Resume()……


Task和Thread

如果Task需要一个线程,默认它会利用线程池来获取。



线程池


理解“池(pool)”的概念:

  • 一个线程使用完成后并不销毁,而是放回池中
  • 所以池中可以存放多个线程
  • 下次使用时直接从池中取出未使用的线程

一个进程只有一个线程池,池中的线程都是:

  • 后台线程
  • 使用默认的priority
  • 使用相同的栈大小



TaskScheduler

而且.NET中还有一个专门的TaskSchedular负责线程池中线程的调度。

大体上来说,它利用以下一些方式提供其运行效率:


  • 使用队列,先进先出
  • 使用算法防止某些线程偷懒(work stealing)
  • 处理一些长运行(long-running)线程

绝大多数场景,我们使用.NET内置的调度就OK了。





概念澄清

即使是工作多年的程序员,相当一部分人也没弄明白这些概念:


并行和并发

并行(parallel):多个任务真正的“同时”进行 ,其对应的是概念是串行

因为一个CPU核心同一个时间点只能处理一个任务,所以并行只能在多(核)处理器上实现。

并行是能够真正提高程序运行效率的。


在电脑没有多核CPU的时代(或者程序没有运行在多核上),我们用并发(concurrency)来模拟并行。

简单的说:


  • CPU的运行被分成很多的“时间片”(时间片非常的短,我们人类是察觉不到的
  • 这个时间片里CPU可以执行A任务,下一个时间片里CPU就执行B任务,再下一个时间片C任务;然后再回来执行A,再执行B,再C;再A……如此往复
  • 在我们看来,A/B/C三个任务似乎被同时执行一样


操作系统的多进程就是这样实现的(一遍听课,一遍VS code,^_^)

并发通常都会造成异步。

并发如果提高性能?

并发其实提高的是CPU的应用效率,或者说:减少CPU闲置。

很多任务,需要CPU、内存和I/O共同配合完成。但是,CPU的运算速度最快,I/O最慢,所以如果同步串行的话,CPU不得不停下来等待I/O,如图所示:


但是,当任务可以并发进行时,CPU就可以不用等待I/O,如图所示:

@想一想@:并发是不是总能提高性能?



多线程和异步

把异步和多线程(包括并行)混为一谈! —— 这是最大的误区

通常情况,多线程会导致异步——但这时候惯常情况,两种并无决定性的必然关系。

严格来讲,多线程和异步可以毫无关系:

明白了这些,才能明白:

新开一个Task并不总是新开一个Thread!

  • 从逻辑上讲,Task是比线程“更高层的抽象”,它是对任务(work)的封装,而不是线程(thread)的封装
  • 从实现上讲,异步并不一定需要一个线程。是否开启一个新线程,是由scheduler决定的,目前来说:
    • I/O相关异步,是不开新线程的,异步由底层I/O实现
    • CPU相关的线程,会利用TaskSchedular从线程池获取



        static void Show(Task task)
        {
            Console.WriteLine("Show():");
            Console.WriteLine($"task-{task.Id}.Start()...." +
                $"status:{task.Status}," +
                $"task.IsCompleted:{task.IsCompleted}," +
                $"task.AsyncState:{task.AsyncState}," +
                $"ThreadId:{Thread.CurrentThread.ManagedThreadId}");
        }
            Action getup = () =>
            {
                Thread.Sleep(100);
                Console.WriteLine($"getUp(): TaskId:{Task.CurrentId}, " +
                    $"ThreadId:{Thread.CurrentThread.ManagedThreadId}");
            };
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine($"第{i + 1}次:");
                Console.WriteLine();

                Task t1 = new Task(getup);
                t1.Start();     //new出来的task还需要显式调用Start()才开始运行
                Console.WriteLine("after: t1.Start()...");

                //t1.RunSynchronously();    //同步运行
                //Console.WriteLine("after: t1.RunSynchronously()...");

                //Task t1 = Task.Run(getup);    //得到开始运行的task对象
                //Console.WriteLine("after: Task.Run(getup)...");

                //Task t1 = Task.Factory.StartNew(getup);   //更多重载(控制)
                //Console.WriteLine("after: Task.Factory.StartNew(getup)...");

                //t1.Wait();      //确保t1任务完成
                //Console.WriteLine("t1.Wait()...");

                for (int j = 0; j < 5; j++)
                {
                    Show(t1);
                }

                Console.WriteLine();
            }

推荐顺序:Task.Run() =>Task.Factory.StartNew() =>new Task()

理解区别:

  • t1.Wait();
  • t1.RunSynchronously();

对于Task<T>而言,区别在于:

            Func<long> getup = () =>
            {
                //Thread.Sleep(100);  长时间Sleep且不获得Result,有可能导致主线程都结束了该Task还未运行
                Console.WriteLine($"getUp(): TaskId:{Task.CurrentId}, " +
                    $"ThreadId:{Thread.CurrentThread.ManagedThreadId}");
                return DateTime.Now.Ticks;
            };
                Task<long> t1 = new Task<long>(getup);
                Console.WriteLine("t1.Result:" + t1.Result);    //使用Result属性获得返回值
                Console.WriteLine("t1.Status:" + t1.Status);


在getUp()中对比:

                //long now = DateTime.Now.Ticks;
                //Console.WriteLine(now);
                //return now;

                Console.WriteLine(DateTime.Now.Ticks);
                return DateTime.Now.Ticks;
理解:t1.Result会实际运行return之后的代码



作业:

重构之前的验证码作业:

  1. 创建一个新的前台线程(Thread),在这个线程上运行生成随机字符串的代码
  2. 在一个任务(Task)中生成画布
  3. 使用生成的画布,用两个任务完成:
    • 在画布上添加干扰线条
    • 在画布上添加干扰点
  4. 将生成的验证码图片异步的存入文件
  5. 能捕获抛出的若干异常,并相应的处理
  6. 以上作业,需要在控制台输出线程和Task的Id,以演示异步并发的运行。

作业点评

  • 不要用大小写来区分方法
  • 理解字段的作用
  • 使用IList而不是List
  • 代码的书写习惯
  • 习惯:提交之前查看改动(对比)
    ---
  • 演示你最近的三次提交
作业点评
  • x,y => width/height
  • Factory 的构造函数不应该有参数(x,y)
  • 规划好类的“入口”“出口”(构造函数/访问修饰符)
  • 字段用于内部,属性用于外部
  • 一般在构造函数中给字段赋值,而不是直接赋值





想一想:如何使用这些方法返回的Task?有没有什么问题?(Status/Result/……





源栈培训 C# 进阶 异步 多线程
赞: 130 踩: 0

打赏
已收到打赏的 帮帮币

你的 打赏 非常重要!
为了保证文章的质量,每一篇文章的发布,都已经消耗了作者 1 枚 帮帮币
没有“帮帮币”,作者无法发布新的文章。

全系列阅读
评论 / 0

锋利的C#


C#语法基础

包括C#语法面向过程部分,即:变量赋值、分支循环和函数封装等,以及面向对象部分:即封装、继承、多态、值/引用类型、装箱拆箱等各部分

C#进阶

包括C#语言特有(或领先)的语言特性,比如泛型、Linq、Lambda、异步方法等,以及一些常用类库,如集合、I/O等

面向对象

C#语法中面向对象部分内容:即封装、继承、多态、值/引用类型、接口、抽象类、Object、装箱拆箱等……

全部
关键字



帮助

反馈