記一次 socket 通信性能優化過程

上段時間測試人員對某個服務端程序進行了性能壓力測試,發現當使用 JMeter 向程序併發發送 100 個請求後,再發送請求,則會出現程序無法響應的現象。想着這個問題比較棘手,就拖了不少時間。最近其他事情少了點,可以專心下來優化這個程序的性能,就着手開幹了。

利用 Wireshark 和 Python 構造請求報文

客戶端對向發送的請求報文進行了加密,且密鑰存在過期時間。爲方便構造請求報文,我們使用了 Wireshark 對請求報文進行抓包,然後直接將抓取的報文由 Python 程序發送。

我們先在 Wireshark 對過濾條件進行配置,服務端程序的監聽端口爲 5111,故配置tcp.dstport==5111 的過濾條件。
然後使用客戶端向服務端程序發送請求,可以看到 Wireshark 顯示出相應的請求報文:
在這裏插入圖片描述

右鍵報文,選擇顯示分組字節,顯示爲C數組

在這裏插入圖片描述

由於 JMeter 使用不多,並不太熟悉 JMeter 的使用,故這裏使用 Python 來模擬客戶端向服務端程序發送請求。
將相應的報文數據拷貝到 Python 程序,即可模擬客戶端向服務端程序發送請求。爲節省篇幅,Python 代碼只截取了部分 msg 請求數據。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import struct
from socket import socket, AF_INET, SOCK_STREAM

host = '10.88.115.114'
port = 5111

msg = [
    0x02, 0x93, 0x72, 0x36, 0x3a, 0x03, 0x00, 0x00,
    0x74, 0x61, 0x73, 0x6b, 0x3d, 0x79, 0x73, 0x73,
    0x54, 0x72
]

n = len(msg)
fmt = '%dB' % n
bt = struct.pack(fmt, *msg)

while True:
    client = socket(AF_INET, SOCK_STREAM)
    client.connect((host, port))
    client.send(bt)
    data = client.recv(1024)
    print(data)
    break

修復 FD_SETSIZE 的問題

Python 程序模擬同時有大量的客戶端請求與服務端程序建立連接併發送請求。運行 Python 程序後,發現服務端對於前面的請求還是可以正常響應的,但運行一段時間後,服務端程序就無法響應了。使用top -c 命令查看服務器 CPU ,可以看到服務端程序基本佔滿 100% 的 CPU。使用 tail -f 命令查看程序日誌,可以看到主線程不再打印日誌,說明已卡住了。
爲進一步確認主線程阻塞的地方,使用 pstack 命令來查看程序運行堆棧:

Thread 1 (Thread 0x7f73bfbee720 (LWP 14182)):
#0  0x00007f73bd39c623 in select () from /lib64/libc.so.6
#1  0x0000000000419c7c in ReadNetworkData(_SOCKET_INFORMATION*) ()
#2  0x000000000041d731 in ServerMain() ()
#3  0x000000000043dc89 in main ()

可以看到,程序阻塞在函數 ReadNetworkDataselect 系統調用上了。

而服務端程序正常情況下運行堆棧如下,程序阻塞在epoll_wait 等待測試的文件描述符是否就緒。

Thread 1 (Thread 0x7feaf652c720 (LWP 7815)):
#0  0x00007feaf3ce21c3 in epoll_wait () from /lib64/libc.so.6
#1  0x000000000041d313 in ServerMain() ()
#2  0x000000000043dc05 in main ()

再觀察服務端日誌,可以看到當文件描述符的值小於 1024 時,是可以正常響應客戶端請求的,但當文件描述符大於等於 1024 時,則會導致主線程阻塞在 select 調用上。
網上搜索關鍵詞 select 1024 ,果然看到由於 FD_SETSIZE 參數上限爲 1024 導致的慘案,例如,雲風的 BLOG:一起 select 引起的崩潰

原程序讀取客戶端請求數據的代碼如下:

while(1)
{
    nRet = select( FD_SETSIZE, &fds, NULL, NULL, &tmOut );
    if(nRet== 0)
        break;
    nRead = recv(si->nSock, si->pRecvBuffer + si->nRecvLen, si->nBufferLen - si->nRecvLen, 0);
    ...

selectFD_SETSIZE 參數上限爲 1024,當需要測試的文件描述符大於等於 1024 時,則會出現越界的現象。由於程序中通過輪詢來測試文件描述是否就緒,而測試的文件描述符又大於 1024,則會出現一直輪詢下去的問題,導致程序一直阻塞在 select 調用上。

參考 stackoverflow 的例子,使用 poll 代替 select ,對程序進行改寫,改寫後程序代碼如下:

while (1) {
    // 非阻塞 poll,輪詢
    nready = poll(&client, 1, 0);
    if (nready == 0) {
        LogMessage("poll not ready, socket: %d", si->nSock);
        break;
    } else if (nready == -1) {
        LogMessage("poll error, socket: %d", si->nSock);
		break;
    } else {
        LogMessage("poll read ready, socket: %d", si->nSock);
    }
    nRead = recv(si->nSock, si->pRecvBuffer + si->nRecvLen, si->nBufferLen - si->nRecvLen, 0);

使用 poll 改寫 select 後,即使客戶端連接數大於 1024,也沒出現程序卡死的問題。

修復 accept 接受用戶連接的問題

修復 FS_SETSIZE 的問題後,再提交給測試人員進行壓力測試,發現仍然存在問題。向服務端程序併發發送 100 個請求後,再發送請求,仍然會出現無法響應的現象。

查看程序日誌,發現當使用 Python 向服務端程序發送請求時,日誌會出現上一個連接的 IP 和端口,也就是說,程序將上一個連接當成是新建立的連接來使用了。
這時,如果再從舊的連接讀取或寫入數據,會出現 “Connection reset by peer” 或者 “Broken pipe”的錯誤,這是由於舊的連接可能已經斷開了。
於是懷疑 accept 調用是否存在問題。考慮到調用 accept 前使用 epoll_wait 來測試監聽套接字是否就緒,且使用了 ET 模式,故監聽套接字就緒時,可能已經有一個或者多個客戶端連接進來,故只調用一次 accept 就可能會出現連接錯誤的問題。

程序原來的 accept 處理邏輯如下:

if (fd==g_TcpSock)
{
	//A new tcp connection come in.
	nAddrLen = sizeof(addrPeer);
	if( (sockNew = accept(g_TcpSock, (struct sockaddr*)&addrPeer, (socklen_t *)&nAddrLen)) !=  -1)
	{
		LogMessage("accept tcp connection %s:%d.", inet_ntoa(addrPeer.sin_addr),(int)(ntohs(addrPeer.sin_port)));
		//set keep-alive.
		nKA = TRUE;
		setsockopt(sockNew,IPPROTO_TCP,SO_KEEPALIVE,(const char*)&nKA,sizeof(nKA));
		//將該客戶端加到列表
		AddConnection(sockNew,&addrPeer, NET_SOURCE_TYPE_IN_TCP, IN_TCP_HEAD_MAGIC);
	}
	else
	{
		nRet = errno;
		LogMessage("accept() tcp error: %d,%d",sockNew,nRet);
	}
	continue;
}

參考文章 《I/O多路複用之 epoll 系統調用》 的說明,改寫了 accept 調用的邏輯:

if (fd==g_TcpSock)
{
	// 監聽套接字就緒,表明有一個或者多個連接進來
	while (true) {
		nAddrLen = sizeof(addrPeer);
		sockNew = accept(g_TcpSock, (struct sockaddr*)&addrPeer, (socklen_t *)&nAddrLen);
		if (sockNew == -1) {
			if (errno == EAGAIN || errno == EWOULDBLOCK) {
				// 處理完所有的連接
				LogMessage("finish accept all connection, errno: %d", errno);
				break;
			} else {
				LogMessage("accept() tcp errno: %d, error message: %s", errno, strerror(errno));
				break;
			}
		}
		LogMessage("accept tcp connection %s:%d, socket: %d",
				   inet_ntoa(addrPeer.sin_addr),(int)(ntohs(addrPeer.sin_port)), sockNew);
		//set keep-alive.
		nKA = TRUE;
		setsockopt(sockNew,IPPROTO_TCP,SO_KEEPALIVE,(const char*)&nKA,sizeof(nKA));
		//將該客戶端加到列表
		AddConnection(sockNew,&addrPeer, NET_SOURCE_TYPE_IN_TCP, IN_TCP_HEAD_MAGIC);
	}
	continue;
}

改寫後,不再出現連接錯亂的現象,原來壓力測試發現的問題也不再出現。

小結

這次對服務端程序 socket 通信性能進行優化,挑戰還是不小,幾次沒有頭緒差點想要放棄。通過分散自己的思維,翻看自己以前寫的文章,最後還是順利修復了問題。看來平時的積累非常重要,要加強平時的學習。

參考資料

  • https://blog.csdn.net/lihao21/article/details/66475051
  • https://blog.csdn.net/lihao21/article/details/67631516
  • https://blog.csdn.net/lihao21/article/details/64951446
  • https://blog.csdn.net/lihao21/article/details/71307115
  • https://blog.csdn.net/lihao21/article/details/66097377
  • https://blog.csdn.net/lihao21/article/details/64624796#commentBox
  • https://bbs.csdn.net/topics/60361248
  • https://blog.csdn.net/a3192048/article/details/84671340
  • https://stackoverflow.com/questions/7976388/increasing-limit-of-fd-setsize-and-select
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章