一文搞懂多线程中各个难点
6.9读写锁
6.9.1什么是读写锁?
大部分情况下,对于共享变量的访问特点:只是读取共享变量的值,而不是修改,只有在少数情况下,才会真正的修改共享变量的值。
在这种情况下,读请求之间是同步的,它们之间的并发访问是安全的。然而写请求必须锁住读请求和其它写请求。
即读线程可多个同时读,而写线程只允许同一时间内一个线程去写。
6.9.2读写锁接口
#include
读写锁的默认属性:
对于调用pthread_rwlock_init初始化的读写锁,在不需要读写锁的时候,需要调用pthread_rwlock_destroy销毁。
6.9.3读者加锁
#include
最大的好处就是,允许多个线程以只读加锁的方式获取到读写锁;
本质上,读写锁的内部维护了一个引用计数,每当线程以读方式获取读写锁时,该引用计数+1;
当释放以读加锁的方式的读写锁时,会先对引用计数进行-1,直到引用计数的值为0的时候,才真正释放了这把读写锁。
6.9.4写者加锁
#include
写锁用的是独占模式,如果当前读写锁被某写线程占用着,则不允许任何读锁通过请求,也不允许任何写锁请求通过,读锁请求和写锁请求都要陷入阻塞,直到线程释放写锁。
6.9.5 解锁
#include
不论是读者加锁还是写者加锁,都采用该接口进行解释。
读者解锁,只有当引用计数为0的时候,才真正释放了读写锁。
6.9.6读写锁的竞争策略
对于读写锁而言,目前有两种策略,读者优先和携着优先;
读写锁的类型有如下几种:
PTHREAD_RWLOCK_PREFER_READER_NP, //读者优先
PTHREAD_RWLOCK_PREFER_WRITER_NP, //很唬人, 但是也是读者优先
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP, //写者优先
PTHREAD_RWLOCK_DEFAULT_NP = PTHREAD_RWLOCK_PREFER_READER_NP
读者优先:读锁来请求可以立即响应,只要有一个读锁没完成,那么写锁就无法写。这种策略是不公平的,极端情况下,写现场很可能被饿死,即线程总是拿不到锁资源。
写者优先:只要线程申请了写锁,那么在写锁后面到来的读锁请求就会统统被阻塞,不能先于写锁拿到锁。
读写锁实现中的变量及含义
对于读请求而言:如果
1. 无线程持有写锁,即_writer = 0.
2. 采用读者优先策略或者当前没有写锁申请请求,即 _nr_writers_queue = 0
3. 当满足这两个条件时,读锁请求立即获得读锁,返回之前执行_nr_readers++,表示多了一个线程正在读
4. 不满足这两个条件时,执行_nr_readers_queued++,表示增加了一个读锁等待者,然后调用futex,陷入阻塞。醒来之后,执行_nr_readers_queued- -,再次判断是否满足条件1,2
对于写请求而言:如果
1. 无线程持有写锁,即_writer = 0.
2. 没有线程持有读锁,即_nr_readers = 0.
3. 如果上述条件满足,就会立即拿到锁,将_writer 置为当前线程的ID
4. 如果不满足,则执行_nr_writers_queue++, 表示增加了一个写锁等待者线程,然后执行futex陷入等待。醒来后,先执行_nr_writers_queue- -,再继续判断条件1,2
对于解锁,如果当前是写锁:
1. 执行_writer = 0.,表示释放写锁。
2. 根据_nr_writers_queue判断有没有写锁,如果有则唤醒一个写锁,如果没有写锁等待者,则唤醒所有的读锁等待者。
对于解锁,如果当前是读锁:
1. 执行_nr_readers- -,表示读锁占有者少了一个。
2. 判断_nr_readers是否等于0,是的话则表示当前线程是最后一个读锁占有者,需要唤醒写锁等待者或读锁等待者
3. 根据_nr_writers_queue判断是否存在写锁等待者,若有,则唤醒一个写锁等待线程
4. 如果没有写锁等待者,判断是否存在读锁等待者,若有,则唤醒全部的读锁等待者
读写锁很容易造成,读者饿死或者写者饿死。
也可以设计公平的读写锁。
代码:
#include
上述代码很容易触发线程饿死。
读饿死或者写饿死。
7.线程间同步7.1为什么需要线程同步?
线程同步是为了对临界资源访问的合理性。
例如:
就像工厂里生产车间没有原料了, 所有生产车间都停工了, 工人们都在车间睡觉。突然进来一批原料, 如果原料充足, 你会发广播给所有车间, 原料来了, 快来开工吧。如果进来的原料很少, 只够一个车间开工的, 你可能只会通知一个车间开工。
7.2如何做到线程间同步?
条件等待是线程间同步的另一种方法。
如果条件不满足, 它能做的事情就是等待, 等到条件满足为止。通常条件的达成, 很可能取决于另一个线程, 比如生产者-消费者模型。当另外一个线程发现条件符合的时候, 它会选择一个时机去通知等待在这个条件上的线程。有两种可能性, 一种是唤醒一个线程, 一种是广播, 唤醒其他线程。
则在这个情况下,需要做到:
1、线程在条件不满足的情况下, 主动让出互斥量, 让其他线程去折腾, 线程在此处等待, 等待条件的满足;
2、一旦条件满足, 线程就可以立刻被唤醒。
3、线程之所以可以安心等待, 依赖的是其他线程的协作, 它确信会有一个线程在发现条件满足以后, 将向它发送信号, 并且让出互斥量。
7.3条件变量
本质上是PCB等待队列 + 等待接口 + 唤醒接口。
7.3.1条件变量的初始化
静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态初始化
pthread_cond_init(pthread_cond_t *cond,const pthread_condattr_t *attr);
7.3.2条件变量的等待
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict conpthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
为什么这两个接口中有互斥锁?
条件不会无缘无故地突然变得满足了, 必然会牵扯到共享数据的变化。所以一定要有互斥锁来保护。没有互斥锁, 就无法安全地获取和修改共享数据。
同步并没有保证互斥,而保证互斥是使用到了互斥锁。
pthread_mutex_lock(&m)
while(condition_is_false)
{
pthread_mutex_unlock(&m);
//解锁之后, 等待之前, 可能条件已经满足, 信号已经发出, 但是该信号可能会被错过
cond_wait(&cv);
pthread_mutex_lock(&m);
}
上面的解锁和等待不是原子操作。解锁以后, 调用cond_wait之前,如果已经有其他线程获取到了互斥量, 并且满足了条件, 同时发出了通知信号, 那么cond_wait将错过这个信号, 可能会导致线程永远处于阻塞状态。所以解锁加等待必须是一个原子性的操作, 以确保已经注册到事件的等待队列之前, 不会有其他线程可以获得互斥量。
那先注册等待事件, 后释放锁不行吗?注意, 条件等待是个阻塞型的接口, 不单单是注册在事件的等待队列上, 线程也会因此阻塞于此, 从而导致互斥量无法释放, 其他线程获取不到互斥量, 也就无法通过改变共享数据使等待的条件得到满足, 因此这就造成了死锁。
pthread_mutex_lock(&m);
while(condition_is_false)
pthread_cond_wait(&v,&m);//此处会阻塞
如果代码运行到此处, 则表示我们等待的条件已经满足了,
*并且在此持有了互斥量
在满足条件的情况下, 做你想做的事情。
pthread_mutex_unlock(&m);
pthread_cond_wait函数只能由拥有互斥量的线程来调用, 当该函数返回的时候, 系统会确保该线程再次持有互斥量, 所以这个接口容易给人一种误解, 就是该线程一直在持有互斥量。事实上并不是这样的。这个接口向系统声明了我在PCB等待序列中之后, 就把互斥量给释放了。这样其他线程就有机会持有互斥量,操作共享数据, 触发变化, 使线程等待的条件得到满足。
pthread_cond_wait内部会进行解锁逻辑,则一定要先放到PCB等待序列中,再进行解锁。
while(condition_is_false)
pthread_cond_wait(&v,&m);//此处会阻塞
if(condition_is_false)
pthread_cond_wait(&v,&m);//此处会阻塞
唤醒以后, 再次检查条件是否满足, 是不是多此一举?
因为唤醒中存在虚假唤醒(spurious wakeup) , 换言之,条件尚未满足, pthread_cond_wait就返了。在一些实现中, 即使没有其他线程向条件变量发送信号, 等待此条件变量的线程也有可能会醒来。
条件满足了发送信号, 但等到调用pthread_cond_wait的线程得到CPU资源时, 条件又再次不满足了。好在无论是哪种情况, 醒来之后再次测试条件是否满足就可以解决虚假等待的问题。
pthread_cond_wait内部实现逻辑:
将调用pthread_cond_wait函数的执行流放入到PCB等待队列当中
解锁
等待被唤醒
被唤醒之后:
1、从PCB等待队列中移除出来
2、抢占互斥锁
情况1:拿到互斥锁,pthread_cond_wait就返回了
情况2:没有拿到互斥锁,阻塞在pthread_cond_wait内部抢锁的逻辑中
当阻塞在pthread_cond_wait函数抢锁逻辑中时,一旦执行流时间耗尽,意味着线程就被切换出来了,程序计数器就保存的是抢锁的指令,上下文信息保存的就是寄存器的值
当再次拥有CPU资源后,恢复抢锁逻辑
直到抢锁成功,pthread_cond_wait函数才会返回
7.3.3条件变量的唤醒
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_signal负责唤醒等待在条件变量上的一个线程。
pthread_cond_broadcast,就是广播唤醒等待在条件变量上的所有线程。
先发送信号,然后解锁互斥量,这个顺序是必须的嘛?
先通知条件变量、 后解锁互斥量, 效率会比先解锁、 后通知条件变量低。因为先通知后解锁, 执行pthread_cond_wait的线程可能在互斥量已然处于加锁状态的时候醒来, 发现互斥量仍然没有解锁, 就会再次休眠, 从而导致了多余的上下文切换。
7.3.4条件变量的销毁
int pthread_cond_destroy(pthread_cond_t *cond);
注意:
1、永远不要用一个条件变量对另一个条件变量赋值, 即pthread_cond_t cond_b = cond_a不合法, 这种行为是未定义的。
2、使用PTHREAD_COND_INITIALIZE静态初始化的条件变量, 不需要被销毁。
3、要调用pthread_cond_destroy销毁的条件变量可以调用pthread_cond_init重新进行初始化。
4、不要引用已经销毁的条件变量, 这种行为是未定义的。
例:
#include
在这里为什么有两个条件变量呢?
若所有的线程只使用一个条件变量,会导致所有线程最后都进入PCB等待队列。
thread apply all bt查看:
7.3.5情况分析:两个生产者,两个消费者,一个PCB等待队列
1、最开始的情况,两个消费者抢到了锁,此时生产者未生产,则都放入PCB等待队列中
2、一个生产者抢到了锁,生产了一份材料,唤醒一个消费者,此时三者抢锁,若两个生产者分别先后抢到了锁,则都进入PCB等待队列中
3、只有一个消费者,则必会抢到锁,消费材料,唤醒PCB等待队列,若此时唤醒的是,消费者,则现在是这样一个情况:
4、两个消费者在外边抢锁,一定都会进入PCB等待队列中
解决上述问题可采用两种方法:
1、使用int pthread_cond_broadcast(pthread_cond_t *cond);,唤醒PCB等待队列中所有的线程。此时所有线程都会同时执行抢锁逻辑,太消费资源了。此方法不妥
2、采用两个PCB等待序列,一个放生产者,一个放消费者,生产者唤醒消费者,消费者唤醒生产者。
8.线程取消8.1线程取消函数接口int pthread_cancel(pthread_t thread);
一个线程可以通过调用该函数向另一个线程发送取消请求。这不是个阻塞型接口, 发出请求后, 函数就立刻返回了, 而不会等待目标线程退出之后才返回。
调用pthread_cancel时, 会向目标线程发送一个SIGCANCEL的信号, 该信号就是kill -l中消失的32号信号。
线程的默认取消状态是PTHREAD_CANCEL_ENABLE。即是可被取消的。
什么是取消点?可通过man pthreads查看取消点
就是对于某些函数, 如果线程允许取消且取消类型是延迟取消, 并且线程也收到了取消请求, 那么当执行到这些函数的时候, 线程就可以退出了。
8.2线程取消带来的弊端
目标线程可能会持有互斥量、 信号量或其他类型的锁, 这时候如果收到取消请求, 并且取消类型是异步取消, 那么可能目标线程掌握的资源还没有来得及释放就被迫退出了, 这可能会给其他线程带来不可恢复的后果, 比如死锁(其他线程再也无法获得资源) 。
注意:
轻易不要调用pthread_cancel函数, 在外部杀死线程是很糟糕的做法,毕竟如果想通知目标线程退出, 还可以采取其他方法。
如果不得不允许线程取消, 那么在某些非常关键不容有失的代码区域, 暂时将线程设置成不可取消状态, 退出关键区域之后, 再恢复成可以取消的状态。
在非关键的区域, 也要将线程设置成延迟取消, 永远不要设置成异步取消。
8.2线程清理函数
假设遇到取消请求, 线程执行到了取消点, 却没有来得及做清理动作(如动态申请的内存没有释放, 申请的互斥量没有解锁等) , 可能会导致错误的产生, 比如死锁, 甚至是进程崩溃。
为了避免这种情况, 线程可以设置一个或多个清理函数, 线程取消或退出时,会自动执行这些清理函数, 以确保资源处于一致的状态。
如果线程被取消, 清理函数则会负责解锁操作。
void pthread_cleanup_push(void (*routine)(void *),void *arg);
void pthread_cleanup_pop(int execute);
这两个函数必须同时出现, 并且属于同一个语法块。
何时会触发注册的清理函数:?
1、当线程的主函数是调用pthread_exit返回的, 清理函数总是会被执行。
2、当线程是被其他线程调用pthread_cancel取消的, 清理函数总是会被执行。
3、当线程的主函数是通过return返回的, 并且pthread_cleanup_pop的唯一参数execute是0时, 清理函数不会被执行.
4、线程的主函数是通过return返回的, 并且pthread_cleanup_pop的唯一参数execute是非零值时, 清理函数会执行一次。
代码:
#include
结果:只要拿到锁,就表明线程清理函数成功了。
9.多线程与fork()
永远不要在多线程程序里面调用fork。
Linux的fork函数, 会复制一个进程, 对于多线程程序而言, fork函数复制的是用fork的那个线程, 而并不复制其他的线程。fork之后其他线程都不见了。Linux存在forkall语义的系统调用, 无法做到将多线程全部复制。
多线程程序在fork之前, 其他线程可能正持有互斥量处理临界区的代码。fork之后, 其他线程都不见了, 那么互斥量的值可能处于不可用的状态, 也不会有其他线程来将互斥量解锁。
10.生产者与消费者模型10.1生产者与消费者模型的本质
本质上是一个线程安全的队列,和两种角色的线程(生产者和消费者)
存在三种关系:
1、生产者与生产者互斥
2、消费者与消费者互斥
3、生产者与消费者同步+互斥
10.2为什么需要生产者与消费者模型?
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生成完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列中取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费解耦的。
10.3优点
1、解耦
2、支持高并发
3、支持忙闲不均
10.4实现两个消费者线程,两个生产者线程的生产者消费者模型
生产者生成时用的同一个全局变量,故对该全局变量进行了加锁。
#include
先考虑代码的核心逻辑(先实现)
考虑核心逻辑中是否访问临界资源或者说执行临界区代码,如果有就需要保持互斥
考虑线程之间是否需要同步
最新活动更多
-
11月20日火热报名中>> 2024 智能家居出海论坛
-
11月28日立即报名>>> 2024工程师系列—工业电子技术在线会议
-
12月19日立即报名>> 【线下会议】OFweek 2024(第九届)物联网产业大会
-
即日-12.26火热报名中>> OFweek2024中国智造CIO在线峰会
-
即日-2025.8.1立即下载>> 《2024智能制造产业高端化、智能化、绿色化发展蓝皮书》
-
精彩回顾立即查看>> 【在线会议】多物理场仿真助跑新能源汽车
推荐专题
发表评论
请输入评论内容...
请输入评论/评论长度6~500个字
暂无评论
暂无评论