一个应用程序多线程误用的分析

简介:

一、需求和初步实现

很简单的一个windows服务:客户端连接邮件服务器,下载邮件(含附件)并保存为.eml格式,保存成功后删除服务器上的邮件。实现的伪代码大致如下:

GetAndSaveMail

 

开发中接收邮件的时候使用了开源组件Mail.Net(实际上这是OpenSMTP.NetOpenPop两个项目的并集),调用接口实现很简单。代码写完后发现基本功能是满足了,本着在稳定的基础上更快更有效率的原则,最终进行性能调优。

 

二、性能调优及产生BUG分析

暂时不管这里的耗时操作是属于计算密集型还是IO密集型,反正有人一看到有集合要一个一个遍历顺序处理,就忍不住有多线程异步并行操作的冲动。有条件异步尽量异步,没有条件异步,创造条件也要异步,真正发挥多线程优势,充分利用服务器的强大处理能力,而且也自信中规中矩写了很多多线程程序,这个业务逻辑比较简单而且异常处理也较容易控制(就算有问题也有补偿措施,可以在后期处理中完善它),理论上每天需要查收的邮件的数量也不会太多,不会长时间成为CPU和内存杀手,这样的多线程异步服务实现应该可以接受。而且根据分析,显而易见,这是一个典型的频繁访问网络IO密集型的应用程序,当然要从IO处理上下功夫。

 

1、收取邮件

从Mail.Net的示例代码中看到,取邮件需要一个从1开始的索引,而且必须有序。如果异步发起多个请求,这个索引怎么传入呢?必须有序这一条开始让我有点犹豫,如果通过Lock或者Interlocked等同步构造,很显然就失去了多线程的优势,我猜可能还不如顺序同步获取速度快。

分析归分析,我们还是写点代码试试看效率如何。

快速写个异步方法传递整型参数,同时通过Interlocked控制提取邮件总数的变化,每一个异步方法获取完了之后通过Lock将Message加入到listAllMsg列表中即可。

邮件服务器测试邮件不多,测试获取一两封邮件,嗯,很好,提取邮件成功,初步调整就有收获,可喜可贺。

 

2、保存邮件

调优过程是这样的:遍历并保存为.eml的实现代码改为使用多线程,将message.SaveToFile保存操作并行处理,经测试,保存一到两封邮件,CPU没看出高多少,保存的效率貌似稍有提升,又有点进步。

 

3、删除邮件

再次调优:仿照多线程保存操作,将遍历删除邮件的代码进行修改,也通过多线程并行处理删除的操作。好,很好,非常好,这时候我心里想着什么Thread啊,ThreadPool啊,CCR啊,TPL啊,EAP啊,APM啊,把自己知道的能用的全给它用一遍,挑最好用的最优效率的一个,显得很有技术含量,哇哈哈。

然后,快速写了个异步删除方法开始测试。在邮件不多的情况下,比如三两封信,能正常工作,看起来好像蛮快的。

到这里我心里已经开始准备庆祝大功告成了。

 

4、产生BUG原因分析

从上面的1、2、3独立效果看,似乎每一个线程都能够独立运行而不需要相互通信或者数据共享,而且使用了异步多线程技术,取的快存的快删的也快,看上去邮件处理将进入最佳状态。但是最后提取、保存、删除集成联调测试。运行了一段时间查看日志,悲剧发生了:

在测试邮件较多的时候,比如二三十封左右,日志里看到有PopServerException异常,好像还有点乱码,而且每次乱码好像还不一样;再测试三两封信,发现有时能正常工作,有时也抛出PopServerException异常,还是有乱码,分析出错堆栈,是在删除邮件的地方。

我kao,这是要闹哪样啊,和邮件服务器关系没搞好吗,怎么总是PopServerException异常?

难道,难道是异步删除方法有问题?异步删除,索引为1的序号,嗯,索引的问题?还是不太确定。

到这里你能发现多线程处理删除操作抛出异常的原因吗?你已经知道原因了?OK,下面的内容对你就毫无意义了,可以不用往下看了。

 

谈谈我的排查经过。

看日志我初步怀疑是删除邮件的方法有问题,但是看了一下目测还是可靠的。接着估计是删除时邮件编码不正确,后来又想不太可能,同样的邮件同步代码查收保存删除这三个操作就没有异常抛出。不太放心,又分几次分别测试了几封邮件,有附件的没附件的,html的纯文本的,同步代码处理的很好。

百思不得其解,打开Mail.NET源码,从DeleteMessage方法跟踪查看到Mail.Net的Pop3Client类中的SendCommand方法,一下子感觉有头绪了。DeleteMessage删除邮件的源码如下:

DeleteMessage

 

最后一行SendCommand需要提交一个DELE命令,跟进去看看它是怎么实现的:

SendCommand

 

注意InputStream和OutputStream属性,它们的定义如下(神奇的private修饰属性,这种写法少见哪):

Private Property

 

给它赋值的地方是调用Pop3Client类里的 public void Connect(Stream inputStream, Stream outputStream)方法,而这个Connect方法最终调用的Connect方法如下:

Connect

 

一下子看到了TcpClient对象,这个不就是基于Socket,通过Socket编程实现POP3协议操作指令吗?毫无疑问需要发起TCP连接,什么三次握手呀,发送命令操作服务器呀…一下子全想起来了。

我们知道一个TCP连接就是一个会话(Session),发送命令(比如获取和删除)需要通过TCP连接和邮件服务器通信。如果是多线程在一个会话上发送命令(比如获取(TOP或者RETR)、删除(DELE))操作服务器,这些命令的操作都不是线程安全的,这样很可能出现OutputStream和InputStream数据不匹配而相互打架的情况,这个很可能就是我们看到的日志里有乱码的原因。说到线程安全,突然恍然大悟,我觉得查收邮件应该也有问题。为了验证我的想法,我又查看了下GetMessage方法的源码:

GetMessage

 

内部的GetMessageAsBytes方法最终果然还是走SendCommand方法:

GetMessage

 

根据我的跟踪,在测试中抛出异常的乱码来自于LastServerResponse(This is the last response the server sent back when a command was issued to it),在IsOKResponse方法中它不是以“+OK”开头就会抛出PopServerException异常:

IsOkResponse

 

分析到这里,终于知道最大的陷阱是Pop3Client不是线程安全的。终于找到原因了,哈哈哈,此刻我犹如见到女神出现一样异常兴奋心花怒放,高兴的差点忘了错误的代码就是自己写的。

片刻后终于冷静下来,反省自己犯了很低级的失误,晕死,我怎么把TCP和线程安全这茬给忘了呢?啊啊啊啊啊啊,好累,感觉再也不会用类库了。

对了,保存为.eml的时候是通过Message对象的SaveToFile方法,并不需要和邮件服务器通信,所以异步保存没有出现异常(二进制数组RawMessage也不会数据不匹配),它的源码是下面这样的:

Message.SaveToFile

 

再来总结看看这个bug是怎么产生的:对TCP和线程安全没有保持足够的敏感和警惕,看见for循环就进行性能调优,测试数据不充分,不小心触雷。归根结底,产生错误的原因是对线程安全考虑不周异步场景选择不当,这种不当的使用还有很多,比较典型的就是对数据库连接的误用。我看过一篇讲数据库连接对象误用的文章,比如这一篇为什么要关闭数据库连接,可以不关闭吗?,当时我也总结过,所以很有印象。现在还是要罗嗦一下,对于using一个Pop3Client或者SqlConnection这种方式共用一个连接访问网络的情况可能不适合使用多线程,尤其是和服务器进行密集通信的时候,哪怕用对了多线程技术,性能也不见得有提升。

我们经常使用的一些Libray或者.NET客户端,比如FastDFS、Memcached、RabbitMQ、Redis、MongDB、Zookeeper等等,它们都要访问网络和服务器通信并解析协议,分析过几个客户端的源码,记得FastDFS,Memcached及Redis的客户端内部都有一个Pool的实现,印象中它们就没有线程安全风险。依个人经验,使用它们的时候必须保持敬畏之心,也许你用的语言和类库编程体验非常友好,API使用说明通俗易懂,调用起来看上去轻而易举,但是要用好用对也不是全部都那么容易,最好快速过一遍源码理解大致实现思路,否则如不熟悉内部实现原理埋头拿过来即用很可能掉入陷阱当中而不自知。当我们重构或调优使用多线程技术的时候,绝不能忽视一个深刻的问题,就是要清醒认识到适合异步处理的场景,就像知道适合使用缓存场景一样,我甚至认为明白这一点比怎么写代码更重要。还有就是重构或调优必须要谨慎,测试所依赖的数据必须准备充分,实际工作当中这一点已经被多次证明,给我的印象尤其深刻。很多业务系统数据量不大的时候都可以运行良好,但在高并发数据量较大的环境下很容易出现各种各样莫名其妙的问题,比如本文中所述,在测试多线程异步获取和删除邮件的时候,邮件服务器上只有一两封内容和附件很小的邮件,通过异步获取和删除都正常运行,没有任何异常日志,但是数据一多,出现异常日志,排查,调试,看源码,再排查......这篇文章就面世了。





本文转自JeffWong博客园博客,原文链接:http://www.cnblogs.com/jeffwongishandsome/archive/2012/12/04/2796976.html,如需转载请自行联系原作者

相关实践学习
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
目录
相关文章
|
7月前
|
设计模式 消息中间件 安全
【JUC】(3)常见的设计模式概念分析与多把锁使用场景!!理解线程状态转换条件!带你深入JUC!!文章全程笔记干货!!
JUC专栏第三篇,带你继续深入JUC! 本篇文章涵盖内容:保护性暂停、生产者与消费者、Park&unPark、线程转换条件、多把锁情况分析、可重入锁、顺序控制 笔记共享!!文章全程干货!
436 1
|
8月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
存储 NoSQL Redis
Redis 新版本引入多线程的利弊分析
【10月更文挑战第16天】Redis 新版本引入多线程是一个具有挑战性和机遇的改变。虽然多线程带来了一些潜在的问题和挑战,但也为 Redis 提供了进一步提升性能和扩展能力的可能性。在实际应用中,我们需要根据具体的需求和场景,综合评估多线程的利弊,谨慎地选择和使用 Redis 的新版本。同时,Redis 开发者也需要不断努力,优化和完善多线程机制,以提供更加稳定、高效和可靠的 Redis 服务。
389 1
线程CPU异常定位分析
【10月更文挑战第3天】 开发过程中会出现一些CPU异常升高的问题,想要定位到具体的位置就需要一系列的分析,记录一些分析手段。
385 0
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
1274 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
调度 开发者
核心概念解析:进程与线程的对比分析
在操作系统和计算机编程领域,进程和线程是两个基本而核心的概念。它们是程序执行和资源管理的基础,但它们之间存在显著的差异。本文将深入探讨进程与线程的区别,并分析它们在现代软件开发中的应用和重要性。
606 4
|
监控 安全 算法
线程死循环确实是多线程编程中的一个常见问题,它可能导致应用程序性能下降,甚至使整个系统变得不稳定。
线程死循环是多线程编程中常见的问题,可能导致性能下降或系统不稳定。通过代码审查、静态分析、日志监控、设置超时、使用锁机制、测试、选择线程安全的数据结构、限制线程数、使用现代并发库及培训,可有效预防和解决死循环问题。
451 1
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
267 0
|
存储 监控 Java
|
安全 Java 开发者
Swing 的线程安全分析
【8月更文挑战第22天】
432 4

热门文章

最新文章