忙碌前端反模式
對大量背景執行緒執行非同步工作可能會導致其他並行前景工作資源不足,將回應時間降低至無法接受的層級。
問題說明
耗用大量資源的工作可能會增加使用者要求的回應時間,並造成高延遲。 改善回應時間的其中一種方法是將耗用大量資源的工作卸載至個別執行緒。 此方法可讓應用程式保持回應,同時在背景進行處理。 不過,在背景執行緒上執行的工作仍會耗用資源。 如果這些工作太多,它們可能會導致處理要求的執行緒資源不足。
注意
資源一詞可以包含許多事物,例如 CPU 使用率、記憶體佔用率,以及網路或磁碟 I/O。
當應用程式開發為整合型程式碼,並將所有商務邏輯合併成與展示層共用的單一階層時,通常就會發生此問題。
以下是示範問題的虛擬程式碼。
public class WorkInFrontEndController : ApiController
{
[HttpPost]
[Route("api/workinfrontend")]
public HttpResponseMessage Post()
{
new Thread(() =>
{
//Simulate processing
Thread.SpinWait(Int32.MaxValue / 100);
}).Start();
return Request.CreateResponse(HttpStatusCode.Accepted);
}
}
public class UserProfileController : ApiController
{
[HttpGet]
[Route("api/userprofile/{id}")]
public UserProfile Get(int id)
{
//Simulate processing
return new UserProfile() { FirstName = "Alton", LastName = "Hudgens" };
}
}
WorkInFrontEnd
控制器中的Post
方法會實作 HTTP POST 作業。 此作業會模擬長時間執行的 CPU 密集型工作。 工作會在個別執行緒上執行,以嘗試讓 POST 作業快速完成。UserProfile
控制器中的Get
方法會實作 HTTP GET 作業。 此方法的 CPU 密集程度要少得多。
主要考量是 Post
方法的資源需求。 雖然它會將工作放在背景執行緒上,但工作仍會耗用相當大量的 CPU 資源。 這些資源會與其他並行使用者執行的其他作業共用。 如果適量的使用者同時傳送此要求,整體效能可能會受到影響,讓所有作業變慢。 例如,使用者可能會在 Get
方法中遇到顯著的延遲。
如何修正問題
將耗用大量資源的處理序移至個別的後端。
透過這種方法,前端會將耗用大量資源的工作放在訊息佇列上。 後端會挑選工作以進行非同步處理。 佇列也會做為負載平衡器,緩衝處理後端的要求。 如果佇列長度變得太長,您可以設定自動調整以擴增後端。
以下是先前程式碼的修訂版本。 在此版本中,Post
方法會將訊息放在服務匯流排佇列上。
public class WorkInBackgroundController : ApiController
{
private static readonly QueueClient QueueClient;
private static readonly string QueueName;
private static readonly ServiceBusQueueHandler ServiceBusQueueHandler;
public WorkInBackgroundController()
{
string serviceBusNamespace = ...;
QueueName = ...;
ServiceBusQueueHandler = new ServiceBusQueueHandler(serviceBusNamespace);
QueueClient = ServiceBusQueueHandler.GetQueueClientAsync(QueueName).Result;
}
[HttpPost]
[Route("api/workinbackground")]
public async Task<long> Post()
{
return await ServiceBusQueueHandler.AddWorkLoadToQueueAsync(QueueClient, QueueName, 0);
}
}
後端會從服務匯流排佇列提取訊息,並執行處理。
public async Task RunAsync(CancellationToken cancellationToken)
{
this._queueClient.OnMessageAsync(
// This lambda is invoked for each message received.
async (receivedMessage) =>
{
try
{
// Simulate processing of message
Thread.SpinWait(Int32.MaxValue / 1000);
await receivedMessage.CompleteAsync();
}
catch
{
receivedMessage.Abandon();
}
});
}
考量
- 此方法會對應用程式增加一些額外的複雜性。 您必須安全地處理佇列和清除佇列,以避免在發生失敗時遺失要求。
- 應用程式會相依於訊息佇列的其他服務。
- 處理環境必須能夠充分調整,才能處理預期的工作負載,並符合所需的輸送量目標。
- 雖然此方法應可改善整體回應性,但移至後端的工作可能需要更長的時間才能完成。
如何偵測問題
忙碌前端的徵兆包括執行耗用大量資源的工作時延遲很高。 終端使用者可能會報告回應時間延長或服務逾時所造成的失敗。這些失敗也可能傳回 HTTP 500 (內部伺服器) 錯誤或 HTTP 503 (服務無法使用) 錯誤。 請檢查網頁伺服器的事件記錄,其中可能包含錯誤原因和情況的詳細資訊。
您可以執行下列步驟來協助識別此問題:
- 執行生產系統的處理序監視,以識別回應時間變慢的時間點。
- 檢查在這些時間點擷取的遙測資料,以判斷所執行的作業與所使用的資源組合。
- 找出回應時間不良與當時所進行作業的數量與組合之間的任何相互關聯。
- 對每個可疑的作業進行負載測試,以識別哪些作業正在耗用資源,並導致其他作業資源不足。
- 檢閱這些作業的原始程式碼,以判斷它們可能導致資源耗用量過高的原因。
診斷範例
下列各節會將這些步驟套用至先前所述的範例應用程式。
識別速度變慢的時間點
檢測每個方法,以追蹤每個要求所耗用的持續時間和資源。 然後,監視生產環境中的應用程式。 如此可以針對要求彼此競爭的方式提供整體檢視。 在壓力期間,執行緩慢且需要大量資源的要求可能會影響其他作業,而且您可以透過監視系統並注意到效能下降的狀況,觀察到此行為。
下圖顯示了監視儀表板。 (我們使用了 AppDynamics 進行測試)。一開始,系統具有輕量負載。 然後使用者開始要求 UserProfile
GET 方法。 效能相當好,直到其他使用者開始向 WorkInFrontEnd
POST 方法發出要求為止。 此時,回應時間大幅增加 (第一個箭號)。 只有在 WorkInFrontEnd
控制器的要求量減少 (第二個箭號) 之後,回應時間才會改善。
檢查遙測資料並尋找相互關聯
下一個影像顯示一些收集來監視相同間隔內資源使用率的計量。 起初,存取系統的使用者很少。 隨著更多使用者連線,CPU 使用率也變得非常高 (100%)。 另請注意,當 CPU 使用量增加時,網路 I/O 速率一開始會上升。 不過,一旦 CPU 使用量達到尖峰後,網路 I/O 實際上就會降低。 這是因為一旦 CPU 滿載時,系統就只能處理相對較少的要求。 當使用者中斷連線時,CPU 負載會減少。
此時,WorkInFrontEnd
控制器中的 Post
方法似乎是需要更仔細檢查的主要候選項目。 需要在受控制的環境中進一步工作,才能確認假設。
執行負載測試
下一個步驟是在受控制的環境中執行測試。 例如,執行一系列的負載測試,其中依序包含後省略每個要求以查看效果。
下圖顯示針對先前測試中使用的雲端服務相同部署所執行的負載測試結果。 測試使用了 500 位使用者在 UserProfile
控制器中執行 Get
作業的常數負載,以及使用者在 WorkInFrontEnd
控制器中執行 Post
作業的步驟負載。
一開始,步驟負載為 0,因此只有作用中使用者會執行 UserProfile
要求。 系統每秒可以回應大約 500 個要求。 60 秒之後,100 位額外使用者的負載開始將 POST 要求傳送至 WorkInFrontEnd
控制器。 傳送至控制器的 UserProfile
控制器的工作負載幾乎立即下降到大約每秒 150 個要求。 這是因為負載測試執行器的運作方式所致。 它會在傳送下一個要求之前等候回應,因此接收回應所需的時間越長,要求速率越低。
隨著更多使用者將 POST 要求傳送至 WorkInFrontEnd
控制器,UserProfile
控制器的回應率會繼續下降。 但請注意,WorkInFrontEnd
控制器所處理的要求數量維持相對固定。 系統飽和度變得很明顯,因為這兩個要求的整體速率傾向於穩定但低的限制。
檢閱原始程式碼
最後一個步驟是查看原始程式碼。 開發小組知道 Post
方法可能需要相當長的時間,這就是為什麼原始實作使用了個別執行緒的原因。 這解決了立即的問題,因為 Post
方法並未封鎖等候長時間執行的工作完成。
不過,此方法所執行的工作仍會耗用 CPU、記憶體和其他資源。 讓此處理序以非同步方式執行可能會實際損害效能,因為使用者可以用不受控制的方式同時觸發大量作業。 伺服器可執行的執行緒數目有限制。 超過此限制之後,當應用程式嘗試啟動新的執行緒時,可能會收到例外狀況。
注意
這並不表示您應該避免非同步作業。 對網路呼叫執行非同步等候是一種建議的做法。 (請參閱同步 I/O 反模式)。此處的問題在於,CPU 密集型工作會在另一個執行緒上繁衍。
實作解決方案並驗證結果
下圖顯示實作解決方案之後的效能監視。 負載類似於先前所示的狀況,但 UserProfile
控制器的回應時間現在要快得多。 要求數量在同一期間內從 2,759 增加到 23,565。
請注意,WorkInBackground
控制器也處理了更大量的要求。 不過,在此情況下,您無法進行直接比較,因為在此控制器中執行的工作與原始程式碼大不相同。 新版本只會將要求排入佇列,而不是執行耗時的計算。 重點是這個方法不再拖累整個系統的負載。
CPU 和網路使用率也顯示效能改善。 CPU 使用率永遠不會達到 100%,而且已處理的網路要求數量遠高於先前,在工作負載卸除之前不會減少。
下圖顯示負載測試的結果。 相較於先前的測試,已服務的整體要求量大幅改善。