一、需求和初步实现
很简单的一个windows服务:客户端连接邮件服务器,下载邮件(含附件)并保存为.eml格式,保存成功后删除服务器上的邮件。实现的伪代码大致如下:
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;
}
}
}
}
二、性能调优及产生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删除邮件的源码如下:
ValidateMessageNumber(messageNumber);
if (State != ConnectionState.Transaction)
throw new InvalidUseException("You cannot delete any messages without authenticating yourself towards the server first");
SendCommand("DELE " + messageNumber);
}
// 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);
}
/// <summary>
/// This is the stream used to write commands to the server
/// </summary>
private Stream OutputStream { get; set; }
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属性初始化
}
我们知道一个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);
}
if (response.StartsWith("+OK", StringComparison.OrdinalIgnoreCase))
return;
throw new PopServerException("The server did not respond with a +OK response. The response was: /"" + response + "/"");
}
片刻后终于冷静下来,反省自己犯了很低级的失误,晕死,我怎么把TCP和线程安全这茬给忘了呢?啊啊啊啊啊啊,好累,感觉再也不会用类库了。
对了,保存为.eml的时候是通过Message对象的SaveToFile方法,并不需要和邮件服务器通信,所以异步保存没有出现异常(二进制数组RawMessage也不会数据不匹配),它的源码是下面这样的:
File.WriteAllBytes(file.FullName, RawMessage);
}
我们经常使用的一些Libray或者.NET客户端,比如FastDFS、Memcached、RabbitMQ、Redis、MongDB、Zookeeper等等,它们都要访问网络和服务器通信并解析协议,分析过几个客户端的源码,记得FastDFS,Memcached及Redis的客户端内部都有一个Pool的实现,印象中它们就没有线程安全风险。依个人经验,使用它们的时候必须保持敬畏之心,也许你用的语言和类库编程体验非常友好,API使用说明通俗易懂,调用起来看上去轻而易举,但是要用好用对也不是全部都那么容易,最好快速过一遍源码理解大致实现思路,否则如不熟悉内部实现原理埋头拿过来即用很可能掉入陷阱当中而不自知。当我们重构或调优使用多线程技术的时候,绝不能忽视一个深刻的问题,就是要清醒认识到适合异步处理的场景,就像知道适合使用缓存场景一样,我甚至认为明白这一点比怎么写代码更重要。还有就是重构或调优必须要谨慎,测试所依赖的数据必须准备充分,实际工作当中这一点已经被多次证明,给我的印象尤其深刻。很多业务系统数据量不大的时候都可以运行良好,但在高并发数据量较大的环境下很容易出现各种各样莫名其妙的问题,比如本文中所述,在测试多线程异步获取和删除邮件的时候,邮件服务器上只有一两封内容和附件很小的邮件,通过异步获取和删除都正常运行,没有任何异常日志,但是数据一多,出现异常日志,排查,调试,看源码,再排查......这篇文章就面世了。
新闻热点
疑难解答