.Net Framework下的多线程安全集合

10 篇文章 0 订阅
9 篇文章 0 订阅

 每个正式发布的.net framework版本在增加特性时,都会着眼于那些对于当今程序员有挑战的问题上。.net framework 4.0增加了一个新的System.Collections.Concurrent命名空间,使得多线程开发中不同线程间共享数据的问题解决起来更加轻松。特别是当你打算实现一个 producer/consumer 模式时,新提供的相关集合将会帮助你完成不少工作。.net 4.0之前人们也会开发多线程程序,但是4.0中推出的这些新的集合后,意味着现在开发一个多线程序,你可以只做很少的事情framework将承担更多的工作。在微软程序员Alexandra Rusina的并行开发系列文章中,其中有一篇博文正是介绍这些集合类的。本文将深入这些集合类的设计,讨论它们解决了哪些问题,以及如何更好的在你的程序中使用它们;还会指出我所看到的由开发者对于这些集合类型能做什么不能做什么的误解引发的常见问题。

ps:本文相关源代码已经提供在线下载

使用新集合类型开发并行程序

.net4.0之前开发并行程序,如果多个线程需要访问一个共享集合只能自己动手来实现同步。每次编辑集合元素时,需要手工锁定集合;可能还需要在每次集合访问(或是枚举成员)时也进行集合的锁定。以上就是一个最简单的多线程场景。有些应用程序会创建一个背景线程用来随时传递数据到线程共享集合。其它线程会读取和处理这些数据。要满足该场景,需要实现线程间自己的消息传递计划,当有可用新数据或是有数据消毁时通知其它线程。System.Collections.Concurrent 命名空间下包含了相关类和接口来提供以上所需功能及其它多线程开发中常见问题,包括跨线程的数据共享。

System.Collections.Concurrent namespace命名空间中包含了五个不同的集合类型:BlockingCollection<T>, ConcurrentBag<T>, ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T> and ConcurrentStack<T>.此处我打算论述一下这五个类型的共性和设计目标,而不是逐个详细介绍以及论述它们的差异。这几个集合类中的部分实现了接口:IProducerConsumer<T> ,该接口抽象了实现Producer / Consumer 模式需要的相关功能。在你的应用开发中选用哪种集合类,和我们在选择泛型集合类时相同,也就是考虑他们在不同搜索及索引行为下表现的不同执行特征。

本文使用的例子是我用WPF写的一个生成 曼德勃罗集 的应用。每个producer 线程负责生成整图中一块64X64 大小的像素值集合。Consumer线程在一个WritableBitmap上绘制用于显示的点。你可以单击图像中的任意位置来放大局部,也可以变换图中分布的颜色从而产生一些有趣的视觉效果。以上工作都可以通过System.Collections.Concurrent命名空间中的BlockingCollection<T>来轻松实现。

运行该例子时,起初你将看到 从左上角的点(-2,-1) 开始的曼德勃罗集。黑色区域为集合内,其它彩色样点在集合外。

本例提供了两个简单功能来帮助你探秘 曼德勃罗集 的效果。首先,点击图中任意位置将会把点击点周围做10倍放大。确保去点击集合的边缘,因为所有有趣的效果都发生在那儿。下图是我对集合左下边放大五次后的结果。

另外,可以通过按键盘上的'c'键来变换图中颜色分布。下图是同一区域 使用了'summer'颜色分布的效果。

在这些集合中有一个 producer/consumer 模式的标准实现。首先,我启动那些consumer任务.这些任务在producer任务生成数据之前,处于数据等待状态。(稍后我会展示一些producer 和 consumer的代码).启动consumer任务后,紧接着启动producer任务。每个producer任务执行完毕,consumer任务都会对其产生的数据进行所需的处理。在我的 曼德勃罗集 例子中,每个producer任务产生一个64x64的像素块,而consumer任务会将这些一块块的数据呈现出来。

我用并行任务库(TPL)中的Task类来构建producer和consumer任务。任务类提供在线程中独立运行单位工作的抽象。在本例中,使用TaskFactory类创建和启动任务。TPL处理负载平衡,线程运行计划和任务附加(稍后会讨论)。该库(TPL)由常见的ThreadPool和Thread类构建,提供通用功能的抽象。在 并行计算研发中心 你可以了解更多关于.net中并行计算的内容。

实现producer 任务

即使首次编码使用consumer任务,但我发现有了producer任务的说明很容易上手.producer任务应当为 曼德勃罗集 生成一个64x64的颜色块。颜色块及一些支持信息必须放到共享集合。下列代码就是创建色块网格值的:

// You can download the full source code at:
 
// http://code.msdn.microsoft.com/DevCenterSamples
 
// Define a couple needed structures:
 
private struct Coord
 
{
 
    public int X;
 
    public int Y;
 
    public int Z;
 
}
 
private class ImageRegion
 
{
 
    public Int32Rect region;
 
    public List<Coord> values;
 
}
 
// The following is a portion of the UpdateBackingStore() method
 
// from the sample. The DisplayTiles() method is explained later in this article,
 
// in the Implementing the Consumer section.
 
var results = new BlockingCollection<ImageRegion>();
 
Task.Factory.StartNew(() => DisplayTiles(results, context));
 
// Grids are 64 by 64:
 
List<Task> tasks = new List<Task>();
 
for (int yIndex = 0; yIndex < bitmap.PixelHeight; yIndex += 64)
 
    for (int xIndex = 0; xIndex < bitmap.PixelWidth; xIndex += 64)
 
    {
 
        // remember the lazy eval:
 
        var x = xIndex;
 
        var y = yIndex;
 
        var compute = Task.Factory.StartNew(() =>
 
            results.Add(
 
            // GenerateGrid() is not discussed in the article.
 
            // It generates one region of pixels. You can
 
            // look at the project for more details.
 
            GenerateGrid(minX + scale * x, minY + scale * y,
 
            Math.Min(64, width - x), Math.Min(64, height - y), 
 
            x, y, scale)
 
            ));
 
        tasks.Add(compute);
 
    }


这段代码首先创建一个blocking collection 用来保存图形区域数据。之后创建了一批任务,这批任务负责计算为拼凑图形所用的每个色块相关样点颜色值。单个任务返回一个色块所有像素颜色值,同时还包括一些方便在WriteableBitmap 上的正确位置绘制色块的信息。

此处要注意,我特意在使用计算颜色块的背景任务前创建了循环变量(xIndex and yIndex)的副本。(见上列示例代码中的注释。)任务方法是一个lambda表达式,为了任务的执行表达式会捕获一些局部变量。因为运算中用到的多数变量不会改变,所以这些捕获变量的问题不大。然后,xIndex和yIndex值却会在每个任务创建后变化。当一个任务运行后,将会检查xIndex和yIndex的当前值,而此时的值与任务创建时已经不同了。这样意味着如果没有这些变量副本,这些没有立即执行的任务将生成错误数据了。如果就本例而言,你会看到一些区域没有计算。其它区域可能会多次计算。而这将会在图像中留下一些空洞。如果你使用lambda表达式构造任务执行的委托,一定要注意这个问题。

同时,我使用了BlockingCollection<T>的Add方法,而且调用范围内并未使用任何方式的lock语句。BlockingCollection<T>类像System.Collections.Concurrent命名空间下其它集合类型一样,提供了在其Add与remove相关方法中提供了lock功能。访问这类集合时不需要创建自己的锁定机制。

Consumer的实现

Consumer任务负责绘制每个producer任务创建的色点。BlockingCollection 有个方法GetConsumingEnumerable,使用该方法调用producer产生的数据将会非常轻松,感觉就像在一个单线程应用中一样。

// You can download the full source code at:
 
// http://code.msdn.microsoft.com/DevCenterSamples
 
private void DisplayTiles(BlockingCollection<ImageRegion> results,
 
    TaskScheduler context)
 
{
 
    foreach (var result in results.GetConsumingEnumerable())
 
    {
 
        var thisItem = result;
 
        Task.Factory.StartNew(() =>
 
        {
 
            foreach (var element in thisItem.values)
 
            {
 
                zValues[element.X, element.Y] = element.Z;
 
                pixels[element.X + element.Y * bitmap.PixelWidth] = 
 
                    // GetPixel() translates the Mandelbrot set value to a color.
 
                    fromColor(GetPixel(element.Z));
 
            }
 
            bitmap.WritePixels(thisItem.region, pixels, bitmap.PixelWidth * 4, 
 
                thisItem.region.X, thisItem.region.Y);
 
        }, CancellationToken.None, TaskCreationOptions.None,
 
            context);
 
    }
 
}


神奇的GetConsumingEnumerable方法:如果集合为空会发生什么?它是如何知道producer任务何时完成了?当然神奇是有原因的,全在BlockingCollection<T>的代码实现中。使用MoveNext获取数据时,如果没有成功获取Consumer线程会等待。内部实现细节可能会变,但是比起Thread.Sleep方法来要复杂且高效的多。

BlockingCollection<T>也包含一些API,可以用来指定何时所有的producer任务都完成了。如果所有producer 任务把结果存入BlockingCollection<T>,调用CompleteAdding方法,通知BlockingCollection<T>所有数据已生成。此后,一旦枚举完所有已存在数据,再调用MoveNext将不会再等待新的数据生成了,而是直接返回false.

通知集合所有producer任务完成

下面来到使用using the BlockingCollection<T>实现Producer / Consumer模式的最后一步:
编写附加任务通知blocking集合所有任务已经完成。当所有producer任务完成时使用指定的方法Task.Factory.ContinueWhenAll.这段代码中,我会标识BlockingCollection<T>为已完成,通过调用CompleteAdding.同时,可以安全地释放所有producer任务(毕竟他们已经完成了)。

// You can download the full source code at:
 
// http://code.msdn.microsoft.com/DevCenterSamples
 
// This is another snippet from the UpdateBackingStore() method.
 
Task.Factory.ContinueWhenAll(tasks.ToArray(),
 
    result => 
 
        {
 
            results.CompleteAdding();
 
            foreach (var task in tasks)
 
                task.Dispose();
 
        },
 
        CancellationToken.None, TaskContinuationOptions.None, context);


BlockingCollection<T>类提供了一对属性帮助你更好的控制应用的相关行为。通过属性IsAddingCompleted可知道是否所有期望的结果都已添加进集合了(因为CompleteAdding方法的调用,集合可以决定该属性的值)。通过属性IsCompleted可知道是否所有数据均已创建完且已经被使用了(如果CompleteAdding方法已调用,且集合已为空)。所以可知:只要IsCompleted 为真IsAddingCompleted 就一定也返回真。

当然,System.Collections.Concurrent当中的集合类还有更多其它特性。例如你可以指定BlockingCollection<T>的最大容量,一旦设置了该容量producers集合会阻塞尝试添加超出容量限制的内容到集合中。如果指定了最大容量,你可以使用TryAdd方法向一个满了的集合中添加内容而不受阻碍。

另外,每个集合类都有一个Try<something>方法用来从集合中移除内容。该方法在不同集合类中命名有些不同:如 TryDequeue是ConcurrentQueue<T>集合类中的对应方法,TryPop是ConcurrentStack<T>中的对应方法 等。

结论

多线程安全集合类型为实现producer/consumer模式提供了阻塞与限制的标准机制。ConsumingEnumerable枚举方法为数据还未创建完成前的consumer线程阻塞提供了标准实现。通过设置容量的方式也限制了producer线程不会超过consumer线程.使用相关集合类实现Producer/Consumer模式你只需要写三个小的代码段。

  • producer任务 向集合中添加一个元素(数据)。
  • consumer任务使用GetConsumingEnumerable方法处理每一个可用的数据结果。
  • 扩展任务会在所有producer任务完成相关数据生成工作后标识。

通过使用System.Concurrent.Collections下的那些实现了通用功能的集合类,再加上三小段你的代码就在多线程开发中实现了Producer/Consumer模式。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值