阅读导航

一、使用Task

二、并行编程

三、线程同步

四、异步编程模型

五、多线程数据安全

六、异常处理

概述

现代程序开发过程中不可避免会使用到多线程相关的技术,之所以要使用多线程,主要原因或目的大致有以下几个:

1、 业务特性决定程序就是多任务的,比如,一边采集数据、一边分析数据、同时还要实时显示数据;

2、 在执行一个较长时间的任务时,不能阻塞UI界面响应,必须通过后台线程处理;

3、 在执行批量计算密集型任务时,采用多线程技术可以提高运行效率。

传统使用的多线程技术有:

  1. Thread & ThreadPool
  2. Timer
  3. BackgroundWorker

目前,这些技术都不再推荐使用了,目前推荐采用基于任务的异步编程模型,包括并行编程和Task的使用。

一、使用Task:

大部分情况下,多线程的应用场景是在后台执行一个较长时间的任务时,不能阻塞界面响应,同时,任务还是可以取消的。

下面我们实现一个简单的示例功能:用户点击Start按钮时启动一个任务,任务执行过程中通过进度条显示任务进度,点击Stop按钮结束任务。

    public partial class Form1 : Form
{
private volatile bool CancelWork = false; public Form1()
{
InitializeComponent();
} private void btnStart_Click(object sender, EventArgs e)
{
this.btnStart.Enabled = false;
this.btnStop.Enabled = true; CancelWork = false;
Task.Run(() => WorkThread());
} private void btnStop_Click(object sender, EventArgs e)
{
CancelWork = true;
} private void WorkThread()
{
for (int i = ; i < ; i++)
{
this.Invoke(new Action(() =>
{
this.progressBar.Value = i;
})); Thread.Sleep(); if(CancelWork)
{
break;
}
} this.Invoke(new Action(() =>
{
this.btnStart.Enabled = true;
this.btnStop.Enabled = false;
}));
}
}

这个代码写的中规中矩,没什么特别的地方,仅仅是用Tsak取代了早期经常采用的Thread、ThreadPool等,虽然Task内部也是对ThreadPool的封装,但仍然建议尽量采用TASK来实现多任务。

注意:虽然可以通过代码强行结束一个任务,但强烈建议不要这样做,应该给它一个通知让其自己结束。

二、并行编程:

目标:通过一个计算素数的方法,循环计算并打印出10000以内的素数。

计算一个数是否素数的方法:

        private static bool IsPrimeNumber(int number)
{
if (number < )
{
return false;
} if (number == && number == )
{
return true;
} for (int i = ; i < number; i++)
{
if (number % i == )
{
return false;
}
} return true;
}

如果不采用并行编程,常规实现方法:

            for (int i = ; i <= ; i++)
{
bool b = IsPrimeNumber(i);
Console.WriteLine($"{i}:{b}");
}

采用并行编程方法:

           Parallel.For(, , x=>
{
bool b = IsPrimeNumber(x);
Console.WriteLine($"{i}:{b}");
});

运行程序发现时间差异并不大,主要原因是瓶颈在打印控制台上面,去掉打印代码,只保留计算代码,就可以看出性能差异。

Parallel实际是通过线程池进行任务的分配,线程池的最小线程数和最大线程数将影响到整个程序的性能,需要合理设置。(最小线程默认为8。)

            ThreadPool.SetMinThreads(, );
ThreadPool.SetMaxThreads(, );

按照上述设置,假设线程任务耗时比较长不能很快结束。在启动前面10个线程时速度很快,第10~20个线程就比较慢一点,大约0.5秒,到达20个线程以后,如果前期任务没有结束就不能继续分配任务了。

和Task类似,Parallel类仍然是对ThreadPool的封装,但Parallel有一个优势,它能知道所有任务是否完成,如果采用线程池来实现批量任务,我们需要自己通过计数的方式确定所有子任务是否全部完成。

Parallel类还有一个ForEach方法,使用和For类似,就不重复描述了。

三、 线程(或任务)同步

有时我们需要通知一个任务结束,或一个任务等待某个条件进入下一个状态,这就需要用到任务同步的技术。

一个比较简单的方法就是定义一个变量来表示状态。

private volatile bool CancelWork = false;

后台任务可以轮询该变量进行判断:

            for (int i = ; i < ; i++)
{
if(CancelWork)
{
break;
}
}

这是我们常用的方法,可以称为线程状态机同步(虽然只有两个状态)。需要注意的是在通过轮询去读取状态时,循环体内至少应该有1ms的Sleep,不然CPU会很高。

线程同步还有一个比较好的办法就是采用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 = ; i < ; i++)
{
this.Invoke(new Action(() =>
{
this.progressBar.Value = i;
})); if(manualResetEvent.WaitOne())
{
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 。

四、异步编程模型(await、async)

假设我们要实现一个简单的功能:当点击启动按钮时,运行一个任务,任务结束时要报告是否成功,如果成功就显示绿色图标、如果失败就显示红色图标,1秒后图标颜色恢复为白色;任务运行期间启动按钮要不可用。

我写了相关代码:

    public partial class Form1 : Form
{
private void btnStart_Click(object sender, EventArgs e)
{
this.btnStart.Enabled = false; if(DoSomething())
{
this.picShow.BackColor = Color.Green;
}
else
{
this.picShow.BackColor = Color.Red;
} Thread.Sleep(); this.picShow.BackColor = Color.White;
this.btnStart.Enabled = true;
} private bool DoSomething()
{
Thread.Sleep();
return true;
}
}

这段代码逻辑清晰、条理清楚,一看就能明白,但存在两个问题:

1、运行期间UI线程阻塞了,用户界面没有响应;

2、根本不能实现需求,点击启动后,程序卡死6秒种,也没有看到颜色变化,因为UI线程已经阻塞,当重新获得句柄时图标已经是白色了。

为了实现需求,我们改用多任务来实现相关功能:

    public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
} private void btnStart_Click(object sender, EventArgs e)
{
this.btnStart.Enabled = false; Task.Run(() =>
{
if (DoSomething())
{
this.Invoke(new Action(() =>
{
this.picShow.BackColor = Color.Green;
}));
}
else
{
this.Invoke(new Action(() =>
{
this.picShow.BackColor = Color.Red;
}));
} Thread.Sleep(); this.Invoke(new Action(() =>
{
this.btnStart.Enabled = true;
this.picShow.BackColor = Color.White;
}));
});
} private bool DoSomething()
{
Thread.Sleep();
return true;
}
}

以上代码完全实现了最初的需求,但有几个不完美的地方:

1、主线程的btnStart_Click方法除了启动一个任务以外,啥事也没干;

2、由于非UI线程不能访问UI控件,代码里有很多Invoke,比较丑陋;

3、界面逻辑和业务逻辑掺和在一起,使得代码难以理解。

采用C#的异步编程模型,通过使用await、async关键字,可以更好地实现上述需求。

    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(); this.picShow.BackColor = Color.White;
this.btnStart.Enabled = true;
} private async Task<bool> DoSomethingAsync()
{
await Task.Run(() =>
{
Thread.Sleep();
});
return true;
}
}

这段代码看起来就像是同步代码,其业务逻辑是如此的清晰优雅,让人一目了然,关键是它还不阻塞线程,UI正常响应。

可以看到,通过使用await关键字,我们可以专注于业务功能实现,特别是后续任务需要前序任务的返回值的情况下,可以大量减少任务之间的同步操作,代码的可读性也大大增强。

五、 多线程环境下的数据安全

目标:我们要向一个字典加入一些数据项,为了增加效率,我们使用了多个线程。

       private async static void Test1()
{
Task.Run(() => AddData());
Task.Run(() => AddData());
Task.Run(() => AddData());
Task.Run(() => AddData());
} private static void AddData()
{
for (int i = ; i < ; i++)
{
if(!Dic.ContainsKey(i))
{
Dic.Add(i, i.ToString());
} Thread.Sleep();
}
}

向字典重复加入同样的关键字会引发异常,所以在增加数据前我们检查一下是否已经包含该关键字。以上代码看似没有问题,但有时还是会引发异常:“已添加了具有相同键的项。”原因在于我们在检查是否包含该Key时是不包含的,但在新增时其他线程加入了同样的KEY,当前线程再增加就报错了。

【注意:也许你多次运行上述程序都能顺利执行,不报异常,但还是要清楚认识到上述代码是有问题的!毕竟,程序在大部分情况下都运行正常,偶尔报一次故障才是最头疼的事情。】

上述问题传统的解决方案就是增加锁机制。对于核心的修改代码通过锁来确保不会重入。

        private object locker4Add=new object();
private static void AddData()
{
for (int i = ; i < ; i++)
{
lock (locker4Add)
{
if (!Dic.ContainsKey(i))
{
Dic.Add(i, i.ToString());
}
} Thread.Sleep();
}
}

以上代码可以解决问题,但不是最佳方案。更好的方案是使用线程安全的容器: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 = ; i < ; i++)
{
Dic.TryAdd(i, i.ToString());
Thread.Sleep();
}
}

你可以在新增前继续检查一下容器是否已经包含该Key,你也可以不用检查,TryAdd方法确保不会重复添加且不会产生异常。

刚才是多个线程同时写某个对象,如果就单个线程写对象,其他多个线程仅仅是消费(访问)对象,是否可以使用非线程安全的容器呢?

基本上来说多个线程读取一个对象是没有太大问题的,但还是会存在一些要注意的地方:

1、对于常用的List,在对其进行foreach时List对象不能被修改,不仅不能Remove,Add也不可以;否则会报一个异常:异常信息:”集合已修改;可能无法执行枚举操作。”

2、还有一个类似的问题 就是调用Dictionary的ToList方法时有时会报错,将Dictionary 类型改成ConcurrentDictionary类型,问题依然存在,其原因是ToList会读取字典的Count,创建相关大小的区域后执行复制,而此时字典的长度增加了。

以上只是描述了多线程数据访问的两个小例子,实际使用中相关的问题一定会远远不止这些,多线程程序的大部分异常都是因为资源竞争引起的(包括死锁),一定要小心处理。

六、多线程的异常处理

(一) 异常处理的几个基本原则

1、 基本原则:不要轻易捕获根异常

2、 组件或控件抛出异常时可以根据需要自定义一些异常,不要抛出根异常,可以直接使用的常用异常有:FormatException、IndexOutOfRangException、InvalidOperationException、InvalidEnumArgumentException ;没有合适的就自定义;

3、 用户自定义异常从ApplicationException继承;

4、 多线程的内部异常不会传播到主线程,应该在内部进行处理,可以通过事件推到主线程来;

5、应用程序层面可以捕获根异常,做一些记录工作,切不可隐匿异常。

(二) 异常处理方案(基于WPF实现)

主线程的异常处理:

捕获你知道的异常,并自行处理,但不要轻易捕获根异常,下面的代码令人深恶痛绝:

            try
{
DoSomething();
}
catch(Exception)
{
//Do Nothing
}

当然,如果你确定有能力捕获根异常,并且是业务逻辑的一部分,可以捕获根异常 :

            try
{
DoSomething();
MessageBox.Show("OK");
}
catch(Exception ex)
{
MessageBox.Show($"ERROR:{ex.Message}");
}

可等待异步任务的异常处理:

可等待的任务内的异常是可以传递到调用者线程的,可以按照主线程异常统一处理:

            try
{
await DoSomething();
}
catch(FormatException ex)
{
//Do Something
}

Task任务内部异常处理:

非可等待的Task任务内部异常是无法传递到调用者线程的,参考下面代码:

            try
{
Task.Run(() =>
{
string s = "aaa";
int i = int.Parse(s);
});
}
catch (FormatException ex)
{
MessageBox.Show("Error");
}

上面代码不会实现你期望的效果,它只会造成程序的崩溃。(有时候不会立即崩溃,后面会有解释)

处理办法有两个:

1、自行处理:(1)处理可以预料的异常,(2)同时处理根异常(写日志等),也可以不处理根异常,后面统一处理;

2、或将异常包装成事件推送到主线程,交给主线程处理。

public partial class FormSync : Form
{
private event EventHandler<UnhangdledExceptionArgs> UnhandledExceptionCatched; private void Form_Load()
{
UnhandledExceptionCatched += MainWindow_UnhandledExceptionCatched;
}
private void MainWindow_UnhandledExceptionCatched(object sender, UnhangdledExceptionArgs e)
{
MessageBox.Show($"Catch Exception:{e.InnerException.Message}");
} private void Thread1()
{
Task.Run(()=>
{
try
{
throw new ApplicationException("Thread Exception");
}
catch (Exception ex)
{
UnhangdledExceptionArgs args = new UnhangdledExceptionArgs()
{
InnerException = ex
};
UnhandledExceptionCatched?.Invoke(null, args);
}
});
}
} public class UnhangdledExceptionArgs : EventArgs
{
public Exception InnerException { get; set; }
}

Thread和ThreadPool内部异常:

虽然不推荐使用Thread,如果实在要用,其处理原则和上述普通Task任务内部异常处理方案一致。

全局未处理异常的处理:

虽然我们不推荐catch根异常,但如果一旦发生未知异常程序就崩溃,客户恐怕难以接受吧,如果要求所有业务模块都处理根异常并进行保存日志、弹出消息等操作又非常繁琐,所以,处理的思路是业务模块不处理根异常,但应用程序要对未处理异常进行统一处理。

    public partial class App : Application
{
App()
{
this.Startup += App_Startup;
} private void App_Startup(object sender, StartupEventArgs e)
{
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
} //主线程未处理异常
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
DoSomething(e.Exception);
e.Handled = true;
} //未处理线程异常(如果主线程未处理异常已经处理,该异常不会触发)
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is Exception ex)
{
DoSomething(ex);
}
} //未处理的Task内异常
private void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
DoSomething(e.Exception);
} //保存、显示异常信息
private void ProcessException(Exception exception)
{
//保存日志
//提醒用户
}
}

解释一下:

1、 当主线程发生未处理异常时会触发App_DispatcherUnhandledException事件,在该事件中如果设置e.Handled = true,那么系统不会崩溃,如果没有设置e.Handled = true,会继续触发CurrentDomain_UnhandledException事件(毕竟主线程也是线程),而CurrentDomain_UnhandledException事件和TaskScheduler_UnobservedTaskException事件触发后,操作系统都会强行关闭这个应用程序。所以我们应该在App_DispatcherUnhandledException事件中设置e.Handled = true。

2、Thread线程异常会触发CurrentDomain_UnhandledException事件,导致系统崩溃,所以建议尽量不要使用Thread和ThreadPool。

3、非可等待的Task内部异常会触发TaskScheduler_UnobservedTaskException事件,导致系统崩溃,所以建议Task内部自行处理根异常或将异常封装为事件推到主线程。需要额外注意一点:Task内的未处理异常不会被立即触发事件,而是要延迟到GC执行回收的时候才触发,这使得问题更复杂,需要小心处理。

总之

当前,异步编程模型已经是.NET框架的基本功能了,特别是WEB开发,后台代码已经全面异步化了,所以每个C#开发人员都不能轻视它,必须熟练掌握。 虽然在一知半解的情况下也能写多线程程序,写的程序也能跑,但就是那些平时一切正常偶尔抽风一下的错误会让头痛不已。只有深刻了解多线程的内部原理,并遵循结构化的设计原则才能写出健壮、优美的代码。

谈谈C#多线程开发:并行、并发与异步编程的更多相关文章

  1. Java的虚拟线程(协程)特性开启预览阶段,多线程开发的难度将大大降低

    高并发.多线程一直是Java编程中的难点,也是面试题中的要点.Java开发者也一直在尝试使用多线程来解决应用服务器的并发问题.但是多线程并不容易,为此一个新的技术出现了,这就是虚拟线程. 传统多线程的 ...

  2. C#图解教程 第二十章 异步编程

    笔记 异步编程 什么是异步 示例 async/await特性的结构什么是异步方法 异步方法的控制流await表达式取消一个异步操作异常处理和await表达式在调用方法中同步地等待任务在异步方法中异步地 ...

  3. 初步谈谈 C# 多线程、异步编程与并发服务器

    多线程与异步编程可以达到避免调用线程异步阻塞作用,但是两者还是有点不同. 多线程与异步编程的异同: 1.线程是cpu 调度资源和分配的基本单位,本质上是进程中的一段并发执行的代码. 2.线程编程的思维 ...

  4. 关于Web开发里并发、同步、异步以及事件驱动编程的相关技术

    一.开篇语 我的上篇文章<关于如何提供Web服务端并发效率的异步编程技术>又成为了博客园里“编辑推荐”的文章,这是对我写博客很大的鼓励,也许是被推荐的原因很多童鞋在这篇文章里发表了评论,有 ...

  5. C# 并发编程 (异步编程与多线程)

    并发:同时做多件事情 多线程:并发的一种形式,它采用多个线程来执行程序. 并行处理:把正在执行的大量的任务分割成小块,分配给多个同时运行的线程.并行处理是多线程的一种,而多线程是并发的一种. 异步编程 ...

  6. 串行&并行&并发,同步&异步

    1. 串行&并行&并发 1.1 串行 这个非常好理解,字面意思,像串成一个串一样,顺序执行 上一个没执行完的话,后面的就必须无条件等待 一般情况就是一个线程里:任务一个接一个执行,类似 ...

  7. 并发\并行,同步\异步,阻塞\非阻塞,IO多路复用解释

    并发.并行 并发:是指一个时间段内,有几个程序在同一个CPU上运行,但是任意时刻只有一个程序在CPU上运行.由于CPU的运行速度极快,可以在多个程序之间切换,这样造成一个假象就是多个程序同时在运行.并 ...

  8. C++多线程并发---异步编程

    线程同步主要是为了解决对共享数据的竞争访问问题,所以线程同步主要是对共享数据的访问同步化(按照既定的先后次序,一个访问需要阻塞等待前一个访问完成后才能开始).这篇文章谈到的异步编程主要是针对任务或线程 ...

  9. Java多线程专题1: 并发与并行的基础概念

    合集目录 Java多线程专题1: 并发与并行的基础概念 什么是多线程并发和并行? 并发: Concurrency 特指单核可以处理多任务, 这种机制主要实现于操作系统层面, 用于充分利用单CPU的性能 ...

随机推荐

  1. MySQL 索引、视图

    1.索引 什么是索引 一个索引是存储在表中的数据结构,索引在表的列名上创建.索引中包含了一个列的值,这些值保存在一个数据结构中 索引优缺点 索引大大提高了查询速度 会降低更新表的速度,如对表进行INS ...

  2. 如何使用Golang实现一个API网关

    你是否也存在过这样的需求,想要公开一个接口到网络上.但是还得加点权限,否则被人乱调用就不好了.这个权限验证的过程,最好越简单越好,可能只是对比两个字符串相等就够了.一般情况下我们遇到这种需要,就是在函 ...

  3. TSP变形(三进制状压)

    题目:HDU3001 #include <bits/stdc++.h> using namespace std; ],vis[][],dis[][]; ][]; void init()// ...

  4. java线程池原理解析

    五一假期大雄看了一本<java并发编程艺术>,了解了线程池的基本工作流程,竟然发现线程池工作原理和互联网公司运作模式十分相似. 线程池处理流程 原理解析 互联网公司与线程池的关系 这里用一 ...

  5. 201771030115-牛莉梅 实验一 软件工程准备-<初学《构建之法--现代软件工程》的疑问>

    项目 内容 课程班级博客链接 https://edu.cnblogs.com/campus/xbsf/nwnu2020SE 这个作业要求链接 https://www.cnblogs.com/nwnu- ...

  6. 第九章:Python高级编程-Python socket编程

    第九章:Python高级编程-Python socket编程 Python3高级核心技术97讲 笔记 9.1 弄懂HTTP.Socket.TCP这几个概念 Socket为我们封装好了协议 9.2 cl ...

  7. JAVA知识总结(二):封装

    时隔近一年,我突然想起来这个文章还没有发完,所以就继续开始写.也不知道自己上次写到哪里了,不管了这里从面向对象的三个特性说起. 类和对象 在这之前,我们先了解什么是对象,已经什么是面向对象?对象:万物 ...

  8. Ubuntu 1804 安装xmind8详细过程

    安装比较简单, 折腾了很久,一启动就报错,切换了JDK版本就能用了: 安装 登陆官网,下载xmind8: 下载得到文件xmind-8-update9-linux.zip: 将文件解压至路径xmind下 ...

  9. [hdu3644 A Chocolate Manufacturer's Problem]模拟退火,简单多边形内最大圆

    题意:判断简单多边形内是否可以放一个半径为R的圆 思路:如果这个多边形是正多边形,令r(x,y)为圆心在(x,y)处多边形内最大圆的半径,不难发现,f(x,y)越靠近正多边形的中心,r越大,所以可以利 ...

  10. ruoyi-plus-server(一):引入Mybatis-Plus

    背景:著名开源管理系统ruoyi-vue是基于SpringBoot,Spring Security,JWT,Vue & Element 的前后端分离权限管理系统(https://gitee.c ...