[C++高级部分]多线程编程部分

本文介绍了C++中的多线程编程,包括如何创建和结束线程,以及使用mutex、lock_guard确保线程间的互斥。通过示例展示了无锁导致的竞态条件问题,然后通过mutex和lock_guard解决此问题。文章还讨论了线程同步的条件变量在生产者-消费者模型中的应用,并提到了基于CAS操作的atomic原子类型在简单操作中的高效性。
摘要由CSDN通过智能技术生成


C++语言级别的多线程最大的好处:

  • 代码可以跨平台

在不支持语言级别的多线程编程的时候,只能是在代码中调用系统提供的API,程序就不支持在多平台编译运行

实际上,C++语言级别的多线程thread只是在系统的API上提供了一个封装,根据不同的平台实际上底层会调用不同的API:

windows —> createThread

linux----->pthread_create


  • 线程内容:

    一、怎么创建启动一个线程

    • std::thread定义一个线程对象,传入线程所需要的线程函数和参数,线程就自动开始运行了

    二、子线程如何结束

    • 子线程函数运行完成,线程就结束了

    三、主线程如何处理子线程?

    • t.join():等待t线程结束,当前线程继续向下运行
    • t.detach():把t线程设置为分离线程,主线程结束,整个进程结束,看不到分离线程的输出结果了
  • 通过thread编写C++多线程程序

#include <iostream>
#include <thread>
//using namespace std; //公司里面一般不用,避免命名冲突
void threadHandle1(int time)
{
    // 让子线程睡眠2秒
    // thi_thread是一个命名空间, chrono也是一个命名空间
    std::this_thread::sleep_for(std::chrono::seconds(time));
    std::cout << "hello thread1" << std::endl;
}
int main()
{
    // 传入线程函数创建一个线程对象,新线程就开始运行了
    std::thread t1(threadHandle1, 2);

    // 主线程等待子线程运行结束,主线程继续往下运行
    // 主线程运行完成,如果当前进程还有未完成的子线程,进程就会异常终止(C++语言级别的多线程的情况)
    //t1.join();

    // 把子线程设置为分离线程
    // 子线程分离出去,和主线程没有任何的关系
    // 我们也看不到分离出去的线程的输出结果
    t1.detach();
    std::cout << "main thread done" << std::endl;

}

说明:

  • 主线程运行完成,如果当前进程还有未完成的子线程,进程就会异常终止(C++语言级别的多线程的情况)
  • 通过设置分离线程,把子线程分离出去,这样子线程就和主线程无任何关系,主线程正常运行结束,但是我们也看不到分离出去的线程的输出结果

线程间互斥-mutex互斥锁和lock_guard

  • 竞态条件:多线程程序执行的结果是一致的,不会随着CPU对线程不同的调用顺序,而产生不同的运行结果。如果每次运行后的结果不一样,那么说明程序中存在竞态条件
#include <iostream>
#include <thread>
#include <mutex> // 包含互斥锁的头文件
#include <list>
//using namespace std;

int ticketcount = 100;// 总票数

没有加互斥锁,存在竞态条件的线程函数

void seilTicket(int index)
{
    while (ticketcount > 0)
    {
        cout << "窗口:" << index << " 卖出第" << ticketcount << "张票" << endl;
        ticketcount--;
        /*
        mov eax, ticketcount
        sub eax, 1
        mov ticketcount, eax
        */
        
        std::this_thread::sleep_for(std::chrono::microseconds(100));
    }
}

模拟3个窗口进行卖票

int main() 
{
    std::list<std::thread> tlist;
    // 模拟3个窗口进行卖票
    for (int i = 0; i < 3; ++i) 
    {
        tlist.push_back(std::thread(seilTicket,i));
    }

    // 不允许拷贝构造线程,这里使用了引用
    for (std::thread& t:tlist) 
    {
        // 主线程运行完成,如果当前进程还有未完成的子线程,进程就会异常终止
        // 等待所有子线程都运行结束后,主线程继续运行
        t.join();
    }
    return 0;
}

在这里插入图片描述

从运行结果的一部分截图可以看到,存在多次卖出同一张票的情况,这显然是错误的!!原因也很简单:–操作并不是原子操作,当一个线程准备执行–操作时,另一个线程获取了还未被处理的票数(可以举个例子

添加互斥锁,来管理临界资源

  • 锁+双重判断
#include <mutex> // 包含互斥锁头文件
int ticketcount = 100;
std::mutex mtx;// 锁变量

void seilTicket(int index) 
{
    // 锁+双重判断
    while (ticketcount > 0)
    {
    	mtx.lock();
        if (ticketcount > 0) // 这里再加一个判断,避免输出ticketcount为负的情况
        {
            // 临界区代码段
            std::cout << "窗口:" << index << " 卖出第" << ticketcount << "张票" << std::endl;
            /*
            mov eax, ticketcount
            sub eax, 1
            mov ticketcount, eax
            */
            ticketcount--;
        }
        mtx.unlock();
        std::this_thread::sleep_for(std::chrono::microseconds(100));
    }
}

手动添加互斥锁还存在一个问题,如果执行临界区代码时,线程提前(比如条件成立后return)结束了,那么由于还没有执行unlock(),其他线程就无法获取锁了;

所以,我们要使用智能指针,确保无论什么情况,线程结束都能把锁释放掉:

  • lock_guard

    • 接收锁类型变量,调用构造函数,自动加锁

    • 与scoped_ptr类似,不允许使用拷贝构造和赋值运算符重载函数(删除了拷贝构造和赋值运算符重载函数),无法作为参数传递,无法作为函数返回值

    • 出作用域,析构,自动解锁

  • unique_lock

    • 支持自动加锁,释放锁
    • 与unique_ptr类似,不允许使用左值引用的拷贝构造和赋值,定义了带右值引用的拷贝构造和赋值,可以使用lock()和unlock()加锁和解锁
    • 在线程通信中会比较常用到
// lock_guard和unique_guard
void seilTicket(int index) 
{
    // 锁+双重判断
    while (ticketcount > 0)
    { 
        {
            // 这里建议使用
            std::lock_guard<std::mutex> lock(mtx); 
            
            /*
            unique_lock<std::mutex> lck(mtx);
            lck.lock();
            */
            if (ticketcount > 0)
            {
                // 临界区代码段
                std::cout << "窗口:" << index << " 卖出第" << ticketcount << "张票" << std::endl;
                /*
                mov eax, ticketcount
                sub eax, 1
                mov ticketcount, eax
                */
                ticketcount--;
            }
            /*lck.unlock();*/
      
        }// 出作用域就会把自动调用析构把锁释放
        
        std::this_thread::sleep_for(std::chrono::microseconds(100));
    }
}
int main() 
{
    std::list<std::thread> tlist;
    // 模拟3个窗口进行卖票
    for (int i = 0; i < 3; ++i) 
    {
        tlist.push_back(std::thread(seilTicket,i));
    }

    // 不允许拷贝构造线程,这里使用了引用
    for (std::thread& t:tlist) 
    {
        // 主线程运行完成,如果当前进程还有未完成的子线程,进程就会异常终止
        // 等待所有子线程都运行结束后,主线程继续运行
        t.join();
    }
    return 0;
}

在这里插入图片描述


线程间的同步通信

多线程编程的两个问题:

  1. 线程间互斥

    临界区代码段存在竞态条件,需要保证对临界资源的操作是原子操作,所以给临界区代码段加上互斥锁,或者是轻量级的无锁实现CAS

    (Linux下执行 strace ./a.out )

  2. 线程间同步

    • 生产者和消费者线程模型

(注意:C++STL提供的容器都不是线程安全的,需要我们自己去保证)

#include <iostream>
#include <thread>
#include <condition_variable> // 包含条件变量的头文件
#include <queue>
using namespace std;

/*
生产者,消费者模型
*/
// 生产者生产一个物品,通知消费者消费一个,消费完了,消费者再通知生产者继续生产物品

std::mutex mtx; // 定义互斥锁,做线程间的互斥操作
std::condition_variable cv; // 定义条件变量,做线程间的通信操作
class Queue
{
public:
	void put(int val) //生产物品
	{
		// 配合cv.wait()
		unique_lock<std::mutex> lck(mtx);
		while (!que.empty())
		{
			// que不为空,生产者应通知消费者去消费,消费完了,再继续生产
			// 生产者应该进入等待状态,并且把互斥锁mtx释放
			cv.wait(lck);
		}// 获得其他线程的通知后,由等待状态==》阻塞

		que.push(val);
		/*
		notify_one:通知另外的一个线程
		notify_all:通知其他所有线程
		*/
		// 通知其他线程我生产完了,快开始消费
		cv.notify_all();
		cout << "生产者 生产:" << val << "号物品" << endl;
		// 互斥锁mtx释放
	}
	int get() // 消费物品 
	{
		unique_lock<std::mutex> lck(mtx);
		while (que.empty()) // 注意得是循环等待
		{
			// 消费者发现que是空的
			// 1.进入等待状态; 2.把mtx释放
			cv.wait(lck);
		}// 获得其他线程的通知后,由等待状态==》阻塞
		// 阻塞状态下获得其他线程释放的锁后,开始运行
		int val = que.front();
		que.pop();
		// 通知其他线程,我已经消费了,快开始生产
		cv.notify_all();
		cout << "消费者 消费:" << val << "号物品" << endl;
		return val;
	}
private:
	queue<int> que;
};
// 生产者线程函数
void producer(Queue* que) 
{
	for (int i=1;i<=10;++i) 
	{
		que->put(i);
		std::this_thread::sleep_for(std::chrono::microseconds(100));
	}
}

// 消费者线程函数
void consumer(Queue* que)
{
	for (int i = 1; i <= 10; ++i)
	{
		que->get();
		std::this_thread::sleep_for(std::chrono::microseconds(100));
	}
}
  • 注意cv.wait(lck)
    • 线程进入等待状态,并且释放锁
    • 接收到其他线程后的通知后,由等待状态变为阻塞状态,只有获取其他线程释放的锁后才会继续运行
int main()
{
	Queue que;// 共享队列
	std::thread thread1(producer, &que);// 创建生产者线程
	std::thread thread2(consumer, &que);// 创建消费者线程

	thread1.join();
	thread2.join();
	return 0;
}

在这里插入图片描述


再谈unique_lock和lock_guard

  • lock_guard

    • lock_guard对象不能用在函数参数传递或者返回过程中,只能用在简单的临界区代码段的互斥操作中
  • unique_lock

    • 不仅能用在简单的临界区代码段的互斥操作中,还能用在函数调用过程中
    • 线程通信中比较常用
  • 条件变量condition_variable

    cv.notify_all()

    • 通知在cv上等待的线程,条件成立了,起来干活了
    • 其他在cv上等待的线程,收到通知,从等待状态–》阻塞状态–》 获取互斥锁后,线程继续运行

基于CAS操作的atomic原子类型

互斥锁是比较重的,如果临界区代码要做的事情比较复杂,那么一般使用互斥锁;

如果临界区代码只涉及到一些比较简单的操作,比如count++,count–,那么就没必要去使用互斥锁,而是从系统理论角度出发:用CAS来保证++,–操作的原子特性,CAS称为无锁操作,但是并不是不加锁,而是不在软件层面加/解锁,而是相当于给总线加锁(硬件层面,CPU必须支持CAS操作)

#include <iostream>
#include <atomic>
#include <thread>
#include <list>
using namespace std;

// isReady和mycount都是在数据段上存储的全局变量,作为多个线程的共享资源
/*
	cpu执行线程指令时通过优化,将共享资源的值都拷贝到线程的缓存里(cpu的缓存);
	为了保证一个线程对共享变量的改变,能够马上反映到另一个线程上,所以使用volatile修饰共享变量
*/
volatile std::atomic_bool isReady = false;
volatile std::atomic_int mycount = 0;

void task() 
{
	while (!isReady) 
	{
		std::this_thread::yield(); // 线程让出时间片,等待下一次调度
	}
	for (int i=0;i<100;++i) 
	{
		// mycount是原子类型,在硬件层面上保证了mycount++是原子操作
		mycount++;
	}
}
int main() 
{
	list<std::thread> lis;
	for (int i = 0; i < 10; ++i) 
	{
		lis.push_back(thread(task));
	}
	std::this_thread::sleep_for(std::chrono::seconds(3));
	isReady = true;
	for (thread &t:lis) 
	{
		t.join();
	}
	cout << "mycount=" << mycount;
	return 0;
}

如果临界区代码段的操作比较简单,那么可以将共享变量设置为原子类型,提高程序执行的效率!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

下酒番陪绅士

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值