-
C#教程之基于一个应用程序多线程误用的分析详解
一、需求和初步实现
很简单的一个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使用说明通俗易懂,调用起来看上去轻而易举,但是要用好用对也不是全部都那么容易,最好快速过一遍源码理解大致实现思路,否则如不熟悉内部实现原理埋头拿过来即用很可能掉入陷阱当中而不自知。当我们重构或调优使用多线程技术的时候,绝不能忽视一个深刻的问题,就是要清醒认识到适合异步处理的场景,就像知道适合使用缓存场景一样,我甚至认为明白这一点比怎么写代码更重要。还有就是重构或调优必须要谨慎,测试所依赖的数据必须准备充分,实际工作当中这一点已经被多次证明,给我的印象尤其深刻。很多业务系统数据量不大的时候都可以运行良好,但在高并发数据量较大的环境下很容易出现各种各样莫名其妙的问题,比如本文中所述,在测试多线程异步获取和删除邮件的时候,邮件服务器上只有一两封内容和附件很小的邮件,通过异步获取和删除都正常运行,没有任何异常日志,但是数据一多,出现异常日志,排查,调试,看源码,再排查......这篇文章就面世了。