← Quay lại blog

Proxy trong .NET HttpClient: SocketsHttpHandler, SOCKS5 và xoay vòng không rò rỉ socket

Phân tích cách cấu hình proxy đúng cách trong .NET HttpClient với SocketsHttpHandler, kết nối SOCKS5 và tổ chức quay vòng IP mà không bị cạn kiệt socket.

📅18 tháng 10, 2026

Nếu bạn từng gặp phải SocketException: Address already in use hoặc thấy ứng dụng .NET của bạn đột ngột hết socket khả dụng sau vài giờ làm việc với proxy, thì vấn đề gần như luôn nằm ở việc sử dụng không đúng HttpClient. Trong bài viết này, chúng ta sẽ phân tích cách cấu hình proxy thông qua SocketsHttpHandler, kết nối SOCKS5 và tổ chức xoay vòng IP sao cho pool kết nối không trở thành nút thắt cổ chai.

Tại sao HttpClient "ăn" socket

Lỗi cổ điển là tạo một thể hiện mới của HttpClient cho mỗi yêu cầu bên trong using. Khi làm việc với proxy, vấn đề này càng trở nên nghiêm trọng: mỗi kết nối qua máy chủ proxy yêu cầu một TCP handshake riêng biệt, và nếu bạn còn thay đổi proxy trong quá trình chạy, pool kết nối sẽ không được tái sử dụng, và socket sẽ bị treo trong trạng thái TIME_WAIT.

Trên Windows, điều này đặc biệt nghiêm trọng — theo mặc định, có một số lượng cổng động hạn chế (thường là 16384), và nếu ứng dụng phân tích dữ liệu qua proxy với cường độ yêu cầu cao, tất cả các cổng có thể hết chỉ trong vài phút. Trên Linux, tình hình nhẹ nhàng hơn một chút nhờ vào cấu hình kernel, nhưng vấn đề vẫn tồn tại nếu không tái sử dụng kết nối.

HttpClientHandler cũ, có sẵn từ thời .NET Framework, không cho phép kiểm soát linh hoạt vòng đời của các kết nối. Đó là lý do tại sao kể từ .NET Core 2.1, việc sử dụng SocketsHttpHandler được khuyến nghị — nó được viết hoàn toàn bằng managed code và cho phép truy cập trực tiếp vào pool kết nối, timeout DNS và cấu hình proxy.

SocketsHttpHandler: tại sao nó cần thiết

SocketsHttpHandler là lớp giao thông mà từ .NET Core 2.1 trở đi, HttpClient sử dụng mặc định trên Linux và macOS, và bắt đầu từ .NET 5 — trên Windows. Khác với WinHttpHandler cũ, nó:

  • cho phép thiết lập PooledConnectionLifetime — thời gian sống của kết nối trong pool, điều này rất quan trọng khi làm việc với proxy xoay vòng;
  • cung cấp quyền truy cập vào IWebProxy thông qua delegate Proxy, có thể được ghi đè lập trình trong quá trình chạy;
  • hỗ trợ ConnectCallback — một hook cấp thấp để thay thế giao thông, điều này cần thiết cho SOCKS5;
  • giải phóng socket một cách chính xác sau khi PooledConnectionIdleTimeout hết hạn, không để lại rác trong hệ thống.

Cấu hình cơ bản trông như sau:

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
    MaxConnectionsPerServer = 50,
    AutomaticDecompression = DecompressionMethods.All
};

var client = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30)
};

Quy tắc chính: HttpClient với handler này phải là singleton hoặc được tạo thông qua IHttpClientFactory — đừng tạo lại nó cho mỗi yêu cầu, nếu không thì toàn bộ ý nghĩa của pool kết nối sẽ bị mất.

Cấu hình proxy thông qua SocketsHttpHandler

Đối với proxy HTTP/HTTPS, chỉ cần thiết lập thuộc tính Proxy ở cấp độ handler. Điều này hoạt động cho các proxy trung tâm dữ liệu và proxy cư trú, cho phép truy cập qua giao thức HTTP CONNECT:

var proxy = new WebProxy("http://proxy-host:8000")
{
    Credentials = new NetworkCredential("login", "password")
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(3)
};

var client = new HttpClient(handler);

var response = await client.GetAsync("https://example.com");
Console.WriteLine(await response.Content.ReadAsStringAsync());

Nếu cần kết nối các proxy khác nhau cho các host khác nhau (ví dụ, xác thực cho một miền diễn ra trực tiếp, trong khi scraping — qua proxy), hãy sử dụng delegate thay vì một đối tượng tĩnh:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(),
    UseProxy = true
};

handler.Proxy = new CustomProxy(uri =>
{
    return uri.Host.Contains("target-site")
        ? new Uri("http://proxy-host:8000")
        : null;
});

Đối với các tác vụ phân tích thị trường — chẳng hạn như theo dõi giá trên Wildberries hoặc Ozon — thường kết hợp cả hai cách tiếp cận: lưu lượng chính đi qua proxy trung tâm dữ liệu như một proxy trung tâm dữ liệu, trong khi các yêu cầu đến dịch vụ captcha hoặc xác thực — trực tiếp.

Hỗ trợ SOCKS5 trong .NET

Bắt đầu từ .NET 6, SocketsHttpHandler đã nhận được hỗ trợ gốc cho SOCKS4/SOCKS4a/SOCKS5 thông qua việc chỉ định scheme trong URI proxy — không cần thư viện bên ngoài:

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
    {
        Credentials = new NetworkCredential("login", "password")
    },
    UseProxy = true
};

var client = new HttpClient(handler);
var response = await client.GetAsync("https://httpbin.org/ip");

Một điểm quan trọng: trước .NET 6, không có khả năng này, và các nhà phát triển phải viết ConnectCallback của riêng họ, thực hiện thủ công handshake SOCKS5 trước khi thiết lập kết nối TLS. Nếu dự án của bạn vẫn trên .NET Core 3.1 hoặc .NET 5, con đường này vẫn còn phù hợp:

var handler = new SocketsHttpHandler
{
    ConnectCallback = async (context, cancellationToken) =>
    {
        var socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
        {
            NoDelay = true
        };

        await socket.ConnectAsync("proxy-host", 1080, cancellationToken);
        await PerformSocks5Handshake(socket, context.DnsEndPoint, cancellationToken);

        return new NetworkStream(socket, ownsSocket: true);
    }
};

Trên thực tế, đối với các dự án sản xuất, dễ dàng hơn để nâng cấp lên .NET 6+ và sử dụng triển khai tích hợp — nó được kiểm tra trong thời gian chạy và không yêu cầu hỗ trợ riêng cho giao thức handshake.

Xoay vòng proxy mà không cần tạo lại HttpClient

Một lỗi phổ biến khi thực hiện xoay vòng IP là tạo một HttpClient mới (và do đó là một handler mới, và một pool kết nối mới) cho mỗi lần đổi proxy. Điều này chắc chắn dẫn đến việc tích lũy các socket chưa được đóng. Cách tiếp cận đúng là triển khai một IWebProxy tùy chỉnh, trả về địa chỉ proxy khác nhau cho mỗi lần gọi:

public class RotatingProxy : IWebProxy
{
    private readonly string[] _proxies;
    private int _index = -1;

    public RotatingProxy(string[] proxies) => _proxies = proxies;

    public ICredentials Credentials { get; set; }

    public Uri GetProxy(Uri destination)
    {
        var next = Interlocked.Increment(ref _index) % _proxies.Length;
        return new Uri(_proxies[next]);
    }

    public bool IsBypassed(Uri host) => false;
}

Lớp này được kết nối với SocketsHttpHandler duy nhất:

var handler = new SocketsHttpHandler
{
    Proxy = new RotatingProxy(new[]
    {
        "http://proxy1:8000",
        "http://proxy2:8000",
        "http://proxy3:8000"
    }),
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromSeconds(60)
};

var client = new HttpClient(handler);

Sơ đồ này tái sử dụng pool socket nội bộ, nhưng mỗi yêu cầu thực sự đi qua một IP mới. Nếu bạn đang xây dựng các pool kết nối song song cho mỗi proxy (để tăng tốc độ), hãy đặt PooledConnectionLifetime hợp lý — thường là 30-90 giây, để các kết nối không hết hạn nhanh hơn IP bên phía nhà cung cấp proxy. Đối với các tác vụ như phân tích Avito hoặc thu thập dữ liệu từ Instagram API, việc sử dụng proxy cư trú với xoay vòng bên phía nhà cung cấp sẽ thuận tiện hơn — lúc đó trong mã không cần thay đổi địa chỉ, chỉ cần giữ một "điểm vào" với việc tự động thay thế IP ra ngoài.

PooledConnectionLifetime và xoay vòng DNS

Nếu nhà cung cấp proxy sử dụng xoay vòng DNS (một hostname được giải quyết thành nhiều IP trong mỗi lần truy cập), việc thiết lập PooledConnectionLifetime hạn chế là rất quan trọng. Theo mặc định, nó bằng Infinite, và .NET sẽ giữ kết nối đến IP cũ bao lâu tùy thích, bỏ qua các cập nhật DNS.

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromSeconds(30), // tái tạo kết nối
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
    MaxConnectionsPerServer = 20
};

Điều này đặc biệt quan trọng đối với proxy di động, nơi việc thay đổi IP của nhà cung cấp có thể xảy ra mỗi 1-10 phút trong quá trình xoay vòng của nhà cung cấp dịch vụ di động. Nếu giữ kết nối lâu hơn, các yêu cầu sẽ tiếp tục đi qua IP đã được giải phóng, và đôi khi — thậm chí sẽ gặp lỗi timeout. Đối với các kịch bản như vậy, proxy di động rất phù hợp — chúng mang lại độ tin cậy cao nhất từ các hệ thống chống gian lận, nhưng yêu cầu cấu hình TTL kết nối cẩn thận ở cấp độ client.

Giá trị ngắn cho PooledConnectionLifetime không miễn phí — mỗi lần tái tạo kết nối có nghĩa là một TCP+TLS handshake mới, điều này thêm vào độ trễ 50-200 ms. Cân bằng giữa độ mới của IP và hiệu suất được điều chỉnh theo kinh nghiệm: đối với các tác vụ theo dõi giá, 60-120 giây là đủ, trong khi đối với việc thu thập tài khoản tần suất cao — đôi khi cần 10-20 giây.

So sánh các loại proxy cho ứng dụng .NET

Loại proxy Giao thức PooledConnectionLifetime được khuyến nghị Nhiệm vụ
Proxy trung tâm dữ liệu HTTP/HTTPS, SOCKS5 5-10 phút Phân tích, tích hợp API, tốc độ cao
Proxy cư trú HTTP/HTTPS, SOCKS5 30-90 giây Theo dõi giá, vượt qua geo-blocking
Proxy di động HTTP/HTTPS, SOCKS5 10-30 giây Tự động hóa trên mạng xã hội, API nhạy cảm với chống gian lận

IHttpClientFactory và các client kiểu với proxy

Trong các dự án ASP.NET Core, nên đăng ký HttpClient thông qua IHttpClientFactory — điều này giúp giảm bớt trách nhiệm của nhà phát triển trong việc quản lý vòng đời của các handler và tự động tái sử dụng các SocketsHttpHandler nội bộ.

builder.Services.AddHttpClient("ProxyClient")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
        {
            Credentials = new NetworkCredential("login", "password")
        },
        UseProxy = true,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2)
    })
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

Lưu ý về SetHandlerLifetime — đây là một cơ chế riêng để xoay vòng các handler bên trong factory (mặc định là 2 phút), không nên nhầm lẫn với PooledConnectionLifetime của một kết nối riêng lẻ. Factory sẽ định kỳ tạo một handler mới, nhưng các kết nối cũ sẽ tiếp tục sống cho đến khi các yêu cầu đang hoạt động hoàn tất — garbage collector sẽ tự động dọn dẹp chúng.

Đối với các kịch bản với nhiều pool proxy, việc đăng ký nhiều client có tên sẽ thuận tiện — chẳng hạn, riêng cho Mỹ và riêng cho Châu Âu, nếu nhiệm vụ là kiểm tra quảng cáo Google Ads hoặc Facebook Ads từ các khu vực khác nhau:

builder.Services.AddHttpClient("US-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://us-proxy-host:8000"),
        UseProxy = true
    });

builder.Services.AddHttpClient("EU-Proxy")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://eu-proxy-host:8000"),
        UseProxy = true
    });

Chẩn đoán rò rỉ socket

Nếu bạn nghi ngờ có sự tích lũy socket, đừng đoán — hãy kiểm tra. Trên Linux, một cách nhanh chóng để xem số lượng kết nối mở của một tiến trình:

# số lượng kết nối trong TIME_WAIT
ss -tan state time-wait | wc -l

# tất cả các kết nối của một tiến trình cụ thể (PID)
ls -la /proc/{PID}/fd | grep socket | wc -l

Trên Windows, lệnh tương tự cung cấp thống kê netstat -ano với bộ lọc theo PID của tiến trình. Để chẩn đoán sâu hơn ở cấp độ .NET, hãy sử dụng công cụ tích hợp dotnet-counters, hiển thị các chỉ số System.Net.Http theo thời gian thực:

dotnet-counters monitor -p {PID} --counters System.Net.Http

# các bộ đếm chính:
# current-connections
# connections-duration
# requests-failed-rate

Nếu current-connections tăng liên tục và không bao giờ giảm — đây là dấu hiệu rõ ràng rằng handler được tạo lại quá thường xuyên, hoặc PooledConnectionIdleTimeout được thiết lập thành Infinite, và các yêu cầu không hoàn thành đúng cách (ví dụ, không đọc hết nội dung phản hồi, và kết nối không được trả lại pool).

Một nguyên nhân phổ biến khác của rò rỉ là response.Content bị bỏ quên, không được đọc hoặc không được dispose. Luôn sử dụng using var response = ... hoặc gọi rõ ràng Dispose() sau khi hoàn thành công việc với phản hồi.

Kết luận

Việc làm việc đúng cách với proxy trong .NET dựa trên ba nguyên tắc: sử dụng SocketsHttpHandler thay vì các triển khai lỗi thời, giữ HttpClient như một singleton (hoặc thông qua IHttpClientFactory), và thực hiện xoay vòng proxy thông qua một IWebProxy tùy chỉnh, thay vì tạo lại toàn bộ client. Bắt đầu từ .NET 6, SOCKS5 được hỗ trợ gốc, điều này loại bỏ nhu cầu viết giao thức handshake của riêng bạn.

Nếu ứng dụng của bạn phân tích các thị trường hoặc thu thập dữ liệu qua API với cường độ yêu cầu cao, hãy chú ý đến proxy trung tâm dữ liệu — chúng cung cấp tốc độ tối đa khi cấu hình pool kết nối đúng cách. Đối với các tác vụ mà tính ẩn danh và sự tương đồng với lưu lượng người dùng thông thường là quan trọng, proxy cư trú sẽ phù hợp hơn, và cho việc làm việc với API di động và các nền tảng xã hội — proxy di động với thời gian sống kết nối ngắn trong mã.