一、需求和初步實現
很簡單的一個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使用說明通俗易懂,調用起來看上去輕而易舉,但是要用好用對也不是全部都那么容易,最好快速過一遍源碼理解大致實現思路,否則如不熟悉內部實現原理埋頭拿過來即用很可能掉入陷阱當中而不自知。當我們重構或調優使用多線程技術的時候,絕不能忽視一個深刻的問題,就是要清醒認識到適合異步處理的場景,就像知道適合使用緩存場景一樣,我甚至認為明白這一點比怎么寫代碼更重要。還有就是重構或調優必須要謹慎,測試所依賴的數據必須準備充分,實際工作當中這一點已經被多次證明,給我的印象尤其深刻。很多業務系統數據量不大的時候都可以運行良好,但在高并發數據量較大的環境下很容易出現各種各樣莫名其妙的問題,比如本文中所述,在測試多線程異步獲取和刪除郵件的時候,郵件服務器上只有一兩封內容和附件很小的郵件,通過異步獲取和刪除都正常運行,沒有任何異常日志,但是數據一多,出現異常日志,排查,調試,看源碼,再排查......這篇文章就面世了。
新聞熱點
疑難解答