C#并发和异步编程

并发和异步

很多场景都需要用到并发,比如编写快速响应的用户界面,需要并发执行耗时任务以保证用户界面的响应性。通过并行编程,将负载划分到多个核心上,那么多核、处理器计算机就可以提升密集计算代码的执行速度。

程序同时执行代码的机制称为多线程(multithreading)。CLR和操作系统都支持多线程,它是并发的基础概念。因此,要介绍并发编程,首先就要具备线程的基础知识,特别是线程的共享状态。

线程

每一个线程都运行在一个操作系统进程中。这个进程提供了程序执行的独立环境。在单线程(single threaded)程序中,进程中只有一个线程运行,因此线程可以独立使用进程环境。而在多线程程序中,一个进程中会运行多个线程。它们共享同一个执行环境(特别是内存)。这在一定程度上说明了多线程的作用。例如,可以使用一个线程在后台获得数据,同时使用另一个线程显示所获得的数据。而这些数据就是所谓的共享状态(shared state)

创建线程

要创建并启动一个线程,需要首先实例化Thread对象并调用Start方法。Thread的最简单的构造器接收一个ThreadStart委托:一个无参数的方法,表示执行的起始位置。例如:

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

namespace 并发和异步
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(WriteY);
            t.Start();
            for (int i = 0; i < 1000; i++)
            {
                Console.Write("X"); 
            }
        }

        static void WriteY()
        {
            for (int i = 0; i < 1000; i++)
            {
                Console.Write("Y");
            }
        }
    }
}

主线程会创建一个新的线程t,而新的线程会执行方法重复的输出字符y。同时,主线程也会重复的输出字符x。

在单核计算机上,操作系统会为每一个线程划分时间片(Windows系统的典型值为20毫秒)来模拟并发执行。因此上述代码会出现连续的x和y。而在一个多核心的机器上,两个线程可以并行执行(会和机器上其他执行的进程进行竞争),因此虽然我们还是会得到连续的x和y,但这却是由于Console处理并发请求的机制导致的。

线程一旦启动,其IsAlive属性就会返回true,直至线程停止。当Thread的构造函数接收的委托执行完毕之后,线程就会停止。线程停止之后就无法再启动了。

静态属性Thread.CurrentThread将返回当前正在执行的线程:

Console.WriteLine(Thread.CurrentThread.Name);

汇合与休眠

调用Thread的Join方法可以等待线程结束:

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

namespace 并发和异步
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(Go);
            t.Start();
            t.Join();
            Console.WriteLine("Thread t has ended!");
        }

        static void Go()
        {
            for (int i = 0; i < 1000; i++)
            {
                Console.Write("y");
            }
        }
    }
}

Thread.Sleep方法将当前线程的执行暂停指定的时间:

Thread.Sleep(TimeSpan.FromHours(1));
Thread.Sleep(500);

Thread.Sleep(0)将会导致线程立即放弃自己的时间片,自觉地将CPU交于其他的线程。Thread.Yield()执行相同的操作,但是它仅仅会将资源交给同一个处理器上运行的线程。

在等待线程Sleep或者Join的过程中,线程是阻塞(blocked)的。

阻塞

使用ThreadState属性可以测试线程的阻塞状态:

ThreadState是一个标志枚举类型。它由“三层”二进制位组成。然而,其中的大多数值都是冗余、无用或者废弃的。以下的扩展方法将ThreadState限定为以下四个有用的值之一:Unstarted、Running、WaitSleepJoin、Stopped:

I/O密集和计算密集

如果一个操作的绝大部分时间都在等待事件的发生,则称为I/O密集,例如下载网页或者调用Console.ReadLine。(I/O密集操作一般都会涉及输入或者输出,但是这并非硬性要求。例如Thread.Sleep也是一种I/O密集的操作)。而相反的,如果操作的大部分时间都用于执行大量的CPU操作,则称为计算密集。

阻塞与自旋

I/O密集操作主要表现为以下两种形式:要么在当前线程同步进行等待,直至操作完成(例如Console.ReadLine、Thread.Sleep以及Thread.Join);要么异步进行操作,在操作完成的时候或者之后某个时刻触发回调函数(之后将详细介绍)。

本地状态与共享状态

CLR为每一个线程分配了独立的内存栈,从而保证了局部变量的隔离。下面的示例定义了一个拥有局部变量的方法,并同时在主线程和新创建的线程中调用该方法:

static void Main(string[] args)
{
    new Thread(Go_).Start(); //Call Go() on a new thread
    Go_(); //Call Go() on the main thead

}

static void Go_()
{
    for (int cycles = 0; cycles < 5; cycles++)
    {
        Console.Write('?');
    }
}

由于每一个线程的内存栈上都会有一个独立的cycles变量的副本,因此我们可以预测,程序的输出将是10个问号。如果不同的线程拥有同一个对象的引用,则这些线程之间就共享了数据:

class ThreadTest
{
    bool _done;

    static void Main()
    {
        ThreadTest tt = new ThreadTest();
        new Thread(tt.Go).Start();
    }

    void Go()
    {
        if (!_done) { _done = true; Console.WriteLine("Done"); }
    }
}

由于两个线程均在同一个ThreadTest实例上调用了Go()方法,因此它们共享_done字段。因此,“Done”只会打印一次,而非两次。

编译器会将Lambda表达式捕获的局部变量或匿名委托转换为字段,因此它们也可以被共享:

class ThreadTest
{
    static void Main()
    {
        bool _done = false;
        ThreadStart action = () =>
        {
            if (!_done) { _done = true; Console.WriteLine("Done"); }
        };
        new Thread(action).Start();
        action();
    }
}

静态字段提供了另一种在线程之间共享变量的方法:

class ThreadTest
{
    static bool _done;
    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }
    static void Go()
    {
        if (!_done) { _done = true; Console.WriteLine("Done"); }
    }
}

锁与线程安全

在读写共享字段时首先获得一个排它锁防止共享字段同时被多个线程使用。使用C#的lock语句就可以实现这个目标:

class ThreadSafe
{
    static bool _done;
    static readonly object _locker = new object();
    static void Main()
    {
        new Thread(Go).Start();
        Go();
        Console.ReadKey();
    }

    static void Go()
    {
        lock (_locker)
        {
            if (!_done)
            {
                Console.WriteLine("Done");
                _done = true;
            }
        }
    }
}

当两个线程同时竞争一个锁时(它可以是任意引用类型的对象,这里是_locker),一个线程会进行等待(阻塞),直到锁被释放。保证了一次只有一个线程能够进入这个代码块。

锁并非解决线程安全的万能钥匙,锁本身也存在一些问题,可能出现死锁的情况。

向线程传递数据

有时需要给线程的启动方法传递参数。最简单的方案是使用Lambda表达式,并在其中使用指定参数调用相应的方法。

class ThreadTest
{
    static void Main()
    {
        Thread t = new Thread(() => Print("Hello from t"));
        t.Start();
        Console.ReadKey();
    }

    static void Print(string message) { Console.WriteLine(message); }
}

Lambda表达式和变量捕获

Lambda表达式是向线程传递参数的最方便的形式之一。但需要小心,在线程开始后不要意外地修改捕获变量的值。例如,考虑如下的代码:

for (int i = 0; i < 10; i++)
{
    new Thread(() => Console.Write(i)).Start();
}

其输出是不确定的。例如,可能会得到以下的结果:

12378941055

变量i在整个循环的生命周期内引用的都是同一块内存位置。每一个线程都在使用一个可能在运行中随时改变的变量调用Console.Write方法。其解决方案是在循环体内使用临时的变量:

for (int i = 0; i < 10; i++)
{
    int temp = i;
    new Thread(() => Console.Write(temp)).Start();
}

这样,数字0到9都只会出现一次。(但是各个数字出现的顺序仍然是不确定的,因为线程的启动时间是不确定的。)

异常处理

线程执行和线程创建时所处的try/catch/finally语句块无关。假设有如下的程序:

static void Main(string[] args)
{
    try
    {
        new Thread(Go).Start();
    }
    catch (Exception)
    {
        // 永远不会运行下面这一行
        Console.WriteLine("Exception!") ;
    }
}

static void Go() { throw null; }

上面的try/catch语句是无效的。新创建的线程会被未处理的NullReference-Exception异常影响。

解决方法是将异常处理器移动到Go方法之内:

static void Go() {
    try
    {
        throw null;
    }
    catch (Exception)
    {

        throw;
    }
}

在产品环境中,应用程序的所有线程入口方法都需要添加一个异常处理器,就和主线程中一样(通常位于更高一级的执行栈中)。未处理的异常可能会导致整个应用程序崩溃,并弹出丑陋的错误对话框。

集中式异常处理

WPF、UWP和Windows Forms应用程序都支持订阅全局的异常处理事件。分别为Application.DispatcherUnhandledException以及Application.ThreadEx-ception。这些事件将会在程序的消息循环(相当于在Application激活时主线程上运行的所有代码)调用中发生未处理的异常时触发。这种方式非常适合于记录日志并报告应用程序的缺陷(但需要注意,它不会被非UI线程中发生的未处理异常触发)。处理这些事件可以防止应用程序直接关闭,但是为避免应用程序在出现未处理异常后继续执行造成潜在的状态损坏,因此通常需要重新启动应用程序。

AppDomain.CurrentDomain.UnhandledException事件会在任何线程出现未处理异常时触发。但是从CLR 2.0开始,CLR会在该事件处理器执行完毕之后强行关闭应用程序。然而可以通过在应用程序配置文件中添加如下的代码来防止应用程序关闭:

<configuration>
	<runtime>
    	<legacyUnhandleExecptionPolicy enabled="1"/>
    </runtime>
</configuration>

前台进程和后台进程

一般情况下,显式创建的线程称为前台线程(Foreground thread)。只要有一个前台线程还在运行,应用程序就仍然保持运行状态。而后台线程(backgroundthread)则不然。当所有前台线程结束时,应用程序就会停止,且所有运行的后台线程也会随之终止。

static void Main(string[] args)
{
    Thread worker = new Thread(() => Console.ReadLine());
    if (args.Length>0)
    {
        worker.IsBackground = true;
    }
    worker.Start();
}

如果应用程序调用时不带有任何参数,则工作线程会处于前台状态,并在ReadLine语句处等待用户的输入。主线程结束时,由于前台线程仍然在运行,因此应用程序会继续保持运行状态。如果应用程序启动时带有参数,则工作线程就会设置为后台状态,而应用程序也将在主线程结束时退出,从而终止ReadLine的执行。

线程的优先级

线程的Priority属性可以决定相对于其他线程,当前线程在操作系统中执行时间的长短。具体的优先级包括:

public enum ThreadPriority
{
    Lowest,
    BelowNormal,
    Normal,
    AboveNormal,
    Highest
}

默认是Normal优先级。

信号发送

有时一个线程需要等待来自其他线程的通知,即所谓的信号发送(signaling)。最简单的信号发送结构是ManualResetEvent。调用ManualResetEventWaitOne方法可以阻塞当前线程,直到其他线程调用了Set“打开”了信号。以下的示例启动了一个线程,并等待ManualResetEvent。它会阻塞两秒钟,直至主线程发送信号为止:

var signal = new ManualResetEvent(false);

new Thread(() =>
	{
        Console.WriteLine("等待信号...");
        signal.WaitOne();
        signal.Dispose();
        Console.WriteLine("收到信号了!");
    }).Start();
Thread.Sleep(2000);
signal.Set(); //打开信号

在Set调用后,信号发送结构仍然会保持“打开”状态,可以调用Reset方法再次将其“关闭”。

富客户端应用程序的线程

在WPF、UWP和Windows Forms应用程序中,在主线程上执行长时间的操作将导致应用程序失去响应。这是因为主线程同时也是处理消息循环的线程,它会根据键盘和鼠标事件来执行相应的渲染工作。

  • 在WPF中,调用元素上的Dispatcher对象的BeginInvokeInvoke方法。
  • 在UWP应用中,可以调用Dispatcher对象的RunAsyncInvoke方法。
  • Windows Forms应用中:调用控件的BeginInvokeInvoke方法。

所有这些方法都接收一个委托来引用实际执行的方法。

private void nosys_btn_Click(object sender, RoutedEventArgs e)
{
    this.result1.Content = "正在计算请稍等...";

    new Thread(Work).Start();

}
void Work()
{
    Thread.Sleep(3000);
    Action action = () => this.result1.Content = ExecuteTask1(100);
    Dispatcher.BeginInvoke(action);
}

任务

线程是创建并发的底层工具,因此它有一定的局限性。特别是:

  • 虽然在线程启动时不难向其中传递数据,但是当线程Join后却难以从中得到“返回值”。通常不得不创建一些共享字段(来得到“返回值”)。此外,捕获和处理线程中操作抛出的异常也是非常麻烦的。
  • 在线程完成之后,就无法再次启动它,相反只能够将其Join(并阻塞当前操作线程)。

Task类型可以解决所有这些问题。与线程相比,Task是一个更高级的抽象概念,它代表了一个并发操作,而该操作并不一定依赖线程来完成。Task是可以组合(compositional)的(你可以将它们通过延续(continuation)操作串联在一起)。它们可以使用线程池减少启动延迟,也可以通过TaskCompletionSource采用回调的方式避免多个线程同时等待I/O密集型操作。

启动任务

.NET Framework 4.5开始,启动一个基于线程Task的最简单方式是使用Task.Run(Task类位于System.Threading.Tasks命名空间下)静态方法。调用时只需传入一个Action委托:

Task.Run(() => Console.WriteLine("任务启动..."));

Task默认使用线程池中的线程,它们都是后台线程。这意味着当主线程结束时,所有的任务也会随之停止。因此,要在控制台应用程序中运行这些例子,必须在启动任务之后阻塞主线程(例如在任务对象上调用Wait,或者调用Console.ReadLine()方法)

static void Main(string[] args)
{
    Task.Run(() => Console.WriteLine("任务启动..."));
    Console.ReadLine();
}

Wait方法

调用Task的Wait方法可以阻塞当前方法,直到任务完成,这和调用线程对象的Join方法类似:

Task task = Task.Run(() =>
	{
        Thread.Sleep(2000);
        Console.WriteLine("任务启动...");
	});
Console.WriteLine(task.IsCompleted);
task.Wait(); //阻塞直到任务完成

长任务

默认CLR会将任务运行在线程池线程上,如果要执行长时间阻塞的操作,应该避免使用线程池进程。

Task task = Task.Factory.StartNew(() =>
{
     Console.WriteLine("这里执行的是一个长任务");
}, TaskCreationOptions.LongRunning);

线程池上并行运行多个长时间任务,会对性能造成影响。相比较使用TaskCreationOptions.LongRunning而言,跟好的方案:

  • 如果运行的是I/O密集型任务,则使用TaskCompletionSource和异步函数(asynchronous functions)通过回调函数而非使用线程实现并发性。
  • 如果任务是计算密集型,则使用生产者/消费者队列可以控制这些任务造成的并发数量,避免出现线程和进程饥饿的问题。

Task的返回值

Task有一个泛型子类Task,它允许任务返回一个值。如果在调用Task. Run时传入一个Func委托(或者兼容的Lambda表达式)替代Action就可以获得一个Task对象:

Task<int> task = Task.Run(() =>
{
	Console.WriteLine("Task的返回值");
	return 666;
});

通过查询Result属性就可以获得任务的返回值。如果当前任务还没有执行完毕,则调用该属性会阻塞当前线程,直至任务结束。

Task<int> task = Task.Run(() =>
{
    Console.WriteLine("Task的返回值,5秒后输出结果");
    Thread.Sleep(5000);
    return 666;
});
int result = task.Result;
Console.WriteLine("结果为:{0}",result);

异常

任务可以方便地传播异常,和线程不同。如果任务中的代码抛出一个未处理异常(换言之,如果你的任务出错(fault)),那么调用Wait()或者访问Task的Result属性时,该异常就会被重新抛出:

Task task = Task.Run(() =>
{
    throw null;
});
try
{
    task.Wait();
}
catch (AggregateException aex)
{
    if (aex.InnerException is NullReferenceException)
        Console.WriteLine("Null!");
    else
        throw;
}

使用Task的IsFaulted和IsCanceled属性可以在不抛出异常的情况下检测出错的任务。如果这两个属性都返回了false则说明没有错误发生。如果IsCanceled为true,则说明任务抛出了OperationCanceledException;如果IsFaulted为true,则说明任务抛出了其他类型的异常,通过Exception属性可以了解该异常的信息。

延续

延续通常由一个回调方法实现,该方法会在任务完成之后继续执行操作。

Task<int> primeNumberTask = Task.Run(() =>
	Enumerable.Range(2,3000000).Count(n=>
	Enumerable.Range(2, (int)Math.Sqrt(n)-1).All(i=>n%i>0)));
var awaiter = primeNumberTask.GetAwaiter();
awaiter.OnCompleted(() =>
{
    int result = awaiter.GetResult();
    Console.WriteLine(result);
});

调用任务的GetAwaiter方法将返回一个awaiter对象。这个对象的OnCompleted方法告知先导(antecedent)任务(primeNumberTask)当它执行完毕(或者出现错误)时调用一个委托。

TaskCompletionSource类

TaskCompletionSource可以创建一个任务,但是这种任务并非那种需要执行启动操作并在随后停止的任务;而是在操作结束或出错时手动创建的“附属”任务。这非常适用于I/O密集型的工作。它不但可以利用任务所有的优点(能够传递返回值、异常或延续)而且不需要在操作执行期间阻塞线程。

TaskCompletionSource的用法很简单,直接进行实例化即可。它包含一个Task属性,返回一个Task对象。

var tcs = new TaskCompletionSource<int>();
new Thread(() =>
{
    Thread.Sleep(5000);
    tcs.SetResult(42);
})
{
    IsBackground = true
}.Start();

Task<int> task = tcs.Task;
Console.WriteLine(task.Result); //42

TaskCompletionSource的真正作用是创建一个不绑定线程的任务。例如,假设一个任务需要等待5秒钟,之后返回数字42。我们可以使用Timer类,由CLR(进而由操作系统)在x毫秒之后触发一个事件,而无须使用线程:

Task.Dealy方法

Task.Delay是Thread.Sleep的异步版本。

posted @ 2022-01-29 15:15  Apostle浩  阅读(681)  评论(0编辑  收藏  举报