目录
- 七、生产者消费者模型
-
- [7.1 生产者消费者模型的理解](#7.1 生产者消费者模型的理解)
- [7.2 基于BlockingQueue的生产者消费者模型](#7.2 基于BlockingQueue的生产者消费者模型)
- 八、POSIX信号量
-
- [8.1 信号量的回顾](#8.1 信号量的回顾)
- [8.2 POSIX信号量的相关接口](#8.2 POSIX信号量的相关接口)
- [8.3 基于环形队列的生产消费模型](#8.3 基于环形队列的生产消费模型)
-
- [8.3.1 基于环形队列的生产消费模型的原理](#8.3.1 基于环形队列的生产消费模型的原理)
- [8.3.2 基于环形队列的生产消费模型的实现](#8.3.2 基于环形队列的生产消费模型的实现)
- 九、读者写者问题(了解)
-
- [9.1 读者写者模型的理解](#9.1 读者写者模型的理解)
- [9.2 读写锁相关接口](#9.2 读写锁相关接口)
- [9.3 读者写者模型的三种策略](#9.3 读者写者模型的三种策略)
-
- [9.3.1 读者优先策略](#9.3.1 读者优先策略)
- [9.3.2 写者优先策略](#9.3.2 写者优先策略)
- [9.3.3 读写公平策略](#9.3.3 读写公平策略)
- 十、自旋锁(了解)
-
- [10.1 自旋锁的概述](#10.1 自旋锁的概述)
- [10.2 自旋锁的相关接口](#10.2 自旋锁的相关接口)
- [10.3 优缺点](#10.3 优缺点)
- [10.4 注意事项](#10.4 注意事项)
- 十一、STL、智能指针和线程安全
-
- [11.1 STL中的容器是否是线程安全的?](#11.1 STL中的容器是否是线程安全的?)
- [11.2 智能指针是否是线程安全的?](#11.2 智能指针是否是线程安全的?)
- 结尾
上一篇文章中我讲述线程的一些概念、线程控制、线程互斥、可冲入与线程安全、常见锁概念以及线程同步,而这篇文章文章我将讲述生产者消费者模型、POSIX信号量、读写者模型、自旋锁以及STL、智能指针和线程安全。
七、生产者消费者模型
7.1 生产者消费者模型的理解
在我们生活中超市就是一个生产者消费者模型,供应商就代表着生产者,消费的人就是消费者,对应操作系统中的概念,生产者就是线程 ,消费者也是线程 ,超市中的商品就是数据 ,而超市中买来的商品是用来卖的,所以超市需要有保存商品的能力,对应的就是内存,往后讲就是基于特定空间的数据结构或容器,生产者消费者模型本质上就是来进行执行流之间的数据传递的。
供应商(生产者 )之间不用说肯定就是竞争关系,对应操作系统中就是互斥关系。
消费的人(消费者 )之间在我们平时看了,好像各买各的,并没有互相影响,那是因为商品足够多,在商品很少的时候,就会对对方造成影响了,所以消费者之间对应的就是竞争关系,对应操作系统中就是互斥关系。
供应商(生产者 )和消费的人(消费者 )之间的关系,差不多就是供应商在货架上供货的时候,消费的人就不会去货架上拿东西,消费的人在拿东西的时候,供应商也不会在货架上供货,超市为了更好的盈利,会在没货的时候叫供货商来供货,在有货的时候呼吁大家来买,维护了供货和买的顺序性,所以生产者和消费者之间的关系就是互斥和同步。
为了让大家方便记忆和表述,这里提出321原则(并不是官方定义的)
- 3 :3种关系
- 2 :2种身份
- 1 :1个交易场所
我们之前在单线程中进行模块间传递数据,大多数都会用到函数,但是但我们调用函数时,就必须等待这个函数执行完返回后,才能够执行后序的代码,而这里我们使用生产者消费者模型后,使用两个线程,线程2就是上面函数需要执行的内容,线程1只需要将线程2所需要的数据,放入到内存空间中,让线程2自己去拿即可,在线程2执行的过程中,即使线程2执行的很慢,线程1执行的很快,线程1还可以不断的向内存中放入数据,这就体现了生产者消费者模型的一个优点,支持闲忙不均,还通过内存实现了两个模块之间的执行的解耦,使两个模块直接支持了并发。
7.2 基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
这里先提前讲述一个消费者生产者模型的优点就是高效,这里我基于下面的阻塞队列进行讲述,在下面的阻塞队列中,我们让生产者将数据传递给消费者时,实现了同步与互斥,所以阻塞队列在同一个时刻只能由一个线程能够放入资源或取走资源,那和我们使用一个线程直接通过调用函数完成任务有什么区别呢?那么多生产者和多消费者的意义是什么呢?
消费者生产者模型的高效并不是体现在同步与互斥这里的,生产者将资源放到阻塞队列之前需要获取资源,获取资源需要花费时间,消费者将资源从阻塞队列中拿出来后,还需要对资源进行处理,也是需要时间的,所以我们不能单纯考虑阻塞队列中资源的放入和拿取。消费者生产者模型的高效体现在生产者在获取资源的时候,消费者可以从阻塞队列中拿取资源或对资源进行处理,消费者在处理资源的时候,生产者可以获取资源,也可以将资源放入到阻塞队列中去 。多生产者和多消费者的意义就是,多个生产者可以同时获取资源,多个消费者可以同时处理资源,可以提高并发度。生产者消费者模型最大的意义就是在保证资源安全的前提下,提高获取资源的过程和处理资源的过程的并发度。
C++ queue模拟阻塞队列的生产消费模型
cpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
using namespace std;
const int defaultcapacity = 5; // 默认容量为5
template<class T>
class BlockQueue
{
public:
BlockQueue(int capacity = defaultcapacity)
:_capacity(capacity)
{
// 锁和条件变量的初始化
pthread_mutex_init(&_mutex,nullptr);
pthread_cond_init(&_p_cond,nullptr);
pthread_cond_init(&_c_cond,nullptr);
}
bool IsFull()
{
return _q.size() == _capacity;
}
bool IsEmpty()
{
return _q.size() == 0;
}
void Push(const T& in)
{
// 访问临界资源先申请锁
pthread_mutex_lock(&_mutex);
while(IsFull()) // 如果满了就不能继续生产,线程进行等待
{
pthread_cond_wait(&_p_cond,&_mutex);
}
_q.push(in);
// 生产成功后,就可以通知消费者来买了,这里可以生成了就通知,也可以定义某种策略
pthread_cond_signal(&_c_cond);
// 解锁
pthread_mutex_unlock(&_mutex);
}
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
while(IsEmpty()) // 如果为空就不能继续消费,线程进行等待
{
pthread_cond_wait(&_c_cond,&_mutex);
}
*out = _q.front();
_q.pop();
// 消费成功后,就可以通知生产者继续生产了,这里可以消费了就通知,也可以定义某种策略
pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
// 锁和条件变量的销毁
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_p_cond);
pthread_cond_destroy(&_c_cond);
}
private:
queue<T> _q;
int _capacity; // 容量
pthread_mutex_t _mutex;
pthread_cond_t _p_cond; // 生产者的等待条件
pthread_cond_t _c_cond; // 消费者的等待条件
};
在我们模拟的阻塞队列中,下图我们圈出来的部分需要使用while,而不使用if,这是因为在pthread_code_wait函数这里存在一种状态叫做伪唤醒,就是对应条件并不满足,但是线程却被唤醒了,这里举个例子,假设有三个消费线程和一个生产线程,阻塞队列中没有数据了,但所以三个消费线程就在它们对应的条件变量下进行等待,当生产线程生产了一个数据就将三个线程全部唤醒后,若第一个线程竞争到锁后,取走了阻塞队列中的数据,生产线程并没来得及生产第二个数据,然后第二个线程就竞争到锁,但是阻塞队列中已经没有数据了,如果这里使用if,线程就会执行下面的代码去阻塞队列中取走数据,这就可能导致程序出现问题,如果这里使用的是while,线程就会重新判断阻塞队列中是否有数据,没有的话就继续在改条件变量下进行等待,这里使用while就增强了代码的健壮性。三个生产线程和一个消费线程也是同样的道理。
上面模拟了消息队列,那么这里我就先用单生产者和单消费者简单的使用一下,我们以整形数字为资源,在下面的代码中,我们让生产线程一直生产,而消费线程每个一秒消费一次,运行程序观察现象,我们发现生产线程一下子就生产了一堆资源,然后就是消费线程消费一个,生产线程就生产一个,并且消费线程每次消费的都是阻塞队列中最先被生产的资源。这里明显生产的速度要快于消费,这里生产者消费者模型的步调就以消费者为主,出现了很强的同步特点。
这里我们交换一下,让生产线程每隔一秒生产一次,消费线程一直消费,运行程序观察现象,我们发现这次是生产线程生产一个,消费线程就消费一个。这里明显消费的速度要快于生产,这里生产者消费者模型的步调就以生产者为主,出现了很强的同步特点。
我们不仅仅可以让单生产者和单消费者之间进行资源传递,还可以让多个生产者和多个消费者之间进行资源传递,在下面的代码中,我们创建三个生产者和两个消费者,运行代码观察现象,
上面我们使用整数作为资源,实际上资源不仅仅可以是基本数据,还可以让生产者给消费者分配任务,由于我们没有学习网络,这里就用基本运算作为任务,下面我们就以类对象作为资源进行操作。
cpp
#pragma once
#include <string>
using namespace std;
const int defaultreslut = 0;
enum
{
ok = 0,
div_zero,
mod_zero,
unknow
};
string ops = "+-*/%()$#";
class Task
{
public:
Task()
{}
Task(int x, int y, char op)
: data_x(x), data_y(y), _op(op), result(defaultreslut), code(ok)
{}
void Run()
{
switch (_op)
{
case '+':
result = data_x + data_y;
break;
case '-':
result = data_x - data_y;
break;
case '*':
break;
result = data_x * data_y;
case '/':
{
if (data_y == 0)
code = div_zero;
else
result = data_x / data_y;
}
break;
case '%':
{
if (data_y == 0)
code = mod_zero;
else
result = data_x % data_y;
}
break;
default:
code = unknow;
break;
}
}
string PrintTask()
{
string s;
s += to_string(data_x);
s += _op;
s += to_string(data_y);
s += " = ?";
return s;
}
string PrintResult()
{
string s;
s += to_string(data_x);
s += _op;
s += to_string(data_y);
s += " = ";
s += to_string(result);
s += " [";
s += to_string(code);
s += "]";
return s;
}
~Task()
{
}
private:
int data_x;
int data_y;
char _op;
int result;
int code; // 为0代表答案有效
};
下面就让生产者不断的生产任务,消费者每隔一段时间就读取任务并执行任务,运行程序观察现象,生产者先生产了一些任务,然后消费者执行一个任务,生产者就再生产一个任务。
上面实现了单生产者给单消费者分配任务,这里我们再实现一下多个生产者给多个消费者分配任务,在下面的代码中,我们创建三个生产线程和两个消费线程,并让生产线程在很短的时间内不短给消费线程分配任务,运行程序观察现象,我们发现多个消费线程可以同时处理任务,即使它们在拿任务的时候是互斥的,但是如果处理任务的时间比拿取任务的时间多很多,做到多个线程同时处理任务,就可以节省非常多的时间,从而提高整体的效率。
八、POSIX信号量
8.1 信号量的回顾
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
我们在进程间通信中讲到过SystemV信号量,这里简单提及一下。
- 信号量的本质就是一个计数器
- 申请信号量本质是资源的预定机制
- PV操作是原子的
我们上面模拟等待队列的时候,是将整个公共资源当做整体的,如果说我们将公共资源分为很多部分,多个线程访问公共资源的不同部分,就可以避免多线程访问导致的数据不一致问题。
假设我们将公共资源分为五份,就不可能让超过五个的线程访问公共资源,所以我们可以使用信号量进行保护,将信号量的值设置为五,线程访问公共资源时,需要先申请信号量,当有五个线程申请到信号量,后序的信号量都会申请失败。
上面我们在使用线程访问公共资源的时候,需要先申请到锁,然后还需要判断公共资源是否满足访问的条件,而这里申请信号量成功后,就不需要判断公共资源是否满足访问条件了,因为申请信号量本质是资源的预定机制,所以只要申请信号量成功后,就代表线程可以访问公共资源。
8.2 POSIX信号量的相关接口
信号量的初始化
cpp
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
- sem:指向信号量结构的一个指针。这个信号量将被初始化。
- pshared :指明信号量是由进程内线程共享,还是由进程之间共享。
如果 pshared 的值为 0,那么信号量将被进程内的线程共享。
如果 pshared 是非零值,那么信号量将在进程之间共享。 - value:指定信号量的初始值。这个值表示可用的资源数目,即信号量的初始计数。
返回值:
- 成功时,sem_init 返回 0。
- 错误时,sem_init 返回 -1,并把 errno 设置为合适的值。
信号量的销毁
cpp
#include <semaphore.h>
int sem_destroy(sem_t *sem);
参数:
- sem:指向要销毁的信号量对象的指针。这个信号量必须是之前通过 sem_init 或 sem_open 成功初始化的。
返回值:
- 如果传入的信号量指针无效,sem_destroy 将返回 -1,并设置 errno 以指示错误类型。
信号量的等待
cpp
#include <semaphore.h>
int sem_wait(sem_t *sem); // P操作
参数:
- sem:指向要等待的信号量对象的指针。
返回值:
- 成功时,sem_wait 返回 0。
- 如果调用过程中发生错误(例如,信号量指针无效),sem_wait 返回 -1,并设置 errno 以指示错误类型。
信号量的发布
cpp
#include <semaphore.h>
int sem_post(sem_t *sem); // V操作
参数:
- sem:指向要增加的信号量对象的指针。
返回值
- 成功时,sem_post 返回 0。
- 如果调用过程中发生错误(例如,信号量指针无效),sem_post 返回 -1,并设置 errno 以指示错误类型。
8.3 基于环形队列的生产消费模型
环形队列是一种特殊的队列数据结构,它可以通过循环利用数组中的空间来实现队列的操作,它可以保证了元素是先进先出。
8.3.1 基于环形队列的生产消费模型的原理
首先让生产者和消费者指向同一个位置,这时候队列中是没有资源的,所以只能由消费者先进行生产,当生产者不断的生产,将队列填充满后,生产者就不能继续生产了,只能由消费者先消费,当消费者将队列中的资源全部消费完后,重复上面的操作。
得出结论:
- 生产者不能超过消费者一圈
- 消费者不能超过生产者
这里的生产者和消费者只有在下面两种情况下会指向同一个位置:
- 为空,只能让生产者跑
- 为满,只能让消费者跑
这里的只能 就体现出了互斥 ,而让生产者/消费者跑 则体现出了同步 ,由于只有上面两个情况下会出现互斥与同步,所以只需要局部维持互斥与同步 ,大部分时候,生产者和消费者都会指向不同的位置,这时候多线程就可以并发的访问临界区 了。
这里我们对资源进行简单的理解,我们普遍认为只有数据是资源,但是实际上并不是只有数据可以是资源,在这里对于消费者来说数据是资源,但是对于生产者来说空间才是资源。
当最开始的时候,生产者和消费者指向同一个位置,此时空间资源的个数为N,数据资源的个数为0,当生产者进行生产时,需要先申请空间信号量资源,然后会使空间资源减一,当生产任务完成后,数据资源则会加一,当生产者将空间资源全部消耗完毕后,此时空间资源为0,数据资源为N,生产者则不能继续生产了,只能由消费者进行消费,当消费者进行消费时,需要先申请数据信号量资源,然后使数据资源建议,当消费任务完成后,空间资源则加一,当消费者将数据资源全部消耗完毕后,此时空间资源的个数为N,数据资源的个数为0。
当数据资源和空间资源都不为0的时候,就代表着消费者和生产者此时指向的位置必定不是同一个,此时消费者和生产者就可以并发访问临界区了。
8.3.2 基于环形队列的生产消费模型的实现
C++ vector模拟环形队列的生产消费模型
cpp
#pragma once
#include <iostream>
#include <vector>
#include <pthread.h>
#include <semaphore.h>
using namespace std;
const int defaultsize = 5;
template<class T>
class CircularQueue
{
public:
CircularQueue(int size = defaultsize)
:_circular_queue(size),_size(size),_p_pos(0),_c_pos(0)
{
sem_init(&_space_sem,0,size);
sem_init(&_data_sem,0,0);
pthread_mutex_init(&_p_mutex,nullptr);
pthread_mutex_init(&_c_mutex,nullptr);
}
void P(sem_t& sem)
{
sem_wait(&sem);
}
void V(sem_t& sem)
{
sem_post(&sem);
}
void Push(const T& in)
{
// 这里一般都是先申请信号量然后再申请锁
// 因为当多个消费线程申请完信号量后
// 还是只有一个消费线程可以申请到锁,其他的消费线程都要在这里卡住
// 当消费结束后,线程释放锁,其他线程申请到锁后,就可以直接访问临界区了
// 如说先申请锁再申请信号量
// 还是只有一个消费线程可以申请到锁,但是它申请到锁后还需要申请信号量
// 当消费结束后,线程释放锁,其他线程申请到锁后,还需要申请信号量
// 这势必就比上面的方式要慢一点了
P(_space_sem);
pthread_mutex_lock(&_p_mutex);
_circular_queue[_p_pos] = in;
_p_pos++;
_p_pos %= _size;
pthread_mutex_unlock(&_p_mutex);
V(_data_sem);
}
void Pop(T* out)
{
P(_data_sem);
pthread_mutex_lock(&_c_mutex);
*out = _circular_queue[_c_pos];
_c_pos++;
_c_pos %= _size;
pthread_mutex_unlock(&_c_mutex);
V(_space_sem);
}
~CircularQueue()
{
sem_destroy(&_space_sem);
sem_destroy(&_data_sem);
pthread_mutex_destroy(&_p_mutex);
pthread_mutex_destroy(&_c_mutex);
}
private:
vector<T> _circular_queue;
int _size;
int _p_pos; // 生产者的位置
int _c_pos; // 消费者的位置
sem_t _space_sem; // 空间信号量
sem_t _data_sem; // 数据信号量
pthread_mutex_t _p_mutex; // 生产者的锁
pthread_mutex_t _c_mutex; // 消费者的锁
};
这里我们先使用单消费者单生产者进行测试,让生产者每隔一秒生产一次,让消费者不停的消费,运行程序观察现象,我们发现消费者完全是跟着生产者的步调,生产者每生产一个资源,消费者就消费一个资源。
这里我们调整一下顺序,让生产者不停的生产,而消费者每隔一秒消费一次,运行程序观察现象,生产者先是生产了一批资源,然后生产者就跟随着消费者的步调,消费者消费一个资源,生产者就生产一个资源。
同样环形队列中的数据资源不仅仅可以是基本数据,还可以让生产者给消费者分配任务,下面我们就以类对象作为资源进行操作,任务的代码我们在阻塞队列那里有,由于字数太多就不在这里贴出来了,需要的可以在上面看到。
在下面的代码中我们让生产者一直进行,消费者每隔一段时间进行消费,运行程序,我们发现生产者先是生产了一批任务,然后生产者就是跟随着消费者的步调,消费者消费一个,生产者就生产一个。
上面实现了单生产者给单消费者分配任务,这里我们再实现一下多个生产者给多个消费者分配任务,在下面的代码中,我们创建三个生产线程和两个消费线程,并让生产线程在很短的时间内不短给消费线程分配任务,运行程序观察现象,我们发现多个消费线程可以同时处理任务,即使它们在拿任务的时候是互斥的,但是如果处理任务的时间比拿取任务的时间多很多,做到多个线程同时处理任务,就可以节省非常多的时间,从而提高整体的效率。
九、读者写者问题(了解)
9.1 读者写者模型的理解
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。
读者写者问题同样要遵守上面的"321原则"(并非官方命名)
- 3 :3种关系(读者与读者、写者与写者、读者与写者)
- 2 :2种身份(读者、写者)
- 1 :1个交易场所
这里以画画举例,一个人在画的时候,就不能让其他人一起画,并且这时候也不能让别人看,所以这就是读者之间的互斥关系,读者与写者之间的互斥关系。
这个人画完了以后,需要有人来看,不然画画就没有意义了,然后将画进行展示,此时可以有很多人一起来看,并且在这些人看的期间,这个人是不能继续画的,当这些人看完以后,还想看就可以让这个人继续画,这就是读者与写者之间的同步与互斥关系,写者与写者之间的并发关系,也就是没关系。
- 读者与读者:没有关系(并发)
- 写者与写者:互斥
- 读者与写者:互斥与同步
下面是三段伪代码,首先定义了一个变量记录读者的人数,再定义一把读者锁和一把写者锁。
当读者要读的时候,需要先申请读者锁,然后判断读者人数为0,则当前没有人来读,申请写者锁,防止其他人进来写,再将读者的人数加一后,为了让其他读者进来读,需要释放读者锁,然后就可以执行读取操作了,读取完毕后,再申请读者锁,将读者的人数减一,如果这是最后一个人,就需要将写者锁释放,最后在释放读者锁。当读者进来读是人数不为0或走的时候不是最后一个人,执行玩++、- -操作后,不用释放写者锁就可以释放写者锁走了,因为还有人读,防止其他人进来写。
当写者要写的时候,需要先申请写者锁,就可以执行写的操作了,此时有人想读/写就会阻塞在申请写者锁这里了,执行完写操作后,释放写者锁就结束了。
9.2 读写锁相关接口
初始化读写锁
cpp
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *rwlock,
const pthread_rwlockattr_t *attr);
参数:
- rwlock:指向要初始化的读写锁。
- attr:读写锁的属性,attr可以为NULL,表示使用默认属性。
返回值:
- 成功返回0,失败返回错误码。
销毁读写锁
cpp
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
参数:
- rwlock:指向要销毁的读写锁。
返回值:
- 成功返回0,失败返回错误码。
读取锁定
cpp
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
获取一个读锁,如果锁已被其他线程持有(无论是读锁还是写锁),则调用线程会被阻塞。
参数:
- rwlock:指向要锁定的读写锁。
返回值:
- 成功返回0,失败返回错误码。
写入锁定
cpp
#include <pthread.h>
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
获取一个写锁,如果锁已被其他线程持有(无论是读锁还是写锁),则调用线程会被阻塞。
参数:
- rwlock:指向要锁定的读写锁。
返回值:
- 成功返回0,失败返回错误码。
解锁
cpp
#include <pthread.h>
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
参数:
- rwlock:指向要解锁的读写锁。
返回值:
- 成功返回0,失败返回错误码。
9.3 读者写者模型的三种策略
9.3.1 读者优先策略
在这种策略中,读者具有较高的优先级。如果有一个读者想要读取资源,而当前没有写者正在写入,那么读者可以立即开始读取,而无需等待其他读者。但是,如果有写者正在写入或想要写入,读者必须等待。
- 优点:提高了读者的并发性。
- 缺点:可能导致写者长时间等待。
9.3.2 写者优先策略
在这种策略中,写者具有较高的优先级。一旦一个写者请求写入资源,它将尽快获得访问权,即使这意味着需要等待所有正在读取的读者完成他们的读取操作。
- 优点:减少了写者的等待时间。
- 缺点:可能降低读者的并发性。
9.3.3 读写公平策略
这种策略试图在读者和写者之间找到一个平衡点,确保两者都不会长时间等待。这通常涉及到更复杂的同步机制,如信号量、互斥锁和条件变量。
- 优点:在读者和写者之间提供了更公平的访问。
- 缺点:实现起来更复杂,可能引入额外的开销。
十、自旋锁(了解)
10.1 自旋锁的概述
在我们的生活中,我们和朋友约好一起出去玩,当你已经到楼下给他打电话后,它告诉你还要一个小时,这时候你就会选择去图书馆看看书,让他好了打电话你再过去,如果它告诉你还要一分钟,你就会在楼下等着,到一分钟时,还没下来,你就会再打电话,还没下来就再打,所以在我们生活中我们会根据等待的市场决定我们等待的方式。
我们之前使用的锁都是申请锁失败了就在当前锁下阻塞挂起,这里我们介绍一下自旋锁,自旋锁是一种特殊的锁机制,线程在申请锁失败后,会选择不断的继续申请锁,即不断循环检查锁的状态,直到成功获取锁或达到一定的尝试次数。这种机制的核心思想是通过快速尝试获取锁来减少线程切换的开销,提高并发性能。
10.2 自旋锁的相关接口
初始化自旋锁:
cpp
#include <pthread.h>
pthread_spin_init(pthread_spinlock_t *lock, int pshared)
参数:
- lock:指向要初始化的自旋锁对象的指针
- pshared :这个参数用于指定自旋锁是用于线程间共享还是仅用于线程内共享
- PTHREAD_PROCESS_PRIVATE:表示自旋锁仅在当前进程内的线程之间共享。这是默认值。
- PTHREAD_PROCESS_SHARED:表示自旋锁可以在不同进程间的线程之间共享。
获取自旋锁:
cpp
#include <pthread.h>
pthread_spin_lock(pthread_spinlock_t *lock)
参数:
- lock:指向要锁定的自旋锁的指针。
释放自旋锁:
cpp
#include <pthread.h>
pthread_spin_unlock(pthread_spinlock_t *lock)
参数:
- lock:指向要释放的自旋锁的指针。
销毁自旋锁:
cpp
#include <pthread.h>
pthread_spin_destroy(pthread_spinlock_t *lock)
参数:
- lock:指向要销毁的自旋锁的指针
10.3 优缺点
优点
- 低延迟:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率。
- 减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销。
缺点
-
CPU资源浪费:如果锁的持有时间较长,等待获取锁的线程会-直循环等待,导致CPU资源的浪费。
-
可能引起活锁:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁。
10.4 注意事项
- 在使用自旋锁时,需要确保锁被释放的时间尽可能短,以避免CPU资源的浪费。
- 在多CPU环境下,自旋锁可能不如其他锁机制高效,因为它可能导致线程在不同的CPU上自旋等
十一、STL、智能指针和线程安全
11.1 STL中的容器是否是线程安全的?
STL中的容器不是是线程安全的
原因是, STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
11.2 智能指针是否是线程安全的?
对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作的方式保证 shared_ptr 能够高效,原子的操作引用计数。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹