1.相关背景概念
#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include <unistd.h>
using namespace std;
struct customer
{pthread_t tid;string name;
};
int g_tickets = 1000;
void* GetTicket(void* args)
{customer* c = (customer*)args;while(true){if(g_tickets > 0){usleep(1000);cout << c->name << " is getting a ticket " << g_tickets << endl;g_tickets--;}elsebreak;}return nullptr;
}int main()
{vector<customer> custs(5);for(int i = 0; i < 5; i++){custs[i].name= "customer-" + to_string(i + 1);pthread_create(&custs[i].tid, nullptr, GetTicket, &custs[i]);}for(int i = 0; i < 5; i++){pthread_join(custs[i].tid, nullptr);}return 0;
}
例如上面代码中,我们模拟一个抢票系统,票 g_tickets 为全局变量,被所有线程共享,即为临界资源,而在所有线程进行的 GetTicket 函数中,都进行抢票,对 g_tickets 进行 -- 操作。下面访问临界资源 g_tickets 的代码区域就是临界区。
if(g_tickets > 0)
{usleep(1000);cout << c->name << " is getting a ticket " << g_tickets << endl;g_tickets--;
}
随后我们执行代码
发现其竟然抢到了0,-1,-2,-3这些不存在的票,所以我们可以得知访问临界资源时是会出现问题的,于是我们可以引出以下概念:
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成,即一条汇编语句。
上面抢票系统为什么会出现这样的情况呢?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- --ticket 操作本身就不是一个原子操作,即它是多条汇编语句组成
例如,我们简化模型:有customer - 1 和 customer - 2 去抢一张票
该线程先判断,发现 g_ticket > 0
,于是进入第一个if
语句,进行g_ticket--
。但是g_ticket--
本质上是多条汇编语句,比如下面这样:
MOV eax, [0x1000] ; 读取 g_ticket 的值
DEC eax ; 减 1
MOV [0x1000], eax ; 将值写回 g_ticket
第一行MOVE
把内存中 g_ticket 的数据拷贝到CPU
中的eax寄存器,第二行是将 eax中的值 - 1
,第三行是将eax中的值拷贝回内存中的 g_ticket。
假设我们现在执行到 g_ticket-- 汇编的第二条指令,突然线程customer-1
的时间片结束了,要结束调度当前线程,去调度customer-2
了。此时内存中的g_ticket
还没有被修改了,于是CPU
保存当前线程customer-1
的上下文,切换调度customer-2
:
此时线程customer-2也通过if (g_ticket > 0)检测发现还有一张票,于是custome-2也去抢这张票,执行g_ticket--。
这下出问题了,customer-1已经抢了这张票,但是还没来得及把g_ticket变成0,此时customer-2又进来抢了一次票。最后就会出现一张票被两个人抢到的问题。
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
这就需要用锁来实现线程互斥,Linux上提供的这把锁叫互斥量。
2.互斥量mutex
初始化互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
返回值:如果函数成功,返回 0
;如果失败,返回相应的错误码。
方法3,C++STL:
在C++标准模板库(STL)中,可以使用std::mutex
作为互斥量:
#include <mutex>int main() {std::mutex my_mutex;// 使用互斥量...// 不需要显式销毁,因为它是对象,在其作用域结束时会自动析构return 0;
}
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
mutex
: 这是指向已经初始化过的互斥量的指针。
返回值:如果函数成功,则返回 0
;若发生错误,则返回相应的错误码。
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- pthread_mutex_lock:用于申请锁,如果申请失败,就阻塞等待,直到申请到锁;如果申请成功,就执行临界区代码。
- pthread_mutex_trylock:用于非阻塞等待申请锁,如果申请失败,立即返回错误码
EBUSY
,表示互斥量当前不可用;如果申请成功,就执行临界区代码。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_unlock:释放(解锁)互斥量mutex
,使其他线程可以获取该互斥量,调用这个函数的线程应该是当前持有互斥量的线程。
接下来我们修改一下抢票系统的代码,给它加锁,保证抢票g_ticket--
的原子性:
int g_tickets = 1000;
//定义一个全局变量的锁,保证所有线程都用同一个锁
pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
void *GetTicket(void *args)
{customer *c = (customer *)args;while (true){pthread_mutex_lock(&g_mutex); //加锁if (g_tickets > 0){usleep(1000);cout << c->name << " is getting a ticket " << g_tickets << endl;g_tickets--;pthread_mutex_unlock(&g_mutex); //解锁}else{pthread_mutex_unlock(&g_mutex); //解锁break;}}return nullptr;
}
当第一个线程被调度进行抢票,先申请锁g_mutex,然后再去if中访问g_ticket。假如在访问临界资源的过程中,CPU调度了第二个线程,第二个线程也想访问g_ticket,于是也申请锁g_mutex,但是由于锁已经被第一个线程申请走了,此时第二个线程pthread_mutex_lock就会失败,然后阻塞等待。等到第一个线程再次被调度,访问完临界区后,对g_mutex解锁,此时锁又可以被申请了。于是线程二申请到锁,再去访问g_ticket。加锁可以保证任何时候都只有一个线程访问临界区,这就保证了临界区的原子性,从而维护线程的安全!
3. 互斥量的实现原理
互斥量的汇编伪代码如下:
加锁lock
:
moveb $0, %al
xchgb %al, mutex
if (al寄存器的内容 > 0){return 0;
}else挂起等待;
goto lock
假设有这样的情形,有两个线程thread-1
和thread-2
,它们共用内存中的锁mutex
。在CPU
中有一个寄存器%al
,用于存储锁的值。
假设thread-1
进行调度执行pthread_mutex_lock
:
首先执行指令moveb $0, %al
,把寄存器%al
内部的值变成0
:
随后执行xchgb %al, mutex
,让内存中的mutex
与寄存器%al
的值进行交换:
此时寄存器%al
的值变成1
,mutex
的值变成0
。随后执行:
if (al寄存器的内容 > 0){return 0;
}else挂起等待;
判断当前%al
内部的值是否大于0
,如果大于0
那么说明争夺到了锁,此时函数pthread_mutex_lock
返回0
,表示加锁成功,否则执行else
进行挂起等待。
现在假设thread-1
执行到第一条汇编语句后,%al
的值还是0
,但是CPU切换去调度thread-2
了:
现在thread-1
保存自己的硬件上下文,包括%al = 0
在内,随后therad-2
进入:
现在thread-2
执行了两行汇编语句,成功把内存中的mutex
与自己的%al
交换,申请到了锁,此时thread-1
再次调度,thread-2
拷贝走自己的硬件上下文:
恢复硬件上下文后,thread-1的%al等于0,执行第二条语句后,%al和mutex依然是0,这表明锁已经别的线程拿走了,此时在执行if内部的内容,thread-1挂起等待。
其实锁的本质,就是保证mutex变量中以及所有访问锁的线程的%al寄存器中,只会有一个非零值。只有拿到非零值的线程才有资格去访问临界资源。其它线程如果要再次申请锁,由于自己的%al和mutex都是0,就算交换后还是0,也申请不到锁。并不是谁先调用ptherad_mutex_lock,谁就先抢到锁,而是谁先执行该函数内部的xchgb %al, mutex语句,把非零值放到自己的%al中,谁才抢到锁。
再简单看看解锁unlock
:
moveb $1, mutex
唤醒等待mutex的线程;
return 0;
moveb $1, mutex
就是把%al
中的1
还给mutex
,然后唤醒所有等待该锁的线程,让它们再次争夺这把锁。最后return 0
,也就是pthread_mutex_unlock
函数返回0
。