Linux篇18多线程第二部分

发布于:2023-01-04 ⋅ 阅读:(544) ⋅ 点赞:(0)

1.线程互斥

首先,我们先了解一下下面一组概念

临界资源:多线程执行流共享的资源就叫做临界资源

临界区:每个线程内部,访问临界资源的代码,就叫做临界区

互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

现在,我们来举一个买票的例子帮助大家更好地理解临界资源。

  • 假如现在有1000张票,有四个人ABCD去抢。每个人抢到票的数量不做限制,直到抢完为止。我们用一段代码来模拟这种情景。

    #include <stdio.h>
    #include <pthread.h>
    #include <unistd.h>
    int tickets = 1000;//定义一个全局变量来模拟1000张票                                                                                                                                                                                   
    void* TicketGrabbing(void* arg)
    {
      //四个线程抢票,有人抢到,tickets--
      const char* msg = (char*)arg;
      while(1)
      {
        if(tickets > 0)
        {
          usleep(100);
          tickets--;//还有票就继续抢
          printf("%s get a ticket: %d\n", msg, tickets);
        }
        else
        {//抢没了
          break;
        }
      }
      printf("%s quit\n", msg);
      pthread_exit((void*)0);
    }
    int main()
    {
      //我们创建四个线程1234来模拟四个人。
      pthread_t t1, t2, t3, t4;
      pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
      pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
      pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
      pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
    
      pthread_join(t1, NULL);
      pthread_join(t2, NULL);
      pthread_join(t3, NULL);
      pthread_join(t4, NULL);
      return 0;
    }
    
    

    这段代码看似没什么问题,可是当我们当看到运行结果的时候却发现了问题

    image-20220823134450184

    为什么会出现这种结果呢,按照我们的想法,在票被抢到0的时候就应该停止抢票,然后线程退出。

    这是因为,“tickets–”不是原子操作。ticket–至少要分三步完成。

    1. 将内存中的1000放进CPU的寄存器
    2. 将寄存器中数字-1
    3. 把-1之后的结果写回内存

    那么,就有可能出现某一个线程正在进行这三步的时候,另一个线程也来了,上一个线程–还没有完成,所以这个线程看到内存中的数字和上一个线程看到的是一样的,最后内存中的数字是多少就取决于哪个线程后将数据写回内存。

    此外,if(tikcets > 0)也不是原子的,那么就可能出现tickets此时已经为1了,一个线程来了,开始if(tikcets > 0),在判断还没完事的时候,另一个线程也来了,这个线程看到的tickets也是1,那么就也开始if(tikcets > 0)。然后就会出现tickets被–多次导致变成负数。

2.互斥量(锁)

为了避免上述的现象,我们需要一把,当多个线程都要访问临界资源的时候,一个线程在访问临界资源之前,申请一把锁,此时,其他线程无法访问临界资源,只能等待锁被释放。这样就避免了上述的问题。

为了帮助大家更好的理解,我来举一个例子。

  • 有一个自习室,一次只允许一个人进入,在自习室的们外面有一把钥匙挂在墙上。一天,ABC三个人都来自习室学习,他们一定是有先来后到的,第一个来到自习室门口的人A拿到钥匙进入了自习室,并且把门反锁,这时另外两个人BC就无法进入,只能在门外面等,等到里面的人出来再进去。

接下来,我们学习一下互斥量的接口

2.1互斥量的初始化

  • 方法1:静态分配

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
    
  • 方法2:动态分配

    int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
    //第一个参数就是互斥锁
    //第二个参数是互斥锁创建方式,一般我们使用NULL默认即可 
    
    

2.2互斥量的加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

互斥量处于未锁状态,该函数会将互斥量锁定。若其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。(这就是上面例子中竞争钥匙)

2.3互斥量解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

2.4互斥量的销毁

int pthread_mutex_destroy(pthread_mutex_t *mutex)

以上四个接口都是成功返回0,失败返回错误码

当我们在销毁互斥量的时候要注意一下几点

  1. 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  2. 不要销毁一个已经加锁的互斥量
  3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

接下来,我们就使用锁来改善一下抢票的代码

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

pthread_mutex_t mutex;
int tickets = 1000;
void* Gettickets(void* arg)
{
  int i = (int)arg;
  while(1)
  {
    pthread_mutex_lock(&mutex);
    if(tickets > 0)
    {
      usleep(100);
      tickets--;
      printf("线程%d抢到一张票,还剩%d张\n", i, tickets);
      pthread_mutex_unlock(&mutex);
    }
    else
    {
      pthread_mutex_unlock(&mutex);
      break;
    }
  }
  return (void*)0;
}
int main()
{
  pthread_t thd[4];
  pthread_mutex_init(&mutex, NULL);
  int i = 0;
  for(i = 0; i < 4; i++)
  {
    pthread_create(&thd[i], NULL, Gettickets, (void*)i);
  }
  for(i = 0; i < 4; i++)
  {
    pthread_join(thd[i], NULL);
  }                                
  return 0;
}

image-20220824154142804

显然我们使用了互斥量之后,不会出现上面的问题了

2.5关于互斥锁的思考

  1. 首先,在绝大多数情况,加锁本身都是有损于性能的,这几乎是不可避免的。作为程序员,我们已经尽可能的减少加锁带来的性能开销成本。在多执行流下,对于临界资源的保护,是所有执行流都应该尊所的标准

  2. 锁的存在是为了保护临界资源,但是锁本身就是临界资源,所以申请锁的过程必须是原子的。也就是说pthread_mutex_lock(&mutex);这行代码必须是原子的。那么锁的原子性是如何实现的呢?

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

      image-20220824184214434

    2.6死锁

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

    比如说AB进程分别占用pq资源,然后A要申请q资源,由于B不释放q资源,那么A就一直申请不到q资源。同样的,B要申请p资源,A不释放p资源,那么B也一直申请不到p资源。

3.运行等待队列和资源等待队列的理解

image-20220824190114069

image-20220824190220276

image-20220824190240897

4.可重入VS线程安全

4.1概念

  1. 可重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
  2. 线程安全:多个线程并发同一段代码时,不会出现不同的结果。

4.2常见的不可重入与可重入的情况

  • 不可重入情况:
    • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
    • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
    • 可重入函数体内使用了静态的数据结构
  • 可重入情况:
    • 不使用全局变量或静态变量
    • 不使用用malloc或者new开辟出的空间
    • 不调用不可重入函数
    • 不返回静态或全局数据,所有数据都有函数的调用者提供
    • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

4.3常见线程安全与不安全的情况

  • 线程安全:
    • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
    • 类或者接口对于线程来说都是原子操作
    • 多个线程之间的切换不会导致该接口的执行结果存在二义性
  • 线程不安全:
    • 不保护共享变量的函数
    • 函数状态随着被调用,状态发生变化的函数
    • 返回指向静态变量指针的函数
    • 调用线程不安全函数的函数

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

  • 联系

    1. 函数是可重入的,那就是线程安全的
    2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
    3. 如果一个函数中有全局变量,那么这个函数是线程不安全的,且是不可重入的。
  • 区别

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

    1. 函数是可重入的,那就是线程安全的
    2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
    3. 如果一个函数中有全局变量,那么这个函数是线程不安全的,且是不可重入的。
  • 区别

    1. 可重入函数是线程安全函数的一种
    2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
    3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
本文含有隐藏内容,请 开通VIP 后查看