【C++】多线程(1):线程安全与实现

程序员的问题是你无法预料他在做什么,直到为时已晚。

引言:一个线程不安全的例子

线程是轻量级的进程,每个线程可以独立的运行不同的指令序列,但是线程不独立的拥有资源,依赖于创建它的进程而存在。同一进程中的多个线程共享相同的地址空间,同一进程内的多个线程能够很方便的进行数据共享以及通信,也就比进程更适用于并发操作。
C++11中引入了线程的概念,使用<thread>头文件即可创建线程,以下是一个使用多线程的例子,模拟抢票的场景。我们创建了两个线程thd1和thd2,来从总数total中购票。

#include <iostream>
#include <thread>

using namespace std;

void myCount(int times,int& total) {
	int num = 0;
	while (total && times--) {
		total--;
		num++;
	}
	cout << num << endl;
}

int main() {
	int total = 100000;
	int times = 50001;
	thread thd1(myCount,times,ref(total)); // 使用ref函数将引用传递给线程
	thread thd2(myCount,times,ref(total));
	thd1.join();//线程thd1开始运行
	thd2.join();//线程thd2开始运行
	cout << total << endl; //显示剩余票数

	return 0;
}

在这段代码中,我们声明了一个函数myCount,此函数有两个参数,times表明抢票的数目,total表示总共的票数。在创建线程时,需要用thread来构造一个线程对象,参数为函数名,函数的参数,特别注意到两个不同参数的区别,对于total我们使用了ref()函数,表明传入线程的是total的引用(否则各个线程操作的不是一个total,如果total是全局变量则不用ref())。
可以看到票数是小于两个times之和的,期望中的结果是一个线程购买到49999张票,另外一个线程购买到50001张票,最后剩余票数为0;
运行结果:

50001
50001
14206 //可能为任意值

结果表示,两个线程都获得了足够的票数,且total没有归零。
这是因为两个线程在对total读写的时候没有实现同步机制,导致出现竞态问题,本文意在讨论多线程安全的C++实现,其中竞态问题的原理不再赘述。为了程序的正确实现,需要对两个线程实现同步(synchronize),也被称为锁。
下面,讨论C++的几种线程同步的实现

各类锁的使用

互斥锁mutex

C++11提供如下4种语义的互斥量(mutex),其中最常用的是mutex独占互斥锁。

std::mutex			 		//独占的互斥量,不能递归使用。
std::time_mutex 			//带超时的独占互斥量,不能递归使用。
std::recursive_mutex 		//递归互斥量,不带超时功能。
std::recursive_timed_mutex  //带超时的递归互斥量。

//mutex的api
void lock(); //给临界区加锁,并且只能有一个线程获得锁的所有权
bool try_lock(); //查看互斥锁是否为锁定状态,不会阻塞线程
void unlock(); //解锁

在例子中,程序访问的临界区变量为total,在线程访问total变量前,需要进行加锁操作,保证同一时刻只有一个线程能够访问到total变量。
使用mutex实现线程安全的代码如下:

#include <mutex>
mutex mtx; //声明互斥锁
void myCount(int times,int& total) {
	int num = 0;
	while (total && times--) {
		mtx.lock(); //加锁
		total--;
		mtx.unlock(); //解锁
		num++;
	}
	cout << num << endl;

这样我们就可以得到正确的结果。

死锁问题

回忆一下操作系统的知识,死锁的形成有四个必要条件:

  1. 互斥条件(Mutual Exclusion):至少有一个资源一次只能被一个进程或线程占用。
  2. 请求与保持条件(Hold and Wait):进程或线程至少需要持有一个资源,等待其他资源时不释放已占有的资源。
  3. 不可剥夺条件(No Preemption):已分配的资源不能被剥夺
  4. 循环等待条件(Circular Wait):存在一个进程或线程的资源申请序列,使得每个进程或线程都在等待下一个进程或线程所持有的资源。

换言之,只要破坏一个条件,就可以解决死锁问题。
想象我们有两个线程对两个共享变量进行操作,因此需要给两个变量加锁,但是每个线程只有对两个变量都改变完才会释放锁,这样就可能导致每个线程各自占用一个锁,程序无法进行下去,一个例子如下:

#include <iostream>
#include <thread>
#include <mutex>

using namespace std;

mutex mtx1, mtx2;

void threadFunction1() {
    cout << "线程1试图对资源1加锁" << endl;
    mtx1.lock();
    cout << "线程1已对资源1加锁" << endl;
    this_thread::sleep_for(chrono::milliseconds(1000));
    cout << "线程1试图对资源2加锁" << endl;
    mtx2.lock();
    cout << "线程1已对资源2加锁" << endl;

    mtx2.unlock();
    mtx1.unlock();
}

void threadFunction2() {
    cout << "线程2试图对资源2加锁" << endl;
    mtx2.lock();
    cout << "线程2已对资源2加锁" << endl;
    this_thread::sleep_for(chrono::milliseconds(1000));
    cout << "线程2试图对资源1加锁" << endl;
    mtx1.lock();
    cout << "线程2已对资源1加锁" << endl;

    mtx1.unlock();
    mtx2.unlock();
}

int main() {
    thread t1(threadFunction1);
    thread t2(threadFunction2);

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

    cout << "程序运行结束";

    return 0;
}

运行程序后,发生死锁,在编写程序时,需要注意对临界区的访问控制。

原子操作 atomic

将数据声明为原子类型,可以在不使用显式的互斥锁前提下,实现线程安全。如将整型i+1的操作,在具体执行时需要进行取数,增加,放回的操作,一旦操作系统发生中断,让其他线程访问到数据i,则很可能导致线程不安全,而原子操作则让这些操作不可以被中断,进而实现了线程安全。
对于我们的例子,通过把total声明为原子类型,可以实现线程安全。

#include <atomic>
//atomic<int> ati(0); //声明方式1
//atomic_int ati = 0; //声明方式2
void myCount(int times,atomic_int& total) {
	int num = 0;
	while (total && times--) {
		total--;
		num++;
	}
	cout << num << endl;
}

原子操作依赖于操作系统实现,速度比加锁操作快。

条件变量

考虑一个多线程的生产者-消费者模型,需要对缓冲区进行加锁,在缓冲区满或者缓冲区空的时候,线程会对其进行轮询,反复的查询和加锁解锁会占用大量资源,影响程序运行效率。
我们可以使用条件变量类(condition_variable)减少轮询从而提高效率。
条件变量是一个同步原语,它可以在同一时间阻塞一个线程或者多个线程,直到其他线程改变了共享变量(条件)并通知,条件变量的优势如下:

  • 条件变量出现使得线程可以在不满足条件进行休眠,将资源让给有需要的其他线程;
  • 协调线程的运行顺序,两个线程之间执行可以变得有序,
  • 支持复杂同步条件的实现
    在条件变量主要有通知和等待两种api,在生产者-消费者模型中,生产者在生产完产品时通知消费者,而消费者没有产品可以消费时,则进行等待,其api如下:
condition_variable cv;
//其中的lock必须是unique_lock
cv.wait(lock,bool istrue); //第二个参数为可选参数
cv.wait_until(lock, abs_time);
cv.wait_for(lock, rel_time);

cv.notify_one();  //唤醒一个等待线程
cv.onotify_all(); //唤醒全部等待线程

使用条件变量时候,需要锁来保证条件变量的互斥访问,通常和condition_variable搭配使用的是unique_lock,下面是一个使用条件变量的生产者-消费者模型

const int BUFFER_SIZE = 10;
queue<int> buffer;
mutex mtx;
condition_variable cv;

void producer() {
    for (int i = 0; i < 20; ++i) {
        int data = i;
        unique_lock<mutex> lock(mtx);
        //使用lambda表达式返回wait可选参数
        cv.wait(lock, [] { return buffer.size() < BUFFER_SIZE; });
        buffer.push(data);
        cv.notify_one();
    }
}

void consumer(int id) {
    for (int i = 0; i < 20; ++i) {
        unique_lock<mutex> lock(mtx);
        cv.wait(lock, [] { return !buffer.empty(); });
        int data = buffer.front();
        buffer.pop();
        cv.notify_one();
    }
}

wait()函数的第二个参数被称为谓词(predicate),当谓词返回 false 时,线程将继续等待。一旦谓词返回 true,线程将被唤醒并继续执行。这里用lambda表达式判断缓冲区的容量状况,以便及时唤醒线程。如果不使用第二个参数,wait()会在等待期间会释放这个锁。当线程被唤醒时,它会重新获取锁,并且继续执行。
在这个例子中,如果不使用谓词,可能导致死锁问题,因为生产者消费者线程在wait()过程中都不会被唤醒,都会一直等待下去。

虚假唤醒

另外,使用条件变量还会导致虚假唤醒问题,虚假唤醒指线程虽然被唤醒,但是实际上不能运行的情况。

  • 单生产者单消费者:notify_one notify_all都不会出现虚假唤醒。此时notify_one等于notify_all,只通知一个自然不会出现虚假唤醒;
  • 单生产者多消费者,notify_one不会出现虚假唤醒,notify_all会出现。消费者可能同时收到商品到货通知,但是货物只可能有一个,其他消费者被唤醒,但是没有商品可供消费;
  • 多生产者单消费者,notify_one notify_all都不会出现虚假唤醒。此时notify_one等于notify_all,你多次通知给同一个消费者,消费者中总是有商品可供消费;
  • 多生产者多消费者,notify_one notify_all都会出现虚假唤醒。多个生产者发出notify_one,如果一个消费者处理完所有货物(黄牛),其他消费者将无货可用;多个生产者发出notify_all更加是如此。

使用条件变量等待的循环方式,以避免虚假唤醒。

虚假唤醒部分摘录了这篇博客的内容

互斥量模板类

RAII机制

RAII(Resource Acquisition Is Initialization)是C++语言的一种管理资源、避免泄漏的机制。
RAII 机制需要获取使用资源RES的时候,构造一个临时对象T,在其构造T时获取资源,在T生命期控制对RES的访问使之始终保持有效,最后在T析构的时候释放资源。以达到安全管理资源对象,避免资源泄漏的目的。因为C++标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用。
说白了,就是把将需要管理的资源放入对象中,最后对象析构了资源也就自动释放了。
之前的文章提到C++11的智能指针,也是使用了RAII的思想。

C++11标准库中的互斥量封装类

C++11标准库中封装好了lock_guard 和 unique_lock 两个RAII风格的锁,用于在作用域内自动管理互斥量的锁定和解锁,当锁对象被创建时,它会自动锁定互斥量,当对象离开作用域时,它会自动解锁互斥量。
lock_guard比较简单,unique_lock是更灵活的锁,它允许手动锁定和解锁互斥量,延迟加锁、条件变量、超时等。
lock_guard 对象不能复制或移动,因此它只能在局部作用域中使用,其使用方式如下:

mutex mtx;

void myCount(int times, int& total) {
	int num = 0;
	while (total && times--) {
		lock_guard<std::mutex> lock(mtx);
		total--;
		num++;
	}
}

注意,当使用{}括起来的代码块创建std::lock_guard对象时,std::lock_guard的生命周期将仅限于该代码块内部。
对于unique_lock而言,其多样的api体现出其灵活性

  • lock() 尝试加锁,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁
  • try_lock() 尝试加锁,如果当前互斥量已经被其他线程持有,则函数立即返回 false,否则返回 true
  • try_lock_for(sometime) 尝试加锁,直到等待超过设定的时间段或成功
  • try_lock_until(abs_time) 尝试加锁,直到超过设定的时间点或成功
  • unlock():对互斥量进行手动解锁
    unique_lock的演示在条件变量的使用中已经演示过,此处不赘述。

手搓一个lock_guard类

根据前面对标准库中lock_guard的分析,lock_guard类实现较为简单,只要让锁在对象生命周期内起作用,生命周期结束释放,且保证不能赋值和移动。

class myLock {
public:
	explicit myLock(mutex& mtx) : _mtx(mtx) { _mtx.lock(); }
	myLock(const myLock&) = delete; //禁止复制
	myLock& operator=(const myLock&) = delete;  //禁止构造函数
	~myLock() { _mtx.unlock(); }
private:
	mutex& _mtx;
};

在前面的代码中,把myLock类代替lock_guard类可以得到正确的结果。
这篇文章分析了C++11实现多线程同步的几种方法,在下一篇文章中我们将实现一个C++11线程池。

才疏学浅,如有错误还请不吝赐教。

参考:
https://www.zhihu.com/tardis/bd/art/194198073
https://zhuanlan.zhihu.com/p/547970094
https://blog.csdn.net/10km/article/details/49847271

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值