线程之间的锁有:互斥锁、条件锁、自旋锁、读写锁、递归锁。一般而言,锁的功能越强大,性能就会越低。
1、互斥锁
互斥锁用于控制多个线程对他们之间共享资源互斥访问的一个信号量。也就是说是为了避免多个线程在某一时刻同时操作一个共享资源。例如线程池中的有多个空闲线程和一个任务队列。任何是一个线程都要使用互斥锁互斥访问任务队列,以避免多个线程同时访问任务队列以发生错乱。
在某一时刻,只有一个线程可以获取互斥锁,在释放互斥锁之前其他线程都不能获取该互斥锁。如果其他线程想要获取这个互斥锁,那么这个线程只能以阻塞方式进行等待。
头文件:<pthread.h>
类型:pthread_mutex_t,
函数:pthread_mutex_init(pthread_mutex_t * mutex, const phtread_mutexattr_t * mutexattr);//动态方式创建锁,相当于new动态创建一个对象
pthread_mutex_destory(pthread_mutex_t *mutex)//释放互斥锁,相当于delete
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//以静态方式创建锁
pthread_mutex_lock(pthread_mutex_t *mutex)//以阻塞方式运行的。如果之前mutex被加锁了,那么程序会阻塞在这里。
pthread_mutex_unlock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t * mutex);//会尝试对mutex加锁。如果mutex之前已经被锁定,返回非0,;如果mutex没有被锁定,则函数返回并锁定mutex
//该函数是以非阻塞方式运行了。也就是说如果mutex之前已经被锁定,函数会返回非0,程序继续往下执行。
2、条件锁
条件锁就是所谓的条件变量,某一个线程因为某个条件为满足时可以使用条件变量使改程序处于阻塞状态。一旦条件满足以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。最为常见就是在线程池中,起初没有任务时任务队列为空,此时线程池中的线程因为“任务队列为空”这个条件处于阻塞状态。一旦有任务进来,就会以信号量的方式唤醒一个线程来处理这个任务。这个过程中就使用到了条件变量pthread_cond_t。
头文件:<pthread.h>
类型:pthread_cond_t
函数:pthread_cond_init(pthread_cond_t * condtion, const phtread_condattr_t * condattr);//对条件变量进行动态初始化,相当于new创建对象
pthread_cond_destory(pthread_cond_t * condition);//释放动态申请的条件变量,相当于delete释放对象
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;//静态初始化条件变量
pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);//该函数以阻塞方式执行。如果某个线程中的程序执行了该函数,那么这个线程就会以阻塞方式等待,直到收到pthread_cond_signal或者pthread_cond_broadcast函数发来的信号而被唤醒。
注意:pthread_cond_wait函数的语义相当于:首先解锁互斥锁,然后以阻塞方式等待条件变量的信号,收到信号后又会对互斥锁加锁。
为了防止“虚假唤醒”,该函数一般放在while循环体中。例如
- pthread_mutex_lock(mutex);//加互斥锁
- while(条件不成立)//当前线程中条件变量不成立
- {
- pthread_cond_wait(cond, mutex);//解锁,其他线程使条件成立发送信号,加锁。
- }
- ...//对进程之间的共享资源进行操作
- pthread_mutex_unlock(mutex);//释放互斥锁
pthread_cond_signal(pthread_cond_t * cond);//在另外一个线程中改变线程,条件满足发送信号。唤醒一个等待的线程(可能有多个线程处于阻塞状态),唤醒哪个线程由具体的线程调度策略决定
pthread_cond_broadcast(pthread_cond_t * cond);//以广播形式唤醒所有因为该条件变量而阻塞的所有线程,唤醒哪个线程由具体的线程调度策略决定
pthread_cond_timedwait(pthread_cond_t * cond, pthread_mutex_t * mutex, struct timespec * time);//以阻塞方式等待,如果时间time到了条件还没有满足还是会结束
3、自旋锁
前面的两种锁是比较常见的锁,也比较容易理解。下面通过比较互斥锁和自旋锁原理的不同,这对于真正理解自旋锁有很大帮助。
假设我们有一个两个处理器core1和core2计算机,现在在这台计算机上运行的程序中有两个线程:T1和T2分别在处理器core1和core2上运行,两个线程之间共享着一个资源。
首先我们说明互斥锁的工作原理,互斥锁是是一种sleep-waiting的锁。假设线程T1获取互斥锁并且正在core1上运行时,此时线程T2也想要获取互斥锁(pthread_mutex_lock),但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它去处理其他事务去了。
而自旋锁就不同了,自旋锁是一种busy-waiting的锁。也就是说,如果T1正在使用自旋锁,而T2也去申请这个自旋锁,此时T2肯定得不到这个自旋锁。与互斥锁相反的是,此时运行T2的处理器core2会一直不断地循环检查锁是否可用(自旋锁请求),直到获取到这个自旋锁为止。
从“自旋锁”的名字也可以看出来,如果一个线程想要获取一个被使用的自旋锁,那么它会一致占用CPU请求这个自旋锁使得CPU不能去做其他的事情,直到获取这个锁为止,这就是“自旋”的含义。
当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道“自旋锁”是比较耗费CPU的
头文件:<linux\spinlock.h>
自旋锁的类型:spinlock_t
相关函数:初始化:spin_lock_init(spinlock_t *x);
spin_lock(x); //只有在获得锁的情况下才返回,否则一直“自旋”
spin_is_locked(x)// 该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是, 返回真,否则返回假。
注意:自旋锁适合于短时间的的轻量级的加锁机制。
4、读写锁
说到读写锁我们可以借助于“读者-写者”问题进行理解。首先我们简单说下“读者-写者”问题。
计算机中某些数据被多个进程共享,对数据库的操作有两种:一种是读操作,就是从数据库中读取数据不会修改数据库中内容;另一种就是写操作,写操作会修改数据库中存放的数据。因此可以得到我们允许在数据库上同时执行多个“读”操作,但是某一时刻只能在数据库上有一个“写”操作来更新数据。这就是一个简单的读者-写者模型。
API接口说明:
1) 初始化和销毁
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
成功则返回0, 出错则返回错误编号.
2) 读加锁和写加锁
获取锁的两个函数是阻塞操作
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
成功则返回0, 出错则返回错误编号.
3) 非阻塞获得读锁和写锁
非阻塞的获取锁操作, 如果可以获取则返回0, 否则返回错误的EBUSY.
#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
成功则返回0, 出错则返回错误编号.
5.递归锁
递归锁,就是在同一线程上该锁是可重入的(同一个线程可以多次获取同一个递归锁,不会产生死锁。),对于不同线程则相当于普通的互斥锁。
在C/C++中(linux下)就需要使用pthread库中提供的互斥锁,并且设置锁的属性为递归锁:
pthread_mutex_t Mutex;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&Mutex, &attr);
6.互斥量和递归锁之间的讨论
1.1.概念和常识
Mutex可以分为递归锁(recursive mutex)和非递归锁(non-recursive mutex)。可递归锁也可称为可重入锁(reentrant mutex),
非递归锁 又叫不可重入锁(non-reentrant mutex)。二者唯一的区别是,同一个线程可以多次获取同一个递归锁,不会产生死锁。而如果一个线程多次获取同一个非递归锁,则会产生死锁。
但是如果将这两种锁误用,很可能会造成程序的死锁。请看下面的程序。
MutexLock mutex;
void foo()
{
mutex.lock();
// do something
mutex.unlock();
}
void bar()
{
mutex.lock();
// do something
foo();
mutex.unlock();
}
foo函数和bar函数都获取了同一个锁,而bar函数又会调用foo函数。如果MutexLock锁是个非递归锁,则这个程序会立即死锁。
因此在为一段程序加锁时要格外小心,否则很容易因为这种调用关系而造成死锁。
不要存在侥幸心理,觉得这种情况是很少出现的。当代码复杂到一定程度,被多个人维护,调用关系错综复杂时,
程序中很容易犯这样的错误。庆幸的是,这种原因造成的死锁很容易被排除。
但是这并不意味着应该用递归锁去代替非递归锁。递归锁用起来固然简单,但往往会隐藏某些代码问题。
比如调用函数和被调用函数以为自己拿到了锁,都在修改同一个对象,这时就很容易出现问题。因此在能使用非递归锁的情况下,
应该尽量使用非递归锁,因为死锁相对来说,更容易通过调试发现。程序设计如果有问题,应该暴露的越早越好。
1.2 如何避免
为了避免上述情况造成的死锁,AUPE v2一书在第12章提出了一种设计方法。即如果一个函数既有可能在已加锁的情况下使用,
也有可能在未加锁的情况下使用,往往将这个函数拆成两个版本---加锁版本和不加锁版本(添加nolock后缀)。
例如将foo()函数拆成两个函数。
// 不加锁版本
void foo_nolock()
{
// do something
}
// 加锁版本
void fun()
{
mutex.lock();
foo_nolock();
mutex.unlock();
}
为了接口的将来的扩展性,可以将bar()函数用同样方法拆成bar_without_lock()函数和bar()函数。
在Douglas C. Schmidt(ACE框架的主要编写者)的“Strategized Locking, Thread-safe Interface, and Scoped Locking”论文中,
提出了一个基于C 的线程安全接口模式(Thread-safe interface pattern),与AUPE的方法有异曲同工之妙。
即在设计接口的时候,每个函数也被拆成两个函数,没有使用锁的函数是private或者protected类型,
使用锁的的函数是public类型。接口如下:
class T
{
public:
foo(); //加锁
bar(); //加锁
private:
foo_nolock();
bar_nolock();
}
作为对外接口的public函数只能调用无锁的私有变量函数,而不能互相调用。在函数具体实现上,这两种方法基本是一样的。
上面讲的两种方法在通常情况下是没问题的,可以有效的避免死锁。但是有些复杂的回调情况下,则必须使用递归锁。
比如foo函数调用了外部库的函数,而外部库的函数又回调了bar()函数,此时必须使用递归锁,否则仍然会死锁。
AUPE 一书在第十二章就举了一个必须使用递归锁的程序例子。
1.3 读写锁的递归性
读写锁(例如Linux中的pthread_rwlock_t)提供了一个比互斥锁更高级别的并发访问。
读写锁的实现往往是比互斥锁要复杂的,因此开销通常也大于互斥锁。
在我的Linux机器上实验发现,单纯的写锁的时间开销差不多是互斥锁十倍左右。
在系统不支持读写锁时,有时需要自己来实现,通常是用条件变量加读写计数器实现的。有时可以根据实际情况,
实现读者优先或者写者优先的读写锁。
读写锁的优势往往展现在读操作很频繁,而写操作较少的情况下。如果写操作的次数多于读操作,并且写操作的时间都很短,
则程序很大部分的开销都花在了读写锁上,这时反而用互斥锁效率会更高些。
相信很多同学学习了读写锁的基本使用方法后,都写过下面这样的程序(Linux下实现)。
#include
int main ()
{
pthread_rwlock_t rwl;
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
程序2
#include
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
你会很疑惑的发现,程序1先加读锁,后加写锁,按理来说应该阻塞,但程序却能顺利执行。而程序2却发生了阻塞。
更近一步,你能说出执行下面的程序3和程序4会发生什么吗?
/*程序3*/
#include
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_rdlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
/*程序4*/
#include
int main()
{
pthread_rwlock_t rwl;
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_wrlock(&rwl);
pthread_rwlock_unlock(&rwl);
pthread_rwlock_unlock(&rwl);
return -1;
}
在POSIX标准中, 如果一个线程先获得写锁,又获得读锁,则结果是无法预测的。这就是为什么程序1的运行出人所料。
需要注意的是, 读锁是递归锁(即可重入),写锁是非递归锁(即不可重入)。因此程序3不会死锁,而程序4会一直阻塞。
读写锁是否可以递归会可能随着平台的不同而不同,因此为了避免混淆,
建议在不清楚的情况下尽量避免在同一个线程下混用读锁和写锁。
在系统不支持递归锁,而又必须要使用时,就需要自己构造一个递归锁。通常, 递归锁是在非递归互斥锁加引用计数器来实现的。
简单的说, 在加锁前,先判断上一个加锁的线程和当前加锁的线程是否为同一个。如果是同一个线程,则仅仅引用计数器加1。
如果不是的话,则引用计数器设为1,则记录当前线程号,并加锁。关于此的一个实现请参照《 多线程中递归锁的实现》