.NET 多线程开发总结(二)——Thread、ThreadPool、Task、Parallel的简单使用

一、线程对象Thread的创建

我们可以通过System.Thread类来创建线程对象,通常使用的构造函数有如下两种:

public Thread(ThreadStart start);					//线程开始后执行的方法为无参方法
public Thread(ParameterizedThreadStart start);		//线程开始后执行的方法为有参方法

关于该类有一个属性需要特殊说明:IsBackground
它指示当前线程为前台还是后台线程。对于应用程序而言,必须等待所有的前台线程执行完毕,才可以退出;而后台线程则不会阻止进程的退出。

此外,关于Thread最多可以创建多少个的问题,它受系统及栈空间的限制。如:
32位系统,单个线程占用1M空间,一个进程最多有2G空间,那么理论上可以创建2048个线程;
64位系统则不受限制,关于这一点我也做了尝试,图证如下:
任务管理器监控图
(可以看出已经超过2048个线程了,不过在大约创建了2700个线程的时候,程序就自然崩了。电脑表示伤不起,伤不起=。=!)

关于Thread的其他一些属性,如获取当前线程Id、Name、State等,比较简单就不做赘述,可查阅官方文档
MSDN_Thread类

二、线程池ThreadPool的使用

当我们在程序中需要频繁创建线程来执行任务时,每次都new一个新的Thread对象则显得很不爽,且对资源的消耗也是很大的。由此一来,我们可以使用线程池来协助我们创建和管理线程。
特别说明:线程池中创建的线程都是后台线程。

线程池就是存放Thread的大池子,我们需要用到多线程时,只要把过程封装成一个方法丢进去就好了,系统会自动分配当前处于空闲状态的线程来执行该方法,如果没有空闲线程,则会在队列中等待。这一动作,通过ThreadPool.QueueUserWorkItem() 方法来实现。该方法的简单示例如下:

public static void MainProcess()
{
    Console.WriteLine($"主线程ID:{Thread.CurrentThread.ManagedThreadId}");
    ThreadPool.QueueUserWorkItem(new WaitCallback(Task), 233);
    Console.ReadKey();
}
public static void Task(object obj)
{
    Console.WriteLine($"当前线程ID:{Thread.CurrentThread.ManagedThreadId},传入参数为:{(obj == null? "null": obj.ToString())}");
}

运行结果如下:
在这里插入图片描述
下面来说一说线程池对线程的管控方法。不过在此之前,先要了解一个概念。
我们常用的线程分为两种:一种是工作线程(WorkThread),也称辅助线程,用来执行一些计算性的任务,也是最常用的;另一种是I/O线程(CompletionPortThreads),专门用于执行异步I/O操作,如文件读写和网络通讯。这一节主要以工作线程为例进行各项说明。

线程池有三个参数是我们需要了解的,使用得当可以极大提高我们的资源利用率和执行效率。
1)最小空闲线程数:
我想这个参数结合实例演示能说得更清楚:

static void Main(string[] args)
{
	ThreadPool.SetMinThreads(8, 8);
	for (int i = 1; i <= 20; i++)
	{
	    ThreadPool.QueueUserWorkItem(Auto, i);
	}
}

static void Auto(object id)
{
	// 为了输出行数按顺序执行,所以加了个锁
    lock (m_LockObject)
    {
        Console.WriteLine($"线程{id.ToString().PadLeft(2, '0')}开启".AppendTime());
    }
    Thread.Sleep(3000);
}

执行结果:
在这里插入图片描述

由上图可以看出,前8个线程几乎同时执行了方法,也就是说它们是提前预留好的空闲线程,只要有任务分配便会立即响应。设置这个参数要注意,不得小于CPU内核数,也就是说至少给每个CPU分配一个空闲线程,以防止有人光吃饭不干活 = =

2)最大线程数
这一数值决定了可以创建的线程数量上限,很好理解,Pass!

3)当前可用线程数
它=最大线程数-当前活跃线程数,于是我们也很好得出当前活跃线程数。通过判断活跃线程数是否为0,可以得知线程池中所有的线程任务是否执行结束。

三、基于任务(Task)的异步编程

无论是Thread还是ThreadPool,都无法同时满足广大码友既想精准控制每个线程的起承转合,又不想操心线程管理的这种高(tōu)贵(lǎn)需求,于是出现了Task这种堪称ThreadPool的无敌强化版本。
下面来介绍Task的几个强大之处:
1、使用Task的任务类型,可以创建如同Thread的独立线程
在下面的示例当中,通过设置任务类型TaskCreationOptions为LongRunning,实现了创建一个不依赖于线程池的独立线程。

static void Main(string[] args)
{
	ThreadPool.SetMaxThreads(8, 8);
    Task.Factory.StartNew((o) => { Tasks(o); }, 1, TaskCreationOptions.LongRunning);
    Task.Factory.StartNew((o) => { Tasks(o); }, 2, TaskCreationOptions.PreferFairness);
	System.Threading.Thread.Sleep(500);
    GetActiveThreads();
	Console.ReadKey();
}

public static void Tasks(object obj)
{
    Console.WriteLine($"当前线程ID:{Thread.CurrentThread.ManagedThreadId},传入参数为:{(obj == null ? "null" : obj.ToString())}");
    Thread.Sleep(10000);
}

public static void GetActiveThreads()
{
    int work, io;
    int maxwork, maxio;
    ThreadPool.GetAvailableThreads(out work, out io);
    ThreadPool.GetMaxThreads(out maxwork, out maxio);
    Console.WriteLine($"当前活跃工作线程数:{maxwork - work}");
}

输出结果:
在这里插入图片描述
可以看出尽管用线程工厂创建了两个线程任务,但只有一个活跃线程在线程池中。

2、Task的令牌机制
关于Task中的令牌机制,我想用一个故事来说明会比较清楚。
故事中的主角,我们姑且称他为小王吧。这天,小王接到老板的一个任务,这个任务可能比较棘手,要花费较长的时间才能完成。于是老板让小王专心做,不要考虑其他任何事情。大概快到下班时间了,小王还没有回来,老板有点急,就指派小刘去告诉小王,不管做没做完,你都可以回来了。但是为了小王能相信小刘的话,就给了他一个令牌,小王看到这个令牌,就需要立即停止手里的工作,回来向老板汇报。
下面我们来分析一下,故事中的小王,是被老板(MainThread)分配了一个任务(Task)。
这个任务比较耗时,没有办法立即返回结果,但是老板想提前知道结果(Result)。于是指派小刘为钦差(CancellationTokenSource),并给了他一块令牌(CancellationToken),去告诉小王,别干了(Cancel),赶紧回去报告。在这个事件中,我们可以推断,小王的汇报结果应该有以下几种情况:
1)干完了,自己结束的
2)没干完,老板你不让干的
3)碰壁了,干不下去了
我想通过以上事例,应该就能大致明白令牌在Task使用过程中的作用了。OK,接下来就用实际代码来演示上述三种情况:

①小王自己干完的,回去报告

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    Task<int> task = new Task<int>(() => Tasks(cts.Token), cts.Token);
    Console.WriteLine("小王开始任务");
    task.Start();
    Console.WriteLine("小王汇报结果为:" + task.Result);
    Console.WriteLine("任务最终状态为:IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);
    Console.ReadKey();
}

// 为了让小王有东西可以汇报,因此使用有返回值的回调函数
private static int Tasks(CancellationToken ct)
{
    int result = 0;
    Thread.Sleep(1000);
    result++;
    return result;
}

在这里插入图片描述
以上结果反映,任务顺利结束,IsCompleted为True,IsCanceled和IsFaulted均为False。

②小王没做完,老板不让做了

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    Task<int> task = new Task<int>(() => Tasks(cts.Token), cts.Token);
    Console.WriteLine("小王开始任务");
    task.Start();
    Thread.Sleep(500);
    Console.WriteLine("按下回车让小王结束任务");
    Console.ReadKey();
    cts.Cancel();
    try
    {
        Console.WriteLine("小王汇报结果为:" + task.Result);
    }
    catch (AggregateException ex)
    {
        if (ex.InnerException is OperationCanceledException)
        {
            Console.WriteLine("老板取消了小王的任务!");
        }
        else
        {
            Console.WriteLine("小王无法完成当前任务!");
        }
    }
    Console.WriteLine("任务最终状态为:IsCanceled={0}  IsCompleted={1}  IsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);
    Console.ReadKey();
}

private static int Tasks(CancellationToken ct)
{
    int result = 0;
    while (true)
    {
        ct.ThrowIfCancellationRequested();
        Thread.Sleep(1000);
        result++;
    }
    return result;
}

在这里插入图片描述
从任务的最终状态可以看出,由于是老板主动请求取消小王的任务,所以IsCanceled会标记为True,这就是系统对此中止类型的特殊处理。

在这段代码中,用到了一个方法是ThrowIfCancellationRequested,它的作用是当令牌所有者发出取消任务请求时,抛出异常OperationCanceledException,当试图获取Result时,也会引发相同异常。
我们还可以用以下写法:

// 当判断取消请求为True时,才执行ThrowIfCancellationRequested方法
if (ct.IsCancellationRequested)
{
    ct.ThrowIfCancellationRequested();
}

③小王自己遇到了问题,觉得任务做不下去了,需要中止

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    Task<int> task = new Task<int>(() => Tasks(cts.Token), cts.Token);
    Console.WriteLine("小王开始任务");
    task.Start();
    Thread.Sleep(500);
    Console.WriteLine("等待小王汇报任务");
    try
    {
        Console.WriteLine("小王汇报结果为:" + task.Result);
    }
    catch (AggregateException ex)
    {
        if (ex.InnerException is OperationCanceledException)
        {
            Console.WriteLine("老板取消了小王的任务!");
        }
        else
        {
            Console.WriteLine("小王无法完成当前任务!");
        }
    }
    Console.WriteLine("任务最终状态为:IsCanceled={0}  IsCompleted={1}  IsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);
    Console.ReadKey();
}

private static int Tasks(CancellationToken ct)
{
    int result = 0;
    Thread.Sleep(1000);
    throw new Exception();
    return result;
}

在这里插入图片描述
可以看出任务执行过程抛出了其他错误,导致任务中止,此时IsFaulted标记为True。

*这里有一个问题,假如收到了取消任务请求,但是依然是以抛出异常形式中止了任务,会怎么样呢?将代码修改如下:

static void Main(string[] args)
        {
            CancellationTokenSource cts = new CancellationTokenSource();
            Task<int> task = new Task<int>(() => Tasks(cts.Token), cts.Token);
            Console.WriteLine("小王开始任务");
            task.Start();
            Thread.Sleep(500);
            Console.WriteLine("等待小王汇报任务");
            Console.ReadKey();
            cts.Cancel();
            try
            {
                Console.WriteLine("小王汇报结果为:" + task.Result);
            }
            catch (AggregateException ex)
            {
                if (ex.InnerException is OperationCanceledException)
                {
                    Console.WriteLine("老板取消了小王的任务!");
                }
                else
                {
                    Console.WriteLine("小王无法完成当前任务!");
                }
            }
            catch { Console.WriteLine("err"); }
            Console.WriteLine("任务最终状态为:IsCanceled={0}  IsCompleted={1}  IsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);
            Console.ReadKey();
        }

        private static int Tasks(CancellationToken ct)
        {
            int result = 0;
            while (true)
            {
                if (ct.IsCancellationRequested)
                {
                    throw new OperationCanceledException();
                }
                Thread.Sleep(1000);
            }
            return result;
        }

在这里插入图片描述
这里可以看出,任务是主动申请取消的,但是由于小王没有使用ThrowIfCancellationRequested方法抛出异常,而是选择throw的方式抛出异常,任务最终结果被判为IsFaulted。所以说,要想以IsCanceled方式结束任务,必须经过ThrowIfCancellationRequested方法。

3、Task的异步等待机制
在.net Framework 4.5以后,微软提供了async+await语法糖,目的是希望能够在执行某一耗时较长的任务时,仍能做些其他的事情。这一机制运用在UI交互时则显得非常好用。
这里也创建一个窗体项目来作为演示:
1)窗体界面如下
窗体界面
2)核心后台代码如下

private async void button1_Click(object sender, EventArgs e)
{
    textBox1.AppendText("Button1按下了\r\n");
    textBox1.AppendText("执行一个耗时任务\r\n");
    await Task.Run(Tasks);
    textBox1.AppendText("耗时任务执行完毕\r\n");
}

private void Tasks()
{
    Thread.Sleep(3000);
}

private void button2_Click(object sender, EventArgs e)
{
    textBox1.AppendText("Button2按下了\r\n");
}

下面执行操作如下:
①点击button1按钮
②3秒内点击button2按钮(保证在Tasks任务执行结束前)
在这里插入图片描述
结果显示,button1的操作并不会阻塞UI线程,button2可以正常操作。这就是async+await语法糖的强大魅力。

四、并行任务(Parallel)

Parallel的出现,是为了更好地支持并行循环任务的创建和执行。说白了,就是为了更省事。
假如我们用Task来循环创建任务,可能是下面这样:

for (int i = 0; i < 10; i++)
{
    Task.Factory.StartNew((o) => { Console.WriteLine($"当前任务id:{o}"); }, i);
}

而用了Parallel,能更加简洁:

Parallel.For(0, 10, (i) => Console.WriteLine($"当前任务id:{i}"));

如果需要把数据源作为迭代变量,则可以这样:

List<int> source = new List<int> { 2, 4, 6, 8, 10 };
Parallel.ForEach(source, (i) => Console.WriteLine($"当前任务id:{i}"));

当然,Parallel的强大之处还不止于此,配合Linq语法,能够使得数据的查询更加高效,并且代码更加简洁,可读性更高。
下面我们来对Linq单线程查询和多线程查询做一个对比:

static void Main(string[] args)
{
	List<int> source = new List<int> { 1, 2, 3, 4, 5 };
    Stopwatch sw = new Stopwatch();
    sw.Start();
    Console.WriteLine("开始单线程查询");
    var src = source.Where(s =>
    {
        Thread.Sleep(2000);
        return true;
    }).ToList();
    Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}ms");
    sw.Restart();
    Console.WriteLine("开始多线程查询");
    src = source.AsParallel().Where(s =>
    {
        Thread.Sleep(2000);
        return true;
    }).ToList();
    Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}ms");
    Console.ReadKey();
}

在这里插入图片描述
通过模拟每条查询需要2s的耗时,可以看出并行查询提供了有效的多线程执行方式,使得总查询时间不再完全受查询条数的影响。
需要注意的是,当执行条数过少且单条执行速度很快时,不建议使用并行查询方式,因为线程调度所消耗的时间会远远超过执行时间
如下对比实验可以很好得说明这个问题:
实验设计是从1W条Guid字符串集合中匹配包含"ac"的部分。
先是不做任何处理的结果:

static void Main(string[] args)
{
	List<string> guidList = new List<string>();
    for (int i = 0; i < 10000; i++)
    {
        guidList.Add(Guid.NewGuid().ToString());
    }
    Stopwatch sw = new Stopwatch();
    sw.Start();
    Console.WriteLine("Normal");
    var src1 = guidList.Where(s =>
    {
        //Thread.Sleep(1);
        return s.Contains("ac");
    }).ToList();
    Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}ms");
    sw.Restart();
    Console.WriteLine("Parallel");
    var src2 = guidList.AsParallel().Where(s =>
    {
        //Thread.Sleep(1);
        return s.Contains("ac");
    }).ToList();
    Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}ms");
    Console.ReadKey();
}

在这里插入图片描述

可以看到并行执行结果远超过正常执行所消耗的时间。接着将Thread.Sleep(1)代码取消注释,模拟一个耗时操作,执行结果如下:
在这里插入图片描述
这时Parallel的优势就体现出来了+_+

上一篇:.NET 多线程开发总结(一)——并行、并发、异步、同步的概念区分
下一篇:.NET 多线程开发总结(三)——线程间的信号传递(线程交互)

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值