C++11新特性(多线程)

89 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第26天 @[toc]

一、thread类

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

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

1.线程对象可以关联一个线程,用来控制线程和获取线程的状态。 2.当创建一个线程之后,没有提供任何线程函数,该对象没有对应的任何线程。 下面来写一个简单的创建线程的小程序:

void Func(int n)
{
    cout << n << endl;
}
int main()
{
    thread();//创建一个空线程,什么都不做
    thread t1(Func,10);
    thread t2([](int n){cout << n<<endl; }, 20);
    t1.join();
    t2.join();
}

在这里插入图片描述 有一个细节需要注意,那就是thread在向函数传参的时候,不能使用左值引用进行传参:

void Func(int& n)//用引用接收会发生错误
{
    cout << n << endl;
}
int a=10;
thread t1(Func,a);

这里引入了一个ref函数来满足这一操作。即:

thread t1(Func,ref(a));

二、互斥锁

在C++11中,Mutex共包含了四个互斥量的种类:

1.四种锁

(1)mutex

函数名函数功能
lock()上锁
unlock()解锁
try_lock()非阻塞获取锁

当线程函数调用lock()的时候,有以下三种情况:

  • 锁没有被取走,直接获取锁。
  • 当前线程已经有该锁了,形成死锁。
  • 锁被其他线程取走,进行阻塞等待。

当函数调用try_lock的时候,有以下三种情况:

  • 锁没有被取走,直接获取锁。
  • 该线程已经有该锁了,产生死锁。
  • 锁被其他线程取走,会返回false,而不是被阻塞。

(2)recursive_mutex

允许递归上锁,来过得互斥对象的多层所有权,在释放互斥量的时候也需要采用等量的unlock来进行解锁。

(3)time_mutex

比mutex多了两个成员函数.

函数名函数功能
try_lock_for()接受一个时间范围,表示在一段时间范围之内,线程如果没有获得锁则被阻塞住,如果此期间其他线程释放了锁,则该线程可以获得锁,如果超时,返回false
try_lock_util()接受一个时间点作为参数,在指定时间未到来之前,线程如果没有获得锁则被阻塞住,如果此期间其他线程释放了锁,则该线程可以获得锁,如果超时返回false

(4)recurive_timed_mutex

使用的比较少,这里不多介绍。

2.lock_guard

lock_guard是C++11中定义的模板类。主要通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁的问题。 缺陷:太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。

3.unique_lock

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

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

4.锁的原理

我们都知道,锁时来控制信号量的,防止两个线程对信号量进行混乱的修改。

static int x=0;
void Func1(int n)
{
    for (int i = 0; i < n; i++)
    {
        cout << this_thread::get_id() << "->" << x << endl;
        ++x;
    }
}
void Func2(int n)
{
    for (int i = 0; i < n; i++)
    {
        cout << this_thread::get_id() << "->" << x << endl;
        ++x;
    }
}
int main()
{
    thread t1(Func1, 10);
    thread t2(Func2, 20);
    t1.join();
    t2.join();
}   

在这段代码中,t1和t2两个线程对同一个信号量进行++操作,由于底层的++操作的汇编代码是多行的,可能会导致x的数值混乱,因为对于一个++操作来说,它底层的汇编大概分为三步,分别是ld,++,sd 在这里插入图片描述 假设当一个线程该执行完读入和x++后,被切走了,第二个线程读入并执行++多次,然后写回。第一个线程时间片再到来的时候,会带着它的上下文数据,发现该进行++了,就进行++操作,然后写回。这就导致线程2做的工作全都白做了。因此会造成混乱。 因此需要引入锁这一对象: 在这里插入图片描述 当线程1到来时,抢到锁之后会将al寄存器的值(初值为0)与内存中锁的值进行交换,当时间片结束之后,带着它的上下文数据离开。当线程2到来的时候也会和内存中的mutex的值进行交换,只不过此时mutex的值是0,最终线程2的al寄存器的值也为0,因此就可以通过al寄存器的值来判断谁拿到了锁,从而让它对临界资源进行修改。 那么问题就出现了,我们应该在循环里面进行加锁操作还是在循环外面进行加锁操作呢? 如果在循环外面加锁,就相当于两个线程串行运行了,降低了效率。但如果加在里面虽然是并行运行,这样频繁的加锁解锁是需要消耗资源的。 这里我们选择加在循环的外面,因为++x执行的太快了,不适合频繁的加锁解锁。

void Func1(int n)
{
    mtx.lock();
    for (int i = 0; i < n; i++)
    {
        cout << this_thread::get_id() << "->" << x << endl;
        ++x;
    }
    mtx.unlock();
}

三、原子操作

C++将原子操作也封装成了一个对象: 在这里插入图片描述 原子类型支持多个原子操作的函数: 在这里插入图片描述 可以将上文中的x定义为原子类型,表示的是一条汇编语句就执行了他的++操作。

atomic<int> x = 0;
void Func1(int n)
{
    //mtx.lock();
    for (int i = 0; i < n; i++)
    {
        cout << this_thread::get_id() << "->" << x << endl;
        ++x;
    }
    //mtx.unlock();
}

这样书写和加锁的操作是一样的。

四、简单的线程池

int main()
{
    atomic<int> x = 0;
    int N, M;
    cin >> N >> M;
    vector<thread> vthds;
    vthds.resize(N);
    for (int i = 0; i < vthds.size(); i++)
    {
        vthds[i] = thread([M, &x] 
            {
                for (int i = 0; i < M; i++)
                {
                    ++x;
                }
            });
    }
    for (auto& e : vthds)
    {
        e.join();
    }
}

我们可以将vector的每一个元素的类型都设为thread类型,在循环调用,循环等待,就可以完成线程池的工作了。

五、条件变量

假设我们设计一个程序,让线程1和线程2交替进行打印,线程1打印奇数,线程2打印偶数。很容易想到要使用加锁操作进行解决:

int main()
{
    int n = 100;
    int  i = 0;
    mutex mtx;
    thread t1([n, &i, &mtx] 
        {
            while (i < n)
            {
                mtx.lock();
                cout << this_thread::get_id() <<"->" << i << endl;
                ++i;
                mtx.unlock();
            }
        });
    thread t2([n, &i, &mtx]
        {
            while (i < n)
            {
                mtx.lock();
                cout << this_thread::get_id() <<"->" << i << endl;
                ++i;
                mtx.unlock();
            }
        });
    t1.join();
    t2.join();
    return 0;
}

在这里插入图片描述 当运行这段代码的时候很快就能发现问题:我们无法控制两个线程交替抢锁,在大部分的时候都是第一个线程抢到了锁。 这就无法满足我们交替进行打印的条件。这是因为当线程1抢到锁的时候线程2被阻塞住了,那么如何控制两个线程进行交替执行呢? 此时就需要引入条件变量:condition_variable,它提供了几个函数供我们选择:

函数作用
wait阻塞,直到被notify
wait_for最多等待多长时间
notify_one唤醒一个线程
notify_all唤醒所有线程
void wait (unique_lock<mutex>& lck);
void wait (unique_lock<mutex>& lck, Predicate pred);

注意当某个线程调用了wait函数的时候,会调用unlock()释放锁,一旦被notify了,会立刻调用lock()获取锁。因此调用wait的时候需要传入锁。对于第二种方式来说第二个参数表示的是一个标记,只有当prep返回值为false的时候才会发生阻塞,相当于

while(!prep())
	wait(lock);

对于notify_one函数来说,当有线程在该条件变量上阻塞的时候,会通知其开始抢锁,当没有线程在条件变量上阻塞的时候,什么都不会做。 我们可以使用条件变量的等待-通知机制来完成两个线程的交替执行:

	int n = 100;
	int  i = 0;
	mutex mtx;
	condition_variable cv;
	bool flag = false;
	thread t1([n, &i, &mtx,&cv,&flag] 
		{
			while (i < n)
			{
				unique_lock<mutex> lock(mtx);
				cv.wait(lock, [&flag](){return flag; });
				cout << this_thread::get_id() <<"->" << i << endl;
				++i;
				flag = false;
				cv.notify_one();
			}
		});
	thread t2([n, &i, &mtx,&cv,&flag]
		{
			while (i < n)
			{
				unique_lock<mutex> lock(mtx);
				cv.wait(lock, [&flag]() {return !flag; });
				cout << this_thread::get_id() <<"->" << i << endl;
				++i;
				flag = true;
				cv.notify_one();
			}
		});
	t1.join();
	t2.join();

注意,添加环境变量调用wait函数的时候,需要将锁进行封装成unique_lcok类型,该类型会在创建时调用构造函数自动上锁,在销毁的时候会调用析构函数自动解锁。 分析这一段代码,flag的初始值为false:

  • 假设线程1先抢到了锁,此时flag的值为false,线程1就会在条件变量处发生阻塞,然后释放锁。
  • 线程2抢到锁,对i进行++操作,并将flag的值置为true,然后通知线程1停止阻塞。
  • 即使下一次还是线程2抢到了锁,但是flag的值已经变成了true线程2会在wait的地方阻塞住,下一次开始执行++i的只能是线程1
  • 线程1执行之后将flag设为false,这样下一次线程1就会被阻塞住,而线程2就会运行。
  • 假设线程2先抢到锁的情况同理。

此时的运行结果是: 在这里插入图片描述