☰
C++实现多线程查找文件实例
2026/10/20 13:50:52 网站建设 项目流程

前言


在磁盘上按关键字查找文件名,是一个典型的「遍历 + 过滤」任务。单线程版本写起来只有十几行,但当目录树很大、文件数量达到几十万时,遍历和字符串匹配会占用可观的时间,于是很多人第一反应是「开多线程」。这里有一个常见误解:线程越多越快。文件查找的瓶颈通常在磁盘 I/O 和目录元数据访问上,多线程只能把多个线程的等待时间重叠起来,并不能让磁盘的吞吐凭空翻倍;如果所有线程争抢同一块机械硬盘,甚至可能因为随机寻道而变慢。


第二个常见误解是「把std::thread创建出来就完事了」。多线程查找真正难的部分不是遍历,而是任务的分发、共享结果的保护、以及所有线程都能正确退出。稍不注意就会写出数据竞争、漏掉文件、或者主线程先退出导致std::terminate的程序。


本文以 C++17 的std::filesystem为基础,给出一个「一个遍历线程 + 若干工作线程」的生产者/消费者实现,并讨论为什么这样拆分、同步原语怎么用、以及哪些地方容易出错。本机没有 C++ 工具链,示例只做标准语义推演;不同标准库实现的行为细节请以官方文档为准。


一、任务拆分:谁负责遍历,谁负责匹配


最直接的想法是让每个线程各自递归遍历目录。问题在于:线程之间不知道彼此走到了哪里,同一棵子树可能被重复遍历,也很难均分工作量。更稳妥的做法是把任务拆成两个阶段:



  • 遍历阶段:由一个线程用std::filesystem::recursive_directory_iterator走完整棵目录树,把遇到的普通文件路径放进一个共享队列。

  • 匹配阶段:多个工作线程从队列中取出路径,读取文件名(必要时读取内容),判断是否命中关键字,命中则写入结果集。


这样做的好处是目录遍历只发生一次,工作线程之间通过队列解耦,队列本身充当负载均衡器:谁先处理完谁就再取一个。代价是遍历线程可能成为瓶颈——如果目录元数据访问很慢,工作线程会大量时间处于等待状态。这时可以再引入多个遍历线程,每个线程负责一棵互不重叠的子树,但实现复杂度会明显上升。


recursive_directory_iterator默认在遇到权限不足的目录时抛出filesystem_error。生产代码里更常用的是带std::error_code的重载,并配合fs::directory_options::skip_permission_denied,让遍历在遇到不可读目录时跳过而不是中断。需要注意的是,即使加了该选项,某些错误仍可能通过error_code报告,因此每次increment之后都应检查ec。


另外要区分「文件名匹配」和「内容匹配」。前者只需对路径字符串做find,开销很小;后者要打开并读取每个文件,I/O 成本高得多,也更需要限制并发度,避免把磁盘打满。本文示例做的是文件名匹配,内容匹配只需替换matches的实现。


二、线程安全的任务队列与终止条件


共享队列需要一把互斥量保护。工作线程在队列为空时不能直接退出,因为遍历线程可能还没结束、后面还会有新任务;它应该阻塞等待,直到「队列非空」或「遍历完成」这两个条件之一成立。这正是std::condition_variable的典型用法:


// 适用于 C++17
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [this] { return finished_ || !tasks_.empty(); });

谓词在持有锁的状态下求值,因此对finished_和tasks_的读写都被同一把锁保护,不存在数据竞争。注意wait必须搭配谓词,否则虚假唤醒(spurious wakeup)会让线程在队列仍为空时继续往下执行。


终止条件由一个bool finished_表示,它在遍历线程把整棵树走完之后、持有锁的情况下置为true,然后调用notify_all()。工作线程醒来后发现队列为空且finished_为真,才真正返回。这里不能用notify_one()来通知结束:可能有多个工作线程同时在等待,只唤醒一个会让其余线程永远阻塞。


结果集同样需要保护。多个工作线程会同时push_back,因此写入时必须持有互斥量。如果结果只需要在全部线程结束后统一输出,也可以让每个线程维护自己的局部vector,最后合并,减少锁竞争;但合并阶段仍然需要同步。本文为了代码简洁,使用一把锁同时保护队列和结果,缺点是匹配期间不持锁、只有入队和写结果时持锁,实际竞争并不算激烈。


还有一点容易被忽略:std::thread对象在析构前必须join()或detach(),否则标准要求调用std::terminate。本文在run()返回前统一join,主线程不会提前离开作用域。


三、完整示例


下面的示例把遍历、队列、工作线程和结果收集放在一个类里。线程数量取自std::thread::hardware_concurrency(),该函数可能返回 0(表示无法查询),构造函数里做了兜底。


// 适用于 C++17
#include <atomic>
#include <condition_variable>
#include <filesystem>
#include <iostream>
#include <mutex>
#include <queue>
#include <string>
#include <system_error>
#include <thread>
#include <utility>
#include <vector>

namespace fs = std::filesystem;

class FileSearcher {
public:
FileSearcher(std::string root, std::string keyword, unsigned workers)
: root_(std::move(root)),
keyword_(std::move(keyword)),
worker_count_(workers == 0 ? 1u : workers) {}

std::vector<std::string> run() {
std::vector<std::thread> pool;
pool.reserve(worker_count_);
for (unsigned i = 0; i < worker_count_; ++i) {
pool.emplace_back([this] { worker_loop(); });
}

walk(root_);

{
std::lock_guard<std::mutex> lk(mtx_);
finished_ = true;
}
cv_.notify_all();

for (auto& t : pool) {
t.join();
}
return std::move(results_);
}

private:
void walk(const fs::path& root) {
std::error_code ec;
fs::recursive_directory_iterator it(
root, fs::directory_options::skip_permission_denied, ec);
fs::recursive_directory_iterator end;

if (ec) {
std::cerr << "cannot open " << root << ": " << ec.message() << '\n';
return;
}

for (; it != end; it.increment(ec)) {
if (ec) {
ec.clear(); // 跳过这一项,继续后面的遍历
continue;
}
std::error_code file_ec;
if (!it->is_regular_file(file_ec) || file_ec) {
continue;
}
{
std::lock_guard<std::mutex> lk(mtx_);
tasks_.push(it->path().string());
}
cv_.notify_one();
}
}

void worker_loop() {
for (;;) {
std::string path;
{
std::unique_lock<std::mutex> lk(mtx_);
cv_.wait(lk, [this] { return finished_ || !tasks_.empty(); });
if (tasks_.empty()) {
return; // 只有 finished_ 为真时才可能走到这里
}
path = std::move(tasks_.front());
tasks_.pop();
}

if (matches(path)) {
std::lock_guard<std::mutex> lk(mtx_);
results_.push_back(path);
}
}
}

bool matches(const std::string& path) const {
return path.find(keyword_) != std::string::npos;
}

std::string root_;
std::string keyword_;
unsigned worker_count_;

std::mutex mtx_;
std::condition_variable cv_;
std::queue<std::string> tasks_;
std::vector<std::string> results_;
bool finished_ = false;
};

int main(int argc, char** argv) {
if (argc < 3) {
std::cerr << "usage: " << argv[0] << " <directory> <keyword>\n";
return 1;
}

FileSearcher searcher(argv[1], argv[2], std::thread::hardware_concurrency());
std::vector<std::string> hits = searcher.run();

std::cout << "matched " << hits.size() << " file(s)\n";
for (const std::string& path : hits) {
std::cout << path << '\n';
}
return 0;
}

几个实现细节值得说明。worker_loop中从队列取任务和检查finished_在同一把锁下完成,避免了「取到空任务后遍历线程刚好结束」的竞态;matches是const成员函数,只读keyword_,不涉及共享可变状态;run()把results_移动返回,调用发生在所有工作线程join之后,因此没有并发读写。


GCC 8 及更早版本使用std::filesystem时需要额外链接-lstdc++fs;GCC 9 起已并入标准库。MSVC 从 VS 2017 15.7 左右开始提供较完整的实现。这些属于工具链细节,具体以所用编译器文档为准。


四、为什么多线程不一定更快


文件查找的耗时由两部分构成:目录遍历(大量stat/目录读取)和内容匹配(大量read)。前者高度依赖文件系统缓存和磁盘寻道,后者受限于存储带宽。多个线程同时访问磁盘时,操作系统会把请求排队,机械硬盘上的随机访问尤其容易因为寻道而退化;固态硬盘受影响较小,但也不会因为线程数翻倍而线性提速。


因此线程数不是越多越好。一个常见的起点是使用硬件并发数,然后通过std::chrono::steady_clock在目标机器上测量不同线程数下的耗时,同时观察 CPU 占用和 I/O 等待。测量时要注意缓存效应:第一次遍历是冷缓存,第二次可能全部命中页缓存,两次结果没有可比性。可以多次运行取中位数,并说明测试数据的规模与存储介质。


另一个容易被忽视的开销是字符串构造。示例中每个文件路径都被复制进std::string并入队,如果目录里文件极多,这部分内存分配本身就可能很可观。优化方向包括:批量入队、使用std::string_view引用生命周期足够长的缓冲区、或者让遍历线程只投递目录、由工作线程自行枚举。但每引入一层优化,同步逻辑都会更复杂,应先测量再修改。


常见坑点



  1. ❌ 工作线程用while (!tasks_.empty())判断是否退出,队列一空就返回。


✅ 用finished_标记遍历是否结束,配合条件变量等待「有任务或已结束」,否则会漏掉后续入队的文件。



  1. ❌ 在cv_.wait(lk)外面直接检查tasks_.empty(),不加谓词。


✅ 写成cv_.wait(lk, [this]{ return finished_ || !tasks_.empty(); });,防止虚假唤醒。



  1. ❌ 遍历完成后用cv_.notify_one()唤醒等待者。


✅ 结束是全局状态变化,应使用notify_all(),否则其余工作线程可能永远阻塞。



  1. ❌ 多个线程直接对同一个std::vector调用push_back而不加锁。


✅ 用互斥量保护结果写入,或让每个线程持有局部结果、结束后在主线程合并。



  1. ❌ 用recursive_directory_iterator的抛出重载,遇到无权限目录直接异常退出。


✅ 使用带std::error_code的重载并检查每次increment的结果,必要时加上skip_permission_denied。



  1. ❌ 创建std::thread后忘记join()或detach()。


✅ 在作用域结束前join所有线程;std::thread析构时仍可 join 会触发std::terminate。



  1. ❌ 把命中结果按线程完成的顺序当作「文件系统顺序」展示。


✅ 多线程下结果顺序不确定,若需要稳定顺序,应在收集后按路径排序。



  1. ❌ 认为线程数等于 CPU 核心数就一定能加速磁盘查找。


✅ 线程数应根据存储介质和测量结果调整,I/O 密集型任务的瓶颈往往不在 CPU。


总结




要点做法原因



任务拆分单线程遍历 + 多线程匹配避免重复遍历,队列天然负载均衡

队列同步mutex+condition_variable+ 谓词防止虚假唤醒和数据竞争

终止条件finished_标记 +notify_all保证所有工作线程都能退出

结果收集锁保护或线程局部合并vector不是线程安全的

错误处理error_code重载遍历中权限错误不应中断全局

线程数量硬件并发数起步,按测量结果调整I/O 瓶颈下并非越多越好



多线程查找文件的骨架并不复杂,难点集中在同步与退出条件上:只要把「队列为空」和「遍历结束」区分清楚,并保证共享数据始终在锁的保护下访问,程序就是自洽的。至于是否真的值得多线程,应当以目标机器上的测量结果为准,而不是凭直觉增加线程。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询