Linux下线程同步(带你了解什么是互斥锁死锁读写锁条件变量信号量等)

Posted 林夕07

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux下线程同步(带你了解什么是互斥锁死锁读写锁条件变量信号量等)相关的知识,希望对你有一定的参考价值。

线程同步概念

假设有四个线程A、B、C、D,当前一个线程A对内存中的共享资源进行访问的时候,其他线程B、C、D都不可以对这块内存进行操作,直至A对这块内存访问完毕为止,B、C、D中的一个才能访问这块内存,剩余的俩个需要继续阻塞等待,一次类推,直到所有的线程都对这块内存操作完毕。线程对内存的这种访问方式称为线程同步,所谓的现成的同步并不是多个线程同时对内存访问,而是按照先后顺序依次进行的。

源码:

结果图:

CPU对于寄存器、一级缓存、二级缓存、三级缓存是独占的。用于存储处理的数据和线程的状态信息,数据被处理完需要再次被写入到物理内存中,物理内存数据也可以通过文件IO操作写入到磁盘中。

上述程序中,俩个线程共用全局变量number当线程变成运行态之后开始计数时,从物理内存(Memory)中加载数据,然后让数据放到CPU进行运算,最后将结果跟新到物理内存中。

如果线程A执行这个过程期间就失去了CPU时间片,线程A被挂起了最新的数据没能更新到物理内存中(保存到了寄存器中),线程B变成了运行态之后从物理内存读取数据,这个时候B拿的数据就不是最新数据。只能基于旧的数据往后计数,然后失去时间片。线程A在得到时间片变成运行态时,第一件事就是将上次没更新到内存的数据更新到内存(从寄存器拿上次),但是这样会导致B线程已经跟新到内存的数据被覆盖掉。活白干了。最终导致有些数据会被重复很多次。

同步方式

对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对于的共享资源也被称之为临界资源。

互斥锁

互斥锁函数
互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有线程只能顺序执行(不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程可以并行处理的,现在只能串性处理。

在Linux中互斥锁的类型为pthread_mutex_t,创建一个这样类型的变量就得到了一把互斥锁。ptherad_mutex_t mutex;

在创建的锁对象中保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程ID)。一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞,一般情况下,每一个共享资源对应一把互斥锁,锁的个数与线程的个数无关

初始互斥锁: int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

释放互斥锁: int pthread_mutex_destroy(pthread_mutex_t *mutex);

restrict:关键字,用来修饰指针,只有这个关键字修饰的指针可以访问指向的内存地址。其他指针不行
mutex:互斥锁变量地址
attr:互斥锁的属性,一般使用默认属性就可以,这个参数指定NULL

修改互斥锁状态
int pthread_mutex_lock(pthread_mutex *mutex);
该函数被调用时,首先会判断参数mutex互斥锁中的状态是不是锁定状态。
• 若没有被锁定,时打开的。这个线程可以加锁成功。该锁会记录那个线程加锁成功了
• 锁被锁定了,其他线程加锁就会失败,这些线程就会阻塞到这把锁上。
• 当把这把锁解开之后,阻塞在这把锁的线程就解除阻塞了,并且这些线程通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞。
例如:你要去上厕所,发现里面有人,你就在外边等着。

尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
• 如果这把锁没有被锁定,线程加锁成功
• 如果这把锁被锁定后了。调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误码。
例如:你要去上厕所,发现里面有人,你就去干别的事情了,

互斥锁解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
那个线程加的锁,必须那个线程去解锁。

互斥锁的使用

需要先创建一个互斥锁

再使用互斥锁对需要加锁的位置进行加锁和解锁操作。

全部代码如下:

运行图如下:

死锁

当多个线程访问共享资源时,需要加锁,如果锁使用不当,就会造成死锁现象。如果线程死锁造成的后果是:所有线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞了)。

例如:加锁后忘记解锁

再例如:

  1. 当我们加锁后进入if语句后退出(return)了,等其他线程再进来就发现当前已被锁,就阻塞自己。
  2. 重复加锁(多加锁了一次)

多个线程对一个临界资源进行访问我们需要一把锁
一个线程对多个临界资源进行访问我们需要多把锁

如何避免死锁:
• 对共享资源访问完毕之后,一定要解锁。或者在加锁的时候使用tylock
• 如果程序中有多把锁,可以控制对锁的访问顺序(顺序访问共享资源,但是有些情况下是做不到的),另外也可以在对其他互斥锁操作前,先释放当前线程拥有的互斥锁。
• 项目程序中可以引入一些专门用于检测死锁的模块

读写锁

读写锁是一把锁,是互斥锁的升级版。在做读操作的时候可以提高程序执行效率,如果所有的线程都是做读操作,那么读是并行的,但是使用互斥锁,读操作也是串行的。

锁的类型为pthread_rwlock_t 这把锁既可以锁定读操作,还可以锁定写操作。
记录信息:

  1. 锁的状态:锁定/打开
  2. 锁定的是什么操作:读操作/写操作,使用了读写锁锁定了读操作,需要先解锁,再去锁定写操作。
  3. 哪个线程将这把锁锁上的

特点:
• 使用了读写锁的读锁锁定了临界区,线程对临界区的访问是并行的。读锁是共享的。
• 使用了读写锁的写锁锁定了临界区,线程对临界区的访问时串性的。写锁是独占的。
• 使用了读写锁分别对俩个临界区区加了读锁和写锁,俩个线程要同时访问着俩个临界区,访问写锁临界区的线程继续运行,访问读锁临界区的线程阻塞,因为写锁比读锁的优先级高。

如果说程序中所有的线程都对共享资源做写操作,使用读写锁没有优势。但是说程序中所有的线程对共享资源既有读也有写操作,使用读写锁更有优势。

初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlock_t *restrict attr);

rwlock:读写锁地址,传出参数
attr:读写锁属性,一般使用默认属性,指定为NULL

释放读写锁占用的系统资源
int pthread_rwlock_destroy(pthread_rwlock_t *restrict rwlock);

rwlock:读写锁地址,传出参数

加锁读操作
int pthread_rwlock_rdlock(pthread_rwlock_t * );
如果读写锁是打开的,那么加锁成功,
如果读写锁锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的。
如果读写锁锁定了写操作,调用这个函数会阻塞。

如果加读锁失败,不会阻塞当前线程,直接返回错误号 有效避免死锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t * rwlock);
如果读写锁是打开的,那么加锁成功,
如果读写锁锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的。
如果读写锁锁定了写操作,不会阻塞当前线程,函数会返回一个错误码。

加锁写操作
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
如果读写锁是打开的,那么加锁成功,
如果读写锁锁定了读操作,调用这个函数会阻塞。
如果读写锁锁定了写操作,调用这个函数会阻塞。

如果加锁失败,不会阻塞当前线程,直接返回错误号 有效避免死锁
int pthread_rwlock_trywrdlock(pthread_rwlock_t * rwlock);
如果读写锁是打开的,那么加锁成功,
如果读写锁锁定了读操作,不会阻塞当前线程,函数会返回一个错误码。
如果读写锁锁定了写操作,不会阻塞当前线程,函数会返回一个错误码。

解锁 读锁写锁都可以
int pthread_rwlock_unlock(pthread_rwlock_t * rwlock);

读写锁使用

题目要求:8个线程操作同一个全局变量,3个线程不定时写同一全局资源,5个线程不定时读同一线程。
源码如下:

运行效果图:

条件变量

条件变量函数
条件变量的主要作用不是处理线程同步,而是进行线程阻塞。如果多线程程序中只使用条件变量无法实现线程的同步,必须要配合互斥锁来使用。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样得到,二者的区别如下:
• 假设有A-Z26个线程,这26个线程共同访问同一把互斥锁,如果A线程加锁成功,那么其余B-Z线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区。
• 条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界区资源,这种情况下还是会出现共享资源中数据混乱。

一般情况下条件变量用于处理生产者和消费者模型,并且和互斥锁配合使用,条件变量类型对应的类型为pthread_cond_t

被条件变量阻塞的线程的信息会被记录到这个变量中,以便在解除阻塞的时候使用。

初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
cond:条件变量的地址
attr:条件变量属性,一般使用默认属性,指定为NULL

销毁释放资源
int pthread_cond_destroy(pthread_cond_t *cond);
cond:条件变量的地址

阻塞函数
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex *restrict mutex);
该函数在阻塞线程的时候,需要一个互斥锁参数,这个互斥锁主要功能是进行线程同步,让线程顺序进入临界区,避免出现数共享资源的数据混乱。会对互斥锁做俩件事
• 1、在阻塞线程时候,如果线程已经对互斥锁mutex上锁,那么会将这把锁打开,这样避免死锁。
• 2、当线程解除阻塞的时候,函数内部会帮助这个线程再次将这个mutex互斥锁锁上,继续向下访问临界区。

阻塞时长函数
将线程阻塞一定时长,时间到了,线程就解除阻塞。

struct timespec
time_t tv_sec;   
time_t tv_nsec;
;

int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
前俩个参数和前面阻塞哈数一样,第三个参数是阻塞时长,

唤醒阻塞函数(至少唤醒一个)
int pthread_cond_signal(pthread_cond_t *cond);

唤醒阻塞函数(全部唤醒)
int pthread_cond_broadcast(pthread_cond_t *cond);

生产者和消费者

场景描述:使用条件变量实现生产者和消费者模型,生产者有5个,往链表头部添加节点,消费者也有5个,删除链表尾巴的节点

源码如下:

接下里我们该做线程同步问题
• 生产者和消费者区域全部上锁
• 当生产者发现生产数量超过5时,阻塞生产者,唤醒消费者。当生产者生产完毕,也需要唤醒消费者。
• 当消费者发现没有产品时,阻塞消费者,唤醒生产者。当消费者消费完毕,也需要唤醒生产者

信号量

信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的概念,比如:有 A,B 两个线程,B 线程要等 A 线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。流水线

信号量(信号灯)与互斥锁和条件变量的主要不同在于” 灯” 的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。

信号量和条件变量一样用于处理生产者和消费者模型,用于阻塞生产者线程或者消费者线程的运行。
信号的类型为 sem_t 对应的头文件为 <semaphore.h>

初始化信号量/信号灯
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem:信号量变量地址
pshared:0线程同步,非0进程同步
value:初始化当前信号量拥有的资源数(>=0),如果资源数位0,线程就会被阻塞了。

资源释放,线程销毁之后调用这个函数
int sem_destroy(sem_t *sem);

消耗函数
int sem_wait(sem_t *sem); 函数调用时sem中的资源就会被消耗一个,资源数-1
当线程调用这个函数,并且sem>0,线程不会被阻塞,线程会占用sem中的一个资源,因此资源数-1,直到sem中的资源数减为0时。资源被耗尽,因此线程也就被阻塞了。

int sem_trywait(sem_t *sem); 函数调用时sem中的资源就会被消耗一个,资源数-1
当线程调用这个函数,并且sem>0,线程不会被阻塞,线程会占用sem中的一个资源,因此资源数-1,直到sem中的资源数减为0时。资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况。

解除阻塞时间函数
// 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示

struct timespec 
time_t tv_sec;      /* Seconds */
long   tv_nsec;     /* Nanoseconds [0 .. 999999999] */
;

// 调用该函数线程获取sem中的一个资源,当资源数为0时,线程阻塞,在阻塞abs_timeout对应的时长之后,解除阻塞。
// abs_timeout: 阻塞的时间长度, 单位是s, 是从1970.1.1开始计算的
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

该函数的参数 abs_timeout 和 pthread_cond_timedwait 的最后一个参数是一样的,使用方法不再过多赘述。当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,线程被阻塞,当阻塞指定的时长之后,线程解除阻塞。

增加资源函数
// 调用该函数给sem中的资源数+1
int sem_post(sem_t *sem);
调用该函数会将 sem 中的资源数 +1,如果有线程在调用 sem_wait、sem_trywait、sem_timedwait 时因为 sem 中的资源数为 0 被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。

查看信号量函数
// 查看信号量 sem 中的整形数的当前值, 这个值会被写入到sval指针对应的内存中
// sval是一个传出参数
int sem_getvalue(sem_t *sem, int *sval);

生产者消费者(一个资源量)

一个资源量就不需要线程同步。必然是单线程 , 只有资源总数为1时候,才可以不加锁
源代码:

生产者消费者(多个资源)

需要加锁,生产者和消费者共用一个链表,所以用一把锁。
锁要在信号量里面。不然有可能会造成死锁(比如:现在资源被消费完毕。然后一直被消费者抢到,进入阻塞,当全部线程被阻塞在消费者这边时,就造成了死锁)

源代码:

以上是关于Linux下线程同步(带你了解什么是互斥锁死锁读写锁条件变量信号量等)的主要内容,如果未能解决你的问题,请参考以下文章

再探 同步与互斥

再探 同步与互斥

Linux多线程中互斥锁读写锁自旋锁条件变量信号量详解

并发编程--一堆锁,GIL,同步异步,Event事件

Linux:使用读写锁使线程同步

C++笔记--Linux编程(14)-线程同步