文章目录
前言
写这篇文章前犹豫了挺久,因为涉及的内容比较多,写会耗费不少时间。但也正因为都是干货,所以也值得总结一下,希望读者也有耐心地看完,希望对各位或多或少有些帮助,相信对多线程、锁概念只停留在知道多线程操作同一个变量要加锁概念的筒子有很大作用。大佬可能就不需要仔细看了,直接扫一遍看有无你关心的。
文章的主要内容如下,也可通过目录来查阅:
1、多线程并发计数的实现
2、多线程进入临界区的深入剖析
3、互斥锁、自旋锁的实现及应用场景
4、原子操作的实现
5、CAS原子操作原理及实现
提示:以下是本篇文章正文内容,下面案例可供参考
一、一个简单的多线程计数程序
我们先来实现一个简单的多线程计数程序,来实现多个线程对同一个变量的自增。
这里我们创建10个线程,每个线程对同一个变量自增100000次,期待值该变量是增加到1000000。
代码如下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_MOUNT 10
#define INFO printf
void *thread_add_count(void *arg)
{
int *tcount = (int *)arg;
int i = 0;
while(i++ < 100000)
{
(*tcount)++;
usleep(1);
}
}
int main()
{
//线程id
pthread_t tids[THREAD_MOUNT] = {0};
int i = 0;
int count = 0;
for(i = 0; i < THREAD_MOUNT; i++)
{
pthread_create(&tids[i], NULL, thread_add_count, &count);
}
for(i = 0; i < 100; i++)
{
/*主线程100s后退出*/
INFO("count: %d\n", count);
sleep(1);
}
return 0;
}
我们会得到类似下面的一个结果:
count: 896365
count: 915461
count: 934290
count: 952922
count: 960631 //加到这里就不变了
count: 960631
count: 960631
count: 960631
count: 960631
count: 960631
当增加还没到1000000时,就保持一个数值不变了。
这是什么原因导致的呢。可能会有筒子说,你这多个线程操作同一个count却没对它加锁,肯定要出问题呀,加锁了就不会有问题了。这句话没错,加锁了是不会有问题,我们稍后来看看我加锁后的现象。但加锁只是一种方法,并不是导致这个问题出现的原因。本质的原因是什么呢,我们来分析一下
二、多线程计数的本质剖析
1.预备知识,一些简单的汇编知识
1.1寄存器简介及寄存器操作
这里引入一点简单的汇编语言的知识,没学过汇编的也不需要慌。
(1)寄存器 我们程序中操作的变量的存储,大都是存储在寄存器中,寄存器的分类也有很多,比如:
X86 汇编语言中CPU上的通用寄存器有eax, ebx, ecx, edx, esi, edi, ebp, esp,都是32位的寄存器。我们对c语言对一个变量的操作,一般都是转换为对一个寄存器的操作,大家可以理解成当把一个值让寄存器存储时,该寄存器就代表该变量值。这里简单介绍几个寄存器,计算机对不同寄存器有不同的用途。
EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。
EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。
EDX 则总是被用来放整数除法产生的余数。
其他如8位的通用寄存器,如AH, AL, BH, BL,16位段寄存器,CS, DS, SS就不再赘述
1.2简单汇编算术综合运算指令
介绍完寄存器那么我们来介绍一个简单操作指令
(1)mov;
mov 的作用是向目的操作数复制数据
mov destnation, source
目标操作数必须为寄存器或者内存,而源操作数为寄存器,内存或者立即数。mov指令使得目标操作数的值发生改变,而源操作数的值不改变。这里需要注意的是操作数尺寸必须相同
(2)add 将源操作数和目的操作数相加
add destination, source
add 不会改变源操作数,将运算结果保存在目的操作数中
(3)sub 从目的操作数中减去源操作数
sub destination, source
sub 操作不改变源操作数,结果保存在目的操作数中,其指令的执行实际上是add与补码的运算
(4)inc 增指令
inc reg/mem 将操作数加一
(5)dec 减指令
dec reg/mem
(6)xchg 数据交换,将源操作数与目的操作数交换
该指令实际上是交换数据在容器中的位置,不能使用立即数作为操作数,至少是使用一个寄存器,不能用来交换两个内存操作数
(7)neg 将对应的数字求二进制补码
2.正题,多线程计数的计数变化的剖析
前面说了那么多,终于要进入正题了。
我们在多线程操作count使其自增时,count++
语句,cpu执行时,实际上执行的以下三个语句:
mov [count], eax;
inc eax;
mov eax, [count];
加了[]的即表示内存
正常情况下,线程1完整执行完这三条指令,CPU轮转到线程2执行,每个线程都完整的执行完这三个指令才轮转,而在不加锁时,情况并非总是这样。
我们假设线程1执行到mov [count], eax;时其时间片用光,轮转到线程2执行,如图:
假设线程1执行时count值为50,当线程1执行完它的mv [count], eax;时,线程1的eax值为50,这是线程2被轮转进来执行了,线程2执行mov [count], eax;时,count值由于还没被线程1改变,还是50,线程2执行完上述三条操作后,线程2的eax值为51,count值为51,这时假如cpu 再轮转到线程1,从inc eax;执行,需要注意的是,这里的eax是线程1的eax,它的值还是50,线程1仍执行完接下来两条指令后,线程1eax寄存器值为51,count值再一次被赋值为51。也就是说我们两条线程对count值的增加,只起到了加一次的效果。
问题的原因说到这里就解释清楚了,那么问题的解决方法呢。我们接下来会给大家介绍三种方法并对三种方法做一个对比。这三种方法分别是互斥锁,自旋锁,原子语句。首先来看一下互斥锁。
3.使用锁解决多线程计数的问题
3.1使用互斥锁
加锁其实就是在cpu轮转到某条线程要执行这三条语句前,给它上一个lock,实际上是给[count]这个共享资源上一个lock,在线程1拥有该资源的期间,(即这三条语句还没执行完之前),其他线程不能去操作[count]。
使用互斥锁的代码实现如下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_MOUNT 10
#define INFO printf
pthread_mutex_t mutex;
void *thread_add_count(void *arg)
{
int *tcount = (int *)arg;
int i = 0;
while(i++ < 100000)
{
pthread_mutex_lock(&mutex);
(*tcount)++;
pthread_mutex_unlock(&mutex);
usleep(1);
}
}
int main()
{
//线程id
pthread_t tids[THREAD_MOUNT] = {0};
int i = 0;
int count = 0;
pthread_mutex_init(&mutex, NULL);
for(i = 0; i < THREAD_MOUNT; i++)
{
pthread_create(&tids[i], NULL, thread_add_count, &count);
}
for(i = 0; i < 100; i++)
{
/*主线程100s后退出*/
INFO("count: %d\n", count);
sleep(1);
}
return 0;
}
可以看到这时就达到了预期的结果:
count: 927770
count: 949891
count: 971157
count: 992581
count: 1000000
count: 1000000
count: 1000000
count: 1000000
3.2使用自旋锁
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_MOUNT 10
#define INFO printf
//pthread_mutex_t mutex;
pthread_spinlock_t spinlock;
void *thread_add_count(void *arg)
{
int *tcount = (int *)arg;
int i = 0;
while(i++ < 100000)
{
//pthread_mutex_lock(&mutex);
pthread_spin_lock(&spinlock);
(*tcount)++;
//pthread_mutex_unlock(&mutex);
pthread_spin_unlock(&spinlock);
usleep(1);
}
}
int main()
{
//线程id
pthread_t tids[THREAD_MOUNT] = {0};
int i = 0;
int count = 0;
//pthread_mutex_init(&mutex, NULL);
pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED);
for(i = 0; i < THREAD_MOUNT; i++)
{
pthread_create(&tids[i], NULL, thread_add_count, &count);
}
for(i = 0; i < 100; i++)
{
/*主线程100s后退出*/
INFO("count: %d\n", count);
sleep(1);
}
return 0;
}
可以看到使用自旋锁也能达到一样的效果
count: 942767
count: 962963
count: 986564
count: 1000000
count: 1000000
count: 1000000
4.使用原子操作
除了加锁的方法外,对于本例场景,我们还能使用原子操作的方式,达到同样的效果。
所谓的原子操作,就是在执行完毕前不会被其他事件中断的操作,在单核的CPU中,能够用一条指令完成的操作都是原子操作,但多核的CPU不一样,由于多核CPU,线程能被分配到不同核并行地运行,即使是单条指令,也可能是两个核同时执行该单条指令,所以多核的单指令并不一定具有原子性。但也由处理方法,如在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。下面我们就来实现count自增的原子操作:
这里使用内联汇编的写法,对内联汇编不理解的筒子可以去这里瞅瞅,也可以自己再找其他资料,这里就不解释了。
linux内联汇编
asm cookbook
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_MOUNT 10
#define INFO printf
//pthread_mutex_t mutex;
pthread_spinlock_t spinlock;
/*通常可将内联汇编定义成宏定义使代码看起来简洁*/
#define SELF_INC(val) \
__asm__ volatile("lock; incl %0;"\
: "=m" (val)\
: "m" (val))
/*实现在一个int值基础上加上另外一个int值*/
int inc(int *value, int add) {
int old;
__asm__ volatile(
"lock; xaddl %2, %1;"
: "=a" (old)
: "m" (*value), "a"(add)
: "cc", "memory"
);
return old;
}
/*也可以定义成函数如下,这里等同宏定义的
void self_inc(int *val)
{
__asm__ volatile(
"lock; incl %0;"
: "=m" (*val)
: "m" (*val)
);
}
*/
void *thread_add_count(void *arg)
{
int *tcount = (int *)arg;
int i = 0;
while(i++ < 100000)
{
//pthread_mutex_lock(&mutex);
//pthread_spin_lock(&spinlock);
//(*tcount)++;
//pthread_mutex_unlock(&mutex);
//pthread_spin_unlock(&spinlock);
SELF_INC(*tcount);
//self_inc(tcount);
usleep(1);
}
}
int main()
{
//线程id
pthread_t tids[THREAD_MOUNT] = {0};
int i = 0;
int count = 0;
//pthread_mutex_init(&mutex, NULL);
pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED);
for(i = 0; i < THREAD_MOUNT; i++)
{
pthread_create(&tids[i], NULL, thread_add_count, &count);
}
for(i = 0; i < 100; i++)
{
/*主线程100s后退出*/
INFO("count: %d\n", count);
sleep(1);
}
return 0;
}
可以看到结果也是正常,这里就不展示结果了,代码已上自己去跑跑看吧。
5.使用互斥锁,自旋锁,原子操作的应用场景与对比
(1)互斥锁与自旋锁的对比与使用
互斥锁与自旋锁的一个很大的区别就是互斥锁会引起线程切换,而自旋锁不会,自旋锁会自旋到锁被释放然后拿到资源进行操作,理解起来也很简单,互斥锁应用中线程1发觉资源被其他线程2上锁,就阻塞自身,让cpu可以切换到其他工作,自旋锁不一样,我发觉资源被上锁,我就疯狂试探,等资源解锁后立马上锁操作。他们的机制也就意味着,当锁住的内容少,像示例中的简单加减赋值交换等操作,我切换线程的cpu消耗比我等着你释放的等待cpu占用高,还不如我就等着你释放资源我来操作,这时候就该用自旋锁,但当锁住的内容较多时,如红黑树的插入操作,该线程可能会消耗一个较长的占用该锁,那么我检测到资源以及被上锁了我就该让出cpu。
(2)锁与原子操作的对比
这里之所以介绍原子操作,因为我们在高并发的场景性能要求较高时可能会比较常用这种技术。通过前文的讲述可以知道,原子操作是一种无锁(也叫轻量级锁)并发计数,比起互斥锁,自选锁,cpu不仅少了加锁解锁的过程,执行的指令数目也会少很多。因此原子操作是比使用锁的技术要高效很多的,但相应的,由于原子操作的基本做法是将多条指令转换为一条来执行(单核CPU不用再加前缀lock,多处理器必须加),这也限制了原子操作的也不能用在大量内容的“上锁”。下面会给出几个很实用的原子操作的实现。
三、一些常用的原子操作的实现
gcc也有提供一些原子操作,见:gcc内建函数
C++11中的STL中的atomic类的函数使用了模板可以让你跨平台。(完整的C++11的原子操作可参看 Atomic Operation Library)
1)template< class T > bool atomic_compare_exchange_weak( std::atomic* obj,T* expected, T desired );
2)template< class T > bool atomic_compare_exchange_weak( volatile std::atomic* obj,T* expected, T desired );
1.使用cmpxchg命令实现CAS操作
这里我们自己来实现CAS操作,其他的加,自增已在上述例子中给出,自减可自行去实现。
要实现CAS首先得理解cmpxchg指令
百科上是这么说的:
CMPXCHG r/m,r 将累加器AL/AX/EAX/RAX中的值与首操作数(目的操作数)比较,如果相等,第2操作数(源操作数)的值装载到首操作数,zf置1。如果不等, 首操作数的值装载到AL/AX/EAX/RAX并将zf清0
这里需要注意的是,是将累加器中的值与首操作数比较,网上很多博文这里都误人子弟,假如接口如下:
__cmpxchg(int a, int old_a, int new_a);
网上很多博文直接说的是假如a == a,则把new_a赋值给 *a;实际上不是值相比,是与累加器中的比较
x86平台cmpxchg使用的说明如下:
cmpxchg用法
1.CAS(compare and swap,也叫compare and set)
CAS操作应该有三个参数,val的地址,要比较的val值,要设置的val值val_new
这里我们自己实现一个cas操作,达到if(*a == b) a=c;的效果,代码如下:
#include <stdio.h>
#define LOCK_PREFIX "lock;"
static inline int cas(int *a, int b, int c)
{
//如果*a==b, 则*a = c;
int out;
__asm__ volatile(
LOCK_PREFIX
"CMPXCHG %1, %2;"
: "=a"(out)
: "r"(c), "m"(*a), "0"(b)
: "cc", "memory");
return out;
}
int main()
{
int m= 10;
int *a = &m, b = 8, c = 9;
int ret = 0;
ret = cas(a, b, c);
printf("ret:%d, a:%d, b:%d, c:%d\n", ret, m, b, c);
}
读者可将代码运行修改值看结果理解。
需要注意的是这里有一个容易踩坑的点,gcc使用的是AT&T的汇编格式,MS采用Intel的格式,网上对cmpxchg介绍的用法大都是基于MS汇编格式,在gcc内联汇编中参数要对调,如上例c与*a的位置假如是以MS的格式,就要调换位置。
另外附lLinux内核中,比较并交换的函数cmpxchg,代码在include/asm-i386/cmpxchg.h中
读者可以对比
对__typeof__不清楚的可以参考这个:
typeof用法
就是用来取ptr得类型的。__xg是在这个文件中定义的宏:
struct __xchg_dummy { unsigned long a[100]; };
#define __xg(x) ((struct __xchg_dummy )(x))
那么%2经过预处理,展开就是"m"(((struct __xchg_dummy *)(ptr))),这种做法,就可以达到在cmpxchg中的%2是一个地址,就是ptr指向的地址。如果%2是"m"(ptr),那么指针本身的值就出现在cmpxchg指令中。
#define cmpxchg(ptr,o,n)\
((__typeof__(*(ptr)))__cmpxchg((ptr),(unsigned long)(o),\
(unsigned long)(n),sizeof(*(ptr))))
static inline unsigned long __cmpxchg(volatile void *ptr, unsigned long old,
unsigned long new, int size)
{
unsigned long prev;
switch (size) {
case 1: //一个字节,使用cmpxchgb
__asm__ __volatile__(LOCK_PREFIX "cmpxchgb %b1,%2"
: "=a"(prev) //输出是累加器的值,所以用=a限定寄存器
: "q"(new), "m"(*__xg(ptr)), "0"(old)
: "memory");
return prev;
case 2: //一个字(2字节)使用cmpchgw
__asm__ __volatile__(LOCK_PREFIX "cmpxchgw %w1,%2"
: "=a"(prev)
: "r"(new), "m"(*__xg(ptr)), "0"(old)
: "memory");
return prev;
case 4:
__asm__ __volatile__(LOCK_PREFIX "cmpxchgl %1,%2"
: "=a"(prev)
: "r"(new), "m"(*__xg(ptr)), "0"(old)
: "memory");
return prev;
}
return old;
}
nginx的CAS实现还加了“sete “0””,目的大概是将ZF的flag也输出,感兴趣的可以自行去研究。
总结
这篇文章的篇幅比较长,涉及的内容也比较多,通篇看完,读者至少需要知道多线程操作临界区该注意的东西及基本互斥锁自旋锁的应用场景,能写基本的内联汇编的函数实现原子操作。本文涉及到的东西在服务器编程中也都很常用。