线程的同步与互斥:互斥锁

时间:2022-12-25 23:23:24

什么是线程的同步与互斥?

  • 互斥:指在某一时刻指允许一个进程运行其中的程序片,具有排他性和唯一性。
    对于线程A和线程B来讲,在同一时刻,只允许一个线程对临界资源进行操作,即当A进入临界区对资源操作时,B就必须等待;当A执行完,退出临界区后,B才能对临界资源进行操作。
  • 同步:指的是在互斥的基础上,实现进程之间的有序访问。假设现有线程A和线程B,线程A需要往缓冲区写数据,线程B需要从缓冲区读数据,但他们之间存在一种制约关系,即当线程A写的时候,B不能来拿数据;B在拿数据的时候A不能往缓冲区写,也就是说,只有当A写完数据(或B取走数据),B才能来读数据(或A才能往里写数据)。这种关系就是一种线程的同步关系。

那什么是临界资源和临界区呢?

  • 临界资源:能够被多个线程共享的数据/资源。
  • 临界区:对临界资源进行操作的那一段代码

多线程编程中,难免会遇到多个线程同时访问临界资源的问题,如果不对其加以保护,那么结果肯定是不如预期的。看下面这段代码:

static int g_val=0;
void* pthread_mem(void* arg)
{
int i=0;
int val=0;
while(i<500000)
{
val = g_val;
i++;
g_val=val+1;
}
return NULL;
}

int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, NULL, pthread_mem, NULL);
pthread_create(&tid2, NULL, pthread_mem, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf("g_val end is :%d\n",g_val);
return 0;
}

线程1和线程2都需要对g_val进行+1操作,循环500000次,由于每次对g_val进行+1操作时并不是一部完成的(原子操作),在一个线程执行过程中随时都有可能被切出去使另一个线程来操作,假设线程1正在执行时被切出去,此时它已经将g_val累加到3000,而线程2切进来的时候并不知情,可能会将g_val从0开始累加。我们期望的是线程1和线程2能将g_val累加到100 0000,但实际结果确实这样的:
线程的同步与互斥:互斥锁
很明显,结果是一个随机数。。。

线程同步与互斥的实现

  • 互斥锁(Mutex)
    1、互斥锁的本质:
    首先需要明确一点,互斥锁实际上是一种变量,在使用互斥锁时,实际上是对这个变量进行置0置1操作并进行判断使得线程能够获得锁或释放锁。
    (互斥锁的具体实现在文末讲解)
    2、作用:互斥锁的作用是对临界区加以保护,以使任意时刻只有一个线程能够执行临界区的代码。实现了多线程之间的互斥。
    3、接口:
    使用互斥锁主要有以下几个接口操作
//两种方法对锁进行初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//互斥锁的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

//获得锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

//释放锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

两种获得锁方法的比较:
pthread_mutex_lock:如果此时已经有另一个线程已经获得了锁,那么当前线程调用该函数后就会被挂起等待,直到有另一个线程释放了锁,该线程会被唤醒。
pthread_mutex_trylock:如果此时有另一个贤臣已经获得了锁,那么当前线程调用该函数后会立即返回并返回设置出错码为EBUSY,即它不会使当前线程挂起等待。

既然已经有了互斥锁,我们可以对上述代码进行修改,如下:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static int g_val=0;
void* pthread_mem(void* arg)
{
int i=0;
int val=0;
while(i<500000)
{
pthread_mutex_lock(&mutex);
val = g_val;
i++;
g_val=val+1;
pthread_mutex_unlock(&mutex);
}
return NULL;
}

这段代码通过对临界区进行加锁与解锁,每个线程在进入临界区的时候获得一把锁,操作执行完成后释放锁资源,使得其他等待的线程能够抱锁进入,这样就确保了每个线程进来执行的操作都是原子的,这样使得最后的结果为1000000.

  • 互斥锁的底层实现:

上边已经提到,mutex的本质是一种变量。假设mutex为1时表示锁是空闲的,此时某个进程如果调用lock函数就可以获得所资源;当mutex为0时表示锁被其他进程占用,如果此时有进程调用lock来获得锁时会被挂起等待。

1、lock和unlock的实现方案一
线程的同步与互斥:互斥锁
unlock:这个操作是原子的,即通过执行unlock的代码后,mutex要么为1,要么不为1
lock:执行lock时,先要对mutex进行判断,如果mutex>0,修改mutex=0,否则就表示锁被占用,将当前进程挂起等待。假设mutex为1,且有两个线程A和B来进行lock以获得锁,对于A和B来说,他两都拿到mutex为1,都会进入if()条件内部,此时线程A已经将锁拿到(mutex置为0),而B线程并不知道,也将mutex置为0,因此,线程A和线程B都会认为自己已经获得了锁。
对于这种方案,因为lock的过程不是原子的,也会产生错误。

2、lock和unlock的实现方案二
使用swap或exchange指令,这个指令的含义是将寄存器和内存单元中的数据进行交换,这条指令保证了操作的原子性:
线程的同步与互斥:互斥锁
lock:这一步先将0赋值到寄存器al,再将mutex与al中的值交换,再进行判断。当对某个线程进行lock操作时,即使在中间任意一步被切出去也没有问题。这样就保证了lock的操作也是原子的。

使用互斥锁引入的问题:

使用互斥锁可能会导致死锁问题。

  • 死锁:
    指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。通俗一点来讲,假设A线程持有锁a,B线程持有锁b,而线程访问临界区的条件时同时具有锁a和锁b,那么A就会等待B释放锁b,B会等待A释放锁a,如果没有一种措施,他两会一直等待,这样就产生了死锁。
  • 死锁产生的情况
    1、系统资源不足:如果系统资源足够,每个申请锁的线程都能后获得锁,那么产生死锁的情况就会大大降低;
    2、申请锁的顺序不当:当两个线程按照不同的顺序申请、释放锁资源时也会产生死锁。( 自行执行以下代码观察现象。)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int a=0;
int b=0;
pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;

void *another(void* arg)
{
pthread_mutex_lock(&mutex_b);
printf("new_thread,got mutex_b,waiting for mutex_a\n");
sleep(5);
++b;
pthread_mutex_lock(&mutex_a);
b += a++;
pthread_mutex_unlock(&mutex_a);
pthread_mutex_unlock(&mutex_b);
pthread_exit(NULL);
}

int main()
{
pthread_t id;
pthread_mutex_init(&mutex_a, NULL);
pthread_mutex_init(&mutex_b, NULL);
pthread_create(&id, NULL, another, NULL);

pthread_mutex_lock(&mutex_a);
printf("main_thread,got mutex_a,waiting for mutex_b\n");
sleep(5);
++a;
pthread_mutex_lock(&mutex_b);
a += b++;
pthread_mutex_unlock(&mutex_b);
pthread_mutex_unlock(&mutex_a);

pthread_join(id, NULL);
pthread_mutex_destroy(&mutex_a);
pthread_mutex_destroy(&mutex_b);
return 0;
}

在上述代码中,主线程先申请了mutex_a,再申请mutex_b,而在新线程中,新线程先申请mutex_b,再申请mutex_a,这样双方各自持有一把锁,并互相等待对方的锁,就产生了死锁。执行结果如下:
线程的同步与互斥:互斥锁

  • 死锁产生的条件
    1、互斥属性:即每次只能有一个线程占用资源。
    2、请求与保持:即已经申请到锁资源的线程可以继续申请。在这种情况下,一个线程也可以产生死锁情况,即抱着锁找锁。
    3、不可剥夺:线程已经得到所资源,在没有自己主动释放之前,不能被强行剥夺。
    4、循环等待:多个线程形成环路等待,每个线程都在等待相邻线程的锁资源。
  • 死锁的避免:
    1、既然死锁的产生是由于使用了锁,那么在能不使用锁的情况下就尽量不使用,如果有多种方案都能实现,那么尽量不选用带锁的这种方案
    2、尽量避免同时获得多把锁,如果有必要,就要保证获得锁的顺序相同