十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++高性能网络爬虫实战:从libcurl到多线程并发抓取

C++高性能网络爬虫实战:从libcurl到多线程并发抓取 简介一套基于C与Winsock实现的网络爬虫工程面向网络编程初学者演示如何通过Socket发送HTTP请求、解析HTML并提取图片URL、最终下载到本地。工程采用Visual Studio构建包含可运行的exe程序与完整源码覆盖Winsock初始化、TCP连接、HTTP GET请求、正则表达式匹配、BFS爬取顺序及二进制文件读写等关键环节。压缩包共26个文件以cpp源码、exe可执行程序、sln/vcxproj工程配置为主附带pdb调试信息、jpg图片样本及构建日志整体大小7.95MB目录结构清晰适合直接打开调试学习。目前已有1358人学习下载可配合源码阅读理解爬虫工作流程也能通过断点跟踪请求与解析细节是巩固网络编程与数据处理技能的实用练习。C 网络爬虫代码我自己最早写爬虫是用Python脚本一梭子下去基本啥都能爬到。直到有一天线上有个采集任务需要每秒处理上千个URL而且目标站点的反爬策略特别吃CPU资源我才意识到Python的解释器开销和GIL在大规模抓取上有多吃亏。后来咬着牙用C重新写了一版爬虫核心模块同样的机器配置吞吐量提升了好几倍。这篇文章我就把C网络爬虫从选型、代码实现到排坑的完整过程整理出来给那些想用C做高性能爬虫或者正在学习C网络编程的朋友做个参考。主要内容包括C写爬虫的技术栈怎么选、HTTP请求和HTML解析的核心细节、一套能直接编译运行的代码示例以及我实际踩过的坑和排查思路。不管你是刚学完C语法想找个实战项目练手还是工作中确实遇到采集性能瓶颈这篇都能给你一个能落地的起步方案。1. 整体设计与方案选型1.1 为什么用C写爬虫选语言得看你到底要做什么样的爬虫。平时抓个几百上千个页面Python确实舒服库多、代码短、改起来快。但一旦进入工业级采集场景——几万个URL并发、要求毫秒级响应、需要解析大量二进制协议数据——C的优势就很明显了。C爬虫的核心优势我总结为三点可控的内存和线程模型你可以精确控制每个线程栈大小、队列深度、连接数上限不会像脚本语言那样频繁被GC打断。较低的CPU和网络开销静态编译的程序启动快内存占用稳定适合部署在容器里长期运行libcurl底层走epoll/kqueue事件循环连接复用效率很高。方便嵌入C业务系统如果你本身的服务端就是C写的直接用C实现爬虫模块省掉了一层进程间通信或HTTP转发少一次序列化和反序列化对时效性要求高的任务帮助很大。当然C爬虫的缺点是开发效率低语法复杂。所以我的建议是别用C写一次性脚本要写就写可复用的爬虫框架或核心组件。1.2 技术栈怎么选一个完整的C爬虫至少需要三部分HTTP请求库、HTML解析库、数据序列化工具。HTTP请求库我用得最多的是libcurl。虽然有cpp-httplib这种纯头文件库也很轻量但libcurl功能最全面支持HTTP/HTTPS、HTTP/2、代理、cookie、重定向、断点续传而且在高并发下底层是异步I/O性能有保障。唯一麻烦的是libcurl是C API不过封装一层RAII类之后就很好用了后面我会给示例。HTML解析这块看你的目标网页复杂程度简单的数据提取比如固定结构的JSON接口、纯文本页面、正则能搞定的标签直接用C11的std::regex就够了。正则看似简单但处理嵌套标签时容易写错只建议用于提取URL、ID、纯文本等明确模式。复杂DOM解析推荐Gumbo-query或pugixml。Gumbo是Google开源的HTML5解析器容错性很好可以像jQuery一样用CSS选择器找节点很适合抓取不规范的网页。pugixml则侧重XML但XPath的支持比Gumbo好如果你要爬XML/RSS源选pugixml更合适。JSON数据推荐nlohmann/json头文件库使用体验和Python的json模块非常接近。另外Windows下开发最好配一下VSCode的C/C环境把include路径和调试器配置好。我见过不少初学者卡在“代码编译通过但断点不生效”上其实就是没在.vscode/tasks.json和launch.json里正确设置编译任务和调试程序路径。这些配置不复杂但直接影响开发效率。2. 核心细节与关键原理2.1 HTTP请求的姿势要正确很多人第一次用libcurl写的爬虫特别慢还容易被封。问题往往出在以下几点一是请求头没模拟真实浏览器。服务器检测爬虫最基础的手段就是看User-Agent和Accept-Language。不设置UA的话curl默认的标识几乎等于在脸上写着“我是爬虫”。实际使用时建议伪装成Chrome或Firefox的UA同时带上Accept、Accept-Language、Referer等常规字段。二是超时设置缺失。爬虫面对的是公网环境网络抖动、对方服务器慢、DNS解析超时都常有。如果不对CURLOPT_CONNECTTIMEOUT和CURLOPT_TIMEOUT做限制一个卡死的连接就能拖垮整个线程。我的习惯是连接超时5秒总超时10秒重试次数2次重试间隔递增1秒、3秒、7秒这样。三是没有善用连接复用。多个请求访问同一个域名时libcurl的多接口能复用Keep-Alive连接节省了反复TCP握手和TLS握手的开销。这点对高并发抓取特别关键性能差异能有数倍。2.2 字符编码问题不能忽略中文网页的编码形形色色常见的就有UTF-8、GBK、GB2312、BIG5。如果你不处理编码直接拿字节流去解析得到的全是乱码。判断编码的办法通常是看HTML里meta标签的charset字段或者直接用响应头的Content-Type。但有些网页的charset声明和内容实际编码不一致这时候就得靠经验了如果解析出来有大量替换字符或者乱码再尝试其他编码逐个解码。编码转换推荐用ICU库跨平台稳定支持所有常见编码。如果你不想引入这么重的依赖Windows可以用系统APIMultiByteToWideChar加WideCharToMultiByte来做GBK和UTF-8互转Linux下则用iconv库。我后面给的示例里会展示一个简化版转换函数的思路。2.3 解析策略要分清主次很多初学者拿到网页第一步就是正则大法通吃这是我能理解但不太推荐的做法。正则确实快但遇到嵌套的HTML结构很容易出错比如抓div里嵌套div的内容贪婪匹配和非贪婪匹配稍微一疏忽抓出来的就是残缺数据。我的经验是分两步走先用字符串搜索或者正则定位到核心内容区块再用Gumbo或pugixml解析这个局部片段。这样的话既不牺牲性能又能利用HTML解析器的容错性两头好处都拿到了。3. 实操过程与代码实现3.1 环境准备我在Ubuntu 22.04下用g 11验证过下面的代码Windows下用MSVC也能编译注意libcurl相关配置稍做调整即可。安装依赖sudo apt update sudo apt install libcurl4-openssl-dev libgumbo-dev nlohmann-json3-dev如果不想装libgumbo-dev可以退回到正则方案代码会更简单后面我会说明。3.2 基础版爬虫抓取一个页面的标题和所有链接先做一个单线程、功能完整的版本目的是把HTTP请求、响应处理、解析逻辑串起来。#include curl/curl.h #include iostream #include string #include regex #include vector // libcurl 写回调函数把响应体追加到字符串里 static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) { auto* output static_caststd::string*(userp); size_t totalSize size * nmemb; output-append(static_castchar*(contents), totalSize); return totalSize; } // 发送 GET 请求返回响应体字符串 std::string httpGet(const std::string url, int timeoutSec 10) { std::string response; CURL* curl curl_easy_init(); if (!curl) return response; curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); // 自动跟随重定向 curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // 最多重定向5次 curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); // 连接超时5秒 curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeoutSec); // 总超时 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, response); curl_easy_setopt(curl, CURLOPT_USERAGENT, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36); curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, gzip, deflate); // 开启压缩 curl_easy_setopt(curl, CURLOPT_HTTPHEADER, curl_slist_append(nullptr, Accept-Language: zh-CN,zh;q0.9)); CURLcode res curl_easy_perform(curl); if (res ! CURLE_OK) { std::cerr curl error: curl_easy_strerror(res) std::endl; } curl_easy_cleanup(curl); return response; } int main() { curl_global_init(CURL_GLOBAL_DEFAULT); std::string html httpGet(https://example.com); if (html.empty()) { std::cerr Failed to fetch page. std::endl; return 1; } // 提取 title 标签内容 std::regex titleRe(title[^]*(.*?)/title, std::regex::icase); std::smatch titleMatch; if (std::regex_search(html, titleMatch, titleRe)) { std::cout Title: titleMatch[1] std::endl; } // 提取所有 href 链接 std::regex linkRe(href[\]([^\])[\], std::regex::icase); std::sregex_iterator iter(html.begin(), html.end(), linkRe); std::sregex_iterator end; std::vectorstd::string links; for (; iter ! end; iter) { links.push_back((*iter)[1].str()); } std::cout Found links.size() links: std::endl; for (const auto link : links) { std::cout link std::endl; } curl_global_cleanup(); return 0; }编译命令g -stdc17 -O2 crawler.cpp -o crawler -lcurl这段代码跑通后你就拥有了一个最小可用的C爬虫骨架。需要提醒的是正则提取title的结果里面可能还有空白字符或者换行数据入库前记得trim一下链接可能有相对路径需要拼接url的协议和域名才能变成完整URL这也是初学者容易漏掉的细节。3.3 升级版引入Gumbo解析CSS选择器如果你要抓的网页结构复杂正则写起来会让你怀疑人生。用Gumbo-query可以像写前端一样写选择器#include gumbo-query/Document.h #include gumbo-query/Node.h void parseHtml(const std::string html) { CDocument doc; doc.parse(html); // 查找所有 a 标签取 href 属性 CSelection selection doc.find(a); for (int i 0; i selection.nodeNum(); i) { CNode node selection.nodeAt(i); std::string href node.attribute(href); std::string text node.text(); std::cout href href text text std::endl; } // 查找所有 classarticle-title 的节点 CSelection titles doc.find(.article-title); for (int i 0; i titles.nodeNum(); i) { std::cout titles.nodeAt(i).text() std::endl; } }Gumbo-query的底层依旧是Gumbo解析器HTML语法错误它有很强的容错爬国内那些标签嵌套不规范的站点尤其省心。但要注意CSelection遍历同一个doc时如果你在循环里对doc做了重新解析之前的迭代器会失效这是C容器常见的迭代器失效陷阱。3.4 高并发扩展多线程抓取队列单线程爬虫跑起来你会发现大量时间浪费在等网络响应上。这时候就该用多线程了。思路是一个生产者线程往任务队列里塞URL多个消费者线程从队列取URL并发抓取。用C11标准的std::thread加互斥锁就能实现不需要引入额外的线程池库。核心伪代码如下#include queue #include mutex #include condition_variable std::queuestd::string urlQueue; std::mutex queueMutex; std::condition_variable cv; bool done false; void workerThread() { while (true) { std::string url; { std::unique_lockstd::mutex lock(queueMutex); cv.wait(lock, [] { return !urlQueue.empty() || done; }); if (urlQueue.empty() done) break; url urlQueue.front(); urlQueue.pop(); } // 实际抓取 std::string html httpGet(url); // 解析、存储... } }线程数量建议设成CPU核心数的2到4倍太多反而会因为线程切换和连接数限制导致性能下降。我的线上配置是CPU 8核线程池16个线程每个域名最多并发4个连接这样对目标站点的压力也比较友好。特别提醒libcurl的全局初始化curl_global_init在每个进程生命周期里只调用一次不要在多个线程里重复调用。另外curl_easy_init创建的句柄也不是线程安全的但不同线程各自持有独立的easy handle这一点libcurl是安全的你可以放心用。3.5 编译器与工程配置建议有不少人问过我VSCode里怎么跑通带libcurl的C程序。你至少需要三样东西c_cpp_properties.json里配置includePath把curl和nlohmann的头文件目录加进去否则代码会标红提示找不到头文件。tasks.json里配置编译命令g编译时必须加-lcurl不然会报链接错误比如undefined reference to curl_easy_init。launch.json里配置调试程序路径否则F5启动调试时会提示找不到可执行文件。如果你用的是CMakeCMakeLists.txt里两行就能搞定find_package(CURL REQUIRED) target_link_libraries(your_target PRIVATE CURL::libcurl)4. 常见问题与排查技巧实录4.1 常见问题速查表问题现象可能原因解决方法返回空字符串超时、被反爬拦截检查URL是否合法开启CURLOPT_VERBOSE看详细错误码添加Cookie和Referer头中文内容乱码编码未转换先用UTF-8解码乱码则尝试GBK根据metacharset判断后再做转码HTTPS请求报证书错误本地CA证书过期或缺失定位到系统CA证书路径并设置CURLOPT_CAINFO或用项目自带的cacert.pem请求返回403被服务器反爬识别换真实浏览器UA添加Referer降低请求频率检查是否触发了验证码偶尔超时但不稳定连接池失效或网络抖动开启CURLOPT_TCP_KEEPALIVE增加重试机制用指数退避控制QPS解析不到预期的链接HTML结构与预期不符先用浏览器开发者工具检查DOM用Gumbo选择器代替正则注意大小写4.2 开发阶段最实用的调试手段我在开发阶段从不直接对着日志猜问题而是用curl命令行先摸清目标站点的脾气curl -v -A Mozilla/5.0... -H Accept-Language: zh-CN https://example.com/page-v参数会打印TLS握手、请求头、响应头等全过程特别适合定位是连接问题、头信息问题还是内容问题。确认了curl命令行能拿到预期内容之后再回头调试C代码这样问题范围会小很多。另外建议在代码里给libcurl开启CURLOPT_VERBOSEcurl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);它会输出类似Connected to example.com (93.184.216.34) port 443的信息能帮你快速确认DNS解析和TCP连接是否正常。关闭CURLOPT_VERBOSE前记得加注释说明免得线上日志刷屏。4.3 我踩过的几个坑第一个坑是HTTPS证书。刚开始部署到服务器的容器环境突然发现所有HTTPS请求都失败了。一查发现容器里没有安装系统的CA证书。解决方案是下载cacert.pem放到项目目录然后设置CURLOPT_CAINFO指向这个文件。这个坑很典型本地开发环境有证书服务器上没有所以测试一定要在目标部署环境里跑。第二个坑是Content-Encoding。有些服务器返回的是gzip压缩内容如果不设置CURLOPT_ACCEPT_ENCODING拿到的是二进制乱码。我当时还以为是网站改了协议排查了半天。设置了这个选项后libcurl会自动解压响应体省了很多事。第三个坑是入库前的数据清洗。爬下来的标题往往带有多余的换行、Tab和HTML实体符号。如果直接存数据库后面检索和展示会很痛苦。我的习惯是做一个cleanText函数统一去掉多余空白、把amp;等实体转成正常字符、去掉控制字符。这个函数虽然不起眼但能省下后期数据治理的大量精力。5. 合规与工程化经验补充写爬虫最容易忽略的是“君子协议”也就是目标网站的robots.txt。虽然技术上你可以无视它但无论是从职业道德还是法律风险考虑我都建议至少检查一下目标站点有没有明确禁止抓取。爬虫本质上是高频访问对方服务器如果给别人的站点造成负载压力那就和攻击没什么区别了。合规的做法是遵守robots.txt规则、控制合理抓取速率、优先抓取公开API接口、数据使用时注意个人隐私信息保护。作为一个爬虫开发者技术是手段但能不能安全、长久地做这件事取决于你有多尊重规则。工程化方面我额外建议几件事日志要带上时间戳、URL、响应码、耗时方便做故障复盘。抓取结果要做幂等去重避免同一URL被多个线程重复抓。用简单的DB比如SQLite或MySQL存URL状态机待抓取、抓取中、成功、失败这样断点续爬会非常方便。6. 写在最后的一点经验我用了这么长时间C写爬虫最大的感触是C不值得用来重复造爬虫轮子但非常值得用来做核心抓取引擎和通用组件。日常小任务Python确实上手快但一旦你的系统对延迟、吞吐有明确要求C能给你的底气是脚本语言很难替代的。这也是我把整套代码流程记录下来的原因——初期搭建确实比脚本麻烦但跑起来之后的稳定性和性能会让你觉得之前的投入是值得的。最后再分享一个小技巧一定要把抓取、解析、存储三个环节解耦中间通过队列传递数据。这样将来无论是换解析库、加存储后端还是调整并发模型都不至于推翻整个程序重写。爬虫系统很容易写着写着就变成一团乱麻而好的架构能让你从容应对各种需求变化。本文还有配套的精品资源点击获取
返回列表