重生之我在10天内卷赢C++ - DAY 10
🚀 重生之我在10天内卷赢C++ - DAY 10
导师寄语:恭喜你,走到了这趟旅程的最后一站!单线程的程序就像一个武林高手,虽然强大,但终究分身乏术。而现代的计算机,几乎都是多核CPU,只用一个核心,就是对资源的巨大浪费。今天,我们将学习多线程(Multithreading)和多进程(Multiprocessing),让你的程序学会“分身术”,在多个CPU核心上同时执行任务,将程序的性能压榨到极致!这是通往高性能服务端、复杂计算、流畅UI的必经之路。准备好,释放你CPU的全部潜力吧!
🎯 今日目标
- 理解
进程(Process)
与线程(Thread)
的本质区别。 - 【深度解析】 掌握
std::thread
的创建步骤、参数传递机制和生命周期管理。 - 理解并发编程中的核心难题——竞争条件(Race Condition),学会使用互斥锁。
- 【深度解析】 掌握
std::mutex
和std::lock_guard
的使用步骤和工作原理,保证线程安全。 - 【深度解析】 学习 Linux 下多进程的创建方式
fork()
,并理解其返回值和wait()
函数的意义。 - 了解特殊进程状态:僵尸进程和孤儿进程。
- 总结多线程与多进程的适用场景,做出明智的技术选型。
1. 核心概念:进程 vs. 线程
想象一个大工厂:
- 进程 (Process):就是整个工厂。它拥有自己独立的厂房、土地、资源(内存空间、文件句柄等)。开一个新工厂(启动一个新进程)成本很高,但工厂之间是隔离的,一个工厂倒闭了,不会影响另一个。
- 线程 (Thread):是工厂里的工人。一个工厂里可以有很多工人,他们共享工厂的资源(共享同一块内存)。雇一个新工人(创建一个新线程)成本很低,工人们可以方便地协作(直接读写共享内存)。但问题也随之而来:如果两个工人同时去操作同一台机器,就可能导致生产事故。
总结一下:
特性 | 进程 (Process) | 线程 (Thread) |
---|---|---|
定义 | 资源分配的最小单位 | CPU调度的最小单位 |
资源 | 拥有独立的内存空间 | 共享进程的内存空间 |
创建开销 | 大 | 小 |
通信 | 复杂,需要IPC(进程间通信) | 简单,直接读写共享变量 |
健壮性 | 高,一个进程崩溃不影响其他进程 | 低,一个线程崩溃可能导致整个进程崩溃 |
关系 | 一个进程至少包含一个线程 | 线程必须存在于进程之内 |
2. C++多线程实战:std::thread
C++11 的 <thread>
库是我们的主要工具。让我们一步步拆解它。
创建线程的步骤 (Step-by-Step)
- 包含头文件:
#include <thread>
- 定义任务: 准备一个函数,这个函数体就是你希望新线程去做的事情。它可以是普通函数、Lambda 表达式或一个类的成员函数。
- 创建
std::thread
对象: 实例化一个std::thread
对象,将任务函数和其所需的参数传递给它。线程在此时立即被创建并开始执行。 - 管理线程生命周期: 主线程必须决定如何与新线程交互。
- 等待 (Join): 调用
t.join()
。主线程会暂停在这里,直到名为t
的子线程执行完毕。这是最常见的方式,可以确保子线程的工作成果被安全回收。 - 分离 (Detach): 调用
t.detach()
。主线程将不再与子线程保持任何关系,子线程会在后台独立运行。当子线程结束后,其资源由系统自动回收。这是一种“放养”模式,但要小心,如果主程序退出,分离的线程也会被粗暴终止。
- 等待 (Join): 调用
深入解析 std::thread
构造函数
它的核心形式是:template< class Function, class... Args > explicit thread( Function&& f, Args&&... args );
这看起来很复杂,我们把它翻译成白话:
std::thread 线程对象名( 可执行的任务, 任务的第1个参数, 任务的第2个参数, ...);
-
f
(可执行的任务):可以是任何“可调用”的东西:- 普通函数名:
my_function
- Lambda 表达式:
[](){ ... }
- 函数对象 (Functor): 一个重载了
operator()
的类的实例。
- 普通函数名:
-
args...
(任务的参数):这里有一个至关重要的陷阱!- 默认行为是值拷贝:所有传递给
std::thread
构造函数的参数,都会被拷贝到新线程的内部存储中。 - 如何按引用传递?:如果你想让线程直接修改外部变量,你必须用
std::ref()
或std::cref()
(常量引用) 来包裹它。
- 默认行为是值拷贝:所有传递给
举例说明参数传递:
#include <iostream>
#include <thread>
#include <string>
#include <functional> // for std::refusing namespace std;// 任务函数,一个值参数,一个引用参数
void modify_data(int val, string& ref_str) {val += 10; // 修改的是 val 的副本ref_str += " (modified)"; // 修改的是外部 str 的引用cout << "[子线程] val = " << val << ", ref_str = " << ref_str << endl;
}int main() {int my_val = 100;string my_str = "Hello";cout << "[主线程] Before: my_val = " << my_val << ", my_str = \"" << my_str << "\"" << endl;// 创建线程// my_val 是值传递,my_str 通过 std::ref 实现了引用传递thread t(modify_data, my_val, ref(my_str));t.join(); // 等待线程完成cout << "[主线程] After: my_val = " << my_val << ", my_str = \"" << my_str << "\"" << endl;return 0;
}
输出:
[主线程] Before: my_val = 100, my_str = "Hello"
[子线程] val = 110, ref_str = Hello (modified)
[主线程] After: my_val = 100, my_str = "Hello (modified)"
结论:my_val
没变,因为线程操作的是副本。my_str
变了,因为我们用 std::ref
强制传递了引用。
3. 竞争条件与互斥锁 std::mutex
当多个线程同时读写同一个共享变量时,就会发生竞争条件 (Race Condition),最终结果取决于线程执行的微小时间差,导致结果不可预测,通常是错误的。
举例说明(一个错误的银行取钱程序):
#include <iostream>
#include <thread>
#include <vector>int balance = 1000; // 共享的银行存款void withdraw(int amount) {if (balance >= amount) {// 模拟CPU切换,让问题暴露this_thread::sleep_for(chrono::milliseconds(1)); balance -= amount;cout << this_thread::get_id() << " 取款 " << amount << " 成功,余额: " << balance << endl;} else {cout << this_thread::get_id() << " 取款失败,余额不足。" << endl;}
}int main() {cout << "初始余额: " << balance << endl;thread t1(withdraw, 800);thread t2(withdraw, 800);t1.join();t2.join();cout << "最终余额: " << balance << endl; // 理想结果是200,但很可能是-600return 0;
}
问题分析:t1
和t2
都检查到余额1000 >= 800
,于是都执行了减法操作,导致余额被减了两次!
互斥锁 std::mutex
:一次只许一人通过
为了解决这个问题,我们需要一把“锁”,在访问共享资源(balance
)时,先锁上,用完再解开。这块被锁保护的代码区域称为临界区 (Critical Section)。
使用互斥锁 (std::mutex
) 的标准步骤
- #1 包含头文件:
#include <mutex>
- #2 创建互斥锁实例:
std::mutex mtx;
- 这个锁对象本身必须是被多个线程共享的。通常定义为全局变量,或类的成员变量。
- #3 使用
std::lock_guard
上锁: 在需要保护共享数据的代码块(临界区)的开头,创建一个std::lock_guard
对象。std::lock_guard<std::mutex> guard(mtx);
- 原理:
lock_guard
在其构造函数中自动调用mtx.lock()
。
- #4 编写临界区代码: 在
lock_guard
的作用域内,安全地访问共享资源。 - #5 自动解锁: 当代码执行离开
lock_guard
的作用域时(例如函数返回,或大括号结束),lock_guard
的析构函数会被自动调用,它会执行mtx.unlock()
。这就是 RAII 的魔力,它保证了锁一定会被释放,即使发生异常。
函数参数解析:std::lock_guard
std::lock_guard<std::mutex> guard(mtx);
std::mutex
: 这是一个模板参数,告诉lock_guard
它要管理的锁的类型。guard
: 我们给这个lock_guard
对象起的名字。mtx
: 这是传递给构造函数的实际的互斥锁对象。guard
将对mtx
进行加锁。
修复后的银行程序 :
#include <iostream>
#include <thread>
#include <mutex> // Step 1: 包含头文件int balance = 1000;
std::mutex mtx; // Step 2: 创建一个所有线程共享的互斥锁实例void withdraw_safe(int amount) {// Step 3: 创建 lock_guard,它在构造时自动锁定 mtxstd::lock_guard<std::mutex> guard(mtx);// Step 4: ----- 这里是临界区 -----if (balance >= amount) {// ... (省略了 sleep 以突出逻辑)balance -= amount;cout << this_thread::get_id() << " 取款 " << amount << " 成功,余额: " << balance << endl;} else {cout << this_thread::get_id() << " 取款失败,余额不足。" << endl;}// Step 5: 当函数结束,'guard'对象被销毁,其析构函数会自动调用 mtx.unlock()
}int main() {// ... (主函数逻辑不变)cout << "初始余额: " << balance << endl;thread t1(withdraw_safe, 800);thread t2(withdraw_safe, 800);t1.join();t2.join();cout << "最终余额: " << balance << endl; return 0;
}
4. 多进程编程与相关概念
创建进程的步骤 (以 Linux fork()
为例)
- #1 包含头文件:
#include <unistd.h>
(提供fork
) 和#include <sys/wait.h>
(提供wait
)。 - #2 调用
fork()
:pid_t pid = fork();
- #3 检查
fork()
返回值: 这是最关键的一步,用if-else if-else
结构来区分父子进程。pid < 0
:fork()
调用失败,没有创建新进程。pid == 0
: 当前代码正在子进程中执行。pid > 0
: 当前代码仍在父进程中执行,并且pid
的值是刚刚创建的子进程的ID。
- #4 编写父子进程的逻辑: 在对应的
if
分支里,编写各自需要执行的任务。 - #5 父进程等待子进程: 在父进程的逻辑末尾,调用
wait(NULL)
或waitpid(pid, &status, 0)
来等待子进程结束,并回收其资源,防止其变为僵尸进程。
函数参数解析:fork()
和 wait()
-
pid_t fork(void);
- 参数:
void
,无参数。 - 返回值:
pid_t
类型(本质是整型)。- 返回
-1
表示错误。 - 返回
0
表示当前是子进程。 - 返回正数表示当前是父进程,该值是子进程的ID。
- 返回
- 参数:
-
pid_t wait(int *wstatus);
- 作用: 阻塞父进程,直到它的任意一个子进程结束。
- 参数
wstatus
: 这是一个输出参数。如果你关心子进程是如何结束的(正常退出还是被信号杀死,退出码是多少),你可以传递一个int
变量的地址。子进程的退出状态信息会被写入这个地址。如果你不关心,直接传NULL
或nullptr
即可。 - 返回值: 返回结束的那个子进程的ID。
带注释的 fork()
示例:
#include <iostream>
#include <unistd.h> // Step 1
#include <sys/wait.h> // Step 1
using namespace std;int main() {// Step 2: 调用 forkpid_t pid = fork();// Step 3: 检查返回值if (pid < 0) {cerr << "Fork failed!" << endl;return 1;} else if (pid == 0) {// Step 4: 子进程的逻辑cout << "[子进程] 我是子进程, 我的PID是 " << getpid() << ", 我的父进程PID是 " << getppid() << endl;// 子进程可以执行一个独立的任务,例如 `execlp`...exit(0); // 子进程任务完成,正常退出} else {// Step 4: 父进程的逻辑cout << "[父进程] 我是父进程, 我的PID是 " << getpid() << ", 我刚刚创建了子进程 " << pid << endl;// Step 5: 等待子进程结束cout << "[父进程] 我正在等待子进程结束..." << endl;wait(NULL); // 传入 NULL,表示不关心子进程的退出状态cout << "[父进程] 我的子进程已经结束了。" << endl;}cout << "程序结束, PID: " << getpid() << endl;return 0;
}
进程相关概念 (僵尸进程与孤儿进程)
- 僵尸进程 (Zombie Process):一个子进程已经结束运行,但其父进程还没有调用
wait()
或waitpid()
来获取它的退出状态。这个“已死”的子进程会一直保留在系统的进程表中,占用一个进程ID,像个“僵尸”一样。如果僵尸进程过多,会耗尽系统资源。 - 孤儿进程 (Orphan Process):一个父进程在子进程结束前就退出了。这个子进程就成了“孤儿”。不过别担心,系统会自动将孤儿进程过继给1号进程(
init
或systemd
),由它来负责回收。
5. 对比总结:何时用多线程?何时用多进程?
对比维度 | 多线程 (Multithreading) | 多进程 (Multiprocessing) |
---|---|---|
目标 | 并发 (Concurrency):让多个任务“看起来”同时执行,提高响应速度。 | 并行 (Parallelism):让多个任务真正同时在不同CPU核心上执行,提高计算吞吐量。 |
适用场景 | I/O密集型任务 (如网络请求、文件读写)。当一个线程因等待I/O而阻塞时,其他线程可以继续执行,保持CPU繁忙,程序不卡顿。 | CPU密集型任务 (如科学计算、视频编码、大规模数据处理)。利用多核CPU实现真正的并行计算,缩短总计算时间。 |
优点 | 创建快,上下文切换快,数据共享方便。 | 稳定、安全,一个进程的错误不影响其他进程。 |
缺点 | 线程安全问题复杂(需要加锁),一个线程的崩溃可能导致整个应用完蛋。 | 创建慢,上下文切换慢,进程间通信(IPC)复杂且有开销。 |
简单决策指南:
- 任务之间需要大量、频繁地共享数据吗? -> 首选多线程。
- 任务是计算密集型,想充分利用多核CPU算力? -> 多进程是更好的选择,避免了锁的开销和复杂性。
- 任务是I/O密集型,需要同时处理多个网络连接或文件操作? -> 多线程非常适合,可以高效地处理等待。
- 对程序稳定性要求极高,一个模块的失败绝不能影响核心服务? -> 多进程架构更健壮。
✍️ DAY 10 终章作业
任务:多线程求和
实现一个程序,它将一个包含大量数字的 vector
分成N个部分,然后创建N个线程,每个线程负责计算其中一部分的和。最后,主线程将所有线程计算出的部分和累加起来,得到最终的总和。
- 创建一个包含1000万个整数的
vector
,所有元素都为1。 - 编写一个求和函数
void partial_sum(const vector<int>& vec, size_t start, size_t end, long long& result)
,它计算vec
从start
到end-1
索引范围内的元素和,并将结果存入result
中。 - 在
main
函数中,确定要创建的线程数(例如,4个)。 - 将
vector
均等地分割给每个线程。 - 创建并启动这些线程,让它们并行计算各自区段的和。
- 思考:多个线程计算出的部分和,最后要汇总到一个总和里。这是否需要同步?(提示:如果每个线程写到不同的变量里,最后由主线程汇总,则不需要。但如果所有线程都累加到同一个全局变量,就需要!)
- 等待所有线程完成后,由主线程将所有部分和加起来,并打印最终结果。验证结果是否为1000万。
🚀 DAY 10 作业答案与解析:多线程求和
文件:homework_final.cpp
#include <iostream>
#include <vector>
#include <thread>
#include <numeric> // for std::accumulate (可以用来验证)
#include <chrono> // for timingusing namespace std;// 2. 编写求和函数
// const vector<int>& vec: 以常量引用传递大的vector,避免拷贝,高效且安全
// size_t start, size_t end: 线程计算的范围 [start, end)
// long long& result: 以引用传递结果变量,这样函数内部的修改能直接反映到外部
void partial_sum(const vector<int>& vec, size_t start, size_t end, long long& result) {long long local_sum = 0; // 使用局部变量进行计算,避免频繁访问引用,效率更高for (size_t i = start; i < end; ++i) {local_sum += vec[i];}result = local_sum; // 最后将结果赋给引用参数
}int main() {// 1. 创建一个包含1000万个整数的 vector,所有元素都为1const size_t vector_size = 10000000;vector<int> numbers(vector_size, 1);// 3. 确定要创建的线程数// std::thread::hardware_concurrency() 可以获取CPU的核心数,是选择线程数的理想参考const unsigned int num_threads = thread::hardware_concurrency(); cout << "使用 " << num_threads << " 个线程进行计算..." << endl;// 4. 将 vector 均等地分割给每个线程vector<thread> threads;vector<long long> partial_results(num_threads); // 为每个线程准备一个独立的结果存放位置size_t block_size = vector_size / num_threads;size_t start_index = 0;auto start_time = chrono::high_resolution_clock::now();// 5. 创建并启动这些线程for (unsigned int i = 0; i < num_threads; ++i) {size_t end_index = start_index + block_size;// 处理最后一个线程,确保它能计算到数组末尾(处理不能整除的情况)if (i == num_threads - 1) {end_index = vector_size;}// 创建线程,并传入任务函数和参数// partial_results[i] 是每个线程各自的存储空间,不会产生竞争threads.emplace_back(partial_sum, cref(numbers), start_index, end_index, ref(partial_results[i]));start_index = end_index;}// 7. 等待所有线程完成for (auto& t : threads) {if (t.joinable()) {t.join();}}// 主线程将所有部分和加起来long long total_sum = 0;for (long long res : partial_results) {total_sum += res;}auto end_time = chrono::high_resolution_clock::now();auto duration = chrono::duration_cast<chrono::milliseconds>(end_time - start_time);// 打印最终结果cout << "多线程计算的总和是: " << total_sum << endl;cout << "多线程计算耗时: " << duration.count() << " ms" << endl;// --- 对比:单线程计算 ---auto single_start_time = chrono::high_resolution_clock::now();long long single_thread_sum = 0;for(int n : numbers) {single_thread_sum += n;}auto single_end_time = chrono::high_resolution_clock::now();auto single_duration = chrono::duration_cast<chrono::milliseconds>(single_end_time - single_start_time);cout << "\n单线程计算的总和是: " << single_thread_sum << endl;cout << "单线程计算耗时: " << single_duration.count() << " ms" << endl;return 0;
}
编译与运行:
# 使用 -pthread 或 -lpthread 链接线程库
$ g++ homework_final.cpp -o hw_final -std=c++11 -pthread
$ ./hw_final
预期输出 (具体耗时因机器而异):
使用 8 个线程进行计算...
多线程计算的总和是: 10000000
多线程计算耗时: 5 ms单线程计算的总和是: 10000000
单线程计算耗时: 19 ms
深度解析
-
线程安全的实现方式 (关键点)
- 在
main
函数中,我们创建了一个vector<long long> partial_results(num_threads);
。 - 这意味着每个线程都有自己专属的、独立的
long long
变量来存储其中间结果(通过ref(partial_results[i])
传递)。 - 线程 A 写入
partial_results[0]
,线程 B 写入partial_results[1]
,它们操作的是不同的内存地址,因此完全不存在竞争条件 (Race Condition)。 - 所以,这种设计下,我们完全不需要使用
std::mutex
互斥锁! 这是最高效、最优雅的并发模式——无锁并发。
- 在
-
思考题的解答:如果所有线程都累加到同一个全局变量会怎样?
- 假设我们这么设计:
// 错误的设计! long long global_total_sum = 0; // ... // 线程任务函数 void bad_partial_sum(const vector<int>& vec, size_t start, size_t end) {for (size_t i = start; i < end; ++i) {// !!! 极度危险的竞争条件 !!!global_total_sum += vec[i];} }
global_total_sum += vec[i]
这个操作不是原子的。它至少包含三步:- 读 (Read): 从内存读取
global_total_sum
的当前值到 CPU 寄存器。 - 改 (Modify): 在寄存器中执行加法。
- 写 (Write): 将寄存器中的新值写回内存。
- 读 (Read): 从内存读取
- 如果两个线程同时执行,可能会发生:
- 线程 A 读取
global_total_sum
(值为 100)。 - CPU 切换到线程 B。
- 线程 B 也读取
global_total_sum
(值仍然是 100)。 - 线程 B 计算 100 + 1 = 101,并写回内存。
global_total_sum
变为 101。 - CPU 切换回线程 A。
- 线程 A 用它之前读到的旧值 100 计算 100 + 1 = 101,并写回内存。
- 结果:两个线程都加了1,但总和只增加了1。数据丢失了!最终结果会远小于1000万。
- 线程 A 读取
- 如何修复? 必须用互斥锁保护
+=
操作:std::mutex mtx; long long global_total_sum = 0; // ... void better_partial_sum(const vector<int>& vec, size_t start, size_t end) {for (size_t i = start; i < end; ++i) {std::lock_guard<std::mutex> guard(mtx);global_total_sum += vec[i];} }
- 假设我们这么设计:
🎉 恭喜你,完成了“重生之我在10天内卷赢C++”的全部课程! 你已经从一个C++新手,成长为掌握了面向对象、STL、模板、异常处理和并发编程的准高手。但这只是一个开始,C++的世界浩瀚无垠。继续实践,不断学习,去构建真正伟大的软件吧!
点个赞和关注,更多知识包你进步,谢谢!!!你的支持就是我更新的最大动力