C#网络编程入门之UDP
一、概述
UDP和TCP是网络通讯常用的两个传输协议,C#一般可以通过Socket来实现UDP和TCP通讯,由于.NET框架通过UdpClient、TcpListener 、TcpClient这几个类对Socket进行了封装,使其使用更加方便, 本文就通过这几个封装过的类讲解一下相关应用。
二、UDP基本应用
与TCP通信不同,UDP通信是不分服务端和客户端的,通信双方是对等的。为了描述方便,我们把通信双方称为发送方和接收方。
发送方:
首先创建一个UDP对象:
string locateIP = "127.0.0.1"; //本机IP int locatePort = 9001; //发送端口 IPAddress locateIpAddr = IPAddress.Parse(locateIP); IPEndPoint locatePoint = new IPEndPoint(locateIpAddr, locatePort); UdpClient udpClient = new UdpClient(locatePoint);
发送数据:
string remoteIP = "127.0.0.1"; //目标机器IP int remotePort = 9002; //接收端口 IPAddress remoteIpAddr = IPAddress.Parse(remoteIP); IPEndPoint remotePoint = new IPEndPoint(remoteIpAddr, remotePort); byte[] buffer = Encoding.UTF8.GetBytes(“hello”); udpClient.Send(buffer, buffer.Length, remotePoint);
以上就完成了一个发送任务,一个较完整的发送代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
接收端:
首先创建一个UDP对象:
string locateIP = "127.0.0.1"; int locatePort = 9002; IPAddress locateIpAddr = IPAddress.Parse(locateIP); IPEndPoint locatePoint = new IPEndPoint(locateIpAddr, locatePort); UdpClient udpClient = new UdpClient(locatePoint);
接收数据:
IPEndPoint remotePoint = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 1); var received = udpClient.Receive(ref remotePoint); string info = Encoding.UTF8.GetString(received); string from=$” {remotePoint.Address}:{remotePoint.Port}”;
注意两点:
1、remotePoint是获得发送方的IP信息,定义时可以输入任何合法的IP和端口信息;
2、Receive方法是阻塞方法,所以需要在新的线程内运行,程序会一直等待接收数据,当接收到一包数据时程序就返回,要持续接收数据需要重复调用Receive方法。
一个较完整的接收端代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
三、丢包和乱序问题
当发送端发送一包数据时,不管对方是否接收都是发送成功的,UDP协议本身并不会对发送的可靠性进行验证。(这里的可靠性是指是否接收到,如果对方接收到数据包,其内容还是可靠的,这个在链路层进行了保证。)同时,由于网络延时等因素,先发送的包并不能确定先被接收到,所以由于这两个原因,UDP通信存在丢包和乱序的情况。
某些业务场景下,比如实时状态监控,可能对丢包和乱序情况并不敏感, 可以不用处理,但大部分情况下还是介意丢包的,简单的处理办法就是把包的头部固定长度的空间拿出来存放核对信息,比如包编号,如果有缺失,可以要求发送方重发,也可以进行排序。
四、将数据接收包装为事件
我们对UdpClent又进行一次封装,启用一个线程进行接收数据,将接收到的数据包通过事件发布出来,这样使用起来就更方便了。
namespace Communication.UDPClient { public class UdpStateEventArgs : EventArgs { public IPEndPoint remoteEndPoint; public byte[] buffer = null; } public delegate void UDPReceivedEventHandler(UdpStateEventArgs args); public class UDPClient { private UdpClient udpClient; public event UDPReceivedEventHandler UDPMessageReceived; public UDPClient(string locateIP, int locatePort) { IPAddress locateIp = IPAddress.Parse(locateIP); IPEndPoint locatePoint = new IPEndPoint(locateIp, locatePort); udpClient = new UdpClient(locatePoint); //监听创建好后,创建一个线程,开始接收信息 Task.Run(() => { while (true) { UdpStateEventArgs udpReceiveState = new UdpStateEventArgs(); if (udpClient != null) { IPEndPoint remotePoint = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 1); var received = udpClient.Receive(ref remotePoint); udpReceiveState.remoteEndPoint = remotePoint; udpReceiveState.buffer = received; UDPMessageReceived?.Invoke(udpReceiveState); } else { break; } } }); } } }
具体使用办法:
private void btnConnect_Click(object sender, EventArgs e) { string locateIP = "127.0.0.1"; int locatePort = 9002; UDPClient udpClient = new UDPClient(locateIP, locatePort); udpClient.UDPMessageReceived += UdpClient_UDPMessageReceived; } private void UdpClient_UDPMessageReceived(UdpStateEventArgs args) { var remotePoint = args.remoteEndPoint; string info = Encoding.UTF8.GetString(args.buffer); }
限于篇幅,我们只封装了数据接收,时间使用时需要把发送功能也封装进去,使这个类同时具备发送和接收功能,发送功能的封装比较简单就不贴代码了。
传送门:
C#网络编程入门系列包括三篇文章:
(一)C#网络编程入门之UDP
(二)C#网络编程入门之TCP
C#网络编程入门之TCP
一、概述
UDP和TCP是网络通讯常用的两个传输协议,C#一般可以通过Socket来实现UDP和TCP通讯,由于.NET框架通过UdpClient、TcpListener 、TcpClient这几个类对Socket进行了封装,使其使用更加方便, 本文就通过这几个封装过的类讲解一下相关应用。
二、基本应用:连接、发送、接收
服务端建立侦听并等待连接:
TcpListener tcpListener = new TcpListener(IPAddress.Parse("127.0.0.1"), 9000); tcpListener.Start(); if (tcpListener.Pending()) { TcpClient client = tcpListener.AcceptTcpClient(); Console.WriteLine("Connected"); }
服务端是通过AcceptTcpClient方法获得TcpClient对象,而客户端是直接创建TcpClient对象。
TcpClient tcpClient = new TcpClient(); tcpClient.Connect("127.0.0.1", 9000);
发送数据TcpClient对象创建后,发送接收都通过TcpClient对象完成。
发送数据:
TcpClient tcpClient = new TcpClient(); tcpClient.Connect("127.0.0.1", 9000); NetworkStream netStream = tcpClient.GetStream(); int Len = 1024; byte[] datas = new byte[Len]; netStream.Write(datas, 0, Len); netStream.Close(); tcpClient.Close();
接收数据:
TcpClient client = tcpListener.AcceptTcpClient(); Console.WriteLine("Connected"); NetworkStream stream = client.GetStream(); var remote = client.Client.RemoteEndPoint; byte[] data = new byte[1024]; while (true) { if (stream.DataAvailable) { int len = stream.Read(data, 0, 1024); Console.WriteLine($"From:{remote}:Received ({len})"); } Thread.Sleep(1); }
三、 粘包问题
和UDP不太一样,TCP连接不会丢包,但存在粘包问题。(严格来说粘包这个说法是不严谨的,因为TCP通讯是基于流的,没有包的概念,包只是使用者自己的理解。) 下面分析一下粘包产生的原因及解决办法。
TCP数据通讯是基于流来实现的,类似一个队列,当有数据发送过来时,操作系统就会把发送过来的数据依次放到这个队列中,对发送者而言,数据是一片一片发送的,所以自然会认为存在数据包的概念,但对于接收者而言,如果没有及时去取这些数据,这些数据依次存放在队列中,彼此之间并无明显间隔,自然就粘包了。
还有一种情况粘包是发送端造成的,有时我们调用发送代码时,操作系统可能并不会立即发送,而是放到缓存区,当缓存区达到一定数量时才真正发送。
要解决粘包问题,大致有以下几个方案。
1、 约定数据长度,发送端的数据都是指定长度,比如1024;接收端取数据时也取同样长度,不够长度就等待,保证取到的数据和发送端一致;
2、 接收端取数据的频率远大于发送端,比如发送端每1秒发送一段数据,接收端每0.1秒去取一次数据,这样基本可以保证数据不会粘起来;
以上两个方案都要求发送端需要立即发送,不可缓存数据。而且这两种方案都有缺陷:首先,第一种方案:如果要包大小一致的话,如果约定的包比较大,肯定有较多数据冗余,浪费网络资源,如果包较小,连接就比较频繁,效率不高。
其次,第二种方案:这个方案只能在理想环境下可以实现,当服务端遭遇一段时间的计算压力时可能会出现意外,不能完全保证。
比较完善的解决方案就是对接收到的数据进行预处理:首先通过定义特殊的字符组合作为包头和包尾,如果传输ASCII字符,可以用0x02表示开始(STX),用0x03表示结束(ETX),比如:STX ‘H’ ‘e’ ‘l’ ‘l’ ‘o’ ETX (二进制数据: 02 48 65 6C 6C 6F 03)。如果数据较长可以在包头留出固定位置存放包长度, 如:
02 00 05 48 65 6C 6C 6F 03
其中02 05 就表示正文长度为5个字节,可以进行校验。
虽然第三种方案比较严谨,但相对复杂,在传输比较可靠、应用比较简单的场景下,也可以采用前面两种解决方案。
四、 一个完整的例程
服务端:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
客户端:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
传送门:
C#网络编程入门系列包括三篇文章:
(一)C#网络编程入门之UDP
(二)C#网络编程入门之TCP
C#网络编程入门之HTTP
一、概述
本文目的是通过C#代码提供一个HTTP服务,正常情况下如果我们需要向外界提供HTTP服务,常规做法就是通过ASP.NET来实现,有时我们的应用程序或Windows服务需要向外提供一些简单的HTTP服务就可以自己实现,从而避免部署IIS增加系统复杂性。这里必须强调是一些简单的应用,如果应用比较复杂,涉及到路径解析HTML解析等,还是用WEB方式实现比较靠谱。
将HTTP和UDP、TCP放在同一个系列实际上有一点不合适,因为UDP、TCP属于传输层协议,HTTP属于应用层协议,希望读者首先有一个明确的了解。
二、 提供服务
首先启动HHTP服务:
if (!HttpListener.IsSupported) { Console.WriteLine("服务器操作系统不支持建立Http Server,需要更高版本的操作系统!"); return; } HttpListener httpListener = new HttpListener(); try { Console.WriteLine("正在启动Http服务"); int port = 9000; httpListener.Prefixes.Add($"http://*:{port}/"); httpListener.Start(); Console.WriteLine("Http服务启动成功。"); } catch (Exception ex) { Console.WriteLine($"启动Http服务出现异常:{ex.Message}"); return; }
进行监听:
while (true) { Console.WriteLine("开始监听..."); HttpListenerContext context = httpListener.GetContext(); HttpListenerRequest request = context.Request; string Method = request.HttpMethod.ToUpper(); Console.WriteLine($"收到请求,URL:{ request.Url} Method:{Method}"); Response(context, "hello"); }
代码循环进行监听,GetContext方法会引起阻塞,当收到浏览器请求时,服务器立即返回“Hello”。
Response方法实现如下:
private static void Response(HttpListenerContext context, string responseTxt) { HttpListenerResponse response = context.Response; response.ContentType = "html"; response.ContentEncoding = Encoding.UTF8; using (Stream output = response.OutputStream) { byte[] buffer2 = Encoding.UTF8.GetBytes(responseTxt); output.Write(buffer2, 0, buffer2.Length); } }
此时打开浏览器输入地址 http://localhosthost:9000/ 看一下能否看到结果。(如果需要通过其他机器访问,本机要开放防火墙对应端口。)
注意:程序需要以管理员模型运行才能提供服务。
具体办法:工程新增应用程序清单文件:app.manifest,修改配置信息如下:
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <security> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> </requestedPrivileges> </security> </trustInfo> |
三、 响应
通过request.HttpMethod可以取得协议类型,对于GET和POST方法将采取不同的处理方式。
通过request.RawUrl可以取得URL路径,并进行解析,通过request.QueryString可以用户输入的参数值。
if (Method == "GET") { Console.WriteLine($"Get:RawURL:{ request.RawUrl}"); if (request.RawUrl.StartsWith("/version")) { Response(context, "Simple Http Server Ver:0.11"); continue; } else { string username = request.QueryString["username"]; string pwd = request.QueryString["pwd"]; Response(context, $"Welcome:{username}"); continue; } }
以上代码,如果输入:http://localhost:9000?username=hahaha
输出:Welcome:hahaha
在POST方法下,仍然可以通过request.QueryString取得用户通过URL输入的参数,但通过Body传输的数据需要通过其他方式进行读取。
if (Method == "POST") { Console.WriteLine($"POST:RawURL:{ request.RawUrl}"); string content = GetPostInput(request); Console.WriteLine($"Content:{ content}"); Response(context, ""{'Result':'Success','Message':'Hello'}""); continue; }
GetPostInput方法实现如下:
private static string GetPostInput(HttpListenerRequest request) { Stream s = request.InputStream; int count = 0; byte[] buffer = new byte[1024]; StringBuilder builder = new StringBuilder(); while ((count = s.Read(buffer, 0, 1024)) > 0) { builder.Append(Encoding.UTF8.GetString(buffer, 0, count)); } s.Flush(); s.Close(); s.Dispose(); return builder.ToString(); }
为了方便起见,输入输出的数据最好采用json格式。
四、调试
可以通过Chrome或Postman来进行调试。
传送门:
C#网络编程入门系列包括三篇文章:
(一)C#网络编程入门之UDP
(二)C#网络编程入门之TCP
(三)C#网络编程入门之HTTP
谈谈C#多线程开发:并行、并发与异步编程
阅读导航
一、使用Task
二、并行编程
三、线程同步
四、异步编程模型
五、多线程数据安全
六、异常处理
概述
现代程序开发过程中不可避免会使用到多线程相关的技术,之所以要使用多线程,主要原因或目的大致有以下几个:
1、 业务特性决定程序就是多任务的,比如,一边采集数据、一边分析数据、同时还要实时显示数据;
2、 在执行一个较长时间的任务时,不能阻塞UI界面响应,必须通过后台线程处理;
3、 在执行批量计算密集型任务时,采用多线程技术可以提高运行效率。
传统使用的多线程技术有:
- Thread & ThreadPool
- Timer
- BackgroundWorker
目前,这些技术都不再推荐使用了,目前推荐采用基于任务的异步编程模型,包括并行编程和Task的使用。
一、使用Task:
大部分情况下,多线程的应用场景是在后台执行一个较长时间的任务时,不能阻塞界面响应,同时,任务还是可以取消的。
下面我们实现一个简单的示例功能:用户点击Start按钮时启动一个任务,任务执行过程中通过进度条显示任务进度,点击Stop按钮结束任务。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
这个代码写的中规中矩,没什么特别的地方,仅仅是用Tsak取代了早期经常采用的Thread、ThreadPool等,虽然Task内部也是对ThreadPool的封装,但仍然建议尽量采用TASK来实现多任务。
注意:虽然可以通过代码强行结束一个任务,但强烈建议不要这样做,应该给它一个通知让其自己结束。
二、并行编程:
目标:通过一个计算素数的方法,循环计算并打印出10000以内的素数。
计算一个数是否素数的方法:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
如果不采用并行编程,常规实现方法:
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实际是通过线程池进行任务的分配,线程池的最小线程数和最大线程数将影响到整个程序的性能,需要合理设置。(最小线程默认为8。)
ThreadPool.SetMinThreads(10, 10); ThreadPool.SetMaxThreads(20, 20);
按照上述设置,假设线程任务耗时比较长不能很快结束。在启动前面10个线程时速度很快,第10~20个线程就比较慢一点,大约0.5秒,到达20个线程以后,如果前期任务没有结束就不能继续分配任务了。
和Task类似,Parallel类仍然是对ThreadPool的封装,但Parallel有一个优势,它能知道所有任务是否完成,如果采用线程池来实现批量任务,我们需要自己通过计数的方式确定所有子任务是否全部完成。
Parallel类还有一个ForEach方法,使用和For类似,就不重复描述了。
三、 线程(或任务)同步
有时我们需要通知一个任务结束,或一个任务等待某个条件进入下一个状态,这就需要用到任务同步的技术。
一个比较简单的方法就是定义一个变量来表示状态。
private volatile bool CancelWork = false;
后台任务可以轮询该变量进行判断:
for (int i = 0; i < 100; i++) { if(CancelWork) { break; } }
这是我们常用的方法,可以称为线程状态机同步(虽然只有两个状态)。需要注意的是在通过轮询去读取状态时,循环体内至少应该有1ms的Sleep,不然CPU会很高。
线程同步还有一个比较好的办法就是采用ManualResetEvent 和AutoResetEvent :
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
采用WaitOne来等待比通过Sleep进行延时要更好,因为当执行manualResetEvent.WaitOne(1000)时,如果manualResetEvent没有调用Set,该方法在等待1000ms后返回false,如果期间调用了manualResetEvent的Set方法,该方法会立即返回true,不用等待剩下的时间。
采用这种同步方式优于采用通过内部字段变量进行同步的方式,另外尽量采用ManualResetEvent 而不是AutoResetEvent 。
四、异步编程模型(await、async)
假设我们要实现一个简单的功能:当点击启动按钮时,运行一个任务,任务结束时要报告是否成功,如果成功就显示绿色图标、如果失败就显示红色图标,1秒后图标颜色恢复为白色;任务运行期间启动按钮要不可用。
我写了相关代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
这段代码逻辑清晰、条理清楚,一看就能明白,但存在两个问题:
1、运行期间UI线程阻塞了,用户界面没有响应;
2、根本不能实现需求,点击启动后,程序卡死6秒种,也没有看到颜色变化,因为UI线程已经阻塞,当重新获得句柄时图标已经是白色了。
为了实现需求,我们改用多任务来实现相关功能:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
以上代码完全实现了最初的需求,但有几个不完美的地方:
1、主线程的btnStart_Click方法除了启动一个任务以外,啥事也没干;
2、由于非UI线程不能访问UI控件,代码里有很多Invoke,比较丑陋;
3、界面逻辑和业务逻辑掺和在一起,使得代码难以理解。
采用C#的异步编程模型,通过使用await、async关键字,可以更好地实现上述需求。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
这段代码看起来就像是同步代码,其业务逻辑是如此的清晰优雅,让人一目了然,关键是它还不阻塞线程,UI正常响应。
可以看到,通过使用await关键字,我们可以专注于业务功能实现,特别是后续任务需要前序任务的返回值的情况下,可以大量减少任务之间的同步操作,代码的可读性也大大增强。
五、 多线程环境下的数据安全
目标:我们要向一个字典加入一些数据项,为了增加效率,我们使用了多个线程。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
向字典重复加入同样的关键字会引发异常,所以在增加数据前我们检查一下是否已经包含该关键字。以上代码看似没有问题,但有时还是会引发异常:“已添加了具有相同键的项。”原因在于我们在检查是否包含该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。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
你可以在新增前继续检查一下容器是否已经包含该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、或将异常包装成事件推送到主线程,交给主线程处理。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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#开发人员都不能轻视它,必须熟练掌握。 虽然在一知半解的情况下也能写多线程程序,写的程序也能跑,但就是那些平时一切正常偶尔抽风一下的错误会让头痛不已。只有深刻了解多线程的内部原理,并遵循结构化的设计原则才能写出健壮、优美的代码。