1.线程库(thread)
语言可以使技术具有跨平台性,在C++11提供线程库之前,由于线程在不同操作系统下实现的方式各不相同,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差,为了解决这一问题,C++11引入了线程库,使用该线程库并不需要依赖第三方库(-lpthread)。
1.1线程对象的构造函数
(1)无参构造
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程,仅仅只是一个对象。
(2)带参构造
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明:
fn
:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...
:调用可调用对象fn时所需要的若干参数。
调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t2(func, 10);
t2.join();
return 0;
}
(3)移动赋值
thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
由于线程对象也是对象,所以我们可以使用容器进行管理,这样我们可以一次管理一批线程。
int main()
{
int n;
cin >> n;
vector<thread> vthds(n);
for (auto& thd : vthds)
{
thd = thread(Func, 10000);
}
for (auto& thd : vthds)
{
thd.join();
}
cout << x << endl;
return 0;
}
注意:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
- 如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
1.2thread的成员函数
thread中常用的成员函数如下:
成员函数 | 功能 |
---|---|
join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
get_id | 获取该线程的id |
swap | 将两个线程对象关联线程的状态进行交换 |
调用thread的成员函数get_id
可以获取线程的id,但该方法必须通过线程对象来调用get_id
函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread
命名空间下的get_id
函数。比如:
void func()
{
cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
thread t(func);
t.join();
return 0;
}
另外this_thread
命名空间下还提供了以下函数:
函数名 | 功能 |
---|---|
yield | 放弃CPU,让操作系统调度另一线程继续执行 |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
1.3线程函数的参数问题
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,所以就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, num);
t.join();
cout << num << endl; //0
return 0;
}
那么如何才能通过线程函数的形参改变外部的实参呢,有三种方式:
1.3.1借助std::ref函数
传入实参时借助ref函数保持对实参的引用。比如:
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, ref(num));
t.join();
cout << num << endl; //1
return 0;
}
1.3.2将线程函数参数修改为指针类型
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。比如:
void add(int* num)
{
(*num)++;
}
int main()
{
int num = 0;
thread t(add, &num);
t.join();
cout << num << endl; //1
return 0;
}
1.3.3使用lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。比如:
int main()
{
int num = 0;
thread t([&num]{num++; });
t.join();
cout << num << endl; //1
return 0;
}
其他还有诸如线程回收join
函数,线程分离detach
函数等。
2.互斥量库(mutex)
在C++11中,mutex中总共包了四种互斥量:
2.1std::mutex
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。
mutex中常用的成员函数如下:
成员函数 | 功能 |
---|---|
lock | 对互斥量进行加锁 |
try_lock | 尝试对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
注意:try_lock与lock的区别就在于,对于lock如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞,而try_lock会返回false,当前线程不会被阻塞。
2.2std::recursive_mutex
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock。
除此之外,recursive_mutex也提供了lock、try_lock和unlock成员函数,其特性与mutex大致相同。
2.3std::timed_mutex
timed_mutex中提供了以下两个成员函数:
try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。
try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。
除此之外,timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同。
2.4std::recursive_timed_mutex
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
2.5加锁示例
void func(int n, mutex& mtx)
{
mtx.lock();
for (int i = 1; i <= n; i++)
{
cout << i << endl;
}
mtx.unlock();
}
int main()
{
mutex mtx;
thread t1(func, 100, ref(mtx));
thread t2(func, 100, ref(mtx));
t1.join();
t2.join();
return 0;
}
**说明:**为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用,此外,也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁。
然而实际应用中,为了防止解锁之前抛异常或者各种原因没有解锁,我们一般会采用RAII的方式来管理互斥量。
因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
2.6lock_guard
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。
如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域(即{}括起来的区域)来控制lock_guard对象的生命周期。比如:
mutex mtx;
void func()
{
//...
//匿名局部域
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//...
return; //调用析构函数解锁
}
} //调用析构函数解锁
//...
}
int main()
{
func();
return 0;
}
模拟实现lock_guard类的步骤如下:
- lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
- 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
- lock_guard的析构函数中调用互斥锁的unlock进行解锁。
- 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。
template<class Mutex>
class LockGuard
{
public:
LockGuard(Mutex& mtx) :_mtx(mtx)
{
mtx.lock(); //加锁
}
~LockGuard()
{
mtx.unlock(); //解锁
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Mutex& _mtx;
};
2.7unique_lock
unique_lock具有更高的灵活性,比如代码的中间区域有一小段不需要上锁,unique_lock可以自由的在代码的中间位置解锁上锁。比如:
mutex mtx;
void func1()
{
unique_lock<mutex> ul(mtx);
// ...
ul.unlock();
func2();
ul.lock();
// ...
}
除此之外,unique_lock还包含其它成员函数:
- 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
3.原子性操作库(atomic)
多线程最主要的问题是共享数据带来的问题(即线程安全)。在线程安全的学习中我们已经了解到一些有关原子操作的概念,比如当对数据进行修改时,如果该修改操作是原子操作那么就不会引发线程安全问题,如果不是,就需要使用互斥量mutex进行上锁,防止多个线程同时进入临界区。
但是频繁的上锁解锁终究还是影响效率,那么如何解决呢?
C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。如下:
原子类型名称 | 对应的内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigned char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | char16_t |
atomic_char32_t | char32_t |
atomic_wchar_t | wchar_t |
初始化方式:
- atomic_int n = { 0 };
- atomic n = 0;
- atomic n { 0 };
对n的操作此时就会变为原子操作,我们不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量互斥访问。
- 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
- 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
- 原子类型不仅仅支持原子的++操作,还支持原子的–、加一个值、减一个值、与、或、异或操作。
4.条件变量库(condition_variable)
条件变量库提供了两类接口,一类是wait、一类是notify。
4.1wait系列成员函数
//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
函数说明:
- 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
- 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。
wait为什么要传互斥锁? 首先你需要明确的是,使用条件变量一定会搭配使用互斥锁,因为线程同步的场景本身就是在互斥的前提下,即两个线程访问同一资源(临界资源),而现在需要保证的是资源使用的顺序性,所以才引入了条件变量。
而如果此时某个线程由于条件并不满足(这个条件不满足一定是临界资源不满足该线程运行的条件),被设置了等待条件变量满足,从而进入了阻塞状态,能使条件满足一定是该临界资源被修改了,从而满足了线程的运行需要,所以此时你就可以唤醒等待的线程。
也就是说我们想要让条件得到满足,就一定会修改临界资源,而如果你在等待条件变量满足的时候,仍然持有着该临界资源的锁,那么就会导致其他能使该临界资源满足线程运行需要的其他线程访问不了该临界资源,所以给等待条件变量满足的函数传入互斥锁的目的就是让这个锁临时被释放,让其他线程可以访问该临界资源。
wait_for和wait_until函数的使用方式与wait函数类似:
wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。
注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
4.2notify系列成员函数
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all。
-
notify_one:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。
-
notify_all:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
5.实现两个线程交替打印
尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。
该题目主要考察的就是线程的同步和互斥。
交替打印就要求我们能做到:同一个线程不能连续打印,必须交替打印。
我们首先可以明确的是:打印过程必须上锁,这样才能保证同一时间只有一个线程在向控制台输出数据。我们假设有A、B两个线程,当A线程打印完了时要去通知B线程我打印完了,或者说B线程能够打印的条件被满足了B才能打印,而A线程在打印完后必须阻塞住,让B线程打印,B线程打印完了也得通知A线程,即A线程能够打印的条件被满足了,而此时B线程也必须阻塞住。
假设我们让A线程打印奇数,B线程打印偶数,那么很明显我们需要一个bool类型的变量用来记录A、B打印的交替条件,初始设为true,表示A先打印或者说此时应打印奇数,即A应不被阻塞,B被阻塞。
int main()
{
int n = 100;
mutex mtx;
condition_variable cv;
bool flag = true;
//奇数
thread A([&]{
int i = 1;
while (i <= n)
{
unique_lock<mutex> ul(mtx);
while(!flag)//当flag为true时,A不被阻塞,A线程执行
{
cv.wait(ul); //等待条件变量满足
}
cout << this_thread::get_id() << ":" << i << endl;
i += 2;
flag = false;
cv.notify_one(); //唤醒条件变量下等待的一个线程
}
});
//偶数
thread B([&]{
int j = 2;
while (j <= n)
{
unique_lock<mutex> ul(mtx);
while(flag)//当flag为true时B被阻塞,A线程执行
{
cv.wait(ul); //等待条件变量满足
}
cout << this_thread::get_id() << ":" << j << endl;
j += 2;
flag = true;
cv.notify_one(); //唤醒条件变量下等待的一个线程
}
});
A.join();
B.join();
return 0;
}
当然,wait函数还可以传入一个返回值类型为bool的可调用对象,与第一个版本的简单wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。
所以我们可以省略掉while(flag)这块的逻辑,直接给wait传入一个lambda:
int main()
{
int n = 100;
mutex mtx;
condition_variable cv;
bool flag = true;
//奇数
thread A([&]{
int i = 1;
while (i <= n)
{
unique_lock<mutex> ul(mtx);
cv.wait(ul,[&flag]{return flag;}); //等待条件变量满足
cout << this_thread::get_id() << ":" << i << endl;
i += 2;
flag = false;
cv.notify_one(); //唤醒条件变量下等待的一个线程
}
});
//偶数
thread B([&]{
int j = 2;
while (j <= n)
{
unique_lock<mutex> ul(mtx);
cv.wait(ul,[&flag]{return !flag;}); //等待条件变量满足
cout << this_thread::get_id() << ":" << j << endl;
j += 2;
flag = true;
cv.notify_one(); //唤醒条件变量下等待的一个线程
}
});
A.join();
B.join();
return 0;
}
所以根据这样的设计,我们就可以实现两个线程的交替打印了。
我们在努力扩大自己,以靠近,以触及我们自身以外的世界。 —博尔赫斯谈话录