总结一下线程的中同步与互斥的概念
通过互斥锁实现一个生产消费模型
同时辨析读写锁、自旋锁、RCU锁
临界资源引例:售票系统
设一个全局变量int count = 100;
,表示当前售票系统的剩余的票的数量
有一个线程正在执行购票模块的代码count--;
count--
的执行分为三步
- cpu从内存中读取到当前余票(100)到cpu寄存器中
- cpu对该值进行减法操作
- cpu得到的结果(99)写入内存
假设在第二步刚刚结束时,由于出现大量优先级更高的线程,内核保存当前线程上下文,该线程被挂起。
第二个线程执行同样代码并正常退出,(读出100写入99)
第二个线程执行同样代码并正常退出,(读出99写入98)
此时切回第一个线程,继续执行下一步:写入99到内存
经过上述操作,当前余票应为97,实为99,显然程序出现了错乱
加锁保护临界资源
引起上述问题的根本原因:减法操作不是原子性的
原子性:最小粒度的特性。对于代码来说,要么全部执行,要么都不执行
减法操作在执行中可能会被打断,是非原子性的,同时操作对象可能被打断期间被访问,最终造成混乱
为了解决上述问题,我们可以要求涉及共享变量代码段执行前首先申请执行权限
申请成功后,在执行结束前不允许其它线程申请到执行权限
这种解决方案即为:加一把「互斥锁」
同时:
对于每次仅允许一个进程访问的资源,我们称作临界资源
对于对临界资源进行操作的代码段,我们称作临界区
在Linux的这种解决方案中,这把锁称为:互斥量
通过互斥量,避免临界区的并发操作,即实现了对临界资源的保护
上述概念即为所谓常听见的「同步」与「互斥」中的互斥
互斥是为了保证线程的安全,同步则是在保证线程安全的基础上,协调各进程,使得访问临界资源具有顺序性
避免了线程不合理地频繁「申请锁」与「释放锁」带来的额外开销
互斥实例
通过引入互斥量,线程在执行临界区前需要申请锁,释放前其它线程的申请操作都会被阻塞,进而保证安全
通过一段代码实例来认识这一概念
1 |
|
其中
- 互斥量的初始化
- 动态初始化
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr _t *restrict attr);
- mutex:要初始化的互斥量
- attr:NULL 一般使用NULL表示默认的配置
- 成功返回0,失败返回错误码
- 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 需要静态初始化互斥量时使用,声明、定义、初始化一气呵成,不需要销毁
- 动态初始化
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 加锁
- 如果其它线程已经lock成功,则该线程陷入阻塞,等待解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 解锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 销毁互斥量,不可销毁静态初始化的或者未解锁的互斥量
- 要确保销毁后不再加锁
互斥
在上述售票系统的例子中,如果我们加入第五个线程执行:在票数为0时设置票数为100
则会出现新的问题:
- 在票数减至0前,线程5不断地检测票是否为0,产生不必要的开销
- 线程5在临界区被切出去后发生饿死(一直没有切回来),1~4号线程不断地买票(失败)
那么对于这个问题,则需要协同各个线程合理地按顺序执行,即线程的同步
在本例中,我们通过「条件变量」建立一种生产消费模型
1 |
|
其中
int pthread_cond_init(pthread_cond_t *cond,const pthread_condattr_t *rest rict attr);
- cond:准备初始化的条件变量
- attr:NULL 默认配置
int pthread_cond_destroy(pthread_cond_t *cond)
- 销毁条件变量
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t mutex);
- 等待条件的满足
- 函数一进入wait状态就会自动release mutex,退出时lock mutex
- cond:对应的条件变量
- mutex:相关的互斥量
- 唤醒
int pthread_cond_broadcast(pthread_cond_t *cond);
//唤醒所有等待线程int pthread_cond_signal(pthread_cond_t *cond);
读写锁
互斥锁成功地保证了对临界资源访问的安全性,但额外会带来锁申请、释放的开销
在读写两个线程操作频次差距巨大时(且读操作频繁且耗费时间较长(伴随查找))
两个读线程不会相互影响安全,却因为互斥锁带来了额外的开销,此时可以使用读写锁
互斥锁只有两种状态,而读写锁有三种状态,以文件读写为例
- 无锁
- 读写都不会被阻塞
- 读锁
- 其它读线程可以不阻塞的并发读,写操作申请锁会被阻塞
- 写锁
- 单线程的写操作,任何其他线程(包括其他写线程)都在申请锁时阻塞
根据读写锁的特性,在写锁没有申请时,读线程可以高并发的操作,提升整体性能
读写锁非常适合对数据结构读的次数远远大于写的情况
思考:读线程源源不断时,写线程可能被饿死
所以读模式的线程接受到写请求时,应阻塞其后所有读线程
自旋锁
自旋锁类似于读写锁
但是保护安全的方式不是通过阻塞
而是通过 cpu忙等待(死循环轮询)的方式去等待解锁,以保护线程安全
自旋锁适用:锁被持有的时间较短,而且进程并不希望在重新调度上花费太多的成本
RCU锁
(Read-Copy Update):读-复制 更新
实际上是对读写锁的一种改进,同样对读区别对待,但对待的方式不同
读写锁仅支持多个读操作,RCU还支持多个写操作
RCU基本做法为
读操作不做限制
写操作总是在一个副本上修改,在合适的时机更新原件(所有读者都结束对临界资源的访问后,使用回调函数函数完成对原件的更新)
限于篇幅,RCU深度剖析请移步另一篇博文(TODO)
参考 https://blog.csdn.net/aganlengzi/article/details/51339519
POSIX信号量
与条件变量的对比
条件变量总是与互斥锁一起使用的
那么换一个角度:
- 条件变量和配合互斥锁时
- 互斥锁必须总是有给他上锁的线程解锁,信号量灵活的多
- 互斥锁只有锁住、解开两种状态
- 条件变量做唤醒时,如果没有线程接受信号,信号将丢失
实例
限于篇幅,请移步另一篇博文(TODO)