订阅
纠错
加入自媒体

一文搞懂多线程中各个难点

2021-06-28 14:13
一口Linux
关注

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

先考虑代码的核心逻辑(先实现)

考虑核心逻辑中是否访问临界资源或者说执行临界区代码,如果有就需要保持互斥

考虑线程之间是否需要同步

<上一页  1  2  3  4  
声明: 本文由入驻维科号的作者撰写,观点仅代表作者本人,不代表OFweek立场。如有侵权或其他问题,请联系举报。

发表评论

0条评论,0人参与

请输入评论内容...

请输入评论/评论长度6~500个字

您提交的评论过于频繁,请输入验证码继续

暂无评论

暂无评论

人工智能 猎头职位 更多
扫码关注公众号
OFweek人工智能网
获取更多精彩内容
文章纠错
x
*文字标题:
*纠错内容:
联系邮箱:
*验 证 码:

粤公网安备 44030502002758号