C++高级——多线程编程

线程

线程是操作系统能够进行运算调度的最小单位。被包含在进程之中,是进程的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程可以并发执行多个线程,每个线程会执行不同的任务。对应在现实生活中,进程是组长,线程是小组成员。
在这里插入图片描述

怎么创建启动一个线程

语言级别,一般调用std名称空间的thread类来启动一个线程。
其对应操作系统层次的一下系统调用:

windows: createThread
linux:pthread_create

以下是thread类的一个构造函数:

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

我们可以看到,其需要一个线程函数(也可以是类对象和lambda表达式)以及这个函数所需要传入的参数
所以我们便可以这样来创建线程:

void threadHandle1(int time)
{
	// 让子线程睡眠time秒
	std::this_thread::sleep_for(std::chrono::seconds(time));
	cout << "hello thread1!" << endl;
}
void threadHandle2(int time)
{
	// 让子线程睡眠time秒
	std::this_thread::sleep_for(std::chrono::seconds(time));
	cout << "hello thread2!" << endl;
}
// 创建了一个线程对象,传入一个线程函数,新线程就开始运行了
	std::thread t1(threadHandle1, 2);
	std::thread t2(threadHandle2, 3);

这样,t1就会执行threadHandle1函数,t2执行threadHandle2函数。

线程如何区分

线程除了站在我们角度上的以名字区分,它还有一个属于自己的id!
通过std::thread::get_id()便可以获取到该成员对象线程的id。

std::cout << "t1 thread :: ID = " << t1.get_id() << std::endl;

而在线程函数中通过std::this_thread::get_id()获取线程id。

std::cout << "inside thread :: ID = " << std::this_thread::get_id() << std::endl;

在这里插入图片描述

线程如何结束

线程结束主要分为以下四种方式:

线程函数返回(推荐)
调用ExitThraed函数,线程自行撤销
同一进程或者另一个进程中调用TerminateThread函数
ExitProcess和TerminateProcess函数也可以用来终止线程进行

除了第一种,其他都不推荐使用,那我就不把它们写进博客了。
在这里插入图片描述

主线程如何处理子线程

主要用到的就是join和detach,其他的百度去吧。

t1.join();

这个方法让主函数等待子线程结束,主线程才继续往下继续运行。

t1.detach();

这个方法把子线程设置为分离线程。也就是主线程和子线程断绝父子关系了。
在这里插入图片描述
在一般情况下,如果主线程结束,就代表整个进程结束,如果这时有子线程还未结束就会出现运行错误。
当设置子线程为分离线程,主线程结束,子线程也自动结束。

多线程编程

总结了线程的基本知识,我们现在就来看一下多线程编程。
假设有车站的三个买票窗口来卖100张票。
在这里插入图片描述

int ticketCount = 100; // 车站有100张车票,由三个窗口一起卖票
int main()
{
	list<std::thread> tlist;
	for (int i = 1; i <= 3; ++i)
	{
		tlist.push_back(std::thread(sellTicket, i));
	}

	for (std::thread &t : tlist)
	{
		t.join();
	}

	cout << "所有窗口卖票结束!" << endl;

	return 0;
}

现在我们看看具体是怎样卖票的:
我们知道对车票(count)进行卖(--操作)时,会分为3步:

mov eax, count
sub eax,1
mov count,eax

这不是原子性的!
CPU可能刚执行完sub操作的时候,该线程(t1)时间片到了执行到其他线程(t2),这样其他卖票窗口拿到的count也是100,然后这个线程(t2)执行完count = 99,CPU又回去执行t1,这时你就会白给一张票。
在这里插入图片描述
所以就必须引入锁操作。

在多线程程序,需满足竞态条件:多线程程序执行的结果是一致的,不会随着CPU对线程不同的调用顺序,而产生不同的结果。
所以我们就需要定义一把

std::mutex mtx; // 全局的一把互斥锁

有了这把锁,我们就可以实现一个没什么大错的卖票程序了:

// 模拟卖票的线程函数  lock_guard unique_lock
void sellTicket(int index)
{
	while (ticketCount > 0) // ticketCount=1  锁+双重判断
	{
			// 保证所有线程都能释放锁,防止死锁问题的发生 scoped_ptr
			lock_guard<std::mutex> lock(mtx); 
			if (ticketCount > 0)
			{
				// 临界区代码段  =》  原子操作 =》 线程间互斥操作了 =》 mutex
				cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
				//cout << ticketCount << endl;
				ticketCount--;
			}
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

这里用了lock_guard<std::mutex> lock(mtx); 把锁包装成了一个类,保证能出函数一定会释放锁。

CAS原子操作

因为锁的操作是比较重,而且在临界区代码做的事情比较复杂,比较多。所以引入了CAS来保证上面的--操作原子特性。同时这也是无锁操作。
首先定义一下原子的类型:

volatile std::atomic_bool isReady = false;
volatile std::atomic_int mycount = 0;

这里的volatile保证了每次数据都是从内存拿,而不是有一定安全性风险的寄存器。

void task()
{
	while (!isReady)
	{
		std::this_thread::yield(); // 线程出让当前的CPU时间片,等待下一次调度
	}

	for (int i = 0; i < 100; ++i)
	{
		mycount++;
	}
}
int main()
{
	list<std::thread> tlist;
	for (int i = 0; i < 10; ++i)
	{
		tlist.push_back(std::thread(task));
	}

	std::this_thread::sleep_for(std::chrono::seconds(3));
	isReady = true;

	for (std::thread &t : tlist)
	{
		t.join();
	}
	cout << "mycount:" << mycount << endl;

	return 0;
}

这明显就比锁轻便了很多!
在这里插入图片描述

lock_guard和unique_lock

这两个其实可以类比智能指针来记:
lock_gurad类比于scoped_ptr,它的拷贝构造和复制构造都被删除了,不可用在函数参数传递或者返回过程中,只能用在简单的临界区代码段的互斥操作中。

lock_ guard(const lock_ guard&)=delete;
lock_ guard& operator= (const lock_ guard&)=delete;

而unique_lock可以类比于unique_ptr,它不仅可以用在简单的临界代码段的互斥操作中,还能用在函数调用过程中。
总的来说,建议使用unique_lock.

线程通信——生产者消费者模型

现在就用一个比较常用的模型来认识一下线程通信。
首先先定义一下互斥锁mtx和条件变量cv:

std::mutex mtx; // 定义互斥锁,做线程间的互斥操作
std::condition_variable cv; // 定义条件变量,做线程间的同步通信操作

然后定义出生产者和消费者的类queue:

// 生产者生产一个物品,通知消费者消费一个;消费完了,消费者再通知生产者继续生产物品
class Queue
{
public:
	void put(int val) // 生产物品
	{
		//lock_guard<std::mutex> guard(mtx); // scoped_ptr
		unique_lock<std::mutex> lck(mtx); // unique_ptr
		while (!que.empty())
		{
			// que不为空,生产者应该通知消费者去消费,消费完了,再继续生产
			// 生产者线程进入#1等待状态,并且#2把mtx互斥锁释放掉
			cv.wait(lck);  // lck.lock()  lck.unlock
		}
		que.push(val);
		/* 
		notify_one:通知另外的一个线程的
		notify_all:通知其它所有线程的
		通知其它所有的线程,我生产了一个物品,你们赶紧消费吧
		其它线程得到该通知,就会从等待状态 =》 阻塞状态 =》 获取互斥锁才能继续执行
		*/
		cv.notify_all(); 
		cout << "生产者 生产:" << val << "号物品" << endl;
	}
	int get() // 消费物品
	{
		//lock_guard<std::mutex> guard(mtx); // scoped_ptr
		unique_lock<std::mutex> lck(mtx); // unique_ptr
		while (que.empty())
		{
			// 消费者线程发现que是空的,通知生产者线程先生产物品
			// #1 进入等待状态 # 把互斥锁mutex释放
			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::milliseconds(100));
	}
}
void consumer(Queue *que) // 消费者线程
{
	for (int i = 1; i <= 10; ++i)
	{
		que->get();
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

创建两个线程:

int main()
{
	Queue que; // 两个线程共享的队列

	std::thread t1(producer, &que);
	std::thread t2(consumer, &que);

	t1.join();
	t2.join();

	return 0;
}

线程中只调用一次(补于2021.12.23)

程序免不了要初始化数据,上面的知识告诉我们要加锁,但是C++其实还提供了仅调用一次的功能,其需要我们先声明一个全局可见的标记作为初始化的标志。

#include <mutex>    //for once_flag
static std::once_flag flag;

然后我们调用专门的call_once函数,传递我们刚刚的标记和只调用一次的函数。这样C++就会保证即使多个函数重入call_once,也只能有一个线程会成功运行此函数。

#include <thread>
#include <iostream>
#include <mutex> //for once_flag

static std::once_flag flag;

int main()
{
    auto function = []()
    {
        std::call_once(flag, []()
                       { std::cout << "this sentence will be print once" << std::endl; });
    };

    std::thread t1(function);
    std::thread t2(function);

    t1.join();
    t2.join();
}

上面的代码会保证语句只会被打印一次,其最常见的用法其实就是我们构造单例模式保证对象只会被初始化一次。

[ik@localhost test]$ g++ -lpthread -o test test.cpp 
[ik@localhost test]$ ./test 
this sentence will be print once

线程局部存储(补于2021.12.23)

我们都知道,读写全局变量会导致数据竞争,因为共享数据,多线程操作时就会导致状态不一致。但是有的时候全局变量的作用不一定是共享数据,而是传递数据。这个时候就可以使用线程局部存储了,其关键字为thread_local

下面的代码定义了一个线程局部存储变量n,其会在t1和t2两个线程中分别创建一个变量n,也就是说两个线程的n不是一个n,这样我们对其进行相加运算不会叠加。

#include <thread>
#include <iostream>

thread_local int n = 0;
int main()
{
    auto function = [](int input)
    {
        n += input;
        std::cout << "thread id: " << std::this_thread::get_id() << "   "
                  << "n: " << n << std::endl;
    };

    std::thread t1(function, 3);
    std::thread t2(function, 4);

    t1.join();
    t2.join();
}
[ik@localhost test]$ ./test 
thread id: 140672148616960   n: 3
thread id: 140672140224256   n: 4

异步编程async(补于2021.12.23)

大多数thread做的事情也可以用async来实现,但不会看到明显的线程。

#include <future> //for async

std::async(std::forward<_Fn>(__fn), std::forward<_Args>(__args)...);

异步的本质还是调用一个线程去执行我们传入的任务,不过是让底层自动管理线程。其会返回一个future变量,其是函数执行返回的结果。如果有返回值,我们就可以用成员函数get获得结果。

#include <thread>
#include <iostream>
#include <future>
#include <unistd.h> //for sleep

int main()
{
    auto function = []()
    {
        sleep(2);
        return std::this_thread::get_id();
    };

    auto f1 = std::async(function);
    auto f2 = std::async(function);
    std::cout << "excute thread: " << f1.get() << std::endl;
    std::cout << "excute thread: " << f2.get() << std::endl;
}
[ik@localhost test]$ g++ -lpthread -o test test.cpp 
[ik@localhost test]$ ./test 
excute thread: 140098122565376
excute thread: 140098114172672


get只能调用一次,多次调用会触发async异常

下面还有非常重要的一点:如果不显示获取async的返回值future,它就会同步阻塞直至任务完成,于是异步就会变成同步。下面是个很好的例子:

#include <thread>
#include <iostream>
#include <future>
#include <unistd.h> //for sleep

int main()
{
    auto function = [](int time)
    {
        
        std::cout << "thread: " << std::this_thread::get_id() << "   sleep for " << time << "(s)" << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(time));
        return std::this_thread::get_id();
    };

    std::async(function,2);
    std::cout << "this is main" << std::endl;
}

这种情况就会导致同步发生:

[ik@localhost test]$ ./test 
thread: 140604791146240   sleep for 2(s)
this is main

下面是获取future的代码:

#include <thread>
#include <iostream>
#include <future>
#include <unistd.h> //for sleep

int main()
{
    auto function = [](int time)
    {
        
        std::cout << "thread: " << std::this_thread::get_id() << "   sleep for " << time << "(s)" << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(time));
        return std::this_thread::get_id();
    };

    auto f1 = std::async(function,2);
    std::cout << "this is main" << std::endl;
}

执行结果明显就是先输出 this is main ,在输出异步执行的输出:

[ik@localhost test]$ g++ -lpthread -o test test.cpp 
[ik@localhost test]$ ./test 
this is main
thread: 139629252716288   sleep for 2(s)

参考文献

[1] 施磊.腾讯课堂——C++高级.图论科技,2020.7.
[2] DoubleLi.如何终止线程运行.博客园,2012.8.15.
[3] 罗剑锋.罗剑锋的C++实战笔记.极客时间
  • 23
    点赞
  • 131
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

shenmingik

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

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

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

打赏作者

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

抵扣说明:

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

余额充值