锁的基本思想
锁的基本思想与现实中的锁是一致的。
比如有一个保险柜中存放着若干现金,由A,B二人共同使用。当A存钱、放钱的时候需要对保险柜上锁,B同理。
至于为什么需要上锁,通俗来讲就是在计算机中可能出现这样的情况:A,B同时向保险柜存钱,此时由于操作系统可能引入不合时宜的中断,计算机可能会遗漏A、B其中一人的操作。
锁的基本功能
锁的基本功能是阻止多个线程同时进入临界区。
当线程对锁执行lock操作时,如果锁已经被其他线程锁住,那么可以认为它会阻塞知直到锁被其他线程解开(线程可能进入休眠状态,即告诉操作系统不要将CPU资源分配给自己,也可能一直自旋,即始终占用CPU资源执行while测试语句)。如果锁没有被锁住,那么这个线程会将锁上锁。
执行unlock就是将锁解开。
锁的API
我们以A、B同时递增计数器为例,说明锁在程序中的使用。
#include <pthread.h>
//数据结构
pthread_mutex_t lock;
//初始化
pthread_mutex_init(&lock, NULL); //第二个参数为配置,通常填入NULL表示使用默认配置
//上锁,解锁
pthread_mutex_lock(&lock);
多线程程序的难点不在于API的使用,而是如何设计出逻辑正确或者线程安全的代码,这需要严密的逻辑,并且考虑到各种极特殊情况。
#include <iostream>
#include <pthread.h>
using namespace std;
struct counter_t{
int value;
pthread_mutex_t lock;
}c;
void* mythred(void* arg)
{
cout << "begin" << static_cast<char*>(arg) << endl;
pthread_mutex_lock(&c.lock);
for (int i = 0; i < 1e7; i++)
c.value += 1;
cout << "done" << static_cast<char*>(arg) << endl;
pthread_mutex_unlock(&c.lock);
return nullptr;
}
int main()
{
cout << "hello world" << endl;
pthread_t p1, p2;
char A[] = "A", B[] = "B";
pthread_mutex_init(&c.lock, NULL);
pthread_create(&p1, NULL, mythred, static_cast<void*>(&A));
pthread_create(&p2, NULL, mythred, static_cast<void*>(&B));
pthread_join(p1, NULL);
pthread_join(p2, NULL);
cout << c.value << endl;
}
结果:
评价锁
- 锁是否有效:就是看锁能否正确的完成其功能:防止多个线程同时进入临界区。
- 公平性:如果有多个线程在等待锁解开,那么谁将占有锁?该过程是否公平?是否会出现饿死的情况?
- 性能:引入锁的时间开销如何?锁能否适用于多个线程的情况?
实现锁的机制
简单标志
struct lock_t{
int flag;
}
void init(lock_t* mutex){
mutex -> flag = 0;
}
void lock(lock_t* mutex){
while (mutex -> flag == 1) //p1
; //spin-wait*(do nothing)
mutex->flag = 1; //p2
}
void unlock(lock_t* mutex){
mutex -> flag = 0;
}
该方案体现了锁的基本功能,但他是有问题的。
如果A线程在执行p2之前系统转向B线程,那么会出现A、B同时上锁的情形。
测试并设置
上面的方案的问题出在测试的语句p1与设置的语句p2不具有原子性(参见:)
所以我们需要硬件提供支持。在x86架构中,硬件提供了xchg指令。
我们用如下C语言代码说明原子性的测试并设置指令做了什么:
int TestAndSet(int* old_ptr, int new){
int old = *old_ptr;
*old_ptr = new;
return old;
}
//用该语句实现锁:
struct lock_t{
int flag;
}
void init(lock_t* mutex){
mutex -> flag = 0;
}
void lock(lock_t* mutex){
while (TestAndSet(&mutxe->flag, 1) == 1) //p1
; //spin-wait*(do nothing)
mutex->flag = 1; //p2
}
void unlock(lock_t* mutex){
mutex -> flag = 0;
}
比较并交换
硬件支持还有比较并交换原语,它比测试并设置要强大一些。
int CompareAndSwap(int* ptr, int expected, int new)
{
int actual = *ptr;
if (actual == expected)
*ptr = new;
return actual;
}
void lock(lock_t* mutex){
while (CompareAndSwap(&mutxe->flag, 0, 1) == 1) //p1
; //spin-wait*(do nothing)
}
void unlock(lock_t* mutex){
mutex -> flag = 0;
}
链接加载与条件存储
该指令在MIPS架构中广泛应用。
int LoadLinked(int* ptr){
return *ptr;
}
int StoreConditional(int* ptr, int value){
if (ptr指向的值在期间没有改变)
{
*ptr = value;
return 1;
}
else
return 0;
}
void lock(lock_t* mutex){
while (LoadLinked(&mutxe->flag) == 0 &&
StoreConditional(&mutex->flag, 1) == 1)
; //spin-wait*(do nothing)
}
void unlock(lock_t* mutex){
mutex -> flag = 0;
}
按照我个人的理解,这个方案也是对简单标志方案的改进。前面提到,简单方案出错是因为测试指令与设置指令直接可能出现中断。在这里我允许中断,但通过条件存储指令确保其它线程在中断期间没有改变标志的值。比较测试并设置方案,该方案具有更好的性能。
如何避免自旋
前面提到的几种机制,都是自旋锁,即等待的线程重复执行while测试指令,始终占有CPU资源。这种锁的性能是很低的(很多CPU时钟片段被没有意义的自旋占据)
最简单的方法是通过硬件的支持,规定等待的线程让出CPU资源。
这种方案仍有不足,如果等待的线程数特别多,那么CPU会进行多次无意义的上下文切换。
休眠代替自旋
《操作系统导论》教材介绍了使用等待队列,加入休眠机制的方案。
实在是天资愚笨,没看明白书上的代码。
大概是说,如果一个线程在等待锁,那么就让它休眠,并加入等待队列中,等占有锁的线程释放锁时,它会从等待队列中唤醒一个线程。