C++ 工业边缘 libcurl HTTP 客户端实战

在工业边缘网关里,HTTP 客户端通常不是"能发一个 GET 就够了"。它要访问本地运维接口、云端 API、OTA 下载服务、时间同步服务和第三方平台,同时面对弱网、证书更换、DNS 异常、长周期运行和有限的 CPU / 内存资源。

libcurl 是 C / C++ 生态里成熟的 HTTP 客户端库,支持 HTTP / HTTPS、代理、证书、断点下载、连接复用和 Multi 并发接口。但工程效果取决于怎么封装:全局初始化、句柄生命周期、超时、TLS、重试、观测和取消逻辑,才是决定稳定性的部分。

本文按工业边缘项目的方式梳理 libcurl 的使用:依赖接入、基础请求、POST、HTTPS、超时、连接复用、RAII 封装、Multi 并发、错误处理和常见坑。

一、为什么选择 libcurl

工业边缘项目里常见几类 HTTP 需求:

  • 调用本地设备或边缘服务的 REST API;
  • 向云平台上送测点、事件和文件;
  • 下载固件包、配置包和证书;
  • 使用企业 CA 或双向 TLS 访问内网系统;
  • 在弱网环境下做超时控制、重试和断点续传;
  • 让多个采集任务、上传任务和控制任务并发访问不同服务。

libcurl 的优势在于:

能力 工程价值
多协议支持 HTTP / HTTPS / FTP / MQTT 等场景可复用同一套基础库
TLS 后端可插拔 OpenSSL、mbedTLS、Schannel 等后端按平台选择
easy / multi 两层 API 同步请求和事件驱动并发可分层实现
连接复用 减少握手和 TCP 建链开销
精细选项 超时、限速、证书、代理、重定向、回调都能控制
长期维护 版本迭代成熟,工业和嵌入式系统使用广泛

它也不是"引入后自动稳定"。libcurl 解决协议层能力,业务层仍要补齐重试策略、幂等、鉴权、观测、资源上限和优雅取消。

二、依赖接入与版本确认

1. 常见安装方式

Linux 下优先使用系统包或包管理器:

bash 复制代码
# Debian / Ubuntu
sudo apt install libcurl4-openssl-dev

# Fedora
sudo dnf install libcurl-devel

# Alpine
sudo apk add curl-dev

跨平台项目常用 vcpkg 或 Conan:

bash 复制代码
# vcpkg manifest 模式
vcpkg add port curl

# Conan
conan install . --output-folder=build --build=missing

构建时通过 pkg-config 拿编译和链接参数:

bash 复制代码
g++ -std=c++17 main.cpp $(pkg-config --cflags --libs libcurl)

不要只写 -lcurl 就结束。交叉编译时尤其要确认头文件、链接库和运行时 so 是否来自同一个 sysroot,否则容易出现编译通过、运行时加载旧版本库的问题。

2. 确认 TLS 能力

同一个 libcurl 版本可能编译了不同 TLS 后端。部署前先检查:

cpp 复制代码
#include <curl/curl.h>
#include <iostream>

int main() {
    curl_version_info_data* info = curl_version_info(CURLVERSION_NOW);
    std::cout << "curl: " << info->version << "\n";
    std::cout << "ssl: "
              << (info->ssl_version ? info->ssl_version : "not enabled")
              << "\n";
}

工业网关镜像应该固定 libcurl 和 TLS 后端版本,并把证书 bundle 的更新纳入镜像升级流程。

三、先理解 libcurl 的对象模型

libcurl 的核心对象有三层:

对象 作用 线程与生命周期要点
global runtime 初始化底层库和 TLS 后端 curl_global_init / curl_global_cleanup 不是线程安全的
easy handle 一次可配置的请求上下文 同一 CURL* 不能被多个线程同时使用
multi handle 管理多个 easy handle 的并发传输 multi 自身也不应被多个线程无保护并发访问

最小调用顺序是:

text 复制代码
curl_global_init
  ↓
curl_easy_init
  ↓
curl_easy_setopt
  ↓
curl_easy_perform
  ↓
curl_easy_getinfo
  ↓
curl_easy_cleanup
  ↓
curl_global_cleanup
``+

### 全局初始化只做一次

原文章节中的类构造函数里直接调用 `curl_global_init()`、析构里调用 `curl_global_cleanup()`,如果这个类被创建多次,或者对象在多线程运行期间析构,会带来明显风险。

正确做法是进程生命周期内初始化一次,服务停止、所有请求结束后再清理:

```cpp
#include <curl/curl.h>

#include <atomic>
#include <mutex>
#include <stdexcept>

class CurlRuntime {
public:
    static void Init() {
        std::call_once(flag_, [] {
            CURLcode code = curl_global_init(CURL_GLOBAL_DEFAULT);
            if (code != CURLE_OK) {
                throw std::runtime_error(curl_easy_strerror(code));
            }
            initialized_ = true;
        });
    }

    static void Shutdown() {
        if (initialized_.exchange(false)) {
            curl_global_cleanup();
        }
    }

private:
    inline static std::once_flag flag_;
    inline static std::atomic<bool> initialized_{false};
};

更保守的策略是:长期运行的网关服务在启动时初始化,进程退出前不主动 cleanup,交给操作系统回收;测试程序或动态加载模块则必须保证所有 handle 都已清理后再调用 curl_global_cleanup()

四、基础 GET:一个更完整的示例

下面示例包含响应回调、总超时、连接超时、重定向控制和状态码读取:

cpp 复制代码
#include <curl/curl.h>

#include <iostream>
#include <string>

static size_t OnBodyData(char* data, size_t size, size_t nmemb, void* userp) {
    auto* body = static_cast<std::string*>(userp);
    const size_t bytes = size * nmemb;

    if (bytes > body->max_size() - body->size()) {
        return 0;  // 返回 0 会让 libcurl 以 CURLE_WRITE_ERROR 中止。
    }

    body->append(data, bytes);
    return bytes;
}

int main() {
    CurlRuntime::Init();

    CURL* curl = curl_easy_init();
    if (!curl) {
        std::cerr << "curl_easy_init failed\n";
        return 1;
    }

    std::string body;
    long status = 0;
    CURLcode result = CURLE_FAILED_INIT;

    curl_easy_setopt(curl, CURLOPT_URL, "https://api.local/devices");
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, OnBodyData);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);

    // 线程化 Unix 程序建议关闭 libcurl 内部使用信号处理超时。
    curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);

    // 3 秒内必须完成 TCP / TLS 建链,10 秒内完成整个请求。
    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 10000L);

    // 允许重定向,但必须限制跳数和目标协议。
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
    curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
    curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");

    result = curl_easy_perform(curl);

    // CURLE_OK 只说明协议交互完成,不代表业务成功。
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);

    if (result != CURLE_OK) {
        std::cerr << "transport error: "
                  << curl_easy_strerror(result) << "\n";
    } else if (status < 200 || status >= 300) {
        std::cerr << "http error: " << status << "\n";
    } else {
        std::cout << "status: " << status << "\n";
        std::cout << "body: " << body << "\n";
    }

    curl_easy_cleanup(curl);
    CurlRuntime::Shutdown();
}

三个容易误判的点:

  1. CURLE_OK 不等于 HTTP 2xx ,必须读取并判断 CURLINFO_RESPONSE_CODE
  2. 总超时包含 DNS、连接、TLS 和传输,大文件下载不能只设一个固定总超时;
  3. CURLOPT_PROTOCOLS_STR / CURLOPT_REDIR_PROTOCOLS_STR 需要较新的 libcurl,老版本要使用对应的位掩码接口并做版本适配。

五、POST JSON:缓冲区和 Header 生命周期

CURLOPT_POSTFIELDS 默认不会复制请求体,指针指向的内存必须在整个 curl_easy_perform() 期间保持有效。JSON 中可能包含 \0 时,还要显式设置长度。

cpp 复制代码
#include <curl/curl.h>

#include <memory>
#include <stdexcept>
#include <string>

struct Response {
    long status = 0;
    std::string body;
};

static size_t OnBodyData(char* data, size_t size, size_t nmemb, void* userp) {
    auto* response = static_cast<Response*>(userp);
    const size_t bytes = size * nmemb;
    response->body.append(data, bytes);
    return bytes;
}

class HeaderList {
public:
    void Append(const std::string& value) {
        curl_slist* updated = curl_slist_append(list_.get(), value.c_str());
        if (!updated) {
            throw std::runtime_error("curl_slist_append failed");
        }
        list_.release();
        list_.reset(updated);
    }

    curl_slist* get() const { return list_.get(); }

private:
    struct Deleter {
        void operator()(curl_slist* list) const {
            curl_slist_free_all(list);
        }
    };

    std::unique_ptr<curl_slist, Deleter> list_;
};

Response PostJson(CURL* curl, const std::string& url,
                  const std::string& json, const std::string& token) {
    HeaderList headers;
    headers.Append("Content-Type: application/json");
    headers.Append("Authorization: Bearer " + token);

    Response response;

    curl_easy_reset(curl);
    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_POST, 1L);
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers.get());
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json.data());
    curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE,
                     static_cast<curl_off_t>(json.size()));
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, OnBodyData);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 10000L);
    curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);

    CURLcode result = curl_easy_perform(curl);
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response.status);

    if (result != CURLE_OK || response.status < 200 || response.status >= 300) {
        throw std::runtime_error(
            "post failed: curl=" + std::string(curl_easy_strerror(result)) +
            ", http=" + std::to_string(response.status));
    }

    return response;
}

Header list 也必须存活到请求结束。上面的 HeaderList 在函数返回前不会被释放,而 curl_easy_perform() 是同步调用,所以生命周期是安全的;如果改成异步或 Multi API,请求上下文必须同时持有 body、header、response 和 callback 状态。

六、HTTPS:先保证验证,再谈兼容

工业系统常见两类问题:企业使用私有 CA,或者现场网关证书 bundle 过旧。正确做法是把企业根证书加入信任链,而不是关闭证书校验。

cpp 复制代码
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);

// 使用系统默认 CA 时通常不需要显式设置;
// 使用企业 CA 时,建议打包到应用目录或系统证书目录。
curl_easy_setopt(curl, CURLOPT_CAINFO, "/etc/ssl/certs/ca-bundle.crt");

CURLOPT_SSL_VERIFYHOST 的有效值是 022 表示校验证书域名,0 表示不校验。不要使用历史上的 1

双向 TLS

如果云端或内网系统要求设备证书:

cpp 复制代码
curl_easy_setopt(curl, CURLOPT_SSLCERT, "/etc/zenova/certs/client.pem");
curl_easy_setopt(curl, CURLOPT_SSLKEY, "/etc/zenova/certs/client-key.pem");

// 私钥有密码时设置;密码来源应走密钥管理,而不是写死在代码里。
curl_easy_setopt(curl, CURLOPT_KEYPASSWD, key_password.c_str());
``+

### 证书更换的三条原则

1. 私钥文件权限最小化,进程只读;
2. 证书路径支持配置,而不是编译进代码;
3. 证书剩余有效期、指纹和加载结果要可观测。

如果现场临时关闭 `CURLOPT_SSL_VERIFYPEER` 才能调通,这只能作为定位手段,不能作为交付方案。

## 七、超时、取消与资源上限

### 1. 分层超时

| 场景 | 建议策略 |
| --- | --- |
| 局域网 REST API | 连接超时 1-3 秒,总超时 3-5 秒 |
| 云端 API | 连接超时 3-5 秒,总超时 10-30 秒 |
| OTA 固件下载 | 低速检测 + 断点续传,不用固定总超时 |
| 文件上传 | 分块或流式,配合重试和幂等键 |
| 控制类命令 | 严格总超时,失败后明确进入失败状态 |

对大文件,固定 `CURLOPT_TIMEOUT` 可能导致下载到 80% 仍然被中止。更合适的是低速检测:

```cpp
// 30 秒内平均速度低于 1 byte/s 即中止。
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);

2. 进度回调可以用来取消请求

CURLOPT_XFERINFOFUNCTION 返回非 0 时,libcurl 会中止传输:

cpp 复制代码
struct TransferContext {
    std::atomic<bool>* cancel;
};

static int OnTransferProgress(void* userp, curl_off_t, curl_off_t,
                              curl_off_t, curl_off_t) {
    auto* ctx = static_cast<TransferContext*>(userp);
    return ctx->cancel->load() ? 1 : 0;
}

std::atomic<bool> cancel{false};
TransferContext context{&cancel};

curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, OnTransferProgress);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &context);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);

这让网关收到 OTA 回滚、服务退出或任务取消信号时,可以中断长传输,而不是等待超时。

3. 限制响应体大小

工业网关内存有限,接口也不能无条件信任服务端:

cpp 复制代码
static size_t OnBoundedBody(char* data, size_t size, size_t nmemb,
                            void* userp) {
    auto* body = static_cast<std::string*>(userp);
    const size_t bytes = size * nmemb;
    constexpr size_t max_body_size = 4 * 1024 * 1024;

    if (bytes > body->max_size() - body->size() ||
        body->size() + bytes > max_body_size) {
        return 0;
    }

    body->append(data, bytes);
    return bytes;
}

CURLOPT_MAXFILESIZE_LARGE 依赖响应中的 Content-Length,对 chunked 响应不能替代回调里的硬限制。

八、连接复用:同一个 easy handle 顺序请求

HTTP 连接复用可以减少 TCP 握手、TLS 握手和 DNS 查询开销。对同一个 easy handle 顺序发起多次请求,libcurl 会尽量复用连接缓存中的连接。

cpp 复制代码
CURL* curl = curl_easy_init();

curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, 1L);
curl_easy_setopt(curl, CURLOPT_TCP_KEEPIDLE, 60L);
curl_easy_setopt(curl, CURLOPT_TCP_KEEPINTVL, 15L);

for (const auto& url : urls) {
    // reset 会清空请求选项,但保留连接缓存等可复用状态。
    curl_easy_reset(curl);

    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, OnBodyData);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
    curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 10000L);

    CURLcode result = curl_easy_perform(curl);
}

curl_easy_cleanup(curl);

几个细节:

  • CURLOPT_TCP_KEEPALIVE 是 TCP 层探测,不等于业务心跳;
  • 换 URL、端口、协议或证书条件后,能否复用由 libcurl 判断;
  • 多线程中不要共享同一个 easy handle;
  • 需要跨句柄共享连接或 DNS 缓存时,研究 curl_share 并配置锁;
  • CURLOPT_FRESH_CONNECTFORBID_REUSE 只适合诊断或特定强一致场景。

DNS 缓存默认按 easy handle 维护。弱网或内网环境可以显式设置:

cpp 复制代码
curl_easy_setopt(curl, CURLOPT_DNS_CACHE_TIMEOUT, 300L);

如果某个现场必须临时绑定 IP,可使用 CURLOPT_RESOLVE,但仍要保留证书域名校验,不能因为绑定 IP 就绕过主机名校验。

九、RAII 封装:让 cleanup 不再依赖人记住

裸指针管理 CURL*curl_slist* 很容易出现异常路径泄漏。下面是一个可复用的最小封装:

cpp 复制代码
#include <curl/curl.h>

#include <array>
#include <memory>
#include <stdexcept>
#include <string>

class CurlEasy {
public:
    CurlEasy(const CurlEasy&) = delete;
    CurlEasy& operator=(const CurlEasy&) = delete;
    CurlEasy(CurlEasy&&) = delete;
    CurlEasy& operator=(CurlEasy&&) = delete;

    CurlEasy() {
        handle_.reset(curl_easy_init());
        if (!handle_) {
            throw std::runtime_error("curl_easy_init failed");
        }
        std::fill(error_buffer_.begin(), error_buffer_.end(), '\0');
        Set(CURLOPT_ERRORBUFFER, error_buffer_.data());
    }

    void Reset() { curl_easy_reset(handle_.get()); }

    CURL* get() const { return handle_.get(); }

    template <typename T>
    void Set(CURLoption option, T value) {
        CURLcode code = curl_easy_setopt(handle_.get(), option, value);
        if (code != CURLE_OK) {
            throw std::runtime_error(curl_easy_strerror(code));
        }
    }

    std::string ErrorMessage(CURLcode code) const {
        std::string message = curl_easy_strerror(code);
        if (error_buffer_[0] != '\0') {
            message += ": ";
            message += error_buffer_.data();
        }
        return message;
    }

private:
    struct Deleter {
        void operator()(CURL* handle) const {
            curl_easy_cleanup(handle);
        }
    };

    std::unique_ptr<CURL, Deleter> handle_;
    std::array<char, CURL_ERROR_SIZE> error_buffer_{};
};

一个同步客户端可以基于它实现:

cpp 复制代码
struct HttpResult {
    CURLcode curl_code = CURLE_FAILED_INIT;
    long status = 0;
    std::string body;
    std::string effective_url;
    double total_time_ms = 0.0;
    std::string error;
};

HttpResult Get(CurlEasy& easy, const std::string& url) {
    HttpResult result;

    easy.Reset();
    easy.Set(CURLOPT_URL, url.c_str());
    easy.Set(CURLOPT_HTTPGET, 1L);
    easy.Set(CURLOPT_WRITEFUNCTION, OnBoundedBody);
    easy.Set(CURLOPT_WRITEDATA, &result.body);
    easy.Set(CURLOPT_NOSIGNAL, 1L);
    easy.Set(CURLOPT_CONNECTTIMEOUT_MS, 3000L);
    easy.Set(CURLOPT_TIMEOUT_MS, 10000L);

    result.curl_code = curl_easy_perform(easy.get());
    curl_easy_getinfo(easy.get(), CURLINFO_RESPONSE_CODE, &result.status);
    double total_time_sec = 0.0;
    curl_easy_getinfo(easy.get(), CURLINFO_TOTAL_TIME, &total_time_sec);
    result.total_time_ms = total_time_sec * 1000.0;

    char* effective_url = nullptr;
    curl_easy_getinfo(easy.get(), CURLINFO_EFFECTIVE_URL, &effective_url);
    if (effective_url) {
        result.effective_url = effective_url;
    }

    if (result.curl_code != CURLE_OK) {
        result.error = easy.ErrorMessage(result.curl_code);
    }
    return result;
}

这个封装仍然不意味着实例可以随意跨线程并发调用。要么每个线程持有自己的客户端,要么外部用队列或 mutex 串行化请求。

十、Multi API:并发请求要收集结果

curl_multi_perform() 只驱动传输,业务结果要通过 curl_multi_info_read() 读取。只等 still_running 变 0,而不处理消息,会丢掉每个请求的完成状态。

cpp 复制代码
#include <curl/curl.h>

#include <memory>
#include <string>
#include <utility>
#include <vector>

constexpr size_t kMaxResponseBody = 4 * 1024 * 1024;

struct RequestContext {
    std::string url;
    std::string body;
    CURL* easy = nullptr;
    CURLcode result = CURLE_FAILED_INIT;
    long status = 0;
};

static size_t OnBodyData(char* data, size_t size, size_t nmemb, void* userp) {
    auto* context = static_cast<RequestContext*>(userp);
    const size_t bytes = size * nmemb;
    if (bytes > context->body.max_size() - context->body.size() ||
        context->body.size() + bytes > kMaxResponseBody) {
        return 0;
    }
    context->body.append(data, bytes);
    return bytes;
}

struct MultiDeleter {
    void operator()(CURLM* handle) const {
        curl_multi_cleanup(handle);
    }
};

struct EasyDeleter {
    void operator()(CURL* handle) const {
        curl_easy_cleanup(handle);
    }
};

struct EasyHandle {
    std::unique_ptr<CURL, EasyDeleter> handle;
    bool attached = false;
};

struct MultiSession {
    std::unique_ptr<CURLM, MultiDeleter> multi;
    std::vector<EasyHandle> easy;

    MultiSession() {
        multi.reset(curl_multi_init());
        if (!multi) {
            throw std::runtime_error("curl_multi_init failed");
        }
    }

    ~MultiSession() {
        for (auto& item : easy) {
            if (item.attached) {
                curl_multi_remove_handle(multi.get(), item.handle.get());
                item.attached = false;
            }
        }
    }
};

std::vector<RequestContext> FetchAll(const std::vector<std::string>& urls) {
    std::vector<RequestContext> contexts(urls.size());
    MultiSession session;

    for (size_t i = 0; i < urls.size(); ++i) {
        contexts[i].url = urls[i];
        std::unique_ptr<CURL, EasyDeleter> easy(curl_easy_init());
        if (!easy) {
            throw std::runtime_error("curl_easy_init failed");
        }

        session.easy.push_back({std::move(easy), false});
        CURL* handle = session.easy.back().handle.get();
        contexts[i].easy = handle;

        curl_easy_setopt(handle, CURLOPT_URL, urls[i].c_str());
        curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, OnBodyData);
        curl_easy_setopt(handle, CURLOPT_WRITEDATA, &contexts[i]);
        curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L);
        curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
        curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS, 10000L);

        CURLMcode added = curl_multi_add_handle(session.multi.get(), handle);
        if (added != CURLM_OK) {
            throw std::runtime_error(curl_multi_strerror(added));
        }
        session.easy.back().attached = true;
    }

    int running = 0;
    do {
        CURLMcode performed = curl_multi_perform(session.multi.get(), &running);
        if (performed != CURLM_OK) {
            throw std::runtime_error(curl_multi_strerror(performed));
        }

        int messages_left = 0;
        while (CURLMsg* message =
                   curl_multi_info_read(session.multi.get(), &messages_left)) {
            if (message->msg != CURLMSG_DONE) {
                continue;
            }

            for (auto& context : contexts) {
                if (context.easy != message->easy_handle) {
                    continue;
                }

                context.result = message->data.result;
                curl_easy_getinfo(context.easy, CURLINFO_RESPONSE_CODE,
                                  &context.status);
                for (auto& item : session.easy) {
                    if (item.handle.get() == context.easy) {
                        curl_multi_remove_handle(session.multi.get(),
                                                 context.easy);
                        item.attached = false;
                        break;
                    }
                }
                break;
            }
        }

        if (running > 0) {
            int numfds = 0;
            CURLMcode waited = curl_multi_wait(
                session.multi.get(), nullptr, 0, 1000, &numfds);
            if (waited != CURLM_OK) {
                throw std::runtime_error(curl_multi_strerror(waited));
            }
        }
    } while (running > 0);

    return contexts;
}

Multi 并发不是无限并发。边缘设备上应设置最大同时请求数,把超出部分放入队列;上传、下载和控制类请求也应分级,避免 OTA 下载占满带宽。

十一、错误处理、重试与观测

1. 区分传输错误和业务错误

类型 例子 处理方式
传输错误 DNS、TCP、TLS、超时、写回调中止 记录 libcurl 错误码和系统 errno
HTTP 状态错误 401、403、409、429、500、503 按 API 语义处理
业务语义错误 平台返回 200 但 body 表示失败 解析响应体和业务错误码
数据错误 JSON 不合法、字段越界 进入本地异常处理,不应盲目重试

CURLE_OK 后仍要处理状态码和响应体,这是 libcurl 客户端最常见的误用之一。

2. 重试要受幂等性约束

libcurl 库层没有通用的业务重试策略,重试应由封装层实现:

  • GET、HEAD、幂等查询可以直接重试;
  • POST 上传要使用幂等键、去重窗口或服务端事务;
  • 429 和 503 可参考 Retry-After
  • 401、403、400 通常不应自动重试;
  • 指数退避必须加抖动,避免多站点同时恢复造成请求风暴。

3. 最小观测指标

text 复制代码
http_requests_total{host,method,result,status}
http_request_duration_ms{host,method}
http_transport_errors_total{host,error_code}
http_retry_total{host,method,reason}
http_active_requests{pool}
http_response_body_bytes{host}

排查现场问题时,建议记录:

  • libcurl 错误码和错误详情;
  • HTTP 状态码;
  • 有效 URL,尤其是重定向后的 URL;
  • 本地 errno;
  • 主 IP 和端口;
  • 是否新建连接;
  • DNS、连接、TLS、首字节和总耗时;
  • 请求 ID 和 trace ID。

日志里不要输出完整 Authorization、Cookie、设备私钥和敏感业务报文;调试回调中要做脱敏。

十二、工业边缘场景的封装建议

本地 API

特点是延迟低、失败要快速定位:

  • 短连接超时和总超时;
  • 不建议长时间盲目重试;
  • 服务不可用时降级到本地缓存或默认策略;
  • 与进程管理、看门狗和健康检查联动。

云端上送

特点是网络不稳定、数据不能随便丢:

  • 本地待发队列;
  • 幂等键;
  • 批量压缩;
  • 指数退避;
  • 发送成功后再确认删除;
  • 与死信队列或异常记录衔接。

OTA 下载

特点是大文件、可恢复、可取消:

  • 使用断点续传;
  • 校验文件哈希和签名;
  • 支持取消与回滚;
  • 使用低速检测而不是固定总超时;
  • 限制下载速率,避免影响采集和控制。

双向 TLS

特点是证书生命周期长:

  • 证书路径可配置;
  • 私钥权限受控;
  • 证书到期前告警;
  • 更换证书后验证握手与域名。

十三、常见坑与应对

坑 1:把全局初始化放进类构造函数

现象:多个对象反复 init / cleanup,或多线程运行期间 cleanup,导致不稳定。

应对:全局初始化只做一次;所有请求结束后再清理。

坑 2:多个线程共用同一个 CURL*

现象:偶发崩溃、选项互相覆盖、响应串扰。

应对:每个线程独立 handle;需要共享连接时研究 curl_share 并实现锁。

坑 3:认为 CURLE_OK 就是业务成功

现象:HTTP 500 也被当成成功。

应对:每次读取 CURLINFO_RESPONSE_CODE,并解析业务返回。

坑 4:请求体或 Header 生命周期错误

现象:偶发请求体截断、Header 缺失或内存异常。

应对:CURLOPT_POSTFIELDS 指向的内存在请求结束前保持有效;Header list 使用 RAII;异步场景由请求上下文统一持有。

坑 5:没有超时和响应体上限

现象:服务异常时任务卡死或内存被响应体耗尽。

应对:连接超时、总超时或低速检测;Write callback 设置最大字节数;长下载支持取消。

坑 6:关闭 TLS 校验

现象:被中间人攻击或连到错误服务。

应对:保持证书和域名校验;企业私有 CA 应导入信任链;证书问题不能通过关闭校验"修复"。

坑 7:Multi 只驱动不读消息

现象:请求完成了,但拿不到错误码和状态。

应对:每个完成的 handle 都通过 curl_multi_info_read() 读取结果。

坑 8:重试没有幂等边界

现象:网关断电恢复后重复上报或重复创建资源。

应对:服务端幂等键、本地去重状态、状态机和重试上限。

十四、运行时层面的角色

对工业边缘运行时来说,libcurl 更适合被封装成统一的 HTTP 基础模块,而不是散落在各个业务里:

  • 统一 TLS、代理、DNS 和超时配置;
  • 统一连接池、并发上限和队列;
  • 统一错误码、指标、日志和 trace 上下文;
  • 为不同业务提供普通 API、文件上传、OTA 下载和 mTLS profile;
  • 把重试、幂等、降级和本地缓存策略产品化;
  • 保证长周期运行下的资源回收和配置热更新。

在类似 Zenova EdgeOS 的边缘运行时中,这部分能力的意义是让协议接入、云端上送和 OTA 下载复用同一套经过验证的 HTTP 基础设施,减少每个项目重复踩坑。

TL;DR

  1. libcurl 成熟稳定,但工程重点在初始化、生命周期、超时、TLS、观测和重试策略;
  2. curl_global_init / curl_global_cleanup 不是线程安全的,应在进程层面只做一次;
  3. 同一个 easy handle 不能被多个线程同时使用;
  4. CURLE_OK 只代表传输完成,还必须检查 HTTP 状态码和业务响应;
  5. CURLOPT_POSTFIELDS 和 Header list 的生命周期必须覆盖整个请求;
  6. TLS 校验默认应保持开启,企业私有 CA 应加入信任链;
  7. 连接复用使用同一 easy handle 顺序请求,并用 curl_easy_reset 清理请求级选项;
  8. Multi API 必须读取 curl_multi_info_read 的完成消息;
  9. 响应体大小、下载速度、最大重定向和并发数都要有边界;
  10. 重试必须结合幂等性、退避、抖动和业务语义。
相关推荐
Jackson_GJH2 小时前
Qt 右键自定义菜单的实现
开发语言·c++·qt
张小姐的猫2 小时前
【AI大模型接入SDK】 —— Gemini接入封装
android·数据结构·数据库·c++·人工智能·python
时空节拍AI数字人2 小时前
AI 数字人为什么需要“3D”?2D 不够用吗
人工智能·网络协议·tcp/ip·3d·信息可视化
CoderYanger2 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
一木 之林2 小时前
七、一-AI 工程实践、插件化调试与软件交付
java·linux·c++
小白羊丨3 小时前
HTTP/2 如何缓解 HTTP/1.1 阻塞;长连接对 Nginx 有什么影响?
网络协议·nginx·http
小木_.3 小时前
Python 离线识别滑块缺口距离,项目推荐
开发语言·python·滑块识别·人机验证·滑块缺口·缺口识别
liliangcsdn3 小时前
因子权重矩阵处理-因子权重收缩Shrinkage算法的探索
开发语言·python·机器学习
Murphy_lx4 小时前
1124. 表现良好的最长时间段
c++·算法