极品分享

C# 多线程Task的使用及其异常处理的若干事项(一)

Task简述

微软在.NET Framework 4.0的时候引进了一个新的类:System.Thread.Task,用它来表示一个异步操作,比如从网上下载一个文件,或者一个比较耗时的文件写入动作,如果把这些操作放到UI线程里做,就引起用户界面失去响应,所以我们要另外开线程去做这些事情,开线程并不是什么新鲜事,只不过到了.NET Framework 4.0,微软用Task对它进行了一次再包装而已,这个再包装带来的直接好处当然是代码更加简单了,除此之外,还会有更好的性能,因为它的底层实现用了“线程池”,需要执行任务的时候,唤醒工作线程,否则让工作线程转入睡眠,这样就省去了创建线程的开销,而这一切对我们程序员来说可以是透明的,我们用就是了,微软相当于是把Thread抽象成了Task。

最简单的例子

好吧,先来一个最简单的例子。

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        Task taskA = Task.Factory.StartNew(() => DoSomeWork(2000));
        taskA.Wait(); //主线程阻塞在这里,直到A任务完成
        Console.WriteLine("taskA has completed.");
     }
    
    static void DoSomeWork(int val)
    {
         Thread.Sleep(val);//用Sleep来模拟一个很耗时的任务
    }
}

代码应该没有太多需要解释的地方,如果你对Lambda表达式不熟悉的话,看到“=>”这种符号可能会觉得有些奇怪,关于Lambda表达式,网上有很多文章,当然了,最推荐的还是微软官方的文章《Lambda表达式(C#编程指南)》,有空的话看看,其实也花不了多少时间的,如果没空,那我就在这里简单说说:它其实就是一个匿名函数。上面的代码可以写成这样:

static void ToDoTwoSecondsWork()
    {
        DoSomeWork(2000);
    }

    static void Main(string[] args)
    {
        Task taskA = Task.Factory.StartNew(ToDoTwoSecondsWork);
        taskA.Wait(); //主线程阻塞在这里,直到A任务完成
        Console.WriteLine("taskA has completed.");
    }

效果一样的,但这样写要多写一个叫“ToDoTwoSecondsWork”的函数,这样显式地写函数名出来也是有好处的,就是方便设断点调试。

提示:Wait方法可设置超时等待时间,时间一到,即便任务未完成,也会返回。

ContinueWith

在上面的例子中,用了Wait方法来等待任务结束,这种方法其实还是会阻塞主线程的,也就是说会带来UI失去响应,那这不符合我们的初衷啊,有什么办法?可以试试看这个:ContinueWith,ContinueWith并不阻塞主线程,主线程会继续往下走,而工作线程把任务完成后,会调用一个回调函数。

static void Main(string[] args)
    {
        Task taskA = Task.Factory.StartNew(() => DoSomeWork(2000));
        taskA.ContinueWith(t => Debug.WriteLine("taskA has completed."));
        Console.ReadKey();
    }

当任务A结束的时候,将会在Output窗口输出"taskA has completed."这个消息,为什么我不直接在控制台上输出呢?这是因为这个输出动作的执行线程不是主线程,虽然这是一个控制台程序,但我们从设计上来说也不应该直接用工作线程去操作UI,工作线程和UI通信的方式可以参考我另一篇博客:C# WPF 使用BeginInvoke开启新线程读取载入数据等耗时操作,避免界面卡顿的方法!


多个工作线程

上面的例子虽说也是“多线程”,但其实工作线程只有一个,我们有时候需要同时开启多个工作线程,如“多线程下载”就是一个典型的例子,另外做一些特别耗时的多媒体处理工作时,我们也往往会把一个处理拆分为几个部分,让它们同时执行,充分利用现在的CPU的多核的优势。下面看例子:

Task[] tasks = new Task[10];
for (int i = 0; i < 10; i++)
{
    tasks[i] = Task.Factory.StartNew(() => DoSomeWork(2000));
}
Task.WaitAll(tasks);
Console.WriteLine("All tasks have completed.");

我们一共创建了10个任务,也许你要问:这10个任务是同时执行的吗?回答是否定的,.NET framework会自动安排它们执行,如果你的电脑有4个核心,很可能CPU会同时安排4个任务去执行,总之你不能假定这些任务是同时执行的,所以在拆分任务的时候,最好任务和任务间彼此独立,不要一个任务等待另一个任务,否则容易导致死锁。

提示:如果你只需等待其中一个任务结束,你可以尝试Task.WaitAny。

获取结果

请看下面这个例子:

    static void Main(string[] args)
    {
        Task<int> taskA = Task<int>.Factory.StartNew(() => NumberSumWork(100));
        taskA.Wait();
        Console.WriteLine("The result is " + taskA.Result);
     }
    
     static int NumberSumWork(int iVal)
    {
        int iResult = 0;
        for (int i = 1; i <= iVal; i++)
        {
            Thread.Sleep(40);
            iResult += i;
        }
        return iResult;
    }

其实获取Result这个属性本身就包含了Wait,所以上面的代码也可以这样写:

static void Main(string[] args)
    {
        Task<int> taskA = Task<int>.Factory.StartNew(() => NumberSumWork(100));
        Console.WriteLine("The result is " + taskA.Result);
     }

如果用ContinueWith的话,还可以这样写:

taskA.ContinueWith(t => Debug.WriteLine(t.Result));

抛出异常

我相信上面的内容都很好理解,但说到异常,这该如何处理呢?

先说个故事:前阵子我在做一个程序,这个程序有一个功能,那就是到网上去检查最新版本,考虑到这个动作可能要消耗几秒钟的时间,我把它做成了非阻塞的形式,也就是用前面提到的那个“ContinueWith”,这样用户界面就可以一直不失去响应了,即使网络不通,那也是过了几秒钟之后,就会在界面上提醒用户说“获取最新版信息失败”。这个程序在我的电脑上跑没有任何问题,但拿到了同事的电脑上就出现了问题,当网络不通的时候,程序也会“获取最新版信息失败”,一旦有了这个失败失败,一做界面切换,程序就直接崩溃,在另一个同事那里也出现了类似的问题,但崩溃的时间点不太一样,我使用全局异常捕捉也捉不到这个异常,我经过大量调试,可在自己的电脑上就是无法重现这个问题,我确信这是由于使用了多线程引起的,但就是找不出原因,最后我把ContinueWith换掉,改成了阻塞的方式,就没这个问题了,但这样在执行更新检查的时候界面也就会短暂地失去响应。后面我会再去分析这个事情,现在回到正题。

先来看最简单的异常处理例子:

class MyException:Exception
{
    public MyException(string message)
        :base(message)
    {
    }
}

class Program
{
    static void Main(string[] args)
    {
        Task taskA = Task.Factory.StartNew(()=>DoSomeWork(2000));
        taskA.Wait();
        Console.WriteLine("taskA has completed.");
     }
    
    static void DoSomeWork(int val)
    {
        Thread.Sleep(val);
        throw new MyException("This is a test exception.");
    }
}

调试,出现异常了,执行点停留在Wait这个地方:

24111635-20b7ec4f5c494a4986389255b8bde5aa.png

异常类型为AggregateException:

24111649-bad6e06807f9458d86298923d71a4bfb.png


出错了,异常类型不是Exception,而是AggregateException,为什么呢?因为这是由别的线程产生的异常,并且可能不止一个异常,所以要用AggregateException这个类来充当一个异常的容器,工作线程产生的异常会放入这个容器中,最后由我们来处理这个容器。把上面的代码改一改,就能产生一个包含多个Exception的AggregateException。

class Program
{
    static void Main(string[] args)
    {
        Task[] tasks = new Task[2];
        tasks[0] = Task.Factory.StartNew(() => DoSomeWork(2000));
        tasks[1] = Task.Factory.StartNew(() => DoSomeWork(3000));
        Task.WaitAll(tasks);
        Console.WriteLine("Tasks have completed.");
     }
    
    static void DoSomeWork(int val)
    {
        Thread.Sleep(val);
        throw new MyException(string.Format("This is a test exception."+val));
    }
}

对于AggregateException的处理,可以这样做:

        try
        {
            Task.WaitAll(tasks);
        }
        catch (AggregateException ae)
        {
            ae.Handle(x =>
                {
                    if (x is MyException)
                    {
                        Console.WriteLine("异常已经处理:"+x.Message);
                        return true; //表示异常已经处理
                    }
                    return false; //表示异常未处理,继续抛出
                });
        }

结果显示为:
异常已经处理:This is a test exception.2000
异常已经处理:This is a test exception.3000

ContinueWith的异常

上文提到,ContinueWith是非阻塞的,try-catch它是没办法捕捉到AggregateException的,那这样的话Task产生的异常都到哪里去了?现在想想我前面讲的那个故事:我使用ContinueWith的时候,一旦网络连接错误,程序在同事的电脑上就会在某个时候(比如界面切换的时候)崩溃,而在我的电脑上则无法重现这样的问题。这样说来:ContinueWith所产生的这个异常(网络连接异常)在我的电脑上被莫名其妙地忽略了,而在同事的电脑上却被当作了未处理的异常。现在,我把我的那个程序简化为下面的代码(完整代码):

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

class TestException:Exception
{
    public TestException(string message)
        :base(message)
    {
    }
}

class Program
{
    static void Main(string[] args)
    {
        Task<int> taskA = Task<int>.Factory.StartNew(() => DoSomeWork(2000));
        taskA.ContinueWith(t => Debug.WriteLine("taskA has completed. result is " + taskA.Result));
          Console.ReadKey();
        GC.Collect();
        Console.ReadKey();
    }
     
    static int DoSomeWork(int val)
    {
        Thread.Sleep(val);
        throw new TestException("This is a test exception.");
        return val;
    }
}

运行这段代码,什么都不要动,等两秒钟,你会发现程序没有报任何异常,虽然你能在Visual Studio的Output窗口中看到“A first chance exception of...”这样的异常提示,但程序仍然是安然无恙的,在我的电脑上,再碰两下回车键,程序就退出了,没有任何错误提示,而在同事的电脑上,等了这两秒钟后一碰回车键,就出现了这样的异常提示:

24111924-00fcf7ad8fe5450c88cd916251bbffb4.png

这是怎么回事!这种问题自己是琢磨不出来的,只能到网上去寻找答案,请看这个链接:《Application Compatibility in the .NET Framework 4.5

其中有以下的内容:

  • Feature: Unobserved exceptions in Task operations

  • Change: Because the Task class represents an asynchronous operation, it catches all non-severe exceptions that occur during asynchronous processing. In the .NET Framework 4.5, if an exception is not observed and your code never waits on the task, the exception will no longer propagate on the finalizer thread and crash the process during garbage collection.

  • Impact: This change enhances the reliability of applications that use the Task class to perform unobserved asynchronous processing. The previous behavior can be restored by providing an appropriate handler for the TaskScheduler.UnobservedTaskException event.

对于Task异步操作中所产生的未被获取的异常的处理,.NET Framework 4.5和.NET Framework 4.0是不一样的,4.5会将它忽略掉,而4.0会很当真。这就是为什么我的程序在我的电脑上重现不出那个错误的原因,我安装了.NET Framework 4.5了,同事的电脑上还是4.0的。

也许你还有个问题:那为什么要加入“GC.Collect()”这么一个语句呢?你有没有注意到?你运行这个程序的时候如果不动任何键,它是不报异常的,动一下才报,这是因为这样的AggregateException是在执行垃圾回收的时候才会被抛出的,这就是为什么我那个程序在同事的电脑上是在“界面切换”的时候才崩溃,因为那个时候很可能(并不一定)会执行一次垃圾回收,所以崩溃的时间点看起来也并不那么固定,因为垃圾回收其实是由.NET Framework自动执行的,我们不应该去干预它。

那如果我们要处理这个“被忽略”的异常,应该怎么做呢?上面的例子改一下:

                           taskA.ContinueWith(t =>
                           {
                               if (!t.IsFaulted)
                               {
                                   Debug.WriteLine("taskA has completed. result is " + taskA.Result);
                               }
                               else
                               {
                                   Debug.WriteLine("发生异常:" + t.Exception);    //Log异常
                                   t.Exception.Handle(x => true);                  //并将异常标记为已处理
                               }
                           });

这样一来,在.NET Framework 4.0中也不会出现异常了,为了兼容.NET Framework 4.0,你得用这种写法。

2017-03-28 0 /
NET学习
/
标签: 

评论回复

回到顶部