重生之我在10天内卷赢C++ - DAY 10

发布于:2025-08-04 ⋅ 阅读:(21) ⋅ 点赞:(0)

🚀 重生之我在10天内卷赢C++ - DAY 10

导师寄语:恭喜你,走到了这趟旅程的最后一站!单线程的程序就像一个武林高手,虽然强大,但终究分身乏术。而现代的计算机,几乎都是多核CPU,只用一个核心,就是对资源的巨大浪费。今天,我们将学习多线程(Multithreading)多进程(Multiprocessing),让你的程序学会“分身术”,在多个CPU核心上同时执行任务,将程序的性能压榨到极致!这是通往高性能服务端、复杂计算、流畅UI的必经之路。准备好,释放你CPU的全部潜力吧!

🎯 今日目标

  1. 理解进程(Process)线程(Thread)的本质区别。
  2. 【深度解析】 掌握 std::thread创建步骤、参数传递机制和生命周期管理。
  3. 理解并发编程中的核心难题——竞争条件(Race Condition),学会使用互斥锁
  4. 【深度解析】 掌握 std::mutexstd::lock_guard使用步骤和工作原理,保证线程安全。
  5. 【深度解析】 学习 Linux 下多进程的创建方式 fork(),并理解其返回值和 wait() 函数的意义
  6. 了解特殊进程状态:僵尸进程孤儿进程
  7. 总结多线程与多进程的适用场景,做出明智的技术选型。

1. 核心概念:进程 vs. 线程

想象一个大工厂:

  • 进程 (Process):就是整个工厂。它拥有自己独立的厂房、土地、资源(内存空间、文件句柄等)。开一个新工厂(启动一个新进程)成本很高,但工厂之间是隔离的,一个工厂倒闭了,不会影响另一个。
  • 线程 (Thread):是工厂里的工人。一个工厂里可以有很多工人,他们共享工厂的资源(共享同一块内存)。雇一个新工人(创建一个新线程)成本很低,工人们可以方便地协作(直接读写共享内存)。但问题也随之而来:如果两个工人同时去操作同一台机器,就可能导致生产事故。

总结一下:

特性 进程 (Process) 线程 (Thread)
定义 资源分配的最小单位 CPU调度的最小单位
资源 拥有独立的内存空间 共享进程的内存空间
创建开销
通信 复杂,需要IPC(进程间通信) 简单,直接读写共享变量
健壮性 高,一个进程崩溃不影响其他进程 低,一个线程崩溃可能导致整个进程崩溃
关系 一个进程至少包含一个线程 线程必须存在于进程之内

2. C++多线程实战:std::thread

C++11 的 <thread> 库是我们的主要工具。让我们一步步拆解它。

创建线程的步骤 (Step-by-Step)

  1. 包含头文件: #include <thread>
  2. 定义任务: 准备一个函数,这个函数体就是你希望新线程去做的事情。它可以是普通函数、Lambda 表达式或一个类的成员函数。
  3. 创建std::thread对象: 实例化一个 std::thread 对象,将任务函数和其所需的参数传递给它。线程在此时立即被创建并开始执行
  4. 管理线程生命周期: 主线程必须决定如何与新线程交互。
    • 等待 (Join): 调用 t.join()。主线程会暂停在这里,直到名为 t 的子线程执行完毕。这是最常见的方式,可以确保子线程的工作成果被安全回收。
    • 分离 (Detach): 调用 t.detach()。主线程将不再与子线程保持任何关系,子线程会在后台独立运行。当子线程结束后,其资源由系统自动回收。这是一种“放养”模式,但要小心,如果主程序退出,分离的线程也会被粗暴终止。

深入解析 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::ref

using 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,但很可能是-600
    return 0;
}

问题分析t1t2都检查到余额1000 >= 800,于是都执行了减法操作,导致余额被减了两次!

互斥锁 std::mutex:一次只许一人通过

为了解决这个问题,我们需要一把“锁”,在访问共享资源(balance)时,先锁上,用完再解开。这块被锁保护的代码区域称为临界区 (Critical Section)

使用互斥锁 (std::mutex) 的标准步骤

  1. #1 包含头文件: #include <mutex>
  2. #2 创建互斥锁实例: std::mutex mtx;
    • 这个锁对象本身必须是被多个线程共享的。通常定义为全局变量,或类的成员变量。
  3. #3 使用std::lock_guard上锁: 在需要保护共享数据的代码块(临界区)的开头,创建一个 std::lock_guard 对象。
    • std::lock_guard<std::mutex> guard(mtx);
    • 原理lock_guard 在其构造函数中自动调用 mtx.lock()
  4. #4 编写临界区代码: 在 lock_guard 的作用域内,安全地访问共享资源。
  5. #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,它在构造时自动锁定 mtx
    std::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. #1 包含头文件: #include <unistd.h> (提供 fork) 和 #include <sys/wait.h> (提供 wait)。
  2. #2 调用fork(): pid_t pid = fork();
  3. #3 检查fork()返回值: 这是最关键的一步,用 if-else if-else 结构来区分父子进程。
    • pid < 0: fork() 调用失败,没有创建新进程。
    • pid == 0: 当前代码正在子进程中执行
    • pid > 0: 当前代码仍在父进程中执行,并且 pid 的值是刚刚创建的子进程的ID。
  4. #4 编写父子进程的逻辑: 在对应的 if 分支里,编写各自需要执行的任务。
  5. #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 变量的地址。子进程的退出状态信息会被写入这个地址。如果你不关心,直接传 NULLnullptr 即可。
    • 返回值: 返回结束的那个子进程的ID。

带注释的 fork() 示例:

#include <iostream>
#include <unistd.h>   // Step 1
#include <sys/wait.h> // Step 1
using namespace std;

int main() {
    // Step 2: 调用 fork
    pid_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号进程(initsystemd),由它来负责回收。

5. 对比总结:何时用多线程?何时用多进程?

对比维度 多线程 (Multithreading) 多进程 (Multiprocessing)
目标 并发 (Concurrency):让多个任务“看起来”同时执行,提高响应速度。 并行 (Parallelism):让多个任务真正同时在不同CPU核心上执行,提高计算吞吐量。
适用场景 I/O密集型任务 (如网络请求、文件读写)。当一个线程因等待I/O而阻塞时,其他线程可以继续执行,保持CPU繁忙,程序不卡顿。 CPU密集型任务 (如科学计算、视频编码、大规模数据处理)。利用多核CPU实现真正的并行计算,缩短总计算时间。
优点 创建快,上下文切换快,数据共享方便。 稳定、安全,一个进程的错误不影响其他进程。
缺点 线程安全问题复杂(需要加锁),一个线程的崩溃可能导致整个应用完蛋。 创建慢,上下文切换慢,进程间通信(IPC)复杂且有开销。

简单决策指南:

  1. 任务之间需要大量、频繁地共享数据吗? -> 首选多线程
  2. 任务是计算密集型,想充分利用多核CPU算力? -> 多进程是更好的选择,避免了锁的开销和复杂性。
  3. 任务是I/O密集型,需要同时处理多个网络连接或文件操作? -> 多线程非常适合,可以高效地处理等待。
  4. 程序稳定性要求极高,一个模块的失败绝不能影响核心服务? -> 多进程架构更健壮。

✍️ DAY 10 终章作业

任务:多线程求和

实现一个程序,它将一个包含大量数字的 vector 分成N个部分,然后创建N个线程,每个线程负责计算其中一部分的和。最后,主线程将所有线程计算出的部分和累加起来,得到最终的总和。

  1. 创建一个包含1000万个整数的 vector,所有元素都为1。
  2. 编写一个求和函数 void partial_sum(const vector<int>& vec, size_t start, size_t end, long long& result),它计算vecstartend-1索引范围内的元素和,并将结果存入result中。
  3. main 函数中,确定要创建的线程数(例如,4个)。
  4. vector 均等地分割给每个线程。
  5. 创建并启动这些线程,让它们并行计算各自区段的和。
  6. 思考:多个线程计算出的部分和,最后要汇总到一个总和里。这是否需要同步?(提示:如果每个线程写到不同的变量里,最后由主线程汇总,则不需要。但如果所有线程都累加到同一个全局变量,就需要!)
  7. 等待所有线程完成后,由主线程将所有部分和加起来,并打印最终结果。验证结果是否为1000万。

🚀 DAY 10 作业答案与解析:多线程求和

文件:homework_final.cpp

#include <iostream>
#include <vector>
#include <thread>
#include <numeric> // for std::accumulate (可以用来验证)
#include <chrono>  // for timing

using 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,所有元素都为1
    const 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

深度解析

  1. 线程安全的实现方式 (关键点)

    • 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 互斥锁! 这是最高效、最优雅的并发模式——无锁并发。
  2. 思考题的解答:如果所有线程都累加到同一个全局变量会怎样?

    • 假设我们这么设计:
      // 错误的设计!
      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] 这个操作不是原子的。它至少包含三步:
      1. 读 (Read): 从内存读取 global_total_sum 的当前值到 CPU 寄存器。
      2. 改 (Modify): 在寄存器中执行加法。
      3. 写 (Write): 将寄存器中的新值写回内存。
    • 如果两个线程同时执行,可能会发生:
      • 线程 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万。
    • 如何修复? 必须用互斥锁保护 += 操作:
      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++的世界浩瀚无垠。继续实践,不断学习,去构建真正伟大的软件吧!

点个赞和关注,更多知识包你进步,谢谢!!!你的支持就是我更新的最大动力


网站公告

今日签到

点亮在社区的每一天
去签到