线程Ⅱ

线程池
为什么要使用线程池?

创建和销毁线程是一个昂贵的操作,要耗费大量的时间。太多的线程还会浪费内存资源,由于操作系统必须调度可运行的线程并执行上下文切换,所以太多的线程还会影响性能,而线程池可以改善这些情况。

线程池是什么?

可以将线程池想象成为可以由应用程序使用的一个线程集合,每个CLR一个线程池,这个线程池由CLR控制的所有AppDomain共享。如果一个进程中加载了多个CLR,那么每个CLR都有自己的线程池。

线程池是如何工作的?

CLR初始化时,线程池中是没有线程的。在内部,线程池维护了一个操作请求队列,在应用程序想执行一个异步操作时,就调用一个方法,将一个记录项追加到线程池的队列中。线程池的代码从这个队列中提取记录项,将这个记录项派遣给一个线程池线程。此时如果线程池中没有线程,会创建一个新的线程(产生一定的性能开销)。当线程完成任务后,线程不会被销毁,而会回到线程池且进入空闲状态,等待响应下一个请求。由于线程不再销毁所以不再产生额外的性能损失。

如果应用程序向线程发出许多请求,线程池会尝试只用已有一个线程处理这些请求。如果发送请求的速度超过了线程池线程处理的速度,就会创建额外的线程来处理。

当应用程序停止向线程池发送求情,此时线程池中的线程什么都不做,造成内存资源的浪费。所以有一个机制:当线程池线程闲置一段时间后(不同版本的CLR对时间有所差异),线程会自动唤醒并终止自己释放资源。

特点

线程池可以容纳少量线程避免资源浪费,也可以创建大量线程充分利用多处理器、超线程处理器以及多核处理器。换句话说线程池是启发式的,如果应用程序要执行许多任务同时又有可用的CPU,那么线程池会创建更多的线程。

使用线程池异步编程
private static void SomeMethod(object state)
{
    //方法由线程池线程执行
    Console.WriteLine("state = {0}", state);
    Thread.Sleep(10000);

    //方法返回后线程回到线程池等待下一个请求
}

static void Main()
{
    ThreadPool.QueueUserWorkItem(SomeMethod, 1);
    Console.ReadKey();
}
执行上下文

每个线程都关联了一个执行上下文数据结构,包含有:安全设置、宿主设置以及逻辑调用上下文数据。正常情况下,每当一个线程(初始线程)使用另一个线程(辅助线程)执行任务时,前者的执行上下文应该复制到辅助线程,这样可以确保辅助线程的任何操作都是使用同样的安全设置和宿主设置,还能保证初始线程的逻辑调用上下文可以在辅助线程中使用。默认情况下初始线程的执行上下文可以流向任何辅助线程,但执行上下文中包含大量信息,这会对性能造成一定的影响。

ExecutionContext控制执行上下文
static void Main(string[] args)
{
    //将数据放入Main线程的逻辑调用上下文
    CallContext.LogicalSetData("Name", "DoubleJ");

    //线程池线程能访问逻辑调用上下文数据
    ThreadPool.QueueUserWorkItem(state => {
        Console.WriteLine("state = {0}, name = {1}", state, CallContext.LogicalGetData("Name"));
    }, 1);

    //阻止Main线程的执行上下文流动
    ExecutionContext.SuppressFlow();

    //线程池线程将无法访问逻辑调用上下文数据
    ThreadPool.QueueUserWorkItem(state => {
        Console.WriteLine("state = {0}, name = {1}", state, CallContext.LogicalGetData("Name"));
    }, 2);

    //恢复Main线程的执行上下文流动
    ExecutionContext.RestoreFlow();

    //线程池线程又可以访问逻辑调用上下文数据了
    ThreadPool.QueueUserWorkItem(state => {
        Console.WriteLine("state = {0}, name = {1}", state, CallContext.LogicalGetData("Name"));
    }, 3);

    Console.ReadKey();
}
运行结果

image.png

任务

ThreadPool的QueueUserWorkItem方法虽然非常简单,但是却没办法知道操作在什么时候完成以及获取返回值。现在使用任务(task)可以弥补这些不足。

等待任务完成并获取返回结果
static void Main()
{
    Task t = new Task(() =>
    {
        int sum = 0;
        for (int i = 0; i < 10000; i++)
            sum += i;
        return sum;
    });

    //启动任务
    t.Start();

    //等待任务完成
    t.Wait();

    //查看返回结果
    Console.WriteLine("result = {0}", t.Result);
    Console.ReadKey();
}

线程调用Wait方法时,系统会检测线程要等待的Task是否已经开始执行,如果是,调用Wait方法的线程会阻塞,直到Task运行结束为止。如果Task还没有开始执行,系统可能会使用调用Wait方法的线程来执行Task,这种情况调用Wait的线程将不会阻塞,它会执行Task并立即返回。如果线程在调用Wait前已经获得了一个线程同步锁,而Task试图获取同一个锁,就会造成线程死锁。

取消任务
static void Main()
{
    var t = new Task(() =>
    {
        int sum = 0;
        for (int i = 0; i < 10000; i++)
        {
            //如果已取消则会抛出异常
            cts.Token.ThrowIfCancellationRequested();
            sum += i;
        }
        return sum;
    }, cts.Token);

    t.Start();
    //异步请求,Task可能已经完成了
    cts.Cancel();

    try
    {
        //如果任务已取消,Result会引发AggregateException
        Console.WriteLine("result = {0}", t.Result);
    }
    catch (AggregateException exp)
    {
        exp.Handle(e => e is OperationCanceledException);
        Console.WriteLine("任务已取消");
    }
    Console.ReadKey();
}

创建一个任务时可以将一个CancellationToken传给Task的构造器,从而将CancellationToken和该Task关联在一起。如果CancellationToken在Task调度前被取消,则Task永远都不会再执行。

运行结果

image.png

一个任务完成时自动启动一个新的任务
private static int Sum(int n)
{
    n += 1;
    Console.WriteLine("n = {0}", n);
    return n;
}

static void Main()
{
    Task t = new Task(n => Sum((int)n), 0);
    t.Start();
    t.ContinueWith(task => Sum(task.Result));
    Console.ReadKey();
}

上述代码执行完任务(t)时,会启动另一个任务,执行上述代码的线程不会进入阻塞状态并等待两个任务中的任意一个任务完成,线程可以继续执行其它代码。

运行结果

image.png

父任务和子任务
private static int Sum(int n)
{
    n += 1;
    Console.WriteLine("n = {0}", n);
    return n;
}

static void Main()
{
    Task parent = new Task(() =>
    {
        var result = new int[3];
        new Task(() => result[0] = Sum(0), TaskCreationOptions.AttachedToParent).Start();
        new Task(() => { Thread.Sleep(5000); result[1] = Sum(1); }, TaskCreationOptions.AttachedToParent).Start();
        new Task(() => result[2] = Sum(2), TaskCreationOptions.AttachedToParent).Start();
        return result;
    });
    parent.ContinueWith(parentTask => Array.ForEach(parentTask.Result, Console.WriteLine));
    parent.Start();
    Console.ReadKey();
}
运行结果

image.png

现在改动一行代码,如下:

new Task(() => { Thread.Sleep(5000); result[1] = Sum(1); }, TaskCreationOptions.AttachedToParent).Start();

//上段代码改为
new Task(() => { Thread.Sleep(5000); result[1] = Sum(1); }).Start();
运行结果

image.png

结论
默认情况一个任务创建的Task对象是顶级任务,这些任务与创建他们的那个任务没有关联,然而使用TaskCreationOptions.AttachedToParent标记将一个Task和创建它的那个Task关联起来,这样一来除非所有的子任务以及子任务的子任务结束运行,否则父任务就不会认为已经结束。

任务工厂
private static int Sum(int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++)
    {
        checked
        {
            sum += i;
        }
    }
    return sum;
}

static void Main()
{
    Task parent = new Task(() => {
        var cts = new CancellationTokenSource();
        var tf = new TaskFactory(
            cts.Token,
            TaskCreationOptions.AttachedToParent,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default
        );

        //创建并启动子任务
        var childTask = new[]
        {
            tf.StartNew(() => Sum(1000)),
            tf.StartNew(() => Sum(10000)),
            tf.StartNew(() => Sum(100000))
        };

        //任何子任务抛出异常就取消其余子任务
        for (int i = 0; i < childTask.Length; i++)
            childTask[i].ContinueWith(t => cts.Cancel(), TaskContinuationOptions.OnlyOnFaulted);

        //完成所有子任务后
        tf.ContinueWhenAll(
            childTask,
            completedTask => completedTask.Where(t => !t.IsFaulted && !t.IsCanceled).Max(t => t.Result),
            CancellationToken.None
        ).ContinueWith(
            t => Console.WriteLine("max result is : {0}", t.Result
        ), TaskContinuationOptions.ExecuteSynchronously);
    });

    //子任务完成后
    parent.ContinueWith(p =>
    {
        foreach (var e in p.Exception.Flatten().InnerExceptions)
            Console.WriteLine("Exception : {0}", e.Message);
    }, TaskContinuationOptions.OnlyOnFaulted);
    parent.Start();
    Console.ReadKey();
}
运行结果

image.png

Parallel类的使用
Parallel的静态For方法
//单线程执行(不建议)
for (int i = 0; i < 100000; i++)
    DoSomthing(i);

//并行工作(推荐)
Parallel.For(0, 100000, (i) => DoSomthing(i));
Parallel的静态ForEach方法
var collection = new int[] { 1, 2, 3 };
//不建议
foreach (var item in collection)
    DoSomthing(item);

//推荐
Parallel.ForEach(collection, item => DoSomthing(item));

*如果既可以使用For也可以使用ForEach,那么使用For方法的速度要比ForEach方法快一些

Parallel的静态Invoke方法
//一个线程顺序执行多个方法
Method1();
Method2();
Method3();

//线程池的线程并行执行方法
Parallel.Invoke(
    () => Method1(),
    () => Method2(),
    () => Method3()
);

使用Parallel的前提是代码必须可以是并行执行的,如果任务一定要顺序执行请勿使用。Parallel的所有方法都让调用线程参与处理,如果调用线程在线程池线程完成自己的那一部分工作之前完成工作,那么调用线程便会自动挂起,直到所有工作完成后继续。

注意事项

Parallel虽然好用,但也需要开销,其中必须分配委托对象,而每一个工作项,都要调用一次委托。如果有大量可以使用多线程处理的工作项,那么也许能提升性能。再或者每一个工作项都需要涉及大量的工作,那么调用委托所造成的性能损失便可以忽略不计。但是如果工作项很少或者每一个工作项都能处理得非常快,这种情况下使用Parallel的方法反而会损害性能。

For,ForEach,Invoke的参数ParallelOptions对象
  • CancellationToken:允许取消操作,默认为CancellationToken.None
  • MaxDegreeOfParallelism:指定可以并发执行的最大工作项数量
  • TaskScheduler:指定要使用哪个TaskScheduler,默认为TaskScheduler.Default
定时执行任务

以下代码创建了一个计时器,并且立即执行一次SomeMethod方法,之后每间隔1s继续执行SomeMethod方法

private static  System.Threading.Timer s_Timer;

static void Main()
{
    s_Timer = new Timer(SomeMethod, 6, 0, Timeout.Infinite);
    Console.ReadKey();
}

private static void SomeMethod(object state)
{
    Console.WriteLine("state = {0}", state);

    //让Timer在1s后再调用这个方法
    s_Timer.Change(1000, Timeout.Infinite);
}

在内部,线程池为所有Timer对象只使用了一个线程。该线程知道下一个Timer对象还需要多久触发,下一个Timer对象触发时线程就会唤醒,在内部调用ThreadPool的QueueUserWorkItem,将一个工作项添加到线程池的请求队列中,使回调方法可以得到调用。如果回调方法执行时间很长,计时器可能再次触发,这种情况可能造成多个线程池线程同时执行回调方法。解决这个问题可以在构造Timer对象时,将period参数指定为Timeout.Infinite,这样计时器就只会触发一次。如果要循环执行计时器可以在回调方法内部调用Change方法,如上述代码那样。

运行结果

可以看到控制台立即输出state = 6之后每隔1s在输出state = 6

不推荐的做法
private static  System.Threading.Timer s_Timer;

static void Main()
{
    s_Timer = new Timer(SomeMethod, 6, 0, 1000);
    Console.ReadKey();
}

private static void SomeMethod(object state)
{
    Console.WriteLine("state = {0}", state);
}

这种做法虽然与上一段代码结果一致,一旦回调方法执行时间过长,超过period参数指定的调用回调方法的时间间隔,那么便可能出现多个线程同时执行回调方法,这并不是想要的结果。

CLR线程池
数据结构图

image.png

管理工作者线程

ThreadPool.QueueUserWorkItem和Timer类总是将工作项放到全局队列中,工作者线程采用先入先出的算法将工作项从队列中取出,并处理它们。由于多个工作者线程可能同时从全局队列中取出工作项,所以所有工作者线程都竞争同一个线程同步锁。

每个工作者线程都有一个自己的本地队列,当一个工作者线程调度一个Task时,Task会添加到调用线程的本地队列。工作者线程准备处理一个工作项时,它总是会先检查它的本地队列,如果存在一个Task,工作者线程就从它的本地队列中移除Task,并对工作项进行处理(工作者线程采用的是后入先出的算法取出队列中的工作项)。由于每个工作者线程的本地队列只有自己能访问,所以无需线程同步锁。

当工作者线程发现自己的本地队列为空时,工作者线程就会尝试从另一个工作者线程的本地队列的尾部取出一个工作项,并要求获取一个线程同步锁(对性能有些许影响)。如果所有本地队列都为空,那么工作者线程会使用先入先出算法尝试从全局队列取出工作项并获取线程同步锁,如果全局队列也为空,那么工作者线程将进入睡眠状态,等待事情的发生。如果睡眠时间太长,将自动唤醒并销毁自身。

线程池会快速创建工作者线程,使数量等于调用ThreadPool.SetMinThreads方法传递的值,如果没有调用该方法,默认等于进程允许使用的CPU数量。通常进程允许使用机器上的所有CPU数,所以线程池创建的工作者线程数量很快便会达到机器上的CPU数量。创建线程后,线程池会监视工作项的完成速度,如果时间太长,线程池会创建更多的线程,如果工作项完成速度很快,工作者线程就会被销毁。

CPU缓存行和伪共享
缓存行

为了提升访问内存的速度,CPU在逻辑上将所有内存都划分为缓存行,缓存行是2的整数幂个连续字节,最常见的缓存行大小是64个字节,所以CPU从RAM中获取并存储64个字节块。例如应用程序读取一个Int32的数据,那么会获取包含这个Int32值的64个字节。获取更多的字节通常可以增强性能,因为应用程序大多数在访问一些数据之后继续访问这些数据周围的其它数据。此时由于相邻的数据已经在CPU的缓存中了,就避免了慢速度的RAM访问。

但是,如果两个或多个内核访问同一个缓存行中的字节,内核之间必须互相通信,并在不同的内核之间传递缓存行,造成多个内核不能同时处理相邻的字节,从而对性能造成严重的影响。

代码测试
private const int COUNT = 100000000;

private static int s_OperationCount = 2;
private static long s_StartTime;

class SomeType
{
    public int Field1;
    public int Field2;
}

private static void AccessField(SomeType type, int field)
{
    //线程各自访问type中的字段
    for (int i = 0; i < COUNT; i++)
    {
        if (field == 0)
            type.Field1++;
        else
            type.Field2++;
    }

    //最后一个线程结束后显示花费时间
    if(Interlocked.Decrement(ref s_OperationCount) == 0)
        Console.WriteLine("花费时间:{0}", (Stopwatch.GetTimestamp() - s_StartTime) / (Stopwatch.Frequency / 1000));
}

static void Main()
{
    var type = new SomeType();
    s_StartTime = Stopwatch.GetTimestamp();

    //两个线程访问对象中的字段
    ThreadPool.QueueUserWorkItem(o => AccessField(type, 0));
    ThreadPool.QueueUserWorkItem(o => AccessField(type, 1));

    Console.ReadKey();
}

上述代码的type对象包含两个字段Field1和Field2,这两个字段极有可能在同一个缓存行中,接着启动两个线程执行AccessField方法,一个线程操作Field1,另一个线程操作Field2,每个线程完成时递减s_OperationCount的值,最后显示两个线程完成工作花费的总时间。

运行结果

image.png

接着修改一下SomeType类,让它变成这样:

[StructLayout(LayoutKind.Explicit)]
class SomeType
{
    [FieldOffset(0)]
    public int Field1;

    [FieldOffset(64)]
    public int Field2;
}

修改后的SomeType类使用了缓存行分隔Field1字段和Field2字段,在第一个版本中这个两个字段属于同一个缓存行,造成不同的CPU必须不停的来回传递字节。虽然从程序的角度看,两个线程处理的是不同的数据,但从CPU的缓存行角度看,CPU处理的是相同的数据,称为伪共享。在修改后的代码中,字段分别属于不同的缓存行,所以CPU可以做到独立工作,不必共享。

再次执行查看结果,速度显著提升

image.png

访问数组

由于数组在数组内存起始处维护着数组的长度信息,具体位置是在前几个元素之后。访问一个元素时,CLR会验证使用的索引是否在数组的长度范围内。所以访问一个数组的元素总是会牵扯到访问数组的长度,因此为了避免产生额外的伪共享,应避免让一个线程访问数组的前几个元素,同时让另一个线程访问数组中的其它元素。

你可能感兴趣的:(编程线程缓存线程池)