26计算限制的异步操作02-CLR

由CLR via C#(第三版) ,摘抄记录...

 6 Parallel的静态For,ForEach和Invoke方法

在一些常见的编程情形中,使用任务也许会提升性能。为了简化编程,静态类System.Threading.Tasks.Paraller封装了这些常见的情形,它内部使用Task对象。例如,不要像下面一样处理一个集合中的所有项:

// 一个线程顺序执行这个工作(每次迭代调用一次DoWork)
for (Int32 i = 0; i< 1000; i++ ) DoWork(i);

    相反,可以使用Parallel类型的For方法,让多个线程池治线程帮助执行这个工作:

// 线程池的线程并行处理工作
Parallel.For(0,1000,i=>DoWork(i));

    类似的,如果有一个集合,那么不要像下面这样写:

// 一个线程顺序执行这个工作(每次迭代调用一次DoWork)
foreach ( var item in conllection) DoWork(item);

    而是这样做:

// 线程池的线程并行处理工作
Parallel.ForEach(conllection,item=>DoWork(item));

    如果代码中既可以用For,也可以用ForEach,那么建议使用For,因为它执行的快一点。最后,如果要执行几个方法,那么可以顺序执行它们,如下所示:

//  一个线程顺序执行所有方法    
Method1();
Method2();
Method3();

    也可以并行执行它们: 

复制代码
// 线程池的线程并行执行
parallel.Invoke(
            () => Method1(),
            () => Method2(),
            () => Method3());
 
复制代码

    Parallel的所有方法都让调用线程参与处理。从资源利用的角度说,这是一件好事,因为我们不希望调用线程停下来,等待线程池做完所有工作后才继续。然而,如果调用线程在线程池完成自己的那一部分工作之前完成工作,调用程序就会将自己挂起,知道所有工作完成。这也是一件好事,因为这个提供了和普通for和foreach循环时相同的语义:线程要在所有工作后才继续运行。还要注意,如果任何操作抛出一个未处理的异常,你调用的paraller方法最后会抛出一个AggregateException

    当然,这并不是说需要检查自己的所有源代码,将for循环替换成Parallel.For的调用。调用Parallel的方法时,有一个前提条件务必记住:工作项要能并行执行。因此,如果工作项必须顺序执行,就不要调用Parallel的方法。另外,要避免会修改任何共享数据的工作项,因为多个线程同时处理的数据可能损坏。为了解决这个问题,一般的方法就是围绕数据访问添加线程同步锁。但是这样一来,一次就只能有一个线程访问数据,无法享受并行处理多个想带来的好处。
    除此之外,Parallel的方法本身也有开销:委托对象必须分配,而针对每一个工作项,都要调用一次这些委托。如果有大量可由多个线程处理的工作项,那么也许会获得性能的提升。但是,如果只为区区几个工作项使用Parallel的方法,或者为处理得非常快的工作项使用Parallel就会得不偿失了。
    Parallel的For,ForEach和Invoke方法都能接受一个ParallelOptions对象的重载版本。这个对象的定义如下:
   
复制代码
 // 存储用于配置 Parallel 类的方法的操作的选项。
     public class ParallelOptions
    {
        // 初始化 ParallelOptions 类的新实例
        public ParallelOptions();
        // 获取或设置与此 ParallelOptions 实例关联的 CancellationToken,运行取消操作
        public CancellationToken CancellationToken { get; set; }
        // 获取或设置此 ParallelOptions 实例所允许的最大并行度,默认为-1(可用CPU数)
        public int MaxDegreeOfParallelism { get; set; }
        // 获取或设置与此 ParallelOptions 实例关联的 TaskScheduler。默认为TaskScheduler.Default
        public TaskScheduler TaskScheduler { get; set; }
    }
复制代码

    除此之外,For和ForEach方法有一些重载版本允许传递3个委托:

    任务局部初始化委托(localInit),为参与工作的每一个任务都调用一次委托。这个委托是在任务被要求处理一个工作项之前调用。
    主体委托(body),为参与工作的各个线程所处理的每一项都调用一次委托。
    任务局部终结委托(localFinally),为参与工作的每一个任务都调用一次委托。这个委托是在任务处理好派遣给它的所有工作之后调用。即使主体委托引发一个未处理的异常,也会调用它。
    以下示例代码演示了如何利用3个委托,计算一个目录中的所有文件的字节长度总计值:
复制代码
private static Int64 DirectoryBytes(String path, String searchPattern, SearchOption searchOption)
        {
            var files = Directory.EnumerateFiles(path, searchPattern, searchOption);
            Int64 masterTotal = 0;
 
            ParallelLoopResult result = Parallel.ForEach<String, Int64>(files,
               () =>
               {
                   // localInit: 每个任务开始之前调用一次
                   // 每个任务开始之前,总计值都初始化为0
                   return 0;
               },
 
               (file, parallelLoopState, index, taskLocalTotal) =>
               {
                   // body: 每个任务调用一次
                   // 获得这个文件的大小,把它添加到这个任务的累加值上
                   Int64 fileLength = 0;
                   FileStream fs = null;
                   try
                   {
                       fs = File.OpenRead(file);
                       fileLength = fs.Length;
                   }
                   catch (IOException) { /* 忽略拒绝访问的文件 */ }
                   finally { if (fs != null) fs.Dispose(); }
                   return taskLocalTotal + fileLength;
               },
 
               taskLocalTotal =>
               {
                   // localFinally: 每个任务完成后调用一次
                   // 将这个任务的总计值(taskLocalTotal)加到中的总计值(masterTotal)上去
                   Interlocked.Add(ref masterTotal, taskLocalTotal);
               });
            return masterTotal;
        }
复制代码

    每个任务都通过taskLocalTotal变量为分配给它的文件维护自己的总计值。每个任务完成工作之后,都调用Interlocked.Add方法[对两个 32 位整数进行求和并用和替换第一个整数],以一种线程安全的方式更新总的总计值。由于每个任务都有自己的总计值,可以在一个工作项处理期间,无需进行线程同步。由于线程同步会造成性能的损失,所以不需要线程同步是一件好事。只有在每个任务返回之后,masterTotal才需要以一种线程安全的方式更新materTotal变量。所以,因为调用Interlocked.Add方法而造成的性能损失每个任务只发生一次,而不会每个工作项都发生。

    注意,我们向主题委托传递一个ParallelLoopState对象,它的定义如下:
    
复制代码
// 可用来使 Parallel 循环的迭代与其他迭代交互
    public class ParallelLoopState
    {
        // 获取循环的任何迭代是否已引发相应迭代未处理的异常
        public bool IsExceptional { get; }
        // 获取循环的任何迭代是否已调用 Stop
        public bool IsStopped { get; }
        // 获取从中调用 Break 的最低循环迭代。
        public long? LowestBreakIteration { get; }
        // 获取循环的当前迭代是否应基于此迭代或其他迭代发出的请求退出。
        public bool ShouldExitCurrentIteration { get; }
        // 告知 Parallel 循环应在系统方便的时候尽早停止执行当前迭代之外的迭代。
        public void Break();
        // 告知 Parallel 循环应在系统方便的时候尽早停止执行。
        public void Stop();
}
复制代码

      参与工作的每一个任务都会获得它自己的ParallelState对象,并可通过这个对象和参与工作的其他任务进行交互。Stop方法告诉循环停止处理任何更多的工作,未来对IsStopped属性的查询会返回true。Break方法告诉循环不再继续处理当前项之后的项。例如,假如ForEach被告知要处理100项,并在第5项时调用了Break,那么循环会确保前5项处理好之后,ForEach才返回。但注意,这并不是说在这100项中,只有前5项被处理,也许第5项之后可能在以前已经处理过了。LowestBreakIteration属性返回在处理过程中调用过Break方法的最低的项。从来没有调用过Break,LowestBreakIteration会返回null。

    处理任何一项时,如果造成一个未处理的异常,IsExceptional属性会返回true。如果处理一项时会花费大量的时间,代码可查询ShouldExitCurrentIteration属性看它是否应该提前退出。如果调用过Stop,调用过Break,取消过CancellationTokenSource,或者处理一项时造成了未处理的异常,这个属性就会返回true。
    Parallel的For和ForEach方法都返回一个ParallelLoopResult实例,他看起来像下面这样: 
   
复制代码
// 提供执行 System.Threading.Tasks.Parallel 循环的完成状态。
    public struct ParallelLoopResult
    {
        // 获取该循环是否已运行完成(即该循环的所有迭代均已执行,并且该循环没有收到提前结束的请求)。
        public bool IsCompleted { get; }
 
        // 获取从中调用 System.Threading.Tasks.ParallelLoopState.Break() 的最低迭代的索引。
        public long? LowestBreakIteration { get; }
    }
复制代码

    可通过检查属性来了解循环的结果,如果IsCompleted返回true。表明循环运行完成,所有项都得到了处理。如果IsCompleted为false,而且LowestBreakIteration为null,表明参与工作的某个线程调用了Stop方法。如果LowestBreakIteration返回false,而且LowestBreakIteration不为null,表名参与工作的某个线程调用的Break方法,LowestBreakIteration返回的Int64值指明了保证已得到处理的最低一项的索引。

7、并行语言查询(PLINQ)

 

   Microsoft的语言集成查询(LINQ)功能提供了一个简捷的语法来查询数据集合。使用LINQ,可轻松对数据线进行筛选、排序、投射等。使用LINQ to Object时,只有一个线程顺序处理数据集合中的所有项;我们称为顺序查询。为了提高处理性能,可以使用并行LINQ,它将顺序查询转换成为一个并行查询,在内部使用任务(这些任务的排列由默认TaskScheduler来调度),将集合中的数据线的处理工作分散到多个CPU上,一边并发处理多个数据。和Prarllel的方法相似,如果同时要处理大量项,或者每一项的处理过程都是一个耗时的计算限制的操作,那么就可以使用PLINQ获得最大的收益。
    静态System.Linq.ParallelEnumerable类(在System.Core.dll中定义)实现了PLINQ的所有功能,所以必须通过C#的using指令将System.Linq命名空间导入到你的源码中。尤其是,这个类公开了所有标准LINQ操作符的并行版本,比如Where,Select,SelectMany,GroupBy,Join,Skip,Task等。所有这些方法都是扩展了System.Linq.ParallelQuery<T>类型的扩展方法。为了让自己的LINQ to Object查询调用这些方法的并行版本,必须将自己的顺序查询(基于IEnumberable或者IEnumerable<T>)转换成并行查询(基于ParallelQuery或者ParallelQuery<T>),这是用ParallelEnumerable的AsParallel扩展方法来实现的,如下所示:
public static ParallelQuery<TSource> AsParallel<TSource>(this IEnumerable<TSource> source)

    下面是将一个顺序查询转换成并行查询的例子。查询返回的是一个程序集中定义的所有过时(obsolete)方法。

复制代码
private static void ObsoleteMethods(Assembly assembly)
        {
            var query =
               from type in assembly.GetExportedTypes().AsParallel()
               from method in type.GetMethods(BindingFlags.Public |
                  BindingFlags.Instance | BindingFlags.Static)
               let obsoleteAttrType = typeof(ObsoleteAttribute)
               where Attribute.IsDefined(method, obsoleteAttrType)
               orderby type.FullName
               let obsoleteAttrObj = (ObsoleteAttribute)
                  Attribute.GetCustomAttribute(method, obsoleteAttrType)
               select String.Format("Type={0}\nMethod={1}\nMessage={2}\n",
                  type.FullName, method.ToString(), obsoleteAttrObj.Message);
 
            // 显示结果
            foreach (var result in query) Console.WriteLine(result);
        }
复制代码

    在一个查询中,可以从执行并行操作换回执行顺序操作,这是通过调用ParallelEnumerable的AsSequential方法做到的:

public static IEnumerable <TSource> AsSequential<TSource>(this ParallelQuery<TSource> source)
    这个方法将一个ParallQuery<T>转换回一个IEnumerable<T>。这样一来,在调用了AsDequential之后执行的操作只由一个线程执行。
    通常,一个LINQ查询的结果数是让某个线程执行一个foreach语句计算获得的。这意味着只有一个线程遍历查询的所有结果。如果希望以并行的方式处理查询的结果,就应该使用ParallelEnumerable的ForAll方法处理查询:
static void ForAll<TSource>(this ParallelQuery<TSource> source,Action<TSource> action)

    这个方法允许多个线程同时 处理结果,可以修改前面的代码来使用该方法:

//显示结果
query.ForAll(Console.WriteLine);

    然而,让多个线程同时调用Console.WriteLine反而会损害性能,因为Console类内部会对线程进行同步,确保每次只有一个线程能访问控制台程序窗口,避免来自多个线程的文本最后显示成一团乱麻。希望为每个结果都执行计算时,才使用ForAll方法。

    由于PLINQ可用多个线程处理数据项,所以数据项被并发处理,结果被无序返回。如果需要让PLINQ保存数据项的顺序,可调用ParallelEnumerable的AsOrderd方法。调用这个方法时,线程会成组处理数据项。然后,这些数据项被合并回去,以保持顺序。这样会损害到性能。一下操作符生成不排序的操作:Distinct,Except,Intersect,Union,Join,GroupBy,GroupJoin和ToLookup。在这些操作符之后,如果想再次强制排序,只需调用AsOrdered方法。
    以下操作符生成排序的操作:ORderBy,OrderByDescending,Thenby和ThenByDescending。在这些出操作符之后,如果想再次恢复不排序的处理,只需调用AsUnordered方法。
    PLINQ提供了一些额外的ParallelEnumerable方法,可调用它们来控制查询的处理方式:
复制代码
// 设置要与查询关联的 CancellationToken
public static ParallelQuery<TSource> WithCancellation<TSource>(this ParallelQuery<TSource> source, CancellationToken cancellationToken);
// 设置要在查询中使用的并行度。 并行度是将用于处理查询的同时执行的任务的最大数目。
public static ParallelQuery<TSource> WithDegreeOfParallelism<TSource>(this ParallelQuery<TSource> source, int degreeOfParallelism);
// 设置查询的执行模式。
public static ParallelQuery<TSource> WithExecutionMode<TSource>(this ParallelQuery<TSource> source, ParallelExecutionMode executionMode);
//设置此查询的合并选项,它指定查询对输出进行缓冲处理的方式。
public static ParallelQuery<TSource> WithMergeOptions<TSource>(this ParallelQuery<TSource> source, ParallelMergeOptions mergeOptions);
复制代码
     显然,WithCancellation方法允许传递一个CancellationToken,使查询处理能提前停止。WithDegreeOfParallelism方法指定最多允许多少个线程处理查询;他不会强迫创建满全部线程,如果并不是全部都需要的话。你一般不会调用这个方法。另外,默认情况下,会为每个内核用一个线程来执行查询。
    PLINQ分析一个查询,然后决定如何最好地处理它。有的时候,顺序处理一个查询可以获得更好的性能,尤其在使用以下任何操作时:Concat,ElementAt(OrDefault),First(OrDefault),Last(OrDefault),Skip(While),Task(While)或Zip。使用Select(Many)或Where的重载版本,并向你的selector或predicate委托传递一个位置索引时也是如此。然而,可以调用WithExecutionMode,向它传递某个ParallelExecuteMode标志,从而强迫查询以并行方式处理:
public enum ParallelExecutionMode {
    Default = 0,                // 让并行LINQ决定处理查询的最佳方式
    ForceParallelism = 1   // 强迫查询以其并行方式处理
}

    如前所述,并行LINQ让多个线程处理数据项,结果必须再合并回去。可调用WithMergeOptions向它传递以下某个ParallelMargeOptions标志,从而控制这些结果的缓冲和合并方式: 

复制代码
    // 指定查询中要使用的输出合并的首选类型。 换言之,它指示 PLINQ 如何将多个分区的结果合并回单个结果序列。 这仅是一个提示,系统在并行处理所有查询时可能不会考虑这一点。
    public enum ParallelMergeOptions
    {
        //   使用默认合并类型,即 AutoBuffered。
        Default = 0,
 
        //  不利用输出缓冲区进行合并。 一旦计算出结果元素,就向查询使用者提供这些元素。
        NotBuffered = 1,
 
        //  利用系统选定大小的输出缓冲区进行合并。 在向查询使用者提供结果之前,会先将结果累计到输出缓冲区中。
        AutoBuffered = 2,
 
        //   利用整个输出缓冲区进行合并。 在向查询使用者提供任何结果之前,系统会先累计所有结果。
        FullyBuffered = 3,
    }
复制代码

    这些选项使你能在某种程度上控制速度和内存消耗的对应关系。NotBuffered 最省内存,但处理速度慢一些。FullyBuffered 消耗较多内存,但运行得最快。NotBuffered 介于NotBuffered 和FullyBuffered 之间,最好亲自试验所有选项,并对比其性能,来选择那种方式。

 

  8、执行定时计算限制操作

 
    System.Threading命名空间定义了一个Timer类,可用它让一个线程池线程定时调用一个方法。构造Timer类的一个实例相当于告诉线程池:在将来的某个时间会执行你的一个方法。Timer类提供了几个构造函数,相互都非常相似:
复制代码
public sealed class Timer : MarshalByRefObject, IDisposable
{
        public Timer(TimerCallback callback, object state, int dueTime, int period);
        public Timer(TimerCallback callback, object state, long dueTime, long period);
        public Timer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period);
        public Timer(TimerCallback callback, object state, uint dueTime, uint period);
}
复制代码

    4个构造器以完全一样的方式构造Timer对象。callback参数标识希望由一个线程池线程回调的方法。当然,你写的对调方法必须和System.Threading.TimerCallback委托类型匹配,如下所示:

delegate void TimerCallback(Object state);

    构造器的state参数允许在每次调用回调方法时都像它传递状态数据;如果没有需要传递的状态数据,可以传递null。dueTime参数告诉CLR在首次调用回调方法之前要等待多少毫秒。可以使用一个有符号或无符号的32位值,一个有符号的64位值或者一个TimeSpan值指定毫秒数。如果希望回调方法立即调用,为dueTime参数指定0即可。最后一个参数(period)指定了以后每次调用回调方法需要等待的时间(毫秒)。如果为这个参数传递Timeout.Infinite(-1),线程池线程值调用回调方法一次。

    在内部,线程池为所有Timer对象只使用了一个线程。这个线程知道下一个Timer对象在什么时候到期。下一个Timer对象到期时,线程就会唤醒,在内部调用TreadPool的QueueUserWorkItem,将一个工作项添加到线程池队列中,使你的回调方法得到调用。如果回调方法的执行时间很长,计时器可能(在上个回调还没有完成时)再次触发。这个能造成多个线程同时执行你的对调方法。为解决这个问题,我的建议是:构造Timer时,为period参数指定Timeout.Infinite。这样,计时器就只触发一次。然后,在你的对调方法中,调用Change方法指定一个新的dueTime,并再次为period参数指定Timeout.Infinite。以下是Change方法的各个重载版本:
复制代码
public sealed class Timer : MarshalByRefObject, IDisposable
{
      
        public bool Change(int dueTime, int period);
     
        public bool Change(long dueTime, long period);
      
        public bool Change(TimeSpan dueTime, TimeSpan period);
     
        public bool Change(uint dueTime, uint period);
}
复制代码

    Timer类还提供了Dispose方法,运行完全取消计时器,并可再当时处于pending状态的所有回调完成之后,向notifyObject参数标识的内核对象发送信号。以下是Dispose方法的各个重载版本:

public sealed class Timer : MarshalByRefObject, IDisposable
{
       public void Dispose();
       public bool Dispose(WaitHandle notifyObject);
}

    提示:一个Timer对象被垃圾回收时,它的终结代码告诉线程池取消计时器,使它不再触发。所以,使用一个Timer对象时,要确定有一个变量在保持Timer对象的存货,否则对你的回调方法调用就会停止。

    以下代码演示了如何让一个线程池线程立即开始调用一个回调方法,以后每2秒钟调用一次:
复制代码
internal static class TimerDemo
        {
            private static Timer s_timer;
 
            public static void Go()
            {
                Console.WriteLine("Main thread: starting a timer");
                using (s_timer = new Timer(ComputeBoundOp, 5, 0, Timeout.Infinite))
                {
                    Console.WriteLine("Main thread: Doing other work here...");
                    Thread.Sleep(10000);
                } // 现在调用Dispose取消计时器
            }
 
            // 一个方法的签名必须符合 TimerCallback 委托
            private static void ComputeBoundOp(Object state)
            {
                // 这个方法由一个线程池线程执行
                Console.WriteLine("In ComputeBoundOp: state={0}", state);
                Thread.Sleep(1000);
 
                // 让 Timer 在2秒钟之后再调用这个方法
                s_timer.Change(2000, Timeout.Infinite);
 
                // 这个方法返回时,线程回归池中,等待下一个工作项
            }
        }
复制代码

    FCL事实上提供了几个计时器,大多是开发人员都不清楚每个计时器到底有什么独到之处,在这里试着解释一下:

    System.Threading的Timer类    这是刚刚讨论过的计时器。要在一个线程池线程上执行定时的(周期性发生的)后台任务,它是最好的计时器。
    System.Windows.Forms的Timer类    构造这个类的一个实例,相当于告诉Windows将一个计时器和调用线程关联。当这个计时器触发时,WIndows将一条计时器消息(WM_TIMER)注入线程的消息队列。线程必须执行一个消息泵来提取这些消息,并把它们派遣给想要的回调方法。注意,所有这些工作都只有一个线程完成——设置计时器的线程保证就是执行回调方法的线程。这还意味着你的计时器方法不会由多个线程并发执行。
    System.Windows.Threading的DispatcherTimer类    这个类是 System.Windows.Forms的Timer类在Siverlight和WPF应用程序中的等价物。
    System.Timers的Timer类    这个计时器基本是System.Threading的Timer类的一个包装类。当计时器到触发时,会导致CLR将事件放到线程池的队列中。尽量不要使用这个类而是使用System.Threading的Timer类。
 
  9、线程池如何管理线程
  
     9.1、设置线程池限制
    CLR允许开发人员设置线程池要创建最大线程数。但实践证明,线程池永远都不该为池中的线程数设置上限,因为可能发生饥饿或死锁。假如队列中有1000个工作项,但这些工作项全都因为一个事件而阻塞,等1001个工作项发出信号才能解除阻塞。如果设置最大1000个线程,第1001个工作项就会执行,所有1000个线程都会一直阻塞,最终用户被迫终止应用程序,并丢失他们都做的为保存的工作。
    由于存在饥饿和死锁问题,CLR团队一直都在稳步地增加线程默认能够拥有的最大线程数。目前默认值是最大1000个线程。这基本可以看成是不限数量的,因为一个32位进程最大有2G的可用选址空间。加载一组Win32和CLR DLLs,并分配了本地堆和托管堆之后,剩余约1.5G的地址控制。由于每个线程都要为其用户模式栈和线程环境块(TEB)准备超过1MB的内存,所以一个32位进程中,最多能够有1360个线程。试图创建更多的线程,会抛出一个OutOfMemotyExcption。当然,64位就另当别论了。
    System.Threading.ThreadPool类提供了几个静态方法,可调用它们设置和查询线程池的线程数:GetMaxThreads,SetMaxThreads,GetMinThreads,SetMinThreads和GetAvailableThreads[获得可用的线程数量]。强烈建议你不要调用上述任何方法。限制线程池的线程数,一般只会造成应用程序性能变得更差。
 
    2、如何管理工作者线程
    图 26-1 展示了构成线程池的一部分的工作者线程的各种数据结构。ThreadPool.QueueUserWorkItem方法和Timer类总是将工作项放到全局队列中。工作者线程采用一个先入先出算法将工作项从这个队列中取出来,并处理它们。由于多个工作者线程可能同时从全局队列中拿走工作项,所以所有工作者线程都竞争一个线程同步锁,以保证两个或多个线程不会获取同一个工作项。这个线程同步锁在某些应用程序中可能成为瓶颈,对伸缩性和性能造成某种程序的限制。
 
    现在,让我们谈谈使用默认TaskScheduler(通过查询TaskScheduler的静态Default属性来获得)来调度的Task对象。当一个非工作者线程调度一个Task时,Task会添加到全局队列中。但是,每个工作者线程都有它自己的本地队列。当一个工作者线程调度一个Task时,Task会添加到调用线程的本地队列中。
    一个工作者线程准备好处理一个工作项时,它总是先检查它的本地队列来查找一个Task。入股存在一个Task,工作者线程就从它的本地队列中移出task,并对工作项进行处理。要注意的是,工作者线程采用后入先出的算法将任务从它的本地队列中取出。由于工作者线程是唯一允许访问它自己的本地队列列头的线程,所以无需同步锁,而且在队列中添加和删除Task的速度非常快。这个行为的副作用在于,Task是按照和进入队列时相当的顺序执行的。
    提示:线程池从来不保证排队中的工作项的处理顺序,这是合理的,尤其考虑到多线程可能同事处理工作项。然而,上述副作用使得这个问题变得更加恶化。你必须保证自己的应用程序对工作项或Task的执行顺序不做任何预设。
    如果一个工作者线程发现它的本地队列变空了,工作者线程就会尝试从另一个工作者线程的本例队列中"偷"一个Task。这个Task是从一个本地队列的尾部"偷"走的,并要求获得一个线程同步锁,这对性能可能有少许影响。当然,这种"偷窃"行为很少发生,所以很少需要获取这个锁。如果所有本地队列都变空,那么工作者线程会使用FIFO算法,从全局队列中提取一个工作项。如果全局队列也为空,那么线程就会进入睡眠状态,等待事情的发生。如果睡眠的时间太长,它会自己醒来,并销毁自己,允许系统回收线程使用的资源(包括内核对象、栈、TEB等)。
    线程池会快速常见工作者线程,是工作者线程的数量等于传给ThreadPool的SetMinThreads方法的值。如果从不调用这个方法(也不建议你调用),那么默认值等于你的进程允许使用的CPU数,这是由线程的affinity mask(关联掩码)决定的。通常,你的进程允许使用机器上的所有CPU数,所以线程池创建的工作者线程数量很快就会打到机器上的CPU数。创建了这么多的的线程后,线程池会监视工作项的完成速度。如果工作项完成的时间太长,线程池会创建更多的工作者线程。如果工作项的完成速度开始变快,工作者线程会被销毁。
 
  十、缓存线和伪共享
 
    为了提升反复访问内存的性能,如今的CPU在芯片上都集成了高速缓存。线程首次从RAM去取一些值时,CPU从RAM获取所需的值,并把它存储到CPU的高速缓存中。事实上,为了进一步提升性能,CPU会在逻辑上将所有内存都划分为所谓的缓冲行(cache line)。一个缓冲行有64个字节构成,所以CPU从RAM中获取并存储64字节的块。如果应用程序需要读取一个Int32值,那么会获取包含了那个Intt32的64个字节,这样会获取到比需要的更多字节,这样通常会造成性能增强,因为大多数应用程序在访问了一些数据之后,通常会继续访问存储在那些数据周围的数据。由于相邻的数据已经提取到CPU的缓存中,就避免了慢速的RAM访问。
    然而,如果两个或多个内核访问同一个缓冲行中的字节,内核必须相互通信,并在内核之间传递缓冲行,造成多个内核不能同时处理相邻的字节,这对性能会造成严重影响。
    下面用一些代码演示这一点:
复制代码
internal static class FalseSharing
        {
            private class Data
            {
                // 这两个字段是相邻的,并(极有可能)在相同的缓冲行中
                public Int32 field1;
                public Int32 field2;
            }
 
            private const Int32 iterations = 100000000;
            private static Int32 s_operations = 2;
            private static Int64 s_startTime;
 
            public static void Go()
            {
                // 分配一个对象,并记录开始时间
                Data data = new Data();
                s_startTime = Stopwatch.GetTimestamp();
 
                // 让零个线程访问在对象中它们自己的字段
                ThreadPool.QueueUserWorkItem(o => AccessData(data, 0));
                ThreadPool.QueueUserWorkItem(o => AccessData(data, 1));
 
                //处于测试目的,阻塞Go线程
                Console.ReadLine();
            }
 
            private static void AccessData(Data data, Int32 field)
            {
                // 这里的线程各自访问它们在Data对象中自己的字段
                for (Int32 x = 0; x < iterations; x++)
                {
                    if (field == 0)
                    {
                        data.field1++;
                    }
                    else
                    {
                        data.field2++;
                    }
                }
 
 
                // 不管哪个线程最后结束,都显示它花的时间
                if (Interlocked.Decrement(ref s_operations) == 0)
                {
                    Console.WriteLine("Access time: {0:N0}", Stopwatch.GetTimestamp() - s_startTime);
                }
 
            }
        }
复制代码

 

    上述代码中,Data对象在构造时包含了两个字段。这两个字段极有可能在同一个缓冲行中。然后,两个线程池线程启动并执行AccessData方法。一个将1加到Data的filed1上的100 000 000 次,另一个线程对filed2字段做同样的事情。每个线程完成后,都递减s_operations字段中的值;最后一个将字段递减为0的线程就是最后一个结束的线程,它显示两个线程完成它们的工作总共发了多少时间。我的机器大约花了245毫秒。
    现在,让我们修改Data类,使它看起来项下面这样: 
 
复制代码
[StructLayout(LayoutKind.Explicit)]
    private class Data {
        // 这两个字段分开了,不再相同的缓冲行中
        [FieldOffset(0)]
        public Int32 field1;
        [FieldOffset(64)]
        public Int32 field2;
    }
复制代码

    在上述代码中,现在用一个缓存线(64字节)分隔两个字段。再次运行,结果变成了201毫秒,比第一个版本快了一些。从程序角度看,两个线程处理的是不同的数据。但从CPU缓存线来看,CPU处理的是相同的数据。这称为伪共享(false sharing)。在第二个版本中,字段在不同的缓存线上,所以CPU可以真正做到独立,不必共享什么。

    通过上述讨论,应该知道在多个线程同时访问相邻的数据时,缓存线和伪共享可能对应用程序产生严重影响。在性能非常紧要的情形下,这是你应该注意的一点。如果检查到这个问题,通常都可以设计出一种方式来避免它(这里用的就是FiledOffset attribute)。
    要注意的是,数组在数组内存起始处维护着它的长度,具体位置是在前几个数据元素之后,访问一个数组元素时,CLR验证你使用的索引在数组的长度之内。这意味着访问一个数组总是牵涉到访问数组的长度。因此,为了避免产生额外的伪共享,应该避免让一个线程向数组的前几个元素写入,同时让其他线程访问数组中的其他元素。

~~~~~~待续。。。。

posted @ 2015-04-08 17:37  北落师门α  阅读(216)  评论(0编辑  收藏  举报