【Linux】线程的同步与互斥

Linux线程的同步与互斥

文章目录

  • Linux线程的同步与互斥
    • Linux线程互斥
      • 进程线程间互斥相关概念
      • 互斥量mutex
        • 互斥量的接口
          • 初始化互斥量(pthread_mutex_init)
          • 销毁互斥量(pthread_mutex_destroy)
          • 互斥量加锁(pthread_mutex_lock)
          • 互斥量解锁(pthread_mutex_unlock)
        • 互斥量具体操作(改进买票程序)
        • 互斥量的原理
          • 如何保证申请锁的过程是原子的?
      • 线程安全和可重入
        • 常见线程安全情况
        • 常见线程不安全情况
        • 常见可重入的情况
        • 常见不可重入情况
        • 可重入与线程安全的联系和区别
      • 死锁
        • 什么是死锁
        • 死锁产生的四个必要条件
        • 如何避免死锁
    • Linux线程同步
      • 同步的概念与竞态条件
      • 条件变量
        • 条件变量实现接口
          • 初始化条件变量(pthread_cond_init)
          • 销毁条件变量(pthread_cond_destroy)
          • 等待条件变量满足(pthread_cond_wait)
          • 唤醒等待(pthread_cond_signal)
        • 条件变量基本使用
        • 为什么pthread_cond_wait需要互斥量
        • 条件变量使用规范

Linux线程互斥

进程线程间互斥相关概念

  • 临界资源:多个线程都能访问的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码区域,就叫临界区
  • 互斥:任何时候,互斥保证有且只有一个执行流进入临界区,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两种结果,完成和未完成

互斥和原子性

多线程情况下,如果有一个全局变量,也就是多个线程都能访问的临界资源多个线程对这个临界资源进行操作,那么就可能会出现这个数据的不确定性互斥的作用就是保证临界资源只能被一个线程操作,当一个线程进入临界区,剩下的线程会被排斥,也就是让这个临界区不可能出现多个执行流同时访问的情况

原子性是指一步操作它要么完整的被执行,要么完全不执行。这种特性就叫原子性。

下面有一个抢票的代码,用四个线程同时抢10张票,票的总数是一个全局变量,也就是临界资源,对票数的判断和票数的减少,则为临界区:

#include
#include
#include
//总票数
int tickets = 10;

void* get_ticket(void* arg) {
    while (1) {
        if (tickets > 0) {
            //模拟买票其他操作用时
            usleep(1000);
            printf("[thread %d] get ticket NO:%d\n", (long)arg, tickets);
            tickets--;
        }
        else {
            break;
        }
    }
}
int main() {
    pthread_t tid[4];
    int i = 0;
    for (; i < 4; ++i) {
        pthread_create(tid + i, NULL, get_ticket, (void*)i);
    }

    for (i = 0; i < 4; ++i) {
        pthread_join(tid[i], NULL);
    }
    return 0;
}

【Linux】线程的同步与互斥_第1张图片

出现了票数为负的情况,也就是说票被多卖了3张,有几张一样的票被同时卖给了不同的人

原因

if判断票数为1的时候,进入循环后,usleep(1000)模拟其他业务逻辑的这段时间内,线程时间片轮转,切换到了其他线程,其他线程也在票数为1时进入了循环,进行了tickets–操作,这时就会在剩余一张票时进行了多次卖票操作,造成了这种情况。

还有一张可能,就是ticket–本身就不是一个原子操作

查看windows环境和Linux环境下tackets–的汇编代码

【Linux】线程的同步与互斥_第2张图片

请添加图片描述

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量tickets从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

既然--操作有三行汇编代码,那么在一个线程执行第一行代码,也就是将ticket的值加载进了寄存器还没进行-1操作,这时时间片轮转到了其他线程,这时第一次执行–的线程会将tackets的值保存到上下文信息中,其他线程也执行第一句汇编,这时两个线程的tackets的值是一样的,但是两个线程都会执行--操作进行了两次--tackets的值却只减少了1,就会出现问题。

互斥量mutex

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线进入该临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

【Linux】线程的同步与互斥_第3张图片

互斥量的接口

初始化互斥量(pthread_mutex_init)

初始化互斥量有两种方式,

  • 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁

  • 动态分配

pthread_mutex_init接口

#include 
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

参数

  • 第一个参数restrict mutex,要初始化的互斥量
  • 第二个参数restrict attr,初始化互斥量的属性,一般设置为NULL即可

返回值

互斥量初始化成功返回0,失败返回错误码。

销毁互斥量(pthread_mutex_destroy)
#include 
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数

  • mutex,要初始化的互斥量

返回值

互斥量销毁成功返回0,失败返回错误码。

销毁互斥量需要注意:

  • 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁(pthread_mutex_lock)
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

pthread_mutex_trylock 功能与pthread_mutex_lock一样,只是当mutex已经是锁定的时候,pthread_mutex_trylock直接返回错误码EBUSY,而不是阻塞进程。

参数

  • mutex,要初始化的互斥量

返回值

互斥量加锁成功返回0,失败返回错误码。

调用pthread_ lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
  • 发起函数调用时,自身线程已经锁定互斥量,那么线程会直接阻塞,但是互斥量是被自身线程锁定的,这时线程没法解锁线程,就会造成**死锁**
互斥量解锁(pthread_mutex_unlock)
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数

  • mutex,要初始化的互斥量

返回值

互斥量解锁成功返回0,失败返回错误码。


互斥量具体操作(改进买票程序)

既然多个线程同时访问临界区会出现问题,那么就在进入临界区之前对临界区进行加锁操作,在有可能退出临界区的所有出口进行解锁操作

#include
#include
#include
//定义互斥量
pthread_mutex_t lock;
int tickets = 10;

void* get_ticket(void* arg) {
    while (1) {
        //在一个线程解锁时,要稍等一会,不然还没等别的线程开始抢锁,刚刚解锁的线程就又会立刻加锁,造成全部票被一个线程全部抢走的情况
        usleep(1000);
        //在进入临界区之前加锁
       	pthread_mutex_lock(&lock);
        if (tickets > 0) {
            usleep(1000);
            printf("[thread %d] get ticket NO:%d\n", (long)arg, tickets);
            tickets--;
            //退出临界区的出口解锁
            pthread_mutex_unlock(&lock);
        }
        else {
            //退出临界区的出口解锁
            pthread_mutex_unlock(&lock);
            break;
        }
    }
}
int main() {
    pthread_t tid[4];
    //初始化互斥量
    pthread_mutex_init(&lock,NULL);
    int i = 0;
    for (; i < 4; ++i) {
        pthread_create(tid + i, NULL, get_ticket, (void*)i);
    }

    for (i = 0; i < 4; ++i) {
        pthread_join(tid[i], NULL);
    }
    pthread_mutex_destroy(&lock);
    return 0;
}

【Linux】线程的同步与互斥_第4张图片

注意

  • 加锁或者解锁一定要选在最合适的地方,加锁的区域越少越好,因为被加锁的区域线程是串行的,一个线程获得锁资源,其他线程就会阻塞,效率比较低,所以加锁的代码执行的越少效率越高
  • 在一个线程解锁时,要稍等一会,不然还没等别的线程开始抢锁,刚刚解锁的线程就又会立刻加锁,造成全部票被一个线程全部抢走的情况
  • 解锁时要在任何可能退出临界区的出口解锁,不然可能会解不了锁,造成死锁

互斥量的原理

互斥锁的本质其实是一个0/1计数器,用来标记资源状态,是否被加锁

  • 加锁的本质是让这个计数器由1变为0

  • 解锁的本质是让这个计数器由0变为1

一个线程申请锁资源,其实就是读取这个计数器的值,判断临界区是否加锁,如果锁被别的线程占用,那线程就会进入等待队列阻塞等待,等到锁被重新释放,也就是计数器由0变为1,就会唤醒等待队列队头的线程,申请锁

所有线程在进入临界区时都会申请锁,那么锁也是临界资源,那么锁本身也要被保护,其实锁的申请是一个原子操作,只有两种状态,申请成功或者申请失败。

如何保证申请锁的过程是原子的?

为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令,该指令的作用把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

看看lock和unlock的伪代码

【Linux】线程的同步与互斥_第5张图片

我们认为mutex就是这个计数器的值,mutex的值默认为1,al是一个寄存器

  • 申请锁的过程是线程先执行move指令,把al寄存器里的值初始化为0,
  • 然后把al寄存器里的0和计数器里的1值进行交换
  • 这时判断寄存器里的值是否大于0,大于0就申请锁成功,小于0就进入等待队列,等待占有锁的线程释放锁资源,释放后就有可能唤醒挂起的进程继续申请锁,直到申请到锁资源进入临界区。

【Linux】线程的同步与互斥_第6张图片

【Linux】线程的同步与互斥_第7张图片

  • 一个线程在申请锁资源的时候,执行完第一步初始化,这时如果时间片发生轮转,轮到其他线程申请,其他线程也是将al初始化为0,并不会对结果造成影响

  • 若是执行完第二步交换指令,把mutex的值交换到了al寄存器,这时时间片切换到其他线程,那么在切换前,会保存上下文数据,1这个值会被线程保存起来

  • 其他进程进来,把al初始化为0,再去交换mutex的值,此时这个1已经交换到第一个线程中了,新线程拿不到1就会阻塞,原来线程拿到时间片就会恢复上下文数据,这个1也会被恢复到al寄存器,继续执行下面的指令 。

  • 从始至终,这个1只有一份,线程要不拿到1也就是申请到锁,要不拿不到1,只有申请到和没申请到两种状态,所以说申请锁的过程是原子操作。

解锁的时候只有1条指令,就是把1放回mutex里,虽然al里还是1,但是申请锁的时候无论如何都会将al重新初始化为0,也就保证了1的唯一性。

线程安全和可重入

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。

一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数


常见线程安全情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见线程不安全情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

常见不可重入情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 函数体内使用了静态的数据结构

可重入与线程安全的联系和区别

联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

死锁

什么是死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态

死锁产生的四个必要条件

  • 互斥条件:一个资源只能被一个线程使用
  • 请求和保持条件:一个线程因请求资源而阻塞时对已获得的资源保持不放
  • 不剥夺条件:一个线程已获得资源,在未使用完之前,不能强行剥夺
  • 环路等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

这四个条件是必要条件,也就是说必须同时满足以上四个条件才会造成死锁

如果一个线程在申请到锁的情况下再次申请锁,那这个线程也会造成死锁

如何避免死锁

避免死锁就是要破坏死锁产生的必要条件

  • 资源一次性分配,解决请求保持问题
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 当进程新申请资源未成功,释放已有资源

除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。


Linux线程同步

同步的概念与竞态条件

  • 同步:在保证数据安全的前提下(加锁保护),让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

  • 竞态条件:竞态条件 (race condition) 又名竞争危害 (race hazard)。旨在描述一个系统或者进程的输出展现无法预测的、对事件间相对时间的排列顺序的致命相依性。

互斥量不是万能的,比如某个线程正在等待共享数据内某个条件出现,可能需要重复对数据对象加锁和解锁(轮询),但是这样轮询非常耗费时间和资源,而且效率非常低

例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取。

这时我们就要引入一种方法,当一个线程申请到锁资源,但是没有满足这个线程的运行条件,那么就让这个线程挂起等待,一旦条件满足,就立即唤醒这个线程,这样这个线程就不会一直申请锁又释放锁,却不做任何事情,提高效率。这种方法就叫做同步。

条件变量

条件变量(condition variable)是利用线程间共享的全局变量进行同步的一种机制

主要包括两个动作:

  • 一个线程等待某个条件成立,而将自己挂起等待
  • 另一个线程使条件成立,并通知等待的线程继续
  • 为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。

条件变量实现接口

初始化条件变量(pthread_cond_init)
  • 静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

使用 PTHREAD_COND_INITIALIZER 初始化的条件变量不需要销毁

  • 动态分配

pthread_cond_init接口

#include 
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

参数

  • 第一个参数restrict cond,要初始化的条件变量
  • 第二个参数restrict attr,初始化条件变量的属性,一般设置为NULL即可。

返回值

条件变量初始化成功返回0,失败返回错误码。


销毁条件变量(pthread_cond_destroy)

pthread_cond_destroy接口

int pthread_cond_destroy(pthread_cond_t *cond);

参数

  • cond,要销毁的条件变量

返回值

条件变量销毁成功返回0,失败返回错误码。

注意

使用PTHREAD_COND_INITIALIZER 初始化的互斥量不需要销毁


等待条件变量满足(pthread_cond_wait)

pthread_cond_wait接口

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

参数

  • 第一个参数cond,需要等待的条件变量
  • 第二个参数restrict mutex,当前临界区的互斥量

返回值

函数调用成功返回0,失败返回错误码。


唤醒等待(pthread_cond_signal)

唤醒等待有两个接口

//唤醒等待队列中的全部线程
int pthread_cond_broadcast(pthread_cond_t *cond); 

//唤醒等待队列中首个线程
int pthread_cond_signal(pthread_cond_t *cond);

参数

唤醒在cond条件变量等待的线程

返回值

函数调用成功返回0,失败返回错误码

条件变量基本使用

创建两个线程,一个线程等待唤醒,唤醒就打印字符串,一个线程唤醒,每隔一秒唤醒一次

#include
#include
#include
using namespace std;
//创建条件变量
pthread_cond_t cond;
//创建互斥量
pthread_mutex_t lock;

//线程1等待被唤醒
void* pthread1_run(void* arg) {
    while (1) {
        pthread_cond_wait(&cond, &lock);
        cout << "唤醒成功,活动!" << endl;
    }
}
//线程2每隔一秒唤醒线程2
void* pthread2_run(void* arg) {
    while (1) {
        pthread_cond_signal(&cond);
        sleep(1);
    }
}
int main() {
    //初始化条件变量和互斥量
    pthread_cond_init(&cond, nullptr);
    pthread_mutex_init(&lock, nullptr);
    //创建两个线程
    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, pthread1_run, nullptr);
    pthread_create(&tid2, nullptr, pthread2_run, nullptr);

    //等待两个线程
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);

    //摧毁互斥量和条件变量
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);

    return 0;
}

【Linux】线程的同步与互斥_第8张图片

为什么pthread_cond_wait需要互斥量

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
  • 线程要判断是否满足条件,就一定要先进入临界区,而进入临界区就一定要先获得锁资源,一旦条件不满足,就要等待,但是当前线程是占有锁资源的,如果直接进行阻塞等待,那么这个锁就永远不会释放了,就会造成死锁
  • 所以线程在调用pthread_cond_wait进行等待时,会先释放锁资源,而要释放锁,就得需要传入互斥量,pthread_cond_wait会在调用时自动释放锁资源
  • 当该线程被唤醒时,程序会接着从pthread_cond_wait处继续运行,这时线程是没有锁的,也就是临界区没有被保护的,那么当线程被唤醒后,pthread_cond_wait会帮线程重新持有释放的锁资源,而重新申请锁,也需要互斥量

所以pthread_cond_wait调用时需要传入两个参数,一个条件变量和互斥量。

条件变量使用规范

  • 等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
	pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
  • 给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

你可能感兴趣的:(Linux,linux)