Gần đây tôi đã tạo ra một ứng dụng đơn giản để kiểm tra thông lượng cuộc gọi HTTP có thể được tạo theo cách không đồng bộ so với cách tiếp cận đa luồng cổ điển.
Ứng dụng này có thể thực hiện một số lượng cuộc gọi HTTP được xác định trước và cuối cùng, nó sẽ hiển thị tổng thời gian cần thiết để thực hiện chúng. Trong các thử nghiệm của tôi, tất cả các cuộc gọi HTTP được thực hiện cho máy chủ IIS cục bộ của tôi và họ đã truy xuất một tệp văn bản nhỏ (kích thước 12 byte).
Phần quan trọng nhất của mã để triển khai không đồng bộ được liệt kê bên dưới:
public async void TestAsync()
{
this.TestInit();
HttpClient httpClient = new HttpClient();
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
ProcessUrlAsync(httpClient);
}
}
private async void ProcessUrlAsync(HttpClient httpClient)
{
HttpResponseMessage httpResponse = null;
try
{
Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
httpResponse = await getTask;
Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}
finally
{
if(httpResponse != null) httpResponse.Dispose();
}
lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
}
Phần quan trọng nhất của việc thực hiện đa luồng được liệt kê dưới đây:
public void TestParallel2()
{
this.TestInit();
ServicePointManager.DefaultConnectionLimit = 100;
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
Task.Run(() =>
{
try
{
this.PerformWebRequestGet();
Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}
lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
});
}
}
private void PerformWebRequestGet()
{
HttpWebRequest request = null;
HttpWebResponse response = null;
try
{
request = (HttpWebRequest)WebRequest.Create(URL);
request.Method = "GET";
request.KeepAlive = true;
response = (HttpWebResponse)request.GetResponse();
}
finally
{
if (response != null) response.Close();
}
}
Chạy thử nghiệm cho thấy phiên bản đa luồng nhanh hơn. Phải mất khoảng 0,6 giây để hoàn thành cho 10k yêu cầu, trong khi một async mất khoảng 2 giây để hoàn thành cùng một lượng tải. Đây là một chút ngạc nhiên, bởi vì tôi đã mong đợi cái async sẽ nhanh hơn. Có lẽ đó là do thực tế là các cuộc gọi HTTP của tôi rất nhanh. Trong một kịch bản trong thế giới thực, nơi máy chủ sẽ thực hiện một hoạt động có ý nghĩa hơn và ở đó cũng cần có độ trễ mạng, kết quả có thể bị đảo ngược.
Tuy nhiên, điều thực sự làm tôi lo lắng là cách ứng xử của httpClient khi tải được tăng lên. Vì phải mất khoảng 2 giây để gửi 10k tin nhắn, tôi nghĩ rằng sẽ mất khoảng 20 giây để gửi số lượng tin nhắn gấp 10 lần, nhưng khi chạy thử nghiệm cho thấy cần khoảng 50 giây để gửi tin nhắn 100k. Hơn nữa, thường mất hơn 2 phút để gửi 200k tin nhắn và thường, một vài ngàn trong số chúng (3-4k) không thành công với ngoại lệ sau:
Không thể thực hiện thao tác trên ổ cắm vì hệ thống thiếu đủ không gian bộ đệm hoặc do hàng đợi đã đầy.
Tôi đã kiểm tra các bản ghi IIS và các hoạt động không thành công với máy chủ. Họ đã thất bại trong khách hàng. Tôi đã chạy thử nghiệm trên máy Windows 7 với phạm vi cổng phù hợp mặc định là 49152 đến 65535. Chạy netstat cho thấy khoảng 5-6k cổng đã được sử dụng trong các thử nghiệm, vì vậy về mặt lý thuyết nên có sẵn nhiều hơn. Nếu việc thiếu các cổng thực sự là nguyên nhân của các ngoại lệ, điều đó có nghĩa là netstat không báo cáo đúng tình huống hoặc HttClient chỉ sử dụng số lượng cổng tối đa sau đó bắt đầu ném ngoại lệ.
Ngược lại, cách tiếp cận đa luồng trong việc tạo các cuộc gọi HTTP hoạt động rất dễ đoán. Tôi mất khoảng 0,6 giây cho 10k tin nhắn, khoảng 5,5 giây cho 100k tin nhắn và như mong đợi khoảng 55 giây cho 1 triệu tin nhắn. Không có tin nhắn thất bại. Hơn nữa, trong khi nó chạy, nó không bao giờ sử dụng hơn 55 MB RAM (theo Trình quản lý tác vụ Windows). Bộ nhớ được sử dụng khi gửi tin nhắn tăng không đồng bộ theo tỷ lệ tải. Nó đã sử dụng khoảng 500 MB RAM trong các bài kiểm tra tin nhắn 200k.
Tôi nghĩ có hai lý do chính cho kết quả trên. Điều đầu tiên là httpClient dường như rất tham lam trong việc tạo kết nối mới với máy chủ. Số lượng cổng được sử dụng cao được báo cáo bởi netstat có nghĩa là nó có thể không được hưởng lợi nhiều từ việc giữ HTTP.
Thứ hai là dường như httpClient không có cơ chế điều tiết. Trong thực tế, điều này dường như là một vấn đề chung liên quan đến hoạt động không đồng bộ. Nếu bạn cần thực hiện một số lượng lớn các hoạt động, tất cả chúng sẽ được bắt đầu cùng một lúc và sau đó các phần tiếp theo của chúng sẽ được thực hiện khi chúng có sẵn. Về lý thuyết, điều này là ổn, bởi vì trong các hoạt động không đồng bộ, tải nằm trên các hệ thống bên ngoài nhưng như đã chứng minh ở trên, điều này không hoàn toàn đúng. Có một số lượng lớn các yêu cầu bắt đầu cùng một lúc sẽ tăng mức sử dụng bộ nhớ và làm chậm toàn bộ quá trình thực thi.
Tôi đã quản lý để có được kết quả tốt hơn, bộ nhớ và thời gian thực hiện khôn ngoan hơn, bằng cách giới hạn số lượng yêu cầu không đồng bộ tối đa với cơ chế trì hoãn đơn giản nhưng nguyên thủy:
public async void TestAsyncWithDelay()
{
this.TestInit();
HttpClient httpClient = new HttpClient();
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
await Task.Delay(DELAY_TIME);
ProcessUrlAsyncWithReqCount(httpClient);
}
}
Sẽ thực sự hữu ích nếu httpClient bao gồm một cơ chế giới hạn số lượng yêu cầu đồng thời. Khi sử dụng lớp Nhiệm vụ (dựa trên nhóm luồng .Net), việc điều tiết sẽ tự động đạt được bằng cách giới hạn số lượng luồng đồng thời.
Để có cái nhìn tổng quan hoàn chỉnh, tôi cũng đã tạo một phiên bản kiểm tra async dựa trên HttpWebRequest chứ không phải là httpClient và quản lý để thu được kết quả tốt hơn nhiều. Để bắt đầu, nó cho phép đặt giới hạn về số lượng kết nối đồng thời (với ServicePointManager.DefaultConnectionLimit hoặc thông qua cấu hình), có nghĩa là nó không bao giờ hết cổng và không bao giờ bị lỗi đối với bất kỳ yêu cầu nào (theo mặc định, httpClient , nhưng dường như bỏ qua cài đặt giới hạn kết nối).
Cách tiếp cận async httpWebRequest vẫn chậm hơn khoảng 50 - 60% so với phương pháp đa luồng, nhưng nó có thể dự đoán và đáng tin cậy. Nhược điểm duy nhất của nó là nó sử dụng một lượng lớn bộ nhớ dưới tải lớn. Ví dụ, cần khoảng 1,6 GB để gửi 1 triệu yêu cầu. Bằng cách giới hạn số lượng yêu cầu đồng thời (như tôi đã làm ở trên đối với HttpClient), tôi đã quản lý để giảm bộ nhớ đã sử dụng xuống chỉ còn 20 MB và có thời gian thực hiện chậm hơn 10% so với phương pháp đa luồng.
Sau bài thuyết trình dài này, các câu hỏi của tôi là: Có phải lớp httpClient từ .Net 4.5 là một lựa chọn tồi cho các ứng dụng tải chuyên sâu? Có cách nào để điều tiết nó, mà nên khắc phục các vấn đề tôi đề cập đến? Làm thế nào về hương vị không đồng bộ của HttpWebRequest?
Cập nhật (cảm ơn @Stephen Cleary)
Khi nó bật ra, HttpClient, giống như HttpWebRequest (dựa trên mặc định), có thể có số lượng kết nối đồng thời trên cùng một máy chủ bị giới hạn với ServicePointManager.DefaultConnectionLimit. Điều kỳ lạ là theo MSDN , giá trị mặc định cho giới hạn kết nối là 2. Tôi cũng đã kiểm tra xem phía tôi sử dụng trình gỡ lỗi chỉ ra rằng thực sự 2 là giá trị mặc định. Tuy nhiên, dường như trừ khi đặt rõ ràng một giá trị thành ServicePointManager.DefaultConnectionLimit, giá trị mặc định sẽ bị bỏ qua. Vì tôi không đặt giá trị cho nó một cách rõ ràng trong các thử nghiệm httpClient của mình, tôi nghĩ rằng nó đã bị bỏ qua.
Sau khi thiết lập ServicePointManager.DefaultConnectionLimit thành 100 HttpClient trở nên đáng tin cậy và có thể dự đoán được (netstat xác nhận rằng chỉ có 100 cổng được sử dụng). Nó vẫn chậm hơn async HttpWebRequest (khoảng 40%), nhưng lạ thay, nó sử dụng ít bộ nhớ hơn. Đối với thử nghiệm bao gồm 1 triệu yêu cầu, nó đã sử dụng tối đa 550 MB, so với 1,6 GB trong httpWebRequest không đồng bộ.
Vì vậy, trong khi httpClient kết hợp ServicePointManager.DefaultConnectionLimit dường như đảm bảo độ tin cậy (ít nhất là đối với kịch bản mà tất cả các cuộc gọi được thực hiện đối với cùng một máy chủ), có vẻ như hiệu suất của nó bị ảnh hưởng tiêu cực do thiếu cơ chế điều tiết thích hợp. Một cái gì đó sẽ giới hạn số lượng yêu cầu đồng thời ở một giá trị có thể định cấu hình và đặt phần còn lại vào hàng đợi sẽ làm cho nó phù hợp hơn nhiều cho các kịch bản có khả năng mở rộng cao.
SemaphoreSlim
, như đã đề cập hoặc ActionBlock<T>
từ TPL Dataflow.
HttpClient
Nên tôn trọngServicePointManager.DefaultConnectionLimit
.