线程互斥锁:守护临界区的关键

发布于:2025-08-05 ⋅ 阅读:(19) ⋅ 点赞:(0)

线程互斥

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

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

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

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

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来⼀些问题。
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
   char *id = (char*)arg;
   while ( 1 ) {
       if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            } else {
                   break;
                   }
                }
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
⼀次执⾏结果:
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

为什么可能无法获得争取结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是⼀个原子操作
1.取出ticket--部分的汇编代码
2.objdump -d a.out > test.objdump
3.152  40064b:   8b 05 e3 04 20 00    mov    0x2004e3(%rip),%eax     #
600b34 <ticket>
4.153 400651:    83 e8 01 sub $0x1,%eax
5.154 400654:    89 05 da 04 20 00    mov    %eax,0x2004da(%rip) #
600b34 <ticket>

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

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

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

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

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

互斥量的接口

初始化互斥量
初始化互斥量有两种方法:

  • 方法1,静态分配
   1 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
  • 方法2,动态分配:
​
1 int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
  pthread_mutexattr_t *restrict attr);
2 参数:
3      mutex:要初始化的互斥量
4      attr:NULL

​

销毁互斥量

销毁互斥量需要注意:

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

互斥量加锁和解锁

1  int pthread_mutex_lock(pthread_mutex_t *mutex);
2  int pthread_mutex_unlock(pthread_mutex_t *mutex);
3  返回值:成功返回0,失败返回错误号

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

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

改进上面的售票系统:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
    char *id = (char*)arg;
    while ( 1 ) {
        pthread_mutex_lock(&mutex);
        if ( ticket > 0 ) {
             usleep(1000);
             printf("%s sells ticket:%d\n", id, ticket);
             ticket--;
             pthread_mutex_unlock(&mutex);
            // sched_yield(); 放弃CPU
       } else {
           pthread_mutex_unlock(&mutex);
           break;
       }
    }
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
  • 经过上面的例子,⼤家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据⼀致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,⼀个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock和unlock的伪代码改一下

线程同步

条件变量

  • 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了
  • 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列
    中。这种情况就需要用到条件变量。

同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

条件变量函数

初始化

1  int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
   *restrict attr);
2  参数:
3       cond:要初始化的条件变量
4       attr:NULL

销毁

1  int pthread_cond_destroy(pthread_cond_t *cond) 

等待条件满足

1  int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
   mutex);
2  参数:
3       cond:要在这个条件变量上等待
4       mutex:互斥量,后⾯详细解释

唤醒等待

1  int pthread_cond_broadcast(pthread_cond_t *cond);
2  int pthread_cond_signal(pthread_cond_t *cond);

简单案例:

  • 我们先使用PTHREAD_COND/MUTEX_INITIALIZER进行测试,对其他细节暂不追究
  • 然后将接口更改成为使用pthread_cond_init/pthread_cond_destroy 的方式,方便后
    续进行封装
#include <iostream>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *active( void *arg )
{
    std::string name = static_cast<const char*>(arg);
    while (true){
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 活动..." << std::endl;
        pthread_mutex_unlock(&mutex);
    }
}
int main( void )
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, active, (void*)"thread-1");
    pthread_create(&t2, NULL, active, (void*)"thread-2");
    sleep(3); // 可有可⽆,这⾥确保两个线程已经在运⾏
    while(true)
    {
// 对⽐测试
// pthread_cond_signal(&cond); // 唤醒⼀个线程
   pthread_cond_broadcast(&cond); // 唤醒所有线程
   sleep(1);
    }
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
}
$ ./cond
thread-1 活动...
thread-2 活动...
thread-1 活动...
thread-1 活动...
thread-2 活动..

锁的理解

锁的原理:

  1. 硬件实现:关闭时钟中断

  2. 软件实现:

    谁获取到了1,就代表谁申请到了锁

    lock可以理解为pthreadmutexlock

    unlock可以理解为pthreadmutexunlock

死锁

  • 死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的⼀种永久等待状态
  • 为了方便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问

申请⼀把锁是原子的,但是申请两把锁就不⼀定了

造成的结果是:

死锁四个必要条件:

  • 互斥条件:⼀个资源每次只能被⼀个执行流使用
  • 请求与保持条件:⼀个执行流因请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:⼀个执行流已获得的资源,在末使用完之前,不能强行剥夺

  • 循环等待条件:若⼲执执行流之间形成⼀种头尾相接的循环等待资源的关系

避免死锁

  • 破坏死锁的四个必要条件
  1. 破坏循环等待条件问题:资源⼀次性分配,使用超时机制、加锁顺序⼀致
// 下⾯的C++不写了,理解就可以
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>
// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;
// ⼀个函数,同时访问两个共享资源
void access_shared_resources()
{
    // std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    // std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    // // 使⽤ std::lock 同时锁定两个互斥锁
    // std::lock(lock1, lock2);
    // 现在两个互斥锁都已锁定,可以安全地访问共享资源
    int cnt = 10000;
    while (cnt)
    {
        ++shared_resource1;
        ++shared_resource2;
        cnt--;
    }
    // 当离开 access_shared_resources 的作⽤域时,lock1 和 lock2 的析构函数会被
⾃动调⽤
    // 这会导致它们各⾃的互斥量被⾃动解锁
}
// 模拟多线程同时访问共享资源的场景
void simulate_concurrent_access()
{
    std::vector<std::thread> threads;
    // 创建多个线程来模拟并发访问
    for (int i = 0; i < 10; ++i)
    {
         threads.emplace_back(access_shared_resources);
    }
    // 等待所有线程完成
    for (auto &thread : threads)
    {
        thread.join();
    }
    // 输出共享资源的最终状态
    std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
    std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}
int main()
{
    simulate_concurrent_access();
    return 0;
}
1  $ ./a.out // 不⼀次申请
2  Shared Resource 1: 94416
3  Shared Resource 2: 94536
1  $ ./a.out // ⼀次申请
2  Shared Resource 1: 100000
3  Shared Resource 2: 100000
  • 避免锁未释放的场景

网站公告

今日签到

点亮在社区的每一天
去签到