在多线程中,我们经常会要用到锁,那么,锁是什么,我们为什么要用到锁?回到问题的本质,我们在什么场景下会用到锁?锁是针对程序中的临界资源,也就是公共资源的,当我们有两个或多个线程同时对一个临界资源操作的时候,为了保证共享数据操作的完整性,我们要为这些公共资源加锁。
在Linux中常见的锁主要有互斥锁、自旋锁、读写锁,至于递归锁则是互斥锁的一个特例。
在讲什么是互斥锁之前,我们先来看一下下面这段代码:
#includ
#includ
define THREAD_NUM 10
void *thread_proc(void *arg)
{
int *pcount = (int*)arg;
int i = 0;
while(i ++ < 100000) {
(*pcount) ++;
usleep(1);
}
}
int main()
{
pthread_t thread_id[THREAD_NUM] = {0};
int count = 0;
int i = 0;
for(i = 0; i < THREAD_NUM; i ++)
{
// 创建10个线程,每个线程对count实行自加到10万,count为这10个线程的一个共享资源
pthread_create(&thread_id[i], NULL, thread_proc, &count);
}
// 每隔一秒打印一次count的值
for(i = 0; i < 100; i ++) {
printf("count --> %d\n", count);
sleep(1);
}
return 0;
}
他的执行结果为下面这样:
执行这段代码,我们本意是想最后打印出来的count值能到100万,但是实际上,最后我们打印出来的count值只会有99万多,那这是为什么呢?这就是由于多线程对同一个临界资源进行操作,我们代码是只有一行idx++,但是在这行代码翻译成汇编代码的时候就变成了这样:
我们的一行idx++,翻译成汇编代码变成了3行。理想状态下,我们希望者行汇编代码能像下面这样顺序执行:
但是实际时,由于多线程的并发操作,使得部分时候执行的顺序变成了这样:
这就导致了我们上面的代码会出现最后的打印只有99万多的结果。为了确保不发生这种情况,我们就需要在对临界资源操作时加上锁。
Linux中的互斥锁是我们最常使用于线程同步的锁,标记用来保证在任一时刻,只能有一个线程访问该对象,同一线程多次加锁操作会造成死锁,通常情况下锁操作失败会将该线程睡眠等待锁释放时被唤醒。那么我们怎么使用互斥锁呢?pthread.h头文件中就提供了互斥锁的使用。
上面这些函数成功时返回0,失败则返回错误码。我们来在之前的代码中加入锁,再打印下结果:
#includ
#includ
define THREAD_NUM 10
pthread_mutex_t mutex;
void *thread_proc(void *arg)
{
int *pcount = (int*)arg;
int i = 0;
while(i ++ < 100000) {
pthread_mutex_lock(&mutex); // 加锁
(*pcount) ++;
pthread_mutex_unlock(&mutex); // 解锁
usleep(1);
}
}
int main()
{
pthread_t thread_id[THREAD_NUM] = {0};
int count = 0;
// 初始化互斥锁
pthread_mutex_init(&mutex, NULL);
int i = 0;
for(i = 0; i < THREAD_NUM; i ++)
{
// 创建10个线程,每个线程对count实行自加到10万,count为这10个线程的一个共享资源
pthread_create(&thread_id[i], NULL, thread_proc, &count);
}
// 每隔一秒打印一次count的值
for(i = 0; i < 100; i ++) {
printf("count --> %d\n", count);
sleep(1);
}
return 0;
}
这里可以看到,可以达到我们想要的结果100万。
严格上讲递归锁只是互斥锁的一个特例,同样只能有一个线程访问该对象,但递归锁允许同一个线程在未释放其拥有的锁时反复对该锁进行加锁操作。windows下的临界区默认是支持递归锁的,而linux下的互斥量则需要设置参数PTHREAD_MUTEX_RECURSIVE,默认则是不支持的。我们先来看下下面的代码:
#include
#include
int count = 0;
pthread_mutex_t mutex;
void* thread_proc(void*)
{
int i = 0;
for (i=0; i<5000; i++)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
count ++;
printf("count = %d\n", count);
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tid1, tid2;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid1, NULL, thread_proc, NULL);
pthread_create(&tid2, NULL, thread_proc, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
这里,我们在互斥锁lock后又调用了一次lock,这时,程序就会死锁,不输出任何信息。
但是,如果我们这里使用的是递归锁的话,就不会有死锁的问题。
#include
#include
int count = 0;
pthread_mutex_t mutex;
void* thread_proc(void*)
{
int i = 0;
for (i=0; i<5000; i++)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
count ++;
printf("count = %d\n", count);
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tid1, tid2;
// 需要先定义一个pthread_mutexattr_t变量,用于设置锁的属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
//设置锁的属性
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&mutex, &attr);
pthread_create(&tid1, NULL, thread_proc, NULL);
pthread_create(&tid2, NULL, thread_proc, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
使用递归锁,结果就可以正确的输出1~10000。
同样用来标记只能有一个线程访问该对象,在同一线程多次加锁操作会造成死锁。使用硬件提供的swap指令或test_and_set指令实现,同互斥锁不同的是在锁操作需要等待的时候并不是睡眠等待唤醒,而是循环检测保持者已经释放了锁。这样做的好处是节省了线程从睡眠状态到唤醒之间内核会产生的消耗,在加锁时间短暂的环境下这点会提高很大效率。
自旋锁的实现是为了保护一段短小的临界区操作代码,主要是用于在SMP上保护临界区,保证这个临界区的操作是原子的,从而避免并发的竞争冒险。在Linux内核中,自旋锁通常用于包含内核数据结构的操作,你可以看到在许多内核数据结构中都嵌入有spinlock,这些大部分就是用于保证它自身被操作的原子性,在操作这样的结构体时都经历这样的过程:上锁-操作-解锁。如果内核控制路径发现自旋锁“开着”(可以获取),就获取锁并继续自己的执行。相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径“锁着”,就在原地“旋转”,反复执行一条紧凑的循环检测指令,直到锁被释放。 自旋锁是循环检测“忙等”,即等待时内核无事可做(除了浪费时间),进程在CPU上保持运行,所以它保护的临界区必须小,且操作过程必须短。不过,自旋锁通常非常方便,因为很多内核资源只锁1毫秒的时间片段,所以等待自旋锁的释放不会消耗太多CPU的时间。
自旋锁的初始化有两种方式:
自旋锁的加锁和解锁:
在使用方法上,自旋锁和互斥锁差不多,这里还用上面互斥锁的那个例子:
#includ
#includ
define THREAD_NUM 10
pthread_spinlock_t spinlock;
void *thread_proc(void *arg)
{
int *pcount = (int*)arg;
int i = 0;
while(i ++ < 100000) {
pthread_spin_lock(&spinlock); // 加锁
(*pcount) ++;
pthread_spin_unlock(&spinlock); // 解锁
usleep(1);
}
}
int main()
{
pthread_t thread_id[THREAD_NUM] = {0};
int count = 0;
// 初始化自旋锁
pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED);
int i = 0;
for(i = 0; i < THREAD_NUM; i ++)
{
// 创建10个线程,每个线程对count实行自加到10万,count为这10个线程的一个共享资源
pthread_create(&thread_id[i], NULL, thread_proc, &count);
}
// 每隔一秒打印一次count的值
for(i = 0; i < 100; i ++) {
printf("count --> %d\n", count);
sleep(1);
}
return 0;
}
互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑
至于自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下,自旋锁一般用于多核的服务器。
高级别锁,区分读和写,符合条件时允许多个线程访问对象。处于读锁操作时可以允许其他线程和本线程的读锁, 但不允许写锁, 处于写锁时则任何锁操作都会睡眠等待。常见的操作系统会在写锁等待时屏蔽后续的读锁操作以防写锁被无限孤立而等待,在操作系统不支持情况下可以用引用计数加写优先等待来用互斥锁实现。 读写锁适用于大量读少量写的环境,但由于其特殊的逻辑使得其效率相对普通的互斥锁和自旋锁要慢一个数量级。值得注意的一点是按POSIX标准在线程申请读锁并未释放前本线程申请写锁是成功的,但运行后的逻辑结果是无法预测。
读写锁中的读操作可以共享,写操作是排它的,读可以有多个在读,写只有唯一个在写,写的时候不允许读操作。对于读数据较修改数据频繁的应用,用读写锁代替互斥锁可以提高效率。因为使用互斥锁时,即使是读出数据(相当于操作临界区资源)都需要上互斥锁;而采用读写锁则允许在任一时刻多个读出。
读写锁的初始化有两种方式:
读写锁的加锁和解锁:
获取读写锁的读操作有两种方式:
如果对应的读写锁被其它写者持有,或者读写锁被读者持有,该线程都会阻塞等待。