C#多线程编程:使用并发集合

原文链接:https://www.cnblogs.com/wyt007/p/9486752.html

编程需要对基本的数据结构和算法有所了解。程序员为并发情况选择最合适的数据结构,那就需要知道很多事情,例如算法运行时间、空间复杂度,以及大写0标记法等。在不同的广为人知的场景中,我们总知道哪种数据结构更高效。
对于并行计算,我们需要使用适当的数据结构。这些数据结构具备可伸缩性,尽可能地避免锁,同时还能提供线程安全的访问。.NET framework版本4引入了System.Collections.Concurrent命名空间,其中包含了一些数据结构。在本章中,我们将展示这些数据结构并通过简单的例子来说明如何使用它们。
先从ConcurrentQueue开始。该集合使用了原子的比较和交换(Compare and Swap,简称CAS)操作,以及SpinWait来保证线程安全。它实现了一个先进先出( First In FirstOut,简称FIFO)的集合,这意味着元素出队列的顺序与加入队列的顺序是一致的。可以调用Enqueue方法向队列中加入元素。TryDequeue方法试图取出队列中的第一个元素,而 TryPeek方法则试图得到第一个元素但并不从队列中删除该元素。
ConcurrentStack的实现也没有使用任何锁,只采用了CAS操作。它是一个后进先出, (Last In First Out,简称LIFO)的集合,这意味着最近添加的元素会先返回。可以使用Push和PushRange方法添加元素,使用TryPop和TryPopRange方法获取元素,以及使用TryPeek方法检查元素。
ConcurrentBag是一个支持重复元素的无序集合。它针对这样以下情况进行了优化,即多个线程以这样的方式工作:每个线程产生和消费自己的任务,极少与其他线程的任务交互 (如果要交互则使用锁),添加元素使用Add方法,检查元素使用TryPeek方法,获取元素使,用TryTake方法。
请避免使用上面提及的集合的Count属性。实现这些集合使用的是链表, Count操作的时间复杂度为0(N)。如果想检查集合是否为空,请使用IsEmpty属性,其时间复杂度为0(1)。
ConcurrentDictionary是一个线程安全的字典集合的实现。对于读操作无需使用锁。但是对于写操作则需要锁。该并发字典使用多个锁,在字典桶之上实现了一个细粒度的锁模型。使用参数concurrencyLevel可以在构造函数中定义锁的数量,这意味着预估的线程数量将并发地更新该字典。
由于并发字典使用锁,所以一些操作需要获取该字典中的所有锁。如果没必要请避免使用以下操作: Count, IsEmpty, Keys, Values, CopyTo及ToArray。
BlockingCollection是对IProducerConsumerCollection泛型接口的实现的一个高级封装。它有很多先进的功能来实现管道场景,即当你有一些步骤需要使用之前步骤运行的结果时。BlockingCollectione类支持如下功能:分块、调整内部集合容量、取消集合操作、从多个块集合中获取元素。

使用 ConcurrentDictionary

本节展示了一个非常简单的场景,比较在单线程环境中使用通常的字典集合与使用并发字典的性能。

class Program
{
    static void Main(string[] args)
    {
        var concurrentDictionary = new ConcurrentDictionary<int, string>();
        var dictionary = new Dictionary<int, string>();

        var sw = new Stopwatch();

        sw.Start();
        for (int i = 0; i < 1000000; i++)
        {
            lock (dictionary)
            {
                dictionary[i] = Item;
            }
        }
        sw.Stop();
        Console.WriteLine("Writing to dictionary with a lock: {0}", sw.Elapsed);

        sw.Restart();
        for (int i = 0; i < 1000000; i++)
        {
            concurrentDictionary[i] = Item;
        }
        sw.Stop();
        Console.WriteLine("Writing to a concurrent dictionary: {0}", sw.Elapsed);

        sw.Restart();
        for (int i = 0; i < 1000000; i++)
        {
            lock (dictionary)
            {
                CurrentItem = dictionary[i];
            }
        }
        sw.Stop();
        Console.WriteLine("Reading from dictionary with a lock: {0}", sw.Elapsed);

        sw.Restart();
        for (int i = 0; i < 1000000; i++)
        {
            CurrentItem = concurrentDictionary[i];
        }
        sw.Stop();
        Console.WriteLine("Reading from a concurrent dictionary: {0}", sw.Elapsed);

        Console.ReadKey();
    }

    const string Item = "Dictionary item";
    public static string CurrentItem;
}

当程序启动时我们创建了两个集合,其中一个是标准的字典集合,另一个是新的并发字典集合。然后采用锁的机制向标准的字典中添加元素,并测量完成100万次迭代的时间。同样也采用同样的场景来测量ConcurrentDictionary的性能,最后比较从两个集合中获取值的性能。
通过这个非常简单的场景,我们发现ConcurrentDictionary写操作比使用锁的通常的字典要慢得多,而读操作则要快些。因此如果对字典需要大量的线程安全的读操作, ConcurrentDictionary是最好的选择。
如果你对字典只需要多线程访问只读元素,则没必要执行线程安全的读操作。在此场景中最好只使用通常的字典或ReadOnlyDictionary集合。
ConcurrentDictionary的实现使用了细粒度锁( fine-grained locking)技术,这在多线程写入方面比使用锁的通常的字典(也被称为粗粒度锁)的可伸缩性更好。正如本例中所示,当只用一个线程时,并发字典非常慢,但是扩展到5到6个线程(如果有足够的CPU核心来同时运行它们),并发字典的性能会更好。

使用 ConcurrentQueue 实现异步处理

本节将展示创建能被多个工作者异步处理的一组任务的例子。

class Program
{
    static void Main(string[] args)
    {
        Task t = RunProgram();
        t.Wait();
        Console.ReadKey();
    }

    static async Task RunProgram()
    {
        var taskQueue = new ConcurrentQueue<CustomTask>();
        var cts = new CancellationTokenSource();

        var taskSource = Task.Run(() => TaskProducer(taskQueue));

        Task[] processors = new Task[4];
        for (int i = 1; i <= 4; i++)
        {
            string processorId = i.ToString();
            processors[i-1] = Task.Run(
                () => TaskProcessor(taskQueue, "Processor " + processorId, cts.Token));
        }

        await taskSource;
        cts.CancelAfter(TimeSpan.FromSeconds(2));

        await Task.WhenAll(processors);
    }

    static async Task TaskProducer(ConcurrentQueue<CustomTask> queue)
    {
        for (int i = 1; i <= 20; i++)
        {
            await Task.Delay(50);
            var workItem = new CustomTask {Id = i};
            queue.Enqueue(workItem);
            Console.WriteLine("Task {0} has been posted", workItem.Id);
        }
    }

    static async Task TaskProcessor(
        ConcurrentQueue<CustomTask> queue, string name, CancellationToken token)
    {
        CustomTask workItem;
        bool dequeueSuccesful = false;

        await GetRandomDelay();
        do
        {
            dequeueSuccesful = queue.TryDequeue(out workItem);
            if (dequeueSuccesful)
            {
                Console.WriteLine("Task {0} has been processed by {1}", workItem.Id, name);
            }

            await GetRandomDelay();
        }
        while (!token.IsCancellationRequested);
    }

    static Task GetRandomDelay()
    {
        int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
        return Task.Delay(delay);
    }

    class CustomTask
    {
        public int Id { get; set; }
    }
}

当程序运行时,我们使用ConcurrentQueue集合实例创建了一个任务队列。然后创建了一个取消标志,它是用来在我们将任务放入队列后停止工作的。接下来启动了一个单独的工,作线程来将任务放入任务队列中。该部分为异步处理产生了工作量。
现在定义该程序中消费任务的部分。我们创建了四个工作者,它们会随机等待一段时,间,然后从任务队列中获取一个任务,处理该任务,一直重复整个过程直到我们发出取消标志信号。最后,我们启动产生任务的线程,等待该线程完成。然后使用取消标志给消费者发信号我们完成了工作。最后一步将等待所有的消费者完成。
我们看到队列中的任务按从前到后的顺序被处理,但一个后面的任务是有可能会比前面的任务先处理的,因为我们有四个工作者独立地运行,而且任务处理时间并不是恒定的。我,们看到访问该队列是线程安全的,没有一个元素会被提取两次。

改变 ConcurrentStack 异步处理顺序

本节是前一小节的细微修改版。我们又一次创建了被多个工作者异步处理的一组任务,但是这次使用ConcurrentStack来实现并看看有什么不同。

class Program
{
    static void Main(string[] args)
    {
        Task t = RunProgram();
        t.Wait();

        Console.ReadKey();
    }

    static async Task RunProgram()
    {
        var taskStack = new ConcurrentStack<CustomTask>();
        var cts = new CancellationTokenSource();

        var taskSource = Task.Run(() => TaskProducer(taskStack));

        Task[] processors = new Task[4];
        for (int i = 1; i <= 4; i++)
        {
            string processorId = i.ToString();
            processors[i - 1] = Task.Run(
                () => TaskProcessor(taskStack, "Processor " + processorId, cts.Token));
        }

        await taskSource;
        cts.CancelAfter(TimeSpan.FromSeconds(2));

        await Task.WhenAll(processors);
    }

    static async Task TaskProducer(ConcurrentStack<CustomTask> stack)
    {
        for (int i = 1; i <= 20; i++)
        {
            await Task.Delay(50);
            var workItem = new CustomTask { Id = i };
            stack.Push(workItem);
            Console.WriteLine("Task {0} has been posted", workItem.Id);
        }
    }

    static async Task TaskProcessor(
        ConcurrentStack<CustomTask> stack, string name, CancellationToken token)
    {
        await GetRandomDelay();
        do
        {
            CustomTask workItem;
            bool popSuccesful = stack.TryPop(out workItem);
            if (popSuccesful)
            {
                Console.WriteLine("Task {0} has been processed by {1}", workItem.Id, name);
            }

            await GetRandomDelay();
        }
        while (!token.IsCancellationRequested);
    }

    static Task GetRandomDelay()
    {
        int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
        return Task.Delay(delay);
    }

    class CustomTask
    {
        public int Id { get; set; }
    }
}

当程序运行时,我们创建了一个ConcurrentStack集合的实侈e其余的代码与前一小节中几乎一样,唯一不同之处是我们对并发堆栈使用Push和TryPop方法,而对并发队列使用Enqueue和TryDequeue方法。
现在可以看到任务处理的顺序被改变了。堆栈是一个LIFO集合,工作者先处理最近的任务。在并发队列中,任务被处理的顺序与被添加的顺序几乎一致。这意味着根据工作者的数量,我们必将在一定时间窗内处理先被创建的任务。而在堆栈中,早先创建的任务具有较低的优先级,而且直到生产者停止向堆栈中放入更多任务后,该任务才有可能被处理。这种行为是确定的,最好在该场景下使用队列。

使用 ConcurrentBag 创建一个爬虫

本节展示了在多个独立的既可生产工作又可消费工作的工作者间如何扩展工作量。

class Program
    {
        static void Main(string[] args)
        {
            CreateLinks();
            Task t = RunProgram();
            t.Wait();
        }

        static Dictionary<string, string[]> _contentEmulation = new Dictionary<string, string[]>();

        static async Task RunProgram()
        {
            var bag = new ConcurrentBag<CrawlingTask>();

            string[] urls = new[] {"http://microsoft.com/", "http://google.com/", "http://facebook.com/", "http://twitter.com/"};
        
            var crawlers = new Task[4];
            for (int i = 1; i <= 4; i++)
            {
                string crawlerName = "Crawler " + i.ToString();
                bag.Add(new CrawlingTask { UrlToCrawl = urls[i-1], ProducerName = "root"});
                crawlers[i - 1] = Task.Run(() => Crawl(bag, crawlerName));
            }

            await Task.WhenAll(crawlers);

            Console.ReadKey();
        }

        static async Task Crawl(ConcurrentBag<CrawlingTask> bag, string crawlerName)
        {
            CrawlingTask task;
            while (bag.TryTake(out task))
            {
                IEnumerable<string> urls = await GetLinksFromContent(task);
                if (urls != null)
                {
                    foreach (var url in urls)
                    {
                        var t = new CrawlingTask
                        {
                            UrlToCrawl = url,
                            ProducerName = crawlerName
                        };

                        bag.Add(t);
                    }
                }
                Console.WriteLine("Indexing url {0} posted by {1} is completed by {2}!",
                    task.UrlToCrawl, task.ProducerName, crawlerName);
            }
        }

        static async Task<IEnumerable<string>> GetLinksFromContent(CrawlingTask task)
        {
            await GetRandomDelay();

            if (_contentEmulation.ContainsKey(task.UrlToCrawl)) return _contentEmulation[task.UrlToCrawl];

            return null;
        }

        static void CreateLinks()
        {
            _contentEmulation["http://microsoft.com/"] = new [] { "http://microsoft.com/a.html", "http://microsoft.com/b.html" };
            _contentEmulation["http://microsoft.com/a.html"] = new[] { "http://microsoft.com/c.html", "http://microsoft.com/d.html" };
            _contentEmulation["http://microsoft.com/b.html"] = new[] { "http://microsoft.com/e.html" };

            _contentEmulation["http://google.com/"] = new[] { "http://google.com/a.html", "http://google.com/b.html" };
            _contentEmulation["http://google.com/a.html"] = new[] { "http://google.com/c.html", "http://google.com/d.html" };
            _contentEmulation["http://google.com/b.html"] = new[] { "http://google.com/e.html", "http://google.com/f.html" };
            _contentEmulation["http://google.com/c.html"] = new[] { "http://google.com/h.html", "http://google.com/i.html" };

            _contentEmulation["http://facebook.com/"] = new [] { "http://facebook.com/a.html", "http://facebook.com/b.html" };
            _contentEmulation["http://facebook.com/a.html"] = new[] { "http://facebook.com/c.html", "http://facebook.com/d.html" };
            _contentEmulation["http://facebook.com/b.html"] = new[] { "http://facebook.com/e.html" };

            _contentEmulation["http://twitter.com/"] = new[] { "http://twitter.com/a.html", "http://twitter.com/b.html" };
            _contentEmulation["http://twitter.com/a.html"] = new[] { "http://twitter.com/c.html", "http://twitter.com/d.html" };
            _contentEmulation["http://twitter.com/b.html"] = new[] { "http://twitter.com/e.html" };
            _contentEmulation["http://twitter.com/c.html"] = new[] { "http://twitter.com/f.html", "http://twitter.com/g.html" };
            _contentEmulation["http://twitter.com/d.html"] = new[] { "http://twitter.com/h.html" };
            _contentEmulation["http://twitter.com/e.html"] = new[] { "http://twitter.com/i.html" };
        }

        static Task GetRandomDelay()
        {
            int delay = new Random(DateTime.Now.Millisecond).Next(150, 200);
            return Task.Delay(delay);
        }

        class CrawlingTask
        {
            public string UrlToCrawl { get; set; }

            public string ProducerName { get; set; }
        }
    }

该程序模拟了使用多个网络爬虫进行网页索引的场景。网络爬虫是这样一个程序:它使用网页地址打开一个网页,索引该网页内容,尝试访问该页面包含的所有链接,并且也索引这些链接页面。刚开始,我们定义了一个包含不同网页URL的字典。该字典模拟了包含其,他页面链接的网页。该实现非常简单,并不关心索引已经访问过的页面,但正因为它如此简单我们才可以关注并行工作负载。
接着创建了一个并发包,其中包含爬虫任务。我们创建了四个爬虫,并且给每个爬虫都提供了一个不同的网站根URL,然后等待所有爬虫完成工作。现在每个爬虫开始检索提供给,它的网站URL,我们通过等待一个随机事件来模拟网络10处理。如果页面包含的URL越多,爬虫向包中放入的任务也会越多。然后检查包中是否还有任何需要爬虫处理的任务,如果没有说明爬虫完成了工作。
如果检查前四个根URL后的第一行输出内容,我们将看到被爬虫N放置的任务通常会,被同一个爬虫处理。然而,接下来的行则会不同。这是因为ConcurrentBag内部针对多个线程既可以添加元素又可以删除元素的场景进行了优化。实现方式是每个线程使用自己的本地,队列的元素,所以使用该队列时无需任何锁。只有当本地队列中没有任何元素时,我们才执行一些锁定操作并尝试从其他线程的本地队列中“偷取”工作。这种行为有助于在所有工作者间分发工作并避免使用锁。

使用 BlockingCollection 进行异步处理

本节将描述如何使用BlockingCollection来简化实现异步处理的工作负载。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Using a Queue inside of BlockingCollection");
        Console.WriteLine();
        Task t = RunProgram();
        t.Wait();

        Console.WriteLine();
        Console.WriteLine("Using a Stack inside of BlockingCollection");
        Console.WriteLine();
        t = RunProgram(new ConcurrentStack<CustomTask>());
        t.Wait();
    }

    static async Task RunProgram(IProducerConsumerCollection<CustomTask> collection = null)
    {
        var taskCollection = new BlockingCollection<CustomTask>();
        if(collection != null)
            taskCollection= new BlockingCollection<CustomTask>(collection);

        var taskSource = Task.Run(() => TaskProducer(taskCollection));

        Task[] processors = new Task[4];
        for (int i = 1; i <= 4; i++)
        {
            string processorId = "Processor " + i;
            processors[i - 1] = Task.Run(
                () => TaskProcessor(taskCollection, processorId));
        }

        await taskSource;

        await Task.WhenAll(processors);
    }

    static async Task TaskProducer(BlockingCollection<CustomTask> collection)
    {
        for (int i = 1; i <= 20; i++)
        {
            await Task.Delay(20);
            var workItem = new CustomTask { Id = i };
            collection.Add(workItem);
            Console.WriteLine("Task {0} has been posted", workItem.Id);
        }
        collection.CompleteAdding();
    }

    static async Task TaskProcessor(
        BlockingCollection<CustomTask> collection, string name)
    {
        await GetRandomDelay();
        foreach (CustomTask item in collection.GetConsumingEnumerable())
        {
            Console.WriteLine("Task {0} has been processed by {1}", item.Id, name);
            await GetRandomDelay();
        }
    }

    static Task GetRandomDelay()
    {
        int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
        return Task.Delay(delay);
    }

    class CustomTask
    {
        public int Id { get; set; }
    }

}

先说第一个场景,这里我们使用了BlockingCollection类,它带来了很多优势。首先,我们能够改变任务存储在阻塞集合中的方式。默认情况下它使用的是ConcurrentQueue容器,但是我们能够使用任何实现了IProducerConsumerCollection泛型接口的集合。为了演示该点,我们运行了该程序两次,第二次时使用ConcurrentStack作为底层集合。
工作者通过对阻塞集合迭代调用GetConsumingEnumerable方法来获取工作项。如果在该集合中没有任何元素,迭代器会阻塞工作线程直到有元素被放置到集合中。当生产者调用集合的CompleteAdding时该迭代周期会结束。这标志着工作完成了。
这里很容易犯一个错误,即对BlockingCollection进行迭代,因为它自身实现了IEnumerable接口。不要忘记使用GetConsumingEnumerable,否则你迭代的只是集合的“快照”,这并不是期望的程序行为。
工作量生产者将任务插入到BlockingCollection然后调用CompleteAdding方法,这会使所有工作者完成工作。现在在程序输出中我们看到两个结果序列,演示了并发队列和堆栈集合的不同之处。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值