ARTICLE DETAIL

资讯详情

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

c++学习(18):多线程基础

c++学习(18):多线程基础 //7. 多线程基础 //并行模拟两个独立的门电路简单示例。 #include iostream #include thread #include vector #include chrono // 模拟一个耗时计算 int simulateGate(int id, int delayMs) { std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); int result id * 2; // 随便算点东西 std::cout Gate id finished, result result std::endl; return result; } int main() { std::vectorstd::thread threads; std::vectorint results(5); // 存储每个线程的结果 for (int i 0; i 5; i) { threads.emplace_back([i, results]() { results[i] simulateGate(i, 100 i * 50); }); } for (auto t : threads) { t.join(); } std::cout All gates simulated. std::endl; return 0; }一、头文件cpp#include iostream #include thread #include vector #include chronothread提供std::thread类用于创建和管理线程。chrono提供时间相关的工具比如std::chrono::milliseconds用来表示一个时间段。iostream和vector你已经熟悉。二、simulateGate函数cppint simulateGate(int id, int delayMs) { std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); int result id * 2; std::cout Gate id finished, result result std::endl; return result; }std::this_thread::sleep_for(...)让当前线程暂停执行一段指定的时间。std::chrono::milliseconds(delayMs)构造一个表示delayMs毫秒的时间间隔对象。sleep_for接收这个对象然后让当前线程休眠相应的时间。休眠期间这个线程不占用 CPU操作系统会去执行其他线程。int result id * 2;随便算一个结果用来模拟“门电路计算”。std::cout ...打印信息。注意如果有多个线程同时执行这一行输出可能会交错后面会解释。return result;返回计算结果。这个函数本身没有多线程逻辑它只是一个普通的、耗时的函数。多线程的逻辑在main里。三、main函数cppint main() { std::vectorstd::thread threads; std::vectorint results(5);std::vectorstd::thread threads;创建一个空的vector用来存放线程对象。std::thread是一个类它的对象代表一个线程。std::vectorint results(5);创建一个包含 5 个int的vector每个元素初始化为 0。这个vector用来存放每个线程的计算结果。3.1 创建线程的循环cppfor (int i 0; i 5; i) { threads.emplace_back([i, results]() { results[i] simulateGate(i, 100 i * 50); }); }这一行是整个多线程的核心。我们拆开看1threads.emplace_back(...)emplace_back是vector的成员函数在vector末尾直接构造一个新元素。这里新元素的类型是std::thread。emplace_back接收的参数会直接传给std::thread的构造函数。std::thread的构造函数接受一个可调用对象函数、lambda、函数对象等以及可选的参数。这里我们只传了一个 lambda。为什么不用push_backstd::thread对象不能被拷贝只能被移动。push_back需要拷贝或移动一个已经存在的thread对象。而emplace_back直接在vector内部构造thread对象把 lambda 作为构造参数传进去避免了先构造临时thread再移动的步骤。2Lambda 表达式[i, results]() { ... }[i, results]是捕获列表。i是按值捕获每个线程会得到当前循环变量i的一个副本。因为i在循环中每次迭代都会变化如果不按值捕获所有线程可能都会看到最终的值5。按值捕获保证每个线程拿到自己那一份i。results是按引用捕获所有线程共享同一个results对象。这样线程才能把计算结果写回主线程的results中。()是参数列表这里是空的表示这个 lambda 不接收额外参数。{ results[i] simulateGate(i, 100 i * 50); }是函数体。每个线程执行时会调用simulateGate传入自己的i和一个延迟时间100 i * 50毫秒。然后把返回值赋给results[i]。3线程启动当std::thread对象被构造时它立即启动一个新线程并在这个新线程中执行传入的可调用对象。所以循环的每一次迭代都会创建一个新线程一共 5 个线程并发运行。所以程序运行的现象是打印每一条结果的时间间隔相同而不是时间间隔递增3.2 等待线程结束cppfor (auto t : threads) { t.join(); }t.join()阻塞当前线程这里是主线程直到t所代表的线程执行完毕。每个std::thread对象只能join一次。join之后该线程对象不再代表任何线程。这个循环的作用是主线程依次等待 5 个子线程全部结束。如果不join主线程可能会继续往下执行甚至结束整个程序而子线程还在运行。程序结束时如果还有未join的线程std::thread的析构函数会调用std::terminate导致程序异常终止。正常运行的输出结果是若注释掉join得到的结果是3.3 最后输出cppstd::cout All gates simulated. std::endl;主线程在等待所有子线程结束后打印这一行。四、多线程执行时的行为主线程创建 5 个线程每个线程开始执行 lambda。每个线程调用simulateGate其中sleep_for让线程暂停一段时间。因为每个线程的延迟时间不同100、150、200、250、300 毫秒所以它们完成的时间不同。当某个线程的sleep_for结束后它计算result打印一行信息并把结果写入results[i]。主线程在join循环中等待直到所有线程都完成。最后主线程打印 All gates simulated.。输出可能交错因为多个线程可能同时执行std::cout ...而std::cout不是线程安全的。多个线程同时写入可能导致输出混乱例如两行信息交织在一起。这个示例没有处理这个问题所以实际运行时可能会看到交错的输出。五、数据竞争问题results是一个大小为 5 的vectorint。每个线程写入results[i]其中i是线程自己的副本各不相同。因此不同线程写入的是vector的不同元素。C 标准保证不同线程修改同一个容器的不同元素是安全的只要容器本身不被重新分配。这里results大小固定不会重新分配所以没有数据竞争。如果多个线程写入同一个元素就需要同步机制如std::mutex但本示例没有这种情况。六、总结这份代码演示了如何用std::thread创建线程。如何用 lambda 捕获变量把参数传给线程函数。如何用join等待线程结束。如何用sleep_for模拟耗时操作。多线程可以并发执行但共享输出流时可能产生交错。你之前学过的vector、lambda、引用捕获在这里全部用上了。多线程的新东西主要是std::thread、join、sleep_for和chrono。理解这些之后你就能看懂这份代码的每一行了。一、成员函数和自由函数Q忽然发现一个技巧你看这句std::this_thread::sleep_for(std::chrono::milliseconds(delayMs));虽然看起来很长但其实那串::都不用看只要看最后一个sleep_for和milliseconds识别出这是一个函数就行。以及这里竟然不是在对对象操作我竟然有点不习惯了A你这个“阅读技巧”非常实用而且你正好撞到了 C 里“自由函数”和“成员函数”的分界线上。你以前习惯的对象.方法()叫“成员函数”而这里直接写函数名调用叫“自由函数”。两者在底层的行为完全不同。1. 关于::的阅读技巧你说的完全正确中间的::只是命名空间分隔符可以跳过直接看最后一段。std::this_thread::sleep_for→ 看最后一段它是sleep_for一个函数。std::chrono::milliseconds→ 看最后一段它是milliseconds一个类用于构造时间对象。这条规则在阅读长名字时非常有用因为它能让你在 0.5 秒内判断“这是个函数调用”还是“在创建对象”。2. 为什么这里不是“对对象操作”因为sleep_for不属于任何对象。它是一个自由函数Free Function或者更准确地说是命名空间std::this_thread里的一个普通函数。你之前习惯了cppfile.read(...); // file 是一个对象read 是它的成员函数 vec.push_back(...); // vec 是一个对象push_back 是它的成员函数这种调用方式编译器底层会隐式传递一个this指针指向file或vec。而sleep_for的调用方式cppstd::this_thread::sleep_for(...);它没有对象没有this指针。编译器直接调用这个函数把milliseconds(...)构造出来的时间对象作为参数传进去。为什么它可以没有对象 因为sleep_for的作用对象是当前正在执行它的线程。它不需要你去指定“让哪个线程睡觉”——你已经在那个线程里了它就是让“自己”暂停。这是一种“隐式的上下文”不需要显式的对象参数。3. 那std::this_thread是什么std::this_thread不是一个对象它是一个命名空间。它内部只有几个自由函数sleep_for、sleep_until、get_id、yield。这些函数都作用于“调用它们的那个线程”。C 标准库用命名空间来组织这些函数而不是用类是因为“当前线程”这个概念不需要用对象来表示。你不需要创建一个“线程对象”来代表自己——你本身就是线程。4. 你之前其实见过类似的只是没注意std::cout和std::cin是对象全局对象所以它们用成员函数和operator。但标准库里还有大量自由函数比如std::move(x)不是x.move()而是直接传x。std::swap(a, b)不是a.swap(b)虽然std::string有swap成员函数但通用版本是自由函数。std::getline(cin, line)不是cin.getline(line)虽然istream有一个成员getline但更常用的std::getline是自由函数。这些函数都不依附于某个特定对象它们接收对象作为参数然后执行操作。5. 为什么你会不习惯因为你之前学的vector、string、ifstream等都是以对象为中心的——你创建一个对象然后调用它的成员函数。这是一种“面向对象”的视角。而sleep_for是以功能为中心的——它只是一个动作作用于当前线程。你不需要一个“线程对象”来代表当前线程。它是“自由”的不属于任何对象。C 是混合语言它既有面向对象的部分类、成员函数、继承、多态也有面向过程的部分自由函数、命名空间。你之前一直在看面向对象的部分现在你看到了面向过程的部分。这两种风格在 C 里是共存的而且经常混用。6. 一句话总结std::this_thread::sleep_for自由函数作用是让当前线程休眠。std::chrono::milliseconds(delayMs)创建一个表示时间间隔的对象作为sleep_for的参数。它不是成员函数所以没有对象.的前缀而是用命名空间::函数名的形式调用。补充从今天起你看代码可以多一个步骤先看::前面是什么。如果::前面是一个变量名vec、s、file那后面跟的是成员函数或者静态成员但概率低。如果::前面是一个命名空间名std、std::this_thread、std::chrono那后面跟的是自由函数或类型名。如果::前面是一个类名std::vector那后面跟的是静态成员或类型别名。这个判断只需要 0.5 秒但它能让你从“看不懂”变成“一眼看穿”。比如cppstd::vectorint v {3, 1, 2}; v.push_back(4); // 成员函数 std::sort(v.begin(), v.end()); // 自由函数二、lambda回顾Qstd::thread 的构造函数接受一个可调用对象函数、lambda、函数对象等以及可选的参数。这里我们只传了一个 lambda。lambda好眼熟但忘了再给我快速复习一下ALambda 的本质就一句话你在代码里直接写一个“匿名函数”编译器会把它变成一个匿名的类函数对象。1. 语法结构cpp[捕获列表](参数列表) { 函数体 }对应到你的线程代码cpp[i, results]() { results[i] simulateGate(i, 100 i * 50); }[i, results]捕获列表。声明这个 lambda 要用到外部哪些变量。()参数列表。这里为空表示不额外接收参数。{ ... }函数体。和普通函数一样。2. 捕获列表的两种常见方式写法含义在你的代码里[i]按值捕获ilambda 内部拿到的是i的副本。i是循环变量每个线程需要自己独立的副本所以按值捕获。[results]按引用捕获resultslambda 内部直接操作外部的results对象。所有线程需要把结果写回同一个results所以按引用捕获。其他捕获写法[]按值捕获所有用到的外部变量。[]按引用捕获所有用到的外部变量。[x, y]混用x按值y按引用。3. 编译器把 lambda 变成了什么你写的 lambdacpp[i, results]() { results[i] simulateGate(i, 100 i * 50); }编译器生成一个匿名类等价于cppclass __anonymous_lambda { private: int i; // 按值捕获的副本 std::vectorint* results_ptr; // 按引用捕获底层存的是地址 public: __anonymous_lambda(int i_copy, std::vectorint results_ref) : i(i_copy), results_ptr(results_ref) {} void operator()() const { (*results_ptr)[i] simulateGate(i, 100 i * 50); } };然后std::thread的构造函数接收这个匿名类的一个实例并在新线程中调用它的operator()。4. 为什么 lambda 能传给std::thread因为std::thread接受任何可调用对象。可调用对象只需要满足一个条件能通过()调用。普通函数void f() {}→f()可以调用。函数对象重载了operator()的类实例 →obj()可以调用。Lambda编译器生成的匿名类实例 →lambda_obj()可以调用。Lambda 的优势是你不用在别处定义一个函数或类直接在使用的地方内联写出来。5. 快速记忆Lambda 没有名字的函数 能捕获外部变量。语法是[捕获](参数){函数体}。编译器把它变成一个带operator()的匿名类。你之前的std::find_if里也用过 lambdacpp[](const GateInfo g) { return g.inputCount 2; }这里的[]是空的因为它没有用到任何外部变量。Q我看到有[]和()这两个有什么区别看起来好像都是接收函数体内用到的参数A不。[]和()接收的完全是两种不同来源的东西。[]捕获列表接收的是外部作用域里的变量比如你main里的i和results。()参数列表接收的是调用者传进来的参数比如find_if每次遍历时传给 lambda 的那个元素。例如你可以通过这样lambda(5)传入一个参数。我们用你见过的两个 lambda 对比例1线程代码里的 lambdacpp[i, results]() { results[i] simulateGate(i, 100 i * 50); }[]里写了i和results这两个变量来自main的循环是 lambda 在被创建时从外部“抓”进来的。()里是空的因为std::thread不会给 lambda 传任何参数它只是直接调用operator()。例2find_if里的 lambdacpp[](const GateInfo g) { return g.inputCount 2; }[]里是空的因为它没有用到任何外部变量。()里写了const GateInfo g这个参数是find_if在每次遍历时传进来的当前元素。为什么要分成两个因为它们的时机和来源不同维度[]捕获列表()参数列表数据来源定义 lambda 时所在的外部作用域调用 lambda 时由调用者传入绑定时机lambda 被创建时捕获的变量就被绑定到匿名对象里lambda 被调用时参数才被传入是否每次调用都变不变捕获的值是创建时的一份副本或引用每次调用可以不同类比普通函数的“全局变量”或“成员变量”普通函数的“形参”一个直观对比cppint external 10; // 外部变量 // 捕获 external按值参数是 x auto lambda [external](int x) { return external x; }; lambda(5); // 返回 15 lambda(7); // 返回 17external来自[]在 lambda 创建时就被固定为 10之后每次调用都用这个值。x来自()每次调用时由调用者传入所以两次调用分别是 5 和 7。回到你的线程代码cpp[i, results]() { ... }i和results是外部变量通过[]被“抓”进 lambda让 lambda 内部可以使用它们。()是空的因为std::thread在启动线程时直接调用operator()不会传任何参数。所以[]解决的是“我怎么拿到外部变量”()解决的是“调用者怎么把数据传给我”。两者是不同的问题。三、补充Q多线程在c中的运行是我们创建多个独立运行的线程在其中运行不同的东西。其中要注意的一点是对于线程可能需要阻塞否则或会出现线程还没运行完程序就结束了的情况会报错。我感觉我能关注到的点就这个是我没看出门道来吗A这个点是多线程使用的最基本安全规则它很重要但确实不是全部。更准确地说你抓到的是“怎么用才不出错”必须 join但还没触及“为什么会出错”和“怎么用才高效”。这个示例为了教学简洁刻意避开了一些陷阱。它之所以能“安然无恙”地跑完是因为它恰好避开了几个常见的雷。我把这些雷列出来你对照着看就知道自己“没看出”的是什么了。1. 数据竞争这个示例刚好躲过去了但不是因为它安全你的代码里cppresults[i] simulateGate(i, 100 i * 50);每个线程写的是results[i]i是按值捕获的副本每个线程拿到的是不同的i0、1、2、3、4。所以它们写的是results的不同元素。C 标准保证不同线程修改同一个容器的不同元素是安全的。但前提是容器本身不发生重新分配。这里results大小固定为 5不会重新分配所以没有数据竞争。如果代码改成cppint shared_counter 0; // 每个线程都写 shared_counter就会出问题。因为多个线程同时读-改-写同一个变量结果不可预测。这需要用std::atomic或std::mutex保护。你没看出来是因为这个示例故意没写这种危险代码。2. 输出交错你看到了但没意识到它是问题std::cout不是线程安全的。多个线程同时执行std::cout ...时输出可能混在一起。你运行时如果看到过类似这样的输出textGate Gate 0 finished, result1 finished, result2那就是交错了。这个示例没有保护cout所以实际运行时可能偶尔看到混乱的输出。你没意识到这是问题因为程序还是跑完了。3. 捕获列表的坑这个示例刚好用对了但原理你还没深究cpp[i, results]() { ... }i必须按值捕获。如果写成[i]那么所有线程都会共享同一个i。循环结束时i变成了 5所有线程都会去写results[5]——越界程序崩溃。results必须按引用捕获。如果写成[results]按值每个线程会拷贝一份results写进去的结果主线程根本看不到。这个示例写对了但你可能还没意识到“写错会怎样”。这正是你未来写多线程代码时最容易踩的坑之一。4. 线程开销不是线程越多越好这个示例只创建了 5 个线程。如果你要模拟 1000 个门创建 1000 个线程操作系统调度开销会非常大反而变慢。工业级 EDA 工具不会“一个任务一个线程”而是用线程池预先创建固定数量的线程比如 CPU 核心数让它们从任务队列里取任务。你在组里论文里看到的 OpenCL、并行静态学习底层就是类似的思想。这个示例没有展示这个因为它只是“最小演示”。5.join的本质不只是“等”还是“同步”你抓到的join是主线程等待子线程结束。但它的本质是同步点主线程必须等到所有子线程的数据写入完成才能安全地读取results。如果没有join主线程可能在子线程还没写results时就去读它读到的是初始值 0或者程序直接崩溃因为std::thread析构时发现线程还在运行会调用std::terminate。你抓到的“不 join 会报错”是std::thread的析构行为。但“join 之后才能安全读数据”才是它在逻辑上的必要性。所以你“没看出门道”吗不是。你看出的是正确使用的必要条件。你没看出的是违反这些条件会怎样以及在真实工程中怎么处理这些陷阱。这很正常。这个示例的定位就是“让你先跑起来看到多线程的基本形态”。门道在后面数据竞争、原子操作、互斥锁、条件变量、线程池。这些都是你将来做 EDA 并行化时必然会碰到的。你现在不需要一次性全部吞下。你只需要知道这个示例是一个“安全但简化”的版本它背后藏着好几个没引爆的雷。等你以后遇到真正的并行代码你会想起今天这个“刚好没踩雷”的示例。Q对的这里我确实不太明白。i是因为变成了别名/取引用所以变成共享了吗result合法是因为它是个数组所以针对不同i进去操作就好了吗A1.i为什么变成共享了因为i不是“把i的值复制进来”而是“把i的地址存进来”。当 lambda 被编译器转换成匿名类时cpp// 你写的 [i, results]() { ... } // 编译器生成的匿名类简化版 class __lambda { private: int i; // 按值捕获存的是 i 的副本 std::vectorint* results_ptr; // 按引用捕获存的是 results 的地址 public: void operator()() const { (*results_ptr)[i] simulateGate(i, 100 i * 50); } };如果你写成[i]编译器生成的成员变量就变成cppint i; // 引用成员底层存的是 i 的地址这意味着 5 个线程的 lambda 实例里i这个成员都指向主线程循环里的同一个i变量。问题在于主线程的循环for (int i 0; i 5; i)在不停地修改这个i。当 5 个线程开始执行时主线程可能已经把i加到 5 了。所有线程读到的i都是 5于是它们都去写results[5]——越界。根本原因i让 lambda 内部持有的是i的地址而不是i在某一刻的值。地址指向的变量在主线程里还在变。2.results为什么是合法的是因为它是数组吗不是因为它是数组。 是因为你需要所有线程操作同一个results对象。results是一个std::vectorint它在栈上有一个对象包含指针、大小、容量它管理的数据在堆上。如果你按值捕获[results]每个 lambda 会拷贝一份results对象包含它自己的一份堆数据。5 个线程各自写各自的副本主线程的results永远是初始值。如果你按引用捕获[results]所有 lambda 持有的是同一个results对象的地址。线程写入的就是主线程的那个results。所以results合法的原因是你需要共享而且results的大小和地址在循环过程中不会改变它是在循环外定义的。所有线程写入的是同一个容器的不同元素这是安全的。3. 那为什么i不能也按引用而results可以关键区别在于i在循环中会变化results不会。变量在循环中是否变化捕获方式结果i每次迭代都变0→1→2→3→4→5按值[i]每个线程拿到自己那一刻的副本安全i每次迭代都变按引用[i]所有线程共享同一个变量读到的是最终值 5越界results地址不变内容变被线程写入按引用[results]所有线程操作同一个容器安全results地址不变按值[results]每个线程操作自己的副本主线程看不到结果4. 一句话总结[i]把i的值复制进 lambda。每个线程拿到独立的副本互不干扰。[i]把i的地址存进 lambda。所有线程共享同一个i而它在主线程里还在变所以危险。[results]把results的地址存进 lambda。所有线程共享同一个容器但它们写的是不同元素所以安全。你问“是因为它是数组吗”——不是。是因为你需要共享且共享的对象本身在循环中不改变地址。这个判断标准是你以后写多线程捕获列表时最需要记住的问自己“这个变量在循环中会不会变我需不需要所有线程看到同一份”
返回列表