C++11 线程库

目录

1.线程库

1.1thread类的简单介绍

1.2 线程函数参数

1.3原子性操作库

1.4lock_guard与unique_lock

1.5mutex的种类


1.线程库

1.1thread类的简单介绍

        在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库。而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。

函数名功能
thread()构造一个线程对象,没有关联任何线程函数,即没有启动任何线程
thread(fn,
args1, args2,
...)
构造一个线程对象,并关联线程函数fn,args1,args2,...为线程函数的
参数
get_id()获取线程id
jionable()线程是否还在执行,joinable代表的是一个正在执行中的线程。
jion()该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行
detach()在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离
的线程变为后台线程,创建的线程的"死活"就与主线程无关

        C++的线程库是面向对象的,支持直接构造一个线程对象。像thread接口,是支持可变参数的,相较于Linux下的原生接口是方便很多的。C++11出于安全性的考虑,是不支持线程间的拷贝的。

        为了解决线程对象不能拷贝的问题,C++11引入了移动语义。移动语义允许对象通过“窃取”另一个对象的资源来初始化自己,而不是通过拷贝。这样,资源的管理权就从源对象转移到了目标对象,而源对象则变为一个空壳或处于无效状态。

注意:
1. 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
 

#include <thread>
int main()
{
	std::thread t1;
	cout << t1.get_id() << endl;
	return 0;
}

get_id()的返回值类型为id类型,id类型实际为std::thread命名空间下封装的一个类,该类中
包含了一个结构体:

// vs下查看
typedef struct
{ /* thread identifier for Win32 */
	void* _Hnd; /* Win32 HANDLE */
	unsigned int _Id;
} _Thrd_imp_t;

调用方法

cout << this_thread::get_id() << endl;

3. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:

  • 函数指针
  • lambda表达式
  • 函数对象
#include <iostream>
using namespace std;
#include <thread>
void ThreadFunc(int a)
{
	cout << "Thread1->" << a << endl;
	cout << endl;
}
class TF
{
public:
	void operator()()
	{
		cout << "Thread3" << endl;
		cout << endl;
	}
};
int main()
{
	// 线程函数为函数指针
	thread t1(ThreadFunc, 10);
	// 线程函数为lambda表达式
	thread t2([] {cout << "Thread2" << endl;
				  cout << endl; });
	// 线程函数为函数对象
	TF tf;
	thread t3(tf);
	t1.join();
	t2.join();
	t3.join();
	cout << "Main thread!" << endl;
	return 0;
}

5.可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效

  • 采用无参构造函数构造的线程对象
  • 线程对象的状态已经转移给其他线程对象
  • 线程已经调用jion或者detach结束

面试题:并发与并行的区别?
        并发:多个线程或进程实际上是通过时间片轮转的方式来交替执行的。每个任务在获得CPU时间片时执行一段时间,然后被挂起,让出CPU给其他任务执行,如此循环往复。这种方式在宏观上看起来像是多个任务在同时执行,但实际上在微观上它们是顺序执行的。

        并行:并行则是指多个任务同时执行的能力,每个任务都在独立的处理单元(如CPU核心)上执行。并行处理可以显著提高计算效率,因为多个任务可以同时进行,互不干扰。在多核处理器上,每个核心都可以独立地执行一个线程或进程,从而实现真正的并行处理。

1.2 线程函数参数

        线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。

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

void ThreadFunc1(int& x)
{
	x += 10;
}
void ThreadFunc2(int* x)
{
	*x += 10;
}
int main()
{
	int a = 10;
	// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际
	//引用的是线程栈中的拷贝
		thread t1(ThreadFunc1, a);
	t1.join();
	cout << a << endl;
	// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
	thread t2(ThreadFunc1, std::ref(a));
	t2.join();
	cout << a << endl;
	// 地址的拷贝
	thread t3(ThreadFunc2, &a);
	t3.join();
	cout << a << endl;
	return 0;
}

注意:如果是类成员函数作为线程参数时,必须将this作为线程函数参数。


1.3原子性操作库

        多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问、题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:

#include <iostream>
using namespace std;
#include <thread>
unsigned long sum = 0L;
void fun(size_t num)
{
	for (size_t i = 0; i < num; ++i)
		sum++;
}
int main()
{
	cout << "Before joining,sum = " << sum << std::endl;
	thread t1(fun, 10000000);
	thread t2(fun, 10000000);
	t1.join();
	t2.join();
	cout << "After joining,sum = " << sum << std::endl;
	return 0;
}

        在 fun 函数中,两个线程(t1 和 t2)都试图修改全局变量 sum。由于这两个线程是并行执行的,它们对 sum 的修改可能会相互干扰,导致最终的结果不确定。这就是所谓的竞态条件。由于竞态条件,sum 的最终值可能并不是两个线程各自循环次数的总和(即 20000000)。因为当一个线程在增加 sum 的值时,另一个线程可能也在同时尝试做同样的事情,这可能导致某些增加操作被覆盖或遗漏。

C++98中传统的解决方式:可以对共享修改的数据可以加锁保护
 

#include <iostream>
using namespace std;
#include <thread>
#include <mutex>
std::mutex m;
unsigned long sum = 0L;
void fun(size_t num)
{
	for (size_t i = 0; i < num; ++i)
	{
		m.lock();
		sum++;
		m.unlock();
	}
}
int main()
{
	cout << "Before joining,sum = " << sum << std::endl;
	thread t1(fun, 10000000);
	thread t2(fun, 10000000);
	t1.join();
	t2.join();
	cout << "After joining,sum = " << sum << std::endl;
	return 0;
}

        虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。

        因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。

注意:需要使用以上原子操作变量时,必须添加头文件
 

#include <iostream>
using namespace std;
#include <thread>
#include <atomic>
atomic_long sum{ 0 };
void fun(size_t num)
{
	for (size_t i = 0; i < num; ++i)
		sum++; // 原子操作
}
int main()
{
	cout << "Before joining, sum = " << sum << std::endl;
	thread t1(fun, 1000000);
	thread t2(fun, 1000000);
	t1.join();
	t2.join();
	cout << "After joining, sum = " << sum << std::endl;
	return 0;
}

在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。
更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。
 

atmoic<T> t; // 声明一个类型为T的原子类型变量t

注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
 

#include <atomic>
int main()
{
	atomic<int> a1(0);
	//atomic<int> a2(a1); // 编译失败
	atomic<int> a2(0);
	//a2 = a1; // 编译失败
	return 0;
}

1.4lock_guard与unique_lock

在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能通过锁的方式来进行控制。
比如:一个线程对变量number进行加一100次,另外一个减一100次,每次操作加一或者减一之后,输出number的结果,要求:number最后的值为1
 

#include <thread>
#include <mutex>
int number = 0;
mutex g_lock;
int ThreadProc1()
{
	for (int i = 0; i < 100; i++)
	{
		g_lock.lock();
		++number;
		cout << "thread 1 :" << number << endl;
		g_lock.unlock();
	}
	return 0;
}
int ThreadProc2()
{
	for (int i = 0; i < 100; i++)
	{
		g_lock.lock();
		--number;
		cout << "thread 2 :" << number << endl;
		g_lock.unlock();
	}
	return 0;
}
int main()
{
	thread t1(ThreadProc1);
	thread t2(ThreadProc2);
	t1.join();
	t2.join();
	cout << "number:" << number << endl;
	system("pause");
	return 0;
}

        上述代码的缺陷:锁控制不好时,可能会造成死锁,最常见的比如在锁中间代码返回,或者在锁的范围内抛异常。因此:C++11采用RAII的方式对锁进行了封装,即lock_guard和unique_lock。


1.5mutex的种类

在C++11中,Mutex总共包了四个互斥量的种类:
1. std::mutex
        C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能进行移动。mutex最常用的三个函数:

函数名函数功能
lock()上锁:锁住互斥量
unlock()解锁:释放对互斥量的所有权
try_lock()尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻

注意,线程函数调用lock()时,可能会发生以下三种情况:

  • 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁
  • 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)

线程函数调用try_lock()时,可能会发生以下三种情况:

  • 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock释放互斥量
  • 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)

2. std::recursive_mutex
        其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,
std::recursive_mutex 的特性和 std::mutex 大致相同。

3. std::timed_mutex
比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until() 。

try_lock_for()
        接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与
std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回
false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超
时(即在指定时间内还是没有获得锁),则返回 false。
try_lock_until()
        接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,
如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指
定时间内还是没有获得锁),则返回 false。

4. std::recursive_timed_mutex

5.lock_guard
std::lock_gurad 是 C++11 中定义的模板类。定义如下:
 

template<class _Mutex>
class lock_guard
{
public:
	// 在构造lock_gard时,_Mtx还没有被上锁
	explicit lock_guard(_Mutex& _Mtx)
		: _MyMutex(_Mtx)
	{
		_MyMutex.lock();
	}
	// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
	lock_guard(_Mutex& _Mtx, adopt_lock_t)
		: _MyMutex(_Mtx)
	{}
	~lock_guard() _NOEXCEPT
	{
		_MyMutex.unlock();
	}
	lock_guard(const lock_guard&) = delete;
	lock_guard& operator=(const lock_guard&) = delete;
private:
	_Mutex& _MyMutex;
};

        通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁
问题。

#include<vector>
#include<mutex>
#include<atomic>
#include<condition_variable>
using namespace std;
int main()
{
	vector<thread> vthd;
	int n;
	cin >> n;
	vthd.resize(n);
	int x = 0;
	mutex mtx;
	auto func = [&](int n) {
		
		// 局部域
		{
			lock_guard<mutex> lock(mtx);
			for (size_t i = 0; i < n; i++)
			{
				++x;
			}
		}
	};

	for (auto& thd : vthd)
	{
		// 移动赋值
		thd = thread(func, 100000);
	}

	for (auto& thd : vthd)
	{
		thd.join();
	}
	cout << x << endl;


	return 0;
}

在上面的例子当中可以使用局部域的方式控制锁的范围。


        lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供了
unique_lock。

6.unique_lock
unique_lock与lock_gard类似unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。

int n;
std::mutex some_mutex;

void prepare_data()
{
    cout << n++ << endl;
}

void do_something()
{
    cout << n++ << endl;
}

std::unique_lock<std::mutex> get_lock()
{
    std::unique_lock<std::mutex> lk(some_mutex);//与lock_guard相同,构造时获取锁
    cout << "owns_lock? " << lk.owns_lock() << endl;//1
    prepare_data();
    return lk;
}

int main()
{
    //unique_lock基本使用
    std::mutex mutex2;
    //告诉构造函数暂不获取锁
    std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);
    cout << "owns_lock? " << lock2.owns_lock() << endl;//0
    lock2.lock();//手动获取锁
    std::cout << "owns_lock? " << lock2.owns_lock() << endl;//1
    lock2.unlock();//手动解锁
    cout << "owns_lock? " << lock2.owns_lock() << endl;//0
    //锁所有权转移到函数外部
    std::unique_lock<std::mutex> lk(get_lock());//
    do_something();
}
//析构
//lock2未获取锁mutex2,因此不会调用unlock
//lk对象持有锁some_mutex,调用unlock


与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

  • 上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
  • 修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)。
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

1.6 支持两个线程交替打印,一个打印奇数,一个打印偶数(使用条件变量使线程同步)

          condition_variable和Linux posix的条件变量并没有什么大的区别,主要还是面向对象实现的。

        具体到这段代码,每个线程(t1 和 t2)在访问共享资源(这里是 flag 变量和输出操作)之前,都会通过 unique_lock<mutex> lock(mtx); 来获取对互斥量 mtx 的锁定。这确保了同一时间只有一个线程可以执行这些受保护的操作,从而避免了数据竞争和其他并发问题。

        每个 while 循环迭代中,线程会首先尝试获取互斥量,如果当前互斥量已经被另一个线程锁定,则当前线程会阻塞,直到互斥量被释放(即另一个线程完成了其受保护的操作并解锁了互斥量)。一旦线程成功获取了互斥量,它就会执行受保护的操作(检查 flag 变量的值,打印信息,修改 flag 变量等),并在 unique_lock 对象的作用域结束时自动释放互斥量,允许其他线程继续执行。

        需要注意的是,这段代码在 main 函数中首先启动了线程 t2,然后让主线程休眠了 1000 毫秒(即 1 秒),之后才启动线程 t1。这种启动顺序和延时可能会导致一些不可预测的行为,因为线程 t2 可能会在没有线程 t1 的情况下运行多次循环迭代,直到 flag 被设置为 false 并等待 t1 线程的通知。然而,由于 t1 线程在 t2 线程之后启动,所以 t1 线程第一次尝试获取互斥量时,flag 可能已经是 false 了(如果 t2 线程在 t1 启动前就已经执行了足够的迭代),这会导致 t1 线程在第一次迭代时就执行打印操作,而不是像注释中提到的那样“第一个打印的是 t1 打印 0”。

int main()
{
	std::mutex mtx;
	condition_variable c;
	int n = 100;
	bool flag = true;

	thread t2([&]() {
		int j = 1;
		while (j < n)
		{
			unique_lock<mutex> lock(mtx);

			// 只要flag == true t2一直阻塞'
			// 只要flag == false t2不会阻塞
			while (flag)
				c.wait(lock);

			cout<<"t2-> " << j << endl;
			j += 2; // 奇数
			flag = true;

			c.notify_one();
		}
		});


	this_thread::sleep_for(std::chrono::milliseconds(1000));


	// 第一个打印的是t1打印0
	thread t1([&]() {
		int i = 0;
		while (i < n)
		{
			unique_lock<mutex> lock(mtx);
			// flag == false t1一直阻塞
			// flag == true t1不会阻塞
			while (!flag)
			{
				c.wait(lock);
			}

			cout <<"t1->" << i << endl;

			flag = false;
			i += 2; // 偶数

			c.notify_one();
		}
	});


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

	return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值