原子操作CAS与锁实现
一 多线程运行时临界资源的问题
今天学习多线程编程时遇到了一个问题。以下是代码。
void *thread_callback(void *arg) {
int *count = (int *)arg;
int i = 0;
while (i++ < 100000) {
(*count)++;
usleep(1);
}
}
int main() {
pthread_t thid[THREAD_COUNT];
memset(thid, 0, THREAD_COUNT*sizeof(pthread_t));
int count = 0;
for (int i = 0; i < THREAD_COUNT; i++) {
pthread_create(&thid[i], 0, thread_callback, &count);
}
for (int i = 0; i < 100; i++) {
printf("count --> %d\n", count);
sleep(1);
}
return 0;
}
运行这个程序时,按照预测,printf应该会打印出1000000这个数字,但是每次运行后,打印的最大数字都不是10000000,而是接近一百万的数字。
经过分析,发现count应该算作一个临界资源。“count++”这行代码,在汇编后,会形成三行代码。分别是:
- Mov [idx], %eax
- Inc %eax
- Mov %eax, [idx]
这三行代码分别表示将内存中的某个数赋值到寄存器中、寄存器自加1、将寄存器中的数赋值回原内存地址中。当线程一运行到2语句时,可能会被线程二打断,线程二运行完这3条语句后,线程一继续运行,此时寄存器中的值不是内存中的最新值,而是没有被线程二处理过的值。这样,线程二就白处理了。
解决这个问题,有三种方法可以选择,分别是加互斥锁、加自旋锁或者将count++改变为原子操作。
解决方法
1. 互斥锁 mutex
互斥锁本质上就是一个全局变量,它只有 “lock” 和 “unlock” 两个值,通过对资源进行 "加锁(lock)"和 “解锁(unlock)”,可以确保同一时刻最多有 1 个线程访问该资源,从根本上避免了“多线程抢夺资源”的情况发生。其中,对资源进行“加锁”和“解锁”操作的必须是同一个线程。
pthread_mutex_t mutex;//一般生命为全局变量
pthread_mutex_init(&mutex, NULL);//初始化锁变量
pthread_mutex_lock(&mutex);
pthread_mutex_unlock(&mutex);//解锁和加锁需在一个线程
pthread_mutex_destroy(&mutex);//销毁一个锁变量
当一个线程遇到一个被互斥锁锁住的锁变量,这个线程会被阻塞。
2.自旋锁 spinlock
Spinlock 是内核中提供的一种比较常见的锁机制,自旋锁是“原地等待”的方式解决资源冲突的。即,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)。
pthread_spinlock_t spinlock;
pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED);//初始化
pthread_spin_lock(&spinlock);
pthread_spin_unlock(&spinlock);
pthread_spin_destroy(&spinlock);
自旋锁和互斥锁的不同点在于,当一个线程加锁的自旋锁是没有被解锁的锁时,这个线程不会被阻塞,而是会反复检测锁有没有被解开。它的优点在于不会因触发切换线程而导致系统调用,缺点在于会空转CPU浪费时间。
互斥锁适用于会调用系统调用或复杂的对临界资源的操作。
自旋锁适用于不会调用系统调用或不复杂的对临界资源的操作。
3.原子操作
原子操作是指不会被线程调度机制打断的操作。原子操作需要CPU指令集的支持。
CAS原子指令的实现:
int inc(int *value, int add) {
__asm__ volatile { //volatile指不让编译器优化这些代码
"lock; xaddl %2, %1;"//%1指的是第二个参数value,%2指的是第三个参数add;lock,指的是锁住总线
: "=a" (old) //是第一条指令运算的结果
: "m" (*value), "a" (add)
: "cc", "memory"
};
return old;
}
将某些对临界资源的操作设置成原子操作也是一个好办法,但是前提是CPU支持这种原子操作。只有少数对临界资源的操作能设置成原子操作。
二 线程私有空间:
线程内部的全局变量
如果需要在一个线程内部的各个函数调用都能访问、但其它线程不能访问的变量,这就需要新的机制来实现,我们称之为Static memory local to a thread (线程局部静态变量),同时也可称之为线程特有数据
pthread_key_create(&key, NULL);//1
...
pthread_getspecific(key);//2
pthread_setspecific(key, ptr);//3
第一个函数在main函数里运行,它声明了此进程的每个线程都有一块私有空间。
第二个函数在某个线程里运行,能获得本线程私有空间的地址。
第三个函数是向私有空间里赋值。
六 CPU的亲缘性:affinity
cpu的亲缘性,指的是进程要在某个给定的 CPU 上尽量长时间地运行而不被迁移到其他处理器的倾向性。
进程(线程)只在某一个CPU运行,避免了CPU间的切换,能使此进程较快的运行。
在内核里进程和线程是不分的。
void process_affinity(int num) {
// gettid();
pid_t selfid = syscall(__NR_gettid);//获取进程id
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(selfid%num, &mask);
sched_setaffinity(selfid, sizeof(mask), &mask);//将线程和某个CPU黏合在一起
}
int main() {
int num = sysconf(_SC_NPROCESSORS_CONF);//获取CPU核数
int i = 0;
pid_t pid = 0;
for (i = 0; i < num/2; i++) {
pid = fork();//通过复制创建一个子进程,这个进程被称为子进程,调用者被称为父进程
if (pid <= (pid_t)0) {
break;
}
}
if (pid == 0) {
process_affinity(num);
}
while (1) {
}
七 setjmp/longjmp
setjmp 和 longjmp可以在一个线程中的函数间跳转。
jmp_buf env;
void func() {
longjmp(env, idx);//1
}
void func() {
setjmp(env);//2
}
程序可以从1直接跳转到2运行,2返回idx。这种特性可以用来做try catch。