多线程 --- 线程互斥

目录

1. 线程互斥

1.1. 相关背景概念

1.2. 互斥锁

1.2.1. 初始化互斥量

1.2.2. 销毁互斥量

1.2.3. 互斥量加锁 && 解锁

1.3. 互斥量 (锁) 的原理

1.3.2. 相关问题和解释

1.3.2. 锁的实现原理

1.3.3. 可重入 && 线程安全问题

1.3.4. 常见的线程不安全的情况

1.3.5. 常见的线程安全的情况

1.3.6. 常见不可重入的情况

1.3.7. 常见可重入的情况

1.3.8. 可重入和线程安全的联系

1.4. 死锁

1.4.1. 死锁产生四个的必要条件

1.4.2. 如何避免死锁


1. 线程互斥

1.1. 相关背景概念

临界资源:任何一个时刻只能被一个执行流访问的资源,我们称之为临界资源。 更确切地说,在多线程场景下,当一个资源被多执行流共享的时候,通过某种方式让任何一个时刻只能被一个执行流访问的资源,我们称之为临界资源!

临界区:通俗点说,在多线程场景下,每个执行流 (线程) 内部访问临界资源的代码,我们称之为临界区。

互斥:在多线程场景下,互斥是一种同步机制。任何一个时刻,互斥可以保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用 (正确性和一致性),避免多个执行流同时通过临界区访问临界资源导致数据不一致以及其他问题。

原子性:指的是不会被任何调度机制打断。进行的操作是不可分割的,要么完成,要么未完成,那么我们就称该操作具有原子性。原子操作的执行是连续的、不可中断的。

说了这么多,可是我现在有一个疑问,为什么需要线程互斥呢? 在什么情景下需要呢? 因此,为了理解其中缘由,我们看一份代码:

#define THREAD_NUM 3
#define BUFFER_SIZE 64

int ticket = 3000; 

void* gain_ticket(void* arg)
{
  char* name = static_cast<char*>(arg);
  while(true)
  {
    if(ticket > 0){
      usleep(1000);
      // 由于我们是先打印在--, 故应该打印票数为1, 即代表票售完.
      std::cout << name << " get a ticket " << ticket << std::endl; 
      ticket--; 
    }
    else{
      break;
    }
  }
  delete[] name;
  return nullptr;
}

void Test1(void)
{
  pthread_t tid[3];
  for(size_t i = 0; i < 3; ++i)
  {
    char* buffer = new char[BUFFER_SIZE];
    snprintf(buffer, BUFFER_SIZE, "%s-%ld", "thread", i + 1);
    pthread_create(tid + i, nullptr, gain_ticket, static_cast<void*>(buffer));
  }
  for(size_t i = 0; i < THREAD_NUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }
}
int main()
{
  Test1();
  return 0;
}

代码逻辑: 我们创建了三个新线程,让每一个新线程都去调用这个 gain_ticket 抢票逻辑,由于在代码中我们是先打印票数在减减票数,故最后正确现象应该是,某个执行流打印的票数为1即代表着抢票逻辑完成,那么我们编译代码并运行,看看现象:

很不幸,结果与我们的预期不一致。 为什么呢?分析如下:

首先,上面的代码中,每个执行流 (新线程) 都会调用这个抢票逻辑,换言之,这个抢票逻辑会被多个执行流进入,那么这种现象是什么呢? 这就是重入现象,即对该函数进行了重入。

其次 ( usleep(1000) 操作 ), usleep 这个函数是按照微妙为单位进行休眠的。虽然这个时间对于我们来说是瞬间的,但是对于CPU而言,是具有一定的时间长度的,换言之,在上面的处理逻辑中,usleep 会导致多个执行流同时进入该代码段。

然后( if( ticket > 0) 操作 ), 事实上,判断的本质也是计算的一种,也是在访问 ticket 这个资源。因为这个 ticket 是保存在内存中的,而要计算,就必然是CPU进行计算,因此这个 ticket 也必然要Load到CPU中的寄存器中,而CPU中内置的寄存器的数据我们称之为当前被调度的执行流的上下文。

有了这些认识,我们就可以解释上面的现象了,当某个线程判断访问 ticket 时, 恰巧此时ticket的值就是1, 然后就要进行 usleep, 此时这个执行流就被挂起,同时,因为执行流切换且此时的 ticket 值为1,其他两个执行流也分别进入了该代码段, 现在就出现了一个现象,此时的 ticket 值为1, 但是有三个执行流都在该代码段中,且要进行 ticket-- 操作,而由于我们是先打印在--,故我们看到了 1、0、-1 这三个数据。 其中 1 是合法的,0、-1 都是非法数据。

最后 ( ticket-- 操作 ),表面上,ticket-- 操作,我们看似是一条语句,但实际上,该操作并非是原子的。我们很清楚,ticket 这个全局变量,肯定是会 Load 进内存的,而 -- 操作也必然是一种计算,而要计算,就必须要将内存的数据 Load 到 CPU 中,而 CPU 中的寄存器的数据我们称之为该执行流的上下文, 当CPU计算完毕后,就需要将计算后的数据重新写回内存。换言之,tickert--这个操作,就我们目前分析来看,就至少需要三条汇编指令,它们分别是:

1、 Load。 将内存中的 ticket 加载到CPU内的寄存器中。

2、 Update。 更新寄存器里面的值, 执行 -1 操作。

3、 Store。将新值,从寄存器中的数据写回到内存中。

为了更好理解 ticket-- 这个操作,我们参考一下下面这个图:

情景如下:

A、B线程分别要对这个全局变量 ticket 进行 --,A线程先被调度,并进行 Load、Update 此时CPU寄存器中的值就是 999, 但由于时间片的问题,A线程被切换,此时就要保存上下文,并该执行流的 task_struct 重新在运行队列排队,但是此时 ticket 在内存中的值仍就为1000,A线程被挂起,B线程开始被调度,B线程很顺利,在时间片期间,进行了多次--操作,此时内存中的 ticket 值以及成为了499,B线程时间片到达,B线程被挂起,A线程重新被调度,第一步就是恢复上下文,将自己的1000重新Load进CPU中的寄存器中,然后继续在上次被挂起的地方调度,也就是进行 Store 操作,故将999重新写回内存。 好,B线程废了九牛二虎之力将 ticket 减到了 499,你A线程一上来,又将 ticket 搞成了999,此时就导致了数据紊乱的问题。

有了上面的分析,我们至少知道,在多线程场景下,由于多线程切换,对一个不加保护的全局变量进行--,可能会导致了数据不一致问题。就上面的代码而言,无论是你 if 判断导致的还是 ticket-- 导致的,都可能出现数据错误或者数据不一致的问题。 

补充:大部分情况,线程使用的都是局部变量 (比如栈上的变量),变量的地址空间在线程栈空间中,这种情况,变量归属单个线程,其他线程无法获得这种变量 (一般情况下)。

但有时候,很多变量都需要在线程间共享,这样的变量称之为共享变量,可以通过数据的共享,完成线程之间的交互,多个线程并发的操作共享变量,会带来问题 (例如线程不安全以及数据不一致等问题)。

就例如上面的情况,导致数据错误和数据不一致的问题,为了解决共享变量的并发访问问题,常用的方法是使用同步机制,如互斥锁、条件变量、信号量等。这些机制可以保证同时只有一个线程访问共享变量,确保数据的一致性和正确性。正确地使用同步机制可以避免竞态条件和数据竞争问题,保障线程间的正确交互。

1.2. 互斥锁

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

  1. 互斥行为 ( Mutual Exclusion )。当某个执行流进入临界区访问临界资源时,不允许其他线程进入该临界区。换言之,执行流在临界区访问临界资源时,必须确保同一时间只有一个线程可以进入临界区访问临界资源。
  2. 独占访问( Exclusive Access )。如果多个线程同时要进入临界区访问临界资源,并且此时临界区没有线程在运行,那么只能允许一个线程进入该临界区访问临界资源。
  3. 无饥饿(No Starvation)。如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。这意味着线程不能永久地持有锁,并且应该尽量减少线程在临界区外等待的时间。

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

接下来,我们先了解一下接口,至于如何实现,我们最后讲。

首先什么是互斥量呢?

pthread_mutex_t mylock;  // 这就是一把锁

该锁的提供者是pthread线程库,即原生线程库。我们也可以看看它在内核中的声明:

// pthread_mutex_t 类型的声明
typedef union
{
  struct __pthread_mutex_s
  {
    int __lock;
    unsigned int __count;
    int __owner;
#ifdef __x86_64__
    unsigned int __nusers;
#endif
    /* KIND must stay at this position in the structure to maintain
       binary compatibility.  */
    int __kind;
#ifdef __x86_64__
    short __spins;
    short __elision;
    __pthread_list_t __list;
# define __PTHREAD_MUTEX_HAVE_PREV	1
/* Mutex __spins initializer used by PTHREAD_MUTEX_INITIALIZER.  */
# define __PTHREAD_SPINS             0, 0
#else
    unsigned int __nusers;
    __extension__ union
    {
      struct
      {
	short __espins;
	short __elision;
# define __spins __elision_data.__espins
# define __elision __elision_data.__elision
# define __PTHREAD_SPINS         { 0, 0 }
      } __elision_data;
      __pthread_slist_t __list;
    };
#endif
  } __data;
  char __size[__SIZEOF_PTHREAD_MUTEX_T];
  long int __align;
} pthread_mutex_t;

1.2.1. 初始化互斥量

初始化锁有两种方案,针对两种场景。

方案一: 静态分配 (全局/静态锁)

pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;  // 初始化全局/静态锁

全局/静态锁不需要进行显式销毁。

全局或静态锁是指在进程运行期间一直存在的锁对象,通常定义为静态变量或全局变量。这样的锁对象在程序的整个生命周期内都存在,不需要手动销毁。

当进程退出时,操作系统会自动回收所有的资源,包括全局或静态锁所占用的资源。因此,我们无需显式地销毁全局或静态锁。当进程结束时,所有的资源都会被操作系统自动释放。

方案二: 动态分配 (局部锁)

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

参数介绍:

mutex:指向 pthread_mutex_t 类型的指针,用于指定待初始化的互斥锁对象。这个对象是由调用者分配的内存空间,函数会将该内存空间进行初始化以创建一个互斥锁。

attr:指向 pthread_mutex_t 类型的指针,于指定互斥锁的属性。如果为 nullptr,则使用默认的属性,一般我们使用都是 nullptr,默认即可        。

因为是局部锁,因此,当生命周期结束时,需要我们手动销毁局部锁,以防止资源泄露。

1.2.2. 销毁互斥量

int pthread_mutex_destroy(pthread_mutex_t *mutex);

注意事项:

  1. 全局/静态锁不需要显式销毁:对于全局或静态互斥锁,由于其生命周期与整个进程的生命周期一致,并且资源的释放由操作系统自动完成,通常无需显式地销毁全局或静态互斥锁。

  2. 不要销毁已加锁的互斥锁:在销毁互斥锁之前,确保没有线程在使用该锁或该锁没有被加锁。如果一个互斥锁已经被加锁而尝试销毁它,会导致未定义的行为。

  3. 销毁后确保互斥锁不再被使用:在销毁互斥锁之后,需要确保没有其他线程在尝试使用该互斥锁。否则,已被销毁的互斥锁可能会被意外使用,导致不可预期的行为。

1.2.3. 互斥量加锁 && 解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 返回值: 成功返回0, 失败返回错误码。

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

1、 互斥量处于未加锁状态,既没有线程占用该锁,那么调用 pthread_mutex_lock 会成功获取锁,同时返回0。

2、 当互斥量已经处于加锁状态,即该锁已被某个线程占用,当其他执行流调用 pthread_mutex_lock 来申请该锁时,调用线程将会被阻塞在 pthread_mutex_lock 内部 (执行流被挂起),直到获取到锁的线程使用 pthread_mutex_unlock 释放锁,其他线程才会被唤醒,并开始竞争锁资源,一旦被唤醒的线程成功获取到锁资源,它会从 pthread_mutex_lock 函数中返回,继续执行后面的代码。

如图所示:

改善后的抢票逻辑: 

#define THREAD_NUM 3
#define BUFFER_SIZE 64
// 初始化全局锁
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER; 
int ticket = 3000;
void* gain_ticket(void* arg)
{
  char* name = static_cast<char*>(arg);
  while(true)
  {
    // 注意,判断也是在访问临界资源,因此也需要进行加锁保护
    pthread_mutex_lock(&mylock);
    if(ticket > 0){
      usleep(1000);
      // 由于我们是先打印在--, 故应该打印票数为1, 即代表票售完.
      std::cout << name << " get a ticket " << ticket << std::endl; 
      ticket--; 
      // 释放锁
      pthread_mutex_unlock(&mylock); 
    }
    else{
      // 释放锁
      pthread_mutex_unlock(&mylock); 
      break;
    }
    usleep(rand() % 1000);
  }
  delete[] name;
  return nullptr;
}

void Test1(void)
{
  pthread_t tid[3];

  for(size_t i = 0; i < 3; ++i)
  {
    char* buffer = new char[BUFFER_SIZE];
    snprintf(buffer, BUFFER_SIZE, "%s-%ld", "thread", i + 1);
    pthread_create(tid + i, nullptr, gain_ticket, static_cast<void*>(buffer));
  }

  for(size_t i = 0; i < THREAD_NUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }
}

现象如下:

现在就符合我们的预期。 

但需要注意的是,在多线程场景下,由于加锁会导致执行流调度临界区时串行化 (互斥化),因为一次只有一个线程能够进入临界区访问临界资源,那么一定程度上会导致效率降低。因此,加锁保护临界资源时,一定要保证加锁的粒度,粒度越小越好。例如一些打印语句 (不涉及到临界资源),那么我们完全可以将其归置于临界区之外,降低串行化影响效率的程度,提高程序的并发性能。

总结一下,确保加锁粒度小,只针对临界资源进行加锁,并将不涉及临界资源的操作放在临界区之外,是提高多线程程序性能的重要策略。这样可以最大程度地利用并行能力,减少串行化的影响,提高程序的并发性和效率。

上面的实现方案是通过全局锁实现的,那么同样,我们也可以用局部锁来对我们的临界资源进行保护。如下: 

#define PTHREAD_NUM 3
#define BUFFER_SIZE 32

int ticket = 1000;

class PTHREAD_INFO
{
public:
  PTHREAD_INFO(const std::string& name, pthread_mutex_t* Plock)
    :_name(name)
     ,_Plock(Plock)
  {}
public:
  std::string _name;
  pthread_mutex_t* _Plock;
};

void* GetTicket(void* arg)
{
  PTHREAD_INFO* info = static_cast<PTHREAD_INFO*>(arg);
  while(true)
  {
    pthread_mutex_lock(info->_Plock);
    if(ticket > 0)
    {
      usleep(1000);
      std::cout << info->_name << " get a ticket " << ticket << std::endl;
      ticket--;
      pthread_mutex_unlock(info->_Plock);
    }
    else
    {
      pthread_mutex_unlock(info->_Plock);
      break;
    }
    usleep(rand() % 500);
  }
  delete info;
  return nullptr;
}

void Test1(void)
{
  pthread_t tid[PTHREAD_NUM];
  pthread_mutex_t local_lock;
  // 初始化局部锁
  pthread_mutex_init(&local_lock, nullptr);
  char buffer[BUFFER_SIZE] = {0};
  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    snprintf(buffer, BUFFER_SIZE, "%s-%ld", "thread", i + 1);
    PTHREAD_INFO* info = new PTHREAD_INFO(buffer, &local_lock);
    pthread_create(tid + i, nullptr, GetTicket, static_cast<void*>(info));
  }
  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }
  // 局部锁需要我们手动释放
  pthread_mutex_destroy(&local_lock);
}

int main()
{
  srand((size_t)time(nullptr));
  Test1();
  return 0;
}

现象就不演示了,在这里还有一个问题,在多线程场景下,一定是线程越多,处理任务的效率越高吗? 我们就以上面的代码为基础,测试一下,在不同线程数目的场景下,其处理时间是多少,我们所用的接口就是 gettimeofday,具体如下:

man 2 gettimeofday

#include <sys/time.h>
int gettimeofday(struct timeval *tv, struct timezone *tz);

struct timeval {
    time_t      tv_sec;     /* seconds */
    suseconds_t tv_usec;    /* microseconds */
};

测试代码如下: 

void Test1(void)
{
  struct timeval start_time;
  pthread_t tid[PTHREAD_NUM];
  pthread_mutex_t local_lock;
  // 初始化局部锁
  pthread_mutex_init(&local_lock, nullptr);
  char buffer[BUFFER_SIZE] = {0};


  gettimeofday(&start_time, nullptr);
  // 以微妙为单位计算当前时间
  uint64_t u_start_time = start_time.tv_sec * pow(10,6) + start_time.tv_usec; 


  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    snprintf(buffer, BUFFER_SIZE, "%s-%ld", "thread", i + 1);
    PTHREAD_INFO* info = new PTHREAD_INFO(buffer, &local_lock);
    pthread_create(tid + i, nullptr, GetTicket, static_cast<void*>(info));
  }
  for(size_t i = 0; i < PTHREAD_NUM; ++i)
  {
    pthread_join(tid[i], nullptr);
  }
  // 局部锁需要我们手动释放
  pthread_mutex_destroy(&local_lock);
  

  struct timeval end_time;
  gettimeofday(&end_time, nullptr);
  // 以微妙为单位计算当前时间
  uint64_t u_end_time = end_time.tv_sec * pow(10,6) + end_time.tv_usec;
  std::cout << "spend time: " << u_end_time - u_start_time << std::endl;

}

测试现象如下 (仅供参考): 

可以看到,线程数目越多,花费的时间可能不是更少,反而会变多。因此,间接证明了,线程数目的增多和效率并非成正比关系,换言之,线程数目越多,其效率不一定更快。

这是由于线程创建、切换和同步等操作都需要消耗额外的时间和资源。

当线程数目增多时,系统需要频繁地在不同的线程之间切换,这会引入上下文切换开销。上下文切换是指从一个线程切换到另一个线程所需的操作,包括保存当前线程的上下文和加载下一个线程的上下文。当线程数目过多时,上下文切换的开销可能会占据执行时间的大部分,甚至超过线程并行所带来的性能提升。

此外,线程间的同步也会引入额外的开销。当多个线程并发地访问共享资源时,需要使用锁或其他同步机制来确保数据的一致性和线程安全。然而,过多的线程可能会导致不必要的竞争和频繁的锁竞争,进而降低了并发性能。

1.3. 互斥量 (锁) 的原理

1.3.2. 相关问题和解释

上面我们已经解决了互斥量的使用和为什么要有互斥量这些问题。而接下来,新的问题就是:在多线程场景下,加锁之后,多线程在临界区内是否会发生上下文切换?

答案:会被切换。在我们以前就说过,执行流在被CPU调度的时候,操作系统会在任意时刻都可能对当前被调度的执行流进行上下文切换 (这是完全随机的),在这里也同样如此,因此,尽管执行流处于临界区中,也会在任意时刻被操作系统中断发生上下文切换。

既然执行流在临界区内会被切换? 那么是否有问题呢?

答案: 没有问题。首先,一个执行流能够进入临界区,那么前提必然是这个执行流当前是持有锁的,换言之,你这个线程是在持有锁的前提下被切换的 (这把锁当前属于你这个线程)。因此,其他抢票线程即使要想进入临界区访问临界资源,就必须要先获取这把锁,但此时锁是无法获取成功的,因为此时这把锁已被其他执行流占用,所以其他线程无法进入临界区访问临界资源 (体现了互斥行为),就保证了临界区中的临界资源是被独占访问的,确保了数据的一致性和线程安全性。因此,不会有任何问题。

可能存在这样的疑问:在多线程场景下,一个线程,不申请锁资源,就想访问临界区中的临界资源,是否可以?

答案显而易见,这是不允许的。这种情况就属于错误的编码方式。我们要保证,执行流要想进入临界区访问临界资源,就必须要先获取相应的锁资源,才能进入临界区,访问临界资源,其他方式都不可以。

这种机制保证了对临界资源的互斥访问,避免了数据竞争和并发问题。如果一个线程没有获取锁就试图访问临界资源,就会破坏这种互斥性,导致不可预期的结果。

因此,在编写多线程代码时,一定要确保在访问临界资源之前先获得相应的锁资源,以确保线程间的互斥和正确的并发行为。

原子性的体现:

原子性指的是一个操作不可被中断,要么完全执行,要么完全不执行,不存在中间结果。

在多线程场景 && 加锁保护临界资源的场景下,在没有持有锁资源的线程看来, 对我这个线程有意义的情况只有两种:

1、 当前没有其他线程持有锁资源 (什么都没做):如果没有其他线程持有锁资源,则当前线程可以直接申请并获取到锁资源,进入临界区执行任务。这种情况下,当前线程可以立即执行临界区中的操作。

2、 持有锁资源的线程释放了锁:如果一个线程持有锁资源,并在临界区内执行任务时释放了锁资源,那么其他线程可以竞争获取锁资源。在这种情况下,当前线程可以通过竞争获得锁资源,并进入临界区执行任务。

上面这两种情况才对我这个线程有意义,因为此时我这个线程才可能成功申请锁资源。

在多线程场景下,对临界资源进行加锁保护后,执行流进入临界区访问临界资源时,一定是串行执行的吗?

答案是:当然是的。 当对临界资源加锁保护后,只有持有锁资源的线程才可以进入临界区访问临界资源, 其他不具备锁资源的线程只能在临界区外阻塞,等待占有锁资源的线程释放锁,然后竞争获得锁资源,进入临界区执行任务。换言之,临界区内只有一个执行流在被调度,因此,当然是串行执行的,更具体点说,执行流在临界区内一定是串行执行的。

经过上面的一些分析,我们至少知道了,临界资源的安全性是由锁来保证的。即执行流要访问临界资源,那么每一个线程都必须先申请锁资源,而同时每一个线程 (在一个进程内) 都必须先看到同一把锁 && 申请 (获得) 这把锁,也就是说,锁本身就是一种共享资源 (临界资源)!而锁保证了其他临界资源的安全,可是谁来保证锁这个临界资源的安全呢???

因此,为了保证锁这份临界资源自身的安全,锁的申请和释放动作必须是原子的!!!

可是,问题来了,我们如何保证呢? 换言之, 锁究竟是如何实现的?

1.3.2. 锁的实现原理

首先说一点,如果我们站在汇编角度,只有一条汇编语句,我们认为该汇编语句的执行是原子的。

接下来,我们先看一看加锁和解锁的汇编伪代码,具体如下:

lock:
    movb   $0,%a1
    xchgb  %al, mutex
    if( al寄存器的内容 > 0 ){
        return 0;
    } 
    else
        挂起等待;
    goto lock,

unlock:
    movb $1, mutex
    唤醒等待mutex的线程;
    return 0;

1、movb 是汇编指令中的一种操作码,它用于将一个字节大小的数据从一个位置拷贝到另一个位置。它可用于将数据从一个寄存器复制到另一个寄存器、从内存复制到寄存器、或者反之。“movb” 是 “move byte” 的缩写,表示拷贝一个字节大小的数据。

2、xchgb 也是汇编指令中的一种操作码,它用于交换两个字节大小的数据。它将源操作数中的值与目标操作数中的值进行交换,可以是寄存器和寄存器、内存和寄存器、或者反之。“xchgb” 是 “exchange byte” 的缩写,表示交换两个字节大小的数据。

%al: CPU中内置的寄存器,它是一个字节大小的通用寄存器,用于存储和处理字节大小的数据。

在硬件层面看来,CPU内部是内置了各种寄存器的,用于存储和处理数据。

而站在执行流的角度: CPU内部的寄存器中的数据我们称之为当前被CPU调度的执行流 (线程或者进程) 的上下文。具体讲:在多线程场景下,CPU内置的寄存器的空间是被所有执行流共享的,但是寄存器中的数据是各个线程私有的,因为这是当前被调度线程的上下文。

有了这些认识,我们看看下面这个场景:

场景如下:

线程A、B分别要进入临界区访问临界资源,因此,这两个线程也必须要先获取锁资源,我们可以简单地将这把锁理解为一个整数,在这里就是为1。

线程A先被CPU调度,因此先执行 movb 这条汇编指令,将 %al 寄存器的值置为0,好巧不巧,由于时间片到来,线程A被操作系统挂起,发生上下文切换,因此要保护上下文,而CPU中寄存器中的数据就是当前被CPU调度的执行流的上下文,故此时这个 %al寄存器中的0就是线程A的上下文,也要保存起来。

当线程A被挂起,CPU开始调度线程B,线程B也要执行 movb ,将 %al寄存器的值置为0,很幸运,线程B没有发生上下文切换,继续被调度,因此开始执行 xchgb 这条汇编指令,即将 %al寄存器中的数据 和 内存中 mutex 的数据做交换,交换后,此时 %al寄存器的值就为1,内存中的 mutex 值为0,线程B继续被调度,进行if语句判断,发现此时的 %al寄存器值 > 0,即代表着申请锁资源成功,并返回,线程B进入临界区执行任务。从这里我们就可以看出,申请锁资源的核心汇编语句就是这条 xchgb 。

线程B由于时间片到来,发生上下文切换,同理,此时 %al 寄存器中的数据 (具体为1)就是线程B的上下文,故也要保存起来。 随后CPU开始调度线程A,并不是一来就开始调度,首先的工作就是恢复上下文,将原先的数据恢复,例如将原来的0重新填入到 %al寄存器中,然后在之前被挂起的位置继续执行,也就是要执行 xchgb 这条汇编指令,可是此时 %al寄存器中的值和 内存中mutex的值都是0,因此,交换后,都还是0 (因为此时这把锁 (就是这个数据1) 在线程B的上下文中),进入if判断,发现%al寄存器值为0,故申请锁资源失败,线程A被挂起等待。

我们发现:

xchgb 交换的现象: 将内存中的数据和CPU中内置的寄存器的数据做交换。

而CPU中寄存器的数据我们称之为当前被CPU调度的执行流的上下文。

因此, xchgb 交换的本质: 将内存中的共享数据由交换的方式变为线程私有的 (线程的上下文)。

而获得 1 这个动作只有一条汇编,即就是xchgb这条汇编,因此申请锁资源是原子的。

同时,我们从上面的分析也发现:在申请锁资源的过程中,即使发生了上下文切换,也没有任何问题,核心原因就是因为,获得锁资源只有一条汇编语句 (xchgb) ,其保证了申请锁资源这个动作的原子性,也保证了锁资源的唯一性。

那么释放锁资源呢?

这个就很好理解了,当某个执行流执行完了临界区中的任务,释放锁后,本质就是将内存中这把锁又重新置为1, 并唤醒其他被挂起等待锁资源的线程,让它们重新开始竞争锁资源。

总而言之,之所以说,申请锁资源和释放锁资源是原子的,其根本原因是因为,这两个动作的核心步骤就只有一条汇编,前者是 xchgb ,代表获取锁资源,后者是movb,代表释放锁资源,由单条汇编指令保证了这两个操作的原子性。 

1.3.3. 可重入 && 线程安全问题

重入:重入现象是针对函数的。在多线程场景下,当同一个函数被多个执行流 (进程或线程) 调用,当前一个执行流还没有执行完,就有其他的执行流进入了该函数,那我们称这种现象就是重入。 当一个函数在重入的情况下,如果不会出现差异化或者任何问题,那我们称这种函数为可重入函数;反之,如果出现了差异化或者其他问题,我们称之为不可重入函数。

线程安全:线程安全是指当多个线程同时访问某个共享资源时,不会产生差异化结果或导致进程崩溃的情况。具体而言,线程安全的代码在多线程环境中表现良好,不会产生数据竞争、死锁、活锁等并发问题。

1.3.4. 常见的线程不安全的情况

1、不正确的共享资源访问:当多个线程同时访问共享资源时,没有正确地使用互斥机制来保护共享资源,可能导致数据的不一致性、错误的计算结果或其他问题。

2、死锁(Deadlock):当多个线程互相等待对方持有的资源,并且无法继续执行时,就会产生死锁。这种情况下,所有的线程都无法继续执行,导致系统无响应。

3、活锁(Livelock):当多个线程由于相互合作而无法继续执行时,就会产生活锁。线程不断地相互响应和回应,但无法向前推进,导致系统无法完成工作。

4、资源泄漏:当线程分配了某个资源后,没有正确地释放或销毁该资源,可能导致资源泄漏和系统资源的浪费。

1.3.5. 常见的线程安全的情况

1、互斥访问共享资源:通过使用互斥锁、信号量等同步机制,确保在同一时间只有一个线程可以访问共享资源,从而避免数据竞争和不一致性。

2、原子操作:通过使用原子操作或者加锁机制,保证对于可能被多个线程同时执行的操作,其执行过程是不可中断的,从而避免竞态条件和数据竞争的问题。

3、同步策略:通过合理的同步策略和交互顺序,确保多个线程之间按照正确的顺序访问和操作共享资源,避免并发问题和数据不一致性。

1.3.6. 常见不可重入的情况

1、调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。

2、调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

3、可重入函数体内使用了静态的数据结构。

4、使用全局变量:如果一个函数使用了全局变量,并且在修改全局变量的过程中没有使用任何同步机制(如互斥锁),当另一个线程调用该函数时,全局变量的值可能会被突然更改,导致不确定的结果。

5、使用静态变量:当一个函数使用静态变量,并且在函数执行期间修改了静态变量的值,如果另一个线程同时调用该函数,会导致静态变量的值的不确定性。

1.3.7. 常见可重入的情况

1、 不使用全局变量或静态变量,使用局部变量。

2、 不使用malloc 或者 new开辟出的空间

3、 不调用不可重入函数

4、 不返回静态或者全局数据,所有数据都由函数的调用者提供

5、 使用互斥锁和同步机制,使多线程环境下的共享资源得到正确的访问和操作,从而实现可重入。

1.3.8. 可重入和线程安全的联系

可重入函数是线程安全函数的一种;

如果一个函数是可重入的,那么这个函数就是线程安全的;

如果一个函数是线程安全的,那么这个函数不一定是可重入的;

如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

一个函数是不可重入的,意味着它无法在多个线程中同时调用而不引发问题,这可能导致线程安全问题。

可重入和不可重入只是一个函数的特征,而并不是说不可重入的函数是错误的。

在多线程场景下,线程安全是一个硬性条件,换言之,实现者不能写出线程不安全的代码,如果线程不安全,应及时调整。

1.4. 死锁

在多线程场景下,多个执行流彼此申请对方的锁资源并且还不释放自己已申请成功的锁资源,进而导致执行流无法向后推进的这种情况,我们称之为死锁。如图所示:

1.4.1. 死锁产生四个的必要条件

简单说,如果一旦产生了死锁,那么这四个条件一定是被同时满足的。这四个条件具体如下:

  1. 互斥条件(Mutual Exclusion):一个资源每次只能同时被一个执行流持有。这意味着当一个执行流获得了某个资源的独占访问权时,其他执行流就无法同时访问这个资源,只能等待该资源被释放。
  2. 请求与保持条件(Request and Hold):一个执行流因请求资源而阻塞时,对已获得的资源保持不释放。这意味着当执行流在持有某个资源的同时还去获取其他资源时,它保持已经拥有的资源,并请求其他资源,但是如果请求的资源被其他执行流持有,则当前执行流就会进入阻塞等待状态。通俗点讲,我拿着我自己申请的资源,还去申请其他的资源,并且我对我自己已成功申请的资源不释放。
  3. 不可剥夺条件(No Preemption):已经分配给执行流的资源不能在其释放之前被其他执行流强制性地剥夺。这意味着资源只能在它自己释放的情况下才能被其他执行流获取。即使其他执行流请求某个已被分配的资源,也不能将该资源从原有执行流那里剥夺。
  4. 环路等待条件(Circular Wait):存在一个执行流的资源等待链,使得每个执行流都在等待下一个执行流所持有的资源。这意味着存在一个循环依赖的资源等待关系,其中每个执行流都在等待其他执行流持有的资源,而这些资源又被其他执行流等待着,形成了一个等待的环路。

对于前三点,我认为很好理解,但对于我个人而言,在学习过程中,认为第四点稍微不好理解,因此在这里补充一下:

当我们谈论环路等待条件时,意味着存在一个执行流的资源等待链,使得每个执行流都在等待下一个执行流所持有的资源。

让我们通过一个简单的例子来理解环路等待条件。假设有三个资源 A、B 和 C,以及三个线程 T1、T2 和 T3。现在,T1 正在持有资源 A,并等待资源 B;T2 正在持有资源 B,并等待资源 C;T3 正在持有资源 C,并等待资源 A。这样就形成了一个循环依赖的周期性等待关系,即 T1 等待 T2 的资源,T2 等待 T3 的资源,而 T3 又等待 T1 的资源。

如图所示:

此时,这种环路等待关系导致上面每一个线程都无法继续执行,因为它们同时在等待其他线程所持有的资源 (导致自身被挂起)。而没有一个线程可以满足其对应线程所等待的资源,导致所有线程都被挂起等待,因此产生了死锁。 

1.4.2. 如何避免死锁

避免死锁的核心就是:破坏死锁产生的四个必要条件中的至少一个。

首先,我们要注意,在未来我们编写代码处理问题时,首先应该想清楚,是否有必要用锁,换言之,在情况允许的前提下能不用锁就不要用锁。 不要因为我们现在正在学习锁,就把锁当成每次编码的必需品,我们要根据具体场景判定是否真正的需要锁。而不加锁,我们破坏的就是互斥条件。

如果一个执行流多次申请锁失败,就将自身所拥有的锁释放,让其他执行流竞争。而这就破坏的是请求与保持条件。

我们可以通过 pthread_mutex_trylock 这个接口实现,这个接口会以非阻塞的方式获取互斥锁。返回值如下:

  • 当函数成功获取互斥锁时,返回值为 0,表示获取成功;
  • 如果互斥锁已被其他线程占用,函数将立即返回值 EBUSY,表示获取失败;
  • 如果在获取互斥锁的过程中发生了其他错误,可能是因为无效参数或系统错误,函数将返回相应的错误码。

要破坏不剥夺条件,可以使用强制释放(force-release)策略。这意味着如果一个线程在等待资源时,如果资源被其他线程占用,那么当前拥有资源的线程将被强制释放,以满足当前等待资源的线程的需求。这样做可能会导致正在执行的线程的部分工作丢失,所以需要谨慎使用 。

要破坏环路等待条件,可以使用资源排序(Resource Ordering)策略。这意味着为了使用资源,线程必须以相同的顺序请求资源。

还有一些其它思路避免死锁,如:

及时释放锁是一种有效的策略,可以在尽早的时候释放不再使用的锁资源,以便其他线程可以获得并继续执行。避免长时间持有锁有助于降低死锁的风险,尤其是在多个线程竞争相同资源时。

将临界区划分为更小的部分也是可行的方法。通过减小临界区的范围,可以降低多个线程同时占用锁的概率。这样可以减少不同线程因为同时持有不同的锁而导致的死锁风险。

1.4.3. 避免死锁算法

当然还有一些避免死锁的算法,在这里简单了解一下:

死锁检测算法旨在检测系统中是否存在死锁。常见的死锁检测算法有资源分配图算法和银行家算法。

资源分配图算法通过构建资源分配图来检测死锁。该算法通过以下步骤进行:

1. 创建资源分配图:将系统中的执行流表示为节点,资源表示为箭头。每个执行流节点连接到它所持有的资源箭头,资源箭头连接到它们的需求者节点。
2. 执行循环检测:检查资源分配图中是否存在环路。如果存在环路,则说明可能存在死锁。
3. 识别死锁:如果存在环路,进一步分析环路中的执行流和资源,可以确定存在死锁的执行流和资源。
4. 解除死锁:一旦确定了死锁的执行流和资源,可以采取相应的措施解除死锁,如终止死锁的执行流或以某种方式重新分配资源。

银行家算法是一种用于处理拥有有限资源的进程的死锁避免算法。它基于资源的分配和请求来预测系统是否处于安全状态,以避免死锁的产生。该算法为每个进程维护最大需求、已分配资源和需要的资源的信息。

银行家算法的工作原理如下:

1. 初始化:为每个进程分配初始资源和最大需求。
2. 请求资源:当进程请求资源时,系统检查是否可以满足请求。如果满足请求后系统仍然处于安全状态,则分配资源给进程;否则,进程必须等待。
3. 模拟执行:系统根据资源请求和释放模拟执行进程。在每次分配或释放资源后,检查系统的状态是否安全。
4. 判断安全状态:如果模拟执行期间系统一直处于安全状态,那么可以确保不会发生死锁。否则,系统可能进入不安全状态,需要采取相应措施避免死锁。

银行家算法根据进程的请求和释放来进行资源的分配,通过合理的资源管理和分配,可以避免死锁的发生。

简单了解一下。

  • 16
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值