• 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;
    };
    
    • 1
    • 2
    • 3
    • 4

    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 对应的头文件为

    初始化信号量/信号灯
    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] */
    };
    
    • 1
    • 2
    • 3
    • 4

    // 调用该函数线程获取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时候,才可以不加锁
    源代码:
    在这里插入图片描述

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

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

    源代码:
    在这里插入图片描述

  • 相关阅读:
    信息技术服务标准是什么
    旧版elasticsearch 2.3.4 集群部署过程
    编程要搞明白的东西(二)
    C++ 获取文件创建时间、修改时间、大小等属性
    数据结构篇【4】——哈希(散列)线性探测实现
    LeetCode:买卖股票 系列 含冷冻期、含手续费(C++)
    数据结构与算法—单链表
    【运维笔记】linux同步时间
    使用Discuz和数据库容器搭建论坛服务
    网络知识基础一
  • 原文地址:https://blog.csdn.net/qq_45254369/article/details/126079545