锁
问题演示
当对公共资源或者临界资源访问时需要加锁,先来看一个不加锁异常的例子:
|
|
我们的预期结果是10万,但实际结果却不足10万。
问题分析
|
|
上述代码不是原子操作,翻译成汇编代码
- 将idx所在内存的值赋值到eax这个累积寄存器上。
- 对eax执行自增
- 将eax的值赋值给idx所在内存
我们希望所有的线程执行count++时,都是像下面这样的理想状态
但是实际情况会包括下面这两种
拿第一种情况来说明,当idx=1,线程1将1赋值给eax,此时发生线程切换,线程2自增完idx=2,切换回线程1,此时eax的值还是1,自增完idx=2,原先应该等于3但是实际结果是2。
线程切换
线程切换首先会保护当前线程的上下文,将
|
|
保存到当前线程的pcb中。
然后将另一个线程的上下文加载到对应的寄存器中。
保存上下文和加载上下文就叫切换。
三种保存上下文的方式(如何从寄存器保存到ctx结构体中)
- mov 汇编指令方式(协程实现上下文保存)
- ucontext linux提供的
- setjmp/longjmp
问题解决
-
互斥锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
#include <stdio.h> #include <pthread.h> #include <unistd.h> #define THERAD_NUM 10 pthread_mutex_t mutex; // 互斥量使用特定的数据类型pthread_mutex_t /** * 对临界资源自增1万次 */ void *thread_proc(void *arg){ int *pcount = (int*) arg; int i = 0; while(i++ <10000){ #if 0 (*pcount) ++; #else pthread_mutex_lock(&mutex); // 加锁 (*pcount) ++; pthread_mutex_unlock(&mutex); // 解锁 #endif usleep(1); } } int main() { pthread_t thread_id[THERAD_NUM] = {0}; // 创建10个线程 int count = 0; pthread_mutex_init(&mutex, NULL); // 互斥量初始化 for(int i = 0; i< THERAD_NUM; i++){ pthread_create(&thread_id[i], NULL, thread_proc, &count); // 传入临界资源count } // 输出临界资源值,目标结果应该是10w for(int i = 0; i<100; i++){ printf("count --> %d\n", count); sleep(1); } } // 运行结果 count --> 3 count --> 100000
-
自旋锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
#include <stdio.h> #include <pthread.h> #include <unistd.h> #define THERAD_NUM 10 pthread_mutex_t mutex; // 互斥量使用特定的数据类型pthread_mutex_t pthread_spinlock_t spinlock; // 自旋锁 /** * 对临界资源自增1万次 */ void *thread_proc(void *arg){ int *pcount = (int*) arg; int i = 0; while(i++ <10000){ #if 0 (*pcount) ++; #elseif 0 pthread_mutex_lock(&mutex); // 加锁 (*pcount) ++; pthread_mutex_unlock(&mutex); // 解锁 #else pthread_spin_lock(&spinlock); // 加锁 (*pcount) ++; pthread_spin_unlock(&spinlock); // 解锁 #endif usleep(1); } } int main() { pthread_t thread_id[THERAD_NUM] = {0}; // 创建10个线程 int count = 0; pthread_mutex_init(&mutex, NULL); // 互斥量初始化 pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED); // 自旋锁初始化,多个线程共享 for(int i = 0; i< THERAD_NUM; i++){ pthread_create(&thread_id[i], NULL, thread_proc, &count); // 传入临界资源count } // 输出临界资源值,目标结果应该是10w for(int i = 0; i<100; i++){ printf("count --> %d\n", count); sleep(1); } }
mutex和spinlock的区别
mutex会挂起,spinlock不会挂起(当然时间片用完了,内核会自动进行调度)
mutex和spinlock的使用
mutex对操作时间比较长的加锁,spinlock对操作时间比较短的加锁。
对于single-core/single-CPU,spinlock将一直浪费CPU资源,如果采用mutex,反而可以立刻让其他的thread运行,可能去释放mutex lock。对于multi-core/mutil-CPU,会存在很多短时间被占用的lock,如果总是去让thread sleep,紧接着去wake up,这样会浪费很多CPU资源,从而降低了系统性能,所以应该尽量使用spinlock。
现实情况是由于程序员不太可能确定每个运行程序的系统CPU和core的个数,所以也不可能去确定使用那一种lock。因此现在的操作系统通常不太区分mutex和spinlock了。实际上,大多数现代操作系统已经使用了混合mutex(hybrid mutex)和混合spinlock(hybrid spinlock)。说白了就是将两者的特点相结合。
操作时间何为长何为短?
以线程切换的时间为标准,线程切换也就几十条条指令,将16个寄存器中的值保存到ctx中,再从ctx中加载到寄存器。
如果在临界资源操作耗时超过线程切换的时间则使用mutex,否则使用spinlock。
比如往红黑树和队列里插入一个数,要求线程安全,红黑树就需要使用mutex,队列使用spinlock即可。
-
原子操作
一条汇编指令实现的操作就是原子操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
#include <stdio.h> #include <pthread.h> #include <unistd.h> #define THERAD_NUM 10 pthread_mutex_t mutex; // 互斥量使用特定的数据类型pthread_mutex_t pthread_spinlock_t spinlock; // 自旋锁 int inc(int *value, int add) { int old; __asm__ volatile( "lock; xaddl %2, %1;" : "=a" (old) : "m" (*value), "a"(add) : "cc", "memory" ); return old; } /** * 对临界资源自增1万次 */ void *thread_proc(void *arg){ int *pcount = (int*) arg; int i = 0; while(i++ <100000){ #if 0 (*pcount) ++; #elif 0 pthread_mutex_lock(&mutex); // 加锁 (*pcount) ++; pthread_mutex_unlock(&mutex); // 解锁 #elif 0 pthread_spin_lock(&spinlock); // 加锁 (*pcount) ++; pthread_spin_unlock(&spinlock); // 解锁 #else inc(pcount, 1); #endif usleep(1); } } int main() { pthread_t thread_id[THERAD_NUM] = {0}; // 创建10个线程 int count = 0; pthread_mutex_init(&mutex, NULL); // 互斥量初始化 pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED); // 自旋锁初始化,多个线程共享 for(int i = 0; i< THERAD_NUM; i++){ pthread_create(&thread_id[i], NULL, thread_proc, &count); // 传入临界资源count } // 输出临界资源值,目标结果应该是10w for(int i = 0; i<100; i++){ printf("count --> %d\n", count); sleep(1); } } // 运行结果 count --> 8 count --> 68613 count --> 137109 count --> 206686 count --> 276091 count --> 346366 count --> 414957 count --> 483418 count --> 549760 count --> 619239 count --> 685568 count --> 750479 count --> 802848 count --> 868675 count --> 937825 count --> 1000000 count --> 1000000 count --> 1000000
三种方式的使用场景
mutex | spinlock | 原子操作 |
---|---|---|
操作内容比较多 | 操作内容比较少 | 单条指令 |
原子操作必须要cpu的指令集中有才可以使用,例如
1 2
list -> prev = node; list = node;
上面这个要加锁只能使用mutex和spinlock,cpu的指令集中没有对上述的原子操作指令。
lock
原子操作是不可分割的操作,在执行完毕时它不会被任何事件中断。在单处理器系统(UniProcessor,简称 UP)中,能够在单条指令中完成的操作都可以认为是原子操作,因为中断只能发生在指令与指令之间。
在多处理器系统(Symmetric Multi-Processor,简称 SMP)中情况有所不同,由于系统中有多个处理器在独立的运行,即使在能单条指令中完成的操作也可能受到干扰。
在所有的 X86 CPU 上都具有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其他的系统总线读取或修改这个内存地址。这种能力是通过 LOCK 指令前缀再加上下面的汇编指令来实现的。当使用 LOCK 指令前缀时,它会使 CPU 宣告一个 LOCK#
信号,这样就能确保在多处理器系统或多线程竞争的环境下互斥地使用这个内存地址。当指令执行完毕,这个锁定动作也就会消失。
能够和 LOCK 指令前缀一起使用的指令如下所示:
BT, BTS, BTR, BTC (mem, reg/imm) XCHG, XADD (reg, mem / mem, reg) ADD, OR, ADC, SBB (mem, reg/imm) AND, SUB, XOR (mem, reg/imm) NOT, NEG, INC, DEC (mem)