基于一个应用程序多线程误用的分析详解
本文导语: 一、需求和初步实现很简单的一个windows服务:客户端连接邮件服务器,下载邮件(含附件)并保存为.eml格式,保存成功后删除服务器上的邮件。实现的伪代码大致如下: 代码如下: public void Process() { ...
一、需求和初步实现
很简单的一个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(messageCount); //用于临时保存取出的邮件
//2、取出邮件后填充至列表,每次最多recordCount封邮件
for (int i = 1; i 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异常:
///
/// Tests a string to see if it is a "+OK" string.
/// An "+OK" string should be returned by a compliant POP3
/// server if the request could be served.
///
/// The method does only check if it starts with "+OK".
///
/// The string to examine
/// Thrown if server did not respond with "+OK" message
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也不会数据不匹配),它的源码是下面这样的:
///
/// Save this to a file.
///
/// Can be loaded at a later time using the method.
///
/// The File location to save the to. Existent files will be overwritten.
/// If is
/// Other exceptions relevant to file saving might be thrown as well
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使用说明通俗易懂,调用起来看上去轻而易举,但是要用好用对也不是全部都那么容易,最好快速过一遍源码理解大致实现思路,否则如不熟悉内部实现原理埋头拿过来即用很可能掉入陷阱当中而不自知。当我们重构或调优使用多线程技术的时候,绝不能忽视一个深刻的问题,就是要清醒认识到适合异步处理的场景,就像知道适合使用缓存场景一样,我甚至认为明白这一点比怎么写代码更重要。还有就是重构或调优必须要谨慎,测试所依赖的数据必须准备充分,实际工作当中这一点已经被多次证明,给我的印象尤其深刻。很多业务系统数据量不大的时候都可以运行良好,但在高并发数据量较大的环境下很容易出现各种各样莫名其妙的问题,比如本文中所述,在测试多线程异步获取和删除邮件的时候,邮件服务器上只有一两封内容和附件很小的邮件,通过异步获取和删除都正常运行,没有任何异常日志,但是数据一多,出现异常日志,排查,调试,看源码,再排查......这篇文章就面世了。