在 C 11 之前,涉及到多线程问题,都是和平台相关的,比如 windows 和 linux 下各有自己的接 口,这使得代码的可移植性比较差 。 C 11 中最重要的特性就是对线程进行支持了,使得 C 在 并行编程时不需要依赖第三方库 ,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread > 头文件。
我们可以参考下面文档:C thread类
1.1 thread类的构造方法
1、支持无参构造。构造一个空线程对象,由于没有关联的线程函数,所以不会直接运行。
2、支持可变参数构造。(最常用) 构造一个线程对象,并关联线程函数fun,args1,args2,...为线程函数的参数。
代码语言:javascript复制#include<iostream>
#include<thread>
using namespace std;
void Add(int x, int y)
{
cout << x y << endl;
}
int main()
{
int a = 10, b = 30;
thread t1(Add, a, b);
t1.join();
return 0;
}
这里join函数的作用是让线程运行完进程进行回收。不然就会造成资源不回收,引发内存泄漏。
3、不支持拷贝构造。
4、支持移动赋值。
1.2 其他函数接口
get_id:获取线程id,也是线程的唯一标识。get_id()的返回值类型为id类型,id类型实际为std::thread命名空间下封装的一个类,该类中包含了一个结构体:
代码语言:javascript复制typedef struct
{ /* thread identifier for Win32 */
void *_Hnd; /* Win32 HANDLE */
unsigned int _Id;
} _Thrd_imp_t;
join:等待线程回收分配给线程的资源。
joinable:用于判断是否需要回收线程资源。
detach:线程与主线程分离,彼此独立运行。两个线程继续,不会以任何方式阻塞或同步。请注意,当任何一个结束执行时,都会释放其自己的资源。
注意
1. 线程是操作系统中的一个概念, 线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
2. 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
3. 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。
线程函数一般情况下可按照以下三种方式提供:
- 函数指针
- lambda表达式
- 函数对象(仿函数)
class temp
{
public:
void operator()()
{
cout << "thread t3" << endl;
}
};
int main()
{
thread t1(Add, 1, 2);
thread t2([]() {cout << "thread t2" << endl; });
temp t;
thread t3(t);
t1.join();
t2.join();
t3.join();
return 0;
}
4. thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。
5. 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效
- 采用无参构造函数构造的线程对象
- 线程对象的状态已经转移给其他线程对象
- 线程已经调用jion或者detach结束
2.3 this_thread命名空间
get_id:用于获取线程id。
sleep_for:进程睡眠一段时间。
sleep_until:进程睡眠至某个时间。
由于不会特别常用,这里就不详细介绍,需要用时差文档即可:this_thread - C Reference (cplusplus.com)
二、mutex锁
2.1 mutex类
多线程最主要的问题是共享数据带来的问题(即线程安全)。 如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
比如:
我们在实现 操作的时候,看起来是一行代码,实际上底层汇编有三条。当我们进行到一半而时间片的时间到了,那么该线程就会被切走阻塞,让别的线程来使用cpu,而如果后来的线程也对a进行 操作,操作后再把原来的进程切换回来,原来的进程操作的还是原来的a,那么最后的结果就会出现问题。
案例代码:
代码语言:javascript复制int ret = 0;
void Func()
{
int n = 10000;
while (n--)
{
ret ;
}
}
int main()
{
thread t1(Func);
thread t2(Func);
t1.join();
t2.join();
cout << ret << endl;
return 0;
}
为了解决这个问题,引入了锁mutex来使得 操作一次完成。
mutex类用到的主要两个函数就是:lock 和 unlock。
代码语言:javascript复制mutex m;
int ret = 0;
void Func()
{
int n = 10000;
while (n--)
{
m.lock();
ret ;
m.unlock();
}
}
int main()
{
thread t1(Func);
thread t2(Func);
t1.join();
t2.join();
cout << ret << endl;
return 0;
}
2.2 recursive_mutex
mutex类的锁是不能够递归加锁的,会出问题。为了适应这种情况,引入了recursive_mutex类。
该类提供的函数接口和mutex类一样,但是允许一个线程多次加锁,来获得互斥对象的多个级别的所有权。
2.3 timed_mutex
相较于上面两种锁,timed_mutex锁增加了两个功能:try_lock_for 和 try_lock_until
try_lock:能够在一定的时间范围内申请锁。如果当前锁未被申请,那么调用的线程就将取走锁;如果当前锁已经被申请了,那么就会返回false。
try_lock_until:尝试申请锁知道某个时间点。
三、原子性操作库(atomic)
虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对 sum 时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。因此C 11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作, C 11 引入的原子操作类型,使得线程间数据的同步变得非常高效。
该类的使用需要包含头文件<atomic>
我们下面看atomic类的构造方法:
可以看到:支持无参构造和列表初始化,但是不能拷贝。
原子类型通常属于 " 资源型 " 数据,多个线程只能访问单个原子类型的拷贝,因此 在 C 11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等,为了防止意外,标准库已经将 atmoic 模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
使用案例:
代码语言:javascript复制atomic<int> a;
void func()
{
int n = 10000;
while (n--)
{
a ;
}
}
int main()
{
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << a << endl;
return 0;
}
在 C 11 中, 程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的 访问 。更为普遍的,程序员可以使用 atomic 类模板,定义出需要的任意原子类型 。
四、利用RAII机制管理锁
4.1 lock_guard
这是一个C 中定义的用来管理锁的类,在构造对象时候加锁,析构对象的时候解锁。
实现代码:
代码语言:javascript复制template<class _Mutex>
class lock_guard
{
public:
explicit lock_guard(_Mutex& _Mtx)
:_MyMutex(_Mtx)
{
_MyMutex.lock();
}
lock_guard(_Mutex& _Mtx, adopt_lock_t)
:_MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operato = (const lock_guard&) = delete;
private:
_Mutex _MyMutex;
};
通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C 11又提供了unique_lock。
案例:
代码语言:javascript复制int a = 0;
mutex mx;
void func()
{
int n = 10000;
lock_guard<mutex> mt(mx);
while (n--)
{
a ;
}
}
int main()
{
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << a << endl;
return 0;
}
4.2 unique_lock
与 lock_gard 类似, unique_lock 类模板也是采用 RAII 的方式对锁进行了封装,并且也是以独占所 有权的方式管理 mutex 对象的上锁和解锁操作,即其对象之间不能发生拷贝 。在构造 ( 或移动(move)赋值 ) 时, unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。 使用以上类型互斥量实例化 unique_lock 的对象时,自动调用构造函数上锁, unique_lock 对象销毁时自动调用析构函数解 锁,可以很方便的防止死锁问题。
与 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所管理的互斥量的指针)。
五、条件变量
我们先看一道题:两个线程交替打印0-100的数字,一个打印奇数,一个打印偶数。
我们通常情况下解题是while循环中用if条件判断来判断,一个线程t1判断奇数打印,一个线程t2判断偶数打印,然后打印完 。但是当我们打印t1奇数的时候,此时时间片切到t2,t2会不断的循环判断,直到时间片切回t1。这样就造成了CPU资源的浪费。
这里就要引入我们的条件变量:std::condition_variable、
条件变量中的 wait 和 notify_one 的接口能够实现进程的等待和唤醒。 使得进程避免因为不满足条件而一直循环判断,浪费资源。
需要注意的是:
wait接口的参数是unique_lock类型。 有人会好奇为什么需要传一个锁进来呢? 因为条件变量操作不是原子性的,我们需要加锁保护,但是我们加了锁让线程等待,但是其他线程因为申请不到锁也会进入阻塞,那么不就死循环了吗? 其实并不是的,wait操作之所以需要传一个锁进来,就是因为wait操作的同时,会将锁释放,让其他线程能够申请到锁,直到用notify_one来唤醒线程的时候,才会重新持有锁。
有了条件变量,我们可以让进程在不满足条件的时候进行等待,在满足条件之后再唤醒进程运行。
案例代码:
代码语言:javascript复制int main()
{
int a = 0;
condition_variable cv;
mutex mt;
//打印奇数
thread t1([&]()
{
while (a <= 100)
{
unique_lock<mutex> lock(mt);
if (a % 2 == 0)
{
cv.wait(lock);
}
cout << "t1->" << a << endl;
a;
cv.notify_one();
}
});
thread t2([&]()
{
while (a <= 100)
{
unique_lock<mutex> lock(mt);
if (a % 2 == 1)
{
cv.wait(lock);
}
cout << "t2->" << a << endl;
a;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
我们这里就发现问题了,怎么会打印出101来呢?
原因出在这里:
因此我们只需要把t1时的循环条件<= 改成 < 即可,这样,在100的时候进不去循环了,自然后面的操作也就不会执行了。