当前位置: 首页 > article >正文

Linux相关概念和易错知识点(30)(线程互斥、线程同步)

目录

1.线程互斥

(1)临界资源和临界区

(2)互斥和原子性

①互斥

②原子性

(3)加锁和解锁(互斥锁)的原理

(4)pthread_mutex系列函数和变量

①lock、unlock

②pthread_mutex_t

(5)加锁常见错误

(6)对互斥锁的认识

2.线程同步

(1)线程互斥引发的问题

(2)pthread_cond系列函数和变量

①pthread_cond_t

②wait、signal


1.线程互斥

(1)临界资源和临界区

临界资源:多执行流中需要被保护的共享的资源。如某一个函数可被所有线程访问,但是在同一时刻所有线程都进入该函数会导致这个函数执行异常(如打印混乱),那么这个函数就是不可重入函数,也就是需要被保护的共享资源,即临界资源对于线程来说,所有函数代码都是共享的,因此对多线程来说临界资源就是要防止多个线程同时访问的资源。

临界区:每个线程内部访问临界资源的代码。临界区是代码,保护临界资源的手段就是保护临界区,只要访问临界资源的代码受到保护,那么对应的临界资源就会受到保护。简而言之,保护临界区是手段,保护临界资源才是目的

(2)互斥和原子性

①互斥

先看一下下面的代码,最后m的结果应该是多少呢?


#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int m = 10;

void *fun(void *p)
{
    while (m > 0)
    {
        sleep(1);
        m--;
    }
    return nullptr;
}

int main()
{
    pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;
    pthread_create(&pid_1, nullptr, fun, (void *)nullptr);
    pthread_create(&pid_2, nullptr, fun, (void *)nullptr);
    pthread_create(&pid_3, nullptr, fun, (void *)nullptr);
    pthread_create(&pid_4, nullptr, fun, (void *)nullptr);
    pthread_create(&pid_5, nullptr, fun, (void *)nullptr);

    pthread_join(pid_1, nullptr);
    pthread_join(pid_2, nullptr);
    pthread_join(pid_3, nullptr);
    pthread_join(pid_4, nullptr);
    pthread_join(pid_5, nullptr);

    printf("m = %d\n", m);

    return 0;
}

结果是

事实上,每次结果都可能不相同,在以上实例中,m可以被所有线程访问,即共享资源,访问修改这个共享资源的代码就是fun函数。如果所有线程同时访问这块代码,就有可能出现错误。

例如在m = 1时,pid_1线程执行到while (m > 0)这个语句时,会先将m的值从内存读到寄存器中,当读完之后,此时该线程时间片到了被切走后,该线程的寄存器的上下文被保存了下来。切到另一个线程之后,如果它执行到while (m > 0)这个语句时,它也会将m的值从内存读到寄存器中,其余线程也如此,这样就会导致判断语句将多个线程放进去了。每个线程的状态都不一样,当pid_1将m--完成之后,可能pid_2才会执行到这里,这个时候m又被--,同理,pid_3和pid_4都参与m--,导致m最终为负数。

因此这个m其实是需要被保护起来的,即临界资源,同样,fun函数中访问临界资源的代码(临界区)也需要被保护。我们通过保护临界区来保护临界资源。

这就叫线程互斥,即任意时刻只能有一个执行流进入临界区,访问临界资源。

②原子性

执行判断语句,CPU分为3步执行,即从内存获取数据到寄存器、分析、执行指令三步。

自减语句也分为3步,即从内存获取数据到寄存器、自减、写回内存三步。

以上两个语句均会被拆分为三个指令执行,并且这三个指令都同等重要,当任意一个指令被执行后就立马切走线程,就有可能导致出现错误。也就是说,执行判断语句和自减不是原子性的。

但是,导致上述代码的错误并不是单纯是因为这两个语句不是原子的,而是while循环的代码这个整体不是原子的。原子指的就是要么完成,要么还没完成,它不会被任何调度机制打断。从while循环的第一个判断语句的第一条指令开始,就视为这个代码块开始执行了,如果这个while循环的代码是原子的,就只有执行完和还没执行完两种状态,那么当第一个线程开始执行第一条指令后,就算存在时间片的轮转,其它线程也无法进入while的执行,因为第一个线程还没执行完。

从另一个角度来理解,一行指令肯定是原子的,因为无论时间片是怎样的,这条指令要么还没执行,要么就必须执行完,不存在执行了一半就被切走了。对于这条指令来说,同一时间内一定只有一个线程在执行它,只要当前线程开始执行它,那么就必须等这个线程执行完这个指令后,其它线程才有机会执行这个指令。

现在我们拓展一条指令为一个代码块,就是我前面所讲的例子。

总结:导致临界资源出现混乱的原因是临界区同时被多个线程访问。其根本原因是while循环代码块整体不是原子的,能同时放进来多个线程。如果while循环整体是原子的,那么一次性就只能允许一个线程进入,也就不会导致临界资源的错误了。

下面我们就要详细讲讲如何将一整个代码块变为原子的。

(3)加锁和解锁(互斥锁)的原理

首先当锁被创建之后,内存中就会新建一个变量mutex,这个变量默认为1。CPU内部有一个寄存器eax,在执行lock指令前默认先设为0,当调用lock指令时,eax和mutex里面的数据会进行交换(寄存器和内存里面的值进行交换),这个时候eax里面就为1,mutex里面的值为0。mutex不会主动修改自己的值,因此它会一直是0。如果执行lock指令之后eax为1,那么就让这个线程执行被加锁的代码。

在该线程执行代码期间,如果有其它线程来申请锁同样需要上面的步骤,eax设置为0,和mutex交换,判断eax是否为1。关键之处在于,当第二个线程交换mutex和eax时,eax本身是0不必多说,但mutex里面也是0,因为第一个线程已经把mutex里面的1交换走了!当线程切换时,第一个线程的eax的1被当作硬件上下文保存起来了,只留下了一个值为0的mutex在内存中。也就是说不管线程怎么切换,只要第一个线程不把mutex的1交换回去,其他线程无论怎么申请,都无法得到那个1。如果eax不是1,那么线程会被阻塞在加锁的函数里,隔一段时间后再次设eax为0,和mutex交换,判断eax。

因此加锁的核心就是那个交换寄存器和内存变量值的指令,只要谁先把内存中mutex的1拿走,谁就拥有临界区的访问权,而其它线程都必须等在原地。只有解锁时,线程把那个1还给mutex之后,其它线程才有机会进入临界区。通过这种方式,就实现了对整个代码块加锁,使其在线程看来是原子的。

形象的理解:mutex就是一间屋子外面墙上挂着的钥匙,并且这钥匙一次性只能给一个人用,谁拿到钥匙谁就可以用这个房间,并且其他人永远进不来,只能在外面等着。使用屋子的人可以中途上厕所、吃饭等,只要不重新把钥匙挂回墙上,这个屋子始终是他的。对其他人来说,拿着钥匙的人怎么用屋子这件事就是原子的,要么没用完,要么用完了。当使用这屋子的人将钥匙重新挂回墙上,其它人才能来抢钥匙,抢夺占有权。

为了实现互斥锁的操作,大多数体系结构都提供了swap和exchange指令,这个指令可以让寄存器内存单元交换数据。

(4)pthread_mutex系列函数和变量

①lock、unlock

pthread_mutex_lock( pthread_mutex_t * mutex )加锁,pthread_mutex_unlock( pthread_mutex_t * mutex )解锁。

pthread_mutex_lock就是执行上述eax设置为0,交换eax和mutex,判断eax是否为1的指令集合,那个1就是锁,凡是没有申请到锁的线程会被阻塞在函数中,直到1被还回来了。相对的,unlock就是还回那把锁的过程,也就是说,lock和unlock两行代码作为临界区的首尾,在这两个函数中间的代码对线程来说就是原子的,一次性只能通行一个线程。也要注意,锁有借有还,lock之后一定要unlock。

除此之外,还有一个函数pthread_mutex_trylock,它和lock唯一的区别是当它申请不到锁时不会一直阻塞在函数中,它申请锁后会返回一个值,让线程自主判断要不要继续申请。

②pthread_mutex_t

lock和unlock会用到一个变量,这个变量就是锁pthread_mutex_t。我们通过pthread_mutex_t mutex来定义它,它的底层定义如下,是一个联合体。

这把锁就对应内存中创建的mutex的1,每当创建一个pthread_mutex_t就会有一个1在内存中创建,可以说这个变量就是互斥锁的核心。结合前面的知识,体会它在加锁的整个流程的作用,体会为什么申请锁要传pthread_mutex_t *

下面是对上面代码的改进

通过加锁,锁间的代码在线程看来就变成原子的了,即实现了线程互斥,因此临界区的代码就不会被多个线程同时访问,也就不会出现临界资源错误了。

当创建的锁是全局或静态对象时,我们就用宏PTHREAD_MUTEX_INITIALIZER来对这个锁初始化,就像上图那样。我们之后也无需对锁进行任何其它操作。

除此之外,这个锁对象还可以是局部的,但这个时候需要手动init和destroy。

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int m = 10;

void *fun(void *p)
{
    pthread_mutex_t* pmutex = static_cast<pthread_mutex_t*>(p);

    pthread_mutex_lock(pmutex);
    while (m > 0)
    {
        sleep(1);
        m--;
    }
    pthread_mutex_unlock(pmutex);

    return nullptr;
}

int main()
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);
    pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;
    pthread_create(&pid_1, nullptr, fun, (void *)&mutex);
    pthread_create(&pid_2, nullptr, fun, (void *)&mutex);
    pthread_create(&pid_3, nullptr, fun, (void *)&mutex);
    pthread_create(&pid_4, nullptr, fun, (void *)&mutex);
    pthread_create(&pid_5, nullptr, fun, (void *)&mutex);

    pthread_join(pid_1, nullptr);
    pthread_join(pid_2, nullptr);
    pthread_join(pid_3, nullptr);
    pthread_join(pid_4, nullptr);
    pthread_join(pid_5, nullptr);
    pthread_mutex_destroy(&mutex);

    printf("m = %d\n", m);

    return 0;
}

注意init函数要在还处于单线程时就使用了,如果在多线程的代码下使用,每个线程访问这行代码后都能申请到锁,那就没有意义了。同理destroy就是销毁一把锁,一定要保证在完全用完这把锁之后才能使用。

(5)加锁常见错误

①init和destroy使用位置不当,每个线程来访问都能申请到锁

②创建锁的位置不当,多个线程生成了多把锁,临界区相当于没加锁

③部分临界区在锁外,导致加锁后一定会稳定地发生错误

④每次循环后都会解锁,直接破坏了代码块的原子性,和没加锁没区别

⑤加锁解锁没有配对,导致一直有线程被阻塞在lock函数里,这使得代码永远无法结束。

(6)对互斥锁的认识

我们能够认识到锁本身也是共享资源,但由于加解锁的核心指令是原子的,使得锁本身是安全的通过锁,我们能够将一段代码变为原子性的,使得线程访问必须一个一个进去,避免了同时访问会带来的错误。

加锁针对的是代码块,但一定不能无脑对大块代码加锁,要保证细粒度,最好只对临界区加锁。加锁会导致运行速度变慢,因为多线程执行任务时,在加锁段只能一个线程一个线程过去,好比独木桥,这座桥在保证足够用的情况下应尽量短。

我们前面讲的互斥锁都是软件实现的,加锁这件事还可以靠硬件实现。如OS一直在时钟中断,每次中断后会减去进程的时间片。当把外部中断和时钟中断禁用,不减时间片,直到执行完代码后恢复时,就实现了硬件的加锁。

2.线程同步

(1)线程互斥引发的问题

当一个线程申请到锁之后,其它线程再申请锁就会被lock,需要等待锁被还回来,当unlock后所有线程再去争夺同一把锁。这看似合理,但原本持有锁的那个线程在新一轮竞争锁的过程中最占有优势。就好比一个人还完钥匙后其它人还没反应过来的时候,他就可以再把锁拿走。这种不公平会引发效率问题,可能导致一直是同一个线程在使用锁。

要解决长期得不到锁引发的饥饿问题,需要定义一个新的规则,即被lock的线程都放到一个新的等待队列,一个线程还了锁后必须排到队列最后。这些在队列里的线程只有被唤醒后才能申请锁,这个唤醒操作每次都是针对队列头部的一个线程。这样一来,线程经过放入等待队列 -> 归还锁 -> 唤醒队头线程 -> 队头线程申请锁,就可以完美解决上述不公平问题。

于是说,多个线程访问临界资源需要线程互斥,多个线程访问临界资源还必须有顺序性,这就叫线程同步。线程互斥和同步紧密关联,互斥保证安全性,同步用于解决互斥锁带来的bug,保证系统调度更加合理和高效。

(2)pthread_cond系列函数和变量

①pthread_cond_t

这就是等待队列对应的联合体。当创建等待队列时,就需要通过pthread_cond_t cond来创建。当把线程放入等待队列的过程中,就要使用这个类型来接收线程。

它和pthread_mutex_t的创建销毁规则一模一样,全局或静态对象可用PTHREAD_COND_INITIALIZER且无需销毁,局部对象需用init和destroy手动创建销毁

②wait、signal

先看一下下面的使用,基于原先的代码进行同步改动

详细讲讲wait的详细操作,wait一定在lock和unlock之间,通常来说就紧跟在lock之后。当拿着锁的单个线程执行wait时,它要传递参数pthread_cond_t * 用于将线程加入等待队列中,还有第二个参数pthread_mutex_t * 用于还回锁。举个例子,A线程lock后拿到锁,执行wait后,A进入了等待队列并且把锁还回去。B线程就能从lock处申请到锁被放了进来,并且同样执行wait后,进入等待队列并还回锁,线程C、D亦是如此。因此,lock、wait后所有线程都被阻塞在等待队列之中。

注意lock后的线程只要有锁就不会被阻塞了,会放进去一个线程。而wait函数阻塞的线程会一直在等待队列里面阻塞,只有当对该等待队列执行signal时才会释放队头的线程。注意wait还有很重要的作用,signal后队头的线程不会马上被释放,还会被阻塞,这个线程会申请锁直到成功。所以wait后放出的线程会拿再次回这把锁,带着锁往下执行。

为什么会有这种功能?因为wait是在临界区,被阻塞在wait等待队列的线程手上都没有锁,当要往下执行时必须重新拿到锁,没有锁为何能够向下执行临界区的代码,这不就和之前的规定(进入临界区的线程拿着唯一的一把锁,目的是要保证该代码块的原子性)相悖吗?

这才是wait的线程被唤醒后一定要拿到锁才会向下执行的根本原因,注意体会逻辑的严密,理解大于记忆。

signal唯一的参数是pthread_cond_t * ,作用是唤醒相应等待队列的队头线程,只要保证指向的等待队列pthread_cond_t相同,该函数在任意地方执行均有效。

在上述代码中,所有执行fun函数的线程都被阻塞在wait队列中,主线程的signal唤醒了wait队列的队头线程,往下继续执行。unlock函数前是signal,它又唤醒了下一个线程,但这个线程必须在锁被还回去后才会被释放,也就是说unlock和signal的顺序不会改变执行逻辑,wait释放线程前都必须要申请到锁!

还有一个唤醒方法pthread_cond_broadcast( &cond ),它会立马唤醒所有队列里面的线程,参与到竞争中,一般来说用的很少,毕竟同步的目的就是保证顺序性,signal显然更严谨地保证顺序性。

下面是局部cond对象的代码

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int m = 10;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *fun(void *p)
{
    pthread_cond_t *pcond = static_cast<pthread_cond_t *>(p);
    pthread_mutex_lock(&mutex);
    pthread_cond_wait(pcond, &mutex);
    {
        sleep(1);
        printf("当前线程:0x%lx\n", pthread_self());
        m--;
    }
    pthread_cond_signal(pcond);

    pthread_mutex_unlock(&mutex);
    return nullptr;
}

int main()
{
    pthread_cond_t cond;
    pthread_cond_init(&cond, nullptr);

    pthread_t pid_1, pid_2, pid_3, pid_4, pid_5;
    pthread_create(&pid_1, nullptr, fun, (void *)&cond);
    pthread_create(&pid_2, nullptr, fun, (void *)&cond);
    pthread_create(&pid_3, nullptr, fun, (void *)&cond);
    pthread_create(&pid_4, nullptr, fun, (void *)&cond);
    pthread_create(&pid_5, nullptr, fun, (void *)&cond);

    sleep(1);
    pthread_cond_signal(&cond);

    pthread_join(pid_1, nullptr);
    pthread_join(pid_2, nullptr);
    pthread_join(pid_3, nullptr);
    pthread_join(pid_4, nullptr);
    pthread_join(pid_5, nullptr);

    pthread_cond_destroy(&cond);

    printf("m = %d\n", m);

    return 0;
}

执行结果是


 


http://www.kler.cn/a/559372.html

相关文章:

  • SQLMesh 系列教程8- 详解 seed 模型
  • C#贪心算法
  • 第六次作业
  • HTTP实验(ENSP模拟器实现)
  • C语言基础之【函数】
  • docker下安装 es 设置账号密码
  • 使用 Grafana 监控 Spring Boot 应用
  • 离子阱量子计算机的原理与应用:开辟量子计算的新天地
  • 分布式服务注册与发现
  • Maxscript血管网络分形的算法实现
  • Golang学习笔记_36——装饰器模式
  • DeepSeek本地搭建 和 Android
  • 后门慈善家
  • Leetcode 3464. Maximize the Distance Between Points on a Square
  • C#素数判定算法
  • Java-01-源码篇-04集合-05-ConcurrentHashMap(1)
  • 模型评测:基于Python和PyTorch的深度学习模型性能评估
  • Redis的弊端
  • vue3 Props的使用
  • SwinTransformer 改进:添加SimAM轻量级注意力机制