Bootstrap

C#异步多线程快速入门

C#异步多线程快速入门

前言:

多线程的写法不难,但能写好多线程却很难,本文作为多线程的快速入门篇,将讲解多线程的基本知识,C#多线程的启动方式以及多线程的安全问题。

一、多线程基本知识

1. 操作系统之 进程、线程、多线程
  • 进程:程序在计算机上运行时,占据计算机资源的合集。进程之间不会相互干扰,这也导致进程间的通信比较困难。
  • 线程:程序执行的最小单位,相应操作的最小执行流,每个操作都需要线程执行。线程属于进程,线程的资源来自进程,一个进程可以有多个线程。
  • 多线程:一个进程里面,有多个线程并发执行。
  • C#封装了Thread类,进行多线程操作,而其本质还是通过向操作系统系统请求执行流。
2. 异步多线程三大特点
  • 什么是同步单线程?
    按顺序执行命令,每行命令执行完成后才能执行下一命令,并且从始至终是同一线程运行。

  • 什么是异步多线程?
    异步命令发起后,不等待结束就直接执行下一行命令,而异步发起的动作由另一新的子线程完成,实现多个线程的并发。

  • 异步多线程的三大特点

  1. 同步单线程方法卡界面。因为界面操作只能有主线程操作,而单线程只有主线程在工作,主线程一直在忙于计算,所以不能操作界面。
    异步多线程方法不卡界面。因为计算任务交给子线程去处理了,主线程已经空下来,可以去相应界面的操作。
  2. 同步单线程方法耗时长。因为只有一个线程计算。
    异步多线程方法耗时短。因为多个线程同时计算,所以计算速度更快。
    但一切都是守恒的,多线程就是用资源换性能,通过更多的资源去换取更短的计算时间,提高性能。经典的同步单线程与异步多线程的资源利用对比图如下。但资源与性能的关系并不是线程增长的,如一个线程耗时a秒,5个线程不一定耗时a/5秒,会比a/5秒大一些,因为多线程需要对多个线程进行协调,这需要成本;同时资源也是有上限的,这就好比有5辆车,但是只有3条道,等等
    在这里插入图片描述
    3.多线程具有无序性,即程序的执行顺序具有不可预测性
    因为,首先,每个线程的启动是无序的:几乎同一时间像操作系统请求线程,而请求线程的这个操作也是需要CPU处理,至于CPU先处理那个请求,具体是什么顺序,无法掌控;
    其次,执行时间不确定:即使是同一个线程同一个任务耗时也可能不同,这和操作系统的调度策略有关。因为CPU的计算能力非常,很多任务并不需要一秒钟就完成了,因此把CPU切片,即把1秒分隔为很多份,每份交替执行,这样一秒内宏观上就是并发了,哪份先执行任务就得看运气了。
    最后,因为启动无序,执行时间也不确定,故每个线程的结束也无序

二、C#多线程启动方式

这是最基本的启动方式,任何多线程都离不开委托,后续任何多线程都离不开委托。

1. 委托异步启动多线程
  • 启动示例
Action<String> a = dosomething;//Action是一个.NET FrameWork定义的委托
Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
a.BeginInvoke("异步启动方法", null, null);
Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);
 private static void dosomething(string s)
 {
   Console.WriteLine("dosomething({0}) start {1}", s,Thread.CurrentThread.ManagedThreadId);
   Thread.Sleep(3000);
   Console.WriteLine("dosomething({0}) end {1}", s, Thread.CurrentThread.ManagedThreadId);
  }

在这里插入图片描述

  • 等待异步完成后,再执行指定命令的方法

a. 通过BeginInvoke的回调方法实现保证异步方法执行完成之后再执行某些命令。

Action<String> a = dosomething;

AsyncCallback callback = ar=> {
    dosomething_after(ar.AsyncState.ToString());
};

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

a.BeginInvoke("异步启动方法",callback, "dosomething执行完成后,执行该方法");

Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);
 private static void dosomething_after(string s)
        {
            Console.WriteLine("dosomething_after start {0} {1}",s, Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(3000);
            Console.WriteLine("dosomething_after end {0} {1}",s, Thread.CurrentThread.ManagedThreadId);
        }

在这里插入图片描述
b. 通过BeginInvoke方法的返回状态,判断异步方法是否执行完成

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
IAsyncResult result = a.BeginInvoke("异步启动方法",null,null);
Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);
int temp = 0;
while (!result.IsCompleted)
{
     temp++;
}
dosomething_after("dosomething执行完成后,执行该方法");

在这里插入图片描述
c. 通过信号量 WaitOne 方法阻塞当前线程,保证异步线程执行完成,再做其它操作。

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
IAsyncResult result = a.BeginInvoke("异步启动方法", null, null);
Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);
result.AsyncWaitHandle.WaitOne();
dosomething_after("dosomething执行完成后,执行该方法");

在这里插入图片描述

2. Thread
  • 启动示例
Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

ThreadStart trdStart = dosomething;//ThreadStart 是一个委托
Thread trd = new Thread(trdStart);
trd.Start();

Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

Thread具有丰富的API,可以实现线程的启动(start),挂起(Suspend),重启(Resume),等待(Join),终止(Abort),重启(ResetAbort)等等。希望通过把这些API暴露给开发者,让其控制线程,但线程资源是操作系统管理的,响应并不灵敏,所以没那么好控制,这么多的API反而会造成开发者的滥用,导致线程出现各种问题。同时Thread启动线程是没有控制的,如果你启动1亿个线程,它真的会启动1亿个线程,这肯定会导致死机。

因此,Thread就像给一个小孩一把热武器,威力很大,但是很可能造成更大的破坏。

3. ThreadPool
  • 启动示例
 Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

            WaitCallback call = state => { //WaitCallback 是一个委托
                Console.WriteLine("dosomething start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething end {0}", Thread.CurrentThread.ManagedThreadId);
            };

            ThreadPool.QueueUserWorkItem(call);
            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
ThreadPool采用池化资源管理设计思想,线程是一种资源,之前每次要线程,就去申请一个线程,使用完成后释放;而池化就是做一个容器,容器提前申请N个线程(会动态调整),程序需要使用线程,直接去容器中获取,用完再放回容器,避免直接控制操作系统频繁的去申请和销毁线程,并且容器自己还会根据限制的数量去申请和释放,就不会导致无限制申请线程,导致死机。
但是,ThreadPool的API又太少了,程序等待顺序控制特别弱。

4. Paralel
  • 启动示例
 Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
            Parallel.Invoke(() =>
            {
                Console.WriteLine("dosomething1 start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething1 end {0}", Thread.CurrentThread.ManagedThreadId);
            },
            ()=>{
                Console.WriteLine("dosomething2 start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething2 end {0}", Thread.CurrentThread.ManagedThreadId);
            });
            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
Parallel基于池化思想实现,它可以启动多线程,并且主线程也参与计算,这会导致卡界面。Parallel.For和Parallel.ForEach方法可以方便启动循环的多线程,也可以通过ParallelOptions轻松控制最大并发数量。

5. Task
  • 启动示例
 Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
            Task task = new Task(() => {
                Console.WriteLine("dosomething1 start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething1 end {0}", Thread.CurrentThread.ManagedThreadId);
            });
            task.Start();
            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述

  • 等待所有Task完成后,接着执行其它计算。可以用Task.WaitAll(),但这会阻塞主线程,导致卡界面。TaskFactory提供的ContinueWhenAll方法不会导致卡界面的问题。
Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
            Task task = new Task(() => {
                Console.WriteLine("dosomething1 start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething1 end {0}", Thread.CurrentThread.ManagedThreadId);
            });
            task.Start();

            Task[] tasks = new Task[] { task };
            TaskFactory tf = new TaskFactory();
            tf.ContinueWhenAll(tasks, obj =>
            {
                Console.WriteLine("dosomething2 start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
                Console.WriteLine("dosomething2 end {0}", Thread.CurrentThread.ManagedThreadId);
            });

            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
.Net FrameWork多线程发展到Task已经很成熟,对于C#的多线程建议采用Task

三、多线程安全

  • 什么是多线程安全?
    一段代码,单线程执行和多线程执行结果不一致,就表明存在多线程安全问题。

  • 循环体中的多线程循环索引一直是最大索引的问题

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

            for (int i = 0; i < 6; i++)
            {
                Task.Run(() =>
                {
                    Console.WriteLine("compute{0} start {1}",i, Thread.CurrentThread.ManagedThreadId);
                    Thread.Sleep(500);
                    Console.WriteLine("compute{0} end {1}", i, Thread.CurrentThread.ManagedThreadId);
                });
            }

            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
我们发现多线程中每个线程的i都是6,这是为什么呢?
因为所有线程共用一个变量i,而主线程把遍历的语句(for (int i = 0; i < 6; i++))执行完成后,线程还没有启动完成,当线程启动后,i已经遍历到6了,所以,每个线程的i都是6;如何解决这个问题呢?只需要让每个线程有自己的i,而不是共用i即可。

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

            for (int i = 0; i < 6; i++)
            {
                int k = i;
                Task.Run(() =>
                {
                    Console.WriteLine("compute{0} start {1}",k, Thread.CurrentThread.ManagedThreadId);
                    Thread.Sleep(500);
                    Console.WriteLine("compute{0} end {1}", k, Thread.CurrentThread.ManagedThreadId);
                });
            }

            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
此时6个线程有6个k值,所以,不会导致k值都一样的问题。因此,也得出一个结论,多线程代码块中不要出现公共变量,这样每个线程操作一个不同的变量内存地址,不会导致内存冲突或数据损坏。

  • 修改一个集合导致线程安全问题的分析
Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

            List<int> list = new List<int>();
            List<Task> lists = new List<Task>();
            for (int i = 0; i < 1000; i++)
            {
                Task task=Task.Run(() =>
                {
                    list.Add(i);
                });
                lists.Add(task);
            }
            TaskFactory tf = new TaskFactory();
            tf.ContinueWhenAll(lists.ToArray(),obj=> {
                Console.WriteLine("List count is {0}", list.Count);
            });

            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
我们发现list 中的元素个数并不是我们所期望的1000,而是少了很多,这是为什么呢?
因为List是数组结构,在内存上是连续的,假如在同一时刻,多个线程同时去操作同一块内存,就会导致数据覆盖问题,导致数据丢失,即造成list中的元素个数小于1000的情况。
如何解决该问题呢?可以通过锁的方式进行解决,下面进行介绍。

  • 通过锁解决集合修改的线程安全问题
    锁本质是锁住一块内存,保证一块内存只能有一个线程操作,其它线程要想操作,只能等锁释放后,才能操作。
    定义一个锁的标识形式如下:
 private static readonly object LOCK=new object();

 private关键字用于保证一个锁不会被其它类的引用。如果是public的锁,该锁可以被其它类引用,导致两个类中的多线程共用同一个锁内存,使得两个类中的多线程不能并发。
 static关键字保证在该类中定义的锁在实例化之前就已完成,如果不是static,那么每次类的实例化都会生成一个不同的锁内存,导致不同实例化类中调用的锁模块并发。
 引用类型选择object,但不能选择string,因为string在堆中是享元的,即string s1=”sun”,与string s2=”sun”是同一块内存。如果用string,可能会导致不能并发。
 注意:泛型类,在类型参数相同时,是同一个类;在类型参数不同时,是不同的类。

Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);

            List<int> list = new List<int>();
            List<Task> lists = new List<Task>();
            for (int i = 0; i < 1000; i++)
            {
                Task task=Task.Run(() =>
                {
                    lock (LOCK)
                    {
                        list.Add(i);
                    }
                });
                lists.Add(task);
            }
            TaskFactory tf = new TaskFactory();
            tf.ContinueWhenAll(lists.ToArray(),obj=> {
                Console.WriteLine("List count is {0}", list.Count);
            });

            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);

在这里插入图片描述
加锁以后,列表的元素个数就正确了。

  • await,async关键字
    await,async关键字可以实现用同步的方式编写异步执行代码。
    编写示例:
 static void Main(string[] args)
        {
            
            Console.WriteLine("main start {0}", Thread.CurrentThread.ManagedThreadId);
            test1();
            Console.WriteLine("main end {0}", Thread.CurrentThread.ManagedThreadId);
            
            Console.ReadKey();
        }
private static async void test1()
        {
            await Task.Run(() =>
            {
                Console.WriteLine("compute start {0}", Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(500);
                Console.WriteLine("compute end {0}", Thread.CurrentThread.ManagedThreadId);
            });

            Console.WriteLine("computeafter start {0}", Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(500);
            Console.WriteLine("computeafter end {0}", Thread.CurrentThread.ManagedThreadId);
        }

在这里插入图片描述

await后面的代码被包装成一个回调,等待await上面的代码执行完成,再执行await后面的代码,但回调用的线程并不一定是当前线程。即await可以理解为首先释放当前线程的调用线程,调用线程回去做自己未做完的任务,然后等到当前线程完成任务,再回调去做await后面的任务。

;