基于一个应用程序多线程误用的分析详解

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


代码如下:

public void Process()
        {
            var recordCount = 1000;//每次取出邮件记录数
            while (true)
            {
                using (var client = new Pop3Client())
                {
                    //1、建立连接,并进行身份认证
                    client.Connect(server, port, useSSL);
                    client.Authenticate(userName, pwd);

var messageCount = client.GetMessageCount(); // 邮箱中现有邮件数
                    if (messageCount > recordCount)
                    {
                        messageCount = recordCount;
                    }
                    if (messageCount < 1)
                    {
                        break;
                    }
                    var listAllMsg = new List<Message>(messageCount); //用于临时保存取出的邮件

//2、取出邮件后填充至列表,每次最多recordCount封邮件
                    for (int i = 1; i <= messageCount; i++) //邮箱索引是基于1开始的,索引范围: [1, messageCount]
                    {
                        listAllMsg.Add(client.GetMessage(i)); //取出邮件至列表
                    }

//3、遍历并保存至客户端,格式为.eml
                    foreach (var message in listAllMsg)
                    {
                        var emlInfo = new System.IO.FileInfo(string.Format("{0}.eml", Guid.NewGuid().ToString("n")));
                        message.SaveToFile(emlInfo);//保存邮件为.eml格式文件
                    }

//4、遍历并删除
                    int messageNumber = 1;
                    foreach (var message in listAllMsg)
                    {
                        client.DeleteMessage(messageNumber); //删除邮件(本质上,在关闭连接前只是打上DELETE标签,并没有真正删除)
                        messageNumber++;
                    }

//5、断开连接,真正完成删除
                    client.Disconnect();

if (messageCount < recordCount)
                    {
                        break;
                    }
                }
            }
        }

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

二、性能调优及产生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删除邮件的源码如下:


代码如下:

public void DeleteMessage(int messageNumber)
        {
            AssertDisposed();

ValidateMessageNumber(messageNumber);

if (State != ConnectionState.Transaction)
                throw new InvalidUseException("You cannot delete any messages without authenticating yourself towards the server first");

SendCommand("DELE " + messageNumber);
        }

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


代码如下:

private void SendCommand(string command)
        {
            // Convert the command with CRLF afterwards as per RFC to a byte array which we can write
            byte[] commandBytes = Encoding.ASCII.GetBytes(command + "\r\n");

// Write the command to the server
            OutputStream.Write(commandBytes, 0, commandBytes.Length);
            OutputStream.Flush(); // Flush the content as we now wait for a response

// Read the response from the server. The response should be in ASCII
            LastServerResponse = StreamUtility.ReadLineAsAscii(InputStream);

IsOkResponse(LastServerResponse);
        }

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


代码如下:

/// <summary>
        /// This is the stream used to read off the server response to a command
        /// </summary>
        private Stream InputStream { get; set; }

/// <summary>
        /// This is the stream used to write commands to the server
        /// </summary>
        private Stream OutputStream { get; set; }

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


代码如下:

/// <summary>
        /// Connects to a remote POP3 server
        /// </summary>
        /// <param name="hostname">The <paramref name="hostname"/> of the POP3 server</param>
        /// <param name="port">The port of the POP3 server</param>
        /// <param name="useSsl">True if SSL should be used. False if plain TCP should be used.</param>
        /// <param name="receiveTimeout">Timeout in milliseconds before a socket should time out from reading. Set to 0 or -1 to specify infinite timeout.</param>
        /// <param name="sendTimeout">Timeout in milliseconds before a socket should time out from sending. Set to 0 or -1 to specify infinite timeout.</param>
        /// <param name="certificateValidator">If you want to validate the certificate in a SSL connection, pass a reference to your validator. Supply <see langword="null"/> if default should be used.</param>
        /// <exception cref="PopServerNotAvailableException">If the server did not send an OK message when a connection was established</exception>
        /// <exception cref="PopServerNotFoundException">If it was not possible to connect to the server</exception>
        /// <exception cref="ArgumentNullException">If <paramref name="hostname"/> is <see langword="null"/></exception>
        /// <exception cref="ArgumentOutOfRangeException">If port is not in the range [<see cref="IPEndPoint.MinPort"/>, <see cref="IPEndPoint.MaxPort"/> or if any of the timeouts is less than -1.</exception>
        public void Connect(string hostname, int port, bool useSsl, int receiveTimeout, int sendTimeout, RemoteCertificateValidationCallback certificateValidator)
        {
            AssertDisposed();

if (hostname == null)
                throw new ArgumentNullException("hostname");

if (hostname.Length == 0)
                throw new ArgumentException("hostname cannot be empty", "hostname");

if (port > IPEndPoint.MaxPort || port < IPEndPoint.MinPort)
                throw new ArgumentOutOfRangeException("port");

if (receiveTimeout < -1)
                throw new ArgumentOutOfRangeException("receiveTimeout");

if (sendTimeout < -1)
                throw new ArgumentOutOfRangeException("sendTimeout");

if (State != ConnectionState.Disconnected)
                throw new InvalidUseException("You cannot ask to connect to a POP3 server, when we are already connected to one. Disconnect first.");

TcpClient clientSocket = new TcpClient();
            clientSocket.ReceiveTimeout = receiveTimeout;
            clientSocket.SendTimeout = sendTimeout;

try
            {
                clientSocket.Connect(hostname, port);
            }
            catch (SocketException e)
            {
                // Close the socket - we are not connected, so no need to close stream underneath
                clientSocket.Close();

DefaultLogger.Log.LogError("Connect(): " + e.Message);
                throw new PopServerNotFoundException("Server not found", e);
            }

Stream stream;
            if (useSsl)
            {
                // If we want to use SSL, open a new SSLStream on top of the open TCP stream.
                // We also want to close the TCP stream when the SSL stream is closed
                // If a validator was passed to us, use it.
                SslStream sslStream;
                if (certificateValidator == null)
                {
                    sslStream = new SslStream(clientSocket.GetStream(), false);
                }
                else
                {
                    sslStream = new SslStream(clientSocket.GetStream(), false, certificateValidator);
                }
                sslStream.ReadTimeout = receiveTimeout;
                sslStream.WriteTimeout = sendTimeout;

// Authenticate the server
                sslStream.AuthenticateAsClient(hostname);

stream = sslStream;
            }
            else
            {
                // If we do not want to use SSL, use plain TCP
                stream = clientSocket.GetStream();
            }

// Now do the connect with the same stream being used to read and write to
            Connect(stream, stream); //In/OutputStream属性初始化
        }

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

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


代码如下:

public Message GetMessage(int messageNumber)
        {
            AssertDisposed();

ValidateMessageNumber(messageNumber);

if (State != ConnectionState.Transaction)
                throw new InvalidUseException("Cannot fetch a message, when the user has not been authenticated yet");

byte[] messageContent = GetMessageAsBytes(messageNumber);

return new Message(messageContent);
        }

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


代码如下:

if (askOnlyForHeaders)
            {
                // 0 is the number of lines of the message body to fetch, therefore it is set to zero to fetch only headers
                SendCommand("TOP " + messageNumber + " 0");
            }
            else
            {
                // Ask for the full message
                SendCommand("RETR " + messageNumber);
            }

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


代码如下:

/// <summary>
        /// Tests a string to see if it is a "+OK" string.<br/>
        /// An "+OK" string should be returned by a compliant POP3
        /// server if the request could be served.<br/>
        /// <br/>
        /// The method does only check if it starts with "+OK".
        /// </summary>
        /// <param name="response">The string to examine</param>
        /// <exception cref="PopServerException">Thrown if server did not respond with "+OK" message</exception>
        private static void IsOkResponse(string response)
        {
            if (response == null)
                throw new PopServerException("The stream used to retrieve responses from was closed");

if (response.StartsWith("+OK", StringComparison.OrdinalIgnoreCase))
                return;

throw new PopServerException("The server did not respond with a +OK response. The response was: \"" + response + "\"");
        }

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

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

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


代码如下:

/// <summary>
        /// Save this <see cref="Message"/> to a file.<br/>
        /// <br/>
        /// Can be loaded at a later time using the <see cref="LoadFromFile"/> method.
        /// </summary>
        /// <param name="file">The File location to save the <see cref="Message"/> to. Existent files will be overwritten.</param>
        /// <exception cref="ArgumentNullException">If <paramref name="file"/> is <see langword="null"/></exception>
        /// <exception>Other exceptions relevant to file saving might be thrown as well</exception>
        public void SaveToFile(FileInfo file)
        {
            if (file == null)
                throw new ArgumentNullException("file");

File.WriteAllBytes(file.FullName, RawMessage);
        }

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

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

(0)

相关推荐

  • 基于一个应用程序多线程误用的分析详解

    一.需求和初步实现很简单的一个windows服务:客户端连接邮件服务器,下载邮件(含附件)并保存为.eml格式,保存成功后删除服务器上的邮件.实现的伪代码大致如下: 复制代码 代码如下: public void Process()        {            var recordCount = 1000;//每次取出邮件记录数            while (true)            {                using (var client = new Pop

  • 基于Java回顾之多线程同步的使用详解

    首先阐述什么是同步,不同步有什么问题,然后讨论可以采取哪些措施控制同步,接下来我们会仿照回顾网络通信时那样,构建一个服务器端的"线程池",JDK为我们提供了一个很大的concurrent工具包,最后我们会对里面的内容进行探索. 为什么要线程同步? 说到线程同步,大部分情况下, 我们是在针对"单对象多线程"的情况进行讨论,一般会将其分成两部分,一部分是关于"共享变量",一部分关于"执行步骤". 共享变量 当我们在线程对象(Run

  • Rust 搭建一个小程序运行环境的方法详解

    目录 从零到一:构建一个能运行小程序的App FinClip 安全沙箱的初始化 获得 SDK Key 以及 SDK Secret 的两种方式 方式一:采用 FinClip.com 托管服务 方式二:自行部署 FinClip 社区版 FinClip SDK 在 App 中的初始化 Rust 开发环境的准备 Rust 代码编译成 iOS 静态库的验证 搭建一个FinClip社区版docker运行环境,安装设置Rust开发编译iOS代码的环境,设置xcode的项目配合,集成FinClip SDK,准备

  • 一个Vue页面的内存泄露分析详解

    什么是内存泄露?内存泄露是指new了一块内存,但无法被释放或者被垃圾回收.new了一个对象之后,它申请占用了一块堆内存,当把这个对象指针置为null时或者离开作用域导致被销毁,那么这块内存没有人引用它了在JS里面就会被自动垃圾回收.但是如果这个对象指针没有被置为null,且代码里面没办法再获取到这个对象指针了,就会导致无法释放掉它指向的内存,也就是说发生了内存泄露.为什么代码里面会拿不到这个对象指针了呢,举一个例子: // module date.js let date = null; expo

  • 分析详解python多线程与多进程区别

    目录 1 基础知识 1.1 线程 1.2 进程 1.3 两者的区别 2 Python 多进程 2.1 创建多进程 方法1:直接使用Process 方法2:继承Process来自定义进程类,重写run方法 2.2 多进程通信 Queue Pipe 2.3 进程池 3 Python 多线程 3.1 GIL 3.2 创建多线程 方法1:直接使用threading.Thread() 方法2:继承threading.Thread来自定义线程类,重写run方法 3.3 线程合并 3.4 线程同步与互斥锁 3

  • 基于多线程并发的常见问题(详解)

    一 概述 1.volatile 保证共享数据一旦被修改就会立即同步到共享内存(堆或者方法区)中. 2.线程访问堆中数据的过程 线程在栈中建立一个数据的副本,修改完毕后将数据同步到堆中. 3.指令重排 为了提高执行效率,CPU会将没有依赖关系的指令重新排序.如果希望控制重新排序,可以使用volatile修饰一个变量,包含该变量的指令前后的指令各自独立排序,前后指令不能交叉排序. 二 常见问题及应对 1.原子性问题 所谓原子性,指的是一个操作不可中断,即在多线程并发的环境下,一个操作一旦开始,就会在

  • 基于mpvue搭建微信小程序项目框架的教程详解

    简介: mpvue框架对于从没有接触过小程序又要尝试小程序开发的人员来说,无疑是目前最好的选择.mpvue从底层支持 Vue.js 语法和构建工具体系,同时再结合相关UI组件库,便可以高效的实现小程序开发 前言: 本文讲述如何搭建完整的小程序项目框架,因为是第一次使用,有不完善的地方请大佬指正. 搭建内容包括: 1.使用scss语法:依赖插件sass-loader .node-sass 2.像vue一样使用路由:依赖插件 mpvue-entry 和 mpvue-router-patch 3.使用

  • Android 应用程序的启动流程示例详解

    目录 应用进程的启动流程 1.ActivityStackSupervisor.startSpecificActivity 2.ATMS.startProcessAsync 3.LocalService.startProcess 4.startProcessLocked函数 5.ProcessList.startProcessLocked 6.ProcessList.startProcessLocked重载 7.ProcessList.startProcess 8.ZygoteState.star

  • Java中注解与原理分析详解

    目录 一.注解基础 二.注解原理 三.常用注解 1.JDK注解 2.Lombok注解 四.自定义注解 1.同步控制 2.类型引擎 一.注解基础 注解即标注与解析,在Java的代码工程中,注解的使用几乎是无处不在,甚至多到被忽视: 无论是在JDK源码或者框架组件,都在使用注解能力完成各种识别和解析动作:在对系统功能封装时,也会依赖注解能力简化各种逻辑的重复实现: 基础接口 在Annotation的源码注释中有说明:所有的注解类型都需要继承该公共接口,本质上看注解是接口,但是代码并没有显式声明继承关

  • SpringCloud微服务续约实现源码分析详解

    目录 一.前言 二.客户端续约 1.入口 构造初始化 initScheduledTasks()调度执行心跳任务 2.TimedSupervisorTask组件 构造初始化 TimedSupervisorTask#run()任务逻辑 3.心跳任务 HeartbeatThread私有内部类 发送心跳 4.发送心跳到注册中心 构建请求数据发送心跳 三.服务端处理客户端续约 1.InstanceRegistry#renew()逻辑 2.PeerAwareInstanceRegistryImpl#rene

随机推荐