c++_学习笔记【02】——多线程

一:前置知识储备

1 学习多线程有关的c++基础

1.1函数对象

1.1.1 介绍

​ 函数对象(Functor)是重载了() 运算符的类/结构体。也叫仿函数——它本质是一个对象,但是可以像普通函数一样被调用(通过 obj()obj(args))。

特点:

1 兼具对象和函数的特性:可以存储状态(类的成员变量),这是普通函数做不到的。

2 可调用性:语法上和普通函数、lambda 表达式、函数指针统一,属于 C++ 的 “可调用对象” 范畴。

3 性能优势:编译器可内敛优化,比含函数针更高效(无直接调用开销)。

1.1.2 函数对象应用
#include<iostream>
#include<vector>
#include<algorithm>

struct Functor01
{
	int sum = 0; // 成员变量:存储累加状态
	void operator()(int num)	// 重载()运算符:编译期确定的成员函数
	{
		sum += num;
	}
};

int main()
{
	std::vector<int> nums = { 1,2,3,4,5 };
	Functor01 f1_;
	// 编译期确定调用Functor01::operator(),直接内联函数体
	f1_(10);
	std::cout << "f1_.sum=" << f1_.sum << std::endl;
	//template <class InputIt, class UnaryFunction>
	//UnaryFunction for_each(InputIt first, InputIt last, UnaryFunction f);
	f1_ = std::for_each(nums.begin(), nums.end(), f1_);
	std::cout << "f1_.sum=" << f1_.sum << std::endl;

	Functor01 f2_; //结果独立
	f2_(3);
	std::cout << "f2_.sum=" << f2_.sum << std::endl;
}

运行结果:

在这里插入图片描述

1.1.3 函数对象在多线程中应用
#include<iostream>
#include<thread>
#include<mutex>
// 带状态的仿函数:线程安全的累加器
struct Functor01 {
	int sum = 0;
	std::mutex mtx; // 互斥锁,保护sum的读写
	void operator()(int num)
	{
		std::lock_guard<std::mutex> lock(mtx);
		sum += num;
		std::cout << "线程ID:" << std::this_thread::get_id()
			<< ",当前sum=" << sum << std::endl;
	}
};

int main()
{
	Functor01 fun_;
	// 传引用!否则线程会拷贝fun_,修改的是副本
	std::thread thread01(std::ref(fun_), 3);
	std::thread thread02(std::ref(fun_), 30);
	std::thread thread03(std::ref(fun_), 300);

	thread01.join();
	thread02.join();
	thread03.join();

	std::cout << "多线程累加后sum=" << fun_.sum << std::endl;
	return 0;
}

运行结果:

在这里插入图片描述

1.2 lambda表达式

1.2.1 介绍

​ Lambda表达式是C++11引入的匿名函数,是C++ 多线程编程中最常用的可调用对象,也是std::thread/std::async等工具的首选入口逻辑。

特点:

1 作为线程入口函数std::thread的构造函数接收任意 “可调用对象”,Lambda 因无需提前定义函数 / 函数对象,成为多线程中定义线程逻辑的最优选择 —— 尤其适合简单、一次性的线程任务。

2 Lambda 在多线程中的核心优势

  • 就地定义逻辑:线程要执行的逻辑直接写在 Lambda 里,代码可读性更高(无需跳转到其他函数);
  • 灵活捕获上下文:可直接捕获外部变量(如共享数据、配置参数),无需通过全局变量 / 指针传参(减少错误);
  • 语法简洁:相比函数对象,无需定义类、重载()运算符,代码量大幅减少。

语法:

[capture](parameters) mutable -> return_type {
    // 函数体
}

各部分说明:

作用
[capture] 捕获列表:指定从外部作用域捕获哪些变量(值 / 引用)到 lambda 内部使用
(parameters) 参数列表:和普通函数的参数一致(可省略,无参数时可空)
mutable 可选:允许修改按值捕获的变量(默认按值捕获的变量是 const 的)
-> return_type 可选:显式指定返回类型(编译器通常能自动推导,可省略)
{} 函数体:具体执行逻辑

常见捕获方式

捕获语法 含义
[] 空捕获:不捕获任何外部变量
[=] 按值捕获:捕获所有外部变量(副本)
[&] 按引用捕获:捕获所有外部变量(引用)
[x, &y] 按值捕获 x,按引用捕获 y
[this] 捕获当前类的 this 指针(类成员函数中)
1.2.2 注意事项

多线程中使用 Lambda 的关键注意事项

  1. 捕获方式的选择

    • 按值捕获([=]/[x]):Lambda 内部是变量的副本,线程间互不影响(无需加锁),但无法修改外部变量;
    • 按引用捕获([&]/[&x]):Lambda 内部直接操作外部变量,必须加锁保护共享变量(避免数据竞争);
    • 禁止捕获局部变量的引用后,线程脱离变量生命周期(如主线程的局部变量被子线程引用,主线程先退出会导致野引用)。
  2. 避免隐式捕获的坑

    • 尽量避免[&](捕获所有引用),显式指定需要捕获的变量(如[&num, &mtx]),减少误捕获导致的问题;
    • 类成员函数中,Lambda 捕获this指针后,需确保对象生命周期长于线程(避免对象销毁后线程访问this)。
  3. mutable 的使用

    std::thread t([x]() mutable {
        x += 5; // 加mutable后可修改副本
        std::cout << x << std::endl;
    });
    
1.2.3 结合多线程基础用法

无捕获的 Lambda 作为线程入口

#include<iostream>
#include<thread>

int main()
{
	std::thread thread01([] {
		int num = 10;
		std::cout << "线程ID" << std::this_thread::get_id() << ",10的立方"
			<< num * num * num << std::endl;
		});
	thread01.join();
	return 0;
}

运行结果:

在这里插入图片描述

1.2.4 结合多线程捕获外部变量

捕获外部变量(按值 / 按引用)

#include<iostream>
#include<mutex>
#include<thread>

int main()
{
	int share_sum = 0;
	std::mutex mtx;

  	//按引用捕获shared_num和mtx,累加10
	std::thread thread01([&share_sum, &mtx]() {
		std::lock_guard<std::mutex> lock(mtx);
		share_sum += 10;
		std::cout << "线程ID:" << std::this_thread::get_id() << " share_sum="
			<< share_sum << std::endl;
		});

	std::thread thread02([&]() { // [&]捕获所有外部变量(引用)
		std::lock_guard<std::mutex> lock(mtx);
		share_sum += 20;
		std::cout << "线程ID:" << std::this_thread::get_id() << " share_sum="
			<< share_sum << std::endl;
		});

	thread01.join();
	thread02.join();
	return 0;
}

运行结果:

在这里插入图片描述

1.2.5 结合 std::async

结合 std::async(异步获取结果)

std::async 是 C++ 标准库提供的异步任务封装器,底层本质是对 std::thread + 同步原语(互斥锁、条件变量) + 异常处理的封装,核心优势是 “简化线程管理”。

注意:

fut.get()阻塞式调用 ,主线程可以在异步任务执行期间做自己的事,直到需要结果时才调用fut.get() 阻塞等待。

在看下面代码前进行概念补充

wait_for() 非阻塞检查任务状态的三种方法

  • std::future_status::ready:任务已完成,可安全调用 get()

  • std::future_status::timeout:任务未完成,主线程继续执行(输出日志);

  • std::future_status::deferred:任务延迟执行(本示例显式指定 async 策略,不会触发)。

    代码:

#include<iostream>
#include<thread>
#include<future>
#include<chrono>

int cala_sum(int a, int b)
{
	std::this_thread::sleep_for(std::chrono::seconds(2));// 模拟耗时2秒
	return a, b;
}

int main()
{
	std::future<int> fut = std::async(cala_sum, 10, 30); 
	int result;
	while (true)
	{
		// 非阻塞检查:等待200毫秒,返回任务状态
		std::future_status status = fut.wait_for(std::chrono::milliseconds(200));
		if (status == std::future_status::ready)// 任务已完成
		{
			result = fut.get();
			break;
		}
		else if (status == std::future_status::timeout)// 任务未完成,超时
		{
			std::cout << "主线程继续运行....." << std::endl;
		}
		else if(status ==  std::future_status::deferred)// 任务延迟执行(本示例不会触发)
		{
			std::cout << "任务延迟执行,立即调用get()..." << std::endl;  // 任务延迟执行(本示例不会触发)
			result = fut.get();  // 触发任务执行
			break;
		}
	}
	std::cout << "异步任务结束:结果=" << result << std::endl;
	return 0;
	
}

运行结果

在这里插入图片描述

在这里插入图片描述

​ 在任务完成时候结果就存入共享区域,当调用.get() 时候就会发生阻塞获取结果,如果该任务未完成则持续阻塞。

1.3 智能指针

1.3.1 介绍

​ C++智能指针是封装了原始指针的的类模板,核心目标是是解决手动释放内存的痛点,例如:内存泄漏、野指针、重复释放等问题。其底层依赖**RAII(资源获取即初始化)**机制,智能指针在对象的生命周期结束(析构)的时候自动释放所管理的内存,无需手动delete .

​ C++11 提供了三种核心智能指针,其中 std::unique_ptrstd::shared_ptr 是最常用的:

智能指针类型 核心特性
std::unique_ptr 独占所有权:同一时间只有一个 unique_ptr 指向对象,不可拷贝(可移动)。
std::shared_ptr 共享所有权:多个 shared_ptr 可指向同一对象,通过引用计数管理生命周期(计数为 0 时释放对象)。
std::weak_ptr 弱引用:配合 shared_ptr 使用,不增加引用计数,解决循环引用问题(补充提及,因和 shared_ptr 强相关)。
1.3.2 unique_ptr

特点:

  • 独占资源,不可拷贝(避免多个指针共同管理统一资源),但是可以通过std::move() 转移所有权

  • 析构时自动释放所指向的内存,适合“一对一”管理资源的方式

  • 支持指向数组(std::unique_ptr<int[]>)。

    代码:

#include<iostream>
#include<memory>

class Test {
private:
	int id_;
public:
	Test(int id):id_(id)
	{
		std::cout << "构造函数,id=" << id_ <<std::endl;
	}
	~Test(){
		std::cout << "析构函数,id=" << id_ << std::endl;
	}

	void printf()
	{
		std::cout << "id=" << id_ << std::endl;
	}
};

int main()
{
	// 1. 创建unique_ptr,独占管理Test对象
	std::unique_ptr<Test> ptr1 = std::make_unique<Test>(1);
	ptr1->printf();// 调用成员函数,语法和原始指针一致

	// 2. 不可拷贝(编译错误)
	// std::unique_ptr<Test> uptr2 = uptr1;

	// 3. 移动所有权(通过std::move)
	std::unique_ptr<Test> ptr2 = std::move(ptr1);
	if (!ptr1)
	{
		std::cout << "ptr1为空" << std::endl;
	}
	ptr2->printf();
	return 0;
}

运行结果:

在这里插入图片描述

1.3.3 share_ptr

特点:

  • 共享所有权:多个share_ptr可以指向同一个对象,内部维护用计数器.use_count()
  • 拷贝时计数器+1,析构时计数器-1,计数器为0时,自动释放对象

代码(共享所有权):

#include<iostream>
#include<memory>
#include<chrono>

class Test {
private:
	int id_;
public:
	Test(int id) :id_(id)
	{
		std::cout << "构造函数,id=" << id_ << std::endl;
	}
	~Test() {
		std::cout << "析构函数,id=" << id_ << std::endl;
	}

	void printf()
	{
		std::cout << "id=" << id_ << std::endl;
	}
};

int main()
{
	std::shared_ptr<Test> ptr3 = std::make_shared<Test>(3);
	std::cout << "引用计数ptr3:" << ptr3.use_count() << std::endl;
	std::shared_ptr<Test> ptr4 = ptr3;
	std::cout << "引用计数ptr3:" << ptr3.use_count() << std::endl;
	std::cout << "引用计数ptr4:" << ptr4.use_count() << std::endl;

	ptr3->printf();
	ptr4->printf();

	ptr3.reset();
	/*reset() 还有重载版本,可用于 “重新绑定资源”
	让ptr3放弃原有资源,重新指向新的Test(4)
	ptr3.reset(new Test(4));*/
	std::cout << "引用计数ptr3:" << ptr3.use_count() << std::endl;
	std::cout << "引用计数ptr4:" << ptr4.use_count() << std::endl;
	return 0;
}

运行结果:

在这里插入图片描述

1.3.4 weak_ptr

特点:

shared_ptr 的循环引用会导致计数器无法归0,内存泄漏。weak_ptr 是弱引用,不增加计数,可解决该问题。

代码:

#include<iostream>
#include<memory>

class B;
class A {
public:
	std::weak_ptr<B> b_ptr;
	~A() { std::cout << "A 析构" << std::endl; }
};
class B {
public:
	std::weak_ptr<A> a_ptr;
	~B() { std::cout << "B 析构" << std::endl; }
};

int main()
{
	std::shared_ptr<A> a = std::make_shared<A>();
	std::shared_ptr<B> b = std::make_shared<B>();
	a->b_ptr = b;
	b->a_ptr = a;
	return 0;
}

运行结果:

在这里插入图片描述

1.3.5 智能指针和多线程的关系

​ 智能指针本身不是"线程安全"的,但是它是多线程编程中管理共享资源的工具——解决了多线程下内存管理的问题,同时需要界限

规则:

  • share_ptr 的引用计数器是线程安全的:多个线程同事拷贝/析构同一个share_ptr ,引用计数器的增减是原子操作的(无需加锁)
  • sharea_ptr 指向的对象不是线程安全的:多个线程读写线程本身,仍然需要加锁
  • unique_ptr 无限程安全保障:因为独占所有权,通常不会多个线程访问同一个unique_ptr,如果转移所有权需要用std::move()并加锁
  • 单个sharea_ptr 对象的修改也不是线程安全的:比如线程 A 修改 sptr = nullptr,线程 B 同时读取 sptr,结果未定义(需加锁)。

注意事项:

  • 避免裸指针混用:多线程中不要同时使用智能指针和原始指针管理同一对象(比如 shared_ptr 管理的对象,又被裸指针 delete,导致重复释放);
  • make_shared 优先于 newmake_shared 一次性分配对象内存和引用计数内存,更高效,且避免内存泄漏(比如 new 后抛出异常,智能指针未创建);
  • shared_ptr 循环引用:多线程中若两个对象互相持有 shared_ptr,会导致内存泄漏,需用 weak_ptr 打破循环;
  • 线程间传递 unique_ptr:只能通过 std::move 转移所有权,且需加锁保证队列 / 容器的线程安全;
  • 不要捕获悬空的智能指针:Lambda 中捕获 shared_ptr 而非原始指针,避免对象销毁后线程访问野指针
1.3.6 生产者消费者问题
#include<iostream>
#include<memory>
#include<condition_variable>
#include<thread>
#include<chrono>
#include<mutex>
#include<queue>

std::mutex print_mtx;
template<typename T>
class ProducerConsumer {
private:
	std::queue<T> buffer_;
	std::size_t max_buffer_size_;
	std::condition_variable cv_producer_;
	std::condition_variable cv_consumer_;
	std::mutex mtx_;
	std::atomic<bool> is_running_;
public:
	ProducerConsumer(size_t num) :max_buffer_size_(num),is_running_(true) {}
	~ProducerConsumer()
	{
		stop();
	}
	void producer(const T& data, size_t producer_id)
	{
		std::unique_lock<std::mutex> lock_(mtx_);
		// 等待条件:停止运行 或 缓冲区未满
		cv_producer_.wait(lock_, [this]() {
			return !is_running_ || buffer_.size() < max_buffer_size_;
			});
		if (!is_running_) return;
		buffer_.push(data);
		{
			std::lock_guard<std::mutex> lock_print(print_mtx);
			std::cout << "[生产者 " << producer_id << "]  生产数据:"
				<< data << "  目前缓冲区大小:" << buffer_.size() << std::endl;
		}
		cv_consumer_.notify_one();
	}

	bool consumer(T& data, size_t consumer_id)
	{
		std::unique_lock<std::mutex> lock_(mtx_);
		// 等待条件:停止运行 或 缓冲区非空
		cv_consumer_.wait(lock_,[this]() {
			return !is_running_ || !buffer_.empty();
			});
		if (!is_running_ && buffer_.empty()) return false;
		data = buffer_.front();
		buffer_.pop();
		{
			std::lock_guard<std::mutex> lock_print(print_mtx);
			std::cout << "------[消费者 " << consumer_id << "]  消费数据:"
				<< data << "  目前缓冲区大小:" << buffer_.size() << std::endl;
		}
		cv_producer_.notify_one();
		return true;
	}

	void stop()
	{
		is_running_ = false;
		// 唤醒所有阻塞的生产者/消费者,避免线程卡死
		cv_consumer_.notify_all();
		cv_producer_.notify_all();
	}

};
// 生产者线程函数
void producer_test(ProducerConsumer<int> *pc , size_t producer_id )
{
	for (int i = 0;i < 3;i++)
	{
		int data = producer_id * 100 + i;
		pc->producer(data, producer_id);
      	 // 模拟生产耗时
		std::this_thread::sleep_for(std::chrono::milliseconds(500));
	}
}
// 消费者线程函数
void consumer_test(ProducerConsumer<int>* pc, size_t consumer_id)
{
	int data;
	while (true)
	{
		if (!pc->consumer(data, consumer_id)) break;
       	// 模拟生产耗时
		std::this_thread::sleep_for(std::chrono::milliseconds(800));
	}
	{
		std::lock_guard<std::mutex> lock_print(print_mtx);
		std::cout << "消费者 " << consumer_id << " 退出! " << std::endl;
	}
}
int main()
{
	ProducerConsumer<int> pc(3);
	std::thread pro_thread01(producer_test, &pc, 1);
	std::thread pro_thread02(producer_test, &pc, 2);
	std::thread pro_thread03(producer_test, &pc, 3);

	std::thread con_thread01(consumer_test, &pc, 1);
	std::thread con_thread02(consumer_test, &pc, 2);

	pro_thread01.join();
	pro_thread02.join();
	pro_thread03.join();

	{
		std::lock_guard<std::mutex> lock_print(print_mtx);
		std::cout << "所有生产者完成生产,等待消费者处理剩余数据..." << std::endl;
	}
	
	pc.stop();

	con_thread01.join();
	con_thread02.join();

	{
		std::lock_guard<std::mutex> lock_print(print_mtx);
		std::cout << "所有线程执行结束!" << std::endl;
	}
	
	return 0;
}

运行结果:

在这里插入图片描述

1.4 右值引用与移动语义

1.4.1 介绍

在前面的例子中已经用到移动语义,如果您读到这里应该了解其作用和用法,就不在过多介绍,下面强调注意事项和重点回忆复习:

关于概念您可参考我以前的文章:

https://blog.csdn.net/weixin_50214044/article/details/154123604?spm=1001.2014.3001.5501

左值(lvalue)右值(rvalue) —— 这是 C++11 引入移动语义的基础:

类型 核心特征 示例
左值 有名称、可取地址、生命周期较长(可在多条语句中使用) int a = 10; 中的 a
右值 无名称、不可取地址、生命周期短暂(仅在当前表达式有效) 10a+b、函数返回的临时对象

右值引用(&& :专门绑定到右值的引用类型,语法为 T&&(区别于左值引用 T&);

移动语义: 传统的拷贝语义(拷贝构造函数/拷贝赋值运算符)会深拷贝对象的资源(如堆内存、文件句柄),开销大;移动语义对于临时对象(右值),无需拷贝资源,直接 “偷” 走其资源(转移所有权),原对象变为 “空壳”,大幅减少内存拷贝和析构开销;通过移动构造函数移动赋值运算符(参数为右值引用 T&&)实现。

std::move() 将左值强制转换为右值引用(仅做类型转换,不移动任何数据),让左值也能触发移动语义;std::move 后,原对象的资源已被转移,禁止再使用原对象(除非重新赋值)。

注意事项:

  • 资源唯一性:移动后原对象变为 “空壳”,禁止多线程同时访问原对象(否则会访问空资源,导致崩溃);

  • 线程安全的移动:移动操作本身不是线程安全的 —— 若多个线程同时对一个对象执行std::move或访问,需加锁保护;

  • 配合智能指针std::unique_ptr是移动语义的典型应用(不可拷贝,只能移动),多线程中传递unique_ptr必须用std::move

  • 避免移动常量const对象无法触发移动语义(会退化为拷贝),因此移动的对象需是非const的;

  • noexcept 关键字:移动构造 / 赋值需加noexcept—— 标准容器(如std::vector)在扩容时,若移动操作不抛出异常,才会使用移动而非拷贝,多线程中更稳定。

    代码:(重写string函数)

    #include<iostream>
    #include<cstring>
    #include<utility>
    using namespace std;
    
    class MyString {
    private:
    	char* _data;
    	size_t _size;
    	size_t _capacity;
    
    	void allocateAndCopy(const char* str, size_t len) {
    		_size = len;
    		_capacity = len + 1;
    		_data = new char[_capacity];
    		strcpy_s(_data,_capacity, str);
    	}
    public:
    	//默认构造函数
    	MyString(const char* str = "") {
    		size_t len = strlen(str);
    		allocateAndCopy(str, len);
    		cout << "默认构造函数:" << _data << endl;
    	}
    	//拷贝构造函数
    	MyString(const MyString& other) {
    		allocateAndCopy(other._data, other._size);
    		cout << "拷贝构造函数:" << _data << endl;
    	}
    	//拷贝赋值函数
    	MyString& operator=(const MyString& other) {
    		allocateAndCopy(other._data, other._size);
    		cout << "拷贝赋值函数:"<<_data << endl;
    		return *this;
    	}
    	// 移动构造函数(浅拷贝资源 + 置空原对象,右值引用参数)
    	MyString(MyString&& other) noexcept {
    		_data = other._data;
    		_size = other._size;
    		_capacity = other._capacity;
    
    		other._data = nullptr;
    		other._size = 0;
    		other._capacity = 0;
    		cout << "移动构造函数:" << _data << endl;
    	}
    	//移动赋值运算符
    	MyString& operator=(MyString&& other) noexcept {
    		if (this != &other) {
    			delete[] _data;
    			_data = other._data;
    			_size = other._size;
    			_capacity = other._capacity;
    
    			other._data = nullptr;
    			other._size = 0;
    			other._capacity = 0;
    		}
    		cout << "移动赋值运算符:" << _data << endl;
    		return *this;
    	}
    
    	~MyString() {
    		cout << "析构:" << (_data ? _data : "nullptr" )<< endl;
    		delete[] _data;
    	}
    
    	const char* c_str() const {
    		return _data;
    	}
    
    	size_t size() const {
    		return _size;
    	}
    };
    
    int main() {
    	MyString s1("hello");
    	MyString s2 = s1;
    
    	MyString s3(s1);
    
    	MyString s4("world");
    	s4 = s1;
    
    	MyString s5("temp1");
    	MyString s6 = move(s5);
    
    	MyString s7("temp7");
    	s7 = move(s6);
    }
    

    运行结果:

    在这里插入图片描述

2 操作系统层面的多线程基础

2.1 进程与线程
2.1.1 介绍

进程官方定义进程是操作系统**进行资源分配和调度的最小单位**,是一个正在运行的程序实例

​ 一个可执行文件(比如你的a.out.exe程序),当双击 / 命令行运行后,操作系统会为它分配独立的内存空间、CPU 时间片、文件句柄、网络端口等系统资源,这个运行中的程序实体,就是进程

线程官方定义线程是进程的一个执行单元,是操作系统进行CPU 调度和执行的最小单位,也被称为轻量级进程。

​ 要理解多线程,首先得搞懂进程线程的本质差异 —— 你可以把它们想象成 “工厂” 和 “工人” 的关系

进程:一家独立的工厂,有自己的厂房(内存空间)、设备(资源)、营业执照(PID),工厂之间完全隔离;

开新工厂成本高(需重新建厂房、买设备)。

线程:工厂里的工人 ,共享工厂的所有资源(厂房、设备),多个工人可同时干活;招新工人成本低(无需新建厂房,直接分配任务)。

具体例子:

  • 你打开的微信是一个进程:它占用独立的内存(存聊天记录、界面数据),拥有 CPU、网络、磁盘等资源的使用权限;
  • 微信里 “接收消息”“刷新朋友圈”“播放语音” 这些同时进行的操作,就是微信进程里的多个线程—— 它们共享微信的内存(比如都能访问你的好友列表),不用重复申请资源。
2.1.2 核心区别
进程(Process) 线程(Thread) 关键说明
定义 操作系统分配资源的基本单位 操作系统调度执行的基本单位 进程是 “资源容器”,线程是 “容器里的执行流”;操作系统给进程分配内存、CPU 时间片等资源,线程负责实际执行代码。
内存空间 独立的地址空间(代码段、数据段、堆、栈);进程间内存完全隔离(默认无法直接访问)。 共享所属进程的全部内存空间(代码、数据、堆);仅拥有独立的栈空间(存储局部变量、函数调用信息)。 例:进程 A 的线程无法直接访问进程 B 的内存(需通过 IPC);同一进程的线程可直接读写同一块堆内存(这也是多线程数据竞争的根源)。
资源占用 占用资源多(内存、文件句柄、网络端口等);创建 / 销毁 / 切换成本高。 几乎不占用额外资源(仅需栈空间、线程 ID);创建 / 销毁 / 切换成本极低(是进程的 1/100~1/1000)。 进程切换:需保存整个进程的内存映射、寄存器状态等;线程切换:仅需保存线程的栈指针、寄存器,无需动进程资源。
独立性 高度独立:一个进程崩溃(如闪退),不会影响其他进程;进程间无默认共享数据。 高度依赖:一个线程崩溃(如空指针),会导致整个进程崩溃;线程间默认共享进程资源(易出并发问题)。 例:浏览器的一个标签页(进程)崩溃,其他标签页仍能用;微信的 “语音线程” 崩溃,整个微信都会闪退。
标识(ID) 进程 ID(PID):全局唯一,操作系统统一管理。 线程 ID(TID):仅在所属进程内唯一;部分系统(如 Linux)TID 全局可见,但核心管理归属进程。 你在任务管理器里看到的 “PID” 是进程 ID,线程 ID 需通过工具(如 Process Explorer)查看。
通信方式 复杂且低效:需通过 IPC(管道、消息队列、共享内存、套接字等)。 简单且高效:直接读写共享内存(全局变量、堆变量);也可通过进程内的同步工具(互斥锁、条件变量)。 例:两个进程通信需写代码实现 “共享内存映射”;同一进程的线程只需定义一个全局变量,所有线程都能访问。
调度与执行 调度粒度粗:操作系统以进程为单位分配 CPU 时间片,再由进程分配给内部线程。 调度粒度细:操作系统直接调度线程,多个线程可在多个 CPU 核心上并行执行。 多进程:CPU 核心可能分配给不同进程;多线程:同一进程的多个线程可同时占用多个 CPU 核心(真正并行)。
创建方式 由操作系统创建(如 fork ()、CreateProcess ());需复制父进程的资源(或全新分配)。 由进程创建(如 pthread_create ()、std::thread);仅需分配线程栈和 TID,共享进程资源。 新建进程的时间:毫秒级;新建线程的时间:微秒级。
2.1.3 避坑点
  1. 线程共享资源是优势,也是最大的风险

    优势:线程间通信成本极低,无需复杂的IPC,合适频繁数据交互的场景(如微信同时处理消息);

    风险:多个线程之间共享资源(如全局变量和堆内存),会导致数据竞争(解决:互斥锁,原子操作)。

  2. 线程的“独立栈”和“共享堆”

    新手常误认为线程的所有数据都是共享的,但实际上:

    • 栈空间:每个线程有独立的栈(默认几 MB),栈上的局部变量是线程私有的,不会有数据竞争;

      例:线程 A 的函数里定义int a = 10;,线程 B 的函数里也定义int a = 20;,两者互不影响。

    • 堆空间 / 全局变量:进程内所有线程共享,这是需要同步的核心区域;

      例:进程里定义int g_num = 0;,线程 A 执行g_num++,线程 B 也执行g_num++,可能导致 g_num 最终不是 2(数据竞争)。

  3. 并行和并发的区别

    并发:多个任务 “交替执行”(如单核 CPU 上,进程 / 线程轮流占用 CPU,宏观看起来同时运行);

    并行:多个任务 “同时执行”(如多核 CPU 上,多个线程分别在不同核心上运行);

  4. 线程切换的 “隐形开销”

    虽然线程切换比进程快,但频繁切换(如数千个线程同时运行)仍会有开销:

    • 操作系统需要保存 / 恢复线程的寄存器、栈指针等状态;

    • 核心数有限时,过多线程会导致 “上下文切换风暴”,反而降低性能;

      → **解决:**CPU 密集型任务的线程数≈CPU 核心数;IO 密集型任务可适当增加线程数,但不宜过多。

  5. 进程和线程的 终止关系

    • 进程是 “父”,线程是 “子”:进程退出时,操作系统会强制终止其所有线程;
    • 线程无法脱离进程存在:线程必须属于某个进程,没有 “独立的线程”;
    • 父进程退出后,子进程可能变成 “僵尸进程”(需手动回收),而线程退出后资源会被进程自动回收(除非是分离线程)。
2.2 线程的生命周期
2.2.1 介绍

​ 线程完整的生命周期包括:新建、就绪、运行、阻塞、终止

在这里插入图片描述

**新建态:**通过代码 / 系统调用,成功创建了线程对象,但是线程还没有被操作系统「纳入调度队列」,线程此时是「空壳」,不会执行任何代码。

**就绪态:**线程已经被操作系统「纳入调度队列」,线程具备了所有运行的条件,只差 CPU 的时间片

运行态: 线程从「就绪队列」中被操作系统选中,成功抢到了 CPU 的时间片,开始执行线程的核心代码逻辑,这是线程生命周期中「唯一执行代码」的状态。

阻塞态: 线程在运行过程中,因为遇到了「无法继续执行」的条件,主动向操作系统申请「暂停执行」,操作系统会把这个线程从「运行态」切换到「阻塞态」,并立刻让出 CPU 时间片,分配给其他就绪态的线程。

注意: 阻塞态的线程,会被移出「就绪队列」,失去排队抢时间片的资格!哪怕 CPU 空闲,阻塞的线程也不会被执行。

可能的阻塞:

​ 1.主动休眠:C++ 代码调用std::this_thread::sleep_for()/sleep_until()

​ 2.主动让步:C++ 代码调用std::this_thread::yield(),线程主动让出 CPU,回到就绪态(yield 是「放弃本次时间片」,不是阻塞,是运行→就绪,不是运行→阻塞);)

​ 3.等待锁资源:线程执行到std::mutex::lock()时,发现锁已经被其他线程占用,这个线程会进入阻塞态,直到锁被释放;

​ 4.等待条件变量:线程执行到std::condition_variable::wait()时,会释放锁 + 进入阻塞态,直到被notify_one()/notify_all()唤醒;

​ 5.等待 IO 操作:线程执行磁盘读写、网络请求、控制台输入等,因为 IO 速度远慢于 CPU,线程会阻塞直到 IO 完成;

​ 6.等待其他线程:C++ 主线程调用t.join()时,主线程会进入阻塞态,直到子线程执行完毕。

终止态: 线程的核心代码逻辑执行完毕,或者线程被主动终止 / 异常终止,线程的生命周期彻底结束,进入终止态,这是线程的「最终状态」,状态不可逆,也无法恢复。

2.2.2 补充点
  1. C++ 的 join () 和 detach ()

    std::thread::join () - 主线程「阻塞等待 + 回收子线程」:调用join()主线程,会立刻从「运行态」→「阻塞态」,直到子线程执行完毕进入「终止态」;操作系统会自动回收子线程的所有资源,子线程不会变成僵尸线程;

    std::thread::detach () - 主线程「分离子线程,不回收」:调用detach()后,子线程和主线程彻底脱离关系,子线程的生命周期不再受主线程控制;之后没有任何线程回收它的资源,会变成「僵尸线程」,僵尸线程会占用系统的线程 ID、内核资源,长期运行会导致系统资源耗尽,程序崩溃;

  2. 阻塞的核心价值

    ​ 阻塞和核心价值是不浪费 CPU,所有需要「等待某个条件满足」的场景,一定要用阻塞(sleep/wait/lock),绝对不要用忙等

  3. CPU 的「时间片」机制

    ​ 现代操作系统都是多任务操作系统,哪怕你的电脑是单核 CPU,也能同时开微信、浏览器、IDE,核心原理就是:CPU 会给每个线程分配「时间片」(极短,一般几毫秒)

2.3 线程的上下文切换
2.3.1 介绍

线程上下文切换 :操作系统内核,在暂停一个正在运行的线程、调度另一个就绪态线程占用 CPU的过程中,所执行的「保存当前线程上下文 → 加载新线程上下文」的完整操作**,是理解多线程性能、多线程开销、多线程调优的根基。**

首先:线程上下文切换是「有开销的」,这是多线程的最大代价之一,我们写 C++ 多线程代码的核心优化思路,本质就是「减少不必要的上下文切换」。

2.3.2 理解上下文切换的基础
  1. CPU 的「单核串行、多核并行」本质:

    CPU 的核心执行逻辑是:一个 CPU 核心,在同一个时刻,只能执行「一个线程」的代码

    • 单核 CPU:所有线程「轮流执行」,靠上下文切换实现「宏观并发」(看起来同时运行),微观上还是串行;
    • 多核 CPU:多个线程可以「同时执行」(并行),每个核心跑一个线程,但核心数永远有限(比如 8 核 16 线程),当线程数>核心数时,依然需要上下文切换。
  2. CPU 的「时间片调度机制」 上面介绍过

  3. 线程的「运行依赖 CPU 寄存器」

    ​ CPU 执行线程代码时,不会把线程的所有数据都读到内存里,而是会把线程的「核心运行数据」存到 CPU 的「寄存器」中(寄存器是 CPU 内部的高速存储,速度比内存快百倍)。

    比如:线程执行到哪一行代码、当前的变量值、函数调用的栈地址、堆内存的指针等,全部存在寄存器里。

2.3.3 什么是线程上下文

线程上下文 = 线程的「运行状态全集」,就是一个线程能「随时暂停、随时恢复执行」所需要的所有核心数据。这些数据是线程运行的「全部家底」,缺了任何一点,线程暂停后就再也恢复不了执行。

​ 完整的线程的上下文分为==「线程私有」和「CPU 专属」==,全部由操作系统内核维护,这两部分合起来,就是线程的完整上下文

1. 线程自身的私有数据(线程创建时就分配,和线程绑定)

  • 线程的独立栈空间:存储线程的局部变量、函数调用栈、返回地址等;

  • 线程的线程 ID(TID)、优先级、所属进程 ID(PID);

  • 线程的状态标记:就绪 / 运行 / 阻塞 / 终止。

    2. CPU 寄存器中的核心数据(线程运行时临时存入,CPU 专属)

    ​ 这是上下文的核心核心,也是上下文切换的「主要开销来源」,CPU 执行哪个线程,寄存器里就存哪个线程的这些数据:

  • 程序计数器(PC):记录线程当前执行到代码的哪一行(下一条要执行的指令地址);

  • 栈指针寄存器:记录线程栈空间的当前位置;

  • 通用寄存器:记录线程运行时的变量值、运算结果等临时数据;

  • 状态寄存器:记录线程的运行状态(比如是否执行了加法 / 减法、是否有异常)。

2.3.4 线程上下文切换的步骤

整个上下文切换的步骤是由操作系统内核自动完成的,程序员(包括 C++ 代码)完全无法干预,我们写的std::threadsleep_foryield等代码,只是「触发」上下文切换,而不是「执行」切换。下面是具体的步骤:

在这里插入图片描述

2.3.5 上下文切换触发的场景

场景1: 线程的时间片用完(被动切换)

​ 线程正常运行,时间片耗尽,操作系统强制切换,线程从「运行态」→「就绪态」,这是最频繁的上下文切换场景,无任何代码触发,纯操作系统调度。

场景2:线程主动放弃CPU(主动切换)

​ 线程在运行中,调用了 C++ 的相关函数,主动让出 CPU,触发上下文切换,例如函数:

  • std::this_thread::sleep_for(xx):线程主动休眠 → 「运行态」→「阻塞态」,触发切换;
  • std::this_thread::yield():线程主动让步 → 「运行态」→「就绪态」,触发切换。

场景3:线程等待资源/条件(主动切换,C++ 多线程核心)

​ C++ 多线程最最最常用的场景,几乎所有带锁、带条件变量的代码都会触发,也是上下文切换的核心场景,例如下面的场景:

  • 线程执行std::mutex::lock(),发现锁被占用 → 线程进入「阻塞态」,触发切换;
  • 线程执行std::condition_variable::wait() → 释放锁 + 进入「阻塞态」,触发切换;
  • 线程等待 IO 操作(磁盘读写、网络请求)、等待控制台输入 → 进入「阻塞态」,触发切换;
  • 主线程执行t.join() → 主线程进入「阻塞态」,触发切换。

场景4:线程优先级抢占(被动切换)

​ 操作系统会给线程分配优先级,高优先级的线程会抢占低优先级线程的 CPU 时间片

场景5:线程正常终止/异常终止

2.3.6 线程和进程上下文切换的区别
对比 线程上下文切换 进程上下文切换 核心差异总结
切换的核心内容 只切换「线程私有上下文」:线程栈、寄存器数据、线程 ID、状态;不切换进程的资源(内存、文件句柄、堆等)。 不仅切换「进程内线程的上下文」,还要切换「进程的全部资源」:进程的地址空间、内存映射表、文件句柄、堆内存、PID 等。 线程切换是「小范围切换」,进程切换是「大范围切换」。
切换开销 极小(微秒级)只需要保存 / 加载线程的寄存器和栈,无额外开销。 极大(毫秒级)需要保存 / 加载进程的所有资源,还要刷新 CPU 的内存缓存,开销是线程的 100~1000 倍 这是「为什么多线程比多进程更高效」的核心原因!
切换频率 极高(每秒上千次),多线程程序的常态。 极低(每秒几次到几十次),多进程程序的切换频率远低于线程。 线程是轻量级的执行单元,就是因为切换成本低。
C++ 中对应场景 所有std::thread的切换都是线程上下文切换。 比如你同时开了 VSCode 和微信,这两个进程的切换就是进程上下文切换。 C++ 多线程开发,本质就是用「低开销的线程切换」实现并发。

还拿工人和工厂的例子:

  • 线程上下文切换 = 同一个工厂里,换一个工人干活:工厂的厂房、设备、原材料都不变,只需要把上一个工人的工具、草稿纸收起来,给下一个工人换上他的工具、草稿纸,立刻就能开工;
  • 进程上下文切换 = 换一个工厂干活:需要把上一个工厂的厂房、设备、原材料全部封存,再打开新工厂的厂房、设备、原材料,才能开工,耗时巨大。
2.3.7 线程上下文切换的开销

通过上文我们知道线程的上下文切换会带有开销,其实所有的开销都是操作系统内核做的事情,具体分为直切开销和简介开销:

1 直接开销

​ 就是操作系统执行保存上下文,加载上下文的消耗,这是固定的,无法避免的;例如,内核需要读写内存(把寄存器的数据写入TCB,从TCB读取数据到寄存器中),修改线程状态标记,加入到就绪队列中。

2 间接开销

​ 简介开销远大于直接开销,也是上下文切换最大的开销,主要有以下两个方面

  • CPU寄存器缓存失效

    CPU的寄存器是高速存储,线程运行中数据都保存在寄存器里,线程上下文切换后,寄存器中的内容全部被替换为新线程的数据,旧线程的数据全部作废。

  • CPU多级缓存(L1/L2/L3)失效

    CPU 为了提速,会把线程常用的内存数据缓存到 CPU 的 L1/L2/L3 缓存中

2.3.8 注意点
  1. 线程数量不是越多越好,而是恰到好处。

    当线程数远大于CPU核心数时候,操作系统会疯狂的进行上下文切换。例如8 核 CPU,开了 100 个线程

    情况①:CPU密集型任务

    ​ 例如,排序,计算,处理逻辑,这类任务的特点是:线程一直占用CPU,几乎不阻塞,不等待资源。

    最优线程数 = CPU核心数(多一两也可以)

    情况②:IO密集型任务

    ​ 例如,文件的读写,网络的请求,数据操作,这类任务的特点是:线程大多时候都在堵塞等待(如磁盘的读写,等网络响应),真正用到CPU的时间确极少。

    最优线程数 = CPU 核心数 × 2 到 CPU 核心数 × 5

  2. 线程的上下文切换,只保存栈的「指针地址」,而不是栈里的所有数据,栈空间的大小(比如默认 2MB),对上下文切换的开销没有任何影响。

2.4 并发优势与风险
2.4.1 介绍

​ 首先,并发,在多线程的场景下,特指同一进程内的多线程并发并发是一个双刃剑,用好,程序的性能会提升数倍(这是c++开发所追求的),但是用不好,程序会出现各种诡异的 BUG、崩溃、性能暴跌,而且这类问题极难排查!

​ 并发:多个任务「交替执行」,在宏观上看起来同时运行,微观上是串行执行

​ 并行:多个任务「真正同时执行」,在宏观和微观上都是同时运行

无论并发还是并行,我们统称「并发编程」,下文所有提到的并发,都包含这两种情况

2.4.2 优势
  1. 最大化利用 CPU 资源

  2. 彻底解决「耗时任务导致程序卡死」的问题,提升程序的响应性

    ​ 多线程程序是「并发执行」的:我们可以把「耗时任务」放到后台线程中执行,主线程(UI 线程 / 业务主线程)继续运行,处理用户操作、界面刷新、请求响应,程序永远不会卡死,用户体验极好。

    场景 1:桌面 GUI 程序(Qt/C++ MFC)

    ​ 比如你写一个 C++ 桌面程序,需要「读取一个 10GB 的大文件」,如果用单线程:点击「读取文件」后,程序界面立刻卡死,鼠标点不动、按钮点不了,直到文件读取完成才恢复;如果用多线程:把「读取文件」的逻辑放到一个后台线程中执行,主线程继续刷新界面、响应用户操作,用户可以正常点击按钮、拖动窗口,文件读取完成后,后台线程通知主线程更新数据,程序全程流畅无卡顿

    场景 2:服务端程序(TCP/UDP/Http 服务)

    ​ 比如你写一个 C++ 的网络服务端,需要处理客户端的请求,如果用单线程:同一时间只能处理 1 个客户端请求,其他客户端只能排队等待,服务端的响应速度极慢,并发量极低;

  3. 线程间通信成本极低,数据共享无需复杂操作

  4. 任务解耦,让代码逻辑更清晰、更易维护

    ​ 多线程程序的代码逻辑是「模块化」:我们可以把不同的功能拆分成不同的线程,每个线程只负责做一件事,线程之间通过简单的方式通信,代码职责清晰,耦合度极低。

  5. 线程的创建 / 销毁 / 切换成本极低,支持海量并发

2.4.3 劣势
  1. 数据竞争

    ​ 线程的核心是统一进程内的线程共享全局变量,静态变量,堆内存,多个线程在没有线程保护的情况下,同时对同一块共享数据进行读写操作,导致结果不符。

    (程序员就是要写代码啊,上面的字都快写吐了,哈哈,来看代码吧)

    #include<iostream>
    #include<thread>
    
    int global_num = 0;
    void add()
    {
    	for (int i = 0;i < 10000;i++)
    	{
    		global_num++;
    	}
    }
    
    int main()
    {
    	std::thread thread01(add);
    	std::thread thread02(add);
    
    	thread01.join();
    	thread02.join();
    
    	std::cout << global_num << std::endl;
    	return 0;
    }
    

    执行结果:

    在这里插入图片描述

    每次执行结果可能不一样(你的电脑如果性能好甚至肯num=20000),但是上面可以表现出问题,主要原因是:

    global_num++ 这个看似简单的操作,在 CPU 层面是3 个独立的非原子操作

    1. 从内存中读取global_num的值到 CPU 寄存器;

    2. 寄存器中的值 + 1;

    3. 把寄存器中的值写回内存的global_num

      ​ 多线程执行时,这 3 步会被「上下文切换」打断,比如:线程 1 执行完步骤 1 和 2,还没写回内存,线程 2 就开始执行步骤 1,读取到了旧值,最终导致自增次数丢失,结果偏小。

      解决办法:

      	for (int i = 0;i < 10000;i++)
      	{
      		std::lock_guard<std::mutex> lock(mtx_);
      		global_num++;
      	}
      

      修改上面的for循环内容即可

  2. 死锁

    多个线程互相持有对方需要的锁,同时又等待对方释放锁,导致所有线程都进入「永久阻塞态」,再也无法继续执行,整个程序卡死。

    死锁的 4 个必要条件(必背,缺一不可,打破任何一个条件就不会死锁):

    1. 互斥:锁的资源是独占的,同一时间只能被一个线程持有;

    2. 持有并等待:线程持有一个锁,同时又等待另一个锁;

    3. 不可抢占:锁只能被持有它的线程主动释放,不能被其他线程抢占;

    4. 循环等待:线程 A 等待线程 B 的锁,线程 B 等待线程 A 的锁,形成闭环。

      #include <iostream>
      #include <thread>
      #include <mutex>
      #include <chrono>
      
      std::mutex mtx1; // 锁1
      std::mutex mtx2; // 锁2
      
      // 线程1:先加锁1,再加锁2
      void func1() {
          mtx1.lock();
          std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 故意延迟,让线程2先加锁2
          mtx2.lock(); // 卡死在这里:线程2已经持有锁2,等待锁1
          std::cout << "线程1执行完毕" << std::endl;
          mtx2.unlock();
          mtx1.unlock();
      }
      
      // 线程2:先加锁2,再加锁1
      void func2() {
          mtx2.lock();
          std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 故意延迟,让线程1先加锁1
          mtx1.lock(); // 卡死在这里:线程1已经持有锁1,等待锁2
          cout << "线程2执行完毕" << endl;
          mtx1.unlock();
          mtx2.unlock();
      }
      
      int main() {
          std::thread t1(func1);
          std::thread t2(func2);
          t1.join(); // 永久阻塞,程序卡死
          t2.join();
          return 0;
      }
      

      运行结果:

      没有结果卡死 哈哈哈

  3. 线程的竞态终止与僵尸线程

    竞态终止:

    ​ 主线程和子线程的执行顺序是「不确定的」,如果主线程先于子线程执行完毕并退出,而子线程还在运行,会导致两种严重后果:

    1. 子线程如果访问了「主线程的局部变量」,这些变量已经被销毁,子线程会访问野指针,直接崩溃;
    2. 主线程退出后,整个进程会被操作系统终止,所有子线程都会被强制杀死,业务逻辑执行失败。

    僵尸进程:

    ​ 线程执行完毕进入「终止态」后,操作系统需要回收线程的内核资源(线程 ID、栈空间、寄存器数据);如果主线程没有调用t.join() 回收这些资源,线程就会变成「僵尸线程」,持续占用系统资源。

  4. 线程的「异常终止」导致整个进程崩溃

    ​ 线程是「进程的一部分」,线程没有独立的地址空间,所有线程共享进程的内存和资源;进程内的任意一个线程,如果发生「未捕获的异常」或「内存错误」(空指针、内存越界、数组越界),会直接触发整个进程的崩溃

    错误例子:

    void func() {
        int* p = nullptr;
        *p = 10; // 空指针访问,线程异常终止
    }
    
    int main() {
        thread t(func);
        t.join();
        cout << "主线程继续执行" << endl; // 永远不会执行,因为进程已经崩溃
        return 0;
    }
    

    正确做法: 在所有线程的入口函数 / Lambda 中,用try-catch捕获「所有异常」

    void func() {
        try {
            int* p = nullptr;
            *p = 10; // 空指针访问,抛出异常
        } catch (...) { // 捕获所有异常
            cout << "线程内部捕获到异常,线程安全退出" << endl;
            return;
        }
    }
    
    int main() {
        thread t(func);
        t.join();
        cout << "主线程继续执行" << endl; // 正常执行,进程不会崩溃
        return 0;
    }
    
  5. 过度并发导致的上下文切换风暴,性能暴跌

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐