概述
现代程序开发过程中不可避免会使用到多线程相关的技术,之所以要使用多线程,主要原因或目的大致有以下几个:
1、 业务特性决定程序就是多任务的,比如,一边采集数据、一边分析数据、同时还要实时显示数据;
2、 在执行一个较长时间的任务时,不能阻塞UI界面响应,必须通过后台线程处理;
3、 在执行批量计算密集型任务时,采用多线程技术可以提高运行效率。
传统使用的多线程技术有:
- Thread & ThreadPool
- Timer
- BackgroundWorker
目前,这些技术都不再推荐使用了,目前推荐采用基于任务的异步编程模型,包括并行编程和Task的使用。
Concurrency并发和Multi-thread多线程不同
你在吃饭的时候,突然来了电话。
- 你吃完饭再打电话,这既不并发也不多线程
- 你吃一口饭,再打电话说一句话,然后再吃饭,再说一句话,这是并发,但不多线程。
- 你有2个嘴巴。一个嘴巴吃饭,一个嘴巴打电话。这就是多线程,也是并发。
并发:表示多个任务同时执行。但是有可能在内核是串行执行的。任务被分成了多个时间片,不断切换上下文执行。
多线程:表示确实有多个处理内核,可同时处理多个任务。
一、并发编程:
使用ThreadPool轮询并发
方法是使用一个List(或其他容器)把所有的对象放进去,创建一个线程(为了防止UI假死,由于这个线程创建后会一直执行切运算密集,所以使用TheadPool和Thread差别不大),在这个线程中使用foreach(或for)循环依次对每个对象执行ReceiveData方法,每次执行的时候创建一个线程池线程来执行。代码如下:
使用Task轮询并发
方法与ThreadPool类似,只是每次创建线程池线程执行ReceiveData方法时是通过Task创建的线程。代码如下所示:
二、并行编程:
private static bool IsPrimeNumber(int number) { if (number < 1) { return false; } if (number == 1 && number == 2) { return true; } for (int i = 2; i < number; i++) { if (number % i == 0) { return false; } } return true; }
如果不采用并行编程,常规实现方法:
for (int i = 1; i <= 10000; i++) { bool b = IsPrimeNumber(i); Console.WriteLine($"{i}:{b}"); }
采用并行编程方法
Parallel.For(1, 10000, x=> { bool b = IsPrimeNumber(x); Console.WriteLine($"{i}:{b}"); })
Parallel类还有一个ForEach方法,使用和For类似。
三、 线程(或任务)同步
线程同步还有一个比较好的办法就是采用ManualResetEvent 和AutoResetEvent :
public partial class Form1 : Form { private ManualResetEvent manualResetEvent = new ManualResetEvent(false); public Form1() { InitializeComponent(); } private void btnStart_Click(object sender, EventArgs e) { this.btnStart.Enabled = false; this.btnStop.Enabled = true; manualResetEvent.Reset(); Task.Run(() => WorkThread()); } private void btnStop_Click(object sender, EventArgs e) { manualResetEvent.Set(); } private void WorkThread() { for (int i = 0; i < 100; i++) { this.Invoke(new Action(() => { this.progressBar.Value = i; })); if(manualResetEvent.WaitOne(1000)) { break; } } this.Invoke(new Action(() => { this.btnStart.Enabled = true; this.btnStop.Enabled = false; })); } }
采用WaitOne来等待比通过Sleep进行延时要更好,因为当执行manualResetEvent.WaitOne(1000)时,如果manualResetEvent没有调用Set,该方法在等待1000ms后返回false,如果期间调用了manualResetEvent的Set方法,该方法会立即返回true,不用等待剩下的时间。
采用这种同步方式优于采用通过内部字段变量进行同步的方式,另外尽量采用ManualResetEvent 而不是AutoResetEvent
public partial class Form1 : Form { public Form1() { InitializeComponent(); } private async void btnStart_ClickAsync(object sender, EventArgs e) { this.btnStart.Enabled = false; var result = await DoSomethingAsync(); if(result) { this.picShow.BackColor = Color.Green; } else { this.picShow.BackColor = Color.Red; } await Task.Delay(1000); this.picShow.BackColor = Color.White; this.btnStart.Enabled = true; } private async Task<bool> DoSomethingAsync() { await Task.Run(() => { Thread.Sleep(5000); }); return true; } }
五、 多线程环境下的数据安全
目标:我们要向一个字典加入一些数据项,为了增加效率,我们使用了多个线程
private async static void Test1() { Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); } private static void AddData() { for (int i = 0; i < 100; i++) { if(!Dic.ContainsKey(i)) { Dic.Add(i, i.ToString()); } Thread.Sleep(50); } }
向字典重复加入同样的关键字会引发异常,所以在增加数据前我们检查一下是否已经包含该关键字。以上代码看似没有问题,但有时还是会引发异常:“已添加了具有相同键的项。”原因在于我们在检查是否包含该Key时是不包含的,但在新增时其他线程加入了同样的KEY,当前线程再增加就报错了。
【注意:也许你多次运行上述程序都能顺利执行,不报异常,但还是要清楚认识到上述代码是有问题的!毕竟,程序在大部分情况下都运行正常,偶尔报一次故障才是最头疼的事情。】
上述问题传统的解决方案就是增加锁机制。对于核心的修改代码通过锁来确保不会重入。
private object locker4Add=new object(); private static void AddData() { for (int i = 0; i < 100; i++) { lock (locker4Add) { if (!Dic.ContainsKey(i)) { Dic.Add(i, i.ToString()); } } Thread.Sleep(50); } }
更好的方案是使用线程安全的容器:ConcurrentDictionary
private static ConcurrentDictionary<int, string> Dic = new ConcurrentDictionary<int, string>(); private async static void Test1() { Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); } private static void AddData() { for (int i = 0; i < 100; i++) { Dic.TryAdd(i, i.ToString()); Thread.Sleep(50); } }