API Tutorials

使用 Task.WhenAll 和 CaptchaAI 在 C# 中异步验证码求解

验证码任务一多,用 for 循环逐个 await 会把等待时间成倍拉长——100 个任务顺序执行,光轮询往返就可能耗掉好几分钟。C# 的 Task.WhenAll 能把这些请求一次性并发出去,整批结果一起收回,还能处理部分失败。

本文覆盖:

  • HttpClient 封装提交与轮询
  • SemaphoreSlim 限流
  • 超时与取消,附完整示例

准备工作

dotnet new console -n CaptchaSolver
cd CaptchaSolver
dotnet add package System.Text.Json

不需要额外第三方库:

  • HttpClientTask.WhenAll——.NET 内置
  • System.Text.Json——解析返回 JSON
  • 拉包慢可加 -s 换镜像源

核心客户端:封装提交与轮询逻辑

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

public class CaptchaAiClient : IDisposable
{
    private readonly HttpClient _client;
    private readonly string _apiKey;
    private const string SubmitUrl = "https://ocr.captchaai.com/in.php";
    private const string ResultUrl = "https://ocr.captchaai.com/res.php";

    public CaptchaAiClient(string apiKey)
    {
        _apiKey = apiKey;
        _client = new HttpClient();
    }

    public async Task<string> SolveCaptchaAsync(string sitekey, string pageurl)
    {
        // Submit
        var submitParams = new FormUrlEncodedContent(new[]
        {
            new KeyValuePair<string, string>("key", _apiKey),
            new KeyValuePair<string, string>("method", "userrecaptcha"),
            new KeyValuePair<string, string>("googlekey", sitekey),
            new KeyValuePair<string, string>("pageurl", pageurl),
            new KeyValuePair<string, string>("json", "1")
        });

        var submitResp = await _client.PostAsync(SubmitUrl, submitParams);
        var submitJson = await submitResp.Content.ReadAsStringAsync();
        var submitData = JsonSerializer.Deserialize<ApiResponse>(submitJson);

        if (submitData.Status != 1)
            throw new Exception($"Submit failed: {submitData.Request}");

        var captchaId = submitData.Request;

        // Poll for result
        for (int i = 0; i < 60; i++)
        {
            await Task.Delay(5000);

            var resultResp = await _client.GetAsync(
                $"{ResultUrl}?key={_apiKey}&action=get&id={captchaId}&json=1"
            );
            var resultJson = await resultResp.Content.ReadAsStringAsync();
            var resultData = JsonSerializer.Deserialize<ApiResponse>(resultJson);

            if (resultData.Status == 1)
                return resultData.Request;

            if (resultData.Request != "CAPCHA_NOT_READY")
                throw new Exception($"Solve failed: {resultData.Request}");
        }

        throw new TimeoutException("Solve timeout after 300s");
    }

    public void Dispose() => _client.Dispose();
}

public class ApiResponse
{
    public int Status { get; set; }
    public string Request { get; set; }
}

SolveCaptchaAsync 封装提交、轮询两步,客户端全程只复用一个 HttpClient(原因见文末):

  • 提交 sitekeypageurlin.php,拿任务 ID
  • 每 5 秒轮询 res.php,超过 300 秒判定超时

用 Task.WhenAll 并发提交任务

public class BatchSolver
{
    private readonly CaptchaAiClient _client;

    public BatchSolver(string apiKey)
    {
        _client = new CaptchaAiClient(apiKey);
    }

    public async Task<BatchResult> SolveAllAsync(
        IReadOnlyList<CaptchaTask> tasks)
    {
        var solveTasks = new Task<TaskResult>[tasks.Count];

        for (int i = 0; i < tasks.Count; i++)
        {
            var task = tasks[i];
            solveTasks[i] = SolveSingleAsync(task);
        }

        // Wait for ALL tasks — no short-circuiting on failure
        var results = await Task.WhenAll(solveTasks);

        return new BatchResult
        {
            Solved = Array.FindAll(results, r => r.Solution != null),
            Failed = Array.FindAll(results, r => r.Error != null)
        };
    }

    private async Task<TaskResult> SolveSingleAsync(CaptchaTask task)
    {
        try
        {
            var solution = await _client.SolveCaptchaAsync(
                task.Sitekey, task.Pageurl);
            return new TaskResult
            {
                TaskId = task.TaskId,
                Solution = solution
            };
        }
        catch (Exception ex)
        {
            return new TaskResult
            {
                TaskId = task.TaskId,
                Error = ex.Message
            };
        }
    }
}

public record CaptchaTask(string TaskId, string Sitekey, string Pageurl);

public class TaskResult
{
    public string TaskId { get; set; }
    public string Solution { get; set; }
    public string Error { get; set; }
}

public class BatchResult
{
    public TaskResult[] Solved { get; set; }
    public TaskResult[] Failed { get; set; }
}

SolveAllAsync 把每个 CaptchaTask 包成 Task<TaskResult>,用 Task.WhenAll 一次等完成,关键在 SolveSingleAsync

  • SolveCaptchaAsync 包进 try/catch,失败也返回带 Error 的结果
  • 否则 Task.WhenAll 只抛第一个异常,其余结果全被吞掉

用 SemaphoreSlim 控制并发数

public async Task<BatchResult> SolveWithLimitAsync(
    IReadOnlyList<CaptchaTask> tasks,
    int maxConcurrency = 10)
{
    var semaphore = new SemaphoreSlim(maxConcurrency);
    var solveTasks = new Task<TaskResult>[tasks.Count];

    for (int i = 0; i < tasks.Count; i++)
    {
        var task = tasks[i];
        solveTasks[i] = ThrottledSolveAsync(task, semaphore);
    }

    var results = await Task.WhenAll(solveTasks);

    return new BatchResult
    {
        Solved = Array.FindAll(results, r => r.Solution != null),
        Failed = Array.FindAll(results, r => r.Error != null)
    };
}

private async Task<TaskResult> ThrottledSolveAsync(
    CaptchaTask task, SemaphoreSlim semaphore)
{
    await semaphore.WaitAsync();
    try
    {
        return await SolveSingleAsync(task);
    }
    finally
    {
        semaphore.Release();
    }
}

SolveAllAsync 一次发出所有任务,量大就可能打满线程配额,SemaphoreSlim 负责限流:

  • maxConcurrency 参考套餐线程数,比如 BASIC($15/月,5 线程)
  • 超出的请求排队即可,不报错
  • 按线程计费,同一线程内解决次数不限

完整示例:并发识别 20 个验证码

class Program
{
    static async Task Main(string[] args)
    {
        var apiKey = Environment.GetEnvironmentVariable("CAPTCHAAI_API_KEY")
            ?? throw new Exception("Set CAPTCHAAI_API_KEY");

        var solver = new BatchSolver(apiKey);

        // Create 20 tasks
        var tasks = new List<CaptchaTask>();
        for (int i = 0; i < 20; i++)
        {
            tasks.Add(new CaptchaTask(
                $"task_{i}",
                "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
                $"https://example.com/page/{i}"
            ));
        }

        Console.WriteLine($"Solving {tasks.Count} CAPTCHAs with concurrency=10...");
        var start = DateTime.UtcNow;

        var result = await solver.SolveWithLimitAsync(tasks, maxConcurrency: 10);

        var elapsed = DateTime.UtcNow - start;
        Console.WriteLine($"\nDone in {elapsed.TotalSeconds:F1}s");
        Console.WriteLine($"  Solved: {result.Solved.Length}");
        Console.WriteLine($"  Failed: {result.Failed.Length}");

        foreach (var s in result.Solved)
            Console.WriteLine($"  ✓ {s.TaskId}: {s.Solution[..Math.Min(30, s.Solution.Length)]}...");

        foreach (var f in result.Failed)
            Console.WriteLine($"  ✗ {f.TaskId}: {f.Error}");
    }
}

不少跨境电商、海外数据采集团队用 .NET 搭建抓取后端,这份示例就模拟了批量场景:提交 20 个 reCAPTCHA 任务,并发数设为 10,打印成功/失败结果,方便接入日志告警,同时只采集自己有权访问的数据。

提示:生产环境建议加结构化日志和重试计数,方便定位失败率偏高的任务类型。

全局超时与取消处理

全局超时后取消所有挂起的任务:

public async Task<BatchResult> SolveWithTimeoutAsync(
    IReadOnlyList<CaptchaTask> tasks,
    int maxConcurrency = 10,
    TimeSpan? timeout = null)
{
    using var cts = new CancellationTokenSource(
        timeout ?? TimeSpan.FromMinutes(10));

    try
    {
        return await SolveWithLimitAsync(tasks, maxConcurrency);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Batch operation timed out.");
        return new BatchResult
        {
            Solved = Array.Empty<TaskResult>(),
            Failed = Array.Empty<TaskResult>()
        };
    }
}

CancellationTokenSource 设置整批任务的最长等待时间:

  • 默认 10 分钟,可用 timeout 参数覆盖
  • 超时后捕获 OperationCanceledException,返回空结果,不冒泡到调用方

Task.WhenAll 与 Parallel.ForEachAsync 怎么选(.NET 6+)

// .NET 6+ alternative
await Parallel.ForEachAsync(tasks,
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (task, ct) =>
    {
        var result = await SolveSingleAsync(task);
        // Process result immediately
    });

什么时候用 Task.WhenAll

等全部跑完拿结果数组,适合批量汇总、生成报表。

什么时候用 Parallel.ForEachAsync

边跑边处理结果,适合实时写队列、写数据库。

方法 收集所有结果 内置并发限制 .NET 版本
Task.WhenAll + SemaphoreSlim 手动(SemaphoreSlim) .NET Core 1.0+
Parallel.ForEachAsync 边跑边处理 内置 .NET 6+

常见故障排查

先查下表:

问题 原因 处理方式
HttpClient 套接字耗尽 每个请求都 new 一个 HttpClient 用单个共享 HttpClient(如上所示)
Task.WhenAll 只抛出第一个异常 没有在单个任务内部做 try/catch SolveSingleAsync 内部捕获(如上所示)
任务超过 1000 个时内存占用高 所有任务一次性同时启动 SemaphoreSlim 控制并发数
SSL/TLS 报错 旧版 .NET 默认走 TLS 1.0 设置 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12

国内网络下轮询迟迟拿不到结果,也可能是 reCAPTCHA 挑战依赖 Google 资源、访问较慢,先排除这点再查代码。

常见问题

接入时最常被问到:

为什么不能给每个任务都 new 一个 HttpClient?

会导致套接字耗尽——HttpClient 设计为长期复用,整个生命周期共用一个实例即可,本文的 CaptchaAiClient 就是这样处理的。

并发数超过 CaptchaAI 套餐的线程数会怎样?

不会报错,多出的请求在 SemaphoreSlim 里排队等待,把 maxConcurrency 设成接近套餐线程数即可。

Task.WhenAll 里某个任务失败,会不会让整批中断?

不会,只要在 SolveSingleAsync 内部做了 try/catch,失败的任务返回带 Error 的结果,Task.WhenAll 仍会等全部任务结束才返回。

轮询间隔和超时时间可以按验证码类型调整吗?

可以,5 秒轮询、300 秒超时是 reCAPTCHA v2 的通用值;Turnstile 这类更快的类型可适当缩短,改几个参数即可。

除了 reCAPTCHA,这套方案能不能识别 Turnstile 或 GeeTest?

可以,替换 SolveCaptchaAsync 的提交参数即可(GeeTest v3 用 gtchallenge),并发逻辑不用改。CaptchaAI 支持 GeeTest v3,v4 暂不支持。

相关文章

下一步

想在 C# 项目里跑通这套方案?注册 CaptchaAI 拿到 API Key,接入示例代码即可开始并发识别。

相关指南:

该文章已禁用评论。