Linux 多线程 | 线程的互斥

在前面的文章中我们讲述了多线程的一些基本的概念以及相关的操作,那么在本章中我们就将继续讲述与多线程相关的同步与互斥之间的问题。

首先我们使用一个例子引出我们的问题,又一个全局的变量g_val = 100,这个变量是被所有的执行流所共享的,那么就可能会存在并发访问的问题。这个问题最可怕的就是当一个执行流在使用的时候,另一个执行流同样要进行操作。假设我们有线程A和线程B都要执行while(g_val--);的操作,当计算机要执行g_val--的操作的时候,从代码上来看只需要进行一步指令,但是从计算机的角度来说要先将数据从内存中加载到CPU中的寄存器中,在寄存器中进行--操作,然后再重新将数据放回内存中。按照下图进行基本的g_val--操作。Linux 多线程 | 线程的互斥_第1张图片

然后,开始程序的运行,首先是线程A,线程A的运气不是很好,当线程A计算刚刚进行第二步的时候,此时线程B要开始调度,那么经过之前学习的知识,需要将线程A的上下文进行保存。然后线程B就开始运行,线程B的运气比较好,一直运行将全局变量的值已经减到了10,当B线程想要再往后运行时遇到了和A 一样的问题,那么B也需要进行上下文的保存。当线程A的上下文重新被启用的时候,此时就会遇到一个问题,虽然我们已经计算将g_val的值减为10,但是由于A中上下文保存的数值是100,因此A开始运行的时候依旧是从100开始计算的。那么这里就出现了我们之前所述的问题:并发访问,进而导致数据不一致的问题。Linux 多线程 | 线程的互斥_第2张图片

  • 因此就需要我们对共享的资源进行保护 - 临界资源 - 衡量共享资源;
  • 我们的任何一个线程都有代码访问临界资源(临界区),同样的不访问临界资源的区域(非临界区) - 衡量线程代码。
  • 当我们想要让多个线程安全的访问临街资源,就可以使用加锁的方式进行互斥访问。
  • --操作并不是原子的,而对应了三条汇编指令:load :将共享变量ticket从内存加载到寄存器中;update : 更新寄存器里面的值,执行-1操作;store :将新值,从寄存器写回共享变量ticket的内存地址。为了让我们上述说的三条指令看起来像一条指令,那么就需要让这些指令是原子性的。

互斥量的接口

初始化互斥量

初始化互斥量有两种方法:静态分配,在全局范围定义 

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

动态分配,在局部定义需要初始化与销毁

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
参数:
    mutex:要初始化的互斥量
    attr:NULL

销毁互斥量

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

互斥量加锁与解锁

互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

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

简单的demo

#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int tickets = 1000; // 临界资源, 加锁保证临界
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 使用这种方式不用进行destory,若是在局部那么就需要使用pthread_mutex_init来进行初始化
void* threadRoutine(void* name)
{
    string tname = static_cast(name);

    while (true)
    {
        pthread_mutex_lock(&mutex); // 所有的线程都需要遵守这个规则
        if (tickets > 0) // 临界区
        {
            usleep(2000); // 模拟抢票花费的时间,在此处进行线程的切换导致有多个进程同时处于此状态
            cout << tname << " get a ticket: " << tickets-- << endl;
            pthread_mutex_unlock(&mutex); 
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
        usleep(1000); // 抢完票的时候需要有后续的动作
    }
    return nullptr;
}

int main()
{
    pthread_t tids[4];

    int n = sizeof(tids)/sizeof(tids[0]);

    for (int i = 0; i < n; ++i)
    {
        char* data = new char[64];
        snprintf(data, 64, "thread-%d", i + 1);
        pthread_create(tids+i, nullptr, threadRoutine, data);
    }

    for (int i = 0; i < n; ++i)
    {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

这里还有一些细节: 

1. 凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁,这是一个游戏规则,不能有例外。

2. 每一个线程访问临界区之前,得加锁,所以加锁是给临界区加锁,加锁的粒度尽量要细一些。

3. 线程访问临界区的时候,需要先加锁->所有的线程都必须看到同一把锁->锁本身就是公共资源->锁如何保证自己的安全?-> 加锁和解锁本身就是原子的!

4. 临界区可以是一行代码,可以是一批代码, a. 线程可能被切换吗?可能,不要特殊化加锁与解锁,还有临界区代码 b. 切换会有影响吗?不会因为在我不在的期间,任何人无法进入临界区,应为它无法申请到锁,因为锁被我拿走了。

5. 这正是体现互斥带来的串行化的表现,站在其他人的角度对其他线程有意义的状态就是:锁被我申请(持有锁),锁被我释放了(不持有锁),原子性就体现在这里。

6. 解锁的过程也应该被设计为原子的。

互斥锁实现原理探究

下面我们来简要的介绍一下互斥锁的实现原理:互斥量。

经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们看一下lock和unlock的伪代码。

这里我们默认 mutex = 1;

lock:
    movb $0, %al // 调用线程,向自己的上下文中写入0
    xchgb %al, mutex 
    if(al寄存器内容 > 0){ 
        return 0;
    }else 
        挂起等待;
    goto lock;

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

1. swap或exchange指令,该指令的作用是把寄存器和内存单元的数据进行相交换

2. 谁在执行加锁与解锁的代码?调用线程

3. 寄存器硬件只有一套,但是寄存器内部的数据是每一个线程都要有的 -->
寄存器 != 寄存器的内容(执行流的上下文)

其中第二句指令的交换:交换(一条汇编,体现加锁的原子性)的本质是: 将共享数据交换到自己的私有上下文当中 -- 加锁; 因为这里是交换所以不会有任何1的新增,那么由于交换是原子的,那么公共的mutex中的1就会被持有锁的那个线程的上下文占用,当其余持有锁的线程被加载到CPU的时候,由于mutex中的值是0, 因此该线程就会被挂起直到能够取到mutex中的1,即获取到锁。

解锁的时候就将1直接mov到mutex中即可。


 

你可能感兴趣的:(开发语言)